CVE-2025-57806
Description
Local Deep Research is an AI-powered research assistant for deep, iterative research. Versions 0.2.0 through 0.6.7 stored confidential information, including API keys, in a local SQLite database without encryption. This behavior was not clearly documented outside of the database architecture page. Users were not given the ability to configure the database location, allowing anyone with access to the container or host filesystem to retrieve sensitive data in plaintext by accessing the .db file. This is fixed in version 1.0.0.
Affected products
1- Range: 0.6.7, v0.4.4, v0.5.0, …
Patches
21394ee66317fMerge pull request #637 from LearningCircuit/dev
300 files changed · +30329 −7716
cookiecutter-docker/{{cookiecutter.config_name}}/docker-compose.{{cookiecutter.config_name}}.yml+5 −1 modified@@ -27,8 +27,10 @@ services: {%- if cookiecutter.enable_searxng %} - LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL=http://searxng:8080 {% endif %} + # Data directory configuration + - LDR_DATA_DIR=/data volumes: - - ldr_data:/install/.venv/lib/python3.13/site-packages/data/ + - ldr_data:/data - ./local_collections/personal_notes:/local_collections/personal_notes/ - ./local_collections/project_docs:/local_collections/project_docs/ - ./local_collections/research_papers:/local_collections/research_papers/ @@ -60,6 +62,8 @@ services: timeout: 5s start_period: 10m retries: 2 + environment: + OLLAMA_KEEP_ALIVE: '30m' volumes: - ollama_data:/root/.ollama - ./scripts/:/scripts/
docker-compose.yml+62 −30 modified@@ -1,48 +1,80 @@ -name: 'local-ai' - services: + local-deep-research: + image: localdeepresearch/local-deep-research + + networks: + - ldr-network + + ports: + - "5000:5000" + environment: + # Web Interface Settings + - LDR_WEB_PORT=5000 + - LDR_WEB_HOST=0.0.0.0 + - LDR_LLM_PROVIDER=ollama + - LDR_LLM_OLLAMA_URL=http://ollama:11434 + + - LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL=http://searxng:8080 + + # Data directory configuration + - LDR_DATA_DIR=/data + volumes: + - ldr_data:/data + - ./local_collections/personal_notes:/local_collections/personal_notes/ + - ./local_collections/project_docs:/local_collections/project_docs/ + - ./local_collections/research_papers:/local_collections/research_papers/ + restart: unless-stopped + depends_on: + ollama: + condition: service_healthy + + searxng: + condition: service_started + ollama: - image: 'ollama/ollama:latest' - deploy: + image: ollama/ollama:latest + deploy: # Remove this section if you do not have an Nvidia GPU. resources: reservations: devices: - driver: nvidia count: 1 - capabilities: [gpu] + capabilities: [ gpu ] + + container_name: ollama_service + entrypoint: [ "/scripts/ollama_entrypoint.sh" ] + healthcheck: + test: [ "CMD", "ollama", "show", "gemma3:12b" ] + interval: 10s + timeout: 5s + start_period: 10m + retries: 2 environment: OLLAMA_KEEP_ALIVE: '30m' volumes: - - type: 'volume' - source: 'ollama' - target: '/root/.ollama' + - ollama_data:/root/.ollama + - ./scripts/:/scripts/ + networks: + - ldr-network + + + restart: unless-stopped searxng: - image: 'ghcr.io/searxng/searxng:latest' + image: searxng/searxng:latest + container_name: searxng + networks: + - ldr-network - deep-research: - image: 'localdeepresearch/local-deep-research:latest' - ports: - - target: '5000' # port bound by app inside container - published: '5000' # port bound on the docker host - protocol: 'tcp' - environment: - LDR_LLM_PROVIDER: 'ollama' - LDR_LLM_OLLAMA_URL: 'http://ollama:11434' - # change model depending on preference and available VRAM - LDR_LLM_MODEL: 'gemma3:12b' - LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL: 'http://searxng:8080' volumes: - - type: 'volume' - source: 'deep-research' - target: '/install/.venv/lib/python3.13/site-packages/data/' - - type: 'volume' - source: 'deep-research-outputs' - target: '/install/.venv/lib/python3.13/research_outputs' + - searxng_data:/etc/searxng + restart: unless-stopped volumes: - ollama: - deep-research: - deep-research-outputs: + ldr_data: + ollama_data: + searxng_data: +networks: + ldr-network:
Dockerfile+104 −2 modified@@ -1,7 +1,13 @@ #### # Used for building the LDR service dependencies. #### -FROM python:3.13.2-slim AS builder +FROM python:3.12.8-slim AS builder-base + +# Install system dependencies for SQLCipher +RUN apt-get update && apt-get install -y \ + libsqlcipher-dev \ + build-essential \ + && rm -rf /var/lib/apt/lists/* # Install dependencies and tools RUN pip3 install --upgrade pip && pip install pdm playwright @@ -15,14 +21,110 @@ COPY src/ src COPY LICENSE LICENSE COPY README.md README.md +#### +# Builds the LDR service dependencies used in production. +#### +FROM builder-base AS builder + # Install the package using PDM RUN pdm install --check --prod --no-editable +#### +# Container for running tests. +#### +FROM builder-base AS ldr-test + +# Install runtime dependencies for SQLCipher, Node.js, and testing tools +RUN apt-get update && apt-get install -y \ + sqlcipher \ + libsqlcipher0 \ + curl \ + xauth \ + xvfb \ + # Dependencies for Chromium + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libatspi2.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + xdg-utils \ + && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Set up Puppeteer environment +ENV PUPPETEER_CACHE_DIR=/app/puppeteer-cache +ENV DOCKER_ENV=true +# Don't skip Chrome download - let Puppeteer download its own Chrome as fallback +# ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + +# Create puppeteer cache directory with proper permissions +RUN mkdir -p /app/puppeteer-cache && chmod -R 777 /app/puppeteer-cache + +# Install Playwright with Chromium first (before npm packages) +RUN playwright install --with-deps chromium || echo "Playwright install failed, will use Puppeteer's Chrome" + +# Copy test package files +COPY tests/api_tests_with_login/package.json /install/tests/api_tests_with_login/ +COPY tests/ui_tests/package.json /install/tests/ui_tests/ + +# Install npm packages - Skip Puppeteer Chrome download since we have Playwright's Chrome +WORKDIR /install/tests/api_tests_with_login +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +RUN npm install +WORKDIR /install/tests/ui_tests +RUN npm install + +# Set CHROME_BIN to help Puppeteer find Chrome from Playwright +# Try to find and set Chrome binary path from Playwright's installation +RUN CHROME_PATH=$(find /root/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1) && \ + if [ -n "$CHROME_PATH" ]; then \ + echo "export CHROME_BIN=$CHROME_PATH" >> /etc/profile.d/chrome.sh; \ + echo "export PUPPETEER_EXECUTABLE_PATH=$CHROME_PATH" >> /etc/profile.d/chrome.sh; \ + fi || true + +# Set environment variables for Puppeteer to use Playwright's Chrome +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/root/.cache/ms-playwright/chromium-1181/chrome-linux/chrome + +# Copy test files to /app where they will be run from +RUN mkdir -p /app && cp -r /install/tests /app/ + +# Ensure Chrome binaries have correct permissions +RUN chmod -R 755 /app/puppeteer-cache + +WORKDIR /install + +# Install the package using PDM +RUN pdm install --check --no-editable +# Configure path to default to the venv python. +ENV PATH="/install/.venv/bin:$PATH" + #### # Runs the LDR service. ### -FROM python:3.13.2-slim AS ldr +FROM python:3.12.8-slim AS ldr + +# Install runtime dependencies for SQLCipher +RUN apt-get update && apt-get install -y \ + sqlcipher \ + libsqlcipher0 \ + && rm -rf /var/lib/apt/lists/* # retrieve packages from build stage COPY --from=builder /install/.venv/ /install/.venv
docs/api-quickstart.md+180 −33 modified@@ -1,53 +1,200 @@ # API Quick Start -## Starting the API Server +## Overview -```bash -cd . -python -m src.local_deep_research.web.app -``` +Local Deep Research provides both HTTP REST API and programmatic Python API access. Since version 2.0, authentication is required for all API endpoints, and the system uses per-user encrypted databases. -The API will be available at `http://localhost:5000/api/v1/` +## Authentication -## Basic Usage Examples +### Web UI Authentication -### 1. Check API Status -```bash -curl http://localhost:5000/api/v1/health -``` +The API requires authentication through the web interface first: + +1. Start the server: + ```bash + python -m local_deep_research.web.app + ``` + +2. Open http://localhost:5000 in your browser +3. Register a new account or login +4. Your session cookie will be used for API authentication + +### HTTP API Authentication + +For HTTP API requests, you need to: + +1. First authenticate through the login endpoint +2. Include the session cookie in subsequent requests +3. Include CSRF token for state-changing operations + +Example authentication flow: + +```python +import requests + +# Create a session to persist cookies +session = requests.Session() -### 2. Get a Quick Summary -```bash -curl -X POST http://localhost:5000/api/v1/quick_summary \ - -H "Content-Type: application/json" \ - -d '{"query": "What is Python programming?"}' +# 1. Login +login_response = session.post( + "http://localhost:5000/auth/login", + json={"username": "your_username", "password": "your_password"} +) + +if login_response.status_code == 200: + print("Login successful") + # Session cookie is automatically stored +else: + print(f"Login failed: {login_response.text}") + +# 2. Get CSRF token for API requests +csrf_response = session.get("http://localhost:5000/auth/csrf-token") +csrf_token = csrf_response.json()["csrf_token"] + +# 3. Make API requests with authentication +headers = {"X-CSRF-Token": csrf_token} +api_response = session.post( + "http://localhost:5000/research/api/start", + json={ + "query": "What is quantum computing?", + "model": "gpt-3.5-turbo", + "search_engines": ["searxng"], + }, + headers=headers +) ``` -### 3. Generate a Report (Long-running) -```bash -curl -X POST http://localhost:5000/api/v1/generate_report \ - -H "Content-Type: application/json" \ - -d '{"query": "Machine learning basics"}' +## Programmatic API Access + +The programmatic API now requires a settings snapshot for proper context: + +```python +from local_deep_research.api import quick_summary +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +# Get user session and settings +with get_user_db_session(username="your_username", password="your_password") as session: + settings_manager = CachedSettingsManager(session, "your_username") + settings_snapshot = settings_manager.get_all_settings() + + # Use the API with settings snapshot + result = quick_summary( + query="What is machine learning?", + settings_snapshot=settings_snapshot, + iterations=2, + questions_per_iteration=3 + ) + + print(result["summary"]) ``` -### 4. Python Example +## API Endpoints + +### Research Endpoints + +All research endpoints are under `/research/api/`: + +- `POST /research/api/start` - Start new research +- `GET /research/api/research/{id}/status` - Check research status +- `GET /research/api/research/{id}/result` - Get research results +- `POST /research/api/research/{id}/terminate` - Stop running research + +### Settings Endpoints + +Settings endpoints are under `/settings/api/`: + +- `GET /settings/api` - Get all settings +- `GET /settings/api/{key}` - Get specific setting +- `PUT /settings/api/{key}` - Update setting +- `GET /settings/api/available-models` - Get available LLM providers +- `GET /settings/api/available-search-engines` - Get search engines + +### History Endpoints + +- `GET /history/api` - Get research history +- `GET /history/api/{id}` - Get specific research details + +## Important Changes from v1.x + +1. **Authentication Required**: All API endpoints now require authentication +2. **Settings Snapshot**: Programmatic API calls need settings_snapshot parameter +3. **Per-User Databases**: Each user has their own encrypted database +4. **CSRF Protection**: State-changing requests require CSRF token +5. **New Endpoint Structure**: APIs moved under blueprint prefixes (e.g., `/research/api/`) + +## Example: Complete Research Flow + ```python import requests +import time -# Get a quick summary -response = requests.post( - "http://localhost:5000/api/v1/quick_summary", - json={"query": "What is AI?"} +# Setup session and login +session = requests.Session() +session.post( + "http://localhost:5000/auth/login", + json={"username": "user", "password": "pass"} ) -print(response.json()["summary"]) +# Get CSRF token +csrf = session.get("http://localhost:5000/auth/csrf-token").json()["csrf_token"] +headers = {"X-CSRF-Token": csrf} + +# Start research +research = session.post( + "http://localhost:5000/research/api/start", + json={ + "query": "Latest advances in quantum computing", + "model": "gpt-3.5-turbo", + "search_engines": ["arxiv", "wikipedia"], + "iterations": 3 + }, + headers=headers +).json() + +research_id = research["research_id"] + +# Poll for results +while True: + status = session.get( + f"http://localhost:5000/research/api/research/{research_id}/status" + ).json() + + if status["status"] in ["completed", "failed"]: + break + + print(f"Progress: {status.get('progress', 'unknown')}") + time.sleep(5) + +# Get final results +results = session.get( + f"http://localhost:5000/research/api/research/{research_id}/result" +).json() + +print(f"Summary: {results['summary']}") +print(f"Sources: {len(results['sources'])}") ``` -## Key Points +## Rate Limiting + +The API includes adaptive rate limiting: +- Default: 60 requests per minute per user +- Automatic retry with exponential backoff +- Rate limits are per-user, not per-IP + +## Error Handling + +Common error responses: +- `401`: Not authenticated - login required +- `403`: CSRF token missing or invalid +- `404`: Resource not found +- `429`: Rate limit exceeded +- `500`: Server error + +Always check response status and handle errors appropriately. -- **Quick Summary**: Fast responses using LLM (seconds) -- **Generate Report**: Comprehensive research (hours) -- **Rate Limit**: 60 requests/minute -- **Timeout**: API requests may timeout on long operations +## Next Steps -For full documentation, see [api-usage.md](api-usage.md) +- See [examples/api_usage](../examples/api_usage/) for complete examples +- Check [docs/CUSTOM_LLM_INTEGRATION.md](CUSTOM_LLM_INTEGRATION.md) for custom LLM setup +- Read [docs/LANGCHAIN_RETRIEVER_INTEGRATION.md](LANGCHAIN_RETRIEVER_INTEGRATION.md) for custom retrievers
docs/DATA_MIGRATION_GUIDE.md+178 −0 added@@ -0,0 +1,178 @@ +# Data Migration Guide + +> **⚠️ Important Note for v1.0**: The upcoming v1.0 release does not support automatic database migration from previous versions. This guide is provided for reference, but users upgrading to v1.0 will need to start with a fresh database. We recommend exporting any important data (API keys, research results) before upgrading. + +## Overview + +Local Deep Research has updated its data storage locations to improve security and follow platform best practices. Previously, data files were stored alongside the application source code. Now, they are stored in user-specific directories that vary by operating system. + +This guide will help you migrate your existing data to the new locations. We recommend creating a backup of your data before proceeding. + +## New Storage Locations + +The application now stores data in platform-specific user directories: + +- **Windows**: `C:\Users\<YourUsername>\AppData\Local\local-deep-research` +- **macOS**: `~/Library/Application Support/local-deep-research` +- **Linux**: `~/.local/share/local-deep-research` + +## What Needs to be Migrated + +The following data will be migrated automatically when possible: + +1. **Database files** (*.db) - Contains your API keys, research history, and settings +2. **Research outputs** - Your generated research reports and findings +3. **Cache files** - Cached pricing information and search results +4. **Log files** - Application logs +5. **Benchmark results** - Performance benchmark data +6. **Optimization results** - LLM optimization data + +## Migration Options + +> **Note**: These migration options are not available in v1.0. Please see the warning at the top of this document. + +### Option 1: Automatic Migration (Recommended for User Installs) + +If you installed Local Deep Research with `pip install --user`, the automatic migration should work: + +```bash +# Simply run the application +python -m local-deep_research.web.app +``` + +The application will automatically detect and migrate your data on startup. + +### Option 2: Run Application with Administrator Privileges + +If you installed with `sudo pip install` or have permission issues, run the application once with administrator privileges: + +```bash +# Run the application with sudo to allow migration +sudo python -m local_deep_research.web.app +``` + +This will: +- Grant the necessary permissions for the automatic migration +- Move all data files to the new user-specific directories +- Complete the migration process +- After this, you can run normally without sudo + +### Option 3: Manual Migration + +If you prefer to migrate manually or the automatic options don't work: + +#### Step 1: Find Your Current Data Location + +```bash +# Find where the application is installed +python -c "import local_deep_research; import os; print(os.path.dirname(local_deep_research.__file__))" +``` + +#### Step 2: Identify Files to Migrate + +Look for these files/directories in the installation directory: +- `data/ldr.db` (and any other .db files) +- `research_outputs/` directory +- `data/cache/` directory +- `data/logs/` directory +- `data/benchmark_results/` directory +- `data/optimization_results/` directory + +#### Step 3: Create New Directories + +```bash +# Linux/macOS +mkdir -p ~/.local/share/local-deep-research + +# Windows (PowerShell) +New-Item -ItemType Directory -Force -Path "$env:LOCALAPPDATA\local-deep-research" +``` + +#### Step 4: Move Your Data + +**Important**: Back up your data before moving! + +```bash +# Example for Linux/macOS (adjust paths as needed) +# Move databases +sudo mv /usr/local/lib/python*/site-packages/local_deep_research/data/*.db ~/.local/share/local-deep-research/ + +# Move research outputs +sudo mv /usr/local/lib/python*/site-packages/local_deep_research/research_outputs ~/.local/share/local-deep-research/ + +# Move other data directories +sudo mv /usr/local/lib/python*/site-packages/local_deep_research/data/cache ~/.local/share/local-deep-research/ +sudo mv /usr/local/lib/python*/site-packages/local_deep_research/data/logs ~/.local/share/local-deep-research/ +``` + +### Option 4: Start Fresh + +If you don't need your existing data, you can simply: + +1. Delete the old data files (requires administrator privileges for system installs) +2. Start using the application - it will create new files in the correct locations + +## Troubleshooting + +### Permission Denied Errors + +If you see "permission denied" errors: + +1. You likely have a system-wide installation (installed with `sudo pip install`) +2. Use Option 2 (Migration Helper) or Option 3 (Manual Migration) above +3. Administrator/sudo privileges will be required to move files from system directories + +### Files Not Found + +If the migration can't find your files: + +1. Check if you have multiple Python installations +2. Verify where the application is installed (Step 1 in Manual Migration) +3. Look for data files in your current working directory + +### Migration Partially Completed + +If some files were migrated but others weren't: + +1. Check the application logs for specific errors +2. Manually move any remaining files following Option 3 +3. Ensure you have write permissions to the new directories + +## Verifying Migration Success + +After migration, verify everything worked: + +```bash +# Check if files exist in new location (Linux/macOS) +ls -la ~/.local/share/local-deep-research/ + +# Check if files exist in new location (Windows PowerShell) +Get-ChildItem "$env:LOCALAPPDATA\local-deep-research" +``` + +You should see: +- `ldr.db` (and possibly other .db files) +- `research_outputs/` directory with your reports +- `cache/` directory +- `logs/` directory + +## Security Benefits + +This migration provides several security improvements: + +1. **User Isolation**: Data is now stored in user-specific directories +2. **Proper Permissions**: User directories have appropriate access controls +3. **Package Updates**: Updates won't affect your data +4. **No System-Wide Access**: Your API keys and research data are private to your user account + +## Getting Help + +If you encounter issues: + +1. Check the application logs in the new logs directory +2. Report issues at: https://github.com/LearningCircuit/local-deep-research/issues +3. Include your operating system and installation method (pip, pip --user, etc.) + +## Reverting Migration (Not Recommended) + +If you absolutely need to revert to the old behavior, you can set environment variables to override the new paths. However, this is not recommended for security reasons and may not be supported in future versions.
docs/env_configuration.md+18 −0 modified@@ -125,3 +125,21 @@ set LDR_ANTHROPIC_API_KEY=your-key-here export LDR_SEARCH__TOOL=wikipedia # Linux/Mac set LDR_SEARCH__TOOL=wikipedia # Windows ``` + +### Data Directory Location + +By default, Local Deep Research stores all data (database, research outputs, cache, logs) in platform-specific user directories. You can override this location using the `LDR_DATA_DIR` environment variable: + +```bash +# Linux/Mac +export LDR_DATA_DIR=/path/to/your/data/directory + +# Windows +set LDR_DATA_DIR=C:\path\to\your\data\directory +``` + +All application data will be organized under this directory: +- `$LDR_DATA_DIR/ldr.db` - Application database +- `$LDR_DATA_DIR/research_outputs/` - Research reports +- `$LDR_DATA_DIR/cache/` - Cached data +- `$LDR_DATA_DIR/logs/` - Application logs
docs/MIGRATION_GUIDE_v1.md+261 −0 added@@ -0,0 +1,261 @@ +# Migration Guide: LDR v0.x to v1.0 + +## Overview + +Local Deep Research v1.0 introduces significant security and architectural improvements: + +- **User Authentication**: All access now requires authentication +- **Per-User Encrypted Databases**: Each user has their own encrypted SQLCipher database +- **Settings Snapshots**: Thread-safe settings management for concurrent operations +- **New API Structure**: Reorganized endpoints under blueprint prefixes + +## Breaking Changes + +### 1. Authentication Required + +**v0.x**: Open access, no authentication +```python +# Direct API access +from local_deep_research.api import quick_summary +result = quick_summary("query") +``` + +**v1.0**: Authentication required +```python +from local_deep_research.api import quick_summary +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +# Must authenticate first +with get_user_db_session(username="user", password="pass") as session: + settings_manager = CachedSettingsManager(session, "user") + settings_snapshot = settings_manager.get_all_settings() + + result = quick_summary( + "query", + settings_snapshot=settings_snapshot # Required parameter + ) +``` + +### 2. HTTP API Changes + +#### Endpoint Structure +- **v0.x**: `/api/v1/quick_summary` +- **v1.0**: `/research/api/start` + +#### Authentication Flow +```python +import requests + +# v1.0 requires session-based authentication +session = requests.Session() + +# 1. Login +session.post( + "http://localhost:5000/auth/login", + json={"username": "user", "password": "pass"} +) + +# 2. Get CSRF token for state-changing operations +csrf = session.get("http://localhost:5000/auth/csrf-token").json()["csrf_token"] + +# 3. Make API requests with CSRF token +response = session.post( + "http://localhost:5000/research/api/start", + json={"query": "test"}, + headers={"X-CSRF-Token": csrf} +) +``` + +### 3. Database Changes + +#### v0.x +- Single shared database: `ldr.db` +- No encryption +- Direct database access from any thread + +#### v1.0 +- Per-user databases: `encrypted_databases/{username}.db` +- SQLCipher encryption with user passwords +- Thread-local session management +- In-memory queue tracking (no more service_db) + +### 4. Settings Management + +#### v0.x +```python +# Direct settings access +from local_deep_research.config import get_db_setting +value = get_db_setting("llm.provider") +``` + +#### v1.0 +```python +# Settings require context +from local_deep_research.settings import CachedSettingsManager + +# Within authenticated session +settings_manager = CachedSettingsManager(session, username) +value = settings_manager.get_setting("llm.provider") + +# Or use settings snapshot for thread safety +settings_snapshot = settings_manager.get_all_settings() +``` + +## Migration Steps + +### 1. Update Dependencies + +```bash +pip install --upgrade local-deep-research +``` + +### 2. Create User Accounts + +Users must create accounts through the web interface: + +1. Start the server: `python -m local_deep_research.web.app` +2. Open http://localhost:5000 +3. Click "Register" and create an account +4. Configure LLM providers and API keys in Settings + +### 3. Update Programmatic Code + +#### Before (v0.x): +```python +from local_deep_research.api import ( + quick_summary, + detailed_research, + generate_report +) + +# Direct usage +result = quick_summary("What is AI?") +``` + +#### After (v1.0): +```python +from local_deep_research.api import quick_summary +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +def run_research(username, password, query): + with get_user_db_session(username, password) as session: + settings_manager = CachedSettingsManager(session, username) + settings_snapshot = settings_manager.get_all_settings() + + return quick_summary( + query=query, + settings_snapshot=settings_snapshot, + # Other parameters remain the same + iterations=2, + questions_per_iteration=3 + ) +``` + +### 4. Update HTTP API Calls + +Create a wrapper for authenticated requests: + +```python +class LDRClient: + def __init__(self, base_url="http://localhost:5000"): + self.base_url = base_url + self.session = requests.Session() + self.csrf_token = None + + def login(self, username, password): + response = self.session.post( + f"{self.base_url}/auth/login", + json={"username": username, "password": password} + ) + if response.status_code == 200: + self.csrf_token = self.session.get( + f"{self.base_url}/auth/csrf-token" + ).json()["csrf_token"] + return response + + def start_research(self, query, **kwargs): + return self.session.post( + f"{self.base_url}/research/api/start", + json={"query": query, **kwargs}, + headers={"X-CSRF-Token": self.csrf_token} + ) + +# Usage +client = LDRClient() +client.login("user", "pass") +result = client.start_research("What is quantum computing?") +``` + +### 5. Update Configuration + +#### API Keys +API keys are now stored encrypted in per-user databases. Users must: +1. Login to the web interface +2. Go to Settings +3. Re-enter API keys for LLM providers + +#### Custom LLMs +Custom LLM registrations now require settings context: + +```python +# v1.0 custom LLM with settings support +def create_custom_llm(model_name=None, temperature=None, settings_snapshot=None): + # Access settings from snapshot if needed + api_key = settings_snapshot.get("llm.custom.api_key", {}).get("value") + return CustomLLM(api_key=api_key, model=model_name, temperature=temperature) +``` + +## Common Issues and Solutions + +### Issue: "No settings context available in thread" +**Solution**: Pass `settings_snapshot` parameter to all API calls + +### Issue: "Encrypted database requires password" +**Solution**: Ensure you're using `get_user_db_session()` with credentials + +### Issue: CSRF token errors +**Solution**: Get fresh CSRF token before state-changing requests + +### Issue: Old endpoints return 404 +**Solution**: Update to new endpoint structure (see mapping above) + +### Issue: Rate limiting not working +**Solution**: Rate limits are now per-user; ensure proper authentication + +## Backward Compatibility + +For temporary backward compatibility, you can: + +1. Set environment variable: `LDR_USE_SHARED_DB=1` (not recommended) +2. Create a compatibility wrapper for your existing code + +```python +# compatibility.py +import os +os.environ["LDR_USE_SHARED_DB"] = "1" # Use at your own risk + +def quick_summary_compat(query, **kwargs): + # Minimal compatibility wrapper + # Note: This bypasses security features! + from local_deep_research.api import quick_summary + return quick_summary(query, settings_snapshot={}, **kwargs) +``` + +⚠️ **Warning**: Compatibility mode bypasses security features and is not recommended for production use. + +## Benefits of v1.0 + +1. **Security**: Encrypted databases protect sensitive API keys and research data +2. **Multi-User**: Multiple users can work simultaneously without conflicts +3. **Performance**: Cached settings and thread-local sessions improve speed +4. **Reliability**: Thread-safe operations prevent race conditions +5. **Privacy**: User data is completely isolated + +## Getting Help + +- Check the [API Quick Start Guide](api-quickstart.md) +- See [examples/api_usage](../examples/api_usage/) for updated examples +- Join our [Discord](https://discord.gg/ttcqQeFcJ3) for migration support +- Report issues on [GitHub](https://github.com/LearningCircuit/local-deep-research/issues)
docs/news/EXCEPTION_HANDLING.md+221 −0 added@@ -0,0 +1,221 @@ +# News API Exception Handling + +## Overview + +The news API module now uses a structured exception handling approach instead of returning error dictionaries. This provides better error handling, cleaner code, and consistent API responses. + +## Exception Hierarchy + +All news API exceptions inherit from `NewsAPIException`, which provides: +- Human-readable error messages +- HTTP status codes +- Machine-readable error codes +- Optional additional details + +```python +from local_deep_research.news.exceptions import NewsAPIException + +class NewsAPIException(Exception): + def __init__(self, message: str, status_code: int = 500, + error_code: Optional[str] = None, + details: Optional[Dict[str, Any]] = None) +``` + +## Available Exceptions + +### InvalidLimitException +- **Status Code**: 400 +- **Error Code**: `INVALID_LIMIT` +- **Usage**: When an invalid limit parameter is provided +- **Example**: +```python +if limit < 1: + raise InvalidLimitException(limit) +``` + +### SubscriptionNotFoundException +- **Status Code**: 404 +- **Error Code**: `SUBSCRIPTION_NOT_FOUND` +- **Usage**: When a requested subscription doesn't exist +- **Example**: +```python +if not subscription: + raise SubscriptionNotFoundException(subscription_id) +``` + +### SubscriptionCreationException +- **Status Code**: 500 +- **Error Code**: `SUBSCRIPTION_CREATE_FAILED` +- **Usage**: When subscription creation fails +- **Example**: +```python +except Exception as e: + raise SubscriptionCreationException(str(e), {"query": query}) +``` + +### SubscriptionUpdateException +- **Status Code**: 500 +- **Error Code**: `SUBSCRIPTION_UPDATE_FAILED` +- **Usage**: When subscription update fails + +### SubscriptionDeletionException +- **Status Code**: 500 +- **Error Code**: `SUBSCRIPTION_DELETE_FAILED` +- **Usage**: When subscription deletion fails + +### DatabaseAccessException +- **Status Code**: 500 +- **Error Code**: `DATABASE_ERROR` +- **Usage**: When database operations fail +- **Example**: +```python +except Exception as e: + raise DatabaseAccessException("operation_name", str(e)) +``` + +### NewsFeedGenerationException +- **Status Code**: 500 +- **Error Code**: `FEED_GENERATION_FAILED` +- **Usage**: When news feed generation fails + +### ResearchProcessingException +- **Status Code**: 500 +- **Error Code**: `RESEARCH_PROCESSING_FAILED` +- **Usage**: When processing research items fails + +### NotImplementedException +- **Status Code**: 501 +- **Error Code**: `NOT_IMPLEMENTED` +- **Usage**: For features not yet implemented +- **Example**: +```python +raise NotImplementedException("feature_name") +``` + +### InvalidParameterException +- **Status Code**: 400 +- **Error Code**: `INVALID_PARAMETER` +- **Usage**: When invalid parameters are provided + +### SchedulerNotificationException +- **Status Code**: 500 +- **Error Code**: `SCHEDULER_NOTIFICATION_FAILED` +- **Usage**: When scheduler notification fails (non-critical) + +## Flask Integration + +### Error Handlers + +The Flask application automatically handles `NewsAPIException` and its subclasses: + +```python +@app.errorhandler(NewsAPIException) +def handle_news_api_exception(error): + return jsonify(error.to_dict()), error.status_code +``` + +### Response Format + +All exceptions are converted to consistent JSON responses: + +```json +{ + "error": "Human-readable error message", + "error_code": "MACHINE_READABLE_CODE", + "status_code": 400, + "details": { + "additional": "context", + "if": "available" + } +} +``` + +## Migration Guide + +### Before (Error Dictionaries) + +```python +def get_news_feed(limit): + if limit < 1: + return { + "error": "Limit must be at least 1", + "news_items": [] + } + + try: + # ... code ... + except Exception as e: + logger.exception("Error getting news feed") + return {"error": str(e), "news_items": []} +``` + +### After (Exceptions) + +```python +def get_news_feed(limit): + if limit < 1: + raise InvalidLimitException(limit) + + try: + # ... code ... + except NewsAPIException: + raise # Re-raise our custom exceptions + except Exception as e: + logger.exception("Error getting news feed") + raise NewsFeedGenerationException(str(e)) +``` + +## Benefits + +1. **Cleaner Code**: Functions focus on their primary logic without error response formatting +2. **Consistent Error Handling**: All errors follow the same format +3. **Better Testing**: Easier to test exception cases with pytest.raises +4. **Type Safety**: IDEs can better understand return types +5. **Centralized Logging**: Error logging can be handled in one place +6. **HTTP Status Codes**: Proper HTTP status codes are automatically set + +## Testing + +Test exception handling using pytest: + +```python +import pytest +from local_deep_research.news.exceptions import InvalidLimitException + +def test_invalid_limit(): + with pytest.raises(InvalidLimitException) as exc_info: + news_api.get_news_feed(limit=-1) + + assert exc_info.value.status_code == 400 + assert exc_info.value.details["provided_limit"] == -1 +``` + +## Best Practices + +1. **Always catch and re-raise NewsAPIException subclasses**: +```python +except NewsAPIException: + raise # Let Flask handle it +except Exception as e: + # Convert to appropriate NewsAPIException + raise DatabaseAccessException("operation", str(e)) +``` + +2. **Include relevant details**: +```python +raise SubscriptionCreationException( + "Database constraint violated", + details={"query": query, "type": subscription_type} +) +``` + +3. **Use appropriate exception types**: Choose the most specific exception that matches the error condition + +4. **Log before raising**: For unexpected errors, log the full exception before converting to NewsAPIException + +## Future Improvements + +- Add retry logic for transient errors +- Implement exception metrics/monitoring +- Add internationalization support for error messages +- Create exception middleware for advanced error handling
docs/SQLCIPHER_INSTALL.md+132 −0 added@@ -0,0 +1,132 @@ +# SQLCipher Installation Guide + +Local Deep Research uses SQLCipher to provide encrypted databases for each user. This ensures that all user data, including API keys and research results, are encrypted at rest. + +## Installation by Platform + +### Ubuntu/Debian Linux + +SQLCipher can be easily installed from the package manager: + +```bash +sudo apt update +sudo apt install sqlcipher libsqlcipher-dev +``` + +After installation, you can install the Python binding: +```bash +pdm add pysqlcipher3 +# or +pip install pysqlcipher3 +``` + +### macOS + +Install using Homebrew: + +```bash +brew install sqlcipher +``` + +You may need to set environment variables for the Python binding: +```bash +export LDFLAGS="-L$(brew --prefix sqlcipher)/lib" +export CPPFLAGS="-I$(brew --prefix sqlcipher)/include" +pdm add pysqlcipher3 +``` + +### Windows + +Windows installation is more complex and requires building from source: + +1. Install Visual Studio 2015 or later (Community Edition works) +2. Install the "Desktop Development with C++" workload +3. Download SQLCipher source from https://github.com/sqlcipher/sqlcipher +4. Build using Visual Studio Native Tools Command Prompt + +For easier installation on Windows, consider using WSL2 with Ubuntu. + +## Alternative: Using Docker + +If you have difficulty installing SQLCipher, you can run Local Deep Research in a Docker container where SQLCipher is pre-installed: + +```dockerfile +FROM python:3.11-slim + +# Install SQLCipher +RUN apt-get update && apt-get install -y \ + sqlcipher \ + libsqlcipher-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install Local Deep Research +RUN pip install local-deep-research pysqlcipher3 + +CMD ["ldr", "serve"] +``` + +## Verifying Installation + +You can verify SQLCipher is installed correctly: + +```bash +# Check command line tool +sqlcipher --version + +# Test Python binding +python -c "import pysqlcipher3; print('SQLCipher is installed!')" +``` + +## Fallback Mode + +If SQLCipher is not available, Local Deep Research will fall back to using regular SQLite databases. However, this means your data will not be encrypted at rest. A warning will be displayed when running without encryption. + +## Security Notes + +- Each user's database is encrypted with their password +- There is no password recovery mechanism - if a user forgets their password, their data cannot be recovered +- The encryption uses SQLCipher's default settings with AES-256 +- API keys and sensitive data are only stored in the encrypted user databases + +## Troubleshooting + +### Linux: "Package not found" + +If your distribution doesn't have SQLCipher in its repositories, you may need to build from source or use a third-party repository. + +### macOS: "Library not loaded" + +Make sure you've set the LDFLAGS and CPPFLAGS environment variables as shown above. + +### Windows: Build errors + +Ensure you're using the Visual Studio Native Tools Command Prompt and have all required dependencies installed. + +### Python: "No module named pysqlcipher3" + +Try using the alternative package: +```bash +pip install sqlcipher3 +``` + +## For Developers + +To add SQLCipher to an automated installation script: + +```bash +#!/bin/bash +# For Ubuntu/Debian +if command -v apt-get &> /dev/null; then + sudo apt-get update + sudo apt-get install -y sqlcipher libsqlcipher-dev +fi + +# For macOS with Homebrew +if command -v brew &> /dev/null; then + brew install sqlcipher +fi + +# Install Python package +pip install pysqlcipher3 || pip install sqlcipher3 +```
docs/troubleshooting-openai-api-key.md+249 −0 added@@ -0,0 +1,249 @@ +# Troubleshooting OpenAI API Key Configuration + +This guide helps troubleshoot common issues with OpenAI API key configuration in Local Deep Research v1.0+. + +## Quick Test + +Run the end-to-end test to verify your configuration: + +```bash +# Using command line arguments +python tests/test_openai_api_key_e2e.py \ + --username YOUR_USERNAME \ + --password YOUR_PASSWORD \ + --api-key YOUR_OPENAI_API_KEY + +# Using environment variables +export LDR_USERNAME=your_username +export LDR_PASSWORD=your_password +export OPENAI_API_KEY=sk-your-api-key +python tests/test_openai_api_key_e2e.py +``` + +## Common Issues and Solutions + +### 1. "No API key found" + +**Symptoms:** +- Error message about missing API key +- Research fails to start + +**Solutions:** + +1. **Via Web Interface:** + - Login to LDR web interface + - Go to Settings + - Select "OpenAI" as LLM Provider + - Enter your API key in the "OpenAI API Key" field + - Click Save + +2. **Via Environment Variable:** + ```bash + export OPENAI_API_KEY=sk-your-api-key + python -m local_deep_research.web.app + ``` + +3. **Programmatically:** + ```python + from local_deep_research.settings import CachedSettingsManager + from local_deep_research.database.session_context import get_user_db_session + + with get_user_db_session(username="user", password="pass") as session: + settings_manager = CachedSettingsManager(session, "user") + settings_manager.set_setting("llm.provider", "openai") + settings_manager.set_setting("llm.openai.api_key", "sk-your-api-key") + ``` + +### 2. "Invalid API key" + +**Symptoms:** +- 401 Unauthorized errors +- "Incorrect API key provided" messages + +**Solutions:** + +1. **Verify API Key Format:** + - OpenAI keys start with `sk-` + - Should be around 51 characters long + - No extra spaces or quotes + +2. **Check API Key Validity:** + ```bash + # Test directly with curl + curl https://api.openai.com/v1/models \ + -H "Authorization: Bearer YOUR_API_KEY" + ``` + +3. **Regenerate API Key:** + - Go to https://platform.openai.com/api-keys + - Create a new API key + - Update in LDR settings + +### 3. "Rate limit exceeded" + +**Symptoms:** +- 429 errors +- "You exceeded your current quota" messages + +**Solutions:** + +1. **Check OpenAI Usage:** + - Visit https://platform.openai.com/usage + - Verify you have available credits + +2. **Add Payment Method:** + - OpenAI requires payment info for API access + - Add at https://platform.openai.com/account/billing + +3. **Use Different Model:** + ```python + settings_manager.set_setting("llm.model", "gpt-3.5-turbo") # Cheaper + # Instead of gpt-4 which is more expensive + ``` + +### 4. "Settings not persisting" + +**Symptoms:** +- API key needs to be re-entered after restart +- Settings revert to defaults + +**Solutions:** + +1. **Ensure Proper Shutdown:** + - Use Ctrl+C to stop server (not kill -9) + - Wait for "Server stopped" message + +2. **Check Database Permissions:** + ```bash + ls -la encrypted_databases/ + # Should show your user database with write permissions + ``` + +3. **Verify Settings Save:** + ```python + # After setting, verify it was saved + saved_key = settings_manager.get_setting("llm.openai.api_key") + print(f"Saved key: {'*' * 20 if saved_key else 'Not saved'}") + ``` + +### 5. "API key not being used" + +**Symptoms:** +- Settings show OpenAI configured but different LLM is used +- API key is saved but not applied + +**Solutions:** + +1. **Check Provider Setting:** + ```python + provider = settings_manager.get_setting("llm.provider") + print(f"Current provider: {provider}") # Should be "openai" + ``` + +2. **Verify Settings Snapshot:** + ```python + settings_snapshot = settings_manager.get_all_settings() + print("Provider:", settings_snapshot.get("llm.provider", {}).get("value")) + print("API Key:", "Set" if settings_snapshot.get("llm.openai.api_key", {}).get("value") else "Not set") + ``` + +3. **Force Provider Selection:** + ```python + # In research call + result = quick_summary( + query="Test", + settings_snapshot=settings_snapshot, + provider="openai", # Force OpenAI + model_name="gpt-3.5-turbo" + ) + ``` + +## Testing Your Configuration + +### 1. Simple API Test + +```python +from local_deep_research.config.llm_config import get_llm +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +with get_user_db_session(username="user", password="pass") as session: + settings_manager = CachedSettingsManager(session, "user") + settings_snapshot = settings_manager.get_all_settings() + + # Test LLM initialization + try: + llm = get_llm(settings_snapshot=settings_snapshot) + print("✓ LLM initialized successfully") + + # Test response + from langchain.schema import HumanMessage + response = llm.invoke([HumanMessage(content="Say hello")]) + print(f"✓ Response: {response.content}") + except Exception as e: + print(f"✗ Error: {e}") +``` + +### 2. Full Research Test + +```python +from local_deep_research.api.research_functions import quick_summary + +result = quick_summary( + query="What is OpenAI?", + settings_snapshot=settings_snapshot, + iterations=1, + questions_per_iteration=1 +) + +print(f"Research ID: {result['research_id']}") +print(f"Summary: {result['summary'][:200]}...") +``` + +## Advanced Configuration + +### Using Azure OpenAI + +```python +settings_manager.set_setting("llm.provider", "openai") +settings_manager.set_setting("llm.openai.api_key", "your-azure-key") +settings_manager.set_setting("llm.openai.api_base", "https://your-resource.openai.azure.com/") +settings_manager.set_setting("llm.model", "your-deployment-name") +``` + +### Using OpenAI-Compatible Endpoints + +```python +settings_manager.set_setting("llm.provider", "openai") +settings_manager.set_setting("llm.openai.api_key", "your-api-key") +settings_manager.set_setting("llm.openai.api_base", "https://your-endpoint.com/v1") +``` + +### Organization ID + +```python +settings_manager.set_setting("llm.openai.organization", "org-your-org-id") +``` + +## Getting Help + +1. **Run Diagnostic Test:** + ```bash + python tests/test_openai_api_key_e2e.py --verbose + ``` + +2. **Check Logs:** + ```bash + # Look for OpenAI-related errors + grep -i "openai\|api.*key" logs/ldr.log + ``` + +3. **Community Support:** + - GitHub Issues: https://github.com/LearningCircuit/local-deep-research/issues + - Discord: https://discord.gg/ttcqQeFcJ3 + +4. **API Key Best Practices:** + - Never commit API keys to version control + - Use environment variables for production + - Rotate keys regularly + - Set usage limits in OpenAI dashboard
examples/api_usage/http/http_api_examples.py+347 −275 modified@@ -1,325 +1,397 @@ #!/usr/bin/env python3 """ -HTTP API Examples for Local Deep Research +HTTP API Examples for Local Deep Research v1.0+ -This script demonstrates how to use the LDR HTTP API endpoints. -Make sure the LDR server is running before running these examples: - python -m src.local_deep_research.web.app +This script demonstrates comprehensive usage of the LDR HTTP API with authentication. +Includes examples for research, settings management, and batch operations. + +Requirements: +- LDR v1.0+ (with authentication features) +- User account created through web interface +- LDR server running: python -m local_deep_research.web.app """ -import requests -import json import time -from typing import Dict, Any +from typing import Any, Dict, List +import requests -# Base URL for the API -BASE_URL = "http://localhost:5000/api/v1" +# Configuration +BASE_URL = "http://localhost:5000" +USERNAME = "your_username" # Change this! +PASSWORD = "your_password" # Change this! -def check_health() -> None: - """Check if the API server is running.""" - try: - response = requests.get(f"{BASE_URL}/health") - print(f"Health check: {response.json()}") - except requests.exceptions.ConnectionError: - print("Error: Cannot connect to API server. Make sure it's running:") - print(" python -m src.local_deep_research.web.app") - exit(1) - - -def quick_summary_example() -> Dict[str, Any]: - """Example: Generate a quick summary of a topic.""" - print("\n=== Quick Summary Example ===") - - payload = { - "query": "What are the latest advances in quantum computing?", - "search_tool": "wikipedia", # Optional: specify search engine - "iterations": 1, # Optional: number of research iterations - "questions_per_iteration": 2, # Optional: questions per iteration - } - - response = requests.post( - f"{BASE_URL}/quick_summary", - json=payload, - headers={"Content-Type": "application/json"}, - ) +class LDRClient: + """Client for interacting with LDR API v1.0+ with authentication""" - if response.status_code == 200: - result = response.json() - print(f"Summary: {result['summary'][:500]}...") - print(f"Number of findings: {len(result.get('findings', []))}") - print(f"Research iterations: {result.get('iterations', 0)}") - return result - else: - print(f"Error: {response.status_code} - {response.text}") - return {} - - -def detailed_research_example() -> Dict[str, Any]: - """Example: Perform detailed research on a topic.""" - print("\n=== Detailed Research Example ===") - - payload = { - "query": "Impact of AI on software development", - "search_tool": "auto", # Auto-select best search engine - "iterations": 2, - "questions_per_iteration": 3, - "search_strategy": "source_based", # Optional: specify strategy - } - - response = requests.post( - f"{BASE_URL}/detailed_research", - json=payload, - headers={"Content-Type": "application/json"}, - ) + def __init__(self, base_url: str = BASE_URL): + self.base_url = base_url + self.session = requests.Session() + self.csrf_token = None + self.username = None - if response.status_code == 200: - result = response.json() - print(f"Query: {result['query']}") - print(f"Research ID: {result['research_id']}") - print(f"Summary length: {len(result['summary'])} characters") - print(f"Sources found: {len(result.get('sources', []))}") - - # Print metadata - if "metadata" in result: - print("\nMetadata:") - for key, value in result["metadata"].items(): - print(f" {key}: {value}") - - return result - else: - print(f"Error: {response.status_code} - {response.text}") - return {} - - -def generate_report_example() -> Dict[str, Any]: - """Example: Generate a comprehensive research report.""" - print("\n=== Generate Report Example ===") - print("Note: This can take several minutes to complete...") - - payload = { - "query": "Future of renewable energy", - "searches_per_section": 2, - "iterations": 1, - "provider": "openai_endpoint", # Optional: LLM provider - "model_name": "llama-3.3-70b-instruct", # Optional: model - "temperature": 0.7, # Optional: generation temperature - } - - # Start the report generation - response = requests.post( - f"{BASE_URL}/generate_report", - json=payload, - headers={"Content-Type": "application/json"}, - timeout=300, # 5 minute timeout - ) + def login(self, username: str, password: str) -> bool: + """Authenticate with the LDR server.""" + response = self.session.post( + f"{self.base_url}/auth/login", + json={"username": username, "password": password}, + ) - if response.status_code == 200: - result = response.json() - - # Save the report to a file - if "content" in result: - with open("generated_report.md", "w", encoding="utf-8") as f: - f.write(result["content"]) - print("Report saved to: generated_report.md") - - # Show report preview - print("\nReport preview (first 500 chars):") - print(result["content"][:500] + "...") - - # Show metadata - if "metadata" in result: - print("\nReport metadata:") - for key, value in result["metadata"].items(): - print(f" {key}: {value}") - - return result - else: - print(f"Error: {response.status_code} - {response.text}") - return {} - - -def search_with_retriever_example() -> Dict[str, Any]: - """Example: Using custom retrievers via HTTP API.""" - print("\n=== Search with Custom Retriever Example ===") - print("Note: This example shows the API structure but won't work") - print("without a real retriever implementation on the server side.") - - # This demonstrates the API structure, but actual retrievers - # need to be registered on the server side - payload = { - "query": "company policies on remote work", - "search_tool": "company_docs", # Use a named retriever - "iterations": 1, - } - - response = requests.post( - f"{BASE_URL}/quick_summary", - json=payload, - headers={"Content-Type": "application/json"}, - ) + if response.status_code == 200: + self.username = username + # Get CSRF token + csrf_response = self.session.get(f"{self.base_url}/auth/csrf-token") + self.csrf_token = csrf_response.json()["csrf_token"] + return True + return False + + def logout(self) -> None: + """Logout from the server.""" + if self.csrf_token: + self.session.post( + f"{self.base_url}/auth/logout", + headers={"X-CSRF-Token": self.csrf_token}, + ) - if response.status_code == 200: - result = response.json() - print("Found information from custom retriever") - return result - else: - print( - f"Expected error (retriever not registered): {response.status_code}" - ) - return {} + def _get_headers(self) -> Dict[str, str]: + """Get headers with CSRF token.""" + return {"X-CSRF-Token": self.csrf_token} if self.csrf_token else {} + def check_health(self) -> Dict[str, Any]: + """Check API health status.""" + response = self.session.get(f"{self.base_url}/auth/check") + return response.json() -def get_available_search_engines() -> Dict[str, Any]: - """Example: Get list of available search engines.""" - print("\n=== Available Search Engines ===") + def start_research(self, query: str, **kwargs) -> Dict[str, Any]: + """Start a new research task.""" + payload = { + "query": query, + "model": kwargs.get("model"), + "search_engines": kwargs.get("search_engines", ["wikipedia"]), + "iterations": kwargs.get("iterations", 2), + "questions_per_iteration": kwargs.get("questions_per_iteration", 3), + "temperature": kwargs.get("temperature", 0.7), + "local_context": kwargs.get("local_context", 2000), + "web_context": kwargs.get("web_context", 2000), + } - response = requests.get(f"{BASE_URL}/search_engines") + response = self.session.post( + f"{self.base_url}/research/api/start", + json=payload, + headers=self._get_headers(), + ) + + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to start research: {response.text}") - if response.status_code == 200: - engines = response.json() - print("Available search engines:") - for name, info in engines.items(): - if isinstance(info, dict): - print( - f" - {name}: {info.get('description', 'No description')}" + def get_research_status(self, research_id: str) -> Dict[str, Any]: + """Get the status of a research task.""" + response = self.session.get( + f"{self.base_url}/research/api/research/{research_id}/status" + ) + return response.json() + + def get_research_result(self, research_id: str) -> Dict[str, Any]: + """Get the results of a completed research task.""" + response = self.session.get( + f"{self.base_url}/research/api/research/{research_id}/result" + ) + return response.json() + + def wait_for_research( + self, research_id: str, timeout: int = 300 + ) -> Dict[str, Any]: + """Wait for research to complete and return results.""" + start_time = time.time() + + while time.time() - start_time < timeout: + status = self.get_research_status(research_id) + + if status.get("status") == "completed": + return self.get_research_result(research_id) + elif status.get("status") == "failed": + raise Exception( + f"Research failed: {status.get('error', 'Unknown error')}" ) - else: - print(f" - {name}") - return engines - else: - print(f"Error: {response.status_code} - {response.text}") - return {} + print( + f" Status: {status.get('status', 'unknown')} - {status.get('progress', 'N/A')}" + ) + time.sleep(3) -def batch_research_example() -> None: - """Example: Perform multiple research queries in batch.""" - print("\n=== Batch Research Example ===") + raise TimeoutError( + f"Research {research_id} timed out after {timeout} seconds" + ) - queries = [ - "Impact of 5G on IoT", - "Blockchain in supply chain", - "Edge computing trends", - ] + def get_settings(self) -> Dict[str, Any]: + """Get all user settings.""" + response = self.session.get(f"{self.base_url}/settings/api") + return response.json() - results = [] + def get_setting(self, key: str) -> Any: + """Get a specific setting value.""" + response = self.session.get(f"{self.base_url}/settings/api/{key}") + if response.status_code == 200: + return response.json() + return None + + def update_setting(self, key: str, value: Any) -> bool: + """Update a setting value.""" + response = self.session.put( + f"{self.base_url}/settings/api/{key}", + json={"value": value}, + headers=self._get_headers(), + ) + return response.status_code in [200, 201] - for query in queries: - print(f"\nResearching: {query}") + def get_history(self, limit: int = 10) -> List[Dict[str, Any]]: + """Get research history.""" + response = self.session.get( + f"{self.base_url}/history/api", params={"limit": limit} + ) + data = response.json() + return data.get("items", data.get("history", [])) - payload = { - "query": query, - "search_tool": "wikipedia", - "iterations": 1, - "questions_per_iteration": 1, - } + def get_available_models(self) -> Dict[str, str]: + """Get available LLM providers and models.""" + response = self.session.get( + f"{self.base_url}/settings/api/available-models" + ) + data = response.json() + return data.get("providers", data.get("models", {})) - response = requests.post( - f"{BASE_URL}/quick_summary", - json=payload, - headers={"Content-Type": "application/json"}, + def get_available_search_engines(self) -> List[str]: + """Get available search engines.""" + response = self.session.get( + f"{self.base_url}/settings/api/available-search-engines" ) + data = response.json() + return data.get("engines", data.get("engine_options", [])) - if response.status_code == 200: - result = response.json() - results.append( - { - "query": query, - "summary": result["summary"][:200] + "...", - "findings_count": len(result.get("findings", [])), - } - ) - print(f" ✓ Completed - {len(result['summary'])} chars") - else: - print(f" ✗ Failed - {response.status_code}") - # Be nice to the API - add a small delay between requests - time.sleep(1) +def example_quick_research(client: LDRClient) -> None: + """Example: Quick research with minimal parameters.""" + print("\n=== Example 1: Quick Research ===") - # Display batch results - print("\n=== Batch Results Summary ===") - for r in results: - print(f"\nQuery: {r['query']}") - print(f"Findings: {r['findings_count']}") - print(f"Summary: {r['summary']}") + research = client.start_research( + query="What are the key principles of machine learning?", + iterations=1, + questions_per_iteration=2, + ) + print(f"Started research ID: {research['research_id']}") -def stream_research_example() -> None: - """Example: Stream research progress (if supported by server).""" - print("\n=== Streaming Research Example ===") - print("Note: This shows how streaming would work if implemented") + # Wait for completion + result = client.wait_for_research(research["research_id"]) - # This is a conceptual example - actual streaming depends on server implementation - payload = { - "query": "Latest developments in AI ethics", - "stream": True, # Request streaming responses - } + print(f"\nSummary: {result['summary'][:500]}...") + print(f"Sources: {len(result.get('sources', []))}") + print(f"Findings: {len(result.get('findings', []))}") - try: - response = requests.post( - f"{BASE_URL}/quick_summary", - json=payload, - headers={"Content-Type": "application/json"}, - stream=True, - ) - if response.status_code == 200: - for line in response.iter_lines(): - if line: - # Parse streaming JSON responses - data = json.loads(line.decode("utf-8")) - if "progress" in data: - print(f"Progress: {data['progress']}") - elif "result" in data: - print("Final result received") - else: - print(f"Streaming not supported or error: {response.status_code}") +def example_detailed_research(client: LDRClient) -> None: + """Example: Detailed research with multiple search engines.""" + print("\n=== Example 2: Detailed Research ===") - except Exception as e: - print(f"Streaming example failed: {e}") - print("This is expected if the server doesn't support streaming") + # Check available search engines + engines = client.get_available_search_engines() + print(f"Available search engines: {engines}") + # Use multiple engines + selected_engines = ( + ["wikipedia", "arxiv"] if "arxiv" in engines else ["wikipedia"] + ) -def main(): - """Run all examples.""" - print("=== Local Deep Research HTTP API Examples ===") - print(f"Using API at: {BASE_URL}") + research = client.start_research( + query="Impact of climate change on global food security", + search_engines=selected_engines, + iterations=3, + questions_per_iteration=4, + temperature=0.7, + ) - # Check if server is running - check_health() + print(f"Started detailed research ID: {research['research_id']}") - # Run examples - try: - # Basic examples - quick_summary_example() - time.sleep(2) # Rate limiting + # Monitor progress + result = client.wait_for_research(research["research_id"], timeout=600) - detailed_research_example() - time.sleep(2) + print(f"\nTitle: {result.get('query', 'N/A')}") + print(f"Summary length: {len(result['summary'])} characters") + print(f"Sources: {len(result.get('sources', []))}") - # Get available engines - get_available_search_engines() - time.sleep(2) + # Show some findings + findings = result.get("findings", []) + if findings: + print("\nTop findings:") + for i, finding in enumerate(findings[:3], 1): + print(f"{i}. {finding.get('text', 'N/A')[:100]}...") - # Advanced examples - search_with_retriever_example() - time.sleep(2) - batch_research_example() - time.sleep(2) +def example_settings_management(client: LDRClient) -> None: + """Example: Managing user settings.""" + print("\n=== Example 3: Settings Management ===") - stream_research_example() - time.sleep(2) + # Get current settings + settings = client.get_settings() + settings_data = settings.get("settings", {}) - # Long-running example (optional - uncomment to run) - # generate_report_example() + # Display current LLM configuration + llm_provider = settings_data.get("llm.provider", {}).get("value", "Not set") + llm_model = settings_data.get("llm.model", {}).get("value", "Not set") - except KeyboardInterrupt: - print("\nExamples interrupted by user") - except Exception as e: - print(f"\nError running examples: {e}") + print(f"Current LLM Provider: {llm_provider}") + print(f"Current LLM Model: {llm_model}") + + # Get available models + models = client.get_available_models() + print(f"\nAvailable providers: {list(models.keys())}") + + # Example: Update temperature setting + current_temp = settings_data.get("llm.temperature", {}).get("value", 0.7) + print(f"\nCurrent temperature: {current_temp}") + + # Update temperature (example - uncomment to actually update) + # success = client.update_setting("llm.temperature", 0.5) + # print(f"Temperature update: {'Success' if success else 'Failed'}") + + +def example_batch_research(client: LDRClient) -> None: + """Example: Running multiple research tasks in batch.""" + print("\n=== Example 4: Batch Research ===") + + queries = [ + "What is quantum entanglement?", + "How does CRISPR gene editing work?", + "What are the applications of blockchain technology?", + ] + + research_ids = [] - print("\n=== Examples completed ===") + # Start all research tasks + for query in queries: + try: + research = client.start_research( + query=query, iterations=1, questions_per_iteration=2 + ) + research_ids.append( + { + "id": research["research_id"], + "query": query, + "status": "started", + } + ) + print(f"Started: {query} (ID: {research['research_id']})") + except Exception as e: + print(f"Failed to start '{query}': {e}") + + # Wait for all to complete + print("\nWaiting for batch completion...") + completed = 0 + + while completed < len(research_ids): + for research in research_ids: + if research["status"] != "completed": + try: + status = client.get_research_status(research["id"]) + if status.get("status") == "completed": + research["status"] = "completed" + completed += 1 + print(f"✓ Completed: {research['query']}") + except Exception: + pass + + if completed < len(research_ids): + time.sleep(3) + + # Get all results + print("\nBatch Results Summary:") + for research in research_ids: + try: + result = client.get_research_result(research["id"]) + print(f"\n{research['query']}:") + print(f" - Summary: {result['summary'][:150]}...") + print(f" - Sources: {len(result.get('sources', []))}") + except Exception as e: + print(f" - Error getting results: {e}") + + +def example_research_history(client: LDRClient) -> None: + """Example: Viewing research history.""" + print("\n=== Example 5: Research History ===") + + history = client.get_history(limit=5) + + if not history: + print("No research history found.") + return + + print(f"Found {len(history)} recent research items:\n") + + for item in history: + created = item.get("created_at", "Unknown date") + query = item.get("query", "Unknown query") + status = item.get("status", "Unknown") + research_id = item.get("id", item.get("research_id", "N/A")) + + print(f"ID: {research_id}") + print(f"Query: {query}") + print(f"Date: {created}") + print(f"Status: {status}") + print("-" * 40) + + +def main(): + """Run all examples.""" + print("=== LDR HTTP API v1.0 Examples ===") + + # Create client + client = LDRClient(BASE_URL) + + # Check if we need to update credentials + if USERNAME == "your_username": + print( + "\n⚠️ WARNING: Please update USERNAME and PASSWORD in this script!" + ) + print("Steps:") + print("1. Start server: python -m local_deep_research.web.app") + print("2. Open: http://localhost:5000") + print("3. Register an account") + print("4. Update USERNAME and PASSWORD in this script") + return + + try: + # Login + print(f"\nLogging in as: {USERNAME}") + if not client.login(USERNAME, PASSWORD): + print("❌ Login failed! Please check your credentials.") + return + + print("✅ Login successful") + + # Check health + health = client.check_health() + print(f"Authenticated: {health.get('authenticated', False)}") + print(f"Username: {health.get('username', 'N/A')}") + + # Run examples + example_quick_research(client) + example_detailed_research(client) + example_settings_management(client) + example_batch_research(client) + example_research_history(client) + + except requests.exceptions.ConnectionError: + print("\n❌ Cannot connect to LDR server!") + print("Make sure the server is running:") + print(" python -m local_deep_research.web.app") + except Exception as e: + print(f"\n❌ Error: {e}") + finally: + # Always logout + client.logout() + print("\n✅ Logged out") if __name__ == "__main__":
examples/api_usage/http/simple_http_example.py+140 −36 modified@@ -1,43 +1,147 @@ #!/usr/bin/env python3 """ -Simple HTTP API Example for Local Deep Research +Simple HTTP API Example for Local Deep Research v1.0+ -Quick example showing how to use the LDR API with Python requests library. +This example shows how to use the LDR API with authentication. +Requires LDR v1.0+ with authentication features. """ import requests +import time +import sys -# Make sure LDR server is running: python -m src.local_deep_research.web.app -API_URL = "http://localhost:5000/api/v1" - -# Example 1: Quick Summary -print("=== Quick Summary ===") -response = requests.post( - f"{API_URL}/quick_summary", json={"query": "What is machine learning?"} -) - -if response.status_code == 200: - result = response.json() - print(f"Summary: {result['summary'][:300]}...") - print(f"Found {len(result.get('findings', []))} findings") -else: - print(f"Error: {response.status_code}") - -# Example 2: Detailed Research -print("\n=== Detailed Research ===") -response = requests.post( - f"{API_URL}/detailed_research", - json={ - "query": "Impact of climate change on agriculture", - "iterations": 2, - "search_tool": "wikipedia", - }, -) - -if response.status_code == 200: - result = response.json() - print(f"Research ID: {result['research_id']}") - print(f"Summary length: {len(result['summary'])} characters") - print(f"Sources: {len(result.get('sources', []))}") -else: - print(f"Error: {response.status_code}") +# Configuration +API_URL = "http://localhost:5000" +USERNAME = "your_username" # Change this! +PASSWORD = "your_password" # Change this! + + +def main(): + # Create a session to persist cookies + session = requests.Session() + + print("=== LDR HTTP API Example ===") + print(f"Connecting to: {API_URL}") + + # Step 1: Login + print("\n1. Authenticating...") + login_response = session.post( + f"{API_URL}/auth/login", + json={"username": USERNAME, "password": PASSWORD}, + ) + + if login_response.status_code != 200: + print(f"❌ Login failed: {login_response.text}") + print("\nPlease ensure:") + print("- The server is running: python -m local_deep_research.web.app") + print("- You have created an account through the web interface") + print("- You have updated USERNAME and PASSWORD in this script") + sys.exit(1) + + print("✅ Login successful") + + # Step 2: Get CSRF token + print("\n2. Getting CSRF token...") + csrf_response = session.get(f"{API_URL}/auth/csrf-token") + csrf_token = csrf_response.json()["csrf_token"] + headers = {"X-CSRF-Token": csrf_token} + print("✅ CSRF token obtained") + + # Example 1: Quick Summary (using the start endpoint) + print("\n=== Example 1: Quick Summary ===") + research_request = { + "query": "What is machine learning?", + "model": None, # Will use default from settings + "search_engines": ["wikipedia"], # Fast for demo + "iterations": 1, + "questions_per_iteration": 2, + } + + # Start research + start_response = session.post( + f"{API_URL}/research/api/start", json=research_request, headers=headers + ) + + if start_response.status_code != 200: + print(f"❌ Failed to start research: {start_response.text}") + sys.exit(1) + + research_data = start_response.json() + research_id = research_data["research_id"] + print(f"✅ Research started with ID: {research_id}") + + # Poll for results + print("\nWaiting for results...") + while True: + status_response = session.get( + f"{API_URL}/research/api/research/{research_id}/status" + ) + + if status_response.status_code == 200: + status = status_response.json() + print(f" Status: {status.get('status', 'unknown')}") + + if status.get("status") == "completed": + break + elif status.get("status") == "failed": + print( + f"❌ Research failed: {status.get('error', 'Unknown error')}" + ) + sys.exit(1) + + time.sleep(2) + + # Get results + results_response = session.get( + f"{API_URL}/research/api/research/{research_id}/result" + ) + + if results_response.status_code == 200: + results = results_response.json() + print(f"\n📝 Summary: {results['summary'][:300]}...") + print(f"📚 Sources: {len(results.get('sources', []))} found") + print(f"🔍 Findings: {len(results.get('findings', []))} findings") + + # Example 2: Check Settings + print("\n=== Example 2: Current Settings ===") + settings_response = session.get(f"{API_URL}/settings/api") + + if settings_response.status_code == 200: + settings = settings_response.json()["settings"] + + # Show some key settings + llm_provider = settings.get("llm.provider", {}).get("value", "Not set") + llm_model = settings.get("llm.model", {}).get("value", "Not set") + + print(f"LLM Provider: {llm_provider}") + print(f"LLM Model: {llm_model}") + + # Example 3: Get Research History + print("\n=== Example 3: Research History ===") + history_response = session.get(f"{API_URL}/history/api") + + if history_response.status_code == 200: + history = history_response.json() + items = history.get("items", history.get("history", [])) + + print(f"Found {len(items)} research items") + for item in items[:3]: # Show first 3 + print( + f"- {item.get('query', 'Unknown query')} ({item.get('created_at', 'Unknown date')})" + ) + + # Logout + print("\n4. Logging out...") + session.post(f"{API_URL}/auth/logout", headers=headers) + print("✅ Logged out successfully") + + +if __name__ == "__main__": + print("Make sure the LDR server is running:") + print(" python -m local_deep_research.web.app\n") + + if USERNAME == "your_username": + print("⚠️ WARNING: Please update USERNAME and PASSWORD in this script!") + print(" Create an account through the web interface first.\n") + + main()
examples/api_usage/programmatic/advanced_features_example.py+611 −0 added@@ -0,0 +1,611 @@ +#!/usr/bin/env python3 +""" +Advanced Features Example for Local Deep Research + +This example demonstrates advanced programmatic features including: +1. generate_report() - Create comprehensive markdown reports +2. Export formats - Save reports in different formats +3. Result analysis - Extract and analyze research findings +4. Keyword extraction - Identify key topics and concepts +""" + +import json +from typing import Dict, List, Any + +from local_deep_research.api import ( + generate_report, + detailed_research, + quick_summary, +) +from local_deep_research.api.settings_utils import create_settings_snapshot + + +def demonstrate_report_generation(): + """ + Generate a comprehensive research report using generate_report(). + + This function creates a structured markdown report with: + - Executive summary + - Detailed findings organized by sections + - Source citations + - Conclusions and recommendations + """ + print("=" * 70) + print("GENERATE COMPREHENSIVE REPORT") + print("=" * 70) + print(""" +This demonstrates the generate_report() function which: +- Creates a structured markdown report +- Performs multiple searches per section +- Organizes findings into coherent sections +- Includes citations and references + """) + + # Configure settings for programmatic mode + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + "llm.temperature": 0.5, # Lower for more focused output + } + ) + + # Generate a comprehensive report + print( + "Generating report on 'Applications of Machine Learning in Healthcare'..." + ) + report = generate_report( + query="Applications of Machine Learning in Healthcare", + output_file="ml_healthcare_report.md", + searches_per_section=2, # Multiple searches per section for depth + settings_snapshot=settings, + iterations=2, + questions_per_iteration=3, + ) + + print("\n✓ Report generated successfully!") + print(f" - Report length: {len(report['content'])} characters") + print( + f" - File saved to: {report.get('file_path', 'ml_healthcare_report.md')}" + ) + + # Show first part of report + print("\nReport preview (first 500 chars):") + print("-" * 40) + print(report["content"][:500] + "...") + + return report + + +def demonstrate_export_formats(): + """ + Show how to export research results in different formats. + + Demonstrates: + - Markdown export (default) + - JSON export for programmatic processing + - Custom formatting with templates + """ + print("\n" + "=" * 70) + print("EXPORT FORMATS") + print("=" * 70) + print(""" +Exporting research in different formats: +- Markdown: Human-readable reports +- JSON: Structured data for processing +- Custom: Template-based formatting + """) + + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + } + ) + + # Get research results + result = detailed_research( + query="Renewable energy technologies", + settings_snapshot=settings, + iterations=1, + questions_per_iteration=2, + ) + + # Export as JSON + json_file = "research_results.json" + with open(json_file, "w", encoding="utf-8") as f: + json.dump(result, f, indent=2, default=str) + print(f"\n✓ JSON export saved to: {json_file}") + print(f" - Contains: {len(result.get('findings', []))} findings") + print(f" - Sources: {len(result.get('sources', []))} sources") + + # Export as Markdown + md_content = format_as_markdown(result) + md_file = "research_results.md" + with open(md_file, "w", encoding="utf-8") as f: + f.write(md_content) + print(f"\n✓ Markdown export saved to: {md_file}") + print(f" - Length: {len(md_content)} characters") + + # Export as custom format (e.g., BibTeX-like citations) + citations = extract_citations(result) + cite_file = "research_citations.txt" + with open(cite_file, "w", encoding="utf-8") as f: + for i, citation in enumerate(citations, 1): + f.write(f"[{i}] {citation}\n") + print(f"\n✓ Citations export saved to: {cite_file}") + print(f" - Total citations: {len(citations)}") + + return result + + +def demonstrate_result_analysis(): + """ + Analyze research results to extract insights and patterns. + + Shows how to: + - Extract key findings + - Identify recurring themes + - Analyze source reliability + - Generate statistics + """ + print("\n" + "=" * 70) + print("RESULT ANALYSIS") + print("=" * 70) + print(""" +Analyzing research results to extract: +- Key findings and insights +- Common themes and patterns +- Source statistics +- Quality metrics + """) + + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + } + ) + + # Perform research + result = detailed_research( + query="Impact of artificial intelligence on employment", + settings_snapshot=settings, + search_strategy="source-based", + iterations=2, + questions_per_iteration=3, + ) + + # Analyze findings + analysis = analyze_findings(result) + + print("\n📊 Research Analysis:") + print(f" - Total findings: {analysis['total_findings']}") + print(f" - Unique sources: {analysis['unique_sources']}") + print(f" - Questions explored: {analysis['total_questions']}") + print(f" - Iterations completed: {analysis['iterations']}") + + print("\n🔍 Finding Categories:") + for category, count in analysis["categories"].items(): + print(f" - {category}: {count} findings") + + print("\n📈 Source Distribution:") + for source_type, count in analysis["source_types"].items(): + print(f" - {source_type}: {count} sources") + + # Extract themes + themes = extract_themes(result) + print("\n🎯 Key Themes Identified:") + for i, theme in enumerate(themes[:5], 1): + print(f" {i}. {theme}") + + return analysis + + +def demonstrate_keyword_extraction(): + """ + Extract keywords and key concepts from research results. + + Demonstrates: + - Keyword extraction from findings + - Concept identification + - Topic clustering + - Trend analysis + """ + print("\n" + "=" * 70) + print("KEYWORD & CONCEPT EXTRACTION") + print("=" * 70) + print(""" +Extracting keywords and concepts: +- Important terms and phrases +- Technical concepts +- Named entities +- Trend indicators + """) + + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + } + ) + + # Quick research for keyword extraction + result = quick_summary( + query="Quantum computing breakthroughs 2024", + settings_snapshot=settings, + iterations=1, + questions_per_iteration=3, + ) + + # Extract keywords + keywords = extract_keywords(result) + + print("\n🔑 Top Keywords:") + for keyword, frequency in keywords[:10]: + print(f" - {keyword}: {frequency} occurrences") + + # Extract concepts + concepts = extract_concepts(result) + + print("\n💡 Key Concepts:") + for i, concept in enumerate(concepts[:5], 1): + print(f" {i}. {concept}") + + # Identify technical terms + technical_terms = extract_technical_terms(result) + + print("\n🔬 Technical Terms:") + for term in technical_terms[:8]: + print(f" - {term}") + + return keywords, concepts + + +def format_as_markdown(result: Dict[str, Any]) -> str: + """Convert research results to markdown format.""" + md = f"# Research Report: {result['query']}\n\n" + md += f"**Research ID:** {result.get('research_id', 'N/A')}\n\n" + + # Summary + md += "## Summary\n\n" + md += result.get("summary", "No summary available") + "\n\n" + + # Findings + md += "## Key Findings\n\n" + findings = result.get("findings", []) + for i, finding in enumerate(findings, 1): + finding_text = finding if isinstance(finding, str) else str(finding) + md += f"{i}. {finding_text}\n\n" + + # Sources + md += "## Sources\n\n" + sources = result.get("sources", []) + for i, source in enumerate(sources, 1): + source_text = source if isinstance(source, str) else str(source) + md += f"- [{i}] {source_text}\n" + + # Metadata + md += "\n## Metadata\n\n" + metadata = result.get("metadata", {}) + for key, value in metadata.items(): + md += f"- **{key}:** {value}\n" + + return md + + +def extract_citations(result: Dict[str, Any]) -> List[str]: + """Extract citations from research results.""" + citations = [] + sources = result.get("sources", []) + + for source in sources: + if isinstance(source, dict): + # Extract URL or title + citation = source.get("url", source.get("title", str(source))) + else: + citation = str(source) + citations.append(citation) + + return citations + + +def analyze_findings(result: Dict[str, Any]) -> Dict[str, Any]: + """Analyze research findings for patterns and statistics.""" + findings = result.get("findings", []) + sources = result.get("sources", []) + questions = result.get("questions", {}) + + # Categorize findings (simplified) + categories = { + "positive": 0, + "negative": 0, + "neutral": 0, + "technical": 0, + } + + for finding in findings: + finding_text = str(finding).lower() + if any( + word in finding_text + for word in ["benefit", "improve", "enhance", "positive"] + ): + categories["positive"] += 1 + elif any( + word in finding_text + for word in ["risk", "challenge", "negative", "concern"] + ): + categories["negative"] += 1 + elif any( + word in finding_text + for word in ["algorithm", "system", "technology", "method"] + ): + categories["technical"] += 1 + else: + categories["neutral"] += 1 + + # Analyze sources + source_types = {} + for source in sources: + source_text = str(source).lower() + if "wikipedia" in source_text: + source_type = "Wikipedia" + elif "arxiv" in source_text: + source_type = "ArXiv" + elif "github" in source_text: + source_type = "GitHub" + else: + source_type = "Other" + source_types[source_type] = source_types.get(source_type, 0) + 1 + + return { + "total_findings": len(findings), + "unique_sources": len(sources), + "total_questions": sum(len(qs) for qs in questions.values()), + "iterations": result.get("iterations", 0), + "categories": categories, + "source_types": source_types, + } + + +def extract_themes(result: Dict[str, Any]) -> List[str]: + """Extract main themes from research results.""" + # Simplified theme extraction based on common patterns + themes = [] + summary = result.get("summary", "") + findings = result.get("findings", []) + + # Combine text for analysis + full_text = summary + " ".join(str(f) for f in findings) + + # Simple theme patterns (in production, use NLP libraries) + theme_patterns = { + "automation": ["automation", "automated", "automatic"], + "job displacement": ["job loss", "unemployment", "displacement"], + "skill requirements": ["skills", "training", "education"], + "economic impact": ["economy", "economic", "gdp", "growth"], + "innovation": ["innovation", "innovative", "breakthrough"], + } + + for theme, keywords in theme_patterns.items(): + if any(keyword in full_text.lower() for keyword in keywords): + themes.append(theme.title()) + + return themes + + +def extract_keywords(result: Dict[str, Any]) -> List[tuple]: + """Extract keywords with frequency from research results.""" + from collections import Counter + import re + + # Combine all text + summary = result.get("summary", "") + findings = " ".join(str(f) for f in result.get("findings", [])) + full_text = f"{summary} {findings}".lower() + + # Simple word extraction (in production, use NLP libraries) + words = re.findall(r"\b[a-z]{4,}\b", full_text) + + # Filter common words + stopwords = { + "that", + "this", + "with", + "from", + "have", + "been", + "were", + "which", + "their", + "about", + } + words = [w for w in words if w not in stopwords] + + # Count frequencies + word_freq = Counter(words) + + return word_freq.most_common(20) + + +def extract_concepts(result: Dict[str, Any]) -> List[str]: + """Extract key concepts from research results.""" + concepts = [] + summary = result.get("summary", "") + + # Simple concept patterns (in production, use NLP for entity extraction) + concept_patterns = [ + r"quantum \w+", + r"\w+ computing", + r"\w+ algorithm", + r"machine learning", + r"artificial intelligence", + r"\w+ technology", + ] + + import re + + for pattern in concept_patterns: + matches = re.findall(pattern, summary.lower()) + concepts.extend(matches) + + # Deduplicate and clean + concepts = list(set(concepts)) + + return concepts[:10] + + +def extract_technical_terms(result: Dict[str, Any]) -> List[str]: + """Extract technical terms from research results.""" + technical_terms = [] + + # Common technical term patterns + tech_indicators = [ + "algorithm", + "system", + "protocol", + "framework", + "architecture", + "quantum", + "neural", + "network", + "model", + "optimization", + ] + + summary = result.get("summary", "").lower() + import re + + for indicator in tech_indicators: + # Find words containing or adjacent to technical indicators + pattern = rf"\b\w*{indicator}\w*\b" + matches = re.findall(pattern, summary) + technical_terms.extend(matches) + + # Deduplicate + technical_terms = list(set(technical_terms)) + + return technical_terms + + +def demonstrate_batch_research(): + """ + Show how to perform batch research on multiple topics. + + Useful for: + - Comparative analysis + - Trend monitoring + - Systematic reviews + """ + print("\n" + "=" * 70) + print("BATCH RESEARCH PROCESSING") + print("=" * 70) + print(""" +Processing multiple research queries: +- Efficient batch processing +- Comparative analysis +- Result aggregation + """) + + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + } + ) + + # Topics for batch research + topics = [ + "Solar energy innovations", + "Wind power technology", + "Hydrogen fuel cells", + ] + + batch_results = {} + + print("\n📚 Batch Research:") + for topic in topics: + print(f"\n Researching: {topic}") + result = quick_summary( + query=topic, + settings_snapshot=settings, + iterations=1, + questions_per_iteration=2, + ) + batch_results[topic] = result + print(f" ✓ Found {len(result.get('findings', []))} findings") + + # Aggregate results + print("\n📊 Aggregate Analysis:") + total_findings = sum( + len(r.get("findings", [])) for r in batch_results.values() + ) + total_sources = sum( + len(r.get("sources", [])) for r in batch_results.values() + ) + + print(f" - Total topics researched: {len(topics)}") + print(f" - Total findings: {total_findings}") + print(f" - Total sources: {total_sources}") + print(f" - Average findings per topic: {total_findings / len(topics):.1f}") + + # Save batch results + batch_file = "batch_research_results.json" + with open(batch_file, "w", encoding="utf-8") as f: + json.dump(batch_results, f, indent=2, default=str) + print(f"\n✓ Batch results saved to: {batch_file}") + + return batch_results + + +def main(): + """Run all advanced feature demonstrations.""" + print("=" * 70) + print("LOCAL DEEP RESEARCH - ADVANCED FEATURES DEMONSTRATION") + print("=" * 70) + print(""" +This example demonstrates advanced programmatic features: +1. Report generation with generate_report() +2. Multiple export formats +3. Result analysis and insights +4. Keyword and concept extraction +5. Batch research processing + """) + + # Run demonstrations + demonstrate_report_generation() + demonstrate_export_formats() + demonstrate_result_analysis() + demonstrate_keyword_extraction() + demonstrate_batch_research() + + print("\n" + "=" * 70) + print("DEMONSTRATION COMPLETE") + print("=" * 70) + print(""" +✓ All advanced features demonstrated successfully! + +Key Takeaways: +1. generate_report() creates comprehensive markdown reports +2. Results can be exported in multiple formats (JSON, MD, custom) +3. Analysis tools extract insights, themes, and patterns +4. Keyword extraction identifies important terms and concepts +5. Batch processing enables systematic research + +Files created: +- ml_healthcare_report.md - Full research report +- research_results.json - Structured research data +- research_results.md - Markdown formatted results +- research_citations.txt - Extracted citations +- batch_research_results.json - Batch research results + +Next Steps: +- Customize report templates for your domain +- Integrate with data visualization tools +- Build automated research pipelines +- Create domain-specific analysis functions + """) + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/custom_llm_retriever_example.py+207 −0 added@@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Example of using a custom LLM with a custom retriever in Local Deep Research. + +This demonstrates how to integrate your own LLM implementation and custom +retrieval system for programmatic access. +""" + +from typing import List, Dict +from langchain_ollama import ChatOllama +from langchain.schema import Document +from langchain_community.vectorstores import FAISS +from langchain_community.embeddings import OllamaEmbeddings + +# Import the search system +from local_deep_research.search_system import AdvancedSearchSystem + +# Re-enable logging after import +from loguru import logger +import sys + +logger.remove() +logger.add(sys.stderr, level="INFO", format="{time} {level} {message}") +logger.enable("local_deep_research") + + +class CustomRetriever: + """Custom retriever that can fetch from multiple sources.""" + + def __init__(self): + # Initialize with sample documents for demonstration + self.documents = [ + { + "content": "Quantum computing uses quantum bits (qubits) that can exist in superposition, " + "allowing parallel computation of multiple states simultaneously.", + "title": "Quantum Computing Fundamentals", + "source": "quantum_basics.pdf", + "metadata": {"topic": "quantum", "year": 2024}, + }, + { + "content": "Machine learning algorithms can be categorized into supervised, unsupervised, " + "and reinforcement learning approaches, each suited for different tasks.", + "title": "ML Algorithm Categories", + "source": "ml_overview.pdf", + "metadata": {"topic": "ml", "year": 2024}, + }, + { + "content": "Neural networks are inspired by biological neurons and consist of interconnected " + "nodes that process information through weighted connections.", + "title": "Neural Network Architecture", + "source": "nn_architecture.pdf", + "metadata": {"topic": "neural_networks", "year": 2023}, + }, + { + "content": "Natural language processing enables computers to understand, interpret, and " + "generate human language, powering applications like chatbots and translation.", + "title": "NLP Applications", + "source": "nlp_apps.pdf", + "metadata": {"topic": "nlp", "year": 2024}, + }, + ] + + # Create embeddings for similarity search + logger.info("Initializing custom retriever with embeddings...") + self.embeddings = OllamaEmbeddings(model="nomic-embed-text") + + # Create vector store from documents + docs = [ + Document( + page_content=doc["content"], + metadata={ + "title": doc["title"], + "source": doc["source"], + **doc["metadata"], + }, + ) + for doc in self.documents + ] + self.vectorstore = FAISS.from_documents(docs, self.embeddings) + + def retrieve(self, query: str, k: int = 3) -> List[Dict]: + """Custom retrieval logic.""" + logger.info(f"Custom Retriever: Searching for '{query}'") + + # Use vector similarity search + similar_docs = self.vectorstore.similarity_search(query, k=k) + + # Convert to expected format + results = [] + for i, doc in enumerate(similar_docs): + results.append( + { + "title": doc.metadata.get("title", f"Document {i + 1}"), + "link": doc.metadata.get("source", "custom_source"), + "snippet": doc.page_content[:150] + "...", + "full_content": doc.page_content, + "rank": i + 1, + "metadata": doc.metadata, + } + ) + + logger.info( + f"Custom Retriever: Found {len(results)} relevant documents" + ) + return results + + +class CustomSearchEngine: + """Adapter to integrate custom retriever with the search system.""" + + def __init__(self, retriever: CustomRetriever, settings_snapshot=None): + self.retriever = retriever + self.settings_snapshot = settings_snapshot or {} + + def run(self, query: str, research_context=None) -> List[Dict]: + """Execute search using custom retriever.""" + return self.retriever.retrieve(query, k=5) + + +def main(): + """Demonstrate custom LLM and retriever integration.""" + print("=== Custom LLM and Retriever Example ===\n") + + # 1. Create custom LLM (just using regular Ollama for simplicity) + print("1. Initializing LLM...") + llm = ChatOllama(model="gemma3:12b", temperature=0.7) + + # 2. Create custom retriever + print("2. Setting up custom retriever...") + custom_retriever = CustomRetriever() + + # 3. Create settings + settings = { + "search.iterations": 2, + "search.questions_per_iteration": 3, + "search.strategy": "source-based", + "rate_limiting.enabled": False, # Disable rate limiting for custom setup + } + + # 4. Create search engine adapter + print("3. Creating search engine adapter...") + search_engine = CustomSearchEngine(custom_retriever, settings) + + # 5. Initialize the search system + print("4. Initializing AdvancedSearchSystem with custom components...") + # Pass programmatic_mode=True to avoid database dependencies + search_system = AdvancedSearchSystem( + llm=llm, + search=search_engine, + settings_snapshot=settings, + programmatic_mode=True, + ) + + # 6. Run research queries + queries = [ + "How do quantum computers differ from classical computers?", + "What are the main types of machine learning algorithms?", + ] + + for query in queries: + print(f"\n{'=' * 60}") + print(f"Research Query: {query}") + print("=" * 60) + + result = search_system.analyze_topic(query) + + # Display results + print("\n=== FINDINGS ===") + print(result["formatted_findings"]) + + # Show metadata + print("\n=== SEARCH METADATA ===") + print(f"• Total findings: {len(result['findings'])}") + print(f"• Iterations: {result['iterations']}") + + # Get actual sources from all_links_of_system or search_results + all_links = result.get("all_links_of_system", []) + for finding in result.get("findings", []): + if "search_results" in finding and finding["search_results"]: + all_links = finding["search_results"] + break + + print(f"• Sources found: {len(all_links)}") + if all_links and len(all_links) > 0: + print("\n=== SOURCES ===") + for i, link in enumerate(all_links[:5], 1): # Show first 5 + if isinstance(link, dict): + title = link.get("title", "No title") + url = link.get("link", link.get("source", "Unknown")) + print(f" [{i}] {title}") + print(f" URL: {url}") + + # Show generated questions + if result.get("questions_by_iteration"): + print("\n=== RESEARCH QUESTIONS GENERATED ===") + for iteration, questions in result[ + "questions_by_iteration" + ].items(): + print(f"\nIteration {iteration}:") + for q in questions[:3]: # Show first 3 questions + print(f" • {q}") + + print("\n✓ Custom LLM and Retriever integration successful!") + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/hybrid_search_example.py+403 −0 added@@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +Hybrid Search Example for Local Deep Research + +This example demonstrates how to combine multiple search sources: +1. Multiple named retrievers for different document types +2. Combining custom retrievers with web search +3. Analyzing and comparing sources from different origins +""" + +from typing import List +from langchain.schema import Document, BaseRetriever +from langchain_community.vectorstores import FAISS +from langchain_community.embeddings import OllamaEmbeddings + +from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api.settings_utils import create_settings_snapshot + + +class TechnicalDocsRetriever(BaseRetriever): + """Mock retriever for technical documentation.""" + + def get_relevant_documents(self, query: str) -> List[Document]: + """Return mock technical documents.""" + # In a real scenario, this would search actual technical docs + return [ + Document( + page_content=f"Technical specification for {query}: Implementation requires careful consideration of system architecture, performance metrics, and scalability factors.", + metadata={ + "source": "tech_docs", + "type": "specification", + "title": f"Technical Spec: {query}", + }, + ), + Document( + page_content=f"Best practices for {query}: Follow industry standards, implement proper error handling, and ensure comprehensive testing coverage.", + metadata={ + "source": "tech_docs", + "type": "best_practices", + "title": f"Best Practices: {query}", + }, + ), + ] + + async def aget_relevant_documents(self, query: str) -> List[Document]: + """Async version.""" + return self.get_relevant_documents(query) + + +class BusinessDocsRetriever(BaseRetriever): + """Mock retriever for business/strategy documents.""" + + def get_relevant_documents(self, query: str) -> List[Document]: + """Return mock business documents.""" + return [ + Document( + page_content=f"Business implications of {query}: Consider market impact, ROI analysis, and strategic alignment with organizational goals.", + metadata={ + "source": "business_docs", + "type": "strategy", + "title": f"Business Strategy: {query}", + }, + ), + Document( + page_content=f"Cost-benefit analysis for {query}: Initial investment requirements, expected returns, and risk assessment factors.", + metadata={ + "source": "business_docs", + "type": "analysis", + "title": f"Cost Analysis: {query}", + }, + ), + ] + + async def aget_relevant_documents(self, query: str) -> List[Document]: + """Async version.""" + return self.get_relevant_documents(query) + + +def create_knowledge_base_retriever() -> BaseRetriever: + """Create a FAISS-based retriever with sample knowledge base documents.""" + documents = [ + Document( + page_content="Machine learning models require training data, validation strategies, and performance metrics for evaluation.", + metadata={"source": "ml_knowledge_base", "topic": "ml_basics"}, + ), + Document( + page_content="Cloud computing provides scalable infrastructure, reducing capital expenditure and enabling flexible resource allocation.", + metadata={ + "source": "cloud_knowledge_base", + "topic": "cloud_benefits", + }, + ), + Document( + page_content="Agile methodology emphasizes iterative development, customer collaboration, and responding to change.", + metadata={"source": "project_knowledge_base", "topic": "agile"}, + ), + Document( + page_content="Data privacy regulations like GDPR require explicit consent, data minimization, and user rights management.", + metadata={ + "source": "compliance_knowledge_base", + "topic": "privacy", + }, + ), + ] + + # Create embeddings and vector store + embeddings = OllamaEmbeddings(model="nomic-embed-text") + vectorstore = FAISS.from_documents(documents, embeddings) + return vectorstore.as_retriever(search_kwargs={"k": 2}) + + +def demonstrate_multiple_retrievers(): + """Show how to use multiple named retrievers for different document types.""" + print("=" * 70) + print("MULTIPLE NAMED RETRIEVERS") + print("=" * 70) + print(""" +Using multiple specialized retrievers: +- Technical documentation retriever +- Business documentation retriever +- Knowledge base retriever +Each provides different perspectives on the same topic. + """) + + # Create different retrievers + tech_retriever = TechnicalDocsRetriever() + business_retriever = BusinessDocsRetriever() + kb_retriever = create_knowledge_base_retriever() + + # Configure settings + settings = create_settings_snapshot( + { + "search.tool": "auto", # Will use all provided retrievers + } + ) + + # Use multiple retrievers in research + result = quick_summary( + query="Implementing machine learning in production", + settings_snapshot=settings, + retrievers={ + "technical": tech_retriever, + "business": business_retriever, + "knowledge_base": kb_retriever, + }, + search_tool="auto", # Use all retrievers + iterations=2, + questions_per_iteration=2, + programmatic_mode=True, + ) + + print("\nResearch Summary (first 400 chars):") + print(result["summary"][:400] + "...") + + # Analyze sources by type + sources = result.get("sources", []) + print(f"\nTotal sources found: {len(sources)}") + + # Group sources by retriever + source_types = {} + for source in sources: + if isinstance(source, dict): + source_type = source.get("metadata", {}).get("source", "unknown") + else: + source_type = "other" + source_types[source_type] = source_types.get(source_type, 0) + 1 + + print("\nSources by retriever:") + for stype, count in source_types.items(): + print(f" - {stype}: {count} sources") + + return result + + +def demonstrate_retriever_plus_web(): + """Show how to combine custom retrievers with web search.""" + print("\n" + "=" * 70) + print("RETRIEVER + WEB SEARCH COMBINATION") + print("=" * 70) + print(""" +Combining internal knowledge with web search: +- Internal: Custom retriever with proprietary knowledge +- External: Wikipedia for general context +This provides both specific and general information. + """) + + # Create internal knowledge retriever + internal_retriever = create_knowledge_base_retriever() + + # Configure settings to use both retriever and web + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", # Also use Wikipedia + } + ) + + # Research combining internal and external sources + result = detailed_research( + query="Best practices for cloud migration", + settings_snapshot=settings, + retrievers={ + "internal_kb": internal_retriever, + }, + search_tool="wikipedia", # Also search Wikipedia + search_strategy="source-based", + iterations=2, + questions_per_iteration=3, + programmatic_mode=True, + ) + + print(f"\nResearch ID: {result['research_id']}") + print(f"Summary length: {len(result['summary'])} characters") + + # Analyze source distribution + sources = result.get("sources", []) + internal_sources = 0 + external_sources = 0 + + for source in sources: + if isinstance(source, dict) and "knowledge_base" in str(source): + internal_sources += 1 + else: + external_sources += 1 + + print("\nSource distribution:") + print(f" - Internal knowledge base: {internal_sources} sources") + print(f" - External (Wikipedia): {external_sources} sources") + + # Show how different sources complement each other + print("\nComplementary insights from hybrid search:") + print( + " - Internal sources provide: Specific procedures, proprietary knowledge" + ) + print( + " - External sources provide: Industry context, general best practices" + ) + + return result + + +def demonstrate_source_analysis(): + """Show how to analyze and compare sources from different origins.""" + print("\n" + "=" * 70) + print("SOURCE ANALYSIS AND COMPARISON") + print("=" * 70) + print(""" +Analyzing source quality and relevance: +- Track source origins +- Compare information consistency +- Identify unique insights from each source type + """) + + # Create multiple retrievers + tech_retriever = TechnicalDocsRetriever() + business_retriever = BusinessDocsRetriever() + + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", + } + ) + + # Run research with detailed source tracking + result = quick_summary( + query="Artificial intelligence implementation strategies", + settings_snapshot=settings, + retrievers={ + "technical": tech_retriever, + "business": business_retriever, + }, + search_tool="wikipedia", # Also use web search + iterations=2, + questions_per_iteration=2, + programmatic_mode=True, + ) + + # Detailed source analysis + print("\nSource Analysis:") + sources = result.get("sources", []) + + # Categorize sources + source_categories = {"technical": [], "business": [], "web": []} + + for source in sources: + if isinstance(source, dict): + source_type = source.get("metadata", {}).get("source", "") + if "tech" in source_type: + source_categories["technical"].append(source) + elif "business" in source_type: + source_categories["business"].append(source) + else: + source_categories["web"].append(source) + else: + source_categories["web"].append(source) + + # Report on each category + for category, category_sources in source_categories.items(): + print(f"\n{category.upper()} Sources ({len(category_sources)}):") + if category_sources: + for i, source in enumerate(category_sources[:2], 1): # Show first 2 + if isinstance(source, dict): + title = source.get("metadata", {}).get("title", "Untitled") + print(f" {i}. {title}") + else: + print(f" {i}. {str(source)[:60]}...") + + # Show findings breakdown + findings = result.get("findings", []) + print(f"\nTotal findings: {len(findings)}") + print("Findings provide integrated insights from all source types") + + return result + + +def demonstrate_meta_search_config(): + """Show how to use meta search configuration for complex setups.""" + print("\n" + "=" * 70) + print("META SEARCH CONFIGURATION") + print("=" * 70) + print(""" +Using meta search for sophisticated search strategies: +- Combine multiple search engines +- Configure aggregation and deduplication +- Control search priority and weighting + """) + + # Create retrievers + tech_retriever = TechnicalDocsRetriever() + + settings = create_settings_snapshot({}) + + # Advanced meta search configuration + result = quick_summary( + query="Quantum computing applications", + settings_snapshot=settings, + retrievers={ + "tech_docs": tech_retriever, + }, + search_tool="meta", # Use meta search + meta_search_config={ + "retrievers": ["tech_docs"], # Include custom retriever + "engines": ["wikipedia", "arxiv"], # Also search these + "aggregate": True, # Combine results + "deduplicate": True, # Remove duplicate content + "max_results_per_engine": 5, # Limit per source + }, + iterations=2, + questions_per_iteration=3, + programmatic_mode=True, + ) + + print("\nMeta search results:") + print(f" - Total sources: {len(result.get('sources', []))}") + print(f" - Summary length: {len(result.get('summary', ''))} chars") + print(f" - Findings: {len(result.get('findings', []))}") + + print("\nMeta search advantages:") + print(" - Comprehensive coverage from multiple sources") + print(" - Automatic deduplication of similar content") + print(" - Balanced perspective from different source types") + + return result + + +def main(): + """Run all hybrid search demonstrations.""" + print("=" * 70) + print("LOCAL DEEP RESEARCH - HYBRID SEARCH DEMONSTRATION") + print("=" * 70) + print(""" +This example shows how to combine multiple search sources: +- Custom retrievers for proprietary knowledge +- Web search engines for public information +- Meta search for sophisticated strategies + """) + + # Run demonstrations + demonstrate_multiple_retrievers() + demonstrate_retriever_plus_web() + demonstrate_source_analysis() + demonstrate_meta_search_config() + + print("\n" + "=" * 70) + print("KEY TAKEAWAYS") + print("=" * 70) + print(""" +1. Multiple Retrievers: Use specialized retrievers for different document types +2. Hybrid Search: Combine internal knowledge with web search for comprehensive results +3. Source Analysis: Track and analyze sources to understand information origin +4. Meta Search: Configure complex search strategies with aggregation and deduplication + +Best Practices: +- Name your retrievers descriptively for easy tracking +- Balance internal and external sources based on your needs +- Use source analysis to verify information consistency +- Configure meta search for optimal result aggregation + """) + + print("\n✓ Hybrid search demonstration complete!") + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/minimal_working_example.py+88 −0 added@@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Minimal working example for programmatic access to Local Deep Research. + +This shows how to use the core functionality without database dependencies. +""" + +from langchain_ollama import ChatOllama +from local_deep_research.search_system import AdvancedSearchSystem + +# Re-enable logging after import (it gets disabled in __init__.py) +from loguru import logger +import sys + +logger.remove() +logger.add(sys.stderr, level="WARNING", format="{time} {level} {message}") +logger.enable("local_deep_research") + + +class MinimalSearchEngine: + """Minimal search engine that returns hardcoded results.""" + + def __init__(self, settings_snapshot=None): + self.settings_snapshot = settings_snapshot or {} + + def run(self, query, research_context=None): + """Return some fake search results.""" + return [ + { + "title": "Introduction to AI", + "link": "https://example.com/ai-intro", + "snippet": "Artificial Intelligence (AI) is the simulation of human intelligence...", + "full_content": "Full article about AI basics...", + "rank": 1, + }, + { + "title": "Machine Learning Explained", + "link": "https://example.com/ml-explained", + "snippet": "Machine learning is a subset of AI that enables systems to learn...", + "full_content": "Detailed explanation of machine learning...", + "rank": 2, + }, + ] + + +def main(): + """Minimal example of programmatic access.""" + print("=== Minimal Local Deep Research Example ===\n") + + # 1. Create LLM + print("1. Creating Ollama LLM...") + llm = ChatOllama(model="gemma3:12b") + + # 2. Create minimal search engine + print("2. Creating minimal search engine...") + + # Settings for search system (without programmatic_mode) + settings = { + "search.iterations": 1, + "search.strategy": "direct", + } + + search = MinimalSearchEngine(settings) + + # 3. Create search system + print("3. Creating AdvancedSearchSystem...") + # IMPORTANT: Pass programmatic_mode=True to avoid database dependencies + system = AdvancedSearchSystem( + llm=llm, + search=search, + settings_snapshot=settings, + programmatic_mode=True, + ) + + # 4. Run a search + print("\n4. Running search...") + result = system.analyze_topic("What is artificial intelligence?") + + # 5. Show results + print("\n=== RESULTS ===") + print(f"Found {len(result['findings'])} findings") + print(f"\nSummary:\n{result['current_knowledge']}") + + print("\n✓ Success! Programmatic access works without database.") + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/programmatic_access.ipynb+0 −583 removedexamples/api_usage/programmatic/README.md+238 −0 added@@ -0,0 +1,238 @@ +# Local Deep Research - Programmatic API Examples + +This directory contains examples demonstrating how to use Local Deep Research programmatically without requiring authentication or database access. + +## Quick Start + +All examples use the programmatic API that bypasses authentication: + +```python +from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api.settings_utils import create_settings_snapshot + +# Create settings for programmatic mode +settings = create_settings_snapshot({ + "search.tool": "wikipedia" +}) + +# Run research +result = quick_summary( + "your topic", + settings_snapshot=settings, + programmatic_mode=True +) +``` + +## Examples Overview + +| Example | Purpose | Key Features | Difficulty | +|---------|---------|--------------|------------| +| **minimal_working_example.py** | Simplest possible example | Basic setup, minimal code | Beginner | +| **simple_programmatic_example.py** | Common use cases with the new API | quick_summary, detailed_research, generate_report, custom parameters | Beginner | +| **search_strategies_example.py** | Demonstrates search strategies | source-based vs focused-iteration strategies | Intermediate | +| **hybrid_search_example.py** | Combine multiple search sources | Multiple retrievers, web + retriever combo | Intermediate | +| **advanced_features_example.py** | Advanced programmatic features | generate_report, export formats, result analysis, keyword extraction | Advanced | +| **custom_llm_retriever_example.py** | Custom LLM and retriever integration | Ollama, custom retrievers, FAISS | Advanced | +| **searxng_example.py** | Web search with SearXNG | SearXNG integration, error handling | Advanced | + +## Example Details + +### minimal_working_example.py +**Purpose:** Show the absolute minimum code needed to use LDR programmatically. +- Creates a simple LLM and search engine +- Runs a basic search +- No external dependencies beyond Ollama + +### simple_programmatic_example.py +**Purpose:** Demonstrate the main API functions with practical examples. +- `quick_summary()` - Fast research with summary +- `detailed_research()` - Comprehensive research with findings +- `generate_report()` - Create full markdown reports +- Custom search parameters +- Different search tools (Wikipedia, auto, etc.) + +### search_strategies_example.py +**Purpose:** Explain and demonstrate the two main search strategies. +- **source-based**: Comprehensive research with detailed citations +- **focused-iteration**: Iterative refinement of research questions +- Side-by-side comparison of strategies +- When to use each strategy + +### hybrid_search_example.py +**Purpose:** Show how to combine multiple search sources for comprehensive research. +- Multiple named retrievers for different document types +- Combining custom retrievers with web search +- Source analysis and tracking +- Meta search configuration + +### advanced_features_example.py +**Purpose:** Demonstrate advanced programmatic features and analysis capabilities. +- `generate_report()` - Create comprehensive markdown reports +- Export formats - JSON, Markdown, custom formats +- Result analysis - Extract insights and patterns +- Keyword extraction - Identify key terms and concepts +- Batch research - Process multiple queries efficiently + +### custom_llm_retriever_example.py +**Purpose:** Advanced integration with custom components. +- Custom LLM implementation (using Ollama) +- Custom retriever with embeddings +- Vector store integration (FAISS) +- Direct use of AdvancedSearchSystem + +### searxng_example.py +**Purpose:** Web search integration using SearXNG. +- SearXNG configuration +- Error handling and fallbacks +- Real-time web search +- Direct use of search engines + +## Key Concepts + +### Programmatic Mode +All examples use `programmatic_mode=True` as an explicit parameter to bypass authentication: +```python +result = quick_summary( + query="your topic", + settings_snapshot=settings, + programmatic_mode=True +) +``` + +### Search Strategies +- **source-based**: Best for academic research, fact-checking +- **focused-iteration**: Best for exploratory research, complex topics + +### Search Tools +Available search tools include: +- `wikipedia` - Wikipedia search +- `arxiv` - Academic papers +- `searxng` - Web search via SearXNG +- `auto` - Automatically select best tool +- `meta` - Combine multiple tools + +### Custom Retrievers +You can provide your own retrievers: +```python +result = quick_summary( + query="topic", + retrievers={"my_docs": custom_retriever}, + search_tool="my_docs", + settings_snapshot=settings, + programmatic_mode=True +) +``` + +## API Functions + +### `quick_summary()` +Generate a quick research summary: +```python +from local_deep_research.api import quick_summary +from local_deep_research.api.settings_utils import create_settings_snapshot + +settings = create_settings_snapshot({}) +result = quick_summary( + query="Your research question", + settings_snapshot=settings, + search_tool="wikipedia", + iterations=2, + programmatic_mode=True +) +``` + +### `detailed_research()` +Perform in-depth research with multiple iterations: +```python +from local_deep_research.api import detailed_research + +result = detailed_research( + query="Your research question", + settings_snapshot=settings, + search_strategy="source-based", + iterations=3, + questions_per_iteration=5, + programmatic_mode=True +) +``` + +### `generate_report()` +Generate comprehensive markdown reports with structured sections: +```python +from local_deep_research.api import generate_report +from local_deep_research.api.settings_utils import create_settings_snapshot + +settings = create_settings_snapshot(overrides={"programmatic_mode": True}) +result = generate_report( + query="Your research question", + settings_snapshot=settings, + output_file="report.md", + searches_per_section=3 +) +``` + +## Requirements + +- Python 3.8+ +- Local Deep Research installed +- Ollama (for most examples) +- SearXNG instance (for searxng_example.py) + +## Running the Examples + +1. Install Local Deep Research: + ```bash + pip install -e . + ``` + +2. Start Ollama (if using Ollama examples): + ```bash + ollama serve + ollama pull gemma3:12b + ollama pull nomic-embed-text # For embeddings + ``` + +3. Run any example: + ```bash + python minimal_working_example.py + python simple_programmatic_example.py + python search_strategies_example.py + ``` + +## Troubleshooting + +### "No settings context available" Error +Make sure to pass `settings_snapshot` and `programmatic_mode` to all API functions: +```python +settings = create_settings_snapshot({}) +result = quick_summary( + "topic", + settings_snapshot=settings, + programmatic_mode=True +) +``` + +### Ollama Connection Error +Ensure Ollama is running: +```bash +ollama serve +``` + +### SearXNG Connection Error +Start a SearXNG instance or use the fallback in the example: +```bash +docker run -p 8080:8080 searxng/searxng +``` + + +## Contributing + +When adding new examples: +1. Focus on demonstrating specific features +2. Include clear comments explaining the code +3. Handle errors gracefully +4. Update this README with the new example + +## License + +See the main project LICENSE file.
examples/api_usage/programmatic/retriever_usage_example.py+0 −199 removed@@ -1,199 +0,0 @@ -""" -Example of using LangChain retrievers with LDR. - -This example shows how to use any LangChain retriever as a search engine in LDR. -""" - -from typing import List -from langchain.schema import Document, BaseRetriever -from langchain.vectorstores import FAISS -from langchain.embeddings import OpenAIEmbeddings - -# Import LDR functions -from local_deep_research.api.research_functions import ( - quick_summary, - detailed_research, -) - - -# Example 1: Simple mock retriever for testing -class MockRetriever(BaseRetriever): - """Mock retriever for demonstration.""" - - def get_relevant_documents(self, query: str) -> List[Document]: - """Return mock documents.""" - return [ - Document( - page_content=f"This is a mock document about {query}. It contains relevant information.", - metadata={ - "title": f"Document about {query}", - "source": "mock_db", - }, - ), - Document( - page_content=f"Another document discussing {query} in detail.", - metadata={ - "title": f"Detailed analysis of {query}", - "source": "mock_db", - }, - ), - ] - - async def aget_relevant_documents(self, query: str) -> List[Document]: - """Async version.""" - return self.get_relevant_documents(query) - - -def example_single_retriever(): - """Example using a single retriever.""" - print("=== Example 1: Single Retriever ===") - - # Create a mock retriever - retriever = MockRetriever() - - # Use it with LDR - result = quick_summary( - query="What are the best practices for ML deployment?", - retrievers={"mock_kb": retriever}, - search_tool="mock_kb", # Use only this retriever - iterations=2, - questions_per_iteration=3, - ) - - print(f"Summary: {result['summary'][:200]}...") - print(f"Sources: {len(result.get('sources', []))} sources found") - - -def example_multiple_retrievers(): - """Example using multiple retrievers.""" - print("\n=== Example 2: Multiple Retrievers ===") - - # Create multiple mock retrievers - tech_retriever = MockRetriever() - business_retriever = MockRetriever() - - # Use them with LDR - result = detailed_research( - query="What are the business and technical implications of ML deployment?", - retrievers={ - "tech_docs": tech_retriever, - "business_docs": business_retriever, - }, - search_tool="auto", # Use all retrievers - iterations=3, - ) - - print(f"Research ID: {result['research_id']}") - print(f"Summary: {result['summary'][:200]}...") - print(f"Findings: {len(result['findings'])} findings") - - -def example_hybrid_search(): - """Example mixing retrievers with web search.""" - print("\n=== Example 3: Hybrid Search (Retriever + Web) ===") - - # Create retriever - retriever = MockRetriever() - - # Use retriever + web search - result = quick_summary( - query="Compare our internal ML practices with industry standards", - retrievers={"internal_kb": retriever}, - search_tool="auto", # Will use both retriever and web search - search_engines=[ - "internal_kb", - "wikipedia", - "searxng", - ], # Specify which to use - iterations=3, - ) - - print(f"Summary: {result['summary'][:200]}...") - - -def example_real_vector_store(): - """Example with a real vector store (requires OpenAI API key).""" - print("\n=== Example 4: Real Vector Store ===") - print("This example requires OpenAI API key to be set") - - try: - # Create embeddings and vector store - embeddings = OpenAIEmbeddings() - - # Create some sample documents - texts = [ - "Machine learning deployment requires careful consideration of infrastructure.", - "Model versioning is crucial for production ML systems.", - "Monitoring and alerting are essential for ML in production.", - "A/B testing helps validate model improvements.", - "Feature stores centralize feature computation and storage.", - ] - - # Create vector store - vectorstore = FAISS.from_texts(texts, embeddings) - retriever = vectorstore.as_retriever(search_kwargs={"k": 3}) - - # Use with LDR - result = quick_summary( - query="What are the key considerations for ML deployment?", - retrievers={"ml_knowledge": retriever}, - search_tool="ml_knowledge", - iterations=2, - ) - - print(f"Summary: {result['summary'][:200]}...") - - except Exception as e: - print(f"Skipping real vector store example: {e}") - - -def example_selective_retriever_usage(): - """Example showing how to selectively use different retrievers.""" - print("\n=== Example 5: Selective Retriever Usage ===") - - # Create specialized retrievers - retrievers = { - "technical": MockRetriever(), - "business": MockRetriever(), - "legal": MockRetriever(), - } - - # Query 1: Technical only - result1 = quick_summary( - query="How to implement distributed training?", - retrievers=retrievers, - search_tool="technical", # Use only technical retriever - ) - print(f"Technical query result: {result1['summary'][:100]}...") - - # Query 2: Business only - result2 = quick_summary( - query="What is the ROI of ML investments?", - retrievers=retrievers, - search_tool="business", # Use only business retriever - ) - print(f"Business query result: {result2['summary'][:100]}...") - - # Query 3: All retrievers - result3 = quick_summary( - query="What are the implications of ML adoption?", - retrievers=retrievers, - search_tool="auto", # Use all retrievers - ) - print(f"Comprehensive query result: {result3['summary'][:100]}...") - - -if __name__ == "__main__": - print("LangChain Retriever Integration Examples") - print("=" * 50) - - # Run examples - example_single_retriever() - example_multiple_retrievers() - example_hybrid_search() - example_selective_retriever_usage() - - # Uncomment to run vector store example (requires OpenAI API key) - # example_real_vector_store() - - print("\n✅ Examples completed!")
examples/api_usage/programmatic/search_strategies_example.py+225 −0 added@@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Search Strategies Example for Local Deep Research + +This example demonstrates the two main search strategies: +1. source-based: Comprehensive research with source citation +2. focused-iteration: Iterative refinement of research questions + +Each strategy has different strengths and use cases. +""" + +from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api.settings_utils import create_settings_snapshot + + +def demonstrate_source_based_strategy(): + """ + Source-based strategy: + - Focuses on gathering and synthesizing information from multiple sources + - Provides detailed citations and source tracking + - Best for: Academic research, fact-checking, comprehensive reports + """ + print("=" * 70) + print("SOURCE-BASED STRATEGY") + print("=" * 70) + print(""" +This strategy: +- Systematically searches for sources related to your topic +- Synthesizes information across multiple sources +- Provides detailed citations for all claims +- Ideal for research requiring source verification + """) + + # Configure settings for programmatic mode + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", # Using Wikipedia for demonstration + } + ) + + # Run research with source-based strategy + result = detailed_research( + query="What are the main causes of climate change?", + settings_snapshot=settings, + search_strategy="source-based", # Explicitly set strategy + iterations=2, # Number of research iterations + questions_per_iteration=3, # Questions to explore per iteration + programmatic_mode=True, + ) + + print(f"Research ID: {result['research_id']}") + print("\nSummary (first 500 chars):") + print(result["summary"][:500] + "...") + + # Show sources found + sources = result.get("sources", []) + print(f"\nSources found: {len(sources)}") + if sources: + print("\nFirst 3 sources:") + for i, source in enumerate(sources[:3], 1): + print(f" {i}. {source}") + + # Show the questions that were researched + questions = result.get("questions", {}) + print(f"\nQuestions researched across {len(questions)} iterations:") + for iteration, qs in questions.items(): + print(f"\n Iteration {iteration}:") + for q in qs[:2]: # Show first 2 questions per iteration + print(f" - {q}") + + return result + + +def demonstrate_focused_iteration_strategy(): + """ + Focused-iteration strategy: + - Iteratively refines the research based on previous findings + - Adapts questions based on what's been learned + - Best for: Deep dives, evolving research questions, exploratory research + """ + print("\n" + "=" * 70) + print("FOCUSED-ITERATION STRATEGY") + print("=" * 70) + print(""" +This strategy: +- Starts with initial research on the topic +- Analyzes findings to generate more targeted questions +- Iteratively refines understanding through multiple rounds +- Ideal for complex topics requiring deep exploration + """) + + # Configure settings + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", + } + ) + + # Run research with focused-iteration strategy + result = quick_summary( + query="How do neural networks learn?", + settings_snapshot=settings, + search_strategy="focused-iteration", # Use focused iteration + iterations=3, # More iterations for deeper exploration + questions_per_iteration=2, # Fewer but more focused questions + temperature=0.7, # Slightly higher for creative question generation + programmatic_mode=True, + ) + + print("\nSummary (first 500 chars):") + print(result["summary"][:500] + "...") + + # Show how questions evolved + questions = result.get("questions", {}) + if questions: + print("\nQuestion evolution across iterations:") + for iteration, qs in questions.items(): + print(f"\n Iteration {iteration}:") + for q in qs: + print(f" - {q}") + + # Show findings + findings = result.get("findings", []) + print(f"\nKey findings: {len(findings)}") + if findings: + print("\nFirst 2 findings:") + for i, finding in enumerate(findings[:2], 1): + text = ( + finding.get("text", "N/A") + if isinstance(finding, dict) + else str(finding) + ) + print(f" {i}. {text[:150]}...") + + return result + + +def compare_strategies(): + """ + Direct comparison of both strategies on the same topic. + """ + print("\n" + "=" * 70) + print("STRATEGY COMPARISON") + print("=" * 70) + print( + "\nComparing both strategies on the same topic: 'Quantum Computing Applications'\n" + ) + + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", + } + ) + + # Same topic, different strategies + topic = "Quantum computing applications in cryptography" + + print("1. Source-based approach:") + source_result = quick_summary( + query=topic, + settings_snapshot=settings, + search_strategy="source-based", + iterations=2, + questions_per_iteration=3, + programmatic_mode=True, + ) + print(f" - Sources found: {len(source_result.get('sources', []))}") + print(f" - Summary length: {len(source_result.get('summary', ''))} chars") + print(f" - Findings: {len(source_result.get('findings', []))}") + + print("\n2. Focused-iteration approach:") + focused_result = quick_summary( + query=topic, + settings_snapshot=settings, + search_strategy="focused-iteration", + iterations=2, + questions_per_iteration=3, + programmatic_mode=True, + ) + print(f" - Sources found: {len(focused_result.get('sources', []))}") + print( + f" - Summary length: {len(focused_result.get('summary', ''))} chars" + ) + print(f" - Findings: {len(focused_result.get('findings', []))}") + + print("\n" + "=" * 70) + print("WHEN TO USE EACH STRATEGY") + print("=" * 70) + print(""" +Use SOURCE-BASED when you need: +- Comprehensive coverage with citations +- Academic or professional research +- Fact-checking and verification +- Documentation with source tracking + +Use FOCUSED-ITERATION when you need: +- Deep exploration of complex topics +- Adaptive research that evolves +- Discovery of unexpected connections +- Exploratory or investigative research + """) + + +def main(): + """Run all demonstrations.""" + print("=" * 70) + print("LOCAL DEEP RESEARCH - SEARCH STRATEGIES DEMONSTRATION") + print("=" * 70) + + # Demonstrate each strategy + demonstrate_source_based_strategy() + demonstrate_focused_iteration_strategy() + + # Compare strategies + compare_strategies() + + print("\n✓ Search strategies demonstration complete!") + print("\nNote: Both strategies can be combined with different search tools") + print( + "(wikipedia, arxiv, searxng, etc.) and custom parameters for optimal results." + ) + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/searxng_example.py+176 −0 added@@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Example of using SearXNG search engine with Local Deep Research. + +This demonstrates how to use SearXNG for web search in programmatic mode. +Note: Requires a running SearXNG instance. +""" + +import os +from langchain_ollama import ChatOllama +from local_deep_research.search_system import AdvancedSearchSystem +from local_deep_research.web_search_engines.engines.search_engine_searxng import ( + SearXNGSearchEngine, +) + +# Re-enable logging +from loguru import logger +import sys + +logger.remove() +logger.add(sys.stderr, level="INFO", format="{time} {level} {message}") +logger.enable("local_deep_research") + + +def main(): + """Demonstrate using SearXNG with Local Deep Research.""" + print("=== SearXNG Search Engine Example ===\n") + + # Check if SearXNG URL is configured + searxng_url = os.getenv("SEARXNG_URL", "http://localhost:8080") + print(f"Using SearXNG instance at: {searxng_url}") + print( + "(Set SEARXNG_URL environment variable to use a different instance)\n" + ) + + # 1. Create LLM + print("1. Setting up Ollama LLM...") + llm = ChatOllama(model="gemma3:12b", temperature=0.3) + + # 2. Configure settings + settings = { + "search.iterations": 2, + "search.questions_per_iteration": 3, + "search.strategy": "source-based", + "rate_limiting.enabled": False, # Disable rate limiting for demo + # SearXNG specific settings + "search_engines.searxng.base_url": searxng_url, + "search_engines.searxng.timeout": 30, + "search_engines.searxng.categories": ["general", "science"], + "search_engines.searxng.engines": ["google", "duckduckgo", "bing"], + "search_engines.searxng.language": "en", + "search_engines.searxng.time_range": "", # all time + "search_engines.searxng.safesearch": 0, # 0=off, 1=moderate, 2=strict + } + + # 3. Create SearXNG search engine + print("2. Initializing SearXNG search engine...") + try: + search_engine = SearXNGSearchEngine(settings_snapshot=settings) + + # Test the connection + print(" Testing SearXNG connection...") + test_results = search_engine.run("test query", research_context={}) + if test_results: + print( + f" ✓ SearXNG is working! Got {len(test_results)} test results." + ) + else: + print(" ⚠ SearXNG returned no results for test query.") + except Exception as e: + print(f"\n⚠ Error connecting to SearXNG: {e}") + print("\nPlease ensure SearXNG is running. You can start it with:") + print(" docker run -p 8888:8080 searxng/searxng") + print("\nFalling back to mock search engine for demonstration...") + + # Fallback to mock search engine + class MockSearchEngine: + def __init__(self, settings_snapshot=None): + self.settings_snapshot = settings_snapshot or {} + + def run(self, query, research_context=None): + return [ + { + "title": f"Result for: {query}", + "link": "https://example.com/result", + "snippet": f"This is a mock result for the query: {query}. " + "In a real scenario, SearXNG would provide actual web search results.", + "full_content": "Full content would be fetched here...", + "rank": 1, + } + ] + + search_engine = MockSearchEngine(settings) + + # 4. Create the search system + print("3. Creating AdvancedSearchSystem...") + # Pass programmatic_mode=True to disable database dependencies + search_system = AdvancedSearchSystem( + llm=llm, + search=search_engine, + settings_snapshot=settings, + programmatic_mode=True, + ) + + # 5. Run research queries + queries = [ + "What are the latest developments in quantum computing in 2024?", + "How does CRISPR gene editing technology work?", + ] + + for query in queries: + print(f"\n{'=' * 60}") + print(f"Research Query: {query}") + print("=" * 60) + + try: + result = search_system.analyze_topic(query) + + # Display results + print("\n=== RESEARCH FINDINGS ===") + if result.get("formatted_findings"): + print(result["formatted_findings"]) + else: + print( + "Summary:", result.get("current_knowledge", "No findings") + ) + + # Show metadata + print("\n=== METADATA ===") + print(f"• Iterations completed: {result.get('iterations', 0)}") + print(f"• Total findings: {len(result.get('findings', []))}") + + # Show search sources from all_links_of_system or search_results in findings + all_links = result.get("all_links_of_system", []) + + # Also check findings for search_results + for finding in result.get("findings", []): + if "search_results" in finding and finding["search_results"]: + all_links = finding["search_results"] + break + + if all_links: + print(f"• Sources found: {len(all_links)}") + for i, link in enumerate( + all_links[:5], 1 + ): # Show first 5 sources + if isinstance(link, dict): + title = link.get("title", "No title") + url = link.get("link", "Unknown") + print(f" [{i}] {title}") + print(f" {url}") + + # Show generated questions + if result.get("questions_by_iteration"): + print("\n=== RESEARCH QUESTIONS ===") + for iteration, questions in result[ + "questions_by_iteration" + ].items(): + print(f"Iteration {iteration}:") + for q in questions[ + :2 + ]: # Show first 2 questions per iteration + print(f" • {q}") + + except Exception as e: + logger.exception("Error during research") + print(f"\n⚠ Error: {e}") + + print("\n✓ SearXNG integration example completed!") + print( + "\nNote: For best results, ensure SearXNG is properly configured with multiple search engines." + ) + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/simple_programmatic_example.py+35 −7 modified@@ -5,11 +5,33 @@ Quick example showing how to use the LDR Python API directly. """ -from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api import ( + detailed_research, + quick_summary, + generate_report, +) +from local_deep_research.api.settings_utils import ( + create_settings_snapshot, +) + +# Use default settings with minimal overrides +# This provides all necessary settings with sensible defaults +settings_snapshot = create_settings_snapshot( + overrides={ + "search.tool": "wikipedia", # Use Wikipedia for this example + } +) + +# Alternative: Use completely default settings +# settings_snapshot = get_default_settings_snapshot() # Example 1: Quick Summary print("=== Quick Summary ===") -result = quick_summary("What is machine learning?") +result = quick_summary( + "What is machine learning?", + settings_snapshot=settings_snapshot, + programmatic_mode=True, +) print(f"Summary: {result['summary'][:300]}...") print(f"Found {len(result.get('findings', []))} findings") @@ -20,6 +42,8 @@ iterations=2, search_tool="wikipedia", search_strategy="source_based", + settings_snapshot=settings_snapshot, + programmatic_mode=True, ) print(f"Research ID: {result['research_id']}") print(f"Summary length: {len(result['summary'])} characters") @@ -35,23 +59,27 @@ temperature=0.5, # Lower temperature for focused results provider="openai_endpoint", # Specify LLM provider model_name="llama-3.3-70b-instruct", # Specify model + settings_snapshot=settings_snapshot, + programmatic_mode=True, ) print(f"Completed {result['iterations']} iterations") print( f"Generated {sum(len(qs) for qs in result.get('questions', {}).values())} questions" ) # Example 4: Generate and Save a Report -print("\n=== Generate Report (Optional - Uncomment to run) ===") +print("\n=== Generate Report ===") print("Note: Report generation can take several minutes") -# Uncomment the following to generate a full report: -""" + +# Generate a comprehensive report report = generate_report( query="Future of artificial intelligence", output_file="ai_future_report.md", # Save directly to file searches_per_section=2, - iterations=1 + iterations=1, + settings_snapshot=settings_snapshot, # Now works with programmatic mode! ) print(f"Report saved to: {report.get('file_path', 'ai_future_report.md')}") print(f"Report length: {len(report['content'])} characters") -""" +print("Report preview (first 300 chars):") +print(report["content"][:300] + "...")
examples/api_usage/programmatic/test_direct_import.py+30 −0 added@@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Test importing search_system directly without going through __init__.py""" + +import sys + +# Try importing the search_system module directly +try: + print("Attempting to import search_system module directly...") + from local_deep_research import search_system + + print("✓ search_system module imported!") + + # Now try to access AdvancedSearchSystem + print("\nTrying to access AdvancedSearchSystem class...") + AdvancedSearchSystem = search_system.AdvancedSearchSystem + print("✓ Got AdvancedSearchSystem class!") + +except Exception as e: + print(f"✗ Failed: {e}") + import traceback + + traceback.print_exc() + +# Also try a more direct import +try: + print("\nAttempting direct file import...") + sys.path.insert(0, "src") + print("✓ Direct import worked!") +except Exception as e: + print(f"✗ Direct import failed: {e}")
examples/api_usage/README.md+96 −12 modified@@ -2,14 +2,22 @@ This directory contains examples for using LDR through different interfaces. +## Important: Authentication Required (v2.0+) + +Since LDR v2.0, all API access requires authentication due to per-user encrypted databases. You must: + +1. Create a user account through the web interface +2. Authenticate before making API calls +3. Pass settings_snapshot for programmatic access + ## Directory Structure - **`programmatic/`** - Direct Python API usage (import from `local_deep_research.api`) - `programmatic_access.ipynb` - Jupyter notebook with comprehensive examples - `retriever_usage_example.py` - Using LangChain retrievers with LDR - **`http/`** - HTTP REST API usage (requires running server) - - `simple_http_example.py` - Quick start example + - `simple_http_example.py` - Quick start example (needs updating for auth) - `http_api_examples.py` - Comprehensive examples including batch processing ## Quick Start @@ -18,27 +26,52 @@ This directory contains examples for using LDR through different interfaces. ```python from local_deep_research.api import quick_summary - -result = quick_summary("What is quantum computing?") -print(result["summary"]) +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +# Authenticate and get settings +with get_user_db_session(username="your_username", password="your_password") as session: + settings_manager = CachedSettingsManager(session, "your_username") + settings_snapshot = settings_manager.get_all_settings() + + # Use the API + result = quick_summary( + "What is quantum computing?", + settings_snapshot=settings_snapshot + ) + print(result["summary"]) ``` ### HTTP API (REST) First, start the server: ```bash -python -m src.local_deep_research.web.app +python -m local_deep_research.web.app ``` -Then use the API: +Then authenticate and use the API: ```python import requests -response = requests.post( - "http://localhost:5000/api/v1/quick_summary", - json={"query": "What is quantum computing?"} +# Create session for cookie persistence +session = requests.Session() + +# Login +session.post( + "http://localhost:5000/auth/login", + json={"username": "your_username", "password": "your_password"} +) + +# Get CSRF token +csrf_token = session.get("http://localhost:5000/auth/csrf-token").json()["csrf_token"] + +# Make API request +response = session.post( + "http://localhost:5000/research/api/start", + json={"query": "What is quantum computing?"}, + headers={"X-CSRF-Token": csrf_token} ) -print(response.json()["summary"]) +print(response.json()) ``` ## Which API Should I Use? @@ -48,19 +81,63 @@ print(response.json()["summary"]) - ✅ Full access to all features and parameters - ✅ Can pass Python objects (like LangChain retrievers) - ❌ Requires LDR to be installed in your environment + - ❌ Requires database session and settings snapshot - **HTTP API**: Use when accessing LDR from other languages or remote systems - ✅ Language agnostic - works with any HTTP client - ✅ Can run LDR on a separate server - ✅ Easy to scale and deploy - ❌ Limited to JSON-serializable parameters - ❌ Requires running the web server + - ❌ Requires authentication and CSRF tokens + +## API Changes in v2.0 + +### Breaking Changes + +1. **Authentication Required**: All endpoints now require login +2. **Settings Snapshot**: Programmatic API needs `settings_snapshot` parameter +3. **New Endpoints**: API routes moved (e.g., `/api/v1/quick_summary` → `/research/api/start`) +4. **CSRF Protection**: POST/PUT/DELETE requests need CSRF token + +### Migration Guide + +#### Old (v1.x): +```python +# Programmatic +from local_deep_research.api import quick_summary +result = quick_summary("query") + +# HTTP +curl -X POST http://localhost:5000/api/v1/quick_summary \ + -d '{"query": "test"}' +``` + +#### New (v2.0+): +```python +# Programmatic - with authentication and settings +with get_user_db_session(username, password) as session: + settings_manager = CachedSettingsManager(session, username) + settings_snapshot = settings_manager.get_all_settings() + result = quick_summary("query", settings_snapshot=settings_snapshot) + +# HTTP - with authentication and CSRF +# See examples above +``` ## Running the Examples +### Prerequisites + +1. Install LDR: `pip install local-deep-research` +2. Create a user account: + - Start server: `python -m local_deep_research.web.app` + - Open http://localhost:5000 and register +3. Configure your LLM provider in settings + ### Programmatic Examples ```bash -# Run the retriever example +# Update credentials in the example files first! python examples/api_usage/programmatic/retriever_usage_example.py # Or use the Jupyter notebook @@ -70,9 +147,16 @@ jupyter notebook examples/api_usage/programmatic/programmatic_access.ipynb ### HTTP Examples ```bash # First, start the LDR server -python -m src.local_deep_research.web.app +python -m local_deep_research.web.app # In another terminal, run the examples +# Note: These need to be updated for v2.0 authentication! python examples/api_usage/http/simple_http_example.py python examples/api_usage/http/http_api_examples.py ``` + +## Need Help? + +- See the [API Quick Start Guide](../../docs/api-quickstart.md) +- Check the [FAQ](../../docs/faq.md) +- Join our [Discord](https://discord.gg/ttcqQeFcJ3) for support
examples/api_usage/UPGRADE_NOTICE.md+65 −0 added@@ -0,0 +1,65 @@ +# Important: Examples Updated for LDR v1.0 + +## Authentication Required + +Starting with LDR v1.0, all API access requires authentication due to the new per-user encrypted database architecture. + +## Updated Examples + +The following examples have been updated for v1.0: + +### ✅ Updated Examples: +- `http/simple_http_example.py` - Basic HTTP API usage with authentication +- `http/http_api_examples.py` - Comprehensive HTTP API examples with LDRClient class +- `programmatic/retriever_usage_example.py` - LangChain retriever integration with auth +- `programmatic/programmatic_access_v1.py` - NEW: Complete programmatic API examples + +### ⚠️ Needs Manual Update: +- `programmatic/programmatic_access.ipynb` - Jupyter notebook (see programmatic_access_v1.py for reference) + +## Quick Migration Guide + +### Old Code (pre-v1.0): +```python +from local_deep_research.api import quick_summary +result = quick_summary("query") +``` + +### New Code (v1.0+): +```python +from local_deep_research.api import quick_summary +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +with get_user_db_session(username="user", password="pass") as session: + settings_manager = CachedSettingsManager(session, "user") + settings_snapshot = settings_manager.get_all_settings() + result = quick_summary("query", settings_snapshot=settings_snapshot) +``` + +## Before Running Examples + +1. **Create an account**: + ```bash + python -m local_deep_research.web.app + # Open http://localhost:5000 and register + ``` + +2. **Configure LLM provider** in Settings (e.g., OpenAI, Anthropic, Ollama) + +3. **Update credentials** in the example files: + - Change `USERNAME = "your_username"` to your actual username + - Change `PASSWORD = "your_password"` to your actual password + +## Common Issues + +- **"No settings context available"**: Pass `settings_snapshot` to API functions +- **"Encrypted database requires password"**: Use `get_user_db_session()` with credentials +- **"CSRF token missing"**: Get CSRF token before POST/PUT/DELETE requests +- **404 errors**: Check new endpoint paths (e.g., `/research/api/start`) + +## Need Help? + +- See [Migration Guide](../../docs/MIGRATION_GUIDE_v1.md) for detailed changes +- Check [API Quick Start](../../docs/api-quickstart.md) for authentication details +- Join our [Discord](https://discord.gg/ttcqQeFcJ3) for support
examples/benchmarks/browsecomp/run_browsecomp_fixed_v2.py+9 −8 modified@@ -11,12 +11,13 @@ import random import re import sys +from pathlib import Path from typing import Optional import pandas as pd # Set up Python path -current_dir = os.path.dirname(os.path.abspath(__file__)) +current_dir = str(Path(__file__).parent.resolve()) sys.path.insert(0, current_dir) try: @@ -70,10 +71,10 @@ def decrypt(ciphertext_b64: str, password: str) -> str: try: encrypted = base64.b64decode(ciphertext_b64) key = derive_key(password, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes(a ^ b for a, b in zip(encrypted, key, strict=False)) return decrypted.decode() except Exception as e: - print(f"Error decrypting data: {str(e)}") + print(f"Error decrypting data: {e!s}") return f"Error: Could not decrypt data: {str(e)[:100]}" @@ -91,8 +92,8 @@ def run_browsecomp_evaluation( Run the BrowseComp evaluation using Local Deep Research. """ # Ensure output directory exists - os.makedirs(output_dir, exist_ok=True) - output_path = os.path.join(output_dir, output_file) + Path(output_dir).mkdir(parents=True, exist_ok=True) + output_path = str(Path(output_dir) / output_file) # Load BrowseComp dataset print(f"Loading dataset from {dataset_path}") @@ -113,7 +114,7 @@ def run_browsecomp_evaluation( print(f"Sampled {num_examples} examples from {len(df)} total examples") # Remove output file if it exists to avoid appending - if os.path.exists(output_path): + if Path(output_path).exists(): os.remove(output_path) results = [] @@ -216,7 +217,7 @@ def run_browsecomp_evaluation( ) except Exception as e: - print(f"Error processing question {i + 1}: {str(e)}") + print(f"Error processing question {i + 1}: {e!s}") # In case of error, write a placeholder result result = { "id": example.get("id", f"q{i}"), @@ -245,7 +246,7 @@ def run_browsecomp_evaluation( "search_tool": search_tool, } - report_path = os.path.join(output_dir, "browsecomp_summary.json") + report_path = str(Path(output_dir) / "browsecomp_summary.json") with open(report_path, "w") as f: json.dump(report, f, indent=2)
examples/benchmarks/claude_grading/benchmark.py+43 −61 modified@@ -12,84 +12,70 @@ - Provides detailed metrics and accuracy reports """ -import logging import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path + # Set up Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "src")) +src_dir = str((Path(__file__).parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") +# Note: Database configuration is now per-user +# For benchmarks, API keys should be provided via environment variables +# or configuration files rather than relying on a shared database -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Logger is already imported from loguru def setup_grading_config(): """ - Create a custom evaluation configuration that uses the local database + Create a custom evaluation configuration that uses environment variables for API keys and specifically uses Claude 3 Sonnet for grading. Returns: Dict containing the evaluation configuration """ - # Import necessary function to get database settings - try: - from local_deep_research.utilities.db_utils import get_db_setting - except ImportError as e: - print(f"Error importing database utilities: {e}") - print("Current sys.path:", sys.path) - return None # Create config that uses Claude 3 Sonnet via Anthropic directly - # This will use the API key from the database # Only use parameters that get_llm() accepts evaluation_config = { "model_name": "claude-3-sonnet-20240229", # Correct Anthropic model name "provider": "anthropic", # Use Anthropic directly "temperature": 0, # Zero temp for consistent evaluation } - # Check if anthropic API key is available in the database - try: - anthropic_key = get_db_setting("llm.anthropic.api_key") - if anthropic_key: + # Check if anthropic API key is available in environment + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + if anthropic_key: + print( + "Found Anthropic API key in environment, will use Claude 3 Sonnet for grading" + ) + else: + print( + "Warning: No Anthropic API key found in ANTHROPIC_API_KEY environment variable" + ) + print("Checking for alternative providers...") + + # Try OpenRouter as a fallback + openrouter_key = os.environ.get("OPENROUTER_API_KEY") + if openrouter_key: print( - "Found Anthropic API key in database, will use Claude 3 Sonnet for grading" + "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" ) + evaluation_config = { + "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format + "provider": "openai_endpoint", + "openai_endpoint_url": "https://openrouter.ai/api/v1", + "temperature": 0, + } else: - print("Warning: No Anthropic API key found in database") - print("Checking for alternative providers...") - - # Try OpenRouter as a fallback - openrouter_key = get_db_setting("llm.openai_endpoint.api_key") - if openrouter_key: - print( - "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" - ) - evaluation_config = { - "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format - "provider": "openai_endpoint", - "openai_endpoint_url": "https://openrouter.ai/api/v1", - "temperature": 0, - } - except Exception as e: - print(f"Error checking for API keys: {e}") + print("ERROR: No API keys found in environment variables") + print("Please set either ANTHROPIC_API_KEY or OPENROUTER_API_KEY") + return None return evaluation_config @@ -159,11 +145,9 @@ def custom_get_evaluation_llm(custom_config=None): traceback.print_exc() # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "benchmark_results", f"claude_grading_{timestamp}" - ) - os.makedirs(output_dir, exist_ok=True) + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str(Path("benchmark_results") / f"claude_grading_{timestamp}") + Path(output_dir).mkdir(parents=True, exist_ok=True) config = { "search_strategy": strategy, @@ -190,7 +174,7 @@ def custom_get_evaluation_llm(custom_config=None): simpleqa_results = simpleqa.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) simpleqa_duration = time.time() - simpleqa_start @@ -203,7 +187,7 @@ def custom_get_evaluation_llm(custom_config=None): # Save results import json - with open(os.path.join(output_dir, "simpleqa_results.json"), "w") as f: + with open(Path(output_dir) / "simpleqa_results.json", "w") as f: json.dump(simpleqa_results, f, indent=2) except Exception as e: print(f"Error during SimpleQA evaluation: {e}") @@ -226,7 +210,7 @@ def custom_get_evaluation_llm(custom_config=None): browsecomp_results = browsecomp.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) browsecomp_duration = time.time() - browsecomp_start @@ -237,9 +221,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"BrowseComp metrics: {browsecomp_results.get('metrics', {})}") # Save results - with open( - os.path.join(output_dir, "browsecomp_results.json"), "w" - ) as f: + with open(Path(output_dir) / "browsecomp_results.json", "w") as f: json.dump(browsecomp_results, f, indent=2) except Exception as e: print(f"Error during BrowseComp evaluation: {e}") @@ -262,7 +244,7 @@ def custom_get_evaluation_llm(custom_config=None): composite_results = composite.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "composite"), + output_dir=str(Path(output_dir) / "composite"), ) composite_duration = time.time() - composite_start @@ -272,7 +254,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"Composite score: {composite_results.get('score', 0):.4f}") # Save results - with open(os.path.join(output_dir, "composite_results.json"), "w") as f: + with open(Path(output_dir) / "composite_results.json", "w") as f: json.dump(composite_results, f, indent=2) except Exception as e: print(f"Error during composite evaluation: {e}")
examples/benchmarks/gemini/run_gemini_benchmark_fixed.py+10 −8 modified@@ -3,10 +3,10 @@ Fixed benchmark with Gemini 2.0 Flash via OpenRouter """ -import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path # Import the benchmark functions from local_deep_research.benchmarks.benchmark_functions import ( @@ -62,11 +62,13 @@ def run_benchmark(examples=1): """Run benchmarks with Gemini 2.0 Flash""" try: # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "../../benchmark_results", f"gemini_eval_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path(__file__).parent.parent.parent + / "benchmark_results" + / f"gemini_eval_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) # Setup the Gemini configuration gemini_config = setup_gemini_config() @@ -82,7 +84,7 @@ def run_benchmark(examples=1): search_tool="searxng", evaluation_model=gemini_config["model_name"], evaluation_provider=gemini_config["provider"], - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) simpleqa_duration = time.time() - simpleqa_start @@ -110,7 +112,7 @@ def run_benchmark(examples=1): search_tool="searxng", evaluation_model=gemini_config["model_name"], evaluation_provider=gemini_config["provider"], - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) browsecomp_duration = time.time() - browsecomp_start
examples/benchmarks/run_browsecomp.py+12 −11 modified@@ -21,6 +21,7 @@ import re import sys import time +from pathlib import Path from typing import Any, Dict from loguru import logger @@ -44,11 +45,11 @@ def decrypt(ciphertext_b64: str, password: str) -> str: try: encrypted = base64.b64decode(ciphertext_b64) key = derive_key(password, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes(a ^ b for a, b in zip(encrypted, key, strict=False)) return decrypted.decode() except Exception as e: - logger.error(f"Error decrypting data: {str(e)}") - return f"Error: Could not decrypt data - {str(e)}" + logger.exception(f"Error decrypting data: {e!s}") + return f"Error: Could not decrypt data - {e!s}" def run_browsecomp_with_canary( @@ -83,16 +84,16 @@ def run_browsecomp_with_canary( # Set up output files timestamp = time.strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - output_dir, f"browsecomp_{timestamp}_results.jsonl" + results_file = str( + Path(output_dir) / f"browsecomp_{timestamp}_results.jsonl" ) - evaluation_file = os.path.join( - output_dir, f"browsecomp_{timestamp}_evaluation.jsonl" + evaluation_file = str( + Path(output_dir) / f"browsecomp_{timestamp}_evaluation.jsonl" ) # Make sure output files don't exist for file in [results_file, evaluation_file]: - if os.path.exists(file): + if Path(file).exists(): os.remove(file) # Process each example @@ -173,7 +174,7 @@ def run_browsecomp_with_canary( f.write(json.dumps(result) + "\n") except Exception as e: - logger.error(f"Error processing example {i + 1}: {str(e)}") + logger.exception(f"Error processing example {i + 1}: {e!s}") # Create error result error_result = { @@ -212,7 +213,7 @@ def run_browsecomp_with_canary( dataset_type="browsecomp", ) except Exception as e: - logger.error(f"Evaluation failed: {str(e)}") + logger.exception(f"Evaluation failed: {e!s}") evaluation_results = [] # Calculate basic metrics @@ -266,7 +267,7 @@ def main(): parser.add_argument( "--output-dir", type=str, - default=os.path.join("examples", "benchmarks", "results", "browsecomp"), + default=str(Path("examples") / "benchmarks" / "results" / "browsecomp"), help="Output directory", )
examples/benchmarks/run_gemini_benchmark.py+9 −6 modified@@ -17,6 +17,7 @@ import os import time from datetime import datetime +from pathlib import Path # Import the benchmark functionality from local_deep_research.benchmarks.benchmark_functions import ( @@ -61,11 +62,13 @@ def run_benchmark(args): os.environ["LDR_LLM__OPENAI_ENDPOINT_URL"] = config["openai_endpoint_url"] # Create timestamp for output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - base_output_dir = os.path.join( - "examples", "benchmarks", "results", f"gemini_{timestamp}" + from datetime import timezone + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + base_output_dir = str( + Path("examples") / "benchmarks" / "results" / f"gemini_{timestamp}" ) - os.makedirs(base_output_dir, exist_ok=True) + Path(base_output_dir).mkdir(parents=True, exist_ok=True) # Configure benchmark settings results = {} @@ -76,7 +79,7 @@ def run_benchmark(args): { "name": "SimpleQA", "function": evaluate_simpleqa, - "output_dir": os.path.join(base_output_dir, "simpleqa"), + "output_dir": str(Path(base_output_dir) / "simpleqa"), } ) @@ -85,7 +88,7 @@ def run_benchmark(args): { "name": "BrowseComp", "function": evaluate_browsecomp, - "output_dir": os.path.join(base_output_dir, "browsecomp"), + "output_dir": str(Path(base_output_dir) / "browsecomp"), } )
examples/benchmarks/run_resumable_parallel_benchmark.py+32 −31 modified@@ -20,7 +20,7 @@ import os import sys import time -from datetime import datetime +from datetime import datetime, UTC from pathlib import Path from typing import Any, Dict, Optional, Tuple @@ -38,19 +38,16 @@ ) from local_deep_research.benchmarks.runners import format_query - # Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) +project_root = str(Path(__file__).parent.parent.parent.resolve()) logger.enable("local_deep_research") def load_existing_results(results_file: str) -> Dict[str, Dict]: """Load existing results from JSONL file.""" results = {} - if os.path.exists(results_file): + if Path(results_file).exists(): logger.info(f"Loading existing results from: {results_file}") with open(results_file, "r") as f: for line in f: @@ -74,8 +71,8 @@ def find_latest_results_file( ) -> Optional[str]: """Find the most recent results file for a dataset.""" # First try dataset subdirectory - dataset_dir = os.path.join(output_dir, dataset_type) - if os.path.exists(dataset_dir): + dataset_dir = str(Path(output_dir) / dataset_type) + if Path(dataset_dir).exists(): pattern = f"{dataset_type}_*_results.jsonl" files = list(Path(dataset_dir).glob(pattern)) if files: @@ -112,15 +109,15 @@ def run_resumable_benchmark( ) # Determine output files - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_results.jsonl" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + results_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_results.jsonl" ) - evaluation_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_evaluation.jsonl" + evaluation_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_evaluation.jsonl" ) - report_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_report.md" + report_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_report.md" ) # Load existing results if resuming @@ -222,7 +219,7 @@ def run_resumable_benchmark( f.write(json.dumps(result) + "\n") except Exception as e: - logger.error(f"Error processing example {i + 1}: {str(e)}") + logger.exception("Error processing example") error_count += 1 # Create error result @@ -274,7 +271,7 @@ def run_resumable_benchmark( "errors": error_count, } except Exception as e: - logger.error(f"Error during evaluation: {str(e)}") + logger.exception("Error during evaluation") return { "accuracy": 0, "metrics": {}, @@ -298,7 +295,7 @@ def run_simpleqa_benchmark_wrapper(args: Tuple) -> Dict[str, Any]: results = run_resumable_benchmark( dataset_type="simpleqa", num_examples=num_examples, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), search_config=search_config, evaluation_config=evaluation_config, resume_from=resume_from, @@ -325,7 +322,7 @@ def run_browsecomp_benchmark_wrapper(args: Tuple) -> Dict[str, Any]: results = run_resumable_benchmark( dataset_type="browsecomp", num_examples=num_examples, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), search_config=browsecomp_config, evaluation_config=evaluation_config, resume_from=resume_from, @@ -411,19 +408,23 @@ def main(): # Determine output directory if args.resume_from: # Create new directory but link to old results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - project_root, "benchmark_results", f"resumed_benchmark_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path(project_root) + / "benchmark_results" + / f"resumed_benchmark_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) logger.info( f"Resuming from {args.resume_from}, new results in {output_dir}" ) else: # Create new timestamp directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - project_root, "benchmark_results", f"parallel_benchmark_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path(project_root) + / "benchmark_results" + / f"parallel_benchmark_{timestamp}" ) os.makedirs(output_dir, exist_ok=True) logger.info(f"Starting new benchmark in: {output_dir}") @@ -516,8 +517,8 @@ def main(): print( f"BrowseComp benchmark completed: {result['new_results']} new, {result['reused_results']} reused" ) - except Exception as e: - logger.error(f"Error in {dataset_name} benchmark: {e}") + except Exception: + logger.exception("Error in benchmark") # Calculate total time total_duration = time.time() - total_start_time @@ -566,12 +567,12 @@ def main(): } with open( - os.path.join(output_dir, "parallel_benchmark_summary.json"), "w" + Path(output_dir) / "parallel_benchmark_summary.json", "w" ) as f: json.dump(summary, f, indent=2) - except Exception as e: - logger.error(f"Error saving summary: {e}") + except Exception: + logger.exception("Error saving summary") return 0
examples/benchmarks/run_simpleqa.py+2 −2 modified@@ -14,8 +14,8 @@ """ import argparse -import os import sys +from pathlib import Path # Import the benchmark functionality from local_deep_research.benchmarks.benchmark_functions import evaluate_simpleqa @@ -39,7 +39,7 @@ def main(): parser.add_argument( "--output-dir", type=str, - default=os.path.join("examples", "benchmarks", "results", "simpleqa"), + default=str(Path("examples") / "benchmarks" / "results" / "simpleqa"), help="Output directory", ) parser.add_argument(
examples/benchmarks/scripts/run_benchmark_with_claude_grading.py+43 −62 modified@@ -12,84 +12,69 @@ - Provides detailed metrics and accuracy reports """ -import logging import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path + # Set up Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "src")) +src_dir = str((Path(__file__).parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") +# Note: Database configuration is now per-user +# For benchmarks, API keys should be provided via environment variables +# or configuration files rather than relying on a shared database -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Logger is already imported from loguru def setup_grading_config(): """ - Create a custom evaluation configuration that uses the local database + Create a custom evaluation configuration that uses environment variables for API keys and specifically uses Claude 3 Sonnet for grading. Returns: Dict containing the evaluation configuration """ - # Import necessary function to get database settings - try: - from local_deep_research.utilities.db_utils import get_db_setting - except ImportError as e: - print(f"Error importing database utilities: {e}") - print("Current sys.path:", sys.path) - return None - # Create config that uses Claude 3 Sonnet via Anthropic directly - # This will use the API key from the database # Only use parameters that get_llm() accepts evaluation_config = { "model_name": "claude-3-sonnet-20240229", # Correct Anthropic model name "provider": "anthropic", # Use Anthropic directly "temperature": 0, # Zero temp for consistent evaluation } - # Check if anthropic API key is available in the database - try: - anthropic_key = get_db_setting("llm.anthropic.api_key") - if anthropic_key: + # Check if anthropic API key is available in environment + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + if anthropic_key: + print( + "Found Anthropic API key in environment, will use Claude 3 Sonnet for grading" + ) + else: + print( + "Warning: No Anthropic API key found in ANTHROPIC_API_KEY environment variable" + ) + print("Checking for alternative providers...") + + # Try OpenRouter as a fallback + openrouter_key = os.environ.get("OPENROUTER_API_KEY") + if openrouter_key: print( - "Found Anthropic API key in database, will use Claude 3 Sonnet for grading" + "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" ) + evaluation_config = { + "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format + "provider": "openai_endpoint", + "openai_endpoint_url": "https://openrouter.ai/api/v1", + "temperature": 0, + } else: - print("Warning: No Anthropic API key found in database") - print("Checking for alternative providers...") - - # Try OpenRouter as a fallback - openrouter_key = get_db_setting("llm.openai_endpoint.api_key") - if openrouter_key: - print( - "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" - ) - evaluation_config = { - "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format - "provider": "openai_endpoint", - "openai_endpoint_url": "https://openrouter.ai/api/v1", - "temperature": 0, - } - except Exception as e: - print(f"Error checking for API keys: {e}") + print("ERROR: No API keys found in environment variables") + print("Please set either ANTHROPIC_API_KEY or OPENROUTER_API_KEY") + return None return evaluation_config @@ -159,11 +144,9 @@ def custom_get_evaluation_llm(custom_config=None): traceback.print_exc() # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "benchmark_results", f"claude_grading_{timestamp}" - ) - os.makedirs(output_dir, exist_ok=True) + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str(Path("benchmark_results") / f"claude_grading_{timestamp}") + Path(output_dir).mkdir(parents=True, exist_ok=True) config = { "search_strategy": strategy, @@ -190,7 +173,7 @@ def custom_get_evaluation_llm(custom_config=None): simpleqa_results = simpleqa.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) simpleqa_duration = time.time() - simpleqa_start @@ -203,7 +186,7 @@ def custom_get_evaluation_llm(custom_config=None): # Save results import json - with open(os.path.join(output_dir, "simpleqa_results.json"), "w") as f: + with open(Path(output_dir) / "simpleqa_results.json", "w") as f: json.dump(simpleqa_results, f, indent=2) except Exception as e: print(f"Error during SimpleQA evaluation: {e}") @@ -226,7 +209,7 @@ def custom_get_evaluation_llm(custom_config=None): browsecomp_results = browsecomp.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) browsecomp_duration = time.time() - browsecomp_start @@ -237,9 +220,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"BrowseComp metrics: {browsecomp_results.get('metrics', {})}") # Save results - with open( - os.path.join(output_dir, "browsecomp_results.json"), "w" - ) as f: + with open(Path(output_dir) / "browsecomp_results.json", "w") as f: json.dump(browsecomp_results, f, indent=2) except Exception as e: print(f"Error during BrowseComp evaluation: {e}") @@ -262,7 +243,7 @@ def custom_get_evaluation_llm(custom_config=None): composite_results = composite.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "composite"), + output_dir=str(Path(output_dir) / "composite"), ) composite_duration = time.time() - composite_start @@ -272,7 +253,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"Composite score: {composite_results.get('score', 0):.4f}") # Save results - with open(os.path.join(output_dir, "composite_results.json"), "w") as f: + with open(Path(output_dir) / "composite_results.json", "w") as f: json.dump(composite_results, f, indent=2) except Exception as e: print(f"Error during composite evaluation: {e}")
examples/benchmarks/scripts/run_focused_benchmark_fixed.py+42 −61 modified@@ -9,84 +9,67 @@ accesses the database for API keys, and uses Claude Anthropic 3.7 for grading. """ -import logging import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path + # Set up Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "src")) +src_dir = str((Path(__file__).parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Use environment variables for configuration +# The system should be configured with proper environment variables: +# - ANTHROPIC_API_KEY for Anthropic API access +# - OPENROUTER_API_KEY for OpenRouter API access (if used) +# - LDR_DATA_DIR for data directory location (if needed) +data_dir = os.environ.get("LDR_DATA_DIR", str(Path(src_dir) / "data")) def setup_grading_config(): """ - Create a custom evaluation configuration that uses the local database + Create a custom evaluation configuration that uses environment variables for API keys and specifically uses Claude Anthropic 3.7 Sonnet for grading. Returns: Dict containing the evaluation configuration """ - # Import necessary function to get database settings - try: - from local_deep_research.utilities.db_utils import get_db_setting - except ImportError as e: - print(f"Error importing database utilities: {e}") - print("Current sys.path:", sys.path) - return None + # No need to import database utilities anymore # Create config that uses Claude 3 Sonnet via Anthropic directly - # This will use the API key from the database + # This will use the API key from environment variables # Only use parameters that get_llm() accepts evaluation_config = { "model_name": "claude-3-sonnet-20240229", # Correct Anthropic model name "provider": "anthropic", # Use Anthropic directly "temperature": 0, # Zero temp for consistent evaluation } - # Check if anthropic API key is available in the database - try: - anthropic_key = get_db_setting("llm.anthropic.api_key") - if anthropic_key: + # Check if anthropic API key is available in environment + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + if anthropic_key: + print( + "Found Anthropic API key in environment, will use Claude 3.7 Sonnet for grading" + ) + else: + print("Warning: No Anthropic API key found in environment") + print("Checking for alternative providers...") + + # Try OpenRouter as a fallback + openrouter_key = os.environ.get("OPENROUTER_API_KEY") + if openrouter_key: print( - "Found Anthropic API key in database, will use Claude 3.7 Sonnet for grading" + "Found OpenRouter API key, will use OpenRouter with Claude 3.7 Sonnet" ) - else: - print("Warning: No Anthropic API key found in database") - print("Checking for alternative providers...") - - # Try OpenRouter as a fallback - openrouter_key = get_db_setting("llm.openai_endpoint.api_key") - if openrouter_key: - print( - "Found OpenRouter API key, will use OpenRouter with Claude 3.7 Sonnet" - ) - evaluation_config = { - "model_name": "anthropic/claude-3-7-sonnet", # OpenRouter format - "provider": "openai_endpoint", - "openai_endpoint_url": "https://openrouter.ai/api/v1", - "temperature": 0, - } - except Exception as e: - print(f"Error checking for API keys: {e}") + evaluation_config = { + "model_name": "anthropic/claude-3-7-sonnet", # OpenRouter format + "provider": "openai_endpoint", + "openai_endpoint_url": "https://openrouter.ai/api/v1", + "temperature": 0, + } return evaluation_config @@ -156,9 +139,9 @@ def custom_get_evaluation_llm(custom_config=None): traceback.print_exc() # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join("benchmark_results", f"direct_eval_{timestamp}") - os.makedirs(output_dir, exist_ok=True) + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str(Path("benchmark_results") / f"direct_eval_{timestamp}") + Path(output_dir).mkdir(parents=True, exist_ok=True) config = { "search_strategy": strategy, @@ -185,7 +168,7 @@ def custom_get_evaluation_llm(custom_config=None): simpleqa_results = simpleqa.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) simpleqa_duration = time.time() - simpleqa_start @@ -198,7 +181,7 @@ def custom_get_evaluation_llm(custom_config=None): # Save results import json - with open(os.path.join(output_dir, "simpleqa_results.json"), "w") as f: + with open(Path(output_dir) / "simpleqa_results.json", "w") as f: json.dump(simpleqa_results, f, indent=2) except Exception as e: print(f"Error during SimpleQA evaluation: {e}") @@ -221,7 +204,7 @@ def custom_get_evaluation_llm(custom_config=None): browsecomp_results = browsecomp.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) browsecomp_duration = time.time() - browsecomp_start @@ -232,9 +215,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"BrowseComp metrics: {browsecomp_results.get('metrics', {})}") # Save results - with open( - os.path.join(output_dir, "browsecomp_results.json"), "w" - ) as f: + with open(Path(output_dir) / "browsecomp_results.json", "w") as f: json.dump(browsecomp_results, f, indent=2) except Exception as e: print(f"Error during BrowseComp evaluation: {e}") @@ -257,7 +238,7 @@ def custom_get_evaluation_llm(custom_config=None): composite_results = composite.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "composite"), + output_dir=str(Path(output_dir) / "composite"), ) composite_duration = time.time() - composite_start @@ -267,7 +248,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"Composite score: {composite_results.get('score', 0):.4f}") # Save results - with open(os.path.join(output_dir, "composite_results.json"), "w") as f: + with open(Path(output_dir) / "composite_results.json", "w") as f: json.dump(composite_results, f, indent=2) except Exception as e: print(f"Error during composite evaluation: {e}")
examples/benchmarks/scripts/run_grader_only.py+38 −54 modified@@ -7,82 +7,66 @@ """ import argparse -import logging import os import sys import time +from pathlib import Path + # Set up Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "src")) +src_dir = str((Path(__file__).parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Use environment variables for configuration +# The system should be configured with proper environment variables: +# - ANTHROPIC_API_KEY for Anthropic API access +# - OPENROUTER_API_KEY for OpenRouter API access (if used) +# - LDR_DATA_DIR for data directory location (if needed) +data_dir = os.environ.get("LDR_DATA_DIR", str(Path(src_dir) / "data")) def setup_grading_config(): """ - Create a custom evaluation configuration that uses the local database + Create a custom evaluation configuration that uses environment variables for API keys and specifically uses Claude 3 Sonnet for grading. Returns: Dict containing the evaluation configuration """ - # Import necessary function to get database settings - try: - from local_deep_research.utilities.db_utils import get_db_setting - except ImportError as e: - print(f"Error importing database utilities: {e}") - print("Current sys.path:", sys.path) - return None + # No need to import database utilities anymore # Create config that uses Claude 3 Sonnet via Anthropic directly + # This will use the API key from environment variables # Only use parameters that get_llm() accepts evaluation_config = { "model_name": "claude-3-sonnet-20240229", # Correct Anthropic model name "provider": "anthropic", # Use Anthropic directly "temperature": 0, # Zero temp for consistent evaluation } - # Check if anthropic API key is available in the database - try: - anthropic_key = get_db_setting("llm.anthropic.api_key") - if anthropic_key: + # Check if anthropic API key is available in environment + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + if anthropic_key: + print( + "Found Anthropic API key in environment, will use Claude 3 Sonnet for grading" + ) + else: + print("Warning: No Anthropic API key found in environment") + print("Checking for alternative providers...") + + # Try OpenRouter as a fallback + openrouter_key = os.environ.get("OPENROUTER_API_KEY") + if openrouter_key: print( - "Found Anthropic API key in database, will use Claude 3 Sonnet for grading" + "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" ) - else: - print("Warning: No Anthropic API key found in database") - print("Checking for alternative providers...") - - # Try OpenRouter as a fallback - openrouter_key = get_db_setting("llm.openai_endpoint.api_key") - if openrouter_key: - print( - "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" - ) - evaluation_config = { - "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format - "provider": "openai_endpoint", - "openai_endpoint_url": "https://openrouter.ai/api/v1", - "temperature": 0, - } - except Exception as e: - print(f"Error checking for API keys: {e}") + evaluation_config = { + "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format + "provider": "openai_endpoint", + "openai_endpoint_url": "https://openrouter.ai/api/v1", + "temperature": 0, + } return evaluation_config @@ -142,12 +126,12 @@ def custom_get_evaluation_llm(custom_config=None): traceback.print_exc() # Create the evaluation output path - results_dir = os.path.dirname(results_path) - results_filename = os.path.basename(results_path) + results_dir = str(Path(results_path).parent) + results_filename = Path(results_path).name evaluation_filename = results_filename.replace( "_results.jsonl", "_evaluation.jsonl" ) - evaluation_path = os.path.join(results_dir, evaluation_filename) + evaluation_path = str(Path(results_dir) / evaluation_filename) # Run the grading print("Starting grading of benchmark results...") @@ -227,10 +211,10 @@ def generate_summary(evaluation_path, output_dir=None): # Determine output directory if output_dir is None: - output_dir = os.path.dirname(evaluation_path) + output_dir = str(Path(evaluation_path).parent) # Generate report - report_path = os.path.join(output_dir, "evaluation_report.md") + report_path = str(Path(output_dir) / "evaluation_report.md") generate_report( metrics=metrics, output_file=report_path, @@ -286,7 +270,7 @@ def main(): args = parser.parse_args() # Check if the results file exists - if not os.path.exists(args.results): + if not Path(args.results).exists(): print(f"Error: Results file not found: {args.results}") return 1
examples/detailed_report_how_to_improve_retrieval_augmented_generation_in_p.md+1800 −1800 modifiedexamples/elasticsearch_search_example.py+8 −11 modified@@ -3,27 +3,24 @@ 展示如何索引文档和搜索数据。(Demonstrates how to index documents and search data.) """ -import logging import sys from pathlib import Path +from loguru import logger + # 添加项目根目录到 Python 路径 (Add project root directory to Python path) sys.path.append(str(Path(__file__).parent.parent)) # Import after adding project root to path -from src.local_deep_research.utilities.es_utils import ( # noqa: E402 +from src.local_deep_research.utilities.es_utils import ( ElasticsearchManager, ) -from src.local_deep_research.web_search_engines.engines.search_engine_elasticsearch import ( # noqa: E402 +from src.local_deep_research.web_search_engines.engines.search_engine_elasticsearch import ( ElasticsearchSearchEngine, ) # 配置日志 (Configure logging) -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Loguru automatically handles logging configuration def index_sample_documents(): @@ -169,10 +166,10 @@ def main(): advanced_search_examples(index_name) except Exception as e: - logger.error( - f"运行示例时出错: {str(e)}" + logger.exception( + f"运行示例时出错: {e!s}" ) # Error running example: {str(e)} - logger.error( + logger.info( "请确保 Elasticsearch 正在运行,默认地址为 http://localhost:9200" ) # Make sure Elasticsearch is running, default address is http://localhost:9200
examples/example_browsecomp.py+6 −7 modified@@ -4,15 +4,14 @@ This helps debug issues with the BrowseComp dataset. """ -import logging import sys -# Configure logging -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger() +from loguru import logger + +# Logger is already imported from loguru +# Set debug level for this script +logger.remove() +logger.add(sys.stderr, level="DEBUG") # Add path to import local_deep_research sys.path.append(".")
examples/llm_integration/advanced_custom_llm.py+6 −5 modified@@ -9,14 +9,15 @@ """ import time -from typing import List, Optional, Any, Dict -from langchain_core.language_models import BaseChatModel -from langchain_core.messages import BaseMessage, AIMessage -from langchain_core.outputs import ChatResult, ChatGeneration +from typing import Any, Dict, List, Optional + from langchain_core.callbacks import CallbackManagerForLLMRun +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult from loguru import logger -from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api import detailed_research, quick_summary class RetryLLM(BaseChatModel):
examples/llm_integration/basic_custom_llm.py+6 −4 modified@@ -5,11 +5,13 @@ with LDR's research functions. """ +from typing import Any, List, Optional + from langchain_core.language_models import BaseChatModel -from langchain_core.messages import BaseMessage, AIMessage -from langchain_core.outputs import ChatResult, ChatGeneration -from typing import List, Optional, Any -from local_deep_research.api import quick_summary, detailed_research +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult + +from local_deep_research.api import detailed_research, quick_summary class CustomLLM(BaseChatModel):
examples/llm_integration/mock_llm_example.py+6 −5 modified@@ -9,13 +9,14 @@ - CI/CD pipelines """ -from typing import List, Optional, Any, Dict -from langchain_core.language_models import BaseChatModel -from langchain_core.messages import BaseMessage, AIMessage -from langchain_core.outputs import ChatResult, ChatGeneration import json +from typing import Any, Dict, List, Optional + +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult -from local_deep_research.api import quick_summary, generate_report +from local_deep_research.api import generate_report, quick_summary class MockLLM(BaseChatModel):
examples/optimization/browsecomp_optimization.py+14 −18 modified@@ -15,34 +15,30 @@ """ import json -import logging -import os import sys from datetime import datetime +from pathlib import Path + from local_deep_research.benchmarks.optimization import optimize_parameters # Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) - -# Configure logging to see progress -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) def main(): # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "examples", "optimization", "results", f"browsecomp_opt_{timestamp}" + from datetime import timezone + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"browsecomp_opt_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print( f"Starting BrowseComp optimization - results will be saved to {output_dir}" @@ -96,7 +92,7 @@ def main(): } with open( - os.path.join(output_dir, "browsecomp_optimization_summary.json"), "w" + Path(output_dir) / "browsecomp_optimization_summary.json", "w" ) as f: json.dump(summary, f, indent=2)
examples/optimization/example_multi_benchmark.py+12 −13 modified@@ -5,20 +5,19 @@ SimpleQA and BrowseComp benchmarks with custom weights. """ -import logging import os import sys from datetime import datetime +from pathlib import Path from typing import Any, Dict + # Print current directory and python path for debugging print(f"Current directory: {os.getcwd()}") print(f"Python path: {sys.path}") # Add appropriate paths -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) -) +sys.path.insert(0, str(Path(__file__).parent.parent.resolve())) try: # Try to import from the local module structure @@ -109,9 +108,7 @@ def optimize_for_speed(*args, **kwargs): }, 0.67 -# Set up logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +# Loguru automatically handles logging configuration def print_optimization_results(params: Dict[str, Any], score: float): @@ -129,7 +126,9 @@ def print_optimization_results(params: Dict[str, Any], score: float): def main(): """Run the multi-benchmark optimization examples.""" # Create a timestamp-based directory for results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + from datetime import timezone + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") output_dir = f"optimization_demo_{timestamp}" os.makedirs(output_dir, exist_ok=True) @@ -141,7 +140,7 @@ def main(): params1, score1 = optimize_parameters( query=query, n_trials=3, # Using a small number for quick demonstration - output_dir=os.path.join(output_dir, "simpleqa_only"), + output_dir=str(Path(output_dir) / "simpleqa_only"), ) print_optimization_results(params1, score1) @@ -150,7 +149,7 @@ def main(): params2, score2 = optimize_parameters( query=query, n_trials=3, # Using a small number for quick demonstration - output_dir=os.path.join(output_dir, "browsecomp_only"), + output_dir=str(Path(output_dir) / "browsecomp_only"), benchmark_weights={"browsecomp": 1.0}, ) print_optimization_results(params2, score2) @@ -160,7 +159,7 @@ def main(): params3, score3 = optimize_parameters( query=query, n_trials=5, # Using a small number for quick demonstration - output_dir=os.path.join(output_dir, "weighted_combination"), + output_dir=str(Path(output_dir) / "weighted_combination"), benchmark_weights={ "simpleqa": 0.6, # 60% weight for SimpleQA "browsecomp": 0.4, # 40% weight for BrowseComp @@ -173,7 +172,7 @@ def main(): params4, score4 = optimize_for_quality( query=query, n_trials=3, - output_dir=os.path.join(output_dir, "quality_focused"), + output_dir=str(Path(output_dir) / "quality_focused"), benchmark_weights={"simpleqa": 0.6, "browsecomp": 0.4}, ) print_optimization_results(params4, score4) @@ -183,7 +182,7 @@ def main(): params5, score5 = optimize_for_speed( query=query, n_trials=3, - output_dir=os.path.join(output_dir, "speed_focused"), + output_dir=str(Path(output_dir) / "speed_focused"), benchmark_weights={"simpleqa": 0.5, "browsecomp": 0.5}, ) print_optimization_results(params5, score5)
examples/optimization/example_optimization.py+13 −17 modified@@ -14,32 +14,28 @@ """ import json -import logging -import os -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path + # Import the optimization functionality from local_deep_research.benchmarks.optimization import ( optimize_parameters, ) -# Configure logging to see progress -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) +# Loguru automatically handles logging configuration def main(): # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "examples", - "optimization", - "results", - f"optimization_results_{timestamp}", + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"optimization_results_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print( f"Starting quick optimization demo - results will be saved to {output_dir}" @@ -72,7 +68,7 @@ def main(): query="SimpleQA quick demo", # Task descriptor search_tool="searxng", # Using SearXNG n_trials=2, # Just 2 trials for quick demo - output_dir=os.path.join(output_dir, "demo"), + output_dir=str(Path(output_dir) / "demo"), param_space=param_space, # Limited parameter space metric_weights={"quality": 0.5, "speed": 0.5}, ) @@ -86,7 +82,7 @@ def main(): "demo": {"parameters": balanced_params, "score": balanced_score}, } - with open(os.path.join(output_dir, "optimization_summary.json"), "w") as f: + with open(Path(output_dir) / "optimization_summary.json", "w") as f: json.dump(summary, f, indent=2) print(f"\nDemo complete! Results saved to {output_dir}")
examples/optimization/example_quick_optimization.py+12 −14 modified@@ -14,20 +14,15 @@ """ import json -import logging -import os import random import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, Tuple -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) +from loguru import logger -logger = logging.getLogger(__name__) +# Loguru automatically handles logging configuration def simulate_optimization( @@ -192,11 +187,14 @@ def optimize_for_quality( def main(): # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "examples", "optimization", "results", f"optimization_demo_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"optimization_demo_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print( f"Starting quick optimization demo - results will be saved to {output_dir}" @@ -259,7 +257,7 @@ def main(): }, } - with open(os.path.join(output_dir, "optimization_summary.json"), "w") as f: + with open(Path(output_dir) / "optimization_summary.json", "w") as f: json.dump(summary, f, indent=2) print(
examples/optimization/gemini_optimization.py+14 −16 modified@@ -19,10 +19,12 @@ import argparse import json -import logging import os import sys -from datetime import datetime +from datetime import datetime, timezone +from pathlib import Path + +from loguru import logger # Import the optimization functionality from local_deep_research.benchmarks.optimization import ( @@ -31,13 +33,6 @@ optimize_parameters, ) -# Configure logging to see progress -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - def setup_gemini_config(api_key=None): """ @@ -101,14 +96,17 @@ def main(): return 1 # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") if args.output_dir: output_dir = args.output_dir else: - output_dir = os.path.join( - "examples", "optimization", "results", f"gemini_opt_{timestamp}" + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"gemini_opt_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print( f"Starting optimization with Gemini 2.0 Flash - results will be saved to {output_dir}" @@ -197,15 +195,15 @@ def main(): } with open( - os.path.join(output_dir, "gemini_optimization_summary.json"), "w" + Path(output_dir) / "gemini_optimization_summary.json", "w" ) as f: json.dump(summary, f, indent=2) print(f"\nOptimization complete! Results saved to {output_dir}") print(f"Recommended parameters for {args.mode} mode: {best_params}") - except Exception as e: - logger.exception(f"Error during optimization: {e}") + except Exception: + logger.exception("Error during optimization") return 1 return 0
examples/optimization/llm_multi_benchmark.py+12 −13 modified@@ -13,7 +13,8 @@ import argparse import os import sys -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, Optional from loguru import logger @@ -112,15 +113,15 @@ def main(): args = parser.parse_args() # Create timestamp-based directory for results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") if args.output_dir: output_dir = args.output_dir else: - output_dir = os.path.join( - "examples", - "optimization", - "results", - f"llm_multi_benchmark_{timestamp}", + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"llm_multi_benchmark_{timestamp}" ) os.makedirs(output_dir, exist_ok=True) @@ -215,9 +216,7 @@ def main(): # Save results to file import json - with open( - os.path.join(output_dir, "multi_benchmark_results.json"), "w" - ) as f: + with open(Path(output_dir) / "multi_benchmark_results.json", "w") as f: json.dump( { "timestamp": timestamp, @@ -235,11 +234,11 @@ def main(): ) print( - f"Results saved to {os.path.join(output_dir, 'multi_benchmark_results.json')}" + f"Results saved to {Path(output_dir) / 'multi_benchmark_results.json'}" ) - except Exception as e: - logger.error(f"Error running optimization: {e}") + except Exception: + logger.exception("Error running optimization") import traceback traceback.print_exc()
examples/optimization/multi_benchmark_simulation.py+6 −12 modified@@ -6,19 +6,13 @@ """ import json -import logging -import os import random import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, Optional, Tuple -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +from loguru import logger class BenchmarkSimulator: @@ -340,9 +334,9 @@ def print_optimization_results(params: Dict[str, Any], score: float): def main(): """Run the multi-benchmark optimization simulation.""" # Create a timestamp-based directory for results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") output_dir = "optimization_sim_" + timestamp - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print("\n🔬 Multi-Benchmark Optimization Simulation 🔬") print(f"Results will be saved to: {output_dir}") @@ -393,7 +387,7 @@ def main(): }, } - results_file = os.path.join(output_dir, "multi_benchmark_results.json") + results_file = str(Path(output_dir) / "multi_benchmark_results.json") with open(results_file, "w") as f: # Convert all values to serializable types json.dump(
examples/optimization/multi_benchmark_speed_demo.py+2 −2 modified@@ -13,12 +13,12 @@ python ../examples/optimization/multi_benchmark_speed_demo.py """ -import os import sys +from pathlib import Path from typing import Any, Dict # Add src directory to Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")) +src_dir = str((Path(__file__).parent.parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir)
examples/optimization/run_gemini_benchmark.py+19 −23 modified@@ -15,25 +15,19 @@ """ import argparse -import logging -import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, List, Optional +from loguru import logger + # Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Loguru automatically handles logging configuration def setup_gemini_config(api_key: Optional[str] = None) -> Dict[str, Any]: @@ -117,13 +111,15 @@ def run_benchmarks( return {"error": "Failed to set up Gemini configuration"} # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") if not output_dir: - output_dir = os.path.join( - project_root, "benchmark_results", f"gemini_eval_{timestamp}" + output_dir = str( + Path(project_root) + / "benchmark_results" + / f"gemini_eval_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) # Set benchmark list if not benchmarks: @@ -148,7 +144,7 @@ def run_benchmarks( search_model=gemini_config["model_name"], search_provider=gemini_config["provider"], endpoint_url=gemini_config["endpoint_url"], - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) elif benchmark.lower() == "browsecomp": logger.info( @@ -162,7 +158,7 @@ def run_benchmarks( search_model=gemini_config["model_name"], search_provider=gemini_config["provider"], endpoint_url=gemini_config["endpoint_url"], - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) else: logger.warning(f"Unknown benchmark: {benchmark}") @@ -185,7 +181,7 @@ def run_benchmarks( } except Exception as e: - logger.error(f"Error running {benchmark} benchmark: {e}") + logger.exception(f"Error running {benchmark} benchmark") import traceback traceback.print_exc() @@ -217,7 +213,7 @@ def run_benchmarks( logger.info("=" * 50) # Save summary to a file - summary_file = os.path.join(output_dir, "benchmark_summary.json") + summary_file = str(Path(output_dir) / "benchmark_summary.json") try: import json @@ -250,8 +246,8 @@ def run_benchmarks( indent=2, ) logger.info(f"Summary saved to {summary_file}") - except Exception as e: - logger.error(f"Error saving summary: {e}") + except Exception: + logger.exception("Error saving summary") return { "status": "complete",
examples/optimization/run_multi_benchmark.py+32 −37 modified@@ -13,25 +13,25 @@ python ../examples/optimization/run_multi_benchmark.py """ -import logging import os import sys -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict +from loguru import logger + # Add src directory to Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")) +src_dir = str((Path(__file__).parent.parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") +# Use environment variables for configuration +# The system should be configured with proper environment variables: +# - ANTHROPIC_API_KEY for Anthropic API access +# - OPENROUTER_API_KEY for OpenRouter API access (if used) +# - LDR_DATA_DIR for data directory location (if needed) +data_dir = os.environ.get("LDR_DATA_DIR", str(Path(src_dir) / "data")) # Import benchmark optimization functions try: @@ -45,13 +45,6 @@ print("Current sys.path:", sys.path) sys.exit(1) -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - def print_optimization_results(params: Dict[str, Any], score: float): """Print optimization results in a nicely formatted way.""" @@ -68,16 +61,18 @@ def print_optimization_results(params: Dict[str, Any], score: float): def main(): """Run multi-benchmark optimization examples.""" # Create a timestamp-based directory for results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") # Put results in the data directory for easier access - if os.path.isdir(data_dir): - output_dir = os.path.join( - data_dir, "optimization_results", "multi_benchmark_" + timestamp + if Path(data_dir).is_dir(): + output_dir = str( + Path(data_dir) + / "optimization_results" + / f"multi_benchmark_{timestamp}" ) else: - output_dir = os.path.join( - "optimization_results", "multi_benchmark_" + timestamp + output_dir = str( + Path("optimization_results") / f"multi_benchmark_{timestamp}" ) os.makedirs(output_dir, exist_ok=True) @@ -154,7 +149,7 @@ def main(): quality_results = evaluator.evaluate( system_config=mini_system_config, num_examples=1, # Use just 1 example for speed - output_dir=os.path.join(output_dir, "simpleqa_test"), + output_dir=str(Path(output_dir) / "simpleqa_test"), ) print("Benchmark evaluation complete!") @@ -173,14 +168,14 @@ def main(): params1, score1 = optimize_parameters( query=query, param_space=tiny_param_space, # Use tiny param space - output_dir=os.path.join(output_dir, "simpleqa_only"), + output_dir=str(Path(output_dir) / "simpleqa_only"), n_trials=1, # Just one trial for testing benchmark_weights={"simpleqa": 1.0}, # SimpleQA only timeout=5, # Limit to 5 seconds ) print_optimization_results(params1, score1) except Exception as e: - logger.error(f"Error running SimpleQA optimization: {e}") + logger.exception("Error running SimpleQA optimization") print(f"Error: {e}") # Run 2: BrowseComp benchmark only (minimal test) @@ -193,7 +188,7 @@ def main(): bc_results = browsecomp_evaluator.evaluate( system_config=mini_system_config, num_examples=1, # Just 1 example for speed - output_dir=os.path.join(output_dir, "browsecomp_test"), + output_dir=str(Path(output_dir) / "browsecomp_test"), ) print("BrowseComp evaluation complete!") @@ -207,7 +202,7 @@ def main(): ) except Exception as e: - logger.error(f"Error running BrowseComp evaluation: {e}") + logger.exception("Error running BrowseComp evaluation") print(f"Error: {e}") # Run 3: Combined benchmark with weights (minimal test) @@ -224,7 +219,7 @@ def main(): combo_results = composite_evaluator.evaluate( system_config=mini_system_config, num_examples=1, # Just 1 example for speed - output_dir=os.path.join(output_dir, "combined_test"), + output_dir=str(Path(output_dir) / "combined_test"), ) print("Combined benchmark evaluation complete!") @@ -239,7 +234,7 @@ def main(): ) except Exception as e: - logger.error(f"Error running combined benchmark evaluation: {e}") + logger.exception("Error running combined benchmark evaluation") print(f"Error: {e}") # Run 4: Combined benchmark with speed optimization @@ -254,7 +249,7 @@ def main(): # Very minimal run with just 1 trial for demonstration params_speed, score_speed = optimize_for_speed( query=query, - output_dir=os.path.join(output_dir, "speed_optimization"), + output_dir=str(Path(output_dir) / "speed_optimization"), n_trials=1, # Just one trial for testing benchmark_weights={"simpleqa": 0.6, "browsecomp": 0.4}, timeout=5, # Limit to 5 seconds @@ -265,8 +260,8 @@ def main(): print("Speed metrics weighting: Quality (20%), Speed (80%)") except Exception as e: - logger.error( - f"Error running speed optimization with multi-benchmark: {e}" + logger.exception( + "Error running speed optimization with multi-benchmark" ) print(f"Error: {e}") @@ -282,7 +277,7 @@ def main(): # Very minimal run with just 1 trial for demonstration params_efficiency, score_efficiency = optimize_for_efficiency( query=query, - output_dir=os.path.join(output_dir, "efficiency_optimization"), + output_dir=str(Path(output_dir) / "efficiency_optimization"), n_trials=1, # Just one trial for testing benchmark_weights={"simpleqa": 0.6, "browsecomp": 0.4}, timeout=5, # Limit to 5 seconds @@ -295,8 +290,8 @@ def main(): ) except Exception as e: - logger.error( - f"Error running efficiency optimization with multi-benchmark: {e}" + logger.exception( + "Error running efficiency optimization with multi-benchmark" ) print(f"Error: {e}")
examples/optimization/run_optimization.py+6 −5 modified@@ -17,7 +17,8 @@ import json import os import sys -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path # Import the optimization functionality from local_deep_research.benchmarks.optimization import ( @@ -36,7 +37,7 @@ def main(): parser.add_argument("query", help="Research query to optimize for") parser.add_argument( "--output-dir", - default=os.path.join("examples", "optimization", "results"), + default=str(Path("examples") / "optimization" / "results"), help="Directory to save results", ) parser.add_argument( @@ -84,8 +85,8 @@ def main(): args = parser.parse_args() # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join(args.output_dir, f"opt_{timestamp}") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str(Path(args.output_dir) / f"opt_{timestamp}") os.makedirs(output_dir, exist_ok=True) print( @@ -185,7 +186,7 @@ def main(): "custom_weights": custom_weights, } - with open(os.path.join(output_dir, "optimization_summary.json"), "w") as f: + with open(Path(output_dir) / "optimization_summary.json", "w") as f: json.dump(summary, f, indent=2) return 0
examples/optimization/run_parallel_benchmark.py+21 −26 modified@@ -15,24 +15,17 @@ import argparse import concurrent.futures -import logging import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path -# Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) +from loguru import logger -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Add the src directory to the Python path +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) def run_simpleqa_benchmark( @@ -61,7 +54,7 @@ def run_simpleqa_benchmark( search_model=model, search_provider=provider, endpoint_url=endpoint_url, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), evaluation_provider="ANTHROPIC", evaluation_model="claude-3-7-sonnet-20250219", ) @@ -101,7 +94,7 @@ def run_browsecomp_benchmark( search_model=model, search_provider=provider, endpoint_url=endpoint_url, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), evaluation_provider="ANTHROPIC", evaluation_model="claude-3-7-sonnet-20250219", ) @@ -176,11 +169,13 @@ def main(): args = parser.parse_args() # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - project_root, "benchmark_results", f"parallel_benchmark_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path(project_root) + / "benchmark_results" + / f"parallel_benchmark_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) # Display start information print(f"Starting parallel benchmarks with {args.examples} examples each") @@ -224,15 +219,15 @@ def main(): try: simpleqa_results = simpleqa_future.result() print("SimpleQA benchmark completed successfully") - except Exception as e: - logger.error(f"Error in SimpleQA benchmark: {e}") + except Exception: + logger.exception("Error in SimpleQA benchmark") simpleqa_results = None try: browsecomp_results = browsecomp_future.result() print("BrowseComp benchmark completed successfully") - except Exception as e: - logger.error(f"Error in BrowseComp benchmark: {e}") + except Exception: + logger.exception("Error in BrowseComp benchmark") browsecomp_results = None # Calculate total time @@ -289,12 +284,12 @@ def main(): } with open( - os.path.join(output_dir, "parallel_benchmark_summary.json"), "w" + Path(output_dir) / "parallel_benchmark_summary.json", "w" ) as f: json.dump(summary, f, indent=2) - except Exception as e: - logger.error(f"Error saving summary: {e}") + except Exception: + logger.exception("Error saving summary") return 0
examples/optimization/strategy_benchmark_plan.py+28 −35 modified@@ -1,6 +1,6 @@ #!/usr/bin/env python3 # This script should be run from the project root directory using: -# cd /home/martin/code/LDR/local-deep-research +# cd /path/to/local-deep-research # python -m examples.optimization.strategy_benchmark_plan """ Strategy Benchmark Plan - Comprehensive Optuna-based optimization for search strategies @@ -10,34 +10,29 @@ """ import json -import logging import os import random import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, Tuple +from loguru import logger + # Skip flake8 import order checks for this file due to sys.path manipulation # flake8: noqa: E402 # Add the src directory to the Python path before local imports -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) # Now we can import from the local project from local_deep_research.benchmarks.optimization.optuna_optimizer import ( OptunaOptimizer, ) -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Logger is already imported from loguru at the top # Number of examples to use in each benchmark experiment NUM_EXAMPLES = 500 @@ -82,10 +77,10 @@ def run_strategy_comparison(): f"Default questions per iteration from DB: {questions_per_iteration}" ) except Exception as e: - logger.error(f"Error initializing LLM or search settings: {str(e)}") - logger.error("Please check your database configuration") + logger.exception(f"Error initializing LLM or search settings: {e!s}") + logger.info("Please check your database configuration") return {"error": str(e)} - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") base_output_dir = f"strategy_benchmark_results_{timestamp}" os.makedirs(base_output_dir, exist_ok=True) @@ -132,8 +127,8 @@ def run_strategy_comparison(): # ====== EXPERIMENT 1: Quality-focused optimization ====== logger.info("Starting quality-focused benchmark with 500 examples") - quality_output_dir = os.path.join(base_output_dir, "quality_focused") - os.makedirs(quality_output_dir, exist_ok=True) + quality_output_dir = str(Path(base_output_dir) / "quality_focused") + Path(quality_output_dir).mkdir(parents=True, exist_ok=True) # Create optimizer for quality quality_optimizer = OptunaOptimizer( @@ -169,13 +164,13 @@ def run_strategy_comparison(): logger.info(f"Best quality score: {best_quality_score}") logger.info(f"Duration: {quality_end - quality_start} seconds") - with open(os.path.join(quality_output_dir, "results.json"), "w") as f: + with open(Path(quality_output_dir) / "results.json", "w") as f: json.dump(quality_result, f, indent=2) # ====== EXPERIMENT 2: Speed-focused optimization ====== logger.info("Starting speed-focused benchmark with 500 examples") - speed_output_dir = os.path.join(base_output_dir, "speed_focused") - os.makedirs(speed_output_dir, exist_ok=True) + speed_output_dir = str(Path(base_output_dir) / "speed_focused") + Path(speed_output_dir).mkdir(parents=True, exist_ok=True) # Create optimizer for speed speed_optimizer = OptunaOptimizer( @@ -211,13 +206,13 @@ def run_strategy_comparison(): logger.info(f"Best speed score: {best_speed_score}") logger.info(f"Duration: {speed_end - speed_start} seconds") - with open(os.path.join(speed_output_dir, "results.json"), "w") as f: + with open(Path(speed_output_dir) / "results.json", "w") as f: json.dump(speed_result, f, indent=2) # ====== EXPERIMENT 3: Balanced optimization ====== logger.info("Starting balanced benchmark with 500 examples") - balanced_output_dir = os.path.join(base_output_dir, "balanced") - os.makedirs(balanced_output_dir, exist_ok=True) + balanced_output_dir = str(Path(base_output_dir) / "balanced") + Path(balanced_output_dir).mkdir(parents=True, exist_ok=True) # Create optimizer for balanced approach balanced_optimizer = OptunaOptimizer( @@ -253,13 +248,13 @@ def run_strategy_comparison(): logger.info(f"Best balanced score: {best_balanced_score}") logger.info(f"Duration: {balanced_end - balanced_start} seconds") - with open(os.path.join(balanced_output_dir, "results.json"), "w") as f: + with open(Path(balanced_output_dir) / "results.json", "w") as f: json.dump(balanced_result, f, indent=2) # ====== EXPERIMENT 4: Multi-Benchmark (SimpleQA + BrowseComp) ====== logger.info("Starting multi-benchmark optimization with 500 examples") - multi_output_dir = os.path.join(base_output_dir, "multi_benchmark") - os.makedirs(multi_output_dir, exist_ok=True) + multi_output_dir = str(Path(base_output_dir) / "multi_benchmark") + Path(multi_output_dir).mkdir(parents=True, exist_ok=True) # Create optimizer with multi-benchmark weights multi_optimizer = OptunaOptimizer( @@ -296,7 +291,7 @@ def run_strategy_comparison(): logger.info(f"Best multi-benchmark score: {best_multi_score}") logger.info(f"Duration: {multi_end - multi_start} seconds") - with open(os.path.join(multi_output_dir, "results.json"), "w") as f: + with open(Path(multi_output_dir) / "results.json", "w") as f: json.dump(multi_result, f, indent=2) # ====== Save summary of all executions ====== @@ -305,7 +300,7 @@ def run_strategy_comparison(): ) execution_stats["timestamp"] = timestamp - with open(os.path.join(base_output_dir, "summary.json"), "w") as f: + with open(Path(base_output_dir) / "summary.json", "w") as f: json.dump(execution_stats, f, indent=2) # Generate summary report @@ -366,7 +361,7 @@ def generate_summary_report(base_dir, stats): """ # Write summary to file - with open(os.path.join(base_dir, "summary_report.md"), "w") as f: + with open(Path(base_dir) / "summary_report.md", "w") as f: f.write(summary_text) @@ -378,7 +373,7 @@ def run_strategy_simulation(num_examples=10): This fallback simulation mode doesn't require actual database or LLM access, making it useful for testing the script structure. """ - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") sim_output_dir = f"strategy_sim_results_{timestamp}" os.makedirs(sim_output_dir, exist_ok=True) @@ -432,7 +427,7 @@ def run_strategy_simulation(num_examples=10): best_params, best_score = sim_optimizer.optimize(strategy_param_space) except Exception as e: - logger.warning(f"Could not initialize real optimizer: {str(e)}") + logger.warning(f"Could not initialize real optimizer: {e!s}") logger.warning( "Falling back to pure simulation mode (no real benchmarks)" ) @@ -456,9 +451,7 @@ def run_strategy_simulation(num_examples=10): "best_score": best_score, } - with open( - os.path.join(sim_output_dir, "simulation_results.json"), "w" - ) as f: + with open(Path(sim_output_dir) / "simulation_results.json", "w") as f: json.dump(sim_result, f, indent=2) return sim_result
examples/optimization/update_llm_config.py+7 −15 modified@@ -18,23 +18,15 @@ """ import argparse -import logging -import os import sys +from pathlib import Path from typing import Optional -# Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) +from loguru import logger -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Add the src directory to the Python path +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) def update_llm_configuration( @@ -66,7 +58,7 @@ def update_llm_configuration( update_db_setting, ) except ImportError: - logger.error( + logger.exception( "Could not import database utilities. Make sure you're in the correct directory." ) return False @@ -149,7 +141,7 @@ def update_llm_configuration( return True except Exception as e: - logger.error(f"Error updating LLM configuration: {str(e)}") + logger.exception(f"Error updating LLM configuration: {e!s}") return False
examples/run_benchmark.py+1 −8 modified@@ -6,22 +6,15 @@ """ import argparse -import logging import os + from local_deep_research.api.benchmark_functions import ( compare_configurations, evaluate_browsecomp, evaluate_simpleqa, ) -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - def main(): """Run benchmark examples."""
examples/show_env_vars.py+65 −0 added@@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Example script showing all available environment variables for LDR configuration. +This demonstrates the centralized environment variable management in SettingsManager. +""" + +import sys +import os +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from local_deep_research.settings.manager import SettingsManager + + +def main(): + print("=== Local Deep Research Environment Variables ===\n") + + all_env_vars = SettingsManager.get_all_env_vars() + + for category, vars_dict in all_env_vars.items(): + print(f"\n{category.upper()} VARIABLES:") + print("-" * 50) + + for var_name, description in sorted(vars_dict.items()): + # Check if currently set + current_value = os.environ.get(var_name) + if current_value: + # Mask sensitive values + if any( + sensitive in var_name + for sensitive in ["KEY", "PASSWORD", "SECRET"] + ): + display_value = "***SET***" + else: + display_value = current_value + status = f" [Current: {display_value}]" + else: + status = "" + + print(f" {var_name}") + print(f" {description}{status}") + + print("\n\n=== Environment Variable Formats ===") + print("-" * 50) + print( + "Settings can be overridden via environment variables using this format:" + ) + print(" Setting key: app.host") + print(" Environment variable: LDR_APP__HOST") + print( + "\nNote: Use double underscores (__) to separate setting path components." + ) + + print("\n\n=== Bootstrap Variables ===") + print("-" * 50) + print("The following variables must be set before database access:") + bootstrap_vars = SettingsManager.get_bootstrap_env_vars() + for var in sorted(bootstrap_vars.keys()): + print(f" - {var}") + + +if __name__ == "__main__": + main()
.github/workflows/accessibility-compliance-tests.yml+74 −0 added@@ -0,0 +1,74 @@ +name: Accessibility Tests + +on: + push: + branches: [ main, dev, develop ] + pull_request: + branches: [ main, dev, develop ] + workflow_dispatch: + +jobs: + accessibility-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v3 + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.11' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev + + - name: Install Python dependencies + run: | + pdm sync -d + + - name: Run accessibility compliance tests + run: | + # Run all accessibility tests + pdm run pytest tests/accessibility/ -v --tb=short || true + + # Run specific accessibility test files that exist + pdm run pytest tests/ui_tests/test_accessibility_compliance.py -v --tb=short || true + pdm run pytest tests/ui_tests/test_wcag_compliance.py -v --tb=short || true + pdm run pytest tests/ui_tests/test_keyboard_navigation.py -v --tb=short || true + pdm run pytest tests/ui_tests/test_screen_reader.py -v --tb=short || true + + # Run HTML structure tests that may contain accessibility checks + pdm run pytest tests/test_html_structure.py -v --tb=short || true + + - name: Generate accessibility report + if: always() + run: | + echo "Accessibility Tests Report" + echo "==========================" + echo "Tests check for:" + echo "- WCAG 2.1 Level AA compliance" + echo "- Keyboard navigation support" + echo "- Screen reader compatibility" + echo "- Color contrast ratios" + echo "- ARIA labels and roles" + echo "- Focus management" + + - name: Upload accessibility test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: accessibility-test-results + path: | + tests/accessibility/ + .pytest_cache/
.github/workflows/api-tests.yml+18 −6 modified@@ -35,21 +35,32 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Set up PDM uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev - name: Install dependencies run: | - pdm install - pdm install -d + # Install in development mode to ensure all modules are available + pdm sync -d + # Verify the package is installed correctly + pdm run python -c "import local_deep_research; print('Package imported:', local_deep_research.__file__)" + pdm run python -c "import local_deep_research.memory_cache; print('Memory cache module imported successfully')" - name: Start server for API unit tests run: | export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH echo "Starting server for API unit tests..." - pdm run ldr-web 2>&1 | tee server.log & + pdm run python -m local_deep_research.web.app 2>&1 | tee server.log & SERVER_PID=$! # Wait for server to start @@ -134,8 +145,9 @@ jobs: fi # Now try with pdm run ldr-web - echo "Starting server with pdm run ldr-web..." - pdm run ldr-web 2>&1 | tee server.log & + echo "Starting server with pdm run python..." + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app 2>&1 | tee server.log & SERVER_PID=$! # Give server a moment to start logging
.github/workflows/check-env-vars.yml+34 −0 added@@ -0,0 +1,34 @@ +name: Check Environment Variables + +on: + pull_request: + paths: + - '**.py' + - 'src/local_deep_research/settings/**' + - '.github/workflows/check-env-vars.yml' + push: + branches: + - main + - dev + paths: + - '**.py' + +jobs: + check-env-vars: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install loguru sqlalchemy sqlalchemy-utc platformdirs pydantic + + - name: Run environment variable validation + run: | + python tests/settings/env_vars/test_env_var_usage.py
.github/workflows/critical-ui-tests.yml+163 −0 added@@ -0,0 +1,163 @@ +name: Critical UI Tests + +on: + push: + branches: [ main, dev, develop ] + pull_request: + branches: [ main, dev, develop ] + workflow_dispatch: + +jobs: + critical-ui-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + + # No services needed - using SQLite databases + + steps: + - uses: actions/checkout@v3 + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.11' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y chromium-browser libsqlcipher-dev + + - name: Install Python dependencies + run: | + pdm sync -d + pdm run playwright install chromium + + - name: Set up test directories + run: | + mkdir -p ~/.local/share/local-deep-research/encrypted_databases + mkdir -p ~/.local/share/local-deep-research + + - name: Initialize database + env: + TEST_ENV: true + run: | + cd src + cat > init_db.py << 'EOF' + import os + from pathlib import Path + # Ensure data directory exists + data_dir = Path.home() / '.local' / 'share' / 'local-deep-research' + data_dir.mkdir(parents=True, exist_ok=True) + (data_dir / 'encrypted_databases').mkdir(parents=True, exist_ok=True) + + from local_deep_research.database.auth_db import init_auth_database, get_auth_db_session + from local_deep_research.database.models.auth import User + from local_deep_research.database.encrypted_db import db_manager + + # Initialize auth database + init_auth_database() + + # Create test user in auth database (no password stored) + session = get_auth_db_session() + user = User(username='test_admin') + session.add(user) + session.commit() + session.close() + + # Create user's encrypted database with password + engine = db_manager.create_user_database('test_admin', 'testpass123') + print('Database initialized successfully') + EOF + pdm run python init_db.py + + - name: Start test server + env: + FLASK_ENV: testing + TEST_ENV: true + SECRET_KEY: test-secret-key-for-ci + run: | + cd src + # Start server and get its PID + pdm run python -m local_deep_research.web.app > server.log 2>&1 & + SERVER_PID=$! + echo "Server PID: $SERVER_PID" + + # Wait for server to start + for i in {1..30}; do + if curl -f http://127.0.0.1:5000 2>/dev/null; then + echo "Server is ready after $i seconds" + break + fi + # Check if process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "Server process died!" + echo "Server log:" + cat server.log + exit 1 + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + + # Final check + if ! curl -f http://127.0.0.1:5000 2>/dev/null; then + echo "Server failed to start after 30 seconds" + echo "Server log:" + cat server.log + exit 1 + fi + + - name: Install Node.js dependencies for UI tests + run: | + cd tests/ui_tests + npm install + + - name: Create screenshots directory + run: | + mkdir -p tests/ui_tests/screenshots + + - name: Run critical UI tests + env: + CI: true + HEADLESS: true + run: | + cd tests/ui_tests + + # Critical authentication and research submission tests + echo "Running critical authentication test..." + node test_auth_flow.js || exit 1 + + echo "Running critical research submission tests..." + node test_research_submit.js || exit 1 + node test_research_simple.js || exit 1 + + echo "Running critical export test..." + node test_export_functionality.js || exit 1 + + echo "Running critical concurrent limits test..." + node test_concurrent_limit.js || exit 1 + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: critical-ui-test-artifacts + path: | + tests/ui_tests/screenshots/ + src/server.log + + - name: Generate test report + if: always() + run: | + echo "Critical UI Tests completed" + if [ -f tests/ui_tests/test_report.json ]; then + cat tests/ui_tests/test_report.json + fi
.github/workflows/enhanced-tests.yml+27 −30 modified@@ -23,49 +23,46 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install - pdm install -d + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Run enhanced test framework tests run: | - export LDR_USE_FALLBACK_LLM=true - export CI=true - pdm run pytest -v \ - tests/test_wikipedia_url_security.py \ - tests/test_search_engines_enhanced.py \ - tests/test_utils.py \ - -x \ - --tb=short + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e CI=true \ + -w /app \ + ldr-test \ + sh -c "python -m pytest -v \ + tests/test_wikipedia_url_security.py \ + tests/test_search_engines_enhanced.py \ + tests/test_utils.py \ + -x \ + --tb=short" - name: Run fixture tests run: | - export LDR_USE_FALLBACK_LLM=true - export CI=true - # Test that fixtures can be imported and used - pdm run python -c " + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e CI=true \ + -w /app \ + ldr-test \ + sh -c 'python -c " from tests.fixtures.search_engine_mocks import SearchEngineMocks, validate_wikipedia_url from tests.mock_fixtures import get_mock_search_results from tests.mock_modules import create_mock_llm_config - print('✅ All fixture imports successful') + print(\"✅ All fixture imports successful\") # Test URL validation - assert validate_wikipedia_url('https://en.wikipedia.org/wiki/Test') == True - assert validate_wikipedia_url('https://evil.com/wiki/Test') == False - print('✅ URL validation tests passed') + assert validate_wikipedia_url(\"https://en.wikipedia.org/wiki/Test\") == True + assert validate_wikipedia_url(\"https://evil.com/wiki/Test\") == False + print(\"✅ URL validation tests passed\") # Test mock data results = get_mock_search_results() assert len(results) == 2 - print('✅ Mock data tests passed') - " + print(\"✅ Mock data tests passed\") + "' - name: Upload test results if: always()
.github/workflows/extended-ui-tests.yml+178 −0 added@@ -0,0 +1,178 @@ +name: Extended UI Tests + +on: + push: + branches: [ main, dev ] + pull_request: + types: [opened, synchronize, reopened] + schedule: + # Run nightly at 2 AM UTC + - cron: '0 2 * * *' + workflow_dispatch: + +jobs: + extended-ui-tests: + runs-on: ubuntu-latest + name: Extended UI Test Suite + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev xvfb + + - name: Install dependencies + run: | + pdm sync -d + cd tests/ui_tests && npm install + + - name: Install browser + run: | + npx puppeteer browsers install chrome + + - name: Setup directories + run: | + mkdir -p data + mkdir -p tests/ui_tests/screenshots + mkdir -p tests/ui_tests/results + + - name: Start server + run: | + export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app & + echo $! > server.pid + + # Wait for server + for i in {1..30}; do + if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then + echo "Server ready" + break + fi + sleep 1 + done + + - name: Run critical UI tests + run: | + export DISPLAY=:99 + cd tests/ui_tests + + # Create test runner for extended tests + cat > run_extended_tests.js << 'EOF' + const { spawn } = require('child_process'); + const path = require('path'); + + const tests = [ + // Critical flows not in main test suite + { name: 'Research Submit', file: 'test_research_submit.js' }, + { name: 'Research Cancellation', file: 'test_research_cancellation.js' }, + { name: 'Export Functionality', file: 'test_export_functionality.js' }, + { name: 'Complete Workflow', file: 'test_complete_workflow.js' }, + { name: 'Concurrent Limit', file: 'test_concurrent_limit.js' }, + { name: 'Multi Research', file: 'test_multi_research.js' }, + + // Additional research tests + { name: 'Research Simple', file: 'test_research_simple.js' }, + { name: 'Research Form', file: 'test_research_form.js' }, + { name: 'Research API', file: 'test_research_api.js' }, + + // History and navigation + { name: 'History Page', file: 'test_history_page.js' }, + { name: 'Full Navigation', file: 'test_full_navigation.js' }, + + // Advanced features + { name: 'Queue Simple', file: 'test_queue_simple.js' }, + { name: 'Direct Mode', file: 'test_direct_mode.js' } + ]; + + let passed = 0; + let failed = 0; + + async function runTest(test) { + console.log(`\nRunning: ${test.name}`); + return new Promise((resolve) => { + const testProcess = spawn('node', [test.file], { + stdio: 'inherit', + env: { ...process.env, HEADLESS: 'true' } + }); + + const timeout = setTimeout(() => { + testProcess.kill(); + console.log(`⏱️ ${test.name} timed out`); + failed++; + resolve(); + }, 90000); // 90 second timeout per test + + testProcess.on('close', (code) => { + clearTimeout(timeout); + if (code === 0) { + console.log(`✅ ${test.name} passed`); + passed++; + } else { + console.log(`❌ ${test.name} failed`); + failed++; + } + resolve(); + }); + }); + } + + async function runAll() { + console.log('Starting Extended UI Test Suite\n'); + + for (const test of tests) { + // Check if file exists before trying to run + const fs = require('fs'); + if (fs.existsSync(test.file)) { + await runTest(test); + } else { + console.log(`⚠️ Skipping ${test.name} - file not found`); + } + } + + console.log(`\n📊 Results: ${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); + } + + runAll(); + EOF + + xvfb-run -a -s "-screen 0 1920x1080x24" node run_extended_tests.js + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: extended-ui-screenshots + path: tests/ui_tests/screenshots/ + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: extended-ui-results + path: tests/ui_tests/results/ + + - name: Stop server + if: always() + run: | + if [ -f server.pid ]; then + kill $(cat server.pid) || true + fi
.github/workflows/file-whitelist-check.yml+23 −7 modified@@ -37,9 +37,11 @@ jobs: "^\.gitignore$" "^\.gitkeep$" ".*\.gitkeep$" + ".*\.gitignore$" "^\.pre-commit-config\.yaml$" "^\.isort\.cfg$" "^\.coveragerc$" + "^\.secrets\.baseline$" "^pytest\.ini$" "^LICENSE$" "^README$" @@ -162,11 +164,19 @@ jobs: "src/local_deep_research/advanced_search_system/.*\.py$" "src/local_deep_research/benchmarks/.*\.py$" "src/local_deep_research/web/static/js/components/.*\.js$" + "src/local_deep_research/database/.*\.py$" + "src/local_deep_research/web/queue/.*\.py$" + "src/local_deep_research/api/.*\.py$" + "src/local_deep_research/settings/.*\.py$" + "src/local_deep_research/memory_cache/.*\.py$" + "src/local_deep_research/web/auth/.*\.py$" + "src/local_deep_research/news/.*\.py$" "docs/.*\.md$" "tests/.*\.py$" ".*test.*\.py$" ".*mock.*\.py$" ".*example.*\.py$" + "scripts/audit_.*\.py$" ) # Check if file matches whitelist patterns @@ -198,10 +208,13 @@ jobs: "docs/.*token.*\.md$" "tests/.*\.py$" ".*test.*\.py$" + "\.secrets\.baseline$" + ".*session_passwords\.py$" + ".*change_password\.html$" ) # Check if filename looks suspicious - if echo "$file" | grep -iE "(secret|password|token|api[_-]?key|\.key$|\.pem$|\.p12$|\.pfx$|\.env$)" >/dev/null; then + if echo "$file" | grep -iE "(secret|password|token|\.key$|\.pem$|\.p12$|\.pfx$|\.env$)" >/dev/null; then # Check if filename matches whitelist patterns FILENAME_WHITELISTED=false for pattern in "${SAFE_FILENAME_PATTERNS[@]}"; do @@ -242,18 +255,21 @@ jobs: if [ -f "$file" ] && [ -r "$file" ]; then # Skip HTML files and other safe file types for entropy checks if ! echo "$file" | grep -qE "\.(html|css|js|json|yml|yaml|md)$"; then - # Look for base64-like strings or hex strings that are suspiciously long - if grep -E "[a-zA-Z0-9+/]{40,}={0,2}|[a-f0-9]{40,}" "$file" >/dev/null 2>&1; then - # Exclude common false positives - if ! grep -iE "(sha256|md5|hash|test|example|fixture|integrity)" "$file" >/dev/null 2>&1; then - HIGH_ENTROPY_VIOLATIONS+=("$file") + # Skip news_strategy.py which contains example categories in prompts + if ! echo "$file" | grep -qE "news_strategy\.py$"; then + # Look for base64-like strings or hex strings that are suspiciously long + if grep -E "[a-zA-Z0-9+/]{40,}={0,2}|[a-f0-9]{40,}" "$file" >/dev/null 2>&1; then + # Exclude common false positives + if ! grep -iE "(sha256|md5|hash|test|example|fixture|integrity)" "$file" >/dev/null 2>&1; then + HIGH_ENTROPY_VIOLATIONS+=("$file") + fi fi fi fi fi # Check for hardcoded paths (Unix/Windows) - if ! echo "$file" | grep -qE "(test|mock|example|\.md$|docker|Docker|\.yml$|\.yaml$)"; then + if ! echo "$file" | grep -qE "(test|mock|example|\.md$|docker|Docker|\.yml$|\.yaml$|config/paths\.py$)"; then # Look for absolute paths and user home directories if grep -E "(/home/[a-zA-Z0-9_-]+|/Users/[a-zA-Z0-9_-]+|C:\\\\Users\\\\[a-zA-Z0-9_-]+|/opt/|/var/|/etc/|/usr/local/)" "$file" >/dev/null 2>&1; then # Exclude common false positives and Docker volume mounts
.github/workflows/followup-research-tests.yml+127 −0 added@@ -0,0 +1,127 @@ +name: Follow-up Research Tests + +on: + push: + paths: + - 'src/local_deep_research/followup_research/**' + - 'src/local_deep_research/advanced_search_system/strategies/contextual_followup_strategy.py' + - 'src/local_deep_research/search_system.py' + - 'tests/test_followup_api.py' + - 'tests/ui_tests/test_followup_research.js' + - '.github/workflows/followup-research-tests.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/local_deep_research/followup_research/**' + - 'src/local_deep_research/advanced_search_system/strategies/contextual_followup_strategy.py' + - 'tests/test_followup_api.py' + - 'tests/ui_tests/test_followup_research.js' + +jobs: + test-followup-research: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PDM + uses: pdm-project/setup-pdm@v3 + with: + python-version: '3.11' + cache: true + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install dependencies + run: | + pdm sync -d + cd tests/ui_tests && npm install + + - name: Install browser + run: | + npx playwright install chromium + + - name: Create test directories + run: | + mkdir -p data + mkdir -p tests/ui_tests/screenshots/followup + mkdir -p tests/ui_tests/results/followup + + - name: Start server + run: | + export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app 2>&1 | tee server.log & + echo $! > server.pid + + # Wait for server + for i in {1..30}; do + if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then + echo "Server ready after $i seconds" + break + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + + # Final check + curl -f http://localhost:5000/api/v1/health || (echo "Server failed to start" && cat server.log && exit 1) + + - name: Run API tests + run: | + pdm run pytest tests/test_followup_api.py -v --tb=short + + - name: Run UI tests + env: + HEADLESS: true + DISPLAY: ':99' + run: | + export DISPLAY=:99 + cd tests/ui_tests + + # Run follow-up research UI test + timeout 300 node test_followup_research.js || { + echo "Follow-up research test failed or timed out" + exit 1 + } + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: followup-test-screenshots + path: tests/ui_tests/screenshots/followup/ + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: followup-test-results + path: | + tests/ui_tests/results/followup/ + server.log + + - name: Stop server + if: always() + run: | + if [ -f server.pid ]; then + kill $(cat server.pid) || true + fi + + - name: Test Summary + if: always() + run: | + echo "Follow-up Research Tests completed" + echo "API tests and UI tests have been executed" + if [ -f tests/ui_tests/results/followup/test_report.json ]; then + cat tests/ui_tests/results/followup/test_report.json + fi
.github/workflows/infrastructure-tests.yml+7 −12 modified@@ -22,17 +22,8 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install Python dependencies - run: | - pdm install + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Set up Node.js uses: actions/setup-node@v4 @@ -45,7 +36,11 @@ jobs: - name: Run Python infrastructure tests run: | - pdm run pytest tests/infrastructure_tests/test_*.py -v --color=yes + docker run --rm \ + -v $PWD:/app \ + -w /app \ + ldr-test \ + sh -c "cd /app && python -m pytest tests/infrastructure_tests/test_*.py -v --color=yes --no-header -rN" - name: Run JavaScript infrastructure tests run: |
.github/workflows/label-fixed-in-dev.yml+67 −0 added@@ -0,0 +1,67 @@ +name: Auto-label Fixed Issues in Dev +on: + pull_request: + types: [closed] + branches: [dev] + +jobs: + label-linked-issues: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read + steps: + - name: Add "fixed in dev" label to linked issues + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ''; + const title = pr.title || ''; + + // Combine title and body for searching + const text = `${title} ${body}`; + + // Find issue references with keywords (fixes, closes, resolves, etc.) + const keywordPattern = /(close[sd]?|fix(es|ed)?|resolve[sd]?)\s+#(\d+)/gi; + const matches = text.matchAll(keywordPattern); + + // Also find simple #123 references without keywords + const simplePattern = /#(\d+)/g; + const simpleMatches = text.matchAll(simplePattern); + + // Collect all issue numbers + const issueNumbers = new Set(); + + // Add issues with keywords (definitely linked) + for (const match of matches) { + issueNumbers.add(match[3]); + } + + // Add simple references (might be linked) + for (const match of simpleMatches) { + issueNumbers.add(match[1]); + } + + // Label each issue + for (const issueNumber of issueNumbers) { + try { + console.log(`Adding "fixed in dev" label to issue #${issueNumber}`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parseInt(issueNumber), + labels: ['fixed in dev'] + }); + } catch (error) { + console.log(`Could not label issue #${issueNumber}: ${error.message}`); + // Continue with other issues even if one fails + } + } + + if (issueNumbers.size === 0) { + console.log('No linked issues found in PR'); + } else { + console.log(`Labeled ${issueNumbers.size} issue(s) as "fixed in dev"`); + }
.github/workflows/llm-tests.yml+69 −46 modified@@ -27,45 +27,55 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install - pdm install -d + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Run LLM registry tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - pdm run pytest tests/test_llm/test_llm_registry.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_llm_registry.py -v" - name: Run LLM integration tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - pdm run pytest tests/test_llm/test_llm_integration.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_llm_integration.py -v" - name: Run API LLM integration tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - pdm run pytest tests/test_llm/test_api_llm_integration.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_api_llm_integration.py -v" - name: Run LLM edge case tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - pdm run pytest tests/test_llm/test_llm_edge_cases.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_llm_edge_cases.py -v" - name: Run LLM benchmark tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - pdm run pytest tests/test_llm/test_llm_benchmarks.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_llm_benchmarks.py -v" llm-example-tests: runs-on: ubuntu-latest @@ -75,32 +85,45 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Test basic custom LLM example run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - timeout 60s pdm run python examples/llm_integration/basic_custom_llm.py || true + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "timeout 60s python examples/llm_integration/basic_custom_llm.py || true" - name: Test mock LLM example run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - timeout 60s pdm run python examples/llm_integration/mock_llm_example.py || true - - - name: Test advanced custom LLM example + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "timeout 60s python examples/llm_integration/mock_llm_example.py || true" + + - name: Test provider switching example + run: | + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "timeout 60s python examples/llm_integration/switch_providers.py || true" + + - name: Test custom research example run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - timeout 60s pdm run python examples/llm_integration/advanced_custom_llm.py || true + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "timeout 60s python examples/llm_integration/custom_research_example.py || true"
.github/workflows/metrics-analytics-tests.yml+175 −0 added@@ -0,0 +1,175 @@ +name: Metrics & Analytics Tests + +on: + push: + paths: + - 'src/local_deep_research/metrics/**' + - 'src/local_deep_research/web/static/js/metrics/**' + - 'src/local_deep_research/web/templates/metrics/**' + - 'tests/ui_tests/test_metrics*.js' + - 'tests/ui_tests/test_cost*.js' + pull_request: + types: [opened, synchronize, reopened] + schedule: + # Run weekly on Sundays at 1 AM UTC + - cron: '0 1 * * 0' + workflow_dispatch: + +jobs: + metrics-analytics-tests: + runs-on: ubuntu-latest + name: Metrics & Analytics Test Suite + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev xvfb + + - name: Install dependencies + run: | + pdm sync -d + cd tests/ui_tests && npm install + + - name: Install browser + run: | + npx puppeteer browsers install chrome + + - name: Setup directories + run: | + mkdir -p data + mkdir -p tests/ui_tests/screenshots/metrics + mkdir -p tests/ui_tests/results/metrics + + - name: Start server + run: | + export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app & + echo $! > server.pid + + # Wait for server + for i in {1..30}; do + if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then + echo "Server ready" + break + fi + sleep 1 + done + + - name: Run metrics and analytics tests + run: | + export DISPLAY=:99 + cd tests/ui_tests + + # Create test runner for metrics tests + cat > run_metrics_tests.js << 'EOF' + const { spawn } = require('child_process'); + + const tests = [ + // Star reviews analytics + { name: 'Star Reviews', file: 'test_star_reviews.js' } + + // TODO: Add these tests as they are implemented: + // { name: 'Metrics Dashboard', file: 'test_metrics_dashboard.js' }, + // { name: 'Cost Analytics', file: 'test_cost_analytics.js' }, + // { name: 'Metrics Verification', file: 'test_metrics_verification.js' }, + // { name: 'Metrics Full Flow', file: 'test_metrics_full_flow.js' }, + // { name: 'Metrics Display', file: 'test_metrics_display.js' }, + // { name: 'Metrics Browser', file: 'test_metrics_browser.js' }, + // { name: 'Metrics with LLM', file: 'test_metrics_with_llm.js' }, + // { name: 'Simple Metrics', file: 'test_simple_metrics.js' }, + // { name: 'Simple Cost', file: 'test_simple_cost.js' } + ]; + + let passed = 0; + let failed = 0; + + async function runTest(test) { + console.log(`\n📊 Running: ${test.name}`); + return new Promise((resolve) => { + const testProcess = spawn('node', [test.file], { + stdio: 'inherit', + env: { ...process.env, HEADLESS: 'true' } + }); + + const timeout = setTimeout(() => { + testProcess.kill(); + console.log(`⏱️ ${test.name} timed out`); + failed++; + resolve(); + }, 60000); // 60 second timeout per test + + testProcess.on('close', (code) => { + clearTimeout(timeout); + if (code === 0) { + console.log(`✅ ${test.name} passed`); + passed++; + } else { + console.log(`❌ ${test.name} failed`); + failed++; + } + resolve(); + }); + }); + } + + async function runAll() { + console.log('Starting Metrics & Analytics Test Suite\n'); + + for (const test of tests) { + // Check if file exists before trying to run + const fs = require('fs'); + if (fs.existsSync(test.file)) { + await runTest(test); + } else { + console.log(`⚠️ Skipping ${test.name} - file not found`); + } + } + + console.log(`\n📈 Results: ${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); + } + + runAll(); + EOF + + xvfb-run -a -s "-screen 0 1920x1080x24" node run_metrics_tests.js + + - name: Upload metrics screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: metrics-screenshots + path: tests/ui_tests/screenshots/metrics/ + + - name: Upload metrics results + if: always() + uses: actions/upload-artifact@v4 + with: + name: metrics-test-results + path: tests/ui_tests/results/metrics/ + + - name: Stop server + if: always() + run: | + if [ -f server.pid ]; then + kill $(cat server.pid) || true + fi
.github/workflows/news-tests.yml+101 −0 added@@ -0,0 +1,101 @@ +name: News Feature Tests + +on: + push: + paths: + - 'src/local_deep_research/web/static/js/news/**' + - 'src/local_deep_research/web/templates/news/**' + - 'src/local_deep_research/news/**' + - 'tests/ui_tests/test_news*.js' + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +jobs: + news-tests: + runs-on: ubuntu-latest + name: News Feature Test Suite + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev xvfb + + - name: Install dependencies + run: | + pdm sync -d + cd tests/ui_tests && npm install + + - name: Install browser + run: | + npx puppeteer browsers install chrome + + - name: Setup directories + run: | + mkdir -p data + mkdir -p tests/ui_tests/screenshots/news + + - name: Start server + run: | + export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app & + echo $! > server.pid + + # Wait for server + for i in {1..30}; do + if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then + echo "Server ready" + break + fi + sleep 1 + done + + - name: Run news feature tests + run: | + export DISPLAY=:99 + cd tests/ui_tests + + # Run all news-related tests + for test in test_news*.js; do + if [ -f "$test" ] && [[ ! "$test" == *"debug"* ]]; then + echo "Running $test..." + xvfb-run -a -s "-screen 0 1920x1080x24" node "$test" || true + fi + done + + - name: Run news API tests + run: | + pdm run pytest tests/test_news/ -v --tb=short || true + + - name: Upload news test screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: news-test-screenshots + path: tests/ui_tests/screenshots/news/ + + - name: Stop server + if: always() + run: | + if [ -f server.pid ]; then + kill $(cat server.pid) || true + fi
.github/workflows/performance-tests.yml+256 −0 added@@ -0,0 +1,256 @@ +name: Performance Tests + +on: + push: + branches: [ main, dev, develop ] + pull_request: + branches: [ main, dev, develop ] + workflow_dispatch: + schedule: + # Run performance tests weekly on Sunday at 3 AM UTC + - cron: '0 3 * * 0' + +jobs: + performance-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + + services: + redis: + image: redis:alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v3 + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.11' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-perf-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-perf- + ${{ runner.os }}-pip- + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y redis-tools + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev + + - name: Install Python dependencies + run: | + pdm sync -d + + - name: Set up test directories + run: | + mkdir -p ~/.local/share/local-deep-research/encrypted_databases + mkdir -p ~/.local/share/local-deep-research + + - name: Initialize database with test data + run: | + cd src + cat > init_perf_db.py << 'EOF' + import os + from pathlib import Path + # Ensure data directory exists + data_dir = Path.home() / '.local' / 'share' / 'local-deep-research' + data_dir.mkdir(parents=True, exist_ok=True) + (data_dir / 'encrypted_databases').mkdir(parents=True, exist_ok=True) + + from local_deep_research.database.auth_db import init_auth_database, get_auth_db_session + from local_deep_research.database.models.auth import User + from local_deep_research.database.encrypted_db import db_manager + + # Initialize auth database + init_auth_database() + + # Create test users and their databases + session = get_auth_db_session() + for i in range(10): + username = f'perftest{i}' + # Create user in auth database (no password stored) + user = User(username=username) + session.add(user) + session.commit() + + # Create user's encrypted database with password + engine = db_manager.create_user_database(username, 'testpass123') + session.close() + print('Databases initialized successfully') + EOF + pdm run python init_perf_db.py + + - name: Start application server + env: + REDIS_URL: redis://localhost:6379 + FLASK_ENV: testing + SECRET_KEY: perf-test-secret-key + run: | + cd src + # Start server and get its PID + pdm run python -m local_deep_research.web.app > server.log 2>&1 & + SERVER_PID=$! + echo "Server PID: $SERVER_PID" + + # Wait for server to start + for i in {1..30}; do + if curl -f http://127.0.0.1:5000 2>/dev/null; then + echo "Server is ready after $i seconds" + break + fi + # Check if process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "Server process died!" + echo "Server log:" + cat server.log + exit 1 + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + + # Final check + if ! curl -f http://127.0.0.1:5000 2>/dev/null; then + echo "Server failed to start after 30 seconds" + echo "Server log:" + cat server.log + exit 1 + fi + + - name: Run database performance tests + run: | + # Note: No dedicated performance test directory exists + # Run any available database tests + pdm run pytest tests/database/test_benchmark_models.py -v || true + + - name: Run API performance tests + run: | + # Note: No dedicated API performance tests exist + echo "Skipping API performance tests - no test files available" + + - name: Run load tests with Locust + run: | + # Note: No performance test directory or locustfile exists + echo "Skipping Locust load tests - no test files available" + + - name: Run memory profiling + run: | + # Profile memory usage of key operations + cat > memory_profile.py << 'EOFSCRIPT' + import tracemalloc + import requests + import json + + tracemalloc.start() + + # Simulate API calls + base_url = 'http://127.0.0.1:5000' + session = requests.Session() + + # Login + session.post(f'{base_url}/auth/login', json={'username': 'perftest0', 'password': 'testpass123'}) + + # Make various API calls + for i in range(10): + session.get(f'{base_url}/api/research/history') + session.get(f'{base_url}/api/user/stats') + + current, peak = tracemalloc.get_traced_memory() + print(f'Current memory usage: {current / 1024 / 1024:.2f} MB') + print(f'Peak memory usage: {peak / 1024 / 1024:.2f} MB') + + tracemalloc.stop() + EOFSCRIPT + pdm run python memory_profile.py + + - name: Run frontend performance tests + run: | + cd tests/ui_tests + npm install + + # Run available metrics tests as performance indicators + echo "Running metrics tests as performance indicators..." + node test_metrics.js || true + + - name: Analyze Python performance hotspots + run: | + # Profile a typical request flow + cat > profile_requests.py << 'EOFSCRIPT' + import requests + import time + + session = requests.Session() + base_url = 'http://127.0.0.1:5000' + + # Login + session.post(f'{base_url}/auth/login', json={'username': 'perftest0', 'password': 'testpass123'}) + + # Simulate typical user actions + for _ in range(5): + session.get(f'{base_url}/api/research/history') + session.get(f'{base_url}/api/models') + time.sleep(0.5) + EOFSCRIPT + pdm run py-spy record -d 10 -o profile.svg -- pdm run python profile_requests.py || true + + - name: Generate performance report + if: always() + run: | + echo "Performance Test Report" + echo "======================" + echo "" + echo "Test Categories:" + echo "- Database query performance" + echo "- API response times" + echo "- Memory usage profiling" + echo "- Load testing results" + echo "- Frontend rendering performance" + echo "" + + # Check for performance regression + if [ -d .benchmarks ]; then + echo "Benchmark History Available" + cat > check_benchmarks.py << 'EOFSCRIPT' + import json + import os + + if os.path.exists('.benchmarks'): + benchmark_files = [f for f in os.listdir('.benchmarks') if f.endswith('.json')] + if benchmark_files: + latest = sorted(benchmark_files)[-1] + with open(f'.benchmarks/{latest}') as f: + data = json.load(f) + if 'benchmarks' in data: + print('Recent benchmark results:') + for bench in data['benchmarks'][:5]: + print(f" {bench.get('name', 'N/A')}: {bench.get('stats', {}).get('mean', 'N/A'):.4f}s") + EOFSCRIPT + pdm run python check_benchmarks.py || true + fi + + - name: Upload performance artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: performance-test-artifacts + path: | + .benchmarks/ + profile.svg + tests/performance/*.html + src/server.log
.github/workflows/pre-commit.yml+6 −1 modified@@ -17,7 +17,12 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev - name: Run pre-commit uses: pre-commit/action@v3.0.1
.github/workflows/publish.yml+11 −1 modified@@ -16,10 +16,20 @@ jobs: with: fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Set up PDM uses: pdm-project/setup-pdm@v4 with: - python-version: '3.10' + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev - name: Build package run: pdm build
.github/workflows/security-tests.yml+143 −0 added@@ -0,0 +1,143 @@ +name: Security Tests + +on: + push: + branches: [ main, dev, develop ] + pull_request: + branches: [ main, dev, develop ] + workflow_dispatch: + schedule: + # Run security tests daily at 2 AM UTC + - cron: '0 2 * * *' + +jobs: + security-tests: + runs-on: ubuntu-latest + timeout-minutes: 25 + + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_security_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-security-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-security- + ${{ runner.os }}-pip- + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.11' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev + + - name: Install dependencies + run: | + pdm sync -d + pdm add bandit safety sqlparse pytest pytest-cov --no-sync + pdm sync + + - name: Run Bandit security linter + run: | + bandit -r src/ -f json -o bandit-report.json || true + if [ -f bandit-report.json ]; then + echo "Bandit security scan completed" + fi + + - name: Check for known vulnerabilities with Safety + run: | + safety check --json > safety-report.json || true + echo "Safety vulnerability scan completed" + + - name: Run SQL injection tests + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_security_db + run: | + # Run SQL injection specific tests + python -m pytest tests/security/test_sql_injection.py -v --tb=short || true + python -m pytest tests/test_sql_injection_prevention.py -v --tb=short || true + + - name: Run XSS prevention tests + run: | + python -m pytest tests/security/test_xss_prevention.py -v --tb=short || true + python -m pytest tests/test_xss_prevention.py -v --tb=short || true + + - name: Run CSRF protection tests + run: | + python -m pytest tests/security/test_csrf_protection.py -v --tb=short || true + python -m pytest tests/test_csrf_protection.py -v --tb=short || true + + - name: Run authentication security tests + run: | + python -m pytest tests/security/test_auth_security.py -v --tb=short || true + python -m pytest tests/test_password_security.py -v --tb=short || true + python -m pytest tests/test_session_security.py -v --tb=short || true + + - name: Run API security tests + run: | + python -m pytest tests/security/test_api_security.py -v --tb=short || true + python -m pytest tests/test_rate_limiting.py -v --tb=short || true + python -m pytest tests/test_api_authentication.py -v --tb=short || true + + - name: Run input validation tests + run: | + python -m pytest tests/security/test_input_validation.py -v --tb=short || true + python -m pytest tests/test_input_sanitization.py -v --tb=short || true + + - name: Check for hardcoded secrets + run: | + # Check for potential secrets in code + grep -r -E "(api[_-]?key|secret[_-]?key|password|token)" src/ --include="*.py" | \ + grep -v -E "(os\.environ|getenv|config\[|placeholder|example|test)" | \ + grep -E "=\s*['\"]" || echo "No hardcoded secrets found" + + - name: Generate security report + if: always() + run: | + echo "Security Test Report" + echo "===================" + echo "" + if [ -f bandit-report.json ]; then + echo "Bandit Security Issues:" + python -c "import json; data=json.load(open('bandit-report.json')); print(f' High: {len([i for i in data.get(\"results\", []) if i[\"issue_severity\"] == \"HIGH\"])}'); print(f' Medium: {len([i for i in data.get(\"results\", []) if i[\"issue_severity\"] == \"MEDIUM\"])}'); print(f' Low: {len([i for i in data.get(\"results\", []) if i[\"issue_severity\"] == \"LOW\"])}')" || true + fi + echo "" + if [ -f safety-report.json ]; then + echo "Known Vulnerabilities:" + python -c "import json; data=json.load(open('safety-report.json')); print(f' Total: {len(data) if isinstance(data, list) else 0}')" || true + fi + + - name: Upload security reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: security-reports + path: | + bandit-report.json + safety-report.json
.github/workflows/tests.yml+63 −68 modified@@ -17,17 +17,8 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Set up Node.js uses: actions/setup-node@v4 @@ -40,8 +31,23 @@ jobs: - name: Run unit tests only run: | - export LDR_USE_FALLBACK_LLM=true - cd tests && pdm run python run_all_tests.py unit-only + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e LDR_TESTING_WITH_MOCKS=true \ + -w /app \ + ldr-test \ + sh -c "python -m pytest -v \ + tests/test_settings_manager.py \ + tests/test_google_pse.py \ + tests/test_wikipedia_url_security.py \ + tests/test_search_engines_enhanced.py \ + tests/test_utils.py \ + tests/test_database_initialization.py \ + tests/rate_limiting/ \ + tests/retriever_integration/ \ + tests/feature_tests/ \ + tests/fix_tests/" - name: Run JavaScript infrastructure tests run: | @@ -56,17 +62,8 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install Python dependencies - run: | - pdm install + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Set up Node.js uses: actions/setup-node@v4 @@ -79,7 +76,11 @@ jobs: - name: Run Python infrastructure tests run: | - pdm run pytest tests/infrastructure_tests/test_*.py -v + docker run --rm \ + -v $PWD:/app \ + -w /app \ + ldr-test \ + sh -c "pytest tests/infrastructure_tests/test_*.py -v" - name: Run JavaScript infrastructure tests run: | @@ -95,23 +96,18 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install - pdm install -d + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Run CI test profile run: | - export LDR_USE_FALLBACK_LLM=true - cd tests && python run_all_tests.py ci --no-server-start + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e LDR_TESTING_WITH_MOCKS=true \ + -w /app \ + ldr-test \ + sh -c "cd tests && python run_all_tests.py ci --no-server-start" # Full tests for PRs to main/dev branches and main branch pushes full-tests: @@ -126,48 +122,41 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install - pdm install -d + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Install Node.js for UI tests uses: actions/setup-node@v4 with: node-version: '18' - name: Install UI test dependencies - run: cd tests && npm install + run: | + export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + cd tests && npm install - name: Install infrastructure test dependencies run: | cd tests/infrastructure_tests && npm install - - name: Start application server + - name: Start application server in Docker run: | - export LDR_USE_FALLBACK_LLM=true - echo "Starting web server..." - pdm run ldr-web 2>&1 | tee server.log & - echo $! > server.pid + docker run -d \ + --name ldr-server \ + -p 5000:5000 \ + -e LDR_USE_FALLBACK_LLM=true \ + ldr-test ldr-web # Wait for server to be ready for i in {1..60}; do if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then echo "Server is ready after $i seconds" break fi - if ! kill -0 $(cat server.pid) 2>/dev/null; then - echo "Server process died!" + if ! docker ps --filter "name=ldr-server" --filter "status=running" -q | grep -q .; then + echo "Server container died!" echo "Server log:" - cat server.log + docker logs ldr-server exit 1 fi echo "Waiting for server... ($i/60)" @@ -178,15 +167,21 @@ jobs: if ! curl -f http://localhost:5000/api/v1/health 2>/dev/null; then echo "Server failed to start after 60 seconds" echo "Server log:" - cat server.log + docker logs ldr-server exit 1 fi - name: Run optimized full test suite (including UI tests) run: | - export LDR_USE_FALLBACK_LLM=true - export CI=true - cd tests && pdm run python run_all_tests.py full + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e LDR_TESTING_WITH_MOCKS=true \ + -e CI=true \ + --network host \ + -w /app \ + ldr-test \ + sh -c "cd tests && python run_all_tests.py full" - name: Run JavaScript infrastructure tests run: | @@ -195,10 +190,8 @@ jobs: - name: Stop server if: always() run: | - if [ -f server.pid ]; then - kill $(cat server.pid) || true - rm server.pid - fi + docker stop ldr-server || true + docker rm ldr-server || true - name: Upload test results and screenshots if: always() @@ -209,3 +202,5 @@ jobs: tests/test_results.json tests/screenshots/ tests/ui_tests/screenshots/ +# Force CI rebuild +# Force CI rebuild - network issue
.github/workflows/text-optimization-tests.yml+57 −0 added@@ -0,0 +1,57 @@ +name: Text Optimization Tests + +on: + push: + paths: + - 'src/local_deep_research/text_optimization/**' + - 'tests/text_optimization/**' + - '.github/workflows/text-optimization-tests.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/local_deep_research/text_optimization/**' + - 'tests/text_optimization/**' + workflow_dispatch: + +jobs: + test-text-optimization: + runs-on: ubuntu-latest + name: Text Optimization Module Tests + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev + + - name: Install dependencies + run: | + # Install in development mode to ensure all modules are available + pdm sync -d + + - name: Run text optimization tests + run: | + pdm run pytest tests/text_optimization/ -v --tb=short + + - name: Run coverage report + run: | + pdm run pytest tests/text_optimization/ --cov=src/local_deep_research/text_optimization --cov-report=xml --cov-report=term + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: text-optimization + name: text-optimization-coverage
.github/workflows/ui-tests.yml+13 −4 modified@@ -29,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Set up Node.js uses: actions/setup-node@v4 @@ -38,11 +38,18 @@ jobs: - name: Set up PDM uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev - name: Install dependencies run: | - pdm install - pdm install -d + # Install in development mode to ensure all modules are available + pdm sync -d cd tests && npm install - name: Install browser dependencies @@ -59,8 +66,9 @@ jobs: - name: Start application server run: | export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH echo "Starting web server..." - pdm run ldr-web 2>&1 | tee server.log & + pdm run python -m local_deep_research.web.app 2>&1 | tee server.log & echo $! > server.pid - name: Wait for server to be ready @@ -93,6 +101,7 @@ jobs: run: | export LDR_USE_FALLBACK_LLM=true export DISPLAY=:99 + export SKIP_FLAKY_TESTS=true cd tests/ui_tests && xvfb-run -a -s "-screen 0 1920x1080x24" node run_all_tests.js - name: Upload UI test screenshots
.github/workflows/version_check.yml+1 −1 modified@@ -26,7 +26,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13.2' + python-version: '3.12' - name: Check and auto-bump version if: "!contains(github.event.head_commit.message, 'chore: auto-bump version')"
.gitignore+38 −4 modified@@ -5,24 +5,32 @@ # Allow directories (needed for git to traverse) !*/ -# Allow specific source code files -!*.py +# Allow specific source code files (but not .py in root - see below) !*.js !*.html !*.css !*.json -!*.md !*.yml !*.yaml !*.sh !*.cfg !*.ipynb !*.template +# Allow Python files everywhere except root +!**/*.py +/*.py + +# Explicitly allow specific Python files in root that are needed +# (Comment out any you don't need) + # Allow specific project files !LICENSE !README !README.md +!CHANGELOG.md +!CONTRIBUTING.md +!LICENSE.md !Dockerfile !pyproject.toml !pdm.lock @@ -33,6 +41,16 @@ !.pre-commit-config.yaml !.isort.cfg + +# Block JSON files in root directory (except package.json which is explicitly allowed above) +/*.json +!package.json + +# Allow pre-commit hooks +!.pre-commit-hooks/ +!.pre-commit-hooks/*.py + + # Block all other dot files/folders .* .*/ @@ -61,6 +79,12 @@ examples/benchmarks/examples/benchmarks/results/ examples/*/results/ **/results/*/ +# Block test output and JSON files +tests/**/results/ +tests/**/*.json +tests/ui_tests/*.json +tests/ui_tests/results/ + # Still block Python cache and build artifacts even if they match patterns above __pycache__/ **/__pycache__/ @@ -195,4 +219,14 @@ screenshots/ # Ignore cookiecutter-generated files. docker-compose.*.yml -scripts/*.sh +backup/ + +# Security - ignore generated secret keys +.secret_key +.cache_key_secret + +# Allow MD files only in specific directories +!docs/**/*.md +!tests/**/*.md +!examples/**/*.md +!cookiecutter-docker/**/*.md
pdm.lock+1681 −1396 modified.pre-commit-config.yaml+62 −0 modified@@ -21,3 +21,65 @@ repos: args: [ --fix ] # Run the formatter. - id: ruff-format + - repo: local + hooks: + - id: custom-code-checks + name: Custom Code Quality Checks + entry: .pre-commit-hooks/custom-checks.py + language: script + files: \.py$ + description: "Check for loguru usage, logger.exception vs logger.error, raw SQL, redundant {e} in logger.exception, and non-UTC datetime usage" + - id: check-env-vars + name: Environment Variable Access Check + entry: .pre-commit-hooks/check-env-vars.py + language: script + files: \.py$ + description: "Ensure environment variables are accessed through SettingsManager" + - id: file-whitelist-check + name: File Whitelist Security Check + entry: .pre-commit-hooks/file-whitelist-check.sh + language: script + types: [file] + description: "Check for allowed file types and file sizes" + - id: check-deprecated-db-connection + name: Check for deprecated get_db_connection usage + entry: .pre-commit-hooks/check-deprecated-db.py + language: script + files: \.py$ + description: "Ensure code uses per-user database connections instead of deprecated shared database" + - id: check-ldr-db-usage + name: Check for ldr.db usage + entry: .pre-commit-hooks/check-ldr-db.py + language: script + files: \.py$ + description: "Prevent usage of shared ldr.db database - all data must use per-user encrypted databases" + - id: check-research-id-type + name: Check for incorrect research_id type hints + entry: .pre-commit-hooks/check-research-id-type.py + language: script + files: \.py$ + description: "Ensure research_id is always treated as string/UUID, never as int" + - id: check-deprecated-settings-wrapper + name: Check for deprecated get_setting_from_db_main_thread usage + entry: .pre-commit-hooks/check-deprecated-settings-wrapper.py + language: script + files: \.py$ + description: "Prevent usage of redundant get_setting_from_db_main_thread wrapper - use SettingsManager directly" + - id: check-datetime-timezone + name: Check DateTime columns have timezone + entry: scripts/pre_commit/check_datetime_timezone.py + language: script + files: \.py$ + description: "Ensure all SQLAlchemy DateTime columns have timezone=True" + - id: check-session-context-manager + name: Check for try/finally session patterns + entry: .pre-commit-hooks/check-session-context-manager.py + language: script + files: \.py$ + description: "Ensure SQLAlchemy sessions use context managers instead of try/finally blocks" + - id: check-pathlib-usage + name: Check for os.path usage + entry: .pre-commit-hooks/check-pathlib-usage.py + language: script + files: \.py$ + description: "Enforce using pathlib.Path instead of os.path for better cross-platform compatibility"
.pre-commit-hooks/check-deprecated-db.py+112 −0 added@@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to check for usage of deprecated database connection methods. +Ensures code uses per-user database connections instead of the deprecated shared database. +""" + +import sys +import re +import os + +# Set environment variable for pre-commit hooks to allow unencrypted databases +os.environ["LDR_ALLOW_UNENCRYPTED"] = "true" + + +def check_file(filepath): + """Check a single file for deprecated database usage.""" + issues = [] + + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + lines = content.split("\n") + + # Pattern to detect get_db_connection usage + db_connection_pattern = re.compile(r"\bget_db_connection\s*\(") + + # Pattern to detect direct imports of get_db_connection + import_pattern = re.compile( + r"from\s+[\w.]+\s+import\s+.*\bget_db_connection\b" + ) + + # Check for usage + for i, line in enumerate(lines, 1): + if db_connection_pattern.search(line): + issues.append( + f"{filepath}:{i}: Usage of deprecated get_db_connection()" + ) + + if import_pattern.search(line): + issues.append( + f"{filepath}:{i}: Import of deprecated get_db_connection" + ) + + # Also check for patterns that suggest using shared database + if "from ..web.models.database import get_db_connection" in content: + issues.append( + f"{filepath}: Imports deprecated get_db_connection from database module" + ) + + # Check for SQLite connections to shared database + shared_db_pattern = re.compile(r"sqlite3\.connect\s*\([^)]*ldr\.db") + for i, line in enumerate(lines, 1): + if ( + shared_db_pattern.search(line) + and "get_user_db_session" not in content + ): + issues.append( + f"{filepath}:{i}: Direct SQLite connection to shared database - use get_user_db_session() instead" + ) + + return issues + + +def main(): + """Main function to check all provided files.""" + if len(sys.argv) < 2: + print("No files to check") + return 0 + + all_issues = [] + + for filepath in sys.argv[1:]: + # Skip the database.py file itself (it contains the deprecated function definition) + if "web/models/database.py" in filepath: + continue + + # Skip migration scripts and test files that might legitimately need shared DB access + if any( + skip in filepath + for skip in ["migrations/", "tests/", "test_", ".pre-commit-hooks/"] + ): + continue + + issues = check_file(filepath) + all_issues.extend(issues) + + if all_issues: + print("❌ Deprecated database connection usage detected!\n") + print("The shared database (get_db_connection) is deprecated.") + print( + "Please use get_user_db_session(username) for per-user database access.\n" + ) + print("Issues found:") + for issue in all_issues: + print(f" - {issue}") + print("\nExample fix:") + print(" # Old (deprecated):") + print(" conn = get_db_connection()") + print(" cursor = conn.cursor()") + print(" # ... SQL query execution ...") + print() + print(" # New (correct):") + print(" from flask import session") + print(" username = session.get('username', 'anonymous')") + print(" with get_user_db_session(username) as db_session:") + print(" results = db_session.query(Model).filter(...).all()") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-deprecated-settings-wrapper.py+141 −0 added@@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to warn about usage of deprecated get_setting_from_db_main_thread wrapper. + +This function is deprecated because it's redundant - use the SettingsManager directly +with proper session context management instead. + +NOTE: This hook currently only warns about usage to allow gradual migration. +""" + +import ast +import sys +from pathlib import Path +from typing import List, Tuple + + +def check_file(filepath: Path) -> List[Tuple[int, str]]: + """Check a single Python file for deprecated wrapper usage. + + Args: + filepath: Path to the Python file to check + + Returns: + List of (line_number, error_message) tuples + """ + errors = [] + + try: + content = filepath.read_text() + + # Check for imports + if "get_setting_from_db_main_thread" in content: + lines = content.split("\n") + for i, line in enumerate(lines, 1): + if "get_setting_from_db_main_thread" in line: + if "from" in line and "import" in line: + errors.append( + ( + i, + "Importing deprecated get_setting_from_db_main_thread - use SettingsManager with proper session context", + ) + ) + elif not line.strip().startswith("#"): + # Check if it's a function call (not in a comment) + errors.append( + ( + i, + "Using deprecated get_setting_from_db_main_thread - use SettingsManager with get_user_db_session context manager", + ) + ) + + # Also parse the AST to catch any dynamic usage + try: + tree = ast.parse(content) + for node in ast.walk(tree): + if ( + isinstance(node, ast.Name) + and node.id == "get_setting_from_db_main_thread" + ): + errors.append( + ( + node.lineno, + "Reference to deprecated get_setting_from_db_main_thread function", + ) + ) + except SyntaxError: + # File has syntax errors, skip AST check + pass + + except Exception as e: + print(f"Error checking {filepath}: {e}", file=sys.stderr) + + return errors + + +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_errors = [] + + for filepath_str in files_to_check: + filepath = Path(filepath_str) + + # Skip non-Python files + if filepath.suffix != ".py": + continue + + # Skip the file that defines the function (db_utils.py) and this hook itself + if filepath.name in [ + "db_utils.py", + "check-deprecated-settings-wrapper.py", + ]: + continue + + errors = check_file(filepath) + if errors: + all_errors.append((filepath, errors)) + + if all_errors: + print( + "\n⚠️ Warning: Found usage of deprecated get_setting_from_db_main_thread wrapper:\n" + ) + print( + "This function is deprecated and will be removed in a future version." + ) + print( + "For Flask routes/views, use SettingsManager with proper session context:\n" + ) + print( + " from local_deep_research.database.session_context import get_user_db_session" + ) + print( + " from local_deep_research.utilities.db_utils import get_settings_manager" + ) + print("") + print(" with get_user_db_session(username) as db_session:") + print( + " settings_manager = get_settings_manager(db_session, username)" + ) + print(" value = settings_manager.get_setting(key, default)") + print("\nFor background threads, use settings_snapshot pattern.") + print("\nFiles with deprecated usage:") + + for filepath, errors in all_errors: + print(f"\n {filepath}:") + for line_num, error_msg in errors: + print(f" Line {line_num}: {error_msg}") + + # Return 1 to fail and enforce migration + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-env-vars.py+208 −0 added@@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Simple pre-commit hook to check for direct os.environ usage. +This is a lightweight check - comprehensive validation happens in CI. +""" + +import ast +import sys + + +# Files/patterns where direct os.environ access is allowed +ALLOWED_PATTERNS = { + # Configuration and settings + "settings/", + "config/", + # Tests + "test_", + "_test.py", + "tests/", + # Scripts and utilities + "scripts/", + ".pre-commit-hooks/", + # Example and optimization scripts + "examples/", + # Specific modules that need direct access (bootstrap/config) + "log_utils.py", # Logging configuration + "server_config.py", # Server configuration + # Database initialization (needs env vars before DB exists) + "alembic/", + "migrations/", + "encrypted_db.py", + "sqlcipher_utils.py", +} + +# System environment variables that are always allowed +SYSTEM_VARS = { + "PATH", + "HOME", + "USER", + "PYTHONPATH", + "TMPDIR", + "TEMP", + "DEBUG", + "CI", + "GITHUB_ACTIONS", + "TESTING", # External testing flag +} + + +class EnvVarChecker(ast.NodeVisitor): + def __init__(self, filename: str): + self.filename = filename + self.errors = [] + + def visit_Call(self, node): + # Check for os.environ.get() or os.getenv() + is_environ_get = False + env_var_name = None + + # Pattern 1: os.environ.get("VAR_NAME") + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and isinstance(node.func.value, ast.Attribute) + and node.func.value.attr == "environ" + and isinstance(node.func.value.value, ast.Name) + and node.func.value.value.id == "os" + ): + is_environ_get = True + if node.args and isinstance(node.args[0], ast.Constant): + env_var_name = node.args[0].value + + # Pattern 2: os.getenv("VAR_NAME") + elif ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "getenv" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "os" + ): + is_environ_get = True + if node.args and isinstance(node.args[0], ast.Constant): + env_var_name = node.args[0].value + + if is_environ_get and env_var_name: + # Allow system vars + if env_var_name in SYSTEM_VARS: + return self.generic_visit(node) + + # Check if file is in allowed location + if not self._is_file_allowed(): + # For LDR_ vars, suggest using SettingsManager + if env_var_name.startswith("LDR_"): + self.errors.append( + ( + node.lineno, + f"Environment variable '{env_var_name}' should be accessed through SettingsManager, not os.environ", + ) + ) + # For other vars, generic warning + else: + self.errors.append( + ( + node.lineno, + f"Direct access to environment variable '{env_var_name}' - consider using SettingsManager", + ) + ) + + self.generic_visit(node) + + def visit_Subscript(self, node): + # Check for os.environ["VAR_NAME"] pattern + if ( + isinstance(node.value, ast.Attribute) + and node.value.attr == "environ" + and isinstance(node.value.value, ast.Name) + and node.value.value.id == "os" + and isinstance(node.slice, ast.Constant) + ): + env_var_name = node.slice.value + + # Allow system vars + if env_var_name in SYSTEM_VARS: + return self.generic_visit(node) + + if not self._is_file_allowed(): + if env_var_name.startswith("LDR_"): + self.errors.append( + ( + node.lineno, + f"Environment variable '{env_var_name}' should be accessed through SettingsManager, not os.environ", + ) + ) + else: + self.errors.append( + ( + node.lineno, + f"Direct access to environment variable '{env_var_name}' - consider using SettingsManager", + ) + ) + + self.generic_visit(node) + + def _is_file_allowed(self) -> bool: + """Check if this file is allowed to use os.environ directly.""" + for pattern in ALLOWED_PATTERNS: + if pattern in self.filename: + return True + return False + + +def check_file(filename: str) -> bool: + """Check a single Python file for direct env var access.""" + if not filename.endswith(".py"): + return True + + try: + with open(filename, "r", encoding="utf-8") as f: + content = f.read() + except Exception as e: + print(f"Error reading {filename}: {e}") + return False + + try: + tree = ast.parse(content, filename=filename) + checker = EnvVarChecker(filename) + checker.visit(tree) + + if checker.errors: + print(f"\n{filename}:") + for line_num, error in checker.errors: + print(f" Line {line_num}: {error}") + return False + + except SyntaxError: + # Skip files with syntax errors + pass + except Exception as e: + print(f"Error parsing {filename}: {e}") + return False + + return True + + +def main(): + """Main function to check all staged Python files.""" + if len(sys.argv) < 2: + print("Usage: check-env-vars.py <file1> <file2> ...") + sys.exit(1) + + files_to_check = sys.argv[1:] + has_errors = False + + for filename in files_to_check: + if not check_file(filename): + has_errors = True + + if has_errors: + print("\n⚠️ Direct environment variable access detected!") + print("\nFor LDR_ variables, use SettingsManager instead of os.environ") + print("See issue #598 for migration details") + print("\nNote: Full validation runs in CI") + sys.exit(1) + else: + sys.exit(0) + + +if __name__ == "__main__": + main()
.pre-commit-hooks/check-ldr-db.py+93 −0 added@@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to prevent usage of ldr.db (shared database). +All data should be stored in per-user encrypted databases. +""" + +import sys +import re +import os +from pathlib import Path + +# Set environment variable for pre-commit hooks to allow unencrypted databases +os.environ["LDR_ALLOW_UNENCRYPTED"] = "true" + + +def check_file_for_ldr_db(file_path): + """Check if a file contains references to ldr.db.""" + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + except (UnicodeDecodeError, IOError): + # Skip binary files or files we can't read + return [] + + # Pattern to find ldr.db references + pattern = r"ldr\.db" + matches = [] + + for line_num, line in enumerate(content.splitlines(), 1): + if re.search(pattern, line, re.IGNORECASE): + # Skip comments and documentation + stripped = line.strip() + if not ( + stripped.startswith("#") + or stripped.startswith("//") + or stripped.startswith("*") + or stripped.startswith('"""') + or stripped.startswith("'''") + ): + matches.append((line_num, line.strip())) + + return matches + + +def main(): + """Main function to check all Python files for ldr.db usage.""" + # Get all Python files from command line arguments + files_to_check = sys.argv[1:] if len(sys.argv) > 1 else [] + + if not files_to_check: + # If no files specified, check all Python files + src_dir = Path(__file__).parent.parent / "src" + files_to_check = list(src_dir.rglob("*.py")) + + violations = [] + + for file_path in files_to_check: + file_path = Path(file_path) + + # Only skip this hook file itself + if file_path.name == "check-ldr-db.py": + continue + + matches = check_file_for_ldr_db(file_path) + if matches: + violations.append((file_path, matches)) + + if violations: + print("❌ DEPRECATED ldr.db USAGE DETECTED!") + print("=" * 60) + print("The shared ldr.db database is deprecated.") + print("All data must be stored in per-user encrypted databases.") + print("=" * 60) + + for file_path, matches in violations: + print(f"\n📄 {file_path}") + for line_num, line in matches: + print(f" Line {line_num}: {line}") + + print("\n" + "=" * 60) + print("MIGRATION REQUIRED:") + print("1. Store user-specific data in encrypted per-user databases") + print("2. Use get_user_db_session() instead of shared database access") + print("3. See migration guide in documentation") + print("=" * 60) + + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-pathlib-usage.py+217 −0 added@@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to enforce using pathlib.Path instead of os.path. + +This hook checks for os.path usage in Python files and suggests +using pathlib.Path instead for better cross-platform compatibility +and more modern Python code. +""" + +import argparse +import ast +import sys +from pathlib import Path +from typing import List, Tuple + + +class OsPathChecker(ast.NodeVisitor): + """AST visitor to find os.path usage.""" + + def __init__(self, filename: str): + self.filename = filename + self.violations: List[Tuple[int, str]] = [] + self.has_os_import = False + self.has_os_path_import = False + + def visit_Import(self, node: ast.Import) -> None: + """Check for 'import os' statements.""" + for alias in node.names: + if alias.name == "os": + self.has_os_import = True + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + """Check for 'from os import path' or 'from os.path import ...' statements.""" + if node.module == "os" and any( + alias.name == "path" for alias in node.names + ): + self.has_os_path_import = True + self.violations.append( + ( + node.lineno, + "Found 'from os import path' - use 'from pathlib import Path' instead", + ) + ) + elif node.module == "os.path": + self.has_os_path_import = True + imported_names = [alias.name for alias in node.names] + self.violations.append( + ( + node.lineno, + f"Found 'from os.path import {', '.join(imported_names)}' - use pathlib.Path methods instead", + ) + ) + self.generic_visit(node) + + def visit_Attribute(self, node: ast.Attribute) -> None: + """Check for os.path.* usage.""" + if ( + isinstance(node.value, ast.Name) + and node.value.id == "os" + and node.attr == "path" + and self.has_os_import + ): + # This is os.path usage + # Try to get the specific method being called + parent = getattr(node, "parent", None) + if parent and isinstance(parent, ast.Attribute): + method = parent.attr + # Skip os.path.expandvars as it has no pathlib equivalent + if method == "expandvars": + return + suggestion = get_pathlib_equivalent(f"os.path.{method}") + else: + suggestion = "Use pathlib.Path instead" + + self.violations.append( + (node.lineno, f"Found os.path usage - {suggestion}") + ) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + """Check for direct calls to os.path functions.""" + if isinstance(node.func, ast.Attribute): + # Store parent reference for better context + node.func.parent = node + + # Check for os.path.* calls + if ( + isinstance(node.func.value, ast.Attribute) + and isinstance(node.func.value.value, ast.Name) + and node.func.value.value.id == "os" + and node.func.value.attr == "path" + ): + method = node.func.attr + # Skip os.path.expandvars as it has no pathlib equivalent + if method == "expandvars": + return + suggestion = get_pathlib_equivalent(f"os.path.{method}") + self.violations.append( + (node.lineno, f"Found os.path.{method}() - {suggestion}") + ) + self.generic_visit(node) + + +def get_pathlib_equivalent(os_path_call: str) -> str: + """Get the pathlib equivalent for common os.path operations.""" + equivalents = { + "os.path.join": "Use Path() / 'subpath' or Path().joinpath()", + "os.path.exists": "Use Path().exists()", + "os.path.isfile": "Use Path().is_file()", + "os.path.isdir": "Use Path().is_dir()", + "os.path.dirname": "Use Path().parent", + "os.path.basename": "Use Path().name", + "os.path.abspath": "Use Path().resolve()", + "os.path.realpath": "Use Path().resolve()", + "os.path.expanduser": "Use Path().expanduser()", + "os.path.split": "Use Path().parent and Path().name", + "os.path.splitext": "Use Path().stem and Path().suffix", + "os.path.getsize": "Use Path().stat().st_size", + "os.path.getmtime": "Use Path().stat().st_mtime", + "os.path.normpath": "Use Path() - it normalizes automatically", + # Note: os.path.expandvars has no pathlib equivalent and is allowed + "os.path.expandvars": "(No pathlib equivalent - allowed)", + } + return equivalents.get(os_path_call, "Use pathlib.Path equivalent method") + + +def check_file( + filepath: Path, allow_legacy: bool = False +) -> List[Tuple[str, int, str]]: + """ + Check a Python file for os.path usage. + + Args: + filepath: Path to the Python file to check + allow_legacy: If True, only check modified lines (not implemented yet) + + Returns: + List of (filename, line_number, violation_message) tuples + """ + try: + content = filepath.read_text() + tree = ast.parse(content, filename=str(filepath)) + except SyntaxError as e: + print(f"Syntax error in {filepath}: {e}", file=sys.stderr) + return [] + except Exception as e: + print(f"Error reading {filepath}: {e}", file=sys.stderr) + return [] + + checker = OsPathChecker(str(filepath)) + checker.visit(tree) + + return [(str(filepath), line, msg) for line, msg in checker.violations] + + +def main() -> int: + """Main entry point for the pre-commit hook.""" + parser = argparse.ArgumentParser( + description="Check for os.path usage and suggest pathlib alternatives" + ) + parser.add_argument( + "filenames", + nargs="*", + help="Python files to check", + ) + parser.add_argument( + "--allow-legacy", + action="store_true", + help="Allow os.path in existing code (only check new/modified lines)", + ) + + args = parser.parse_args() + + # List of files that are allowed to use os.path (legacy or special cases) + ALLOWED_FILES = { + "src/local_deep_research/utilities/log_utils.py", # May need os.path for low-level operations + "src/local_deep_research/config/paths.py", # Already migrated but may have legacy code + ".pre-commit-hooks/check-pathlib-usage.py", # This file itself + } + + violations = [] + for filename in args.filenames: + filepath = Path(filename) + + # Skip non-Python files + if not filename.endswith(".py"): + continue + + # Skip allowed files + if any(filename.endswith(allowed) for allowed in ALLOWED_FILES): + continue + + file_violations = check_file(filepath, args.allow_legacy) + violations.extend(file_violations) + + if violations: + print("\n❌ Found os.path usage - please use pathlib.Path instead:\n") + for filename, line, message in violations: + print(f" {filename}:{line}: {message}") + + print( + "\n💡 Tip: pathlib.Path provides a more modern and cross-platform API." + ) + print( + " Example: Path('dir') / 'file.txt' instead of os.path.join('dir', 'file.txt')" + ) + print( + "\n📚 See https://docs.python.org/3/library/pathlib.html for more information.\n" + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-research-id-type.py+97 −0 added@@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to check for incorrect research_id type hints. +Research IDs are UUIDs and should always be treated as strings, never as integers. +""" + +import sys +import re +import os + +# Set environment variable for pre-commit hooks to allow unencrypted databases +os.environ["LDR_ALLOW_UNENCRYPTED"] = "true" + + +def check_file(filepath): + """Check a single file for incorrect research_id patterns.""" + errors = [] + + with open(filepath, "r", encoding="utf-8") as f: + lines = f.readlines() + + # Patterns to check for + patterns = [ + # Flask route with int type + ( + r"<int:research_id>", + "Flask route uses <int:research_id> - should be <string:research_id>", + ), + # Type hints with int + ( + r"research_id:\s*int", + "Type hint uses research_id: int - should be research_id: str", + ), + # Function parameters with int conversion + ( + r"int\(research_id\)", + "Converting research_id to int - research IDs are UUIDs/strings", + ), + # Integer comparison patterns + ( + r"research_id\s*==\s*\d+", + "Comparing research_id to integer - research IDs are UUIDs/strings", + ), + ] + + for line_num, line in enumerate(lines, 1): + for pattern, message in patterns: + if re.search(pattern, line): + errors.append(f"{filepath}:{line_num}: {message}") + errors.append(f" {line.strip()}") + + return errors + + +def main(): + """Main entry point.""" + # Get files to check from command line arguments + files_to_check = sys.argv[1:] + + if not files_to_check: + print("No files to check") + return 0 + + all_errors = [] + + for filepath in files_to_check: + # Skip non-Python files + if not filepath.endswith(".py"): + continue + + # Skip test files, migration files, and pre-commit hooks (they might have legitimate int usage) + if ( + "test_" in filepath + or "migration" in filepath.lower() + or ".pre-commit-hooks" in filepath + ): + continue + + errors = check_file(filepath) + all_errors.extend(errors) + + if all_errors: + print("Research ID type errors found:") + print("-" * 80) + for error in all_errors: + print(error) + print("-" * 80) + print( + f"Total errors: {len([e for e in all_errors if not e.startswith(' ')])}" + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-session-context-manager.py+188 −0 added@@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to detect try/finally session patterns and suggest context managers. + +This hook checks for SQLAlchemy session management patterns that use try/finally +blocks and suggests replacing them with context managers for better resource +management and cleaner code. +""" + +import ast +import sys +from pathlib import Path +from typing import List, Tuple + + +class SessionPatternChecker(ast.NodeVisitor): + """AST visitor to detect try/finally session patterns.""" + + def __init__(self, filename: str): + self.filename = filename + self.issues: List[Tuple[int, str]] = [] + self.functions_and_methods = [] + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """Visit function definitions to check for session patterns.""" + self._check_function_for_pattern(node) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + """Visit async function definitions to check for session patterns.""" + self._check_function_for_pattern(node) + self.generic_visit(node) + + def _check_function_for_pattern(self, func_node) -> None: + """Check a function body for try/finally session patterns.""" + # Look for session = Session() followed by try/finally + for i, stmt in enumerate(func_node.body): + # Check if this is a session assignment + if isinstance(stmt, ast.Assign): + session_var = self._get_session_var_from_assign(stmt) + if session_var: + # Look for a try/finally block that follows + for next_stmt in func_node.body[ + i + 1 : i + 3 + ]: # Check next 2 statements + if ( + isinstance(next_stmt, ast.Try) + and next_stmt.finalbody + ): + # Check if finally has session.close() + if self._has_session_close_in_finally( + next_stmt.finalbody, session_var + ): + self.issues.append( + ( + stmt.lineno, + f"Found try/finally session pattern. Consider using 'with self.Session() as {session_var}:' instead", + ) + ) + break + + def _get_session_var_from_assign(self, assign_node: ast.Assign) -> str: + """Check if an assignment is creating a session and return the variable name.""" + if isinstance(assign_node.value, ast.Call) and self._is_session_call( + assign_node.value + ): + if assign_node.targets and isinstance( + assign_node.targets[0], ast.Name + ): + return assign_node.targets[0].id + return None + + def _is_session_call(self, call_node: ast.Call) -> bool: + """Check if a call node is creating a SQLAlchemy session.""" + # Check for self.Session() pattern + if isinstance(call_node.func, ast.Attribute): + if call_node.func.attr in ( + "Session", + "get_session", + "create_session", + ): + return True + # Check for Session() pattern + elif isinstance(call_node.func, ast.Name): + if call_node.func.id in ( + "Session", + "get_session", + "create_session", + ): + return True + return False + + def _has_session_close_in_finally( + self, finalbody: List[ast.stmt], session_var: str + ) -> bool: + """Check if finally block contains session.close().""" + for stmt in finalbody: + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): + # Check for session.close() pattern + if ( + isinstance(stmt.value.func, ast.Attribute) + and stmt.value.func.attr == "close" + ): + # Check if it's our session variable + if ( + isinstance(stmt.value.func.value, ast.Name) + and stmt.value.func.value.id == session_var + ): + return True + return False + + +def check_file(filepath: Path) -> List[Tuple[str, int, str]]: + """Check a single Python file for try/finally session patterns.""" + issues = [] + + try: + content = filepath.read_text() + tree = ast.parse(content, filename=str(filepath)) + + checker = SessionPatternChecker(str(filepath)) + checker.visit(tree) + + for line_no, message in checker.issues: + issues.append((str(filepath), line_no, message)) + + except SyntaxError as e: + # Skip files with syntax errors + print(f"Syntax error in {filepath}: {e}", file=sys.stderr) + except Exception as e: + print(f"Error checking {filepath}: {e}", file=sys.stderr) + + return issues + + +def main(): + """Main entry point for the pre-commit hook.""" + # Get list of files to check from command line arguments + files_to_check = sys.argv[1:] if len(sys.argv) > 1 else [] + + if not files_to_check: + print("No files to check") + return 0 + + all_issues = [] + + for filepath_str in files_to_check: + filepath = Path(filepath_str) + + # Skip non-Python files + if not filepath.suffix == ".py": + continue + + # Skip test files and migration files + if "test" in filepath.parts or "migration" in filepath.parts: + continue + + issues = check_file(filepath) + all_issues.extend(issues) + + # Report issues + if all_issues: + print( + "\n❌ Found try/finally session patterns that should use context managers:\n" + ) + for filepath, line_no, message in all_issues: + print(f" {filepath}:{line_no}: {message}") + + print("\n💡 Tip: Replace try/finally blocks with context managers:") + print(" Before:") + print(" session = self.Session()") + print(" try:") + print(" # operations") + print(" session.commit()") + print(" finally:") + print(" session.close()") + print("\n After:") + print(" with self.Session() as session:") + print(" # operations") + print(" session.commit()") + print("\n") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/custom-checks.py+466 −0 added@@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +""" +Custom pre-commit hook for Local Deep Research project. +Checks for: +1. If loguru is used instead of standard logging +2. If logger.exception is used instead of logger.error for error handling +3. That no raw SQL is used, only ORM methods +4. That ORM models (classes inheriting from Base) are defined in models/ folders +5. That logger.exception doesn't include redundant {e} in the message +""" + +import ast +import sys +import re +import os +from typing import List, Tuple + +# Set environment variable for pre-commit hooks to allow unencrypted databases +os.environ["LDR_ALLOW_UNENCRYPTED"] = "true" + + +class CustomCodeChecker(ast.NodeVisitor): + def __init__(self, filename: str): + self.filename = filename + self.errors = [] + self.has_loguru_import = False + self.has_standard_logging_import = False + self.in_except_handler = False + self.has_base_import = False + self.has_declarative_base_import = False + + def visit_Import(self, node): + for alias in node.names: + if alias.name == "logging": + self.has_standard_logging_import = True + # Allow standard logging in specific files that need it + if not ( + "log_utils.py" in self.filename + or "app_factory.py" in self.filename + ): + self.errors.append( + ( + node.lineno, + "Use loguru instead of standard logging library", + ) + ) + elif alias.name == "loguru": + self.has_loguru_import = True + self.generic_visit(node) + + def visit_ImportFrom(self, node): + if node.module == "logging": + self.has_standard_logging_import = True + # Allow standard logging in specific files that need it + if not ( + "log_utils.py" in self.filename + or "app_factory.py" in self.filename + ): + self.errors.append( + ( + node.lineno, + "Use loguru instead of standard logging library", + ) + ) + elif node.module == "loguru": + self.has_loguru_import = True + elif node.module and "sqlalchemy" in node.module: + # Check for SQLAlchemy ORM imports + for name in node.names: + if name.name == "declarative_base": + self.has_declarative_base_import = True + # Also check for database.models.base imports + elif node.module and ( + "models.base" in node.module or "models" in node.module + ): + for name in node.names: + if name.name == "Base": + self.has_base_import = True + self.generic_visit(node) + + def visit_Try(self, node): + # Visit try body normally (not in exception handler) + for child in node.body: + self.visit(child) + + # Visit exception handlers with the flag set + for handler in node.handlers: + self.visit(handler) + + # Visit else and finally clauses normally + for child in node.orelse: + self.visit(child) + for child in node.finalbody: + self.visit(child) + + def visit_ExceptHandler(self, node): + # Track when we're inside an exception handler + old_in_except = self.in_except_handler + self.in_except_handler = True + # Only visit the body of the exception handler + for child in node.body: + self.visit(child) + self.in_except_handler = old_in_except + + def visit_Call(self, node): + # Check for logger.error usage in exception handlers (should use logger.exception instead) + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "error" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "logger" + and self.in_except_handler + ): + # Skip if the error message indicates it's not actually an exception context + # (e.g., "Cannot queue error update" which is a logic error, not an exception) + skip_patterns = [ + "Cannot queue", + "no username provided", + "Path validation error", + "not available. Please install", # ImportError messages about missing packages + ] + + # Try to check if this is in a conditional (if/else) rather than direct except body + # Check both Constant (regular strings) and JoinedStr (f-strings) + if node.args: + if isinstance(node.args[0], ast.Constant): + error_msg = str(node.args[0].value) + if any(pattern in error_msg for pattern in skip_patterns): + self.generic_visit(node) + return + elif isinstance(node.args[0], ast.JoinedStr): + # For f-strings, check the string parts + for value in node.args[0].values: + if isinstance(value, ast.Constant) and any( + pattern in str(value.value) + for pattern in skip_patterns + ): + self.generic_visit(node) + return + + self.errors.append( + ( + node.lineno, + "Use logger.exception() instead of logger.error() in exception handlers", + ) + ) + self.generic_visit(node) + + def visit_ClassDef(self, node): + # Check if this class inherits from Base (SQLAlchemy model) + for base in node.bases: + base_name = "" + if isinstance(base, ast.Name): + base_name = base.id + elif isinstance(base, ast.Attribute): + base_name = base.attr + + if base_name == "Base": + # This is an ORM model - check if it's in the models folder + if ( + "/models/" not in self.filename + and not self.filename.endswith("/models.py") + ): + # Allow exceptions for test files and migrations + if not ( + "test" in self.filename.lower() + or "migration" in self.filename.lower() + or "migrate" in self.filename.lower() + or "alembic" in self.filename.lower() + ): + self.errors.append( + ( + node.lineno, + f"ORM model '{node.name}' should be defined in a models/ folder, not in {self.filename}", + ) + ) + self.generic_visit(node) + + +def check_raw_sql(content: str, filename: str) -> List[Tuple[int, str]]: + """Check for raw SQL usage patterns.""" + errors = [] + lines = content.split("\n") + + # Skip checking this file itself (contains regex patterns that look like SQL) + if "custom-checks.py" in filename: + return errors + + # More specific patterns for database execute calls to avoid false positives + db_execute_patterns = [ + r"cursor\.execute\s*\(", # cursor.execute() + r"cursor\.executemany\s*\(", # cursor.executemany() + r"conn\.execute\s*\(", # connection.execute() + r"connection\.execute\s*\(", # connection.execute() + r"session\.execute\s*\(\s*[\"']", # session.execute() with raw SQL string + ] + + # SQL statement patterns (only check if they appear to be raw SQL strings) + sql_statement_patterns = [ + r"[\"']\s*SELECT\s+.*FROM\s+", # Raw SELECT in strings + r"[\"']\s*INSERT\s+INTO\s+", # Raw INSERT in strings + r"[\"']\s*UPDATE\s+.*SET\s+", # Raw UPDATE in strings + r"[\"']\s*DELETE\s+FROM\s+", # Raw DELETE in strings + r"[\"']\s*CREATE\s+TABLE\s+", # Raw CREATE TABLE in strings + r"[\"']\s*DROP\s+TABLE\s+", # Raw DROP TABLE in strings + r"[\"']\s*ALTER\s+TABLE\s+", # Raw ALTER TABLE in strings + ] + + # Allowed patterns (ORM usage and legitimate cases) + allowed_patterns = [ + r"session\.query\(", + r"\.filter\(", + r"\.filter_by\(", + r"\.join\(", + r"\.order_by\(", + r"\.group_by\(", + r"\.add\(", + r"\.merge\(", + r"Query\(", + r"relationship\(", + r"Column\(", + r"Table\(", + r"text\(", # SQLAlchemy text() function for raw SQL + r"#.*SQL", # Comments mentioning SQL + r"\"\"\".*SQL", # Docstrings mentioning SQL + r"'''.*SQL", # Docstrings mentioning SQL + r"f[\"'].*{", # f-strings (often used for dynamic ORM queries) + ] + + for line_num, line in enumerate(lines, 1): + line_stripped = line.strip() + + # Skip comments, docstrings, and empty lines + if ( + line_stripped.startswith("#") + or line_stripped.startswith('"""') + or line_stripped.startswith("'''") + or not line_stripped + ): + continue + + # Check if line has allowed patterns first + has_allowed_pattern = any( + re.search(pattern, line, re.IGNORECASE) + for pattern in allowed_patterns + ) + + if has_allowed_pattern: + continue + + # Check for database execute patterns + for pattern in db_execute_patterns: + if re.search(pattern, line, re.IGNORECASE): + # Check if this might be acceptable (in migrations or tests) + is_migration = ( + "migration" in filename.lower() + or "migrate" in filename.lower() + or "alembic" in filename.lower() + or "/migrations/" in filename + ) + is_test = "test" in filename.lower() + + # Allow raw SQL in database utility files that need direct access + is_db_util = ( + "sqlcipher_utils.py" in filename + or "socket_service.py" in filename + or "thread_local_session.py" in filename + or "encrypted_db.py" in filename + ) + + # Allow raw SQL in migrations, db utils, and all test files + if not (is_migration or is_db_util or is_test): + errors.append( + ( + line_num, + f"Raw SQL execute detected: '{line_stripped[:50]}...'. Use ORM methods instead.", + ) + ) + + # Check for SQL statement patterns + for pattern in sql_statement_patterns: + if re.search(pattern, line, re.IGNORECASE): + # Check if this might be acceptable (in migrations or tests) + is_migration = ( + "migration" in filename.lower() + or "migrate" in filename.lower() + or "alembic" in filename.lower() + or "/migrations/" in filename + ) + is_test = "test" in filename.lower() + + # Allow raw SQL in database utility files that need direct access + is_db_util = ( + "sqlcipher_utils.py" in filename + or "socket_service.py" in filename + or "thread_local_session.py" in filename + or "encrypted_db.py" in filename + ) + + # Allow raw SQL in migrations, db utils, and all test files + if not (is_migration or is_db_util or is_test): + errors.append( + ( + line_num, + f"Raw SQL statement detected: '{line_stripped[:50]}...'. Use ORM methods instead.", + ) + ) + + return errors + + +def check_datetime_usage(content: str, filename: str) -> List[Tuple[int, str]]: + """Check for non-UTC datetime usage.""" + errors = [] + lines = content.split("\n") + + # Patterns to detect problematic datetime usage + datetime_patterns = [ + # datetime.now() without timezone + ( + r"datetime\.now\s*\(\s*\)", + "Use datetime.now(UTC) or utc_now() instead of datetime.now()", + ), + # datetime.utcnow() - deprecated + ( + r"datetime\.utcnow\s*\(\s*\)", + "datetime.utcnow() is deprecated. Use datetime.now(UTC) or utc_now() instead", + ), + ] + + # Files where we allow datetime.now() for specific reasons + allowed_files = [ + "test_", # Test files + "mock_", # Mock files + "/tests/", # Test directories + ] + + # Check if this file is allowed to use datetime.now() + is_allowed = any(pattern in filename.lower() for pattern in allowed_files) + + if not is_allowed: + for line_num, line in enumerate(lines, 1): + line_stripped = line.strip() + + # Skip comments and docstrings + if ( + line_stripped.startswith("#") + or line_stripped.startswith('"""') + or line_stripped.startswith("'''") + or not line_stripped + ): + continue + + # Check for problematic patterns + for pattern, message in datetime_patterns: + if re.search(pattern, line): + # Check if it's already using UTC + if ( + "datetime.now(UTC)" not in line + and "timezone.utc" not in line + ): + errors.append((line_num, message)) + + return errors + + +def check_file(filename: str) -> bool: + """Check a single Python file for violations.""" + if not filename.endswith(".py"): + return True + + try: + with open(filename, "r", encoding="utf-8") as f: + content = f.read() + except UnicodeDecodeError: + # Skip binary files + return True + except Exception as e: + print(f"Error reading {filename}: {e}") + return False + + # Check for logger.exception with redundant {e} + lines = content.split("\n") + for i, line in enumerate(lines, 1): + # Match logger.exception with f-string containing {e}, {exc}, {ex}, etc. + if re.search( + r'logger\.exception\s*\(\s*[fF]?["\'].*\{(?:e|ex|exc|exception)\}.*["\']', + line, + ): + print( + f"{filename}:{i}: logger.exception automatically includes exception details, remove {{e}} from message" + ) + return False + + # Parse AST for logging checks + try: + tree = ast.parse(content, filename=filename) + checker = CustomCodeChecker(filename) + checker.visit(tree) + + # Check for raw SQL + sql_errors = check_raw_sql(content, filename) + checker.errors.extend(sql_errors) + + # Check for datetime usage + datetime_errors = check_datetime_usage(content, filename) + checker.errors.extend(datetime_errors) + + if checker.errors: + print(f"\n{filename}:") + for line_num, error in checker.errors: + print(f" Line {line_num}: {error}") + return False + + except SyntaxError: + # Skip files with syntax errors (they'll be caught by other tools) + pass + except Exception as e: + print(f"Error parsing {filename}: {e}") + return False + + return True + + +def main(): + """Main function to check all staged Python files.""" + if len(sys.argv) < 2: + print("Usage: custom-checks.py <file1> <file2> ...") + sys.exit(1) + + files_to_check = sys.argv[1:] + has_errors = False + + print("Running custom code checks...") + + for filename in files_to_check: + if not check_file(filename): + has_errors = True + + if has_errors: + print("\n❌ Custom checks failed. Please fix the issues above.") + print("\nGuidelines:") + print("1. Use 'from loguru import logger' instead of standard logging") + print( + "2. Use 'logger.exception()' instead of 'logger.error()' in exception handlers" + ) + print( + "3. Use ORM methods instead of raw SQL execute() calls and SQL strings" + ) + print(" - Allowed: session.query(), .filter(), .add(), etc.") + print(" - Raw SQL is permitted in migration files and schema tests") + print( + "4. Define ORM models (classes inheriting from Base) in models/ folders" + ) + print( + " - Models should be in files like models/user.py or database/models/" + ) + print(" - Exception: Test files and migration files") + sys.exit(1) + else: + print("✅ All custom checks passed!") + sys.exit(0) + + +if __name__ == "__main__": + main()
.pre-commit-hooks/file-whitelist-check.sh+110 −0 added@@ -0,0 +1,110 @@ +#!/bin/bash +# Pre-commit hook adapted from GitHub workflow file-whitelist-check.yml +# Only checks the files being committed, not all files + +# Define allowed file extensions and specific files +ALLOWED_PATTERNS=( + "\.py$" + "\.js$" + "\.html$" + "\.css$" + "\.json$" + "\.md$" + "\.yml$" + "\.yaml$" + "\.sh$" + "\.cfg$" + "\.flake8$" + "\.ipynb$" + "\.template$" + "^\.gitignore$" + "^\.gitkeep$" + ".*\.gitkeep$" + ".*\.gitignore$" + "^\.pre-commit-config\.yaml$" + "^\.isort\.cfg$" + "^\.coveragerc$" + "^\.secrets\.baseline$" + "^pytest\.ini$" + "^LICENSE$" + "^README$" + "^README\.md$" + "^CONTRIBUTING\.md$" + "^SECURITY\.md$" + "^Dockerfile$" + "^pyproject\.toml$" + "^pdm\.lock$" + "^package\.json$" + "^MANIFEST\.in$" + "^\.github/CODEOWNERS$" + "^\.github/.*\.(yml|yaml|md)$" + "installers/.*\.(bat|ps1|iss|ico)$" + "docs/.*\.(png|jpg|jpeg|gif|svg)$" + "docs/.*\.ps1$" + "src/local_deep_research/web/static/sounds/.*\.mp3$" +) + +WHITELIST_VIOLATIONS=() +LARGE_FILES=() + +echo "🔍 Running file whitelist security checks..." + +# Process each file passed as argument +for file in "$@"; do + # Skip if file doesn't exist (deleted files) + if [ ! -f "$file" ]; then + continue + fi + + # 1. Whitelist check + ALLOWED=false + for pattern in "${ALLOWED_PATTERNS[@]}"; do + if echo "$file" | grep -qE "$pattern"; then + ALLOWED=true + break + fi + done + + if [ "$ALLOWED" = "false" ]; then + WHITELIST_VIOLATIONS+=("$file") + fi + + # 2. Large file check (>1MB) + FILE_SIZE=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo 0) + if [ "$FILE_SIZE" -gt 1048576 ]; then + LARGE_FILES+=("$file ($(echo $FILE_SIZE | awk '{printf "%.1fMB", $1/1024/1024}'))") + fi +done + +# Report violations +TOTAL_VIOLATIONS=0 + +if [ ${#WHITELIST_VIOLATIONS[@]} -gt 0 ]; then + echo "" + echo "❌ WHITELIST VIOLATIONS - File types not allowed in repository:" + for violation in "${WHITELIST_VIOLATIONS[@]}"; do + echo " 🚫 $violation" + done + TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#WHITELIST_VIOLATIONS[@]})) +fi + +if [ ${#LARGE_FILES[@]} -gt 0 ]; then + echo "" + echo "❌ LARGE FILES (>1MB) - Files too big for repository:" + for violation in "${LARGE_FILES[@]}"; do + echo " 📏 $violation" + done + TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#LARGE_FILES[@]})) +fi + +if [ $TOTAL_VIOLATIONS -eq 0 ]; then + echo "✅ All file whitelist checks passed!" + exit 0 +else + echo "" + echo "💡 To fix these issues:" + echo " - Add allowed file types to ALLOWED_PATTERNS" + echo " - Use Git LFS for large files" + echo "" + exit 1 +fi
__pypackages__/.gitignore+2 −0 added@@ -0,0 +1,2 @@ +* +!.gitignore
pyproject.toml+19 −5 modified@@ -36,6 +36,7 @@ dependencies = [ "flask-cors>=3.0.10", "flask-socketio>=5.1.1", "sqlalchemy>=1.4.23", + "sqlalchemy-utc>=0.14.0", "wikipedia", "arxiv>=1.4.3", "pypdf", @@ -67,6 +68,15 @@ dependencies = [ "kaleido==0.2.1", "aiohttp>=3.9.0", "tenacity>=8.0.0", + "apscheduler>=3.10.0", + "rich>=13.0.0", + "click>=8.0.0", + "flask-login>=0.6.3", + "dogpile.cache>=1.2.0", + "redis>=4.0.0", + "msgpack>=1.0.0", + "sqlcipher3-binary>=0.5.4; sys_platform == 'linux'", + "sqlcipher3>=0.5.0; sys_platform != 'linux'", ] [project.urls] @@ -86,6 +96,9 @@ include-package-data = true + + + [tool.pdm] distribution = true version = { source = "file", path = "src/local_deep_research/__version__.py" } @@ -96,17 +109,18 @@ include_packages = ["torch", "torch*"] [dependency-groups] dev = [ - "pre-commit>=4.2.0", + "pre-commit>=4.3.0", "jupyter>=1.1.1", "cookiecutter>=2.6.0", "pandas>=2.2.3", "optuna>=4.3.0", - "pytest-mock>=3.14.0", - "pytest>=8.3.5", - "pytest-cov>=6.1.1", + "pytest-mock>=3.14.1", + "pytest>=8.4.1", + "pytest-cov>=6.2.1", "pytest-timeout>=2.3.1", - "pytest-asyncio>=0.23.0", + "pytest-asyncio>=1.0.0", "ruff>=0.11.12", + "freezegun>=1.5.2", ] [tool.pytest.ini_options]
README.md+34 −18 modified@@ -55,14 +55,15 @@ It aims to help researchers, students, and professionals find accurate informati ### 🛠️ Advanced Capabilities - **[LangChain Integration](docs/LANGCHAIN_RETRIEVER_INTEGRATION.md)** - Use any vector store as a search engine -- **[REST API](docs/api-quickstart.md)** - Language-agnostic HTTP access +- **[REST API](docs/api-quickstart.md)** - Authenticated HTTP access with per-user databases - **[Benchmarking](docs/BENCHMARKING.md)** - Test and optimize your configuration - **[Analytics Dashboard](docs/analytics-dashboard.md)** - Track costs, performance, and usage metrics - **Real-time Updates** - WebSocket support for live research progress - **Export Options** - Download results as PDF or Markdown - **Research History** - Save, search, and revisit past research - **Adaptive Rate Limiting** - Intelligent retry system that learns optimal wait times - **Keyboard Shortcuts** - Navigate efficiently (ESC, Ctrl+Shift+1-5) +- **Per-User Encrypted Databases** - Secure, isolated data storage for each user ### 🌐 Search Sources @@ -94,7 +95,7 @@ It aims to help researchers, students, and professionals find accurate informati docker run -d -p 8080:8080 --name searxng searxng/searxng # Step 2: Pull and run Local Deep Research (Please build your own docker on ARM) -docker run -d -p 5000:5000 --name local-deep-research --volume 'deep-research:/install/.venv/lib/python3.13/site-packages/data/' localdeepresearch/local-deep-research +docker run -d -p 5000:5000 --name local-deep-research --volume 'deep-research:/data' -e LDR_DATA_DIR=/data localdeepresearch/local-deep-research ``` ### Option 2: Docker Compose (Recommended) @@ -174,25 +175,40 @@ python -m local_deep_research.web.app ### Python API ```python from local_deep_research.api import quick_summary - -# Simple usage -result = quick_summary("What are the latest advances in quantum computing?") -print(result["summary"]) - -# Advanced usage with custom configuration -result = quick_summary( - query="Impact of AI on healthcare", - search_tool="searxng", - search_strategy="focused-iteration", - iterations=2 -) +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +# Authentication required - use with user session +with get_user_db_session(username="your_username", password="your_password") as session: + settings_manager = CachedSettingsManager(session, "your_username") + settings_snapshot = settings_manager.get_all_settings() + + # Simple usage with settings + result = quick_summary( + query="What are the latest advances in quantum computing?", + settings_snapshot=settings_snapshot + ) + print(result["summary"]) ``` ### HTTP API -```bash -curl -X POST http://localhost:5000/api/v1/quick_summary \ - -H "Content-Type: application/json" \ - -d '{"query": "Explain CRISPR gene editing"}' +```python +import requests + +# Create session and authenticate +session = requests.Session() +session.post("http://localhost:5000/auth/login", + json={"username": "user", "password": "pass"}) + +# Get CSRF token +csrf = session.get("http://localhost:5000/auth/csrf-token").json()["csrf_token"] + +# Make API request +response = session.post( + "http://localhost:5000/research/api/start", + json={"query": "Explain CRISPR gene editing"}, + headers={"X-CSRF-Token": csrf} +) ``` [More Examples →](examples/api_usage/)
scripts/check_benchmark_db.py+125 −0 added@@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Check if benchmark runs exist in the database.""" + +import sys +from pathlib import Path + +# Add the parent directory to the path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from local_deep_research.database.session_context import get_user_db_session +from local_deep_research.database.models.benchmark import ( + BenchmarkRun, + BenchmarkResult, +) +from loguru import logger + + +def check_benchmark_database(): + """Check for benchmark runs in the database.""" + try: + # Try to find the most recent benchmark user + import glob + from local_deep_research.config.paths import get_data_directory + + data_dir = get_data_directory() + db_pattern = str( + Path(data_dir) / "encrypted_databases" / "benchmark_*.db" + ) + benchmark_dbs = glob.glob(db_pattern) + + if benchmark_dbs: + # Get the most recent benchmark user + latest_db = max( + benchmark_dbs, key=lambda x: Path(x).stat().st_mtime + ) + username = Path(latest_db).stem + print(f"Checking database for user: {username}") + else: + # Try to find most recent user database + all_dbs = glob.glob( + str(Path(data_dir) / "encrypted_databases" / "*.db") + ) + if all_dbs: + latest_db = max(all_dbs, key=lambda x: Path(x).stat().st_mtime) + username = Path(latest_db).stem + print( + f"No benchmark users found, using most recent user: {username}" + ) + else: + # Fallback to test user + username = "test" + print("No user databases found, using test user") + + # Use the user's database + with get_user_db_session(username) as session: + # Count total benchmark runs + total_runs = session.query(BenchmarkRun).count() + print(f"\nTotal benchmark runs: {total_runs}") + + if total_runs > 0: + # Get latest benchmark run + latest_run = ( + session.query(BenchmarkRun) + .order_by(BenchmarkRun.created_at.desc()) + .first() + ) + + print("\nLatest benchmark run:") + print(f" ID: {latest_run.id}") + print(f" Name: {latest_run.run_name}") + print(f" Status: {latest_run.status.value}") + print(f" Created: {latest_run.created_at}") + print(f" Total examples: {latest_run.total_examples}") + print(f" Completed examples: {latest_run.completed_examples}") + + # Count results for latest run + results_count = ( + session.query(BenchmarkResult) + .filter(BenchmarkResult.benchmark_run_id == latest_run.id) + .count() + ) + print(f" Results: {results_count}") + + # Show benchmark configuration + print("\nBenchmark configuration:") + print(f" Search config: {latest_run.search_config}") + print(f" Datasets: {latest_run.datasets_config}") + + # Show first few results if any + if results_count > 0: + print("\nFirst 3 results:") + results = ( + session.query(BenchmarkResult) + .filter( + BenchmarkResult.benchmark_run_id == latest_run.id + ) + .limit(3) + .all() + ) + + for i, result in enumerate(results, 1): + print(f"\n Result {i}:") + print(f" Question: {result.question[:100]}...") + print(f" Dataset: {result.dataset_type.value}") + print( + f" Processing time: {result.processing_time:.2f}s" + if result.processing_time + else " Processing time: N/A" + ) + print(f" Correct: {result.is_correct}") + + return True + else: + print("No benchmark runs found in database") + return False + + except Exception as e: + logger.exception("Error checking benchmark database") + print(f"Error: {e}") + return False + + +if __name__ == "__main__": + success = check_benchmark_database() + sys.exit(0 if success else 1)
scripts/check_metrics.py+102 −0 added@@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Check if metrics are being saved in the database.""" + +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.local_deep_research.database.encrypted_db import db_manager +from src.local_deep_research.database.models import TokenUsage, SearchCall + + +def check_user_metrics(username: str, password: str): + """Check metrics for a specific user.""" + print(f"\n🔍 Checking metrics for user: {username}") + print("=" * 60) + + try: + # Open database + if not db_manager.open_user_database(username, password): + print("❌ Failed to open database") + return + + # Get session + session = db_manager.get_session(username) + if not session: + print("❌ Failed to get session") + return + + # Check token usage + token_count = session.query(TokenUsage).count() + print(f"\n📊 Token Usage Records: {token_count}") + + if token_count > 0: + # Get recent token usage + recent_tokens = ( + session.query(TokenUsage) + .order_by(TokenUsage.created_at.desc()) + .limit(5) + .all() + ) + print("\n Recent token usage:") + for token in recent_tokens: + print( + f" - {token.created_at}: {token.model_name} - {token.total_tokens} tokens" + ) + print( + f" Phase: {token.research_phase}, Status: {token.success_status}" + ) + + # Check search calls + search_count = session.query(SearchCall).count() + print(f"\n🔎 Search Call Records: {search_count}") + + if search_count > 0: + # Get recent searches + recent_searches = ( + session.query(SearchCall) + .order_by(SearchCall.created_at.desc()) + .limit(5) + .all() + ) + print("\n Recent searches:") + for search in recent_searches: + print( + f" - {search.created_at}: {search.search_engine} - {search.query[:50]}..." + ) + print(f" Results: {search.results_returned}") + + session.close() + + except Exception as e: + print(f"❌ Error: {e}") + import traceback + + traceback.print_exc() + finally: + if username in db_manager.connections: + db_manager.connections.pop(username) + + +def main(): + """Check metrics for test users.""" + # Check for a specific test user (modify as needed) + test_users = [ + ("simple_1751323627595", "password"), # Latest test user + # Add more test users as needed + ] + + for username, password in test_users: + try: + check_user_metrics(username, password) + except Exception as e: + print(f"Failed to check {username}: {e}") + + print("\n" + "=" * 60) + print("✅ Metrics check complete") + + +if __name__ == "__main__": + main()
scripts/check_research_db.py+68 −0 added@@ -0,0 +1,68 @@ +#!/usr/bin/env python +"""Check if there are any researches in the database.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.resolve())) + +from src.local_deep_research.database.models import ResearchLog +from src.local_deep_research.utilities.db_utils import get_db_session + + +def check_researches(): + """Check for researches in the database.""" + # Note: This checks the shared database, not per-user databases + try: + # Check for the most recently created test user + # The test creates a user with pattern "simple_" + timestamp + + # Look for any user databases that might have been created + db_dir = ( + Path.home() + / ".local" + / "share" + / "local-deep-research" + / "encrypted_databases" + ) + if db_dir.exists(): + db_files = [ + f.name + for f in db_dir.iterdir() + if f.name.endswith(".db") + and not f.name.endswith("-wal") + and not f.name.endswith("-shm") + ] + print(f"Found {len(db_files)} user database(s)") + + # Try with the most recent test user + session = get_db_session(username="simple_1751271039631") + count = session.query(ResearchLog).count() + if count > 0: + print(f"✓ Found {count} research(es) in database") + latest = ( + session.query(ResearchLog) + .order_by(ResearchLog.created_at.desc()) + .first() + ) + if latest: + print( + f" Latest: {latest.title[:50] if latest.title else 'No title'}... (created at {latest.created_at})" + ) + return 0 + else: + print("✗ No researches found in database") + return 1 + except Exception as e: + print(f"✗ Error checking database: {e}") + import traceback + + traceback.print_exc() + return 1 + finally: + if "session" in locals(): + session.close() + + +if __name__ == "__main__": + sys.exit(check_researches())
scripts/dev/kill_servers.py+15 −14 modified@@ -3,6 +3,7 @@ import subprocess import sys import time +from pathlib import Path import psutil @@ -95,17 +96,17 @@ def start_flask_server(port=5000): # Get the virtual environment Python executable # Try multiple common venv locations venv_paths = [ - os.path.join("venv_dev", "bin", "python"), # Linux/Mac - os.path.join("venv", "bin", "python"), # Linux/Mac - os.path.join(".venv", "Scripts", "python.exe"), # Windows - os.path.join("venv_dev", "Scripts", "python.exe"), # Windows - os.path.join("venv", "Scripts", "python.exe"), # Windows + Path("venv_dev") / "bin" / "python", # Linux/Mac + Path("venv") / "bin" / "python", # Linux/Mac + Path(".venv") / "Scripts" / "python.exe", # Windows + Path("venv_dev") / "Scripts" / "python.exe", # Windows + Path("venv") / "Scripts" / "python.exe", # Windows ] venv_path = None for path in venv_paths: - if os.path.exists(path): - venv_path = path + if path.exists(): + venv_path = str(path) break if not venv_path: @@ -222,7 +223,7 @@ def start_flask_server(port=5000): return None except Exception as e: - print(f"Error starting Flask server: {str(e)}") + print(f"Error starting Flask server: {e!s}") return None @@ -235,15 +236,15 @@ def start_flask_server_windows(port=5000): # Get the virtual environment Python executable # Try multiple common venv locations venv_paths = [ - os.path.join("venv_dev", "Scripts", "python.exe"), # Windows - os.path.join("venv", "Scripts", "python.exe"), # Windows - os.path.join(".venv", "Scripts", "python.exe"), # Windows + Path("venv_dev") / "Scripts" / "python.exe", # Windows + Path("venv") / "Scripts" / "python.exe", # Windows + Path(".venv") / "Scripts" / "python.exe", # Windows ] venv_path = None for path in venv_paths: - if os.path.exists(path): - venv_path = path + if path.exists(): + venv_path = str(path) break if not venv_path: @@ -270,7 +271,7 @@ def start_flask_server_windows(port=5000): return True except Exception as e: - print(f"Error starting Flask server: {str(e)}") + print(f"Error starting Flask server: {e!s}") return None
scripts/dev/restart_server.sh+30 −0 added@@ -0,0 +1,30 @@ +#!/bin/bash +# Script to restart the LDR server + +echo "Stopping existing LDR server..." +pkill -f "python -m local_deep_research.web.app" 2>/dev/null || echo "No existing server found" + +# Wait a moment for the process to stop +sleep 1 + +echo "Starting LDR server..." +# Change to the script's parent directory (project root) +cd "$(dirname "$0")/../.." + +# Start server in background and detach from terminal +(nohup pdm run python -m local_deep_research.web.app > /tmp/ldr_server.log 2>&1 &) & +SERVER_PID=$! + +# Give it a moment to start +sleep 2 + +echo "Server started. PID: $SERVER_PID" +echo "Logs: /tmp/ldr_server.log" +echo "URL: http://127.0.0.1:5000" +echo "" +echo "To check server status: ps aux | grep 'python -m local_deep_research.web.app'" +echo "To view logs: tail -f /tmp/ldr_server.log" +echo "To stop server: pkill -f 'python -m local_deep_research.web.app'" + +# Exit immediately - don't wait for background process +exit 0
scripts/ollama_entrypoint.sh+27 −0 added@@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +# 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 gemma3:12b with ollama pull..." +ollama pull gemma3:12b +# Check if the model was pulled successfully +if [ $? -eq 0 ]; then + echo "Model pulled successfully." +else + echo "Failed to pull model." + exit 1 +fi + +# Run ollama forever. +sleep infinity
scripts/pre_commit/check_datetime_timezone.py+141 −0 added@@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Pre-commit hook to ensure all datetime columns use UtcDateTime for SQLite compatibility.""" + +import ast +import re +import sys +from pathlib import Path +from typing import List, Tuple + + +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") 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 + + # Check if file imports UtcDateTime (if it uses any DateTime columns) + has_utc_datetime_import = ( + "from sqlalchemy_utc import UtcDateTime" in content + or "from sqlalchemy_utc import utcnow, UtcDateTime" in content + ) + + # Parse the AST to find Column definitions with DateTime + try: + tree = ast.parse(content) + except SyntaxError: + # Not valid Python, skip + return violations + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + # Check if this is a Column call + if isinstance(node.func, ast.Name) and node.func.id == "Column": + # Check if first argument is DateTime + if node.args and isinstance(node.args[0], ast.Call): + datetime_call = node.args[0] + if ( + isinstance(datetime_call.func, ast.Name) + and datetime_call.func.id == "DateTime" + ): + # This should be UtcDateTime instead + line_num = node.lineno + if 0 <= line_num - 1 < len(lines): + violations.append( + ( + line_num, + lines[line_num - 1].strip(), + "Use UtcDateTime instead of DateTime for SQLite compatibility", + ) + ) + elif ( + isinstance(datetime_call.func, ast.Name) + and datetime_call.func.id == "UtcDateTime" + ): + # This is correct, but check if import exists + 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", + ) + ) + + # Also check for func.now() usage which should be utcnow() + 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", + ) + ) + # Check for datetime.utcnow or datetime.now(UTC) in 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) + + # Only check Python files in database/models directories + if file_path.suffix == ".py" and ( + "database/models" in str(file_path) or "models" in file_path.parts + ): + violations = check_datetime_columns(file_path) + if violations: + all_violations.append((file_path, violations)) + + if all_violations: + print("\n❌ DateTime 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(" Example: ") + print(" from sqlalchemy_utc import UtcDateTime, utcnow") + print(" Column(UtcDateTime, default=utcnow(), ...)\n") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.secrets.baseline+430 −0 added@@ -0,0 +1,430 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": { + "docs/elasticsearch_search_engine.md": [ + { + "type": "Secret Keyword", + "filename": "docs/elasticsearch_search_engine.md", + "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", + "is_verified": false, + "line_number": 34 + } + ], + "examples/elasticsearch_search_example.py": [ + { + "type": "Secret Keyword", + "filename": "examples/elasticsearch_search_example.py", + "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", + "is_verified": false, + "line_number": 37 + } + ], + "examples/optimization/gemini_optimization.py": [ + { + "type": "Secret Keyword", + "filename": "examples/optimization/gemini_optimization.py", + "hashed_secret": "22035eae7902ee4a696f64d1cef8668b6a12e7d3", + "is_verified": false, + "line_number": 14 + } + ], + "src/local_deep_research/advanced_search_system/answer_decoding/browsecomp_answer_decoder.py": [ + { + "type": "Base64 High Entropy String", + "filename": "src/local_deep_research/advanced_search_system/answer_decoding/browsecomp_answer_decoder.py", + "hashed_secret": "e508b4d6483c95a02bf8e5bbdb285e386e3db6f3", + "is_verified": false, + "line_number": 295 + } + ], + "src/local_deep_research/benchmarks/datasets/browsecomp.py": [ + { + "type": "Base64 High Entropy String", + "filename": "src/local_deep_research/benchmarks/datasets/browsecomp.py", + "hashed_secret": "1d278d3c888d1a2fa7eed622bfc02927ce4049af", + "is_verified": false, + "line_number": 90 + } + ], + "src/local_deep_research/web/app_factory.py": [ + { + "type": "Secret Keyword", + "filename": "src/local_deep_research/web/app_factory.py", + "hashed_secret": "66b62fe5726610b1af887a103441fb0c338147da", + "is_verified": false, + "line_number": 55 + } + ], + "src/local_deep_research/web/templates/base.html": [ + { + "type": "Base64 High Entropy String", + "filename": "src/local_deep_research/web/templates/base.html", + "hashed_secret": "ba694a3ebbdda9771f1e2cf97f847ee08f355f91", + "is_verified": false, + "line_number": 55 + } + ], + "tests/api_tests/test_browser_endpoint.py": [ + { + "type": "Secret Keyword", + "filename": "tests/api_tests/test_browser_endpoint.py", + "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", + "is_verified": false, + "line_number": 29 + } + ], + "tests/api_tests/test_research_creation.py": [ + { + "type": "Secret Keyword", + "filename": "tests/api_tests/test_research_creation.py", + "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", + "is_verified": false, + "line_number": 29 + } + ], + "tests/api_tests/test_simple_auth.py": [ + { + "type": "Secret Keyword", + "filename": "tests/api_tests/test_simple_auth.py", + "hashed_secret": "c18006fc138809314751cd1991f1e0b820fabd37", + "is_verified": false, + "line_number": 23 + } + ], + "tests/api_tests/test_without_csrf.py": [ + { + "type": "Secret Keyword", + "filename": "tests/api_tests/test_without_csrf.py", + "hashed_secret": "c18006fc138809314751cd1991f1e0b820fabd37", + "is_verified": false, + "line_number": 21 + } + ], + "tests/auth_tests/test_auth_decorators.py": [ + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_decorators.py", + "hashed_secret": "d4e0e04792fd434b5dc9c4155c178f66edcf4ed3", + "is_verified": false, + "line_number": 19 + } + ], + "tests/auth_tests/test_auth_integration.py": [ + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "c18006fc138809314751cd1991f1e0b820fabd37", + "is_verified": false, + "line_number": 100 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "e38ad214943daad1d64c102faec29de4afe9da3d", + "is_verified": false, + "line_number": 116 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "2aa60a8ff7fcd473d321e0146afd9e26df395147", + "is_verified": false, + "line_number": 135 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "fb47035803a93e720cd9209dd885770a83de1265", + "is_verified": false, + "line_number": 224 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "6211dfd2282903503f9d5ae63cf0f4322989cbf1", + "is_verified": false, + "line_number": 236 + } + ], + "tests/auth_tests/test_auth_routes.py": [ + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "c18006fc138809314751cd1991f1e0b820fabd37", + "is_verified": false, + "line_number": 81 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "e38ad214943daad1d64c102faec29de4afe9da3d", + "is_verified": false, + "line_number": 125 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "2aa60a8ff7fcd473d321e0146afd9e26df395147", + "is_verified": false, + "line_number": 126 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "1ed4fad3f8115f0a53b0f3482e7c64a8bbebe4dc", + "is_verified": false, + "line_number": 164 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "d8ecf7db8fc9ec9c31bc5c9ae2929cc599c75f8d", + "is_verified": false, + "line_number": 206 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "fb47035803a93e720cd9209dd885770a83de1265", + "is_verified": false, + "line_number": 241 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "6211dfd2282903503f9d5ae63cf0f4322989cbf1", + "is_verified": false, + "line_number": 252 + } + ], + "tests/database/test_benchmark_models.py": [ + { + "type": "Hex High Entropy String", + "filename": "tests/database/test_benchmark_models.py", + "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", + "is_verified": false, + "line_number": 43 + } + ], + "tests/database/test_database_init.py": [ + { + "type": "Secret Keyword", + "filename": "tests/database/test_database_init.py", + "hashed_secret": "5a0aee0f3af308cd6d74d617fde6592c2bc94fa3", + "is_verified": false, + "line_number": 77 + } + ], + "tests/ui_tests/auth_helper.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/auth_helper.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 8 + } + ], + "tests/ui_tests/test_check_research_thread.py": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_check_research_thread.py", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 10 + } + ], + "tests/ui_tests/test_concurrent_limit.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_concurrent_limit.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 109 + } + ], + "tests/ui_tests/test_multi_research.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_multi_research.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 185 + } + ], + "tests/ui_tests/test_queue_simple.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_queue_simple.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 125 + } + ], + "tests/ui_tests/test_research_minimal_debug.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_research_minimal_debug.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 27 + } + ], + "tests/ui_tests/test_simple_auth.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_simple_auth.js", + "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", + "is_verified": false, + "line_number": 22 + } + ], + "tests/ui_tests/test_simple_research.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_simple_research.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 23 + } + ], + "tests/ui_tests/test_simple_research_api.py": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_simple_research_api.py", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 11 + } + ], + "tests/ui_tests/test_trace_error.py": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_trace_error.py", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 41 + } + ], + "tests/ui_tests/test_uuid_fresh_db.py": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_uuid_fresh_db.py", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 82 + } + ] + }, + "generated_at": "2025-06-29T08:53:26Z" +}
src/local_deep_research/advanced_search_system/answer_decoding/browsecomp_answer_decoder.py+1 −3 modified@@ -9,13 +9,11 @@ """ import base64 -import logging +from loguru import logger import re import urllib.parse from typing import Optional, Tuple -logger = logging.getLogger(__name__) - class BrowseCompAnswerDecoder: """
src/local_deep_research/advanced_search_system/candidate_exploration/adaptive_explorer.py+1 −1 modified@@ -219,7 +219,7 @@ def _generate_query_with_strategy( return self._direct_search_query(base_query) except Exception as e: - logger.error( + logger.exception( f"Error generating query with strategy {strategy}: {e}" ) return None
src/local_deep_research/advanced_search_system/candidate_exploration/base_explorer.py+6 −6 modified@@ -144,8 +144,8 @@ def _execute_search(self, query: str) -> Dict: logger.warning(f"Unknown search result format: {type(results)}") return {"results": [], "query": query} - except Exception as e: - logger.error(f"Error executing search '{query}': {e}") + except Exception: + logger.exception(f"Error executing search '{query}'") return {"results": []} def _extract_candidates_from_results( @@ -220,8 +220,8 @@ def _generate_answer_candidates( return answers[:5] # Limit to 5 candidates max - except Exception as e: - logger.error(f"Error generating answer candidates: {e}") + except Exception: + logger.exception("Error generating answer candidates") return [] def _extract_entity_names( @@ -262,8 +262,8 @@ def _extract_entity_names( return names[:5] # Limit to top 5 per text - except Exception as e: - logger.error(f"Error extracting entity names: {e}") + except Exception: + logger.exception("Error extracting entity names") return [] def _should_continue_exploration(
src/local_deep_research/advanced_search_system/candidate_exploration/parallel_explorer.py+5 −3 modified@@ -106,7 +106,9 @@ def explore( ) except Exception as e: - logger.error(f"Error processing query '{query}': {e}") + logger.exception( + f"Error processing query '{query}': {e}" + ) # Add new candidates all_candidates.extend(round_candidates) @@ -216,8 +218,8 @@ def _generate_query_variations(self, base_query: str) -> List[str]: return queries[:4] - except Exception as e: - logger.error(f"Error generating query variations: {e}") + except Exception: + logger.exception("Error generating query variations") return [] def _generate_candidate_based_queries(
src/local_deep_research/advanced_search_system/candidate_exploration/progressive_explorer.py+5 −9 modified@@ -3,11 +3,12 @@ """ import concurrent.futures -import logging from dataclasses import dataclass, field from typing import Dict, List, Set, Tuple -logger = logging.getLogger(__name__) +from loguru import logger + +from ...utilities.thread_context import preserve_research_context @dataclass @@ -236,21 +237,16 @@ def _parallel_search( """Execute searches in parallel and return results.""" results = [] - # Import context preservation utility - from ...utilities.thread_context import ( - create_context_preserving_wrapper, - ) - def search_query(query): try: search_results = self.search_engine.run(query) return (query, search_results or []) except Exception as e: - logger.error(f"Error searching '{query}': {str(e)}") + logger.exception(f"Error searching '{query}': {e!s}") return (query, []) # Create context-preserving wrapper for the search function - context_aware_search = create_context_preserving_wrapper(search_query) + context_aware_search = preserve_research_context(search_query) # Run searches in parallel with concurrent.futures.ThreadPoolExecutor(
src/local_deep_research/advanced_search_system/constraint_checking/base_constraint_checker.py+3 −3 modified@@ -117,6 +117,6 @@ def _calculate_weighted_score( """Calculate weighted average score.""" if not constraint_scores or not weights: return 0.0 - return sum(s * w for s, w in zip(constraint_scores, weights)) / sum( - weights - ) + return sum( + s * w for s, w in zip(constraint_scores, weights, strict=False) + ) / sum(weights)
src/local_deep_research/advanced_search_system/constraint_checking/constraint_checker.py+3 −3 modified@@ -196,9 +196,9 @@ def check_candidate( if detailed_results: weights = [r["weight"] for r in detailed_results] scores = [r["score"] for r in detailed_results] - total_score = sum(s * w for s, w in zip(scores, weights)) / sum( - weights - ) + total_score = sum( + s * w for s, w in zip(scores, weights, strict=False) + ) / sum(weights) logger.info(f"Final score for {candidate.name}: {total_score:.2%}")
src/local_deep_research/advanced_search_system/constraint_checking/constraint_satisfaction_tracker.py+1 −2 modified@@ -11,11 +11,10 @@ 4. Constraint difficulty analysis """ -import logging from dataclasses import dataclass from typing import Dict, List -logger = logging.getLogger(__name__) +from loguru import logger @dataclass
src/local_deep_research/advanced_search_system/constraint_checking/evidence_analyzer.py+2 −2 modified@@ -96,8 +96,8 @@ def analyze_evidence_dual_confidence( source=evidence.get("source", "search"), ) - except Exception as e: - logger.error(f"Error analyzing evidence: {e}") + except Exception: + logger.exception("Error analyzing evidence") # Default to high uncertainty return ConstraintEvidence( positive_confidence=0.1,
src/local_deep_research/advanced_search_system/constraint_checking/intelligent_constraint_relaxer.py+1 −3 modified@@ -8,11 +8,9 @@ complex multi-constraint queries that may not have perfect matches. """ -import logging +from loguru import logger from typing import Dict, List -logger = logging.getLogger(__name__) - class IntelligentConstraintRelaxer: """
src/local_deep_research/advanced_search_system/constraint_checking/strict_checker.py+2 −2 modified@@ -187,8 +187,8 @@ def _evaluate_constraint_strictly( match = re.search(r"(\d*\.?\d+)", response) if match: return max(0.0, min(float(match.group(1)), 1.0)) - except Exception as e: - logger.error(f"Error in strict evaluation: {e}") + except Exception: + logger.exception("Error in strict evaluation") return 0.0 # Default to fail on error
src/local_deep_research/advanced_search_system/constraint_checking/threshold_checker.py+2 −2 modified@@ -207,7 +207,7 @@ def _check_constraint_satisfaction( score = float(match.group(1)) return max(0.0, min(score, 1.0)) - except Exception as e: - logger.error(f"Error checking constraint satisfaction: {e}") + except Exception: + logger.exception("Error checking constraint satisfaction") return 0.5 # Default to neutral if parsing fails
src/local_deep_research/advanced_search_system/constraints/__init__.py+1 −1 modified@@ -3,4 +3,4 @@ from .base_constraint import Constraint, ConstraintType from .constraint_analyzer import ConstraintAnalyzer -__all__ = ["Constraint", "ConstraintType", "ConstraintAnalyzer"] +__all__ = ["Constraint", "ConstraintAnalyzer", "ConstraintType"]
src/local_deep_research/advanced_search_system/evidence/base_evidence.py+2 −2 modified@@ -3,7 +3,7 @@ """ from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, UTC from enum import Enum from typing import Any, Dict, Optional @@ -47,7 +47,7 @@ class Evidence: reasoning: Optional[str] = None raw_text: Optional[str] = None timestamp: str = field( - default_factory=lambda: datetime.utcnow().isoformat() + default_factory=lambda: datetime.now(UTC).isoformat() ) metadata: Dict[str, Any] = field(default_factory=dict)
src/local_deep_research/advanced_search_system/evidence/__init__.py+1 −1 modified@@ -6,7 +6,7 @@ __all__ = [ "Evidence", - "EvidenceType", "EvidenceEvaluator", "EvidenceRequirements", + "EvidenceType", ]
src/local_deep_research/advanced_search_system/filters/cross_engine_filter.py+20 −3 modified@@ -7,7 +7,6 @@ from loguru import logger -from ...utilities.db_utils import get_db_setting from ...utilities.search_utilities import remove_think_tags from .base_filter import BaseFilter @@ -21,6 +20,7 @@ def __init__( max_results=None, default_reorder=True, default_reindex=True, + settings_snapshot=None, ): """ Initialize the cross-engine filter. @@ -30,13 +30,30 @@ def __init__( max_results: Maximum number of results to keep after filtering default_reorder: Default setting for reordering results by relevance default_reindex: Default setting for reindexing results after filtering + settings_snapshot: Settings snapshot for thread context """ super().__init__(model) # Get max_results from database settings if not provided if max_results is None: - max_results = int( - get_db_setting("search.cross_engine_max_results", 100) + # Import from thread_settings to avoid database dependencies + from ...config.thread_settings import ( + get_setting_from_snapshot, + NoSettingsContextError, ) + + try: + max_results = get_setting_from_snapshot( + "search.cross_engine_max_results", + default=100, + settings_snapshot=settings_snapshot, + ) + # Ensure we have an integer + if max_results is not None: + max_results = int(max_results) + else: + max_results = 100 + except (NoSettingsContextError, TypeError, ValueError): + max_results = 100 # Explicit default self.max_results = max_results self.default_reorder = default_reorder self.default_reindex = default_reindex
src/local_deep_research/advanced_search_system/filters/followup_relevance_filter.py+165 −0 added@@ -0,0 +1,165 @@ +""" +Follow-up Relevance Filter + +Filters and ranks past research sources based on their relevance +to follow-up questions. +""" + +from typing import Dict, List +from loguru import logger + +from .base_filter import BaseFilter +from ...utilities.search_utilities import remove_think_tags + + +class FollowUpRelevanceFilter(BaseFilter): + """ + Filters past research sources by relevance to follow-up questions. + + This filter analyzes sources from previous research and determines + which ones are most relevant to the new follow-up question. + """ + + def filter_results( + self, results: List[Dict], query: str, max_results: int = 10, **kwargs + ) -> List[Dict]: + """ + Filter search results by relevance to the follow-up query. + + Args: + results: List of source dictionaries from past research + query: The follow-up query + max_results: Maximum number of results to return (default: 10) + **kwargs: Additional parameters: + - past_findings: Summary of past findings for context + - original_query: The original research query + + Returns: + Filtered list of relevant sources + """ + if not results: + return [] + + past_findings = kwargs.get("past_findings", "") + original_query = kwargs.get("original_query", "") + + # Use LLM to select relevant sources + relevant_indices = self._select_relevant_sources( + results, query, past_findings, max_results, original_query + ) + + # Return selected sources + filtered = [results[i] for i in relevant_indices if i < len(results)] + + logger.info( + f"Filtered {len(results)} sources to {len(filtered)} relevant ones " + f"for follow-up query. Kept indices: {relevant_indices}" + ) + + return filtered + + def _select_relevant_sources( + self, + sources: List[Dict], + query: str, + context: str, + max_results: int, + original_query: str = "", + ) -> List[int]: + """ + Select relevant sources using LLM. + + Args: + sources: List of source dictionaries + query: The follow-up query + context: Past findings context + max_results: Maximum number of sources to select + original_query: The original research query + + Returns: + List of indices of relevant sources + """ + if not self.model: + # If no model available, return first max_results + return list(range(min(max_results, len(sources)))) + + # Build source list for LLM + source_list = [] + for i, source in enumerate(sources): + title = source.get("title") or "Unknown" + url = source.get("url") or "" + snippet = ( + source.get("snippet") or source.get("content_preview") or "" + )[:150] + source_list.append( + f"{i}. {title}\n URL: {url}\n Content: {snippet}" + ) + + sources_text = "\n\n".join(source_list) + + # Include context if available for better selection + context_section = "" + if context or original_query: + parts = [] + if original_query: + parts.append(f"Original research question: {original_query}") + if context: + parts.append(f"Previous research findings:\n{context}") + + context_section = f""" +Previous Research Context: +{chr(10).join(parts)} + +--- +""" + + prompt = f""" +Select the most relevant sources for answering this follow-up question based on the previous research context. +{context_section} +Follow-up question: "{query}" + +Available sources from previous research: +{sources_text} + +Instructions: +- Select sources that are most relevant to the follow-up question given the context +- Consider which sources directly address the question or provide essential information +- Think about what the user is asking for in relation to the previous findings +- Return ONLY a JSON array of source numbers (e.g., [0, 2, 5, 7]) +- Do not include any explanation or other text + +Return the indices of relevant sources as a JSON array:""" + + try: + response = self.model.invoke(prompt) + content = remove_think_tags(response.content).strip() + + # Parse JSON response + import json + + try: + indices = json.loads(content) + # Validate it's a list of integers + if not isinstance(indices, list): + raise ValueError("Response is not a list") + indices = [ + int(i) + for i in indices + if isinstance(i, (int, float)) and int(i) < len(sources) + ] + + except (json.JSONDecodeError, ValueError) as parse_error: + logger.debug( + f"Failed to parse JSON, attempting regex fallback: {parse_error}" + ) + # Fallback to regex extraction + import re + + numbers = re.findall(r"\d+", content) + indices = [int(n) for n in numbers if int(n) < len(sources)] + + return indices + except Exception as e: + logger.debug(f"LLM source selection failed: {e}") + # Fallback to first max_results sources + return list(range(min(max_results, len(sources))))
src/local_deep_research/advanced_search_system/filters/journal_reputation_filter.py+69 −18 modified@@ -6,11 +6,13 @@ from langchain_core.language_models.chat_models import BaseChatModel from loguru import logger from methodtools import lru_cache +from sqlalchemy.orm import Session from ...config.llm_config import get_llm +from ...database.models import Journal +from ...database.session_context import get_user_db_session from ...search_system import AdvancedSearchSystem -from ...utilities.db_utils import get_db_session, get_db_setting -from ...web.database.models import Journal +from ...utilities.thread_context import get_search_context from ...web_search_engines.search_engine_factory import create_search_engine from .base_filter import BaseFilter @@ -35,6 +37,7 @@ def __init__( max_context: int | None = None, exclude_non_published: bool | None = None, quality_reanalysis_period: timedelta | None = None, + settings_snapshot: Dict[str, Any] | None = None, ): """ Args: @@ -49,6 +52,7 @@ def __init__( don't have an associated journal publication. quality_reanalysis_period: Period at which to update journal quality assessments. + settings_snapshot: Settings snapshot for thread context. """ super().__init__(model) @@ -58,40 +62,64 @@ def __init__( self.__threshold = reliability_threshold if self.__threshold is None: + # Import here to avoid circular import + from ...config.search_config import get_setting_from_snapshot + self.__threshold = int( - get_db_setting("search.journal_reputation.threshold", 4) + get_setting_from_snapshot( + "search.journal_reputation.threshold", + 4, + settings_snapshot=settings_snapshot, + ) ) self.__max_context = max_context if self.__max_context is None: self.__max_context = int( - get_db_setting("search.journal_reputation.max_context", 3000) + get_setting_from_snapshot( + "search.journal_reputation.max_context", + 3000, + settings_snapshot=settings_snapshot, + ) ) self.__exclude_non_published = exclude_non_published if self.__exclude_non_published is None: self.__exclude_non_published = bool( - get_db_setting( - "search.journal_reputation.exclude_non_published", False + get_setting_from_snapshot( + "search.journal_reputation.exclude_non_published", + False, + settings_snapshot=settings_snapshot, ) ) self.__quality_reanalysis_period = quality_reanalysis_period if self.__quality_reanalysis_period is None: self.__quality_reanalysis_period = timedelta( days=int( - get_db_setting( - "search.journal_reputation.reanalysis_period", 365 + get_setting_from_snapshot( + "search.journal_reputation.reanalysis_period", + 365, + settings_snapshot=settings_snapshot, ) ) ) + # Store settings_snapshot for later use + self.__settings_snapshot = settings_snapshot + # SearXNG is required so we can search the open web for reputational # information. - self.__engine = create_search_engine("searxng", llm=self.model) + self.__engine = create_search_engine( + "searxng", llm=self.model, settings_snapshot=settings_snapshot + ) if self.__engine is None: raise JournalFilterError("SearXNG initialization failed.") @classmethod def create_default( - cls, model: BaseChatModel | None = None, *, engine_name: str + cls, + model: BaseChatModel | None = None, + *, + engine_name: str, + settings_snapshot: Dict[str, Any] | None = None, ) -> Optional["JournalReputationFilter"]: """ Initializes a default configuration of the filter based on the settings. @@ -100,30 +128,50 @@ def create_default( model: Explicitly specify the LLM to use. engine_name: The name of the search engine. Will be used to check the enablement status for that engine. + settings_snapshot: Settings snapshot for thread context. Returns: The filter that it created, or None if filtering is disabled in the settings, or misconfigured. """ + # Import here to avoid circular import + from ...config.search_config import get_setting_from_snapshot + if not bool( - get_db_setting( + get_setting_from_snapshot( f"search.engine.web.{engine_name}.journal_reputation.enabled", True, + settings_snapshot=settings_snapshot, ) ): return None try: # Initialize the filter with default settings. - return JournalReputationFilter(model=model) + return JournalReputationFilter( + model=model, settings_snapshot=settings_snapshot + ) except JournalFilterError: - logger.error( + logger.exception( "SearXNG is not configured, but is required for " "journal reputation filtering. Disabling filtering." ) return None + @staticmethod + def __db_session() -> Session: + """ + Returns: + The database session to use. + + """ + context = get_search_context() + username = context.get("username") + password = context.get("user_password") + + return get_user_db_session(username=username, password=password) + def __make_search_system(self) -> AdvancedSearchSystem: """ Creates a new `AdvancedSearchSystem` instance. @@ -136,8 +184,9 @@ def __make_search_system(self) -> AdvancedSearchSystem: llm=self.model, search=self.__engine, # We clamp down on the default iterations and questions for speed. - max_iterations=2, + max_iterations=1, questions_per_iteration=3, + settings_snapshot=self.__settings_snapshot, ) @lru_cache(maxsize=1024) @@ -193,7 +242,9 @@ def __analyze_journal_reputation(self, journal_name: str) -> int: try: reputation_score = int(response.strip()) except ValueError: - logger.error("Failed to parse reputation score from LLM response.") + logger.exception( + "Failed to parse reputation score from LLM response." + ) raise ValueError( "Failed to parse reputation score from LLM response." ) @@ -209,7 +260,7 @@ def __add_journal_to_db(self, *, name: str, quality: int) -> None: quality: The quality assessment for the journal. """ - with get_db_session() as db_session: + with self.__db_session() as db_session: journal = db_session.query(Journal).filter_by(name=name).first() if journal is not None: journal.quality = quality @@ -275,7 +326,7 @@ def __check_result(self, result: Dict[str, Any]) -> bool: journal_name = self.__clean_journal_name(journal_name) # Check the database first. - with get_db_session() as session: + with self.__db_session() as session: journal = ( session.query(Journal).filter_by(name=journal_name).first() ) @@ -306,7 +357,7 @@ def filter_results( try: return list(filter(self.__check_result, results)) except Exception as e: - logger.error( + logger.exception( f"Journal quality filtering failed: {e}, {traceback.format_exc()}" ) return results
src/local_deep_research/advanced_search_system/findings/base_findings.py+0 −3 modified@@ -3,14 +3,11 @@ Defines the common interface and shared functionality for different findings management approaches. """ -import logging from abc import ABC, abstractmethod from typing import Dict, List from langchain_core.language_models import BaseLLM -logger = logging.getLogger(__name__) - class BaseFindingsRepository(ABC): """Abstract base class for all findings repositories."""
src/local_deep_research/advanced_search_system/findings/repository.py+13 −20 modified@@ -2,7 +2,7 @@ Findings repository for managing research findings. """ -import logging +from loguru import logger from typing import Dict, List, Union from langchain_core.documents import Document @@ -11,8 +11,6 @@ from ...utilities.search_utilities import format_findings from .base_findings import BaseFindingsRepository -logger = logging.getLogger(__name__) - def format_links(links: List[Dict]) -> str: """Format a list of links into a readable string. @@ -161,9 +159,8 @@ def format_findings_to_text( logger.info("Successfully formatted final report.") return formatted_report except Exception as e: - logger.error( - f"Error occurred during final report formatting: {str(e)}", - exc_info=True, + logger.exception( + f"Error occurred during final report formatting: {e!s}" ) # Fallback: return just the synthesized content if formatting fails return f"Error during final formatting. Raw Synthesized Content:\n\n{synthesized_content}" @@ -380,9 +377,8 @@ def target(): # Return only the synthesized content from the LLM return synthesized_content except TimeoutError as timeout_error: - logger.error( - f"LLM invocation timed out during synthesis for query '{query}': {timeout_error}", - exc_info=True, + logger.exception( + f"LLM invocation timed out during synthesis for query '{query}': {timeout_error}" ) # Return more specific error about timeout return "Error: Final answer synthesis failed due to LLM timeout. Please check your LLM service or try with a smaller query scope." @@ -421,17 +417,15 @@ def signal_handler(signum, frame): # Return only the synthesized content from the LLM return synthesized_content except TimeoutError as timeout_error: - logger.error( - f"LLM invocation timed out during synthesis for query '{query}': {timeout_error}", - exc_info=True, + logger.exception( + f"LLM invocation timed out during synthesis for query '{query}': {timeout_error}" ) # Return more specific error about timeout return "Error: Final answer synthesis failed due to LLM timeout. Please check your LLM service or try with a smaller query scope." except Exception as invoke_error: - logger.error( - f"LLM invocation failed during synthesis for query '{query}': {invoke_error}", - exc_info=True, + logger.exception( + f"LLM invocation failed during synthesis for query '{query}': {invoke_error}" ) # Attempt to determine the type of error @@ -474,13 +468,12 @@ def signal_handler(signum, frame): return "Error: Failed to synthesize final answer due to authentication issues. Please check your API keys." else: # Generic error with details - return f"Error: Failed to synthesize final answer. LLM error: {str(invoke_error)}" + return f"Error: Failed to synthesize final answer. LLM error: {invoke_error!s}" except Exception as e: # Catch potential errors during prompt construction or logging itself - logger.error( - f"Error preparing or executing synthesis for query '{query}': {str(e)}", - exc_info=True, + logger.exception( + f"Error preparing or executing synthesis for query '{query}': {e!s}" ) # Return a specific error message for synthesis failure - return f"Error: Failed to synthesize final answer from knowledge. Details: {str(e)}" + return f"Error: Failed to synthesize final answer from knowledge. Details: {e!s}"
src/local_deep_research/advanced_search_system/knowledge/base_knowledge.py+1 −3 modified@@ -2,14 +2,12 @@ Base class for knowledge extraction and generation. """ -import logging +from loguru import logger from abc import ABC, abstractmethod from typing import List from langchain_core.language_models.chat_models import BaseChatModel -logger = logging.getLogger(__name__) - class BaseKnowledgeGenerator(ABC): """Base class for generating knowledge from text."""
src/local_deep_research/advanced_search_system/knowledge/followup_context_manager.py+415 −0 added@@ -0,0 +1,415 @@ +""" +Follow-up Context Manager + +Manages and processes past research context for follow-up questions. +This is a standalone class that doesn't inherit from BaseKnowledgeGenerator +to avoid implementing many abstract methods. +""" + +from typing import Dict, List, Any, Optional +from loguru import logger + +from langchain_core.language_models.chat_models import BaseChatModel +from ...utilities.search_utilities import remove_think_tags + + +class FollowUpContextHandler: + """ + Manages past research context for follow-up research. + + This class handles: + 1. Loading and structuring past research data + 2. Summarizing findings for follow-up context + 3. Extracting relevant information for new searches + 4. Building comprehensive context for strategies + """ + + def __init__( + self, model: BaseChatModel, settings_snapshot: Optional[Dict] = None + ): + """ + Initialize the context manager. + + Args: + model: Language model for processing context + settings_snapshot: Optional settings snapshot + """ + self.model = model + self.settings_snapshot = settings_snapshot or {} + self.past_research_cache = {} + + def build_context( + self, research_data: Dict[str, Any], follow_up_query: str + ) -> Dict[str, Any]: + """ + Build comprehensive context from past research. + + Args: + research_data: Past research data including findings, sources, etc. + follow_up_query: The follow-up question being asked + + Returns: + Structured context dictionary for follow-up research + """ + logger.info(f"Building context for follow-up: {follow_up_query}") + + # Extract all components + context = { + "parent_research_id": research_data.get("research_id", ""), + "original_query": research_data.get("query", ""), + "follow_up_query": follow_up_query, + "past_findings": self._extract_findings(research_data), + "past_sources": self._extract_sources(research_data), + "key_entities": self._extract_entities(research_data), + "summary": self._create_summary(research_data, follow_up_query), + "report_content": research_data.get("report_content", ""), + "formatted_findings": research_data.get("formatted_findings", ""), + "all_links_of_system": research_data.get("all_links_of_system", []), + "metadata": self._extract_metadata(research_data), + } + + return context + + def _extract_findings(self, research_data: Dict) -> str: + """ + Extract and format findings from past research. + + Args: + research_data: Past research data + + Returns: + Formatted findings string + """ + findings_parts = [] + + # Check various possible locations for findings + if formatted := research_data.get("formatted_findings"): + findings_parts.append(formatted) + + if report := research_data.get("report_content"): + # Take first part of report if no formatted findings + if not findings_parts: + findings_parts.append(report[:2000]) + + if not findings_parts: + return "No previous findings available" + + combined = "\n\n".join(findings_parts) + return combined + + def _extract_sources(self, research_data: Dict) -> List[Dict]: + """ + Extract and structure sources from past research. + + Args: + research_data: Past research data + + Returns: + List of source dictionaries + """ + sources = [] + seen_urls = set() + + # Check all possible source fields + for field in ["resources", "all_links_of_system", "past_links"]: + if field_sources := research_data.get(field, []): + for source in field_sources: + url = source.get("url", "") + # Avoid duplicates by URL + if url and url not in seen_urls: + sources.append(source) + seen_urls.add(url) + elif not url: + # Include sources without URLs (shouldn't happen but be safe) + sources.append(source) + + return sources + + def _extract_entities(self, research_data: Dict) -> List[str]: + """ + Extract key entities from past research. + + Args: + research_data: Past research data + + Returns: + List of key entities + """ + findings = self._extract_findings(research_data) + + if not findings or not self.model: + return [] + + prompt = f""" +Extract key entities (names, places, organizations, concepts) from these research findings: + +{findings[:2000]} + +Return up to 10 most important entities, one per line. +""" + + try: + response = self.model.invoke(prompt) + entities = [ + line.strip() + for line in remove_think_tags(response.content) + .strip() + .split("\n") + if line.strip() + ] + return entities[:10] + except Exception as e: + logger.warning(f"Failed to extract entities: {e}") + return [] + + def _create_summary(self, research_data: Dict, follow_up_query: str) -> str: + """ + Create a targeted summary of past research relevant to the follow-up question. + This is used internally for building context. + + Args: + research_data: Past research data + follow_up_query: The follow-up question + + Returns: + Targeted summary for context building + """ + findings = self._extract_findings(research_data) + original_query = research_data.get("query", "") + + # For internal context, create a brief targeted summary + return self._generate_summary( + findings=findings, + query=follow_up_query, + original_query=original_query, + max_sentences=5, + purpose="context", + ) + + def _extract_metadata(self, research_data: Dict) -> Dict: + """ + Extract metadata from past research. + + Args: + research_data: Past research data + + Returns: + Metadata dictionary + """ + return { + "strategy": research_data.get("strategy", ""), + "mode": research_data.get("mode", ""), + "created_at": research_data.get("created_at", ""), + "research_meta": research_data.get("research_meta", {}), + } + + def summarize_for_followup( + self, findings: str, query: str, max_length: int = 1000 + ) -> str: + """ + Create a concise summary of findings for external use (e.g., in prompts). + This creates a length-constrained summary suitable for inclusion in LLM prompts. + + Args: + findings: Past research findings + query: Follow-up query + max_length: Maximum length of summary in characters + + Returns: + Concise summary constrained to max_length + """ + # Use the shared summary generation with specific parameters for external use + return self._generate_summary( + findings=findings, + query=query, + original_query=None, + max_sentences=max_length + // 100, # Approximate sentences based on length + purpose="prompt", + max_length=max_length, + ) + + def _generate_summary( + self, + findings: str, + query: str, + original_query: Optional[str] = None, + max_sentences: int = 5, + purpose: str = "context", + max_length: Optional[int] = None, + ) -> str: + """ + Shared summary generation logic. + + Args: + findings: Research findings to summarize + query: Follow-up query + original_query: Original research query (optional) + max_sentences: Maximum number of sentences + purpose: Purpose of summary ("context" or "prompt") + max_length: Maximum character length (optional) + + Returns: + Generated summary + """ + if not findings: + return "" + + # If findings are already short enough, return as-is + if max_length and len(findings) <= max_length: + return findings + + if not self.model: + # Fallback without model + if max_length: + return findings[:max_length] + "..." + return findings[:500] + "..." + + # Build prompt based on purpose + if purpose == "context" and original_query: + prompt = f""" +Create a brief summary of previous research findings that are relevant to this follow-up question: + +Original research question: "{original_query}" +Follow-up question: "{query}" + +Previous findings: +{findings[:3000]} + +Provide a {max_sentences}-sentence summary focusing on aspects relevant to the follow-up question. +""" + else: + prompt = f""" +Summarize these research findings in relation to the follow-up question: + +Follow-up question: "{query}" + +Findings: +{findings[:4000]} + +Create a summary of {max_sentences} sentences that captures the most relevant information. +""" + + try: + response = self.model.invoke(prompt) + summary = remove_think_tags(response.content).strip() + + # Apply length constraint if specified + if max_length and len(summary) > max_length: + summary = summary[:max_length] + "..." + + return summary + except Exception as e: + logger.warning(f"Summary generation failed: {e}") + # Fallback to truncation + if max_length: + return findings[:max_length] + "..." + return findings[:500] + "..." + + def identify_gaps( + self, research_data: Dict, follow_up_query: str + ) -> List[str]: + """ + Identify information gaps that the follow-up should address. + + Args: + research_data: Past research data + follow_up_query: Follow-up question + + Returns: + List of identified gaps + """ + findings = self._extract_findings(research_data) + + if not findings or not self.model: + return [] + + prompt = f""" +Based on the previous research and the follow-up question, identify information gaps: + +Previous research findings: +{findings[:2000]} + +Follow-up question: "{follow_up_query}" + +What specific information is missing or needs clarification? List up to 5 gaps, one per line. +""" + + try: + response = self.model.invoke(prompt) + gaps = [ + line.strip() + for line in remove_think_tags(response.content) + .strip() + .split("\n") + if line.strip() + ] + return gaps[:5] + except Exception as e: + logger.warning(f"Failed to identify gaps: {e}") + return [] + + def format_for_settings_snapshot( + self, context: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Format context for inclusion in settings snapshot. + Only includes essential metadata, not actual content. + + Args: + context: Full context dictionary + + Returns: + Minimal metadata for settings snapshot + """ + # Only include minimal metadata in settings snapshot + # Settings snapshot should be for settings, not data + return { + "followup_metadata": { + "parent_research_id": context.get("parent_research_id"), + "is_followup": True, + "has_context": bool(context.get("past_findings")), + } + } + + def get_relevant_context_for_llm( + self, context: Dict[str, Any], max_tokens: int = 2000 + ) -> str: + """ + Get a concise version of context for LLM prompts. + + Args: + context: Full context dictionary + max_tokens: Approximate maximum tokens + + Returns: + Concise context string + """ + parts = [] + + # Add original and follow-up queries + parts.append(f"Original research: {context.get('original_query', '')}") + parts.append( + f"Follow-up question: {context.get('follow_up_query', '')}" + ) + + # Add summary + if summary := context.get("summary"): + parts.append(f"\nPrevious findings summary:\n{summary}") + + # Add key entities + if entities := context.get("key_entities"): + parts.append(f"\nKey entities: {', '.join(entities[:5])}") + + # Add source count + if sources := context.get("past_sources"): + parts.append(f"\nAvailable sources: {len(sources)}") + + result = "\n".join(parts) + + # Truncate if needed (rough approximation: 4 chars per token) + max_chars = max_tokens * 4 + if len(result) > max_chars: + result = result[:max_chars] + "..." + + return result
src/local_deep_research/advanced_search_system/knowledge/standard_knowledge.py+5 −7 modified@@ -2,14 +2,12 @@ Standard knowledge generator implementation. """ -import logging -from datetime import datetime +from loguru import logger +from datetime import datetime, UTC from typing import List from .base_knowledge import BaseKnowledgeGenerator -logger = logging.getLogger(__name__) - class StandardKnowledge(BaseKnowledgeGenerator): """Standard knowledge generator implementation.""" @@ -22,7 +20,7 @@ def generate_knowledge( questions: List[str] = None, ) -> str: """Generate knowledge based on query and context.""" - now = datetime.now() + now = datetime.now(UTC) current_time = now.strftime("%Y-%m-%d") logger.info("Generating knowledge...") @@ -95,7 +93,7 @@ def generate_sub_knowledge(self, sub_query: str, context: str = "") -> str: response = self.model.invoke(prompt) return response.content except Exception as e: - logger.error(f"Error generating sub-knowledge: {str(e)}") + logger.exception(f"Error generating sub-knowledge: {e!s}") return "" def generate(self, query: str, context: str) -> str: @@ -137,7 +135,7 @@ def compress_knowledge( ) return compressed_knowledge except Exception as e: - logger.error(f"Error compressing knowledge: {str(e)}") + logger.exception(f"Error compressing knowledge: {e!s}") return current_knowledge # Return original if compression fails def format_citations(self, links: List[str]) -> str:
src/local_deep_research/advanced_search_system/questions/atomic_fact_question.py+1 −3 modified@@ -3,13 +3,11 @@ Decomposes complex queries into atomic, independently searchable facts. """ -import logging +from loguru import logger from typing import Dict, List from .base_question import BaseQuestionGenerator -logger = logging.getLogger(__name__) - class AtomicFactQuestionGenerator(BaseQuestionGenerator): """
src/local_deep_research/advanced_search_system/questions/base_question.py+0 −3 modified@@ -3,12 +3,9 @@ Defines the common interface and shared functionality for different question generation approaches. """ -import logging from abc import ABC, abstractmethod from typing import Dict, List -logger = logging.getLogger(__name__) - class BaseQuestionGenerator(ABC): """Abstract base class for all question generators."""
src/local_deep_research/advanced_search_system/questions/browsecomp_question.py+2 −3 modified@@ -2,13 +2,12 @@ BrowseComp-specific question generation that creates progressive, entity-focused searches. """ -import logging import re from typing import Dict, List -from .base_question import BaseQuestionGenerator +from loguru import logger -logger = logging.getLogger(__name__) +from .base_question import BaseQuestionGenerator class BrowseCompQuestionGenerator(BaseQuestionGenerator):
src/local_deep_research/advanced_search_system/questions/decomposition_question.py+2 −4 modified@@ -1,12 +1,10 @@ -import logging from typing import List from langchain_core.language_models import BaseLLM +from loguru import logger from .base_question import BaseQuestionGenerator -logger = logging.getLogger(__name__) - class DecompositionQuestionGenerator(BaseQuestionGenerator): """Question generator for decomposing complex queries into sub-queries.""" @@ -298,7 +296,7 @@ def generate_questions( return sub_queries[: self.max_subqueries] # Limit to max_subqueries except Exception as e: - logger.error(f"Error generating sub-questions: {str(e)}") + logger.exception(f"Error generating sub-questions: {e!s}") # Fallback to basic questions in case of error return self._generate_default_questions(query)
src/local_deep_research/advanced_search_system/questions/entity_aware_question.py+6 −7 modified@@ -2,13 +2,12 @@ Entity-aware question generation for improved entity identification. """ -import logging -from datetime import datetime +from datetime import datetime, UTC from typing import List -from .base_question import BaseQuestionGenerator +from loguru import logger -logger = logging.getLogger(__name__) +from .base_question import BaseQuestionGenerator class EntityAwareQuestionGenerator(BaseQuestionGenerator): @@ -22,7 +21,7 @@ def generate_questions( questions_by_iteration: dict = None, ) -> List[str]: """Generate questions with entity-aware search patterns.""" - now = datetime.now() + now = datetime.now(UTC) current_time = now.strftime("%Y-%m-%d") questions_by_iteration = questions_by_iteration or {} @@ -60,7 +59,7 @@ def generate_questions( Query: {query} Today: {current_time} -Past questions: {str(questions_by_iteration)} +Past questions: {questions_by_iteration!s} Current knowledge: {current_knowledge} Create direct search queries that combine the key identifying features to find the specific name/entity. @@ -180,5 +179,5 @@ def generate_sub_questions( return questions except Exception as e: - logger.error(f"Error generating sub-questions: {str(e)}") + logger.exception(f"Error generating sub-questions: {e!s}") return []
src/local_deep_research/advanced_search_system/questions/followup/base_followup_question.py+91 −0 added@@ -0,0 +1,91 @@ +""" +Base class for follow-up question generators. + +This extends the standard question generator interface to handle +follow-up research that includes previous research context. +""" + +from abc import abstractmethod +from typing import Dict, List +from ..base_question import BaseQuestionGenerator + + +class BaseFollowUpQuestionGenerator(BaseQuestionGenerator): + """ + Abstract base class for follow-up question generators. + + These generators create contextualized queries that incorporate + previous research findings and context. + """ + + def __init__(self, model): + """ + Initialize the follow-up question generator. + + Args: + model: The language model to use for question generation + """ + super().__init__(model) + self.follow_up_context = {} + + def set_follow_up_context(self, context: Dict): + """ + Set the follow-up research context. + + Args: + context: Dictionary containing: + - past_findings: Previous research findings + - original_query: The original research query + - follow_up_query: The follow-up question from user + - past_sources: Sources from previous research + - key_entities: Key entities identified + """ + self.follow_up_context = context + + @abstractmethod + def generate_contextualized_query( + self, + follow_up_query: str, + original_query: str, + past_findings: str, + **kwargs, + ) -> str: + """ + Generate a contextualized query for follow-up research. + + Args: + follow_up_query: The user's follow-up question + original_query: The original research query + past_findings: The findings from previous research + **kwargs: Additional context parameters + + Returns: + str: A contextualized query that includes previous context + """ + pass + + def generate_questions( + self, + current_knowledge: str, + query: str, + questions_per_iteration: int, + questions_by_iteration: Dict[int, List[str]], + ) -> List[str]: + """ + Generate questions for follow-up research. + + For follow-up research, we typically return a single contextualized + query rather than multiple questions, as the context is already rich. + + Args: + current_knowledge: The accumulated knowledge so far + query: The research query (already contextualized) + questions_per_iteration: Number of questions to generate + questions_by_iteration: Previous questions + + Returns: + List[str]: List containing the contextualized query + """ + # For follow-up research, the query is already contextualized + # Just return it as a single-item list + return [query]
src/local_deep_research/advanced_search_system/questions/followup/__init__.py+14 −0 added@@ -0,0 +1,14 @@ +""" +Follow-up Question Generators Package + +This package contains specialized question generators for follow-up research +that builds upon previous research context. +""" + +from .base_followup_question import BaseFollowUpQuestionGenerator +from .simple_followup_question import SimpleFollowUpQuestionGenerator + +__all__ = [ + "BaseFollowUpQuestionGenerator", + "SimpleFollowUpQuestionGenerator", +]
src/local_deep_research/advanced_search_system/questions/followup/llm_followup_question.py+93 −0 added@@ -0,0 +1,93 @@ +""" +LLM-based follow-up question generator. + +This implementation uses an LLM to intelligently reformulate follow-up +questions based on the previous research context. +""" + +from typing import Dict, List +from loguru import logger +from .base_followup_question import BaseFollowUpQuestionGenerator + + +class LLMFollowUpQuestionGenerator(BaseFollowUpQuestionGenerator): + """ + LLM-based follow-up question generator. + + This generator uses an LLM to reformulate follow-up questions + based on the previous research context, creating more targeted + and effective search queries. + + NOTE: This is a placeholder for future implementation. + Currently falls back to simple concatenation. + """ + + def generate_contextualized_query( + self, + follow_up_query: str, + original_query: str, + past_findings: str, + **kwargs, + ) -> str: + """ + Generate a contextualized query using LLM reformulation. + + Future implementation will: + 1. Analyze the follow-up query in context of past findings + 2. Identify information gaps + 3. Reformulate for more effective searching + 4. Generate multiple targeted search questions + + Args: + follow_up_query: The user's follow-up question + original_query: The original research query + past_findings: The findings from previous research + **kwargs: Additional context parameters + + Returns: + str: An LLM-reformulated contextualized query + """ + # TODO: Implement LLM-based reformulation + # For now, fall back to simple concatenation + logger.warning( + "LLM-based follow-up question generation not yet implemented, " + "falling back to simple concatenation" + ) + + from .simple_followup_question import SimpleFollowUpQuestionGenerator + + simple_generator = SimpleFollowUpQuestionGenerator(self.model) + return simple_generator.generate_contextualized_query( + follow_up_query, original_query, past_findings, **kwargs + ) + + def generate_questions( + self, + current_knowledge: str, + query: str, + questions_per_iteration: int, + questions_by_iteration: Dict[int, List[str]], + ) -> List[str]: + """ + Generate multiple targeted questions for follow-up research. + + Future implementation will generate multiple specific questions + based on the follow-up query and context. + + Args: + current_knowledge: The accumulated knowledge so far + query: The research query + questions_per_iteration: Number of questions to generate + questions_by_iteration: Previous questions + + Returns: + List[str]: List of targeted follow-up questions + """ + # TODO: Implement multi-question generation + # For now, return single contextualized query + return super().generate_questions( + current_knowledge, + query, + questions_per_iteration, + questions_by_iteration, + )
src/local_deep_research/advanced_search_system/questions/followup/simple_followup_question.py+65 −0 added@@ -0,0 +1,65 @@ +""" +Simple concatenation-based follow-up question generator. + +This implementation preserves the current behavior of concatenating +the previous research context with the follow-up query without using +an LLM to reformulate. +""" + +from loguru import logger +from .base_followup_question import BaseFollowUpQuestionGenerator + + +class SimpleFollowUpQuestionGenerator(BaseFollowUpQuestionGenerator): + """ + Simple follow-up question generator that concatenates context. + + This generator creates a contextualized query by directly concatenating + the previous research findings with the follow-up question, without + any LLM-based reformulation. This ensures the follow-up query is + understood in the context of previous research. + """ + + def generate_contextualized_query( + self, + follow_up_query: str, + original_query: str, + past_findings: str, + **kwargs, + ) -> str: + """ + Generate a contextualized query by simple concatenation. + + This method preserves the exact user query while providing full + context from previous research. This ensures queries like + "provide data in a table" are understood as referring to the + previous findings, not as new searches. + + Args: + follow_up_query: The user's follow-up question + original_query: The original research query + past_findings: The findings from previous research + **kwargs: Additional context parameters (unused) + + Returns: + str: A contextualized query with previous research embedded + """ + # Simply concatenate the context with the query - no LLM interpretation needed + # Highlight importance at top, actual request at bottom + contextualized = f"""IMPORTANT: This is a follow-up request. Focus on addressing the specific user request at the bottom of this prompt using the previous research context provided below. + +Previous research query: {original_query} + +Previous findings: +{past_findings} + +--- +USER'S FOLLOW-UP REQUEST: {follow_up_query} +Please address this specific request using the context and findings above. +---""" + + logger.info( + f"Created contextualized query with {len(past_findings)} chars of context" + ) + + return contextualized
src/local_deep_research/advanced_search_system/questions/__init__.py+5 −3 modified@@ -5,13 +5,15 @@ from .browsecomp_question import BrowseCompQuestionGenerator from .decomposition_question import DecompositionQuestionGenerator from .entity_aware_question import EntityAwareQuestionGenerator +from .news_question import NewsQuestionGenerator from .standard_question import StandardQuestionGenerator __all__ = [ + "AtomicFactQuestionGenerator", "BaseQuestionGenerator", - "StandardQuestionGenerator", + "BrowseCompQuestionGenerator", "DecompositionQuestionGenerator", - "AtomicFactQuestionGenerator", "EntityAwareQuestionGenerator", - "BrowseCompQuestionGenerator", + "NewsQuestionGenerator", + "StandardQuestionGenerator", ]
src/local_deep_research/advanced_search_system/questions/news_question.py+49 −0 added@@ -0,0 +1,49 @@ +""" +News question generation implementation. +""" + +from datetime import datetime, UTC +from typing import List, Dict + +from loguru import logger + +from .base_question import BaseQuestionGenerator + + +class NewsQuestionGenerator(BaseQuestionGenerator): + """News-specific question generator for aggregating current news.""" + + def generate_questions( + self, + current_knowledge: str, + query: str, + questions_per_iteration: int = 8, + questions_by_iteration: Dict[int, List[str]] = None, + ) -> List[str]: + """Generate news-specific search queries.""" + date_str = datetime.now(UTC).strftime("%B %d, %Y") + + logger.info("Generating news search queries...") + + # Build diverse news queries + base_queries = [ + f"breaking news today {date_str}", + f"major incidents casualties today {date_str}", + f"unexpected news surprising today {date_str}", + "economic news market movement today", + f"political announcements today {date_str}", + "technology breakthrough announcement today", + "natural disaster emergency today", + "international news global impact today", + ] + + # If user provided specific focus, add those queries + if query and query != "latest important news today": + focus_queries = [ + f"{query} {date_str}", + f"{query} breaking news today", + f"{query} latest developments", + ] + return focus_queries + base_queries[:5] + + return base_queries[:questions_per_iteration]
src/local_deep_research/advanced_search_system/questions/standard_question.py+6 −7 modified@@ -2,13 +2,12 @@ Standard question generation implementation. """ -import logging -from datetime import datetime +from datetime import datetime, UTC from typing import List -from .base_question import BaseQuestionGenerator +from loguru import logger -logger = logging.getLogger(__name__) +from .base_question import BaseQuestionGenerator class StandardQuestionGenerator(BaseQuestionGenerator): @@ -22,7 +21,7 @@ def generate_questions( questions_by_iteration: dict = None, ) -> List[str]: """Generate follow-up questions based on current knowledge.""" - now = datetime.now() + now = datetime.now(UTC) current_time = now.strftime("%Y-%m-%d") questions_by_iteration = questions_by_iteration or {} @@ -32,7 +31,7 @@ def generate_questions( prompt = f"""Critically reflect current knowledge (e.g., timeliness), what {questions_per_iteration} high-quality internet search questions remain unanswered to exactly answer the query? Query: {query} Today: {current_time} - Past questions: {str(questions_by_iteration)} + Past questions: {questions_by_iteration!s} Knowledge: {current_knowledge} Include questions that critically reflect current knowledge. \n\n\nFormat: One question per line, e.g. \n Q: question1 \n Q: question2\n\n""" @@ -121,5 +120,5 @@ def generate_sub_questions( # Limit to at most 5 sub-questions return sub_questions[:5] except Exception as e: - logger.error(f"Error generating sub-questions: {str(e)}") + logger.exception(f"Error generating sub-questions: {e!s}") return []
src/local_deep_research/advanced_search_system/source_management/diversity_manager.py+5 −5 modified@@ -5,8 +5,8 @@ import re from collections import defaultdict from dataclasses import dataclass, field -from datetime import datetime -from typing import Dict, List, Optional, Set, Tuple, Any +from datetime import datetime, UTC +from typing import Any, Dict, List, Optional, Set, Tuple from langchain_core.language_models import BaseChatModel @@ -75,7 +75,7 @@ def analyze_source( if url in self.source_profiles: profile = self.source_profiles[url] profile.evidence_count += 1 - profile.last_accessed = datetime.utcnow() + profile.last_accessed = datetime.now(UTC) return profile # Extract domain @@ -105,7 +105,7 @@ def analyze_source( temporal_coverage=temporal_coverage, geographic_focus=geographic_focus, evidence_count=1, - last_accessed=datetime.utcnow(), + last_accessed=datetime.now(UTC), ) self.source_profiles[url] = profile @@ -606,7 +606,7 @@ def track_source_effectiveness( profile.effectiveness.append( { - "timestamp": datetime.utcnow(), + "timestamp": datetime.now(UTC), "evidence_quality": evidence_quality, "constraint_satisfied": constraint_satisfied, }
src/local_deep_research/advanced_search_system/strategies/adaptive_decomposition_strategy.py+2 −2 modified@@ -559,6 +559,6 @@ def _calculate_confidence(self) -> float: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - from datetime import datetime + from datetime import datetime, timezone - return datetime.utcnow().isoformat() + return datetime.now(timezone.utc).isoformat()
src/local_deep_research/advanced_search_system/strategies/base_strategy.py+24 −3 modified@@ -15,9 +15,18 @@ class BaseSearchStrategy(ABC): def __init__( self, all_links_of_system=None, + settings_snapshot=None, questions_by_iteration=None, + search_original_query: bool = True, ): - """Initialize the base strategy with common attributes.""" + """Initialize the base strategy with common attributes. + + Args: + all_links_of_system: List to store all discovered links + settings_snapshot: Settings snapshot for configuration + questions_by_iteration: Dictionary of questions by iteration + search_original_query: Whether to include the original query in the first iteration + """ self.progress_callback = None # Create a new dict if None is provided (avoiding mutable default argument) self.questions_by_iteration = ( @@ -27,6 +36,18 @@ def __init__( self.all_links_of_system = ( all_links_of_system if all_links_of_system is not None else [] ) + self.settings_snapshot = settings_snapshot or {} + self.search_original_query = search_original_query + + def get_setting(self, key: str, default=None): + """Get a setting value from the snapshot.""" + if key in self.settings_snapshot: + value = self.settings_snapshot[key] + # Extract value from dict structure if needed + if isinstance(value, dict) and "value" in value: + return value["value"] + return value + return default def set_progress_callback( self, callback: Callable[[str, int, dict], None] @@ -98,7 +119,7 @@ def _handle_search_error( Returns: List: Empty list to continue processing """ - error_msg = f"Error during search: {str(error)}" + error_msg = f"Error during search: {error!s}" logger.error(f"SEARCH ERROR: {error_msg}") self._update_progress( error_msg, @@ -118,7 +139,7 @@ def _handle_analysis_error( question: The question being analyzed progress_base: The current progress percentage """ - error_msg = f"Error analyzing results: {str(error)}" + error_msg = f"Error analyzing results: {error!s}" logger.info(f"ANALYSIS ERROR: {error_msg}") self._update_progress( error_msg,
src/local_deep_research/advanced_search_system/strategies/browsecomp_entity_strategy.py+18 −12 modified@@ -106,9 +106,17 @@ class BrowseCompEntityStrategy(BaseSearchStrategy): """ def __init__( - self, model=None, search=None, all_links_of_system=None, **kwargs + self, + model, + search, + all_links_of_system=None, + settings_snapshot=None, + **kwargs, ): - super().__init__(all_links_of_system=all_links_of_system) + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) # Store model and search engine self.model = model @@ -304,10 +312,8 @@ async def search( return answer, metadata except Exception as e: - logger.error( - f"Error in BrowseComp entity search: {e}", exc_info=True - ) - return f"Search failed: {str(e)}", {"error": str(e)} + logger.exception("Error in BrowseComp entity search") + return f"Search failed: {e!s}", {"error": str(e)} def _identify_entity_type(self, query: str) -> str: """Identify what type of entity we're looking for.""" @@ -827,8 +833,8 @@ async def _cached_search(self, query: str) -> List[Dict]: logger.debug(f"Cached new search results for: {query[:50]}...") return normalized_results - except Exception as e: - logger.error(f"Search failed for query '{query}': {e}") + except Exception: + logger.exception(f"Search failed for query '{query}'") return [] async def _generate_entity_answer( @@ -922,9 +928,9 @@ def analyze_topic(self, query: str) -> Dict: return asyncio.run(self._analyze_topic_async(query)) except Exception as e: - logger.error(f"Error in analyze_topic: {e}") + logger.exception("Error in analyze_topic") return { - "findings": [f"Error analyzing query: {str(e)}"], + "findings": [f"Error analyzing query: {e!s}"], "iterations": 0, "questions": {}, "entities_found": 0, @@ -1021,9 +1027,9 @@ async def _analyze_topic_async(self, query: str) -> Dict: } except Exception as e: - logger.error(f"Error in async topic analysis: {e}") + logger.exception("Error in async topic analysis") return { - "findings": [f"Analysis failed: {str(e)}"], + "findings": [f"Analysis failed: {e!s}"], "iterations": 0, "questions": {}, "entities_found": 0,
src/local_deep_research/advanced_search_system/strategies/browsecomp_optimized_strategy.py+4 −3 modified@@ -6,7 +6,7 @@ """ from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List, Optional from langchain_core.language_models import BaseChatModel @@ -53,9 +53,10 @@ def __init__( confidence_threshold: float = 0.90, max_iterations: int = 1, # This is for source-based strategy iterations questions_per_iteration: int = 3, # This is for source-based strategy questions + settings_snapshot=None, ): """Initialize the BrowseComp-optimized strategy.""" - super().__init__(all_links_of_system) + super().__init__(all_links_of_system, settings_snapshot) self.model = model self.search = search self.max_browsecomp_iterations = max_browsecomp_iterations @@ -775,4 +776,4 @@ def _synthesize_final_answer(self, original_query: str) -> Dict: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat()
src/local_deep_research/advanced_search_system/strategies/concurrent_dual_confidence_strategy.py+6 −8 modified@@ -240,8 +240,8 @@ def _progressive_search_with_concurrent_eval(self): self.state.evaluation_futures.append(future) self.state.total_evaluated += 1 - except Exception as e: - logger.error(f"Search error in iteration {iteration}: {e}") + except Exception: + logger.exception(f"Search error in iteration {iteration}") # Check completed evaluations self._check_evaluation_results() @@ -318,10 +318,8 @@ def _evaluate_candidate_thread( return (candidate, score) - except Exception as e: - logger.error( - f"Error evaluating {candidate.name}: {e}", exc_info=True - ) + except Exception: + logger.exception("Error evaluating candidate") return (candidate, 0.0) def _check_evaluation_results(self): @@ -334,8 +332,8 @@ def _check_evaluation_results(self): try: future.result() # Result is already processed in the thread - except Exception as e: - logger.error(f"Failed to get future result: {e}") + except Exception: + logger.exception("Failed to get future result") # Remove completed futures for future in completed:
src/local_deep_research/advanced_search_system/strategies/constrained_search_strategy.py+10 −12 modified@@ -8,7 +8,7 @@ 4. Narrowing down the candidate pool step by step """ -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List from langchain_core.language_models import BaseChatModel @@ -45,6 +45,7 @@ def __init__( max_search_iterations: int = 2, questions_per_iteration: int = 3, min_candidates_per_stage: int = 20, # Need more candidates before filtering + settings_snapshot=None, ): """Initialize the constrained search strategy.""" super().__init__( @@ -57,6 +58,7 @@ def __init__( evidence_threshold=evidence_threshold, max_search_iterations=max_search_iterations, questions_per_iteration=questions_per_iteration, + settings_snapshot=settings_snapshot, ) self.min_candidates_per_stage = min_candidates_per_stage @@ -440,9 +442,8 @@ def _generate_constraint_specific_queries( ] ) - elif ( - constraint.type == ConstraintType.EVENT - or hasattr(constraint.type, "value") + elif constraint.type == ConstraintType.EVENT or ( + hasattr(constraint.type, "value") and constraint.type.value == "temporal" ): # Time-based constraints @@ -641,11 +642,8 @@ def _extract_relevant_candidates( return candidates[:50] # Limit per search - except Exception as e: - logger.error(f"Error extracting candidates: {e}") - import traceback - - logger.error(traceback.format_exc()) + except Exception: + logger.exception("Error extracting candidates") return [] def _quick_evidence_check( @@ -1205,9 +1203,9 @@ def _simple_search(self, search_query: str) -> Dict: "search_results": [], } except Exception as e: - logger.error(f"Simple search error: {e}") + logger.exception("Simple search error") return { - "current_knowledge": f"Search error: {str(e)}", + "current_knowledge": f"Search error: {e!s}", "search_results": [], } @@ -1295,7 +1293,7 @@ def _validate_search_results( def _get_timestamp(self) -> str: """Get current timestamp.""" - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat() def _group_similar_candidates( self, candidates: List[Candidate]
src/local_deep_research/advanced_search_system/strategies/constraint_parallel_strategy.py+8 −10 modified@@ -177,8 +177,8 @@ def _detect_entity_type(self) -> str: entity_type = self.model.invoke(prompt).content.strip() logger.info(f"LLM determined entity type: {entity_type}") return entity_type - except Exception as e: - logger.error(f"Failed to detect entity type: {e}") + except Exception: + logger.exception("Failed to detect entity type") return "unknown entity" def _run_parallel_constraint_searches(self): @@ -226,7 +226,7 @@ def _run_parallel_constraint_searches(self): self._submit_candidates_for_evaluation(candidates) except Exception as e: - logger.error( + logger.exception( f"Search failed for constraint {constraint.value}: {e}" ) @@ -266,8 +266,8 @@ def _run_constraint_search( ) return candidates - except Exception as e: - logger.error(f"Error in constraint search: {e}", exc_info=True) + except Exception: + logger.exception("Error in constraint search") return [] def _build_constraint_query(self, constraint: Constraint) -> str: @@ -362,10 +362,8 @@ def _evaluate_candidate_thread( return (candidate, score) - except Exception as e: - logger.error( - f"Error evaluating {candidate.name}: {e}", exc_info=True - ) + except Exception: + logger.exception(f"Error evaluating {candidate.name}") return (candidate, 0.0) def _verify_entity_type_match(self, candidate: Candidate) -> float: @@ -417,7 +415,7 @@ def _verify_entity_type_match(self, candidate: Candidate) -> float: return 0.5 # Default to middle value on parsing error except Exception as e: - logger.error( + logger.exception( f"Error verifying entity type for {candidate_name}: {e}" ) return 0.5 # Default to middle value on error
src/local_deep_research/advanced_search_system/strategies/direct_search_strategy.py+12 −16 modified@@ -7,18 +7,17 @@ 3. Minimal LLM calls for efficiency """ -import logging from typing import Dict +from loguru import logger + from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search + +# Model and search should be provided by AdvancedSearchSystem from ..filters.cross_engine_filter import CrossEngineFilter from ..findings.repository import FindingsRepository from .base_strategy import BaseSearchStrategy -logger = logging.getLogger(__name__) - class DirectSearchStrategy(BaseSearchStrategy): """ @@ -33,8 +32,8 @@ class DirectSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, include_text_content: bool = True, use_cross_engine_filter: bool = True, @@ -45,8 +44,8 @@ def __init__( ): """Initialize with minimal components for efficiency.""" super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + self.search = search + self.model = model self.progress_callback = None self.include_text_content = include_text_content @@ -189,13 +188,10 @@ def analyze_topic(self, query: str) -> Dict: ) except Exception as e: - import traceback - - error_msg = f"Error in direct search: {str(e)}" - logger.error(error_msg) - logger.error(traceback.format_exc()) - synthesized_content = f"Error: {str(e)}" - formatted_findings = f"Error: {str(e)}" + error_msg = f"Error in direct search: {e!s}" + logger.exception(error_msg) + synthesized_content = f"Error: {e!s}" + formatted_findings = f"Error: {e!s}" finding = { "phase": "Error", "content": synthesized_content,
src/local_deep_research/advanced_search_system/strategies/dual_confidence_strategy.py+9 −6 modified@@ -141,8 +141,8 @@ def _analyze_evidence_dual_confidence( source=evidence.get("source", "search"), ) - except Exception as e: - logger.error(f"Error analyzing evidence: {e}") + except Exception: + logger.exception("Error analyzing evidence") # Default to high uncertainty return ConstraintEvidence( positive_confidence=0.1, @@ -198,7 +198,9 @@ def _gather_evidence_for_constraint( } ) except Exception as e: - logger.error(f"Error gathering evidence for {query}: {e}") + logger.exception( + f"Error gathering evidence for {query}: {e}" + ) return evidence @@ -271,7 +273,8 @@ def _evaluate_candidate_immediately(self, candidate: Candidate) -> float: for c in self.constraint_ranking[: len(constraint_scores)] ] total_score = sum( - s * w for s, w in zip(constraint_scores, weights) + s * w + for s, w in zip(constraint_scores, weights, strict=False) ) / sum(weights) # Log detailed breakdown @@ -315,6 +318,6 @@ def _evaluate_candidate_immediately(self, candidate: Candidate) -> float: return total_score - except Exception as e: - logger.error(f"Error evaluating {candidate.name}: {e}") + except Exception: + logger.exception(f"Error evaluating candidate: {candidate.name}") return 0.0
src/local_deep_research/advanced_search_system/strategies/dual_confidence_with_rejection.py+6 −5 modified@@ -136,7 +136,8 @@ def _evaluate_candidate_immediately(self, candidate) -> float: for c in self.constraint_ranking[: len(constraint_scores)] ] total_score = sum( - s * w for s, w in zip(constraint_scores, weights) + s * w + for s, w in zip(constraint_scores, weights, strict=False) ) / sum(weights) # Log detailed breakdown @@ -174,8 +175,8 @@ def _evaluate_candidate_immediately(self, candidate) -> float: return total_score - except Exception as e: - logger.error(f"Error evaluating {candidate.name}: {e}") + except Exception: + logger.exception(f"Error evaluating {candidate.name}") return 0.0 def _evaluate_candidate_with_constraint_checker(self, candidate) -> float: @@ -214,6 +215,6 @@ def _evaluate_candidate_with_constraint_checker(self, candidate) -> float: return result.total_score - except Exception as e: - logger.error(f"Error evaluating {candidate.name}: {e}") + except Exception: + logger.exception(f"Error evaluating {candidate.name}") return 0.0
src/local_deep_research/advanced_search_system/strategies/early_stop_constrained_strategy.py+12 −8 modified@@ -94,15 +94,15 @@ def _parallel_search(self, combinations: List) -> List[Candidate]: }, ) - except Exception as e: - logger.error(f"Search failed for {combo.query}: {e}") + except Exception: + logger.exception(f"Search failed for {combo.query}") # Wait for evaluation futures to complete for future in concurrent.futures.as_completed(evaluation_futures): try: future.result() - except Exception as e: - logger.error(f"Evaluation failed: {e}") + except Exception: + logger.exception("Evaluation failed") return all_candidates @@ -181,7 +181,9 @@ def _evaluate_candidate_immediately(self, candidate: Candidate) -> float: return total_score except Exception as e: - logger.error(f"Error evaluating candidate {candidate.name}: {e}") + logger.exception( + f"Error evaluating candidate {candidate.name}: {e}" + ) return 0.0 def _progressive_constraint_search(self): @@ -280,7 +282,9 @@ def _gather_evidence_for_constraint( ) return evidence except Exception as e: - logger.error(f"Error gathering evidence for {candidate.name}: {e}") + logger.exception( + f"Error gathering evidence for {candidate.name}: {e}" + ) return [] def _extract_evidence_from_results( @@ -316,8 +320,8 @@ def _extract_evidence_from_results( ), } ) - except Exception as e: - logger.error(f"Error extracting evidence: {e}") + except Exception: + logger.exception("Error extracting evidence") return evidence
src/local_deep_research/advanced_search_system/strategies/entity_aware_source_strategy.py+4 −5 modified@@ -2,14 +2,13 @@ Entity-aware source-based search strategy for improved entity identification. """ -import logging from typing import Dict +from loguru import logger + from ..questions.entity_aware_question import EntityAwareQuestionGenerator from .source_based_strategy import SourceBasedSearchStrategy -logger = logging.getLogger(__name__) - class EntityAwareSourceStrategy(SourceBasedSearchStrategy): """ @@ -23,8 +22,8 @@ class EntityAwareSourceStrategy(SourceBasedSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, include_text_content: bool = True, use_cross_engine_filter: bool = True,
src/local_deep_research/advanced_search_system/strategies/evidence_based_strategy.py+4 −3 modified@@ -5,7 +5,7 @@ and systematically gathers evidence to score each candidate. """ -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List from langchain_core.language_models import BaseChatModel @@ -44,9 +44,10 @@ def __init__( evidence_threshold: float = 0.6, max_search_iterations: int = 2, # For source-based sub-searches questions_per_iteration: int = 3, + settings_snapshot=None, ): """Initialize the evidence-based strategy.""" - super().__init__(all_links_of_system) + super().__init__(all_links_of_system, settings_snapshot) self.model = model self.search = search self.max_iterations = max_iterations @@ -1219,7 +1220,7 @@ def _format_final_synthesis(self, answer: str, confidence: int) -> str: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat() def _calculate_evidence_coverage(self) -> float: """Calculate how much evidence we've collected across all candidates."""
src/local_deep_research/advanced_search_system/strategies/evidence_based_strategy_v2.py+2 −2 modified@@ -10,7 +10,7 @@ import math from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List, Optional, Set, Tuple from langchain_core.language_models import BaseChatModel @@ -828,7 +828,7 @@ def _update_source_profile(self, source_name: str, confidence: float): if source_name in self.source_profiles: profile = self.source_profiles[source_name] profile.usage_count += 1 - profile.last_used = datetime.utcnow() + profile.last_used = datetime.now(UTC) # Update success rate based on confidence alpha = 0.3
src/local_deep_research/advanced_search_system/strategies/focused_iteration_strategy.py+37 −33 modified@@ -20,19 +20,22 @@ """ import concurrent.futures -import logging from typing import Dict, List +from loguru import logger + from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search + +# Model and search should be provided by AdvancedSearchSystem +from ...utilities.thread_context import ( + preserve_research_context, + get_search_context, +) from ..candidate_exploration import ProgressiveExplorer from ..findings.repository import FindingsRepository from ..questions import BrowseCompQuestionGenerator from .base_strategy import BaseSearchStrategy -logger = logging.getLogger(__name__) - class FocusedIterationStrategy(BaseSearchStrategy): """ @@ -49,23 +52,30 @@ class FocusedIterationStrategy(BaseSearchStrategy): def __init__( self, - model=None, - search=None, + model, + search, citation_handler=None, all_links_of_system=None, max_iterations: int = 8, # OPTIMAL FOR SIMPLEQA: 90%+ accuracy achieved questions_per_iteration: int = 5, # OPTIMAL FOR SIMPLEQA: proven config use_browsecomp_optimization: bool = True, # True for 90%+ accuracy with forced_answer handler + settings_snapshot=None, ): """Initialize with components optimized for focused iteration.""" - super().__init__(all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__(all_links_of_system, settings_snapshot) + self.search = search + self.model = model self.progress_callback = None - # Configuration - ensure these are integers - self.max_iterations = int(max_iterations) - self.questions_per_iteration = int(questions_per_iteration) + # Configuration - ensure these are integers with defaults + self.max_iterations = ( + int(max_iterations) if max_iterations is not None else 3 + ) + self.questions_per_iteration = ( + int(questions_per_iteration) + if questions_per_iteration is not None + else 3 + ) self.use_browsecomp_optimization = use_browsecomp_optimization # Initialize specialized components @@ -349,33 +359,29 @@ def analyze_topic(self, query: str) -> Dict: return result except Exception as e: - logger.error(f"Error in focused iteration search: {str(e)}") + logger.exception(f"Error in focused iteration search: {e!s}") import traceback - logger.error(traceback.format_exc()) + logger.exception(traceback.format_exc()) return self._create_error_response(str(e)) def _execute_parallel_searches(self, queries: List[str]) -> List[Dict]: """Execute searches in parallel (like source-based strategy).""" all_results = [] - # Import context preservation utility - from ...utilities.thread_context import ( - create_context_preserving_wrapper, - ) - def search_question(q): try: - result = self.search.run(q) + # Get the current research context to pass explicitly + + current_context = get_search_context() + result = self.search.run(q, research_context=current_context) return {"question": q, "results": result or []} except Exception as e: - logger.error(f"Error searching '{q}': {str(e)}") + logger.exception(f"Error searching '{q}': {e!s}") return {"question": q, "results": [], "error": str(e)} # Create context-preserving wrapper for the search function - context_aware_search = create_context_preserving_wrapper( - search_question - ) + context_aware_search = preserve_research_context(search_question) # Run searches in parallel with concurrent.futures.ThreadPoolExecutor( @@ -399,11 +405,6 @@ def _execute_parallel_searches_with_progress( completed_searches = 0 total_searches = len(queries) - # Import context preservation utility - from ...utilities.thread_context import ( - create_context_preserving_wrapper, - ) - def search_question_with_progress(q): nonlocal completed_searches try: @@ -418,7 +419,10 @@ def search_question_with_progress(q): }, ) - result = self.search.run(q) + # Get the current research context to pass explicitly + + current_context = get_search_context() + result = self.search.run(q, research_context=current_context) completed_searches += 1 # Report completion of this search @@ -441,7 +445,7 @@ def search_question_with_progress(q): } except Exception as e: completed_searches += 1 - logger.error(f"Error searching '{q}': {str(e)}") + logger.exception(f"Error searching '{q}': {e!s}") self._update_progress( f"Search failed for '{q[:30]}{'...' if len(q) > 30 else ''}': {str(e)[:50]}", None, @@ -460,7 +464,7 @@ def search_question_with_progress(q): } # Create context-preserving wrapper for the search function - context_aware_search_with_progress = create_context_preserving_wrapper( + context_aware_search_with_progress = preserve_research_context( search_question_with_progress )
src/local_deep_research/advanced_search_system/strategies/followup/enhanced_contextual_followup.py+387 −0 added@@ -0,0 +1,387 @@ +""" +Enhanced Contextual Follow-Up Strategy + +An improved version of the contextual follow-up strategy that better leverages +past research context, reformulates questions, and reuses sources effectively. +""" + +from typing import Dict, List, Optional, Any +from loguru import logger + +from ..base_strategy import BaseSearchStrategy +from ...filters.followup_relevance_filter import FollowUpRelevanceFilter +from ...knowledge.followup_context_manager import FollowUpContextHandler +from ...questions.followup.simple_followup_question import ( + SimpleFollowUpQuestionGenerator, +) + + +class EnhancedContextualFollowUpStrategy(BaseSearchStrategy): + """ + Enhanced strategy for follow-up research that intelligently uses past context. + + This strategy: + 1. Reformulates follow-up questions based on past findings + 2. Filters and reuses relevant sources from previous research + 3. Passes complete context to the delegate strategy + 4. Optimizes search to avoid redundancy + """ + + def __init__( + self, + model, + search, + delegate_strategy: BaseSearchStrategy, + all_links_of_system=None, + settings_snapshot=None, + research_context: Optional[Dict] = None, + **kwargs, + ): + """ + Initialize the enhanced contextual follow-up strategy. + + Args: + model: The LLM model to use + search: The search engine + delegate_strategy: The strategy to delegate actual search to + all_links_of_system: Accumulated links from past searches + settings_snapshot: Settings configuration + research_context: Context from previous research + """ + super().__init__(all_links_of_system, settings_snapshot) + + self.model = model + self.search = search + self.delegate_strategy = delegate_strategy + self.research_context = research_context or {} + + # Initialize components + self.relevance_filter = FollowUpRelevanceFilter(model) + self.context_manager = FollowUpContextHandler(model) + + # Initialize question generator for creating contextualized queries + self.question_generator = SimpleFollowUpQuestionGenerator(model) + + # For follow-up research, we ALWAYS want to combine sources + # This is the whole point of follow-up - building on previous research + self.combine_sources = True + + # Build comprehensive context + self.full_context = self._build_full_context() + + logger.info( + f"EnhancedContextualFollowUpStrategy initialized with " + f"{len(self.full_context.get('past_sources', []))} past sources" + ) + + def _build_full_context(self) -> Dict[str, Any]: + """ + Build comprehensive context from research data. + + Returns: + Full context dictionary + """ + # Use context manager to build structured context + if self.research_context: + # The follow-up query will be passed to analyze_topic later + # For now, use empty string since we're just building initial context + follow_up_query = "" + context = self.context_manager.build_context( + self.research_context, follow_up_query + ) + + # Also ensure we get the sources from the research context + if "past_sources" not in context or not context["past_sources"]: + # Try to get from various fields in research_context + sources = [] + for field in ["resources", "all_links_of_system", "past_links"]: + if ( + field in self.research_context + and self.research_context[field] + ): + sources.extend(self.research_context[field]) + context["past_sources"] = sources + + # Ensure we have the findings + if "past_findings" not in context or not context["past_findings"]: + context["past_findings"] = self.research_context.get( + "past_findings", "" + ) + + # Ensure we have the original query + context["original_query"] = self.research_context.get( + "original_query", "" + ) + else: + context = { + "past_sources": [], + "past_findings": "", + "summary": "", + "key_entities": [], + "all_links_of_system": [], + "original_query": "", + } + + return context + + def analyze_topic(self, query: str) -> Dict: + """ + Analyze a follow-up topic with enhanced context processing. + + This strategy: + 1. Reformulates the question based on past findings + 2. Filters relevant past sources to reuse + 3. Hands over to the delegate strategy with enhanced context + + Args: + query: The follow-up question to research + + Returns: + Research findings with context enhancement + """ + logger.info(f"Starting enhanced follow-up search for: {query}") + + # Update the context with the actual follow-up query + self.full_context["follow_up_query"] = query + + # Log what context we have + logger.info( + f"Context summary: {len(self.full_context.get('past_sources', []))} past sources, " + f"findings length: {len(self.full_context.get('past_findings', ''))}, " + f"original query: {self.full_context.get('original_query', 'N/A')}" + ) + + self._update_progress( + "Analyzing past research context", 10, {"phase": "context_analysis"} + ) + + # Step 1: Skip reformulation - we'll use the original query with full context + # This avoids LLM misinterpretation of queries like "provide data in a table" + # The context will make it clear what the query refers to + + self._update_progress( + "Preparing contextualized query", + 20, + {"phase": "context_preparation", "original_query": query}, + ) + + # Step 2: Filter relevant sources from past research using original query + relevant_sources = self._filter_relevant_sources(query) + + self._update_progress( + f"Identified {len(relevant_sources)} relevant past sources", + 30, + { + "phase": "source_filtering", + "relevant_sources": len(relevant_sources), + "total_past_sources": len( + self.full_context.get("past_sources", []) + ), + }, + ) + + # Step 3: Inject the relevant sources into delegate strategy + # This gives the delegate strategy a head start with pre-filtered sources + self._inject_context_into_delegate(relevant_sources, query) + + self._update_progress( + "Handing over to research strategy", + 40, + {"phase": "delegate_handover"}, + ) + + # Step 4: Create a query that includes FULL context from previous research + # Use question generator to create contextualized query + past_findings = self.full_context.get("past_findings", "") + original_research_query = self.full_context.get("original_query", "") + + contextualized_query = ( + self.question_generator.generate_contextualized_query( + follow_up_query=query, + original_query=original_research_query, + past_findings=past_findings, + ) + ) + + # Let the delegate strategy (from user's settings) do the actual research + # with the contextualized query and pre-injected sources + result = self.delegate_strategy.analyze_topic(contextualized_query) + + # Step 5: Get past sources for metadata (always needed) + all_past_sources = self.full_context.get("past_sources", []) + + # Step 6: Optionally combine old sources with new ones + # Only do this if the setting is enabled to avoid breaking existing reports + if self.combine_sources: + logger.info( + f"Combining sources: self.combine_sources={self.combine_sources}" + ) + + # Ensure we have all sources from both researches + if "all_links_of_system" not in result: + result["all_links_of_system"] = [] + + # Log initial state + new_sources_count = len(result.get("all_links_of_system", [])) + logger.info( + f"Initial state: {new_sources_count} new sources, {len(all_past_sources)} past sources to combine" + ) + + # Create a set of URLs already in the result to avoid duplicates + existing_urls = { + link.get("url") + for link in result.get("all_links_of_system", []) + } + + # Add all past sources that aren't already in the result + added_count = 0 + for source in all_past_sources: + url = source.get("url") + if url and url not in existing_urls: + # Mark it as from previous research + enhanced_source = source.copy() + enhanced_source["from_past_research"] = True + result["all_links_of_system"].append(enhanced_source) + existing_urls.add(url) + added_count += 1 + + logger.info( + f"Source combination complete: Added {added_count} past sources, total now {len(result['all_links_of_system'])} sources" + ) + else: + logger.info( + f"Source combination skipped: self.combine_sources={self.combine_sources}" + ) + + # Step 7: Add metadata about the follow-up enhancement + if "metadata" not in result: + result["metadata"] = {} + + result["metadata"]["follow_up_enhancement"] = { + "original_query": query, + "contextualized": True, + "sources_reused": len(relevant_sources), + "total_past_sources": len(all_past_sources), + "parent_research_id": self.full_context.get( + "parent_research_id", "" + ), + } + + self._update_progress( + "Enhanced follow-up search complete", + 100, + { + "phase": "complete", + "sources_reused": len(relevant_sources), + "total_sources": len(result.get("all_links_of_system", [])), + }, + ) + + logger.info( + f"Enhanced results: {len(relevant_sources)} past sources reused, " + f"{len(result.get('all_links_of_system', []))} total sources found" + ) + + return result + + def _filter_relevant_sources(self, query: str) -> List[Dict]: + """ + Filter past sources for relevance to the follow-up query. + + Args: + query: The reformulated follow-up query + + Returns: + List of relevant sources + """ + past_sources = self.full_context.get("past_sources", []) + + if not past_sources: + return [] + + # Filter sources using the relevance filter + # Get max sources from settings or use default + max_followup_sources = self.settings_snapshot.get( + "search.max_followup_sources", {} + ).get("value", 15) + + relevant = self.relevance_filter.filter_results( + results=past_sources, + query=query, + max_results=max_followup_sources, + threshold=0.3, + past_findings=self.full_context.get("past_findings", ""), + original_query=self.full_context.get("original_query", ""), + ) + + logger.info( + f"Filtered {len(past_sources)} past sources to " + f"{len(relevant)} relevant ones" + ) + + return relevant + + def _inject_context_into_delegate( + self, relevant_sources: List[Dict], reformulated_query: str + ): + """ + Inject context and sources into the delegate strategy. + + Args: + relevant_sources: Filtered relevant sources + reformulated_query: The reformulated query + """ + # Initialize delegate's all_links_of_system if needed + if not self.delegate_strategy.all_links_of_system: + self.delegate_strategy.all_links_of_system = [] + + # Add relevant sources to the beginning (high priority) + existing_urls = { + link.get("url") + for link in self.delegate_strategy.all_links_of_system + } + + injected_count = 0 + for source in relevant_sources: + url = source.get("url") + if url and url not in existing_urls: + # Add source with enhanced metadata + enhanced_source = source.copy() + enhanced_source["from_past_research"] = True + enhanced_source["follow_up_relevance"] = source.get( + "relevance_score", 1.0 + ) + + self.delegate_strategy.all_links_of_system.insert( + 0, enhanced_source + ) + existing_urls.add(url) + injected_count += 1 + + logger.info( + f"Injected {injected_count} relevant past sources into delegate strategy" + ) + + # Pass context to delegate if it supports it + if hasattr(self.delegate_strategy, "set_followup_context"): + self.delegate_strategy.set_followup_context( + { + "reformulated_query": reformulated_query, + "past_findings_summary": self.full_context.get( + "summary", "" + ), + "key_entities": self.full_context.get("key_entities", []), + "sources_injected": injected_count, + } + ) + + def set_progress_callback(self, callback): + """ + Set progress callback for both wrapper and delegate. + + Args: + callback: Progress callback function + """ + super().set_progress_callback(callback) + if self.delegate_strategy: + self.delegate_strategy.set_progress_callback(callback)
src/local_deep_research/advanced_search_system/strategies/followup/__init__.py+10 −0 added@@ -0,0 +1,10 @@ +""" +Follow-up Research Strategies + +This package contains specialized strategies for handling follow-up research +that builds upon previous research results. +""" + +from .enhanced_contextual_followup import EnhancedContextualFollowUpStrategy + +__all__ = ["EnhancedContextualFollowUpStrategy"]
src/local_deep_research/advanced_search_system/strategies/improved_evidence_based_strategy.py+2 −2 modified@@ -11,7 +11,7 @@ import itertools from collections import defaultdict from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List, Set from langchain_core.language_models import BaseChatModel @@ -476,7 +476,7 @@ def _execute_tracked_search( constraint_ids=[c.id for c in constraints], results_count=len(results.get("all_links_of_system", [])), candidates_found=candidates_found, - timestamp=datetime.utcnow().isoformat(), + timestamp=datetime.now(UTC).isoformat(), strategy_type=strategy_type, ) self.search_attempts.append(attempt)
src/local_deep_research/advanced_search_system/strategies/__init__.py+14 −14 modified@@ -21,23 +21,23 @@ from .standard_strategy import StandardSearchStrategy __all__ = [ - "BaseSearchStrategy", - "StandardSearchStrategy", - "ParallelSearchStrategy", - "ParallelConstrainedStrategy", - "SourceBasedSearchStrategy", - "RapidSearchStrategy", - "IterDRAGStrategy", - "RecursiveDecompositionStrategy", "AdaptiveDecompositionStrategy", - "SmartDecompositionStrategy", - "IterativeReasoningStrategy", - "BrowseCompOptimizedStrategy", + "BaseSearchStrategy", "BrowseCompEntityStrategy", - "EvidenceBasedStrategy", + "BrowseCompOptimizedStrategy", + "ConstraintParallelStrategy", "DualConfidenceStrategy", "DualConfidenceWithRejectionStrategy", - "ConstraintParallelStrategy", - "ModularStrategy", + "EvidenceBasedStrategy", "FocusedIterationStrategy", + "IterDRAGStrategy", + "IterativeReasoningStrategy", + "ModularStrategy", + "ParallelConstrainedStrategy", + "ParallelSearchStrategy", + "RapidSearchStrategy", + "RecursiveDecompositionStrategy", + "SmartDecompositionStrategy", + "SourceBasedSearchStrategy", + "StandardSearchStrategy", ]
src/local_deep_research/advanced_search_system/strategies/iterative_reasoning_strategy.py+2 −2 modified@@ -10,7 +10,7 @@ """ from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List, Optional from langchain_core.language_models import BaseChatModel @@ -757,4 +757,4 @@ def _synthesize_final_answer(self) -> Dict: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat()
src/local_deep_research/advanced_search_system/strategies/iterdrag_strategy.py+30 −16 modified@@ -3,15 +3,15 @@ """ import json -from datetime import datetime +from datetime import datetime, UTC from typing import Dict, List from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search -from ...utilities.db_utils import get_db_setting + +# Model and search should be provided by AdvancedSearchSystem +from ...config.thread_settings import get_setting_from_snapshot from ...utilities.search_utilities import extract_links_from_search_results from ..findings.repository import FindingsRepository from ..knowledge.standard_knowledge import StandardKnowledge @@ -24,11 +24,12 @@ class IterDRAGStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, max_iterations=3, subqueries_per_iteration=2, all_links_of_system=None, + settings_snapshot=None, ): """Initialize the IterDRAG strategy with search and LLM. @@ -38,10 +39,14 @@ def __init__( max_iterations: Maximum number of iterations to run subqueries_per_iteration: Number of sub-queries to generate per iteration all_links_of_system: Optional list of links to initialize with + settings_snapshot: Settings snapshot for thread context """ - super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) + self.search = search + self.model = model self.max_iterations = max_iterations self.subqueries_per_iteration = subqueries_per_iteration @@ -71,7 +76,7 @@ def _generate_subqueries( try: # Format context for question generation context = f"""Current Query: {query} -Current Date: {datetime.now().strftime("%Y-%m-%d")} +Current Date: {datetime.now(UTC).strftime("%Y-%m-%d")} Past Questions: {self.questions_by_iteration} Current Knowledge: {current_knowledge} @@ -82,7 +87,12 @@ def _generate_subqueries( return self.question_generator.generate_questions( query, context, - int(get_db_setting("search.questions_per_iteration")), + int( + get_setting_from_snapshot( + "search.questions_per_iteration", + settings_snapshot=self.settings_snapshot, + ) + ), ) except Exception: logger.exception("Error generating sub-queries") @@ -369,7 +379,7 @@ def analyze_topic(self, query: str) -> Dict: # Create an error finding error_finding = { "phase": "Final synthesis error", - "content": f"Error during synthesis: {str(e)}", + "content": f"Error during synthesis: {e!s}", "question": query, "search_results": [], "documents": [], @@ -410,7 +420,7 @@ def analyze_topic(self, query: str) -> Dict: {chr(10).join(key_findings[:5]) if key_findings else "No valid findings were generated."} ## Error Information -The system encountered an error during final synthesis: {str(e)} +The system encountered an error during final synthesis: {e!s} This is an automatically generated fallback response. """ @@ -423,15 +433,19 @@ def analyze_topic(self, query: str) -> Dict: The system encountered multiple errors while processing your query: "{query}" -Primary error: {str(e)} -Fallback error: {str(fallback_error)} +Primary error: {e!s} +Fallback error: {fallback_error!s} Please try again with a different query or contact support. """ # Compress knowledge if needed if ( - get_db_setting("general.knowledge_accumulation", "ITERATION") + get_setting_from_snapshot( + "general.knowledge_accumulation", + "ITERATION", + settings_snapshot=self.settings_snapshot, + ) == "ITERATION" ): try:
src/local_deep_research/advanced_search_system/strategies/llm_driven_modular_strategy.py+21 −21 modified@@ -190,8 +190,8 @@ def _parse_decomposition(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse decomposition: {e}") + except Exception: + logger.exception("Failed to parse decomposition") # Fallback to simple structure return { @@ -210,8 +210,8 @@ def _parse_combinations(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse combinations: {e}") + except Exception: + logger.exception("Failed to parse combinations") # Fallback return [ @@ -228,8 +228,8 @@ def _parse_creative_searches(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse creative searches: {e}") + except Exception: + logger.exception("Failed to parse creative searches") # Fallback return [ @@ -246,8 +246,8 @@ def _parse_optimized_searches(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse optimized searches: {e}") + except Exception: + logger.exception("Failed to parse optimized searches") # Fallback return { @@ -309,8 +309,8 @@ async def quick_confidence_check(self, candidate, constraints): try: response = await self.model.ainvoke(prompt) return self._parse_confidence(response.content) - except Exception as e: - logger.error(f"Quick confidence check failed: {e}") + except Exception: + logger.exception("Quick confidence check failed") return { "positive_confidence": 0.5, "negative_confidence": 0.3, @@ -351,8 +351,8 @@ def _parse_confidence(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse confidence: {e}") + except Exception: + logger.exception("Failed to parse confidence") return { "positive_confidence": 0.5, @@ -459,14 +459,14 @@ def analyze_topic(self, query: str) -> Dict: } except Exception as e: - logger.error(f"Error in analyze_topic: {e}") + logger.exception("Error in analyze_topic") import traceback - logger.error(f"Traceback: {traceback.format_exc()}") + logger.exception(f"Traceback: {traceback.format_exc()}") return { "findings": [], "iterations": 0, - "final_answer": f"Analysis failed: {str(e)}", + "final_answer": f"Analysis failed: {e!s}", "metadata": {"error": str(e)}, "links": [], "questions_by_iteration": [], @@ -726,7 +726,7 @@ async def search( evaluated_candidates.append(candidate) except Exception as e: - logger.error( + logger.exception( f"Error evaluating candidate {candidate.name}: {e}" ) continue @@ -779,11 +779,11 @@ async def search( return answer, metadata except Exception as e: - logger.error(f"Error in LLM-driven search: {e}") + logger.exception("Error in LLM-driven search") import traceback - logger.error(f"Traceback: {traceback.format_exc()}") - return f"Search failed: {str(e)}", {"error": str(e)} + logger.exception(f"Traceback: {traceback.format_exc()}") + return f"Search failed: {e!s}", {"error": str(e)} async def _generate_final_answer(self, query, best_candidate, constraints): """Generate comprehensive final answer""" @@ -853,8 +853,8 @@ def _gather_evidence_for_constraint(self, candidate, constraint): return evidence - except Exception as e: - logger.error(f"Error gathering evidence: {e}") + except Exception: + logger.exception("Error gathering evidence") # Fallback to mock evidence return [ {
src/local_deep_research/advanced_search_system/strategies/modular_strategy.py+29 −27 modified@@ -158,8 +158,8 @@ def _parse_decomposition(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse decomposition: {e}") + except Exception: + logger.exception("Failed to parse decomposition") # If parsing fails, return empty dict - let the system handle gracefully logger.warning( @@ -175,8 +175,8 @@ def _parse_combinations(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse combinations: {e}") + except Exception: + logger.exception("Failed to parse combinations") # If parsing fails, return empty list - let the system handle gracefully logger.warning("Failed to parse LLM combinations, returning empty list") @@ -220,8 +220,8 @@ async def quick_confidence_check(self, candidate, constraints): try: response = await self.model.ainvoke(prompt) return self._parse_confidence(response.content) - except Exception as e: - logger.error(f"Quick confidence check failed: {e}") + except Exception: + logger.exception("Quick confidence check failed") return { "positive_confidence": 0.5, "negative_confidence": 0.3, @@ -262,8 +262,8 @@ def _parse_confidence(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse confidence: {e}") + except Exception: + logger.exception("Failed to parse confidence") return { "positive_confidence": 0.5, @@ -294,9 +294,13 @@ def __init__( early_stopping: bool = True, # Enable early stopping by default llm_constraint_processing: bool = True, # Enable LLM-driven constraint processing by default immediate_evaluation: bool = True, # Enable immediate candidate evaluation by default + settings_snapshot=None, **kwargs, ): - super().__init__(all_links_of_system=all_links_of_system) + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) self.model = model self.search_engine = search @@ -548,7 +552,7 @@ async def search( result = future.result() batch_results.append(result) except Exception as e: - logger.error( + logger.exception( f"❌ Parallel search failed for '{query[:30]}...': {e}" ) batch_results.append(e) @@ -559,7 +563,9 @@ async def search( # Process batch results - QUEUE CANDIDATES FOR BACKGROUND EVALUATION for j, result in enumerate(batch_results): if isinstance(result, Exception): - logger.error(f"❌ Search failed: {batch[j]} - {result}") + logger.exception( + f"❌ Search failed: {batch[j]} - {result}" + ) continue candidates = self.candidate_explorer._extract_candidates_from_results( @@ -647,7 +653,7 @@ async def search( evaluated_candidates.append(candidate) except Exception as e: - logger.error( + logger.exception( f"💥 Error evaluating candidate {candidate.name}: {e}" ) continue @@ -777,11 +783,8 @@ async def search( return answer, metadata except Exception as e: - logger.error(f"💥 Error in enhanced modular search: {e}") - import traceback - - logger.error(f"🔍 Traceback: {traceback.format_exc()}") - return f"Search failed: {str(e)}", {"error": str(e)} + logger.exception("💥 Error in enhanced modular search") + return f"Search failed: {e!s}", {"error": str(e)} async def _generate_final_answer( self, query: str, best_candidate, constraints @@ -999,8 +1002,8 @@ def _gather_evidence_for_constraint(self, candidate, constraint): return evidence - except Exception as e: - logger.error(f"Error gathering evidence: {e}", exc_info=True) + except Exception: + logger.exception("Error gathering evidence") # Return empty list instead of mock evidence return [] @@ -1079,10 +1082,12 @@ async def _background_candidate_evaluation( ) except Exception as e: - logger.error(f"💥 Error evaluating {candidate.name}: {e}") + logger.exception( + f"💥 Error evaluating {candidate.name}: {e}" + ) - except Exception as e: - logger.error(f"💥 Background evaluation error: {e}") + except Exception: + logger.exception("💥 Background evaluation error") def analyze_topic(self, query: str) -> Dict: """ @@ -1128,14 +1133,11 @@ def analyze_topic(self, query: str) -> Dict: } except Exception as e: - logger.error(f"Error in analyze_topic: {e}") - import traceback - - logger.error(f"Traceback: {traceback.format_exc()}") + logger.exception("Error in analyze_topic") return { "findings": [], "iterations": 0, - "final_answer": f"Analysis failed: {str(e)}", + "final_answer": f"Analysis failed: {e!s}", "metadata": {"error": str(e)}, "links": [], "questions_by_iteration": [],
src/local_deep_research/advanced_search_system/strategies/news_strategy.py+284 −0 added@@ -0,0 +1,284 @@ +""" +News aggregation search strategy for LDR. +Uses optimized prompts and search patterns for news aggregation. +""" + +from typing import List, Dict, Any, Optional +from datetime import datetime, UTC +import json +import re +from loguru import logger + +from .base_strategy import BaseSearchStrategy +from ..questions.news_question import NewsQuestionGenerator + + +class NewsAggregationStrategy(BaseSearchStrategy): + """ + Specialized search strategy for news aggregation. + Uses single iteration with multiple parallel searches for broad coverage. + """ + + def __init__(self, model, search, all_links_of_system=None, **kwargs): + super().__init__(all_links_of_system=all_links_of_system) + self.model = model + self.search = search + self.strategy_name = "news_aggregation" + self.max_iterations = 1 # News needs broad coverage, not deep iteration + self.questions_per_iteration = 8 # More parallel searches for news + self.question_generator = NewsQuestionGenerator(self.model) + + def generate_questions(self, query: str, context: str) -> List[str]: + """Generate news-specific search queries using the NewsQuestionGenerator""" + return self.question_generator.generate_questions( + current_knowledge=context, + query=query, + questions_per_iteration=self.questions_per_iteration, + questions_by_iteration=self.questions_by_iteration, + ) + + async def analyze_findings( + self, all_findings: List[Dict] + ) -> Dict[str, Any]: + """Analyze search results to extract and structure news items""" + + if not all_findings: + return { + "status": "No news found", + "news_items": [], + "answer": "No significant news stories found for the specified criteria.", + } + + # Format findings for LLM analysis + snippets = [] + for i, finding in enumerate( + all_findings[:50] + ): # Limit to 50 for token efficiency + snippet = { + "id": i + 1, + "url": finding.get("url", ""), + "title": finding.get("title", ""), + "snippet": finding.get("snippet", "")[:300] + if finding.get("snippet") + else "", + "content": finding.get("content", "")[:500] + if finding.get("content") + else "", + } + snippets.append(snippet) + + # Create structured prompt for news extraction + prompt = self._create_news_analysis_prompt(snippets) + + try: + response = self.model.invoke(prompt) + content = ( + response.content + if hasattr(response, "content") + else str(response) + ) + + # Extract JSON from response + news_data = self._extract_json_from_response(content) + + if news_data and "news_items" in news_data: + return { + "status": "Success", + "news_items": news_data["news_items"], + "answer": self._format_news_summary( + news_data["news_items"] + ), + } + else: + # Fallback to simple extraction + return self._fallback_news_extraction(snippets) + + except Exception: + logger.exception("Error analyzing news findings") + return self._fallback_news_extraction(snippets) + + def _create_news_analysis_prompt(self, snippets: List[Dict]) -> str: + """Create the analysis prompt for news extraction""" + + snippet_text = "\n\n".join( + [ + f"[{s['id']}] Source: {s['url']}\n" + f"Title: {s['title']}\n" + f"Content: {s['snippet'] or s['content']}" + for s in snippets + ] + ) + + return f""" +Analyze these news snippets from search results and create a structured news report. +Today's date: {datetime.now(UTC).strftime("%B %d, %Y")} + +{snippet_text} + +Create a structured JSON response with the 10 most important news stories: +{{ + "news_items": [ + {{ + "headline": "8 words max describing the story", + "category": "War/Security/Economy/Tech/Politics/Health/Environment/Other", + "source_url": "url from snippets", + "source_id": "[number] from above", + "summary": "3 clear sentences about what happened", + "analysis": "Why this matters and what happens next (2 sentences)", + "impact_score": 1-10, + "entities": {{"people": ["names"], "places": ["locations"], "orgs": ["organizations"]}}, + "topics": ["topic1", "topic2"], + "time_ago": "estimated time (2 hours ago, yesterday, etc)", + "is_developing": true/false, + "surprising_element": "what makes this unexpected or notable (if any)" + }} + ] +}} + +PRIORITIZE: +1. Stories with casualties or significant human impact +2. Economic impacts over $1 billion +3. Major political or diplomatic developments +4. Unexpected or surprising events +5. Breaking developments from the last 24 hours + +Only include stories that are truly newsworthy and significant. +Ensure variety across different categories when possible. +""" + + def _extract_json_from_response(self, content: str) -> Optional[Dict]: + """Extract JSON from LLM response""" + try: + # Try to find JSON in the response + json_match = re.search(r"\{.*\}", content, re.DOTALL) + if json_match: + return json.loads(json_match.group()) + except Exception: + logger.exception("Error extracting JSON") + return None + + def _format_news_summary(self, news_items: List[Dict]) -> str: + """Format news items into a readable summary""" + + if not news_items: + return "No significant news stories found." + + # Group by category + by_category = {} + for item in news_items: + cat = item.get("category", "Other") + if cat not in by_category: + by_category[cat] = [] + by_category[cat].append(item) + + # Build summary + parts = [f"Found {len(news_items)} significant news stories:\n"] + + for category, items in by_category.items(): + parts.append(f"\n**{category}** ({len(items)} stories):") + for item in items[:3]: # Top 3 per category + parts.append( + f"- {item['headline']} " + f"(Impact: {item.get('impact_score', 'N/A')}/10)" + ) + + # Add top story details + if news_items: + top_story = max(news_items, key=lambda x: x.get("impact_score", 0)) + parts.append(f"\n**Top Story**: {top_story['headline']}") + parts.append(f"{top_story.get('summary', 'No summary available')}") + + return "\n".join(parts) + + def _fallback_news_extraction(self, snippets: List[Dict]) -> Dict[str, Any]: + """Simple fallback extraction when JSON parsing fails""" + + news_items = [] + for s in snippets[:10]: + if s["title"] and len(s["title"]) > 10: + news_items.append( + { + "headline": s["title"][:60], + "category": "Other", + "source_url": s["url"], + "summary": s["snippet"] or "No summary available", + "impact_score": 5, + } + ) + + return { + "status": "Fallback extraction", + "news_items": news_items, + "answer": f"Found {len(news_items)} news stories (simplified extraction)", + } + + def analyze_topic(self, query: str) -> Dict: + """ + Analyze a topic for news aggregation. + + Args: + query: The news query or focus area + + Returns: + Dict containing news findings and formatted output + """ + import asyncio + + # Generate news-specific search queries + questions = self.generate_questions(query, "") + self.questions_by_iteration[0] = questions + + all_findings = [] + + # Search for each question + for i, question in enumerate(questions): + self._update_progress( + f"Searching for: {question}", + int((i / len(questions)) * 50), + {"phase": "search", "question": question}, + ) + + try: + if self.search: + results = self.search.run(question) + if results: + all_findings.extend(results) + except Exception: + logger.exception("Search error") + continue + + # Analyze findings - handle both sync and async contexts + try: + # Check if we're already in an async context + loop = asyncio.get_event_loop() + if loop.is_running(): + # We're in an async context, create a task + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + asyncio.run, self.analyze_findings(all_findings) + ) + analysis = future.result() + else: + # We're in a sync context, use run_until_complete + analysis = loop.run_until_complete( + self.analyze_findings(all_findings) + ) + except RuntimeError: + # No event loop, create one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + analysis = loop.run_until_complete( + self.analyze_findings(all_findings) + ) + + return { + "findings": all_findings, + "iterations": 1, + "questions": self.questions_by_iteration, + "formatted_findings": analysis.get("answer", "No news found"), + "current_knowledge": analysis.get("answer", ""), + "news_items": analysis.get("news_items", []), + "status": analysis.get("status", "Unknown"), + }
src/local_deep_research/advanced_search_system/strategies/parallel_constrained_strategy.py+10 −10 modified@@ -350,8 +350,8 @@ def _parallel_search( "total_so_far": len(all_candidates), }, ) - except Exception as e: - logger.error(f"Search failed for {combo.query}: {e}") + except Exception: + logger.exception(f"Search failed for {combo.query}") return all_candidates @@ -385,8 +385,8 @@ def _execute_combination_search( ) return candidates - except Exception as e: - logger.error(f"Error in combination search: {e}", exc_info=True) + except Exception: + logger.exception("Error in combination search") return [] def _quick_extract_candidates( @@ -421,8 +421,8 @@ def _quick_extract_candidates( if name and len(name) > 2: candidates.append(Candidate(name=name)) return candidates[:15] - except Exception as e: - logger.error(f"Entity extraction failed: {e}") + except Exception: + logger.exception("Entity extraction failed") return [] def _validate_hard_constraints( @@ -464,8 +464,8 @@ def _validate_hard_constraints( ) return filtered - except Exception as e: - logger.error(f"Hard constraint validation failed: {e}") + except Exception: + logger.exception("Hard constraint validation failed") return candidates[:10] # Return top candidates if validation fails def _detect_entity_type(self) -> str: @@ -501,6 +501,6 @@ def _detect_entity_type(self) -> str: entity_type = self.model.invoke(prompt).content.strip() logger.info(f"LLM determined entity type: {entity_type}") return entity_type - except Exception as e: - logger.error(f"Failed to detect entity type: {e}") + except Exception: + logger.exception("Failed to detect entity type") return "unknown entity"
src/local_deep_research/advanced_search_system/strategies/parallel_search_strategy.py+29 −18 modified@@ -8,9 +8,8 @@ from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search -from ...utilities.db_utils import get_db_setting + +# Model and search should be provided by AdvancedSearchSystem from ...utilities.search_utilities import extract_links_from_search_results from ..filters.cross_engine_filter import CrossEngineFilter from ..findings.repository import FindingsRepository @@ -26,15 +25,16 @@ class ParallelSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, include_text_content: bool = True, use_cross_engine_filter: bool = True, filter_reorder: bool = True, filter_reindex: bool = True, cross_engine_max_results: int = None, all_links_of_system=None, + settings_snapshot=None, ): """Initialize with optional dependency injection for testing. @@ -48,10 +48,14 @@ def __init__( filter_reindex: Whether to update result indices after filtering cross_engine_max_results: Maximum number of results to keep after cross-engine filtering all_links_of_system: Optional list of links to initialize with + settings_snapshot: Settings snapshot for thread context """ - super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) + self.search = search + self.model = model self.progress_callback = None # Note: questions_by_iteration is already initialized by parent class self.include_text_content = include_text_content @@ -65,6 +69,7 @@ def __init__( max_results=cross_engine_max_results, default_reorder=filter_reorder, default_reindex=filter_reindex, + settings_snapshot=settings_snapshot, ) # Set include_full_content on the search engine if it supports it @@ -116,7 +121,7 @@ def analyze_topic(self, query: str) -> Dict: } # Determine number of iterations to run - iterations_to_run = get_db_setting("search.iterations") + iterations_to_run = self.get_setting("search.iterations") logger.debug("Selected amount of iterations: " + str(iterations_to_run)) iterations_to_run = int(iterations_to_run) try: @@ -148,12 +153,15 @@ def analyze_topic(self, query: str) -> Dict: context = f"""Iteration: {1} of {iterations_to_run}""" else: context = "" + questions_per_iter = self.get_setting( + "search.questions_per_iteration" + ) questions = self.question_generator.generate_questions( current_knowledge=context, query=query, - questions_per_iteration=int( - get_db_setting("search.questions_per_iteration") - ), + questions_per_iteration=int(questions_per_iter) + if questions_per_iter is not None + else 3, questions_by_iteration=self.questions_by_iteration, ) @@ -183,12 +191,15 @@ def analyze_topic(self, query: str) -> Dict: Iteration: {iteration} of {iterations_to_run}""" # Generate questions + questions_per_iter = self.get_setting( + "search.questions_per_iteration" + ) questions = self.question_generator.generate_questions( current_knowledge=context, query=query, - questions_per_iteration=int( - get_db_setting("search.questions_per_iteration") - ), + questions_per_iteration=int(questions_per_iter) + if questions_per_iter is not None + else 3, questions_by_iteration=self.questions_by_iteration, ) @@ -424,10 +435,10 @@ def search_question(q): ) except Exception as e: - error_msg = f"Error in research process: {str(e)}" + error_msg = f"Error in research process: {e!s}" logger.exception(error_msg) - synthesized_content = f"Error: {str(e)}" - formatted_findings = f"Error: {str(e)}" + synthesized_content = f"Error: {e!s}" + formatted_findings = f"Error: {e!s}" finding = { "phase": "Error", "content": synthesized_content,
src/local_deep_research/advanced_search_system/strategies/rapid_search_strategy.py+16 −12 modified@@ -7,8 +7,8 @@ from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search + +# Model and search should be provided by AdvancedSearchSystem from ...utilities.search_utilities import extract_links_from_search_results from ..findings.repository import FindingsRepository from ..knowledge.standard_knowledge import StandardKnowledge @@ -24,15 +24,19 @@ class RapidSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, all_links_of_system=None, + settings_snapshot=None, ): """Initialize with optional dependency injection for testing.""" - super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) + self.search = search + self.model = model self.progress_callback = None # Note: questions_by_iteration is already initialized by parent class @@ -121,7 +125,7 @@ def analyze_topic(self, query: str) -> Dict: # No findings added here - just collecting data except Exception as e: - error_msg = f"Error during initial search: {str(e)}" + error_msg = f"Error during initial search: {e!s}" logger.exception(f"SEARCH ERROR: {error_msg}") self._update_progress( error_msg, 15, {"phase": "search_error", "error": str(e)} @@ -197,7 +201,7 @@ def analyze_topic(self, query: str) -> Dict: # No findings added here - just collecting data except Exception as e: - error_msg = f"Error during search: {str(e)}" + error_msg = f"Error during search: {e!s}" logger.exception(f"SEARCH ERROR: {error_msg}") self._update_progress( error_msg, @@ -266,10 +270,10 @@ def analyze_topic(self, query: str) -> Dict: findings.append(finding) except Exception as e: - error_msg = f"Error synthesizing final answer: {str(e)}" + error_msg = f"Error synthesizing final answer: {e!s}" logger.exception(error_msg) - synthesized_content = f"Error generating synthesis: {str(e)}" - formatted_findings = f"Error: {str(e)}" + synthesized_content = f"Error generating synthesis: {e!s}" + formatted_findings = f"Error: {e!s}" finding = { "phase": "Error", "content": synthesized_content,
src/local_deep_research/advanced_search_system/strategies/recursive_decomposition_strategy.py+2 −2 modified@@ -487,6 +487,6 @@ def _use_source_based_strategy(self, query: str) -> Dict: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - from datetime import datetime + from datetime import datetime, UTC - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat()
src/local_deep_research/advanced_search_system/strategies/smart_decomposition_strategy.py+7 −4 modified@@ -47,6 +47,7 @@ def __init__( model: BaseChatModel, search: Any, all_links_of_system: List[str], + settings_snapshot=None, **kwargs, ): """Initialize the smart decomposition strategy. @@ -55,9 +56,10 @@ def __init__( model: The language model to use search: The search engine instance all_links_of_system: List to store all encountered links + settings_snapshot: Settings snapshot for thread context **kwargs: Additional parameters for sub-strategies """ - super().__init__(all_links_of_system) + super().__init__(all_links_of_system, settings_snapshot) self.model = model self.search = search self.strategy_params = kwargs @@ -95,9 +97,10 @@ def analyze_topic(self, query: str) -> Dict: return self._use_evidence_strategy(query) elif query_type == QueryType.CONSTRAINT_BASED: return self._use_evidence_strategy(query) - elif query_type == QueryType.HIERARCHICAL: - return self._use_recursive_strategy(query) - elif query_type in [QueryType.COMPARATIVE, QueryType.EXPLORATORY]: + elif query_type == QueryType.HIERARCHICAL or query_type in [ + QueryType.COMPARATIVE, + QueryType.EXPLORATORY, + ]: return self._use_recursive_strategy(query) else: # FACTUAL or unknown return self._use_adaptive_strategy(query)
src/local_deep_research/advanced_search_system/strategies/smart_query_strategy.py+17 −15 modified@@ -101,8 +101,8 @@ def _generate_smart_query(self, constraints: List[Constraint]) -> str: logger.info(f"LLM generated query: {query}") return query - except Exception as e: - logger.error(f"Failed to generate smart query: {e}") + except Exception: + logger.exception("Failed to generate smart query") return self._build_standard_query(constraints) def _build_standard_query(self, constraints: List[Constraint]) -> str: @@ -179,7 +179,9 @@ def _execute_combination_search(self, combo) -> List: f"Query '{query}' found {len(candidates)} candidates" ) except Exception as e: - logger.error(f"Search failed for query '{query}': {e}") + logger.exception( + f"Search failed for query '{query}': {e}" + ) else: # Use single query from parent implementation candidates = super()._execute_combination_search(combo) @@ -250,8 +252,8 @@ def _generate_query_variations( unique_queries = [fallback] return unique_queries[: self.queries_per_combination] - except Exception as e: - logger.error(f"Failed to generate query variations: {e}") + except Exception: + logger.exception("Failed to generate query variations") # Fallback to single query return [self._build_standard_query(constraints)] @@ -290,8 +292,8 @@ def _extract_candidates_from_results(self, results: Dict) -> List: logger.info(f"Extracted {len(candidates)} candidates from results") - except Exception as e: - logger.error(f"Error extracting candidates: {e}") + except Exception: + logger.exception("Error extracting candidates") return candidates @@ -344,8 +346,8 @@ def _perform_entity_seeding(self): # Immediately search for these seeds self._search_entity_seeds() - except Exception as e: - logger.error(f"Error generating entity seeds: {e}") + except Exception: + logger.exception("Error generating entity seeds") def _search_entity_seeds(self): """Search for the entity seeds directly.""" @@ -381,8 +383,8 @@ def _search_entity_seeds(self): self.candidates = [] self.candidates.append(candidate) - except Exception as e: - logger.error(f"Error searching for seed {seed}: {e}") + except Exception: + logger.exception(f"Error searching for seed {seed}") def _try_direct_property_search(self): """Try direct searches for high-weight property constraints.""" @@ -441,8 +443,8 @@ def _try_direct_property_search(self): if hasattr(self, "_evaluate_candidate_immediately"): self._evaluate_candidate_immediately(candidate) - except Exception as e: - logger.error(f"Property search error: {e}") + except Exception: + logger.exception("Property search error") def _perform_entity_name_search(self): """Last resort: search for entity names directly with constraints.""" @@ -486,8 +488,8 @@ def _perform_entity_name_search(self): ): return - except Exception as e: - logger.error(f"Entity name search error: {e}") + except Exception: + logger.exception("Entity name search error") def _progressive_constraint_search(self): """Override to add entity seeding and property search."""
src/local_deep_research/advanced_search_system/strategies/source_based_strategy.py+51 −30 modified@@ -4,11 +4,14 @@ from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search -from ...utilities.db_utils import get_db_setting +# LLM and search instances should be passed via constructor, not imported + +# Removed get_db_setting import - using settings_snapshot instead +from ...utilities.thread_context import ( + preserve_research_context, + get_search_context, +) from ...utilities.threading_utils import thread_context, thread_with_app_context -from ...utilities.thread_context import preserve_research_context from ..filters.cross_engine_filter import CrossEngineFilter from ..findings.repository import FindingsRepository from ..questions.atomic_fact_question import AtomicFactQuestionGenerator @@ -24,8 +27,8 @@ class SourceBasedSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, include_text_content: bool = True, use_cross_engine_filter: bool = True, @@ -34,15 +37,22 @@ def __init__( cross_engine_max_results: int = None, all_links_of_system=None, use_atomic_facts: bool = False, + settings_snapshot=None, + search_original_query: bool = True, ): """Initialize with optional dependency injection for testing.""" - # Pass the links list to the parent class - super().__init__(all_links_of_system=all_links_of_system) - # Use provided model and search, or fall back to defaults - # Note: If model/search are provided, they should already have the proper context - self.model = model if model is not None else get_llm() - self.search = search if search is not None else get_search() + # Pass the links list and settings to the parent class + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + search_original_query=search_original_query, + ) + + # Model and search are always provided by AdvancedSearchSystem + self.model = model + self.search = search # Note: progress_callback and questions_by_iteration are already set by parent class + self.include_text_content = include_text_content self.use_cross_engine_filter = use_cross_engine_filter self.filter_reorder = filter_reorder @@ -54,6 +64,7 @@ def __init__( max_results=cross_engine_max_results, default_reorder=filter_reorder, default_reindex=filter_reindex, + settings_snapshot=settings_snapshot, ) # Set include_full_content on the search engine if it supports it @@ -119,7 +130,7 @@ def analyze_topic(self, query: str) -> Dict: } # Determine number of iterations to run - iterations_to_run = get_db_setting("search.iterations", 2) + iterations_to_run = self.get_setting("search.iterations", 2) logger.debug("Selected amount of iterations: " + str(iterations_to_run)) iterations_to_run = int(iterations_to_run) try: @@ -156,16 +167,24 @@ def analyze_topic(self, query: str) -> Dict: current_knowledge=context, query=query, questions_per_iteration=int( - get_db_setting("search.questions_per_iteration") + self.get_setting( + "search.questions_per_iteration", 5 + ) # Default to 5 if not set ), questions_by_iteration=self.questions_by_iteration, ) - # Always include the original query for the first iteration - if query not in questions: - all_questions = [query] + questions - else: - all_questions = questions + # Include original query if enabled and not already present + all_questions = ( + [query] + questions + if self.search_original_query and query not in questions + else questions + ) + + if not self.search_original_query: + logger.info( + "search_original_query=False - skipping original query" + ) self.questions_by_iteration[iteration] = all_questions logger.info( @@ -189,7 +208,9 @@ def analyze_topic(self, query: str) -> Dict: current_knowledge=context, query=query, questions_per_iteration=int( - get_db_setting("search.questions_per_iteration", 2) + self.get_setting( + "search.questions_per_iteration", 2 + ) ), questions_by_iteration=self.questions_by_iteration, ) @@ -215,10 +236,13 @@ def analyze_topic(self, query: str) -> Dict: @preserve_research_context def search_question(q): try: - result = self.search.run(q) + current_context = get_search_context() + result = self.search.run( + q, research_context=current_context + ) return {"question": q, "results": result or []} except Exception as e: - logger.error(f"Error searching for '{q}': {str(e)}") + logger.exception(f"Error searching for '{q}': {e!s}") return {"question": q, "results": [], "error": str(e)} # Run searches in parallel @@ -326,7 +350,7 @@ def search_question(q): reorder=True, # Always reorder in final filtering reindex=True, # Always reindex in final filtering max_results=int( - get_db_setting("search.final_max_results") or 100 + self.get_setting("search.final_max_results", 100) ), start_index=len(self.all_links_of_system), ) @@ -394,13 +418,10 @@ def search_question(q): ) except Exception as e: - import traceback - - error_msg = f"Error in research process: {str(e)}" - logger.error(error_msg) - logger.error(traceback.format_exc()) - synthesized_content = f"Error: {str(e)}" - formatted_findings = f"Error: {str(e)}" + error_msg = f"Error in research process: {e!s}" + logger.exception(error_msg) + synthesized_content = f"Error: {e!s}" + formatted_findings = f"Error: {e!s}" finding = { "phase": "Error", "content": synthesized_content,
src/local_deep_research/advanced_search_system/strategies/standard_strategy.py+30 −15 modified@@ -4,9 +4,9 @@ from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search -from ...utilities.db_utils import get_db_setting + +# Model and search should be provided by AdvancedSearchSystem +from ...config.thread_settings import get_setting_from_snapshot from ...utilities.enums import KnowledgeAccumulationApproach from ...utilities.search_utilities import extract_links_from_search_results from ..findings.repository import FindingsRepository @@ -20,24 +20,38 @@ class StandardSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, all_links_of_system=None, + settings_snapshot=None, ): """Initialize with optional dependency injection for testing.""" - super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) + self.search = search + self.model = model # Get iterations setting - self.max_iterations = int(get_db_setting("search.iterations")) + self.max_iterations = int( + get_setting_from_snapshot( + "search.iterations", settings_snapshot=settings_snapshot + ) + ) self.questions_per_iteration = int( - get_db_setting("search.questions_per_iteration") + get_setting_from_snapshot( + "search.questions_per_iteration", + settings_snapshot=settings_snapshot, + ) ) self.context_limit = int( - get_db_setting("general.knowledge_accumulation_context_limit") + get_setting_from_snapshot( + "general.knowledge_accumulation_context_limit", + settings_snapshot=settings_snapshot, + ) ) # Note: questions_by_iteration is already initialized by parent class @@ -123,9 +137,10 @@ def analyze_topic(self, query: str) -> Dict: logger.info(f"Generated questions: {questions}") question_count = len(questions) - knowledge_accumulation = get_db_setting( + knowledge_accumulation = get_setting_from_snapshot( "general.knowledge_accumulation", "ITERATION", + settings_snapshot=self.settings_snapshot, ) for q_idx, question in enumerate(questions): question_progress_base = iteration_progress_base + ( @@ -158,7 +173,7 @@ def analyze_topic(self, query: str) -> Dict: else: search_results = self.search.run(question) except Exception as e: - error_msg = f"Error during search: {str(e)}" + error_msg = f"Error during search: {e!s}" logger.exception(f"SEARCH ERROR: {error_msg}") self._handle_search_error( error_msg, question_progress_base + 10 @@ -247,7 +262,7 @@ def analyze_topic(self, query: str) -> Dict: {"phase": "analysis_complete"}, ) except Exception as e: - error_msg = f"Error analyzing results: {str(e)}" + error_msg = f"Error analyzing results: {e!s}" logger.exception(f"ANALYSIS ERROR: {error_msg}") self._handle_search_error( error_msg, question_progress_base + 10 @@ -274,7 +289,7 @@ def analyze_topic(self, query: str) -> Dict: ) logger.info("FINISHED ITERATION - Compressing Knowledge") except Exception as e: - error_msg = f"Error compressing knowledge: {str(e)}" + error_msg = f"Error compressing knowledge: {e!s}" logger.exception(f"COMPRESSION ERROR: {error_msg}") self._handle_search_error( error_msg, int((iteration / total_iterations) * 100 - 3)
src/local_deep_research/advanced_search_system/tools/base_tool.py+1 −3 modified@@ -3,12 +3,10 @@ Defines the common interface and shared functionality for different tools. """ -import logging +from loguru import logger from abc import ABC, abstractmethod from typing import Any, Dict -logger = logging.getLogger(__name__) - class BaseTool(ABC): """Abstract base class for all agent-compatible tools."""
src/local_deep_research/api/benchmark_functions.py+9 −10 modified@@ -4,7 +4,8 @@ This module provides functions for running benchmarks programmatically. """ -import logging +from loguru import logger +from pathlib import Path from typing import Any, Dict, List, Optional from ..benchmarks import ( @@ -15,8 +16,6 @@ run_simpleqa_benchmark, ) -logger = logging.getLogger(__name__) - def evaluate_simpleqa( num_examples: int = 100, @@ -220,7 +219,7 @@ def compare_configurations( benchmark_result = run_benchmark( dataset_type=dataset_type, num_examples=num_examples, - output_dir=os.path.join(output_dir, config_name.replace(" ", "_")), + output_dir=str(Path(output_dir) / config_name.replace(" ", "_")), search_config=search_config, run_evaluation=True, ) @@ -235,8 +234,8 @@ def compare_configurations( import time timestamp = time.strftime("%Y%m%d_%H%M%S") - report_file = os.path.join( - output_dir, f"comparison_{dataset_type}_{timestamp}.md" + report_file = str( + Path(output_dir) / f"comparison_{dataset_type}_{timestamp}.md" ) with open(report_file, "w") as f: @@ -282,11 +281,11 @@ def compare_configurations( # Export the API functions __all__ = [ - "evaluate_simpleqa", + "calculate_metrics", + "compare_configurations", "evaluate_browsecomp", + "evaluate_simpleqa", + "generate_report", "get_available_benchmarks", - "compare_configurations", "run_benchmark", # For advanced users - "calculate_metrics", - "generate_report", ]
src/local_deep_research/api/__init__.py+21 −2 modified@@ -9,10 +9,29 @@ generate_report, quick_summary, ) +from .settings_utils import ( + create_settings_snapshot, + get_default_settings_snapshot, + extract_setting_value, +) + +from ..news import ( + get_news_feed, + research_news_item, + save_news_preferences, + get_news_categories, +) __all__ = [ - "quick_summary", + "analyze_documents", "detailed_research", "generate_report", - "analyze_documents", + "quick_summary", + "create_settings_snapshot", + "get_default_settings_snapshot", + "extract_setting_value", + "get_news_feed", + "research_news_item", + "save_news_preferences", + "get_news_categories", ]
src/local_deep_research/api/research_functions.py+166 −12 modified@@ -3,16 +3,20 @@ Provides programmatic access to search and research capabilities. """ -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Callable, Dict, Optional, Union from loguru import logger +from local_deep_research.settings.logger import log_settings from ..config.llm_config import get_llm from ..config.search_config import get_search from ..report_generator import IntegratedReportGenerator from ..search_system import AdvancedSearchSystem +from ..utilities.db_utils import no_db_settings +from ..utilities.thread_context import set_search_context from ..utilities.search_utilities import remove_think_tags +from .settings_utils import create_settings_snapshot def _init_search_system( @@ -27,6 +31,12 @@ def _init_search_system( questions_per_iteration: int = 1, retrievers: Optional[Dict[str, Any]] = None, llms: Optional[Dict[str, Any]] = None, + username: Optional[str] = None, + research_id: Optional[Union[int, str]] = None, + research_context: Optional[Dict[str, Any]] = None, + programmatic_mode: bool = True, + search_original_query: bool = True, + **kwargs: Any, ) -> AdvancedSearchSystem: """ Initializes the advanced search system with specified parameters. This function sets up @@ -48,6 +58,8 @@ def _init_search_system( search_strategy: The name of the search strategy to use. retrievers: Optional dictionary of {name: retriever} pairs to use as search engines llms: Optional dictionary of {name: llm} pairs to use as language models + programmatic_mode: If True, disables database operations and metrics tracking + search_original_query: Whether to include the original query in the first iteration of search Returns: AdvancedSearchSystem: An instance of the configured AdvancedSearchSystem. @@ -70,18 +82,36 @@ def _init_search_system( register_llm(name, llm_instance) logger.info(f"Registered {len(llms)} LLMs: {list(llms.keys())}") + # Extract settings_snapshot from kwargs if available + settings_snapshot = kwargs.get("settings_snapshot") + # Get language model with custom temperature llm = get_llm( temperature=temperature, openai_endpoint_url=openai_endpoint_url, model_name=model_name, provider=provider, + research_id=research_id, + research_context=research_context, + settings_snapshot=settings_snapshot, ) - # Set the search engine if specified + # Set the search engine if specified or get from settings search_engine = None + settings_snapshot = kwargs.get("settings_snapshot") + + # If no search_tool provided, get from settings_snapshot + if not search_tool and settings_snapshot: + search_tool = settings_snapshot.get("search.tool") + if search_tool: - search_engine = get_search(search_tool, llm_instance=llm) + search_engine = get_search( + search_tool, + llm_instance=llm, + username=username, + settings_snapshot=settings_snapshot, + programmatic_mode=programmatic_mode, + ) if search_engine is None: logger.warning( f"Could not create search engine '{search_tool}', using default." @@ -90,7 +120,15 @@ def _init_search_system( # Create search system with custom parameters logger.info("Search strategy: {}", search_strategy) system = AdvancedSearchSystem( - llm=llm, search=search_engine, strategy_name=search_strategy + llm=llm, + search=search_engine, + strategy_name=search_strategy, + username=username, + research_id=research_id, + research_context=research_context, + settings_snapshot=settings_snapshot, + programmatic_mode=programmatic_mode, + search_original_query=search_original_query, ) # Override default settings with user-provided values @@ -104,11 +142,20 @@ def _init_search_system( return system +@no_db_settings def quick_summary( query: str, research_id: Optional[Union[int, str]] = None, retrievers: Optional[Dict[str, Any]] = None, llms: Optional[Dict[str, Any]] = None, + username: Optional[str] = None, + provider: Optional[str] = None, + api_key: Optional[str] = None, + temperature: Optional[float] = None, + max_search_results: Optional[int] = None, + settings: Optional[Dict[str, Any]] = None, + settings_override: Optional[Dict[str, Any]] = None, + search_original_query: bool = True, **kwargs: Any, ) -> Dict[str, Any]: """ @@ -119,7 +166,15 @@ def quick_summary( research_id: Optional research ID (int or UUID string) for tracking metrics retrievers: Optional dictionary of {name: retriever} pairs to use as search engines llms: Optional dictionary of {name: llm} pairs to use as language models - **kwargs: Configuration for the search system. Will be forwarded to + provider: LLM provider to use (e.g., 'openai', 'anthropic'). For programmatic API only. + api_key: API key for the provider. For programmatic API only. + temperature: LLM temperature (0.0-1.0). For programmatic API only. + max_search_results: Maximum number of search results to return. For programmatic API only. + settings: Base settings dict to use instead of defaults. For programmatic API only. + settings_override: Dictionary of settings to override (e.g., {"llm.max_tokens": 4000}). For programmatic API only. + search_original_query: Whether to include the original query in the first iteration of search. + Set to False for news searches to avoid sending long subscription prompts to search engines. + **kwargs: Additional configuration for the search system. Will be forwarded to `_init_search_system()`. Returns: @@ -128,9 +183,51 @@ def quick_summary( - 'findings': List of detailed findings from each search - 'iterations': Number of iterations performed - 'questions': Questions generated during research + + Examples: + # Simple usage with defaults + result = quick_summary("What is quantum computing?") + + # With custom provider + result = quick_summary( + "What is quantum computing?", + provider="anthropic", + api_key="sk-ant-..." + ) + + # With advanced settings + result = quick_summary( + "What is quantum computing?", + temperature=0.2, + settings_override={"search.engines.arxiv.enabled": True} + ) """ logger.info("Generating quick summary for query: %s", query) + # Only create settings snapshot if not already provided (programmatic API) + if "settings_snapshot" not in kwargs: + # Build kwargs for create_settings_snapshot from explicit parameters + snapshot_kwargs = {} + if provider is not None: + snapshot_kwargs["provider"] = provider + if api_key is not None: + snapshot_kwargs["api_key"] = api_key + if temperature is not None: + snapshot_kwargs["temperature"] = temperature + if max_search_results is not None: + snapshot_kwargs["max_search_results"] = max_search_results + + # Create settings snapshot for programmatic use + kwargs["settings_snapshot"] = create_settings_snapshot( + base_settings=settings, + overrides=settings_override, + **snapshot_kwargs, + ) + log_settings( + kwargs["settings_snapshot"], + "Created settings snapshot for programmatic API", + ) + # Generate a research_id if none provided if research_id is None: import uuid @@ -155,21 +252,27 @@ def quick_summary( register_llm(name, llm_instance) logger.info(f"Registered {len(llms)} LLMs: {list(llms.keys())}") - # Set search context with research_id - from ..metrics.search_tracker import set_search_context - search_context = { "research_id": research_id, # Pass UUID or integer directly "research_query": query, "research_mode": kwargs.get("research_mode", "quick"), "research_phase": "init", "search_iteration": 0, "search_engine_selected": kwargs.get("search_tool"), + "username": username, # Include username for metrics tracking + "user_password": kwargs.get( + "user_password" + ), # Include password for metrics tracking } set_search_context(search_context) # Remove research_mode from kwargs before passing to _init_search_system init_kwargs = {k: v for k, v in kwargs.items() if k != "research_mode"} + # Make sure username is passed to the system + init_kwargs["username"] = username + init_kwargs["research_id"] = research_id + init_kwargs["research_context"] = search_context + init_kwargs["search_original_query"] = search_original_query system = _init_search_system(llms=llms, **init_kwargs) # Perform the search and analysis @@ -192,13 +295,20 @@ def quick_summary( } +@no_db_settings def generate_report( query: str, output_file: Optional[str] = None, progress_callback: Optional[Callable] = None, searches_per_section: int = 2, retrievers: Optional[Dict[str, Any]] = None, llms: Optional[Dict[str, Any]] = None, + provider: Optional[str] = None, + api_key: Optional[str] = None, + temperature: Optional[float] = None, + max_search_results: Optional[int] = None, + settings: Optional[Dict[str, Any]] = None, + settings_override: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> Dict[str, Any]: """ @@ -212,14 +322,59 @@ def generate_report( section in the report. retrievers: Optional dictionary of {name: retriever} pairs to use as search engines llms: Optional dictionary of {name: llm} pairs to use as language models + provider: LLM provider to use (e.g., 'openai', 'anthropic'). For programmatic API only. + api_key: API key for the provider. For programmatic API only. + temperature: LLM temperature (0.0-1.0). For programmatic API only. + max_search_results: Maximum number of search results to return. For programmatic API only. + settings: Base settings dict to use instead of defaults. For programmatic API only. + settings_override: Dictionary of settings to override. For programmatic API only. + **kwargs: Additional configuration for the search system. Returns: Dictionary containing the research report with keys: - 'content': The full report content in markdown format - 'metadata': Report metadata including generated timestamp and query + - 'file_path': Path to saved file (if output_file was provided) + + Examples: + # Simple usage with settings snapshot + from local_deep_research.api.settings_utils import create_settings_snapshot + settings = create_settings_snapshot({"programmatic_mode": True}) + result = generate_report("AI research", settings_snapshot=settings) + + # Save to file + result = generate_report( + "AI research", + output_file="report.md", + settings_snapshot=settings + ) """ logger.info("Generating comprehensive research report for query: %s", query) + # Only create settings snapshot if not already provided (programmatic API) + if "settings_snapshot" not in kwargs: + # Build kwargs for create_settings_snapshot from explicit parameters + snapshot_kwargs = {} + if provider is not None: + snapshot_kwargs["provider"] = provider + if api_key is not None: + snapshot_kwargs["api_key"] = api_key + if temperature is not None: + snapshot_kwargs["temperature"] = temperature + if max_search_results is not None: + snapshot_kwargs["max_search_results"] = max_search_results + + # Create settings snapshot for programmatic use + kwargs["settings_snapshot"] = create_settings_snapshot( + base_settings=settings, + overrides=settings_override, + **snapshot_kwargs, + ) + log_settings( + kwargs["settings_snapshot"], + "Created settings snapshot for programmatic API", + ) + # Register retrievers if provided if retrievers: from ..web_search_engines.retriever_registry import retriever_registry @@ -263,6 +418,7 @@ def generate_report( return report +@no_db_settings def detailed_research( query: str, research_id: Optional[Union[int, str]] = None, @@ -311,9 +467,6 @@ def detailed_research( register_llm(name, llm_instance) logger.info(f"Registered {len(llms)} LLMs: {list(llms.keys())}") - # Set search context - from ..metrics.search_tracker import set_search_context - search_context = { "research_id": research_id, "research_query": query, @@ -341,14 +494,15 @@ def detailed_research( "formatted_findings": results.get("formatted_findings", ""), "sources": results.get("all_links_of_system", []), "metadata": { - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "search_tool": kwargs.get("search_tool", "auto"), "iterations_requested": kwargs.get("iterations", 1), "strategy": kwargs.get("search_strategy", "source_based"), }, } +@no_db_settings def analyze_documents( query: str, collection_name: str,
src/local_deep_research/api/settings_utils.py+280 −0 added@@ -0,0 +1,280 @@ +""" +Utilities for managing settings in the programmatic API. + +This module provides functions to create settings snapshots for the API +without requiring database access, reusing the same mechanisms as the +web interface. +""" + +import os +import copy +from typing import Any, Dict, Optional, Union +from loguru import logger + +from ..settings import SettingsManager +from ..settings.base import ISettingsManager + + +class InMemorySettingsManager(ISettingsManager): + """ + In-memory settings manager that doesn't require database access. + + This is used for the programmatic API to provide settings without + needing a database connection. + """ + + # Type mapping from UI elements to Python types (same as SettingsManager) + _UI_ELEMENT_TO_SETTING_TYPE = { + "text": str, + # JSON should already be parsed + "json": lambda x: x, + "password": str, + "select": str, + "number": float, + "range": float, + "checkbox": bool, + } + + def __init__(self): + """Initialize with default settings from JSON file.""" + # Create a base manager to get default settings + self._base_manager = SettingsManager(db_session=None) + self._settings = {} + self._load_defaults() + + def _get_typed_value(self, setting_data: Dict[str, Any], value: Any) -> Any: + """ + Convert a value to the appropriate type based on the setting's ui_element. + + Args: + setting_data: The setting metadata containing ui_element + value: The value to convert + + Returns: + The typed value, or the original value if conversion fails + """ + ui_element = setting_data.get("ui_element", "text") + setting_type = self._UI_ELEMENT_TO_SETTING_TYPE.get(ui_element) + + if setting_type is None: + logger.warning( + f"Unknown ui_element type: {ui_element}, returning value as-is" + ) + return value + + try: + # Special handling for checkbox/bool with string values + if ui_element == "checkbox" and isinstance(value, str): + return value.lower() in ("true", "1", "yes", "on") + return setting_type(value) + except (ValueError, TypeError) as e: + logger.warning( + f"Failed to convert value {value} to type {setting_type}: {e}" + ) + return value + + def _load_defaults(self): + """Load default settings from the JSON file.""" + # Get default settings from the base manager + defaults = self._base_manager.default_settings + + # Convert to the format expected by get_all_settings + for key, setting_data in defaults.items(): + self._settings[key] = setting_data.copy() + + # Check environment variable override + env_key = f"LDR_{key.upper().replace('.', '_')}" + env_value = os.environ.get(env_key) + if env_value is not None: + # Use the typed value conversion + self._settings[key]["value"] = self._get_typed_value( + setting_data, env_value + ) + + def get_setting( + self, key: str, default: Any = None, check_env: bool = True + ) -> Any: + """Get a setting value.""" + if key in self._settings: + setting_data = self._settings[key] + value = setting_data.get("value", default) + # Ensure the value has the correct type + return self._get_typed_value(setting_data, value) + return default + + def set_setting(self, key: str, value: Any, commit: bool = True) -> bool: + """Set a setting value (in memory only).""" + if key in self._settings: + # Validate and convert the value to the correct type + typed_value = self._get_typed_value(self._settings[key], value) + self._settings[key]["value"] = typed_value + return True + return False + + def get_all_settings(self) -> Dict[str, Any]: + """Get all settings with metadata.""" + return copy.deepcopy(self._settings) + + def load_from_defaults_file( + self, commit: bool = True, **kwargs: Any + ) -> None: + """Reload defaults (already done in __init__).""" + self._load_defaults() + + def create_or_update_setting( + self, setting: Union[Dict[str, Any], Any], commit: bool = True + ) -> Optional[Any]: + """Create or update a setting (in memory only).""" + if isinstance(setting, dict) and "key" in setting: + key = setting["key"] + # If the setting has a value, ensure it has the correct type + if "value" in setting: + typed_value = self._get_typed_value(setting, setting["value"]) + setting = setting.copy() # Don't modify the original + setting["value"] = typed_value + self._settings[key] = setting + return setting + return None + + def delete_setting(self, key: str, commit: bool = True) -> bool: + """Delete a setting (in memory only).""" + if key in self._settings: + del self._settings[key] + return True + return False + + def import_settings( + self, + settings_data: Dict[str, Any], + commit: bool = True, + overwrite: bool = True, + delete_extra: bool = False, + ) -> None: + """Import settings from a dictionary.""" + if delete_extra: + self._settings.clear() + + for key, value in settings_data.items(): + if overwrite or key not in self._settings: + # Ensure proper type handling for imported settings + if isinstance(value, dict) and "value" in value: + typed_value = self._get_typed_value(value, value["value"]) + value = value.copy() + value["value"] = typed_value + self._settings[key] = value + + +def get_default_settings_snapshot() -> Dict[str, Any]: + """ + Get a complete settings snapshot with default values. + + This uses the same mechanism as the web interface but without + requiring database access. Environment variables are checked + for overrides. + + Returns: + Dict mapping setting keys to their values and metadata + """ + manager = InMemorySettingsManager() + return manager.get_all_settings() + + +def create_settings_snapshot( + base_settings: Optional[Dict[str, Any]] = None, + overrides: Optional[Dict[str, Any]] = None, + **kwargs, +) -> Dict[str, Any]: + """ + Create a settings snapshot for the programmatic API. + + Args: + base_settings: Base settings dict (defaults to get_default_settings_snapshot()) + overrides: Dict of setting overrides (e.g., {"llm.provider": "openai"}) + **kwargs: Common setting shortcuts: + - provider: Maps to "llm.provider" + - api_key: Maps to "llm.{provider}.api_key" + - temperature: Maps to "llm.temperature" + - max_search_results: Maps to "search.max_results" + - search_engines: Maps to enabled search engines + + Returns: + Complete settings snapshot for use with the API + """ + # Start with base settings or defaults + if base_settings is None: + settings = get_default_settings_snapshot() + else: + settings = copy.deepcopy(base_settings) + + # Apply overrides if provided + if overrides: + for key, value in overrides.items(): + if key in settings: + if isinstance(settings[key], dict) and "value" in settings[key]: + settings[key]["value"] = value + else: + settings[key] = value + else: + # Create a simple setting entry for unknown keys + settings[key] = {"value": value} + + # Handle common kwargs shortcuts + if "provider" in kwargs: + provider = kwargs["provider"] + if "llm.provider" in settings: + settings["llm.provider"]["value"] = provider + else: + settings["llm.provider"] = {"value": provider} + + # Handle api_key if provided + if "api_key" in kwargs: + api_key = kwargs["api_key"] + api_key_setting = f"llm.{provider}.api_key" + if api_key_setting in settings: + settings[api_key_setting]["value"] = api_key + else: + settings[api_key_setting] = {"value": api_key} + + if "temperature" in kwargs: + if "llm.temperature" in settings: + settings["llm.temperature"]["value"] = kwargs["temperature"] + else: + settings["llm.temperature"] = {"value": kwargs["temperature"]} + + if "max_search_results" in kwargs: + if "search.max_results" in settings: + settings["search.max_results"]["value"] = kwargs[ + "max_search_results" + ] + else: + settings["search.max_results"] = { + "value": kwargs["max_search_results"] + } + + # Add any other common shortcuts here... + + return settings + + +def extract_setting_value( + settings_snapshot: Dict[str, Any], key: str, default: Any = None +) -> Any: + """ + Extract a setting value from a settings snapshot. + + Args: + settings_snapshot: Settings snapshot dict + key: Setting key (e.g., "llm.provider") + default: Default value if not found + + Returns: + The setting value + """ + if settings_snapshot is None: + return default + if key in settings_snapshot: + setting = settings_snapshot[key] + if isinstance(setting, dict) and "value" in setting: + return setting["value"] + return setting + return default
src/local_deep_research/benchmarks/benchmark_functions.py+21 −21 modified@@ -4,10 +4,13 @@ This module provides functions for running benchmarks programmatically. """ -import logging -import os +from pathlib import Path from typing import Any, Dict, List, Optional +from loguru import logger + +from ..config.thread_settings import get_setting_from_snapshot + from ..benchmarks import ( calculate_metrics, generate_report, @@ -16,8 +19,6 @@ run_simpleqa_benchmark, ) -logger = logging.getLogger(__name__) - def evaluate_simpleqa( num_examples: int = 100, @@ -71,12 +72,12 @@ def evaluate_simpleqa( if endpoint_url: search_config["openai_endpoint_url"] = endpoint_url - # Check environment variables for additional configuration - if env_model := os.environ.get("LDR_SEARCH_MODEL"): + # Check settings for additional configuration + if env_model := get_setting_from_snapshot("llm.model"): search_config["model_name"] = env_model - if env_provider := os.environ.get("LDR_SEARCH_PROVIDER"): + if env_provider := get_setting_from_snapshot("llm.provider"): search_config["provider"] = env_provider - if env_url := os.environ.get("LDR_ENDPOINT_URL"): + if env_url := get_setting_from_snapshot("llm.openai_endpoint.url"): search_config["openai_endpoint_url"] = env_url # Set up evaluation configuration if needed @@ -159,12 +160,12 @@ def evaluate_browsecomp( if endpoint_url: search_config["openai_endpoint_url"] = endpoint_url - # Check environment variables for additional configuration - if env_model := os.environ.get("LDR_SEARCH_MODEL"): + # Check settings for additional configuration + if env_model := get_setting_from_snapshot("llm.model"): search_config["model_name"] = env_model - if env_provider := os.environ.get("LDR_SEARCH_PROVIDER"): + if env_provider := get_setting_from_snapshot("llm.provider"): search_config["provider"] = env_provider - if env_url := os.environ.get("LDR_ENDPOINT_URL"): + if env_url := get_setting_from_snapshot("llm.openai_endpoint.url"): search_config["openai_endpoint_url"] = env_url # Set up evaluation configuration if needed @@ -260,9 +261,8 @@ def compare_configurations( ] # Create output directory - import os - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) # Run benchmarks for each configuration results = [] @@ -285,7 +285,7 @@ def compare_configurations( benchmark_result = run_benchmark( dataset_type=dataset_type, num_examples=num_examples, - output_dir=os.path.join(output_dir, config_name.replace(" ", "_")), + output_dir=str(Path(output_dir) / config_name.replace(" ", "_")), search_config=search_config, run_evaluation=True, ) @@ -300,8 +300,8 @@ def compare_configurations( import time timestamp = time.strftime("%Y%m%d_%H%M%S") - report_file = os.path.join( - output_dir, f"comparison_{dataset_type}_{timestamp}.md" + report_file = str( + Path(output_dir) / f"comparison_{dataset_type}_{timestamp}.md" ) with open(report_file, "w") as f: @@ -347,11 +347,11 @@ def compare_configurations( # Export the API functions __all__ = [ - "evaluate_simpleqa", + "calculate_metrics", + "compare_configurations", "evaluate_browsecomp", + "evaluate_simpleqa", + "generate_report", "get_available_benchmarks", - "compare_configurations", "run_benchmark", # For advanced users - "calculate_metrics", - "generate_report", ]
src/local_deep_research/benchmarks/cli/benchmark_commands.py+13 −12 modified@@ -5,16 +5,18 @@ """ import argparse -import logging +# import logging - replaced with loguru +import sys +from loguru import logger + +from ...config.paths import get_data_directory from .. import ( get_available_datasets, run_browsecomp_benchmark, run_simpleqa_benchmark, ) -logger = logging.getLogger(__name__) - def setup_benchmark_parser(subparsers): """ @@ -52,8 +54,8 @@ def setup_benchmark_parser(subparsers): benchmark_parent.add_argument( "--output-dir", type=str, - default="data/benchmark_results", - help="Directory to save results (default: data/benchmark_results)", + default=str(get_data_directory() / "benchmark_results"), + help="Directory to save results (default: user data directory/benchmark_results)", ) benchmark_parent.add_argument( "--human-eval", @@ -135,7 +137,7 @@ def setup_benchmark_parser(subparsers): compare_parser.add_argument( "--output-dir", type=str, - default="data/benchmark_results/comparison", + default=str(get_data_directory() / "benchmark_results" / "comparison"), help="Directory to save comparison results", ) compare_parser.set_defaults(func=compare_configs_cli) @@ -337,12 +339,11 @@ def main(): # Parse arguments args = parser.parse_args() - # Set up logging - log_level = logging.DEBUG if args.verbose else logging.INFO - logging.basicConfig( - level=log_level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) + # Set up logging with loguru + if args.verbose: + logger.add(sys.stderr, level="DEBUG") + else: + logger.add(sys.stderr, level="INFO") # Run command if hasattr(args, "func"):
src/local_deep_research/benchmarks/cli/__init__.py+1 −1 modified@@ -5,8 +5,8 @@ running benchmarks and optimization tasks. """ -from .benchmark_commands import main as benchmark_main from .benchmark_commands import ( + main as benchmark_main, setup_benchmark_parser, )
src/local_deep_research/benchmarks/cli.py+13 −14 modified@@ -6,21 +6,20 @@ """ import argparse -import logging + +# import logging - replaced with loguru +from loguru import logger import os +from pathlib import Path import sys -from datetime import datetime +from datetime import datetime, UTC +from ..config.paths import get_data_directory from .comparison import compare_configurations from .efficiency import ResourceMonitor, SpeedProfiler from .optimization import optimize_parameters -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Configure logging - using loguru instead def parse_args(): @@ -50,7 +49,7 @@ def parse_args(): optimize_parser.add_argument("query", help="Research query to optimize for") optimize_parser.add_argument( "--output-dir", - default="data/optimization_results", + default=str(get_data_directory() / "optimization_results"), help="Directory to save results", ) optimize_parser.add_argument("--model", help="Model name for the LLM") @@ -192,7 +191,7 @@ def run_comparison(args): logger.error("No configurations found in the file") return 1 except Exception as e: - logger.error(f"Error loading configurations file: {str(e)}") + logger.exception(f"Error loading configurations file: {e!s}") return 1 # Run comparison @@ -311,9 +310,9 @@ def run_profiling(args): print(f"Average CPU: {resource_results.get('process_cpu_avg', 0):.1f}%") # Save results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - args.output_dir, f"profiling_results_{timestamp}.json" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + results_file = str( + Path(args.output_dir) / f"profiling_results_{timestamp}.json" ) with open(results_file, "w") as f: @@ -349,7 +348,7 @@ def run_profiling(args): speed_profiler.stop() resource_monitor.stop() - logger.error(f"Error during profiling: {str(e)}") + logger.exception(f"Error during profiling: {e!s}") return 1
src/local_deep_research/benchmarks/comparison/evaluator.py+26 −28 modified@@ -6,14 +6,15 @@ """ import json -import logging import os -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, List, Optional import matplotlib.pyplot as plt -from matplotlib.patches import Circle, RegularPolygon import numpy as np +from loguru import logger +from matplotlib.patches import Circle, RegularPolygon from local_deep_research.benchmarks.efficiency.resource_monitor import ( ResourceMonitor, @@ -31,8 +32,6 @@ from local_deep_research.config.search_config import get_search from local_deep_research.search_system import AdvancedSearchSystem -logger = logging.getLogger(__name__) - def compare_configurations( query: str, @@ -110,8 +109,8 @@ def compare_configurations( logger.info(f"Completed repetition {rep + 1} for {config_name}") except Exception as e: - logger.error( - f"Error in {config_name}, repetition {rep + 1}: {str(e)}" + logger.exception( + f"Error in {config_name}, repetition {rep + 1}: {e!s}" ) # Add error info but continue with other configurations config_results.append({"error": str(e), "success": False}) @@ -181,22 +180,21 @@ def compare_configurations( ), "repetitions": repetitions, "metric_weights": metric_weights, - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "results": sorted_results, } # Save results to file - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - result_file = os.path.join( - output_dir, f"comparison_results_{timestamp}.json" - ) + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + result_file = str(Path(output_dir) / f"comparison_results_{timestamp}.json") with open(result_file, "w") as f: json.dump(comparison_report, f, indent=2) # Generate visualizations - visualizations_dir = os.path.join(output_dir, "visualizations") - os.makedirs(visualizations_dir, exist_ok=True) + visualizations_dir = Path(output_dir) / "visualizations" + visualizations_dir.mkdir(parents=True, exist_ok=True) + visualizations_dir = str(visualizations_dir) _create_comparison_visualizations( comparison_report, output_dir=visualizations_dir, timestamp=timestamp @@ -330,7 +328,7 @@ def _evaluate_single_configuration( resource_monitor.stop() # Log the error - logger.error(f"Error evaluating configuration: {str(e)}") + logger.exception("Error evaluating configuration") # Return error information return { @@ -443,7 +441,7 @@ def _create_comparison_visualizations( plt.grid(axis="x", linestyle="--", alpha=0.7) plt.tight_layout() plt.savefig( - os.path.join(output_dir, f"overall_score_comparison_{timestamp}.png") + str(Path(output_dir) / f"overall_score_comparison_{timestamp}.png") ) plt.close() @@ -455,7 +453,7 @@ def _create_comparison_visualizations( quality_metrics, "quality_metrics", "Quality Metrics Comparison", - os.path.join(output_dir, f"quality_metrics_comparison_{timestamp}.png"), + str(Path(output_dir) / f"quality_metrics_comparison_{timestamp}.png"), ) # 3. Speed metrics comparison @@ -466,7 +464,7 @@ def _create_comparison_visualizations( speed_metrics, "speed_metrics", "Speed Metrics Comparison", - os.path.join(output_dir, f"speed_metrics_comparison_{timestamp}.png"), + str(Path(output_dir) / f"speed_metrics_comparison_{timestamp}.png"), ) # 4. Resource metrics comparison @@ -481,22 +479,20 @@ def _create_comparison_visualizations( resource_metrics, "resource_metrics", "Resource Usage Comparison", - os.path.join( - output_dir, f"resource_metrics_comparison_{timestamp}.png" - ), + str(Path(output_dir) / f"resource_metrics_comparison_{timestamp}.png"), ) # 5. Spider chart for multi-dimensional comparison _create_spider_chart( successful_results, config_names, - os.path.join(output_dir, f"spider_chart_comparison_{timestamp}.png"), + str(Path(output_dir) / f"spider_chart_comparison_{timestamp}.png"), ) # 6. Pareto frontier chart for quality vs. speed _create_pareto_chart( successful_results, - os.path.join(output_dir, f"pareto_chart_comparison_{timestamp}.png"), + str(Path(output_dir) / f"pareto_chart_comparison_{timestamp}.png"), ) @@ -726,13 +722,13 @@ def unit_poly_verts(num_vars): plt.close() except Exception as e: - logger.error(f"Error creating spider chart: {str(e)}") + logger.exception("Error creating spider chart") # Create a text-based chart as fallback plt.figure(figsize=(10, 6)) plt.text( 0.5, 0.5, - f"Spider chart could not be created: {str(e)}", + f"Spider chart could not be created: {e!s}", horizontalalignment="center", verticalalignment="center", ) @@ -781,9 +777,9 @@ def _create_pareto_chart(results: List[Dict[str, Any]], output_path: str): # Identify Pareto frontier pareto_points = [] - for i, (q, s) in enumerate(zip(quality_scores, speed_scores)): + for i, (q, s) in enumerate(zip(quality_scores, speed_scores, strict=False)): is_pareto = True - for q2, s2 in zip(quality_scores, speed_scores): + for q2, s2 in zip(quality_scores, speed_scores, strict=False): if q2 > q and s2 > s: # Dominated is_pareto = False break @@ -795,7 +791,9 @@ def _create_pareto_chart(results: List[Dict[str, Any]], output_path: str): pareto_speed = [speed_scores[i] for i in pareto_points] # Sort pareto points for line drawing - pareto_sorted = sorted(zip(pareto_quality, pareto_speed, pareto_points)) + pareto_sorted = sorted( + zip(pareto_quality, pareto_speed, pareto_points, strict=False) + ) pareto_quality = [p[0] for p in pareto_sorted] pareto_speed = [p[1] for p in pareto_sorted] pareto_indices = [p[2] for p in pareto_sorted]
src/local_deep_research/benchmarks/datasets/base.py+5 −7 modified@@ -5,15 +5,13 @@ with benchmark datasets in a maintainable, extensible way. """ -import logging +from loguru import logger import random from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional import pandas as pd -logger = logging.getLogger(__name__) - class BenchmarkDataset(ABC): """Base class for all benchmark datasets. @@ -119,8 +117,8 @@ def load(self) -> List[Dict[str, Any]]: try: processed = self.process_example(example) processed_examples.append(processed) - except Exception as e: - logger.error(f"Error processing example {i}: {e}") + except Exception: + logger.exception(f"Error processing example {i}") # Sample if needed if self.num_examples and self.num_examples < len( @@ -141,8 +139,8 @@ def load(self) -> List[Dict[str, Any]]: self._is_loaded = True return self.examples - except Exception as e: - logger.error(f"Error loading dataset: {e}") + except Exception: + logger.exception("Error loading dataset") raise def get_examples(self) -> List[Dict[str, Any]]:
src/local_deep_research/benchmarks/datasets/browsecomp.py+4 −5 modified@@ -5,14 +5,13 @@ which contains encrypted data that needs special handling. """ -import logging from typing import Any, Dict +from loguru import logger + from .base import BenchmarkDataset from .utils import decrypt, get_known_answer_map -logger = logging.getLogger(__name__) - class BrowseCompDataset(BenchmarkDataset): """BrowseComp benchmark dataset. @@ -122,8 +121,8 @@ def process_example(self, example: Dict[str, Any]) -> Dict[str, Any]: processed["correct_answer"] = decrypted_answer logger.debug(f"Final answer: {decrypted_answer[:50]}...") - except Exception as e: - logger.error(f"Error decrypting example: {e}") + except Exception: + logger.exception("Error decrypting example") return processed
src/local_deep_research/benchmarks/datasets/custom_dataset_template.py+1 −3 modified@@ -5,13 +5,11 @@ Copy this file and modify it to create your own dataset class. """ -import logging +from loguru import logger from typing import Any, Dict from .base import BenchmarkDataset -logger = logging.getLogger(__name__) - class CustomDataset(BenchmarkDataset): """Template for a custom benchmark dataset.
src/local_deep_research/benchmarks/datasets.py+1 −3 modified@@ -14,11 +14,9 @@ 3. Use a manual mapping for specific encrypted strings that have been verified """ -from .datasets import load_dataset - # Re-export the get_available_datasets function # Re-export the default dataset URLs -from .datasets import DEFAULT_DATASET_URLS, get_available_datasets +from .datasets import DEFAULT_DATASET_URLS, get_available_datasets, load_dataset # Re-export the load_dataset function __all__ = ["DEFAULT_DATASET_URLS", "get_available_datasets", "load_dataset"]
src/local_deep_research/benchmarks/datasets/simpleqa.py+2 −3 modified@@ -4,12 +4,11 @@ This module provides a class for the SimpleQA benchmark dataset. """ -import logging from typing import Any, Dict -from .base import BenchmarkDataset +from loguru import logger -logger = logging.getLogger(__name__) +from .base import BenchmarkDataset class SimpleQADataset(BenchmarkDataset):
src/local_deep_research/benchmarks/datasets/utils.py+12 −8 modified@@ -7,10 +7,9 @@ import base64 import hashlib -import logging from typing import Dict -logger = logging.getLogger(__name__) +from loguru import logger def derive_key(password: str, length: int) -> bytes: @@ -32,7 +31,8 @@ def decrypt(ciphertext_b64: str, password: str) -> str: # Skip if the string doesn't look like base64 if not all( - c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" + c + in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" # pragma: allowlist secret for c in ciphertext_b64 ): return ciphertext_b64 @@ -41,7 +41,7 @@ def decrypt(ciphertext_b64: str, password: str) -> str: try: encrypted = base64.b64decode(ciphertext_b64) key = derive_key(password, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes(a ^ b for a, b in zip(encrypted, key, strict=False)) # Check if the result looks like valid text result = decrypted.decode("utf-8", errors="replace") @@ -53,15 +53,17 @@ def decrypt(ciphertext_b64: str, password: str) -> str: ) return result except Exception as e: - logger.debug(f"Standard decryption failed: {str(e)}") + logger.debug(f"Standard decryption failed: {e!s}") # Alternative method - try using just the first part of the password try: if len(password) > 30: alt_password = password.split()[0] # Use first word encrypted = base64.b64decode(ciphertext_b64) key = derive_key(alt_password, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes( + a ^ b for a, b in zip(encrypted, key, strict=False) + ) result = decrypted.decode("utf-8", errors="replace") if ( @@ -81,7 +83,9 @@ def decrypt(ciphertext_b64: str, password: str) -> str: guid_part = password.split("GUID")[1].strip() encrypted = base64.b64decode(ciphertext_b64) key = derive_key(guid_part, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes( + a ^ b for a, b in zip(encrypted, key, strict=False) + ) result = decrypted.decode("utf-8", errors="replace") if ( @@ -100,7 +104,7 @@ def decrypt(ciphertext_b64: str, password: str) -> str: hardcoded_key = "MHGGF2022!" # Known key for BrowseComp dataset encrypted = base64.b64decode(ciphertext_b64) key = derive_key(hardcoded_key, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes(a ^ b for a, b in zip(encrypted, key, strict=False)) result = decrypted.decode("utf-8", errors="replace") if all(32 <= ord(c) < 127 for c in result[:50]) and " " in result[:50]:
src/local_deep_research/benchmarks/efficiency/__init__.py+1 −1 modified@@ -13,6 +13,6 @@ ) __all__ = [ - "SpeedProfiler", "ResourceMonitor", + "SpeedProfiler", ]
src/local_deep_research/benchmarks/efficiency/resource_monitor.py+3 −4 modified@@ -5,13 +5,12 @@ system resource usage during the research process. """ -import logging import threading import time from contextlib import contextmanager from typing import Any, Dict -logger = logging.getLogger(__name__) +from loguru import logger # Try to import psutil, but don't fail if not available try: @@ -161,7 +160,7 @@ def _monitor_resources(self): ) except Exception as e: - logger.error(f"Error monitoring resources: {str(e)}") + logger.exception(f"Error monitoring resources: {e!s}") # Sleep until next sampling interval time.sleep(self.sampling_interval) @@ -415,5 +414,5 @@ def check_system_resources() -> Dict[str, Any]: return result except Exception as e: - logger.error(f"Error checking system resources: {str(e)}") + logger.exception(f"Error checking system resources: {e!s}") return {"error": str(e), "available": False}
src/local_deep_research/benchmarks/efficiency/speed_profiler.py+1 −3 modified@@ -5,13 +5,11 @@ of different components and processes in the research system. """ -import logging +from loguru import logger import time from contextlib import contextmanager from typing import Any, Callable, Dict -logger = logging.getLogger(__name__) - class SpeedProfiler: """
src/local_deep_research/benchmarks/evaluators/base.py+4 −7 modified@@ -5,13 +5,10 @@ must implement, establishing a common interface for different benchmark types. """ -import logging -import os from abc import ABC, abstractmethod +from pathlib import Path from typing import Any, Dict -logger = logging.getLogger(__name__) - class BaseBenchmarkEvaluator(ABC): """ @@ -69,6 +66,6 @@ def _create_subdirectory(self, output_dir: str) -> str: Returns: Path to the benchmark-specific directory """ - benchmark_dir = os.path.join(output_dir, self.name) - os.makedirs(benchmark_dir, exist_ok=True) - return benchmark_dir + benchmark_dir = Path(output_dir) / self.name + benchmark_dir.mkdir(parents=True, exist_ok=True) + return str(benchmark_dir)
src/local_deep_research/benchmarks/evaluators/browsecomp.py+3 −4 modified@@ -5,14 +5,13 @@ benchmark, which tests browsing comprehension capabilities. """ -import logging from typing import Any, Dict +from loguru import logger + from ..runners import run_browsecomp_benchmark from .base import BaseBenchmarkEvaluator -logger = logging.getLogger(__name__) - class BrowseCompEvaluator(BaseBenchmarkEvaluator): """ @@ -74,7 +73,7 @@ def evaluate( } except Exception as e: - logger.error(f"Error in BrowseComp evaluation: {str(e)}") + logger.exception(f"Error in BrowseComp evaluation: {e!s}") # Return error information return {
src/local_deep_research/benchmarks/evaluators/composite.py+4 −5 modified@@ -5,15 +5,14 @@ with weighted scores to provide a comprehensive evaluation. """ -import logging from typing import Any, Dict, Optional +from loguru import logger + # Import specific evaluator implementations from .browsecomp import BrowseCompEvaluator from .simpleqa import SimpleQAEvaluator -logger = logging.getLogger(__name__) - class CompositeBenchmarkEvaluator: """ @@ -107,8 +106,8 @@ def evaluate( combined_score += weighted_contribution except Exception as e: - logger.error( - f"Error running {benchmark_name} benchmark: {str(e)}" + logger.exception( + f"Error running {benchmark_name} benchmark: {e!s}" ) all_results[benchmark_name] = { "benchmark_type": benchmark_name,
src/local_deep_research/benchmarks/evaluators/__init__.py+1 −1 modified@@ -12,7 +12,7 @@ __all__ = [ "BaseBenchmarkEvaluator", - "SimpleQAEvaluator", "BrowseCompEvaluator", "CompositeBenchmarkEvaluator", + "SimpleQAEvaluator", ]
src/local_deep_research/benchmarks/evaluators/simpleqa.py+11 −14 modified@@ -6,19 +6,16 @@ """ import json -import logging -import os +from loguru import logger +from pathlib import Path import time from typing import Any, Dict - from ..datasets.base import DatasetRegistry from ..metrics import calculate_metrics, generate_report from ..runners import run_simpleqa_benchmark # Keep for backward compatibility from .base import BaseBenchmarkEvaluator -logger = logging.getLogger(__name__) - class SimpleQAEvaluator(BaseBenchmarkEvaluator): """ @@ -89,7 +86,7 @@ def evaluate( } except Exception as e: - logger.error(f"Error in SimpleQA evaluation: {str(e)}") + logger.exception(f"Error in SimpleQA evaluation: {e!s}") # Return error information return { @@ -134,14 +131,14 @@ def _run_with_dataset_class( # Set up output files timestamp = time.strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - output_dir, f"simpleqa_{timestamp}_results.jsonl" + results_file = str( + Path(output_dir) / f"simpleqa_{timestamp}_results.jsonl" ) - evaluation_file = os.path.join( - output_dir, f"simpleqa_{timestamp}_evaluation.jsonl" + evaluation_file = str( + Path(output_dir) / f"simpleqa_{timestamp}_evaluation.jsonl" ) - report_file = os.path.join( - output_dir, f"simpleqa_{timestamp}_report.md" + report_file = str( + Path(output_dir) / f"simpleqa_{timestamp}_report.md" ) # Process each example @@ -222,7 +219,7 @@ def _run_with_dataset_class( f.write(json.dumps(result) + "\n") except Exception as e: - logger.error(f"Error processing example {i + 1}: {str(e)}") + logger.exception(f"Error processing example {i + 1}: {e!s}") # Create error result error_result = { @@ -286,7 +283,7 @@ def _run_with_dataset_class( } except Exception as e: - logger.error(f"Error in direct dataset evaluation: {str(e)}") + logger.exception(f"Error in direct dataset evaluation: {e!s}") return { "status": "error", "dataset_type": "simpleqa",
src/local_deep_research/benchmarks/graders.py+38 −15 modified@@ -5,8 +5,8 @@ """ import json -import logging -import os +from loguru import logger +from pathlib import Path import re from typing import Any, Callable, Dict, List, Optional @@ -15,7 +15,6 @@ from ..config.llm_config import get_llm from .templates import BROWSECOMP_GRADER_TEMPLATE, SIMPLEQA_GRADER_TEMPLATE -logger = logging.getLogger(__name__) # Default evaluation configuration using Claude 3.7 Sonnet via OpenRouter DEFAULT_EVALUATION_CONFIG = { @@ -27,13 +26,17 @@ } -def get_evaluation_llm(custom_config: Optional[Dict[str, Any]] = None): +def get_evaluation_llm( + custom_config: Optional[Dict[str, Any]] = None, + settings_snapshot: Optional[Dict[str, Any]] = None, +): """ Get an LLM for evaluation purposes using Claude 3.7 Sonnet via OpenRouter by default, which can be overridden with custom settings. Args: custom_config: Optional custom configuration that overrides defaults + settings_snapshot: Optional settings snapshot for thread-safe access Returns: An LLM instance for evaluation @@ -65,10 +68,26 @@ def get_evaluation_llm(custom_config: Optional[Dict[str, Any]] = None): # Check if we're using openai_endpoint but don't have an API key configured if filtered_config.get("provider") == "openai_endpoint": - # Try to get API key from database settings first, then environment - from ..utilities.db_utils import get_db_setting + # Try to get API key from settings snapshot or environment + api_key = None - api_key = get_db_setting("llm.openai_endpoint.api_key") + if settings_snapshot: + # Get from settings snapshot for thread safety + api_key_setting = settings_snapshot.get( + "llm.openai_endpoint.api_key" + ) + if api_key_setting: + api_key = ( + api_key_setting.get("value") + if isinstance(api_key_setting, dict) + else api_key_setting + ) + else: + # No settings snapshot available + logger.warning( + "No settings snapshot provided for benchmark grader. " + "API key must be provided via settings_snapshot for thread safety." + ) if not api_key: logger.warning( @@ -122,6 +141,7 @@ def grade_single_result( result_data: Dict[str, Any], dataset_type: str = "simpleqa", evaluation_config: Optional[Dict[str, Any]] = None, + settings_snapshot: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Grade a single benchmark result using LLM. @@ -130,12 +150,13 @@ def grade_single_result( result_data: Dictionary containing result data with keys: id, problem, correct_answer, response, extracted_answer dataset_type: Type of dataset evaluation_config: Optional custom config for evaluation LLM + settings_snapshot: Optional settings snapshot for thread-safe access Returns: Dictionary with grading results """ # Get evaluation LLM - evaluation_llm = get_evaluation_llm(evaluation_config) + evaluation_llm = get_evaluation_llm(evaluation_config, settings_snapshot) # Select appropriate template template = ( @@ -253,12 +274,12 @@ def grade_single_result( return graded_result except Exception as e: - logger.error(f"Error grading single result: {str(e)}") + logger.exception(f"Error grading single result: {e!s}") return { "grading_error": str(e), "is_correct": False, "graded_confidence": "0", - "grader_response": f"Grading failed: {str(e)}", + "grader_response": f"Grading failed: {e!s}", } @@ -300,8 +321,9 @@ def grade_results( results.append(json.loads(line)) # Remove output file if it exists - if os.path.exists(output_file): - os.remove(output_file) + output_path = Path(output_file) + if output_path.exists(): + output_path.unlink() graded_results = [] correct_count = 0 @@ -447,7 +469,7 @@ def grade_results( ) except Exception as e: - logger.error(f"Error grading result {idx + 1}: {str(e)}") + logger.exception(f"Error grading result {idx + 1}: {e!s}") # Handle error error_result = result.copy() @@ -499,8 +521,9 @@ def human_evaluation( results.append(json.loads(line)) # Remove output file if it exists - if os.path.exists(output_file): - os.remove(output_file) + output_path = Path(output_file) + if output_path.exists(): + output_path.unlink() human_graded_results = [] correct_count = 0
src/local_deep_research/benchmarks/metrics/calculation.py+10 −14 modified@@ -6,15 +6,13 @@ """ import json -import logging -import os +from loguru import logger +from pathlib import Path import tempfile import time -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, Optional -logger = logging.getLogger(__name__) - def calculate_metrics(results_file: str) -> Dict[str, Any]: """ @@ -34,7 +32,7 @@ def calculate_metrics(results_file: str) -> Dict[str, Any]: if line.strip(): results.append(json.loads(line)) except Exception as e: - logger.error(f"Error loading results file: {e}") + logger.exception("Error loading results file") return {"error": str(e)} if not results: @@ -57,7 +55,7 @@ def calculate_metrics(results_file: str) -> Dict[str, Any]: # Average confidence if available confidence_values = [] for r in results: - if "confidence" in r and r["confidence"]: + if r.get("confidence"): try: confidence_values.append(int(r["confidence"])) except (ValueError, TypeError): @@ -83,7 +81,7 @@ def calculate_metrics(results_file: str) -> Dict[str, Any]: "average_confidence": avg_confidence, "error_count": error_count, "error_rate": error_rate, - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(UTC).isoformat(), } # If we have category information, calculate per-category metrics @@ -171,20 +169,18 @@ def evaluate_benchmark_quality( } except Exception as e: - logger.error(f"Error in benchmark evaluation: {str(e)}") + logger.exception(f"Error in benchmark evaluation: {e!s}") return {"accuracy": 0.0, "quality_score": 0.0, "error": str(e)} finally: # Clean up temporary directory if we created it - if temp_dir and os.path.exists(temp_dir): + if temp_dir and Path(temp_dir).exists(): import shutil try: shutil.rmtree(temp_dir) except Exception as e: - logger.warning( - f"Failed to clean up temporary directory: {str(e)}" - ) + logger.warning(f"Failed to clean up temporary directory: {e!s}") def measure_execution_time( @@ -252,7 +248,7 @@ def measure_execution_time( } except Exception as e: - logger.error(f"Error in speed measurement: {str(e)}") + logger.exception(f"Error in speed measurement: {e!s}") return {"average_time": 0.0, "speed_score": 0.0, "error": str(e)}
src/local_deep_research/benchmarks/metrics/__init__.py+2 −2 modified@@ -15,10 +15,10 @@ from .reporting import generate_report __all__ = [ + "calculate_combined_score", "calculate_metrics", "calculate_quality_metrics", - "calculate_speed_metrics", "calculate_resource_metrics", - "calculate_combined_score", + "calculate_speed_metrics", "generate_report", ]
src/local_deep_research/benchmarks/metrics/reporting.py+8 −6 modified@@ -5,11 +5,13 @@ """ import json -import logging -from datetime import datetime + +# import logging - replaced with loguru +from loguru import logger +from datetime import datetime, UTC from typing import Any, Dict, Optional -logger = logging.getLogger(__name__) +# logger = logging.getLogger(__name__) - using loguru logger directly def generate_report( @@ -39,8 +41,8 @@ def generate_report( for line in f: if line.strip(): results.append(json.loads(line)) - except Exception as e: - logger.error(f"Error loading results for report: {e}") + except Exception: + logger.exception("Error loading results for report") results = [] # Sample up to 5 correct and 5 incorrect examples @@ -140,7 +142,7 @@ def generate_report( ) # Add timestamp - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") report.extend( [ "## Metadata",
src/local_deep_research/benchmarks/metrics/visualization.py+2 −3 modified@@ -5,12 +5,11 @@ of benchmark and optimization results. """ -import logging +from loguru import logger from typing import Dict, List, Optional import numpy as np -logger = logging.getLogger(__name__) # Check if matplotlib is available try: @@ -207,7 +206,7 @@ def plot_quality_vs_speed( # Sort pareto points by speed score pareto_points.sort() if pareto_points: - pareto_x, pareto_y = zip(*pareto_points) + pareto_x, pareto_y = zip(*pareto_points, strict=False) ax.plot(pareto_x, pareto_y, "k--", label="Pareto Frontier") ax.scatter(pareto_x, pareto_y, c="red", s=50, alpha=0.8) except Exception as e:
src/local_deep_research/benchmarks/models/__init__.py+3 −3 modified@@ -1,6 +1,6 @@ """Benchmark database models for ORM.""" -from .benchmark_models import ( +from ...database.models import ( BenchmarkConfig, BenchmarkProgress, BenchmarkResult, @@ -10,10 +10,10 @@ ) __all__ = [ - "BenchmarkRun", - "BenchmarkResult", "BenchmarkConfig", "BenchmarkProgress", + "BenchmarkResult", + "BenchmarkRun", "BenchmarkStatus", "DatasetType", ]
src/local_deep_research/benchmarks/optimization/api.py+5 −8 modified@@ -5,20 +5,17 @@ without having to directly work with the optimizer classes. """ -import logging -import os +from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple # No metrics imports needed here, they're used in the OptunaOptimizer from .optuna_optimizer import OptunaOptimizer -logger = logging.getLogger(__name__) - def optimize_parameters( query: str, param_space: Optional[Dict[str, Any]] = None, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -82,7 +79,7 @@ def optimize_parameters( def optimize_for_speed( query: str, n_trials: int = 20, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -148,7 +145,7 @@ def optimize_for_speed( def optimize_for_quality( query: str, n_trials: int = 30, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -193,7 +190,7 @@ def optimize_for_quality( def optimize_for_efficiency( query: str, n_trials: int = 25, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None,
src/local_deep_research/benchmarks/optimization/__init__.py+6 −6 modified@@ -23,12 +23,12 @@ __all__ = [ "OptunaOptimizer", - "optimize_parameters", - "optimize_for_speed", - "optimize_for_quality", - "optimize_for_efficiency", + "calculate_combined_score", "calculate_quality_metrics", - "calculate_speed_metrics", "calculate_resource_metrics", - "calculate_combined_score", + "calculate_speed_metrics", + "optimize_for_efficiency", + "optimize_for_quality", + "optimize_for_speed", + "optimize_parameters", ]
src/local_deep_research/benchmarks/optimization/metrics.py+2 −2 modified@@ -13,8 +13,8 @@ ) __all__ = [ + "calculate_combined_score", "calculate_quality_metrics", - "calculate_speed_metrics", "calculate_resource_metrics", - "calculate_combined_score", + "calculate_speed_metrics", ]
src/local_deep_research/benchmarks/optimization/optuna_optimizer.py+73 −63 modified@@ -7,10 +7,10 @@ """ import json -import logging import os +from pathlib import Path import time -from datetime import datetime +from datetime import datetime, UTC from functools import partial from typing import Any, Callable, Dict, List, Optional, Tuple @@ -27,14 +27,14 @@ from local_deep_research.benchmarks.efficiency.speed_profiler import ( SpeedProfiler, ) +from loguru import logger + from local_deep_research.benchmarks.evaluators import ( CompositeBenchmarkEvaluator, ) # Import benchmark evaluator components -logger = logging.getLogger(__name__) - # Try to import visualization libraries, but don't fail if not available try: import matplotlib.pyplot as plt @@ -124,7 +124,7 @@ def __init__( } # Generate a unique study name if not provided - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") self.study_name = study_name or f"ldr_opt_{timestamp}" # Create output directory @@ -358,7 +358,7 @@ def _objective( "result": result, "score": result.get("score", 0), "duration": duration, - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(UTC).isoformat(), } self.trials_history.append(trial_info) @@ -384,7 +384,7 @@ def _objective( return result["score"] except Exception as e: - logger.error(f"Error in trial {trial.number}: {str(e)}") + logger.exception(f"Error in trial {trial.number}: {e!s}") # Update callback with error if self.progress_callback: @@ -440,7 +440,7 @@ def _run_experiment(self, params: Dict[str, Any]) -> Dict[str, Any]: # Evaluate quality using composite benchmark evaluator # Use a small number of examples for efficiency - benchmark_dir = os.path.join(self.output_dir, "benchmark_temp") + benchmark_dir = str(Path(self.output_dir) / "benchmark_temp") quality_results = self.benchmark_evaluator.evaluate( system_config=system_config, num_examples=5, # Small number for optimization efficiency @@ -482,7 +482,7 @@ def _run_experiment(self, params: Dict[str, Any]) -> Dict[str, Any]: speed_profiler.stop() # Log error - logger.error(f"Error in experiment: {str(e)}") + logger.exception(f"Error in experiment: {e!s}") # Return error information return {"error": str(e), "score": 0.0, "success": False} @@ -503,11 +503,11 @@ def _optimization_callback(self, study: optuna.Study, trial: optuna.Trial): def _save_results(self): """Save the optimization results to disk.""" # Create a timestamp for filenames - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") # Save trial history - history_file = os.path.join( - self.output_dir, f"{self.study_name}_history.json" + history_file = str( + Path(self.output_dir) / f"{self.study_name}_history.json" ) with open(history_file, "w") as f: # Convert numpy values to native Python types for JSON serialization @@ -534,8 +534,8 @@ def _save_results(self): and hasattr(self.study, "best_params") and self.study.best_params ): - best_params_file = os.path.join( - self.output_dir, f"{self.study_name}_best_params.json" + best_params_file = str( + Path(self.output_dir) / f"{self.study_name}_best_params.json" ) with open(best_params_file, "w") as f: json.dump( @@ -557,8 +557,8 @@ def _save_results(self): # Save the Optuna study if self.study: - study_file = os.path.join( - self.output_dir, f"{self.study_name}_study.pkl" + study_file = str( + Path(self.output_dir) / f"{self.study_name}_study.pkl" ) joblib.dump(self.study, study_file) @@ -577,8 +577,9 @@ def _create_visualizations(self): return # Create directory for visualizations - viz_dir = os.path.join(self.output_dir, "visualizations") - os.makedirs(viz_dir, exist_ok=True) + viz_dir = Path(self.output_dir) / "visualizations" + viz_dir.mkdir(parents=True, exist_ok=True) + viz_dir = str(viz_dir) # Create Optuna visualizations self._create_optuna_visualizations(viz_dir) @@ -598,20 +599,21 @@ def _create_quick_visualizations(self): return # Create directory for visualizations - viz_dir = os.path.join(self.output_dir, "visualizations") - os.makedirs(viz_dir, exist_ok=True) + viz_dir = Path(self.output_dir) / "visualizations" + viz_dir.mkdir(parents=True, exist_ok=True) + viz_dir = str(viz_dir) # Create optimization history only (faster than full visualization) try: fig = plot_optimization_history(self.study) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_optimization_history_current.png", + str( + Path(viz_dir) + / f"{self.study_name}_optimization_history_current.png" ) ) except Exception as e: - logger.error(f"Error creating optimization history plot: {str(e)}") + logger.exception(f"Error creating optimization history plot: {e!s}") def _create_optuna_visualizations(self, viz_dir: str): """ @@ -620,44 +622,46 @@ def _create_optuna_visualizations(self, viz_dir: str): Args: viz_dir: Directory to save visualizations """ - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") # 1. Optimization history try: fig = plot_optimization_history(self.study) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_optimization_history_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_optimization_history_{timestamp}.png" ) ) except Exception as e: - logger.error(f"Error creating optimization history plot: {str(e)}") + logger.exception(f"Error creating optimization history plot: {e!s}") # 2. Parameter importances try: fig = plot_param_importances(self.study) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_param_importances_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_param_importances_{timestamp}.png" ) ) except Exception as e: - logger.error(f"Error creating parameter importances plot: {str(e)}") + logger.exception( + f"Error creating parameter importances plot: {e!s}" + ) # 3. Slice plot for each parameter try: for param_name in self.study.best_params.keys(): fig = plot_slice(self.study, [param_name]) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_slice_{param_name}_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_slice_{param_name}_{timestamp}.png" ) ) except Exception as e: - logger.error(f"Error creating slice plots: {str(e)}") + logger.exception(f"Error creating slice plots: {e!s}") # 4. Contour plots for important parameter pairs try: @@ -672,17 +676,17 @@ def _create_optuna_visualizations(self, viz_dir: str): self.study, params=[param_names[i], param_names[j]] ) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_contour_{param_names[i]}_{param_names[j]}_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_contour_{param_names[i]}_{param_names[j]}_{timestamp}.png" ) ) except Exception as e: logger.warning( - f"Error creating contour plot for {param_names[i]} vs {param_names[j]}: {str(e)}" + f"Error creating contour plot for {param_names[i]} vs {param_names[j]}: {e!s}" ) except Exception as e: - logger.error(f"Error creating contour plots: {str(e)}") + logger.exception(f"Error creating contour plots: {e!s}") def _create_custom_visualizations(self, viz_dir: str): """ @@ -694,7 +698,7 @@ def _create_custom_visualizations(self, viz_dir: str): if not self.trials_history: return - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") # Create quality vs speed plot self._create_quality_vs_speed_plot(viz_dir, timestamp) @@ -746,7 +750,10 @@ def _create_quality_vs_speed_plot(self, viz_dir: str, timestamp: str): # Create scatter plot with size based on iterations*questions sizes = [ - i * q * 5 for i, q in zip(iterations_values, questions_values) + i * q * 5 + for i, q in zip( + iterations_values, questions_values, strict=False + ) ] scatter = plt.scatter( quality_scores, @@ -781,7 +788,7 @@ def _create_quality_vs_speed_plot(self, viz_dir: str, timestamp: str): # Add annotations for key points for i, (q, s, label) in enumerate( - zip(quality_scores, speed_scores, labels) + zip(quality_scores, speed_scores, labels, strict=False) ): if i % max(1, len(quality_scores) // 5) == 0: # Label ~5 points plt.annotate( @@ -824,14 +831,14 @@ def _create_quality_vs_speed_plot(self, viz_dir: str, timestamp: str): # Save the figure plt.tight_layout() plt.savefig( - os.path.join( - viz_dir, - f"{self.study_name}_quality_vs_speed_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_quality_vs_speed_{timestamp}.png" ) ) plt.close() except Exception as e: - logger.error(f"Error creating quality vs speed plot: {str(e)}") + logger.exception(f"Error creating quality vs speed plot: {e!s}") def _create_parameter_evolution_plots(self, viz_dir: str, timestamp: str): """Create plots showing how parameter values evolve over trials.""" @@ -903,14 +910,14 @@ def _create_parameter_evolution_plots(self, viz_dir: str, timestamp: str): # Save the figure plt.tight_layout() plt.savefig( - os.path.join( - viz_dir, - f"{self.study_name}_param_evolution_{param_name}_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_param_evolution_{param_name}_{timestamp}.png" ) ) plt.close() except Exception as e: - logger.error(f"Error creating parameter evolution plots: {str(e)}") + logger.exception(f"Error creating parameter evolution plots: {e!s}") def _create_duration_vs_score_plot(self, viz_dir: str, timestamp: str): """Create a plot showing trial duration vs score.""" @@ -946,7 +953,8 @@ def _create_duration_vs_score_plot(self, viz_dir: str, timestamp: str): # Total questions per trial total_questions = [ - i * q for i, q in zip(trial_iterations, trial_questions) + i * q + for i, q in zip(trial_iterations, trial_questions, strict=False) ] # Create scatter plot with size based on total questions @@ -968,7 +976,9 @@ def _create_duration_vs_score_plot(self, viz_dir: str, timestamp: str): plt.grid(True, linestyle="--", alpha=0.7) # Add trial number annotations for selected points - for i, (d, s) in enumerate(zip(trial_durations, trial_scores)): + for i, (d, s) in enumerate( + zip(trial_durations, trial_scores, strict=False) + ): if ( i % max(1, len(trial_durations) // 5) == 0 ): # Annotate ~5 points @@ -982,20 +992,20 @@ def _create_duration_vs_score_plot(self, viz_dir: str, timestamp: str): # Save the figure plt.tight_layout() plt.savefig( - os.path.join( - viz_dir, - f"{self.study_name}_duration_vs_score_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_duration_vs_score_{timestamp}.png" ) ) plt.close() except Exception as e: - logger.error(f"Error creating duration vs score plot: {str(e)}") + logger.exception(f"Error creating duration vs score plot: {e!s}") def optimize_parameters( query: str, param_space: Optional[Dict[str, Any]] = None, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -1059,7 +1069,7 @@ def optimize_parameters( def optimize_for_speed( query: str, n_trials: int = 20, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -1125,7 +1135,7 @@ def optimize_for_speed( def optimize_for_quality( query: str, n_trials: int = 30, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -1170,7 +1180,7 @@ def optimize_for_quality( def optimize_for_efficiency( query: str, n_trials: int = 25, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None,
src/local_deep_research/benchmarks/runners.py+13 −13 modified@@ -5,8 +5,9 @@ """ import json -import logging +from loguru import logger import os +from pathlib import Path import time from typing import Any, Callable, Dict, Optional @@ -17,8 +18,6 @@ from .metrics import calculate_metrics, generate_report from .templates import BROWSECOMP_QUERY_TEMPLATE -logger = logging.getLogger(__name__) - def format_query(question: str, dataset_type: str = "simpleqa") -> str: """ @@ -109,20 +108,21 @@ def run_benchmark( # Set up output files timestamp = time.strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_results.jsonl" + results_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_results.jsonl" ) - evaluation_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_evaluation.jsonl" + evaluation_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_evaluation.jsonl" ) - report_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_report.md" + report_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_report.md" ) # Make sure output files don't exist for file in [results_file, evaluation_file, report_file]: - if os.path.exists(file): - os.remove(file) + file_path = Path(file) + if file_path.exists(): + file_path.unlink() # Progress tracking total_examples = len(dataset) @@ -245,7 +245,7 @@ def run_benchmark( ) except Exception as e: - logger.error(f"Error processing example {i + 1}: {str(e)}") + logger.exception(f"Error processing example {i + 1}: {e!s}") # Create error result error_result = { @@ -318,7 +318,7 @@ def run_benchmark( ), ) except Exception as e: - logger.error(f"Automated evaluation failed: {str(e)}") + logger.exception(f"Automated evaluation failed: {e!s}") if progress_callback: progress_callback(
src/local_deep_research/benchmarks/web_api/benchmark_routes.py+0 −0 modifiedsrc/local_deep_research/benchmarks/web_api/benchmark_service.py+783 −439 modifiedsrc/local_deep_research/benchmarks/web_api/__init__.py+1 −1 modified@@ -1,6 +1,6 @@ """Benchmark web API package.""" -from .benchmark_service import BenchmarkService from .benchmark_routes import benchmark_bp +from .benchmark_service import BenchmarkService __all__ = ["BenchmarkService", "benchmark_bp"]
src/local_deep_research/citation_handler.py+26 −9 modifiedsrc/local_deep_research/citation_handlers/base_citation_handler.py+12 −1 modifiedsrc/local_deep_research/citation_handlers/forced_answer_citation_handler.py+15 −3 modifiedsrc/local_deep_research/citation_handlers/__init__.py+1 −1 modifiedsrc/local_deep_research/citation_handlers/precision_extraction_handler.py+54 −24 modifiedsrc/local_deep_research/citation_handlers/standard_citation_handler.py+15 −2 modifiedsrc/local_deep_research/cli/__init__.py+0 −0 addedsrc/local_deep_research/config/default_settings/database_settings.py+18 −0 addedsrc/local_deep_research/config/llm_config.py+366 −98 modifiedsrc/local_deep_research/config/paths.py+152 −0 addedsrc/local_deep_research/config/queue_config.py+19 −0 addedsrc/local_deep_research/config/search_config.py+91 −16 modifiedsrc/local_deep_research/config/thread_settings.py+91 −0 addedsrc/local_deep_research/database/auth_db.py+56 −0 addedsrc/local_deep_research/database/credential_store_base.py+117 −0 addedsrc/local_deep_research/database/encrypted_db.py+593 −0 addedsrc/local_deep_research/database/encryption_check.py+112 −0 addedsrc/local_deep_research/database/initialize.py+127 −0 addedsrc/local_deep_research/database/models/active_research.py+53 −0 addedsrc/local_deep_research/database/models/auth.py+39 −0 addedsrc/local_deep_research/database/models/base.py+7 −0 addedsrc/local_deep_research/database/models/benchmark.py+15 −16 renamedsrc/local_deep_research/database/models/cache.py+100 −0 addedsrc/local_deep_research/database/models/__init__.py+117 −0 addedsrc/local_deep_research/database/models/logs.py+74 −0 addedsrc/local_deep_research/database/models/memory_queue.py+285 −0 addedsrc/local_deep_research/database/models/metrics.py+187 −0 addedsrc/local_deep_research/database/models/news.py+262 −0 addedsrc/local_deep_research/database/models/providers.py+33 −0 addedsrc/local_deep_research/database/models/queued_research.py+34 −0 addedsrc/local_deep_research/database/models/queue.py+42 −0 addedsrc/local_deep_research/database/models/rate_limiting.py+52 −0 addedsrc/local_deep_research/database/models/reports.py+124 −0 addedsrc/local_deep_research/database/models/research.py+315 −0 addedsrc/local_deep_research/database/models/settings.py+108 −0 addedsrc/local_deep_research/database/models/user_base.py+9 −0 addedsrc/local_deep_research/database/models/user_news_search_history.py+47 −0 addedsrc/local_deep_research/database/queue_service.py+176 −0 addedsrc/local_deep_research/database/session_context.py+212 −0 addedsrc/local_deep_research/database/session_passwords.py+95 −0 addedsrc/local_deep_research/database/sqlcipher_utils.py+245 −0 addedsrc/local_deep_research/database/temp_auth.py+87 −0 addedsrc/local_deep_research/database/thread_local_session.py+183 −0 addedsrc/local_deep_research/database/thread_metrics.py+166 −0 addedsrc/local_deep_research/defaults/default_settings.json+411 −2 modifiedsrc/local_deep_research/defaults/__init__.py+1 −2 modifiedsrc/local_deep_research/domain_classifier/classifier.py+488 −0 addedsrc/local_deep_research/domain_classifier/__init__.py+6 −0 addedsrc/local_deep_research/domain_classifier/models.py+46 −0 addedsrc/local_deep_research/error_handling/error_reporter.py+27 −2 modifiedsrc/local_deep_research/error_handling/__init__.py+1 −1 modifiedsrc/local_deep_research/error_handling/report_generator.py+5 −4 modifiedsrc/local_deep_research/followup_research/__init__.py+15 −0 addedsrc/local_deep_research/followup_research/models.py+52 −0 addedsrc/local_deep_research/followup_research/routes.py+316 −0 addedsrc/local_deep_research/followup_research/service.py+214 −0 addedsrc/local_deep_research/__init__.py+1 −32 modified@@ -2,47 +2,16 @@ Local Deep Research - A tool for conducting deep research using AI. """ -__author__ = "Your Name" +__author__ = "LearningCircuit" __description__ = "A tool for conducting deep research using AI" from loguru import logger from .__version__ import __version__ -from .config.llm_config import get_llm -from .config.search_config import get_search -from .report_generator import get_report_generator -from .web.app import main -from .web.database.migrations import ensure_database_initialized -from .setup_data_dir import setup_data_dir # Disable logging by default to not interfere with user setup. logger.disable("local_deep_research") -# Initialize database. -setup_data_dir() -ensure_database_initialized() - - -def get_advanced_search_system(strategy_name: str = "iterdrag"): - """ - Get an instance of the advanced search system. - - Args: - strategy_name: The name of the search strategy to use ("standard" or "iterdrag") - - Returns: - AdvancedSearchSystem: An instance of the advanced search system - """ - from .search_system import AdvancedSearchSystem - - return AdvancedSearchSystem(strategy_name=strategy_name) - - __all__ = [ - "get_llm", - "get_search", - "get_report_generator", - "get_advanced_search_system", - "main", "__version__", ]
src/local_deep_research/llm/__init__.py+6 −6 modifiedsrc/local_deep_research/llm/llm_registry.py+21 −15 modifiedsrc/local_deep_research/memory_cache/app_integration.py+36 −0 addedsrc/local_deep_research/memory_cache/cached_services.py+282 −0 addedsrc/local_deep_research/memory_cache/config.py+252 −0 addedsrc/local_deep_research/memory_cache/flask_integration.py+167 −0 addedsrc/local_deep_research/memory_cache/__init__.py+1 −0 addedsrc/local_deep_research/metrics/database.py+47 −34 modifiedsrc/local_deep_research/metrics/db_models.py+0 −111 removedsrc/local_deep_research/metrics/__init__.py+2 −2 modifiedsrc/local_deep_research/metrics/migrate_add_provider_to_token_usage.py+0 −148 removedsrc/local_deep_research/metrics/migrate_call_stack_tracking.py+0 −105 removedsrc/local_deep_research/metrics/migrate_enhanced_tracking.py+0 −75 removedsrc/local_deep_research/metrics/migrate_research_ratings.py+0 −31 removedsrc/local_deep_research/metrics/models.py+0 −61 removedsrc/local_deep_research/metrics/pricing/cost_calculator.py+3 −4 modifiedsrc/local_deep_research/metrics/pricing/__init__.py+1 −1 modifiedsrc/local_deep_research/metrics/pricing/pricing_cache.py+9 −44 modifiedsrc/local_deep_research/metrics/query_utils.py+6 −6 modifiedsrc/local_deep_research/metrics/search_tracker.py+70 −57 modifiedsrc/local_deep_research/metrics/token_counter.py+1294 −427 modifiedsrc/local_deep_research/news/api.py+1300 −0 addedsrc/local_deep_research/news/core/base_card.py+371 −0 addedsrc/local_deep_research/news/core/card_factory.py+349 −0 addedsrc/local_deep_research/news/core/card_storage.py+209 −0 addedsrc/local_deep_research/news/core/__init__.py+0 −0 addedsrc/local_deep_research/news/core/news_analyzer.py+448 −0 addedsrc/local_deep_research/news/core/relevance_service.py+156 −0 addedsrc/local_deep_research/news/core/search_integration.py+158 −0 addedsrc/local_deep_research/news/core/storage_manager.py+456 −0 addedsrc/local_deep_research/news/core/storage.py+269 −0 addedsrc/local_deep_research/news/core/utils.py+53 −0 addedsrc/local_deep_research/news/exceptions.py+191 −0 addedsrc/local_deep_research/news/flask_api.py+1478 −0 addedsrc/local_deep_research/news/__init__.py+40 −0 addedsrc/local_deep_research/__version__.py+1 −1 modified@@ -1 +1 @@ -__version__ = "0.6.7" +__version__ = "1.0.0"
1394ee66317fMerge pull request #637 from LearningCircuit/dev
300 files changed · +30329 −7716
cookiecutter-docker/{{cookiecutter.config_name}}/docker-compose.{{cookiecutter.config_name}}.yml+5 −1 modified@@ -27,8 +27,10 @@ services: {%- if cookiecutter.enable_searxng %} - LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL=http://searxng:8080 {% endif %} + # Data directory configuration + - LDR_DATA_DIR=/data volumes: - - ldr_data:/install/.venv/lib/python3.13/site-packages/data/ + - ldr_data:/data - ./local_collections/personal_notes:/local_collections/personal_notes/ - ./local_collections/project_docs:/local_collections/project_docs/ - ./local_collections/research_papers:/local_collections/research_papers/ @@ -60,6 +62,8 @@ services: timeout: 5s start_period: 10m retries: 2 + environment: + OLLAMA_KEEP_ALIVE: '30m' volumes: - ollama_data:/root/.ollama - ./scripts/:/scripts/
docker-compose.yml+62 −30 modified@@ -1,48 +1,80 @@ -name: 'local-ai' - services: + local-deep-research: + image: localdeepresearch/local-deep-research + + networks: + - ldr-network + + ports: + - "5000:5000" + environment: + # Web Interface Settings + - LDR_WEB_PORT=5000 + - LDR_WEB_HOST=0.0.0.0 + - LDR_LLM_PROVIDER=ollama + - LDR_LLM_OLLAMA_URL=http://ollama:11434 + + - LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL=http://searxng:8080 + + # Data directory configuration + - LDR_DATA_DIR=/data + volumes: + - ldr_data:/data + - ./local_collections/personal_notes:/local_collections/personal_notes/ + - ./local_collections/project_docs:/local_collections/project_docs/ + - ./local_collections/research_papers:/local_collections/research_papers/ + restart: unless-stopped + depends_on: + ollama: + condition: service_healthy + + searxng: + condition: service_started + ollama: - image: 'ollama/ollama:latest' - deploy: + image: ollama/ollama:latest + deploy: # Remove this section if you do not have an Nvidia GPU. resources: reservations: devices: - driver: nvidia count: 1 - capabilities: [gpu] + capabilities: [ gpu ] + + container_name: ollama_service + entrypoint: [ "/scripts/ollama_entrypoint.sh" ] + healthcheck: + test: [ "CMD", "ollama", "show", "gemma3:12b" ] + interval: 10s + timeout: 5s + start_period: 10m + retries: 2 environment: OLLAMA_KEEP_ALIVE: '30m' volumes: - - type: 'volume' - source: 'ollama' - target: '/root/.ollama' + - ollama_data:/root/.ollama + - ./scripts/:/scripts/ + networks: + - ldr-network + + + restart: unless-stopped searxng: - image: 'ghcr.io/searxng/searxng:latest' + image: searxng/searxng:latest + container_name: searxng + networks: + - ldr-network - deep-research: - image: 'localdeepresearch/local-deep-research:latest' - ports: - - target: '5000' # port bound by app inside container - published: '5000' # port bound on the docker host - protocol: 'tcp' - environment: - LDR_LLM_PROVIDER: 'ollama' - LDR_LLM_OLLAMA_URL: 'http://ollama:11434' - # change model depending on preference and available VRAM - LDR_LLM_MODEL: 'gemma3:12b' - LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL: 'http://searxng:8080' volumes: - - type: 'volume' - source: 'deep-research' - target: '/install/.venv/lib/python3.13/site-packages/data/' - - type: 'volume' - source: 'deep-research-outputs' - target: '/install/.venv/lib/python3.13/research_outputs' + - searxng_data:/etc/searxng + restart: unless-stopped volumes: - ollama: - deep-research: - deep-research-outputs: + ldr_data: + ollama_data: + searxng_data: +networks: + ldr-network:
Dockerfile+104 −2 modified@@ -1,7 +1,13 @@ #### # Used for building the LDR service dependencies. #### -FROM python:3.13.2-slim AS builder +FROM python:3.12.8-slim AS builder-base + +# Install system dependencies for SQLCipher +RUN apt-get update && apt-get install -y \ + libsqlcipher-dev \ + build-essential \ + && rm -rf /var/lib/apt/lists/* # Install dependencies and tools RUN pip3 install --upgrade pip && pip install pdm playwright @@ -15,14 +21,110 @@ COPY src/ src COPY LICENSE LICENSE COPY README.md README.md +#### +# Builds the LDR service dependencies used in production. +#### +FROM builder-base AS builder + # Install the package using PDM RUN pdm install --check --prod --no-editable +#### +# Container for running tests. +#### +FROM builder-base AS ldr-test + +# Install runtime dependencies for SQLCipher, Node.js, and testing tools +RUN apt-get update && apt-get install -y \ + sqlcipher \ + libsqlcipher0 \ + curl \ + xauth \ + xvfb \ + # Dependencies for Chromium + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libatspi2.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + xdg-utils \ + && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Set up Puppeteer environment +ENV PUPPETEER_CACHE_DIR=/app/puppeteer-cache +ENV DOCKER_ENV=true +# Don't skip Chrome download - let Puppeteer download its own Chrome as fallback +# ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + +# Create puppeteer cache directory with proper permissions +RUN mkdir -p /app/puppeteer-cache && chmod -R 777 /app/puppeteer-cache + +# Install Playwright with Chromium first (before npm packages) +RUN playwright install --with-deps chromium || echo "Playwright install failed, will use Puppeteer's Chrome" + +# Copy test package files +COPY tests/api_tests_with_login/package.json /install/tests/api_tests_with_login/ +COPY tests/ui_tests/package.json /install/tests/ui_tests/ + +# Install npm packages - Skip Puppeteer Chrome download since we have Playwright's Chrome +WORKDIR /install/tests/api_tests_with_login +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +RUN npm install +WORKDIR /install/tests/ui_tests +RUN npm install + +# Set CHROME_BIN to help Puppeteer find Chrome from Playwright +# Try to find and set Chrome binary path from Playwright's installation +RUN CHROME_PATH=$(find /root/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1) && \ + if [ -n "$CHROME_PATH" ]; then \ + echo "export CHROME_BIN=$CHROME_PATH" >> /etc/profile.d/chrome.sh; \ + echo "export PUPPETEER_EXECUTABLE_PATH=$CHROME_PATH" >> /etc/profile.d/chrome.sh; \ + fi || true + +# Set environment variables for Puppeteer to use Playwright's Chrome +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/root/.cache/ms-playwright/chromium-1181/chrome-linux/chrome + +# Copy test files to /app where they will be run from +RUN mkdir -p /app && cp -r /install/tests /app/ + +# Ensure Chrome binaries have correct permissions +RUN chmod -R 755 /app/puppeteer-cache + +WORKDIR /install + +# Install the package using PDM +RUN pdm install --check --no-editable +# Configure path to default to the venv python. +ENV PATH="/install/.venv/bin:$PATH" + #### # Runs the LDR service. ### -FROM python:3.13.2-slim AS ldr +FROM python:3.12.8-slim AS ldr + +# Install runtime dependencies for SQLCipher +RUN apt-get update && apt-get install -y \ + sqlcipher \ + libsqlcipher0 \ + && rm -rf /var/lib/apt/lists/* # retrieve packages from build stage COPY --from=builder /install/.venv/ /install/.venv
docs/api-quickstart.md+180 −33 modified@@ -1,53 +1,200 @@ # API Quick Start -## Starting the API Server +## Overview -```bash -cd . -python -m src.local_deep_research.web.app -``` +Local Deep Research provides both HTTP REST API and programmatic Python API access. Since version 2.0, authentication is required for all API endpoints, and the system uses per-user encrypted databases. -The API will be available at `http://localhost:5000/api/v1/` +## Authentication -## Basic Usage Examples +### Web UI Authentication -### 1. Check API Status -```bash -curl http://localhost:5000/api/v1/health -``` +The API requires authentication through the web interface first: + +1. Start the server: + ```bash + python -m local_deep_research.web.app + ``` + +2. Open http://localhost:5000 in your browser +3. Register a new account or login +4. Your session cookie will be used for API authentication + +### HTTP API Authentication + +For HTTP API requests, you need to: + +1. First authenticate through the login endpoint +2. Include the session cookie in subsequent requests +3. Include CSRF token for state-changing operations + +Example authentication flow: + +```python +import requests + +# Create a session to persist cookies +session = requests.Session() -### 2. Get a Quick Summary -```bash -curl -X POST http://localhost:5000/api/v1/quick_summary \ - -H "Content-Type: application/json" \ - -d '{"query": "What is Python programming?"}' +# 1. Login +login_response = session.post( + "http://localhost:5000/auth/login", + json={"username": "your_username", "password": "your_password"} +) + +if login_response.status_code == 200: + print("Login successful") + # Session cookie is automatically stored +else: + print(f"Login failed: {login_response.text}") + +# 2. Get CSRF token for API requests +csrf_response = session.get("http://localhost:5000/auth/csrf-token") +csrf_token = csrf_response.json()["csrf_token"] + +# 3. Make API requests with authentication +headers = {"X-CSRF-Token": csrf_token} +api_response = session.post( + "http://localhost:5000/research/api/start", + json={ + "query": "What is quantum computing?", + "model": "gpt-3.5-turbo", + "search_engines": ["searxng"], + }, + headers=headers +) ``` -### 3. Generate a Report (Long-running) -```bash -curl -X POST http://localhost:5000/api/v1/generate_report \ - -H "Content-Type: application/json" \ - -d '{"query": "Machine learning basics"}' +## Programmatic API Access + +The programmatic API now requires a settings snapshot for proper context: + +```python +from local_deep_research.api import quick_summary +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +# Get user session and settings +with get_user_db_session(username="your_username", password="your_password") as session: + settings_manager = CachedSettingsManager(session, "your_username") + settings_snapshot = settings_manager.get_all_settings() + + # Use the API with settings snapshot + result = quick_summary( + query="What is machine learning?", + settings_snapshot=settings_snapshot, + iterations=2, + questions_per_iteration=3 + ) + + print(result["summary"]) ``` -### 4. Python Example +## API Endpoints + +### Research Endpoints + +All research endpoints are under `/research/api/`: + +- `POST /research/api/start` - Start new research +- `GET /research/api/research/{id}/status` - Check research status +- `GET /research/api/research/{id}/result` - Get research results +- `POST /research/api/research/{id}/terminate` - Stop running research + +### Settings Endpoints + +Settings endpoints are under `/settings/api/`: + +- `GET /settings/api` - Get all settings +- `GET /settings/api/{key}` - Get specific setting +- `PUT /settings/api/{key}` - Update setting +- `GET /settings/api/available-models` - Get available LLM providers +- `GET /settings/api/available-search-engines` - Get search engines + +### History Endpoints + +- `GET /history/api` - Get research history +- `GET /history/api/{id}` - Get specific research details + +## Important Changes from v1.x + +1. **Authentication Required**: All API endpoints now require authentication +2. **Settings Snapshot**: Programmatic API calls need settings_snapshot parameter +3. **Per-User Databases**: Each user has their own encrypted database +4. **CSRF Protection**: State-changing requests require CSRF token +5. **New Endpoint Structure**: APIs moved under blueprint prefixes (e.g., `/research/api/`) + +## Example: Complete Research Flow + ```python import requests +import time -# Get a quick summary -response = requests.post( - "http://localhost:5000/api/v1/quick_summary", - json={"query": "What is AI?"} +# Setup session and login +session = requests.Session() +session.post( + "http://localhost:5000/auth/login", + json={"username": "user", "password": "pass"} ) -print(response.json()["summary"]) +# Get CSRF token +csrf = session.get("http://localhost:5000/auth/csrf-token").json()["csrf_token"] +headers = {"X-CSRF-Token": csrf} + +# Start research +research = session.post( + "http://localhost:5000/research/api/start", + json={ + "query": "Latest advances in quantum computing", + "model": "gpt-3.5-turbo", + "search_engines": ["arxiv", "wikipedia"], + "iterations": 3 + }, + headers=headers +).json() + +research_id = research["research_id"] + +# Poll for results +while True: + status = session.get( + f"http://localhost:5000/research/api/research/{research_id}/status" + ).json() + + if status["status"] in ["completed", "failed"]: + break + + print(f"Progress: {status.get('progress', 'unknown')}") + time.sleep(5) + +# Get final results +results = session.get( + f"http://localhost:5000/research/api/research/{research_id}/result" +).json() + +print(f"Summary: {results['summary']}") +print(f"Sources: {len(results['sources'])}") ``` -## Key Points +## Rate Limiting + +The API includes adaptive rate limiting: +- Default: 60 requests per minute per user +- Automatic retry with exponential backoff +- Rate limits are per-user, not per-IP + +## Error Handling + +Common error responses: +- `401`: Not authenticated - login required +- `403`: CSRF token missing or invalid +- `404`: Resource not found +- `429`: Rate limit exceeded +- `500`: Server error + +Always check response status and handle errors appropriately. -- **Quick Summary**: Fast responses using LLM (seconds) -- **Generate Report**: Comprehensive research (hours) -- **Rate Limit**: 60 requests/minute -- **Timeout**: API requests may timeout on long operations +## Next Steps -For full documentation, see [api-usage.md](api-usage.md) +- See [examples/api_usage](../examples/api_usage/) for complete examples +- Check [docs/CUSTOM_LLM_INTEGRATION.md](CUSTOM_LLM_INTEGRATION.md) for custom LLM setup +- Read [docs/LANGCHAIN_RETRIEVER_INTEGRATION.md](LANGCHAIN_RETRIEVER_INTEGRATION.md) for custom retrievers
docs/DATA_MIGRATION_GUIDE.md+178 −0 added@@ -0,0 +1,178 @@ +# Data Migration Guide + +> **⚠️ Important Note for v1.0**: The upcoming v1.0 release does not support automatic database migration from previous versions. This guide is provided for reference, but users upgrading to v1.0 will need to start with a fresh database. We recommend exporting any important data (API keys, research results) before upgrading. + +## Overview + +Local Deep Research has updated its data storage locations to improve security and follow platform best practices. Previously, data files were stored alongside the application source code. Now, they are stored in user-specific directories that vary by operating system. + +This guide will help you migrate your existing data to the new locations. We recommend creating a backup of your data before proceeding. + +## New Storage Locations + +The application now stores data in platform-specific user directories: + +- **Windows**: `C:\Users\<YourUsername>\AppData\Local\local-deep-research` +- **macOS**: `~/Library/Application Support/local-deep-research` +- **Linux**: `~/.local/share/local-deep-research` + +## What Needs to be Migrated + +The following data will be migrated automatically when possible: + +1. **Database files** (*.db) - Contains your API keys, research history, and settings +2. **Research outputs** - Your generated research reports and findings +3. **Cache files** - Cached pricing information and search results +4. **Log files** - Application logs +5. **Benchmark results** - Performance benchmark data +6. **Optimization results** - LLM optimization data + +## Migration Options + +> **Note**: These migration options are not available in v1.0. Please see the warning at the top of this document. + +### Option 1: Automatic Migration (Recommended for User Installs) + +If you installed Local Deep Research with `pip install --user`, the automatic migration should work: + +```bash +# Simply run the application +python -m local-deep_research.web.app +``` + +The application will automatically detect and migrate your data on startup. + +### Option 2: Run Application with Administrator Privileges + +If you installed with `sudo pip install` or have permission issues, run the application once with administrator privileges: + +```bash +# Run the application with sudo to allow migration +sudo python -m local_deep_research.web.app +``` + +This will: +- Grant the necessary permissions for the automatic migration +- Move all data files to the new user-specific directories +- Complete the migration process +- After this, you can run normally without sudo + +### Option 3: Manual Migration + +If you prefer to migrate manually or the automatic options don't work: + +#### Step 1: Find Your Current Data Location + +```bash +# Find where the application is installed +python -c "import local_deep_research; import os; print(os.path.dirname(local_deep_research.__file__))" +``` + +#### Step 2: Identify Files to Migrate + +Look for these files/directories in the installation directory: +- `data/ldr.db` (and any other .db files) +- `research_outputs/` directory +- `data/cache/` directory +- `data/logs/` directory +- `data/benchmark_results/` directory +- `data/optimization_results/` directory + +#### Step 3: Create New Directories + +```bash +# Linux/macOS +mkdir -p ~/.local/share/local-deep-research + +# Windows (PowerShell) +New-Item -ItemType Directory -Force -Path "$env:LOCALAPPDATA\local-deep-research" +``` + +#### Step 4: Move Your Data + +**Important**: Back up your data before moving! + +```bash +# Example for Linux/macOS (adjust paths as needed) +# Move databases +sudo mv /usr/local/lib/python*/site-packages/local_deep_research/data/*.db ~/.local/share/local-deep-research/ + +# Move research outputs +sudo mv /usr/local/lib/python*/site-packages/local_deep_research/research_outputs ~/.local/share/local-deep-research/ + +# Move other data directories +sudo mv /usr/local/lib/python*/site-packages/local_deep_research/data/cache ~/.local/share/local-deep-research/ +sudo mv /usr/local/lib/python*/site-packages/local_deep_research/data/logs ~/.local/share/local-deep-research/ +``` + +### Option 4: Start Fresh + +If you don't need your existing data, you can simply: + +1. Delete the old data files (requires administrator privileges for system installs) +2. Start using the application - it will create new files in the correct locations + +## Troubleshooting + +### Permission Denied Errors + +If you see "permission denied" errors: + +1. You likely have a system-wide installation (installed with `sudo pip install`) +2. Use Option 2 (Migration Helper) or Option 3 (Manual Migration) above +3. Administrator/sudo privileges will be required to move files from system directories + +### Files Not Found + +If the migration can't find your files: + +1. Check if you have multiple Python installations +2. Verify where the application is installed (Step 1 in Manual Migration) +3. Look for data files in your current working directory + +### Migration Partially Completed + +If some files were migrated but others weren't: + +1. Check the application logs for specific errors +2. Manually move any remaining files following Option 3 +3. Ensure you have write permissions to the new directories + +## Verifying Migration Success + +After migration, verify everything worked: + +```bash +# Check if files exist in new location (Linux/macOS) +ls -la ~/.local/share/local-deep-research/ + +# Check if files exist in new location (Windows PowerShell) +Get-ChildItem "$env:LOCALAPPDATA\local-deep-research" +``` + +You should see: +- `ldr.db` (and possibly other .db files) +- `research_outputs/` directory with your reports +- `cache/` directory +- `logs/` directory + +## Security Benefits + +This migration provides several security improvements: + +1. **User Isolation**: Data is now stored in user-specific directories +2. **Proper Permissions**: User directories have appropriate access controls +3. **Package Updates**: Updates won't affect your data +4. **No System-Wide Access**: Your API keys and research data are private to your user account + +## Getting Help + +If you encounter issues: + +1. Check the application logs in the new logs directory +2. Report issues at: https://github.com/LearningCircuit/local-deep-research/issues +3. Include your operating system and installation method (pip, pip --user, etc.) + +## Reverting Migration (Not Recommended) + +If you absolutely need to revert to the old behavior, you can set environment variables to override the new paths. However, this is not recommended for security reasons and may not be supported in future versions.
docs/env_configuration.md+18 −0 modified@@ -125,3 +125,21 @@ set LDR_ANTHROPIC_API_KEY=your-key-here export LDR_SEARCH__TOOL=wikipedia # Linux/Mac set LDR_SEARCH__TOOL=wikipedia # Windows ``` + +### Data Directory Location + +By default, Local Deep Research stores all data (database, research outputs, cache, logs) in platform-specific user directories. You can override this location using the `LDR_DATA_DIR` environment variable: + +```bash +# Linux/Mac +export LDR_DATA_DIR=/path/to/your/data/directory + +# Windows +set LDR_DATA_DIR=C:\path\to\your\data\directory +``` + +All application data will be organized under this directory: +- `$LDR_DATA_DIR/ldr.db` - Application database +- `$LDR_DATA_DIR/research_outputs/` - Research reports +- `$LDR_DATA_DIR/cache/` - Cached data +- `$LDR_DATA_DIR/logs/` - Application logs
docs/MIGRATION_GUIDE_v1.md+261 −0 added@@ -0,0 +1,261 @@ +# Migration Guide: LDR v0.x to v1.0 + +## Overview + +Local Deep Research v1.0 introduces significant security and architectural improvements: + +- **User Authentication**: All access now requires authentication +- **Per-User Encrypted Databases**: Each user has their own encrypted SQLCipher database +- **Settings Snapshots**: Thread-safe settings management for concurrent operations +- **New API Structure**: Reorganized endpoints under blueprint prefixes + +## Breaking Changes + +### 1. Authentication Required + +**v0.x**: Open access, no authentication +```python +# Direct API access +from local_deep_research.api import quick_summary +result = quick_summary("query") +``` + +**v1.0**: Authentication required +```python +from local_deep_research.api import quick_summary +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +# Must authenticate first +with get_user_db_session(username="user", password="pass") as session: + settings_manager = CachedSettingsManager(session, "user") + settings_snapshot = settings_manager.get_all_settings() + + result = quick_summary( + "query", + settings_snapshot=settings_snapshot # Required parameter + ) +``` + +### 2. HTTP API Changes + +#### Endpoint Structure +- **v0.x**: `/api/v1/quick_summary` +- **v1.0**: `/research/api/start` + +#### Authentication Flow +```python +import requests + +# v1.0 requires session-based authentication +session = requests.Session() + +# 1. Login +session.post( + "http://localhost:5000/auth/login", + json={"username": "user", "password": "pass"} +) + +# 2. Get CSRF token for state-changing operations +csrf = session.get("http://localhost:5000/auth/csrf-token").json()["csrf_token"] + +# 3. Make API requests with CSRF token +response = session.post( + "http://localhost:5000/research/api/start", + json={"query": "test"}, + headers={"X-CSRF-Token": csrf} +) +``` + +### 3. Database Changes + +#### v0.x +- Single shared database: `ldr.db` +- No encryption +- Direct database access from any thread + +#### v1.0 +- Per-user databases: `encrypted_databases/{username}.db` +- SQLCipher encryption with user passwords +- Thread-local session management +- In-memory queue tracking (no more service_db) + +### 4. Settings Management + +#### v0.x +```python +# Direct settings access +from local_deep_research.config import get_db_setting +value = get_db_setting("llm.provider") +``` + +#### v1.0 +```python +# Settings require context +from local_deep_research.settings import CachedSettingsManager + +# Within authenticated session +settings_manager = CachedSettingsManager(session, username) +value = settings_manager.get_setting("llm.provider") + +# Or use settings snapshot for thread safety +settings_snapshot = settings_manager.get_all_settings() +``` + +## Migration Steps + +### 1. Update Dependencies + +```bash +pip install --upgrade local-deep-research +``` + +### 2. Create User Accounts + +Users must create accounts through the web interface: + +1. Start the server: `python -m local_deep_research.web.app` +2. Open http://localhost:5000 +3. Click "Register" and create an account +4. Configure LLM providers and API keys in Settings + +### 3. Update Programmatic Code + +#### Before (v0.x): +```python +from local_deep_research.api import ( + quick_summary, + detailed_research, + generate_report +) + +# Direct usage +result = quick_summary("What is AI?") +``` + +#### After (v1.0): +```python +from local_deep_research.api import quick_summary +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +def run_research(username, password, query): + with get_user_db_session(username, password) as session: + settings_manager = CachedSettingsManager(session, username) + settings_snapshot = settings_manager.get_all_settings() + + return quick_summary( + query=query, + settings_snapshot=settings_snapshot, + # Other parameters remain the same + iterations=2, + questions_per_iteration=3 + ) +``` + +### 4. Update HTTP API Calls + +Create a wrapper for authenticated requests: + +```python +class LDRClient: + def __init__(self, base_url="http://localhost:5000"): + self.base_url = base_url + self.session = requests.Session() + self.csrf_token = None + + def login(self, username, password): + response = self.session.post( + f"{self.base_url}/auth/login", + json={"username": username, "password": password} + ) + if response.status_code == 200: + self.csrf_token = self.session.get( + f"{self.base_url}/auth/csrf-token" + ).json()["csrf_token"] + return response + + def start_research(self, query, **kwargs): + return self.session.post( + f"{self.base_url}/research/api/start", + json={"query": query, **kwargs}, + headers={"X-CSRF-Token": self.csrf_token} + ) + +# Usage +client = LDRClient() +client.login("user", "pass") +result = client.start_research("What is quantum computing?") +``` + +### 5. Update Configuration + +#### API Keys +API keys are now stored encrypted in per-user databases. Users must: +1. Login to the web interface +2. Go to Settings +3. Re-enter API keys for LLM providers + +#### Custom LLMs +Custom LLM registrations now require settings context: + +```python +# v1.0 custom LLM with settings support +def create_custom_llm(model_name=None, temperature=None, settings_snapshot=None): + # Access settings from snapshot if needed + api_key = settings_snapshot.get("llm.custom.api_key", {}).get("value") + return CustomLLM(api_key=api_key, model=model_name, temperature=temperature) +``` + +## Common Issues and Solutions + +### Issue: "No settings context available in thread" +**Solution**: Pass `settings_snapshot` parameter to all API calls + +### Issue: "Encrypted database requires password" +**Solution**: Ensure you're using `get_user_db_session()` with credentials + +### Issue: CSRF token errors +**Solution**: Get fresh CSRF token before state-changing requests + +### Issue: Old endpoints return 404 +**Solution**: Update to new endpoint structure (see mapping above) + +### Issue: Rate limiting not working +**Solution**: Rate limits are now per-user; ensure proper authentication + +## Backward Compatibility + +For temporary backward compatibility, you can: + +1. Set environment variable: `LDR_USE_SHARED_DB=1` (not recommended) +2. Create a compatibility wrapper for your existing code + +```python +# compatibility.py +import os +os.environ["LDR_USE_SHARED_DB"] = "1" # Use at your own risk + +def quick_summary_compat(query, **kwargs): + # Minimal compatibility wrapper + # Note: This bypasses security features! + from local_deep_research.api import quick_summary + return quick_summary(query, settings_snapshot={}, **kwargs) +``` + +⚠️ **Warning**: Compatibility mode bypasses security features and is not recommended for production use. + +## Benefits of v1.0 + +1. **Security**: Encrypted databases protect sensitive API keys and research data +2. **Multi-User**: Multiple users can work simultaneously without conflicts +3. **Performance**: Cached settings and thread-local sessions improve speed +4. **Reliability**: Thread-safe operations prevent race conditions +5. **Privacy**: User data is completely isolated + +## Getting Help + +- Check the [API Quick Start Guide](api-quickstart.md) +- See [examples/api_usage](../examples/api_usage/) for updated examples +- Join our [Discord](https://discord.gg/ttcqQeFcJ3) for migration support +- Report issues on [GitHub](https://github.com/LearningCircuit/local-deep-research/issues)
docs/news/EXCEPTION_HANDLING.md+221 −0 added@@ -0,0 +1,221 @@ +# News API Exception Handling + +## Overview + +The news API module now uses a structured exception handling approach instead of returning error dictionaries. This provides better error handling, cleaner code, and consistent API responses. + +## Exception Hierarchy + +All news API exceptions inherit from `NewsAPIException`, which provides: +- Human-readable error messages +- HTTP status codes +- Machine-readable error codes +- Optional additional details + +```python +from local_deep_research.news.exceptions import NewsAPIException + +class NewsAPIException(Exception): + def __init__(self, message: str, status_code: int = 500, + error_code: Optional[str] = None, + details: Optional[Dict[str, Any]] = None) +``` + +## Available Exceptions + +### InvalidLimitException +- **Status Code**: 400 +- **Error Code**: `INVALID_LIMIT` +- **Usage**: When an invalid limit parameter is provided +- **Example**: +```python +if limit < 1: + raise InvalidLimitException(limit) +``` + +### SubscriptionNotFoundException +- **Status Code**: 404 +- **Error Code**: `SUBSCRIPTION_NOT_FOUND` +- **Usage**: When a requested subscription doesn't exist +- **Example**: +```python +if not subscription: + raise SubscriptionNotFoundException(subscription_id) +``` + +### SubscriptionCreationException +- **Status Code**: 500 +- **Error Code**: `SUBSCRIPTION_CREATE_FAILED` +- **Usage**: When subscription creation fails +- **Example**: +```python +except Exception as e: + raise SubscriptionCreationException(str(e), {"query": query}) +``` + +### SubscriptionUpdateException +- **Status Code**: 500 +- **Error Code**: `SUBSCRIPTION_UPDATE_FAILED` +- **Usage**: When subscription update fails + +### SubscriptionDeletionException +- **Status Code**: 500 +- **Error Code**: `SUBSCRIPTION_DELETE_FAILED` +- **Usage**: When subscription deletion fails + +### DatabaseAccessException +- **Status Code**: 500 +- **Error Code**: `DATABASE_ERROR` +- **Usage**: When database operations fail +- **Example**: +```python +except Exception as e: + raise DatabaseAccessException("operation_name", str(e)) +``` + +### NewsFeedGenerationException +- **Status Code**: 500 +- **Error Code**: `FEED_GENERATION_FAILED` +- **Usage**: When news feed generation fails + +### ResearchProcessingException +- **Status Code**: 500 +- **Error Code**: `RESEARCH_PROCESSING_FAILED` +- **Usage**: When processing research items fails + +### NotImplementedException +- **Status Code**: 501 +- **Error Code**: `NOT_IMPLEMENTED` +- **Usage**: For features not yet implemented +- **Example**: +```python +raise NotImplementedException("feature_name") +``` + +### InvalidParameterException +- **Status Code**: 400 +- **Error Code**: `INVALID_PARAMETER` +- **Usage**: When invalid parameters are provided + +### SchedulerNotificationException +- **Status Code**: 500 +- **Error Code**: `SCHEDULER_NOTIFICATION_FAILED` +- **Usage**: When scheduler notification fails (non-critical) + +## Flask Integration + +### Error Handlers + +The Flask application automatically handles `NewsAPIException` and its subclasses: + +```python +@app.errorhandler(NewsAPIException) +def handle_news_api_exception(error): + return jsonify(error.to_dict()), error.status_code +``` + +### Response Format + +All exceptions are converted to consistent JSON responses: + +```json +{ + "error": "Human-readable error message", + "error_code": "MACHINE_READABLE_CODE", + "status_code": 400, + "details": { + "additional": "context", + "if": "available" + } +} +``` + +## Migration Guide + +### Before (Error Dictionaries) + +```python +def get_news_feed(limit): + if limit < 1: + return { + "error": "Limit must be at least 1", + "news_items": [] + } + + try: + # ... code ... + except Exception as e: + logger.exception("Error getting news feed") + return {"error": str(e), "news_items": []} +``` + +### After (Exceptions) + +```python +def get_news_feed(limit): + if limit < 1: + raise InvalidLimitException(limit) + + try: + # ... code ... + except NewsAPIException: + raise # Re-raise our custom exceptions + except Exception as e: + logger.exception("Error getting news feed") + raise NewsFeedGenerationException(str(e)) +``` + +## Benefits + +1. **Cleaner Code**: Functions focus on their primary logic without error response formatting +2. **Consistent Error Handling**: All errors follow the same format +3. **Better Testing**: Easier to test exception cases with pytest.raises +4. **Type Safety**: IDEs can better understand return types +5. **Centralized Logging**: Error logging can be handled in one place +6. **HTTP Status Codes**: Proper HTTP status codes are automatically set + +## Testing + +Test exception handling using pytest: + +```python +import pytest +from local_deep_research.news.exceptions import InvalidLimitException + +def test_invalid_limit(): + with pytest.raises(InvalidLimitException) as exc_info: + news_api.get_news_feed(limit=-1) + + assert exc_info.value.status_code == 400 + assert exc_info.value.details["provided_limit"] == -1 +``` + +## Best Practices + +1. **Always catch and re-raise NewsAPIException subclasses**: +```python +except NewsAPIException: + raise # Let Flask handle it +except Exception as e: + # Convert to appropriate NewsAPIException + raise DatabaseAccessException("operation", str(e)) +``` + +2. **Include relevant details**: +```python +raise SubscriptionCreationException( + "Database constraint violated", + details={"query": query, "type": subscription_type} +) +``` + +3. **Use appropriate exception types**: Choose the most specific exception that matches the error condition + +4. **Log before raising**: For unexpected errors, log the full exception before converting to NewsAPIException + +## Future Improvements + +- Add retry logic for transient errors +- Implement exception metrics/monitoring +- Add internationalization support for error messages +- Create exception middleware for advanced error handling
docs/SQLCIPHER_INSTALL.md+132 −0 added@@ -0,0 +1,132 @@ +# SQLCipher Installation Guide + +Local Deep Research uses SQLCipher to provide encrypted databases for each user. This ensures that all user data, including API keys and research results, are encrypted at rest. + +## Installation by Platform + +### Ubuntu/Debian Linux + +SQLCipher can be easily installed from the package manager: + +```bash +sudo apt update +sudo apt install sqlcipher libsqlcipher-dev +``` + +After installation, you can install the Python binding: +```bash +pdm add pysqlcipher3 +# or +pip install pysqlcipher3 +``` + +### macOS + +Install using Homebrew: + +```bash +brew install sqlcipher +``` + +You may need to set environment variables for the Python binding: +```bash +export LDFLAGS="-L$(brew --prefix sqlcipher)/lib" +export CPPFLAGS="-I$(brew --prefix sqlcipher)/include" +pdm add pysqlcipher3 +``` + +### Windows + +Windows installation is more complex and requires building from source: + +1. Install Visual Studio 2015 or later (Community Edition works) +2. Install the "Desktop Development with C++" workload +3. Download SQLCipher source from https://github.com/sqlcipher/sqlcipher +4. Build using Visual Studio Native Tools Command Prompt + +For easier installation on Windows, consider using WSL2 with Ubuntu. + +## Alternative: Using Docker + +If you have difficulty installing SQLCipher, you can run Local Deep Research in a Docker container where SQLCipher is pre-installed: + +```dockerfile +FROM python:3.11-slim + +# Install SQLCipher +RUN apt-get update && apt-get install -y \ + sqlcipher \ + libsqlcipher-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install Local Deep Research +RUN pip install local-deep-research pysqlcipher3 + +CMD ["ldr", "serve"] +``` + +## Verifying Installation + +You can verify SQLCipher is installed correctly: + +```bash +# Check command line tool +sqlcipher --version + +# Test Python binding +python -c "import pysqlcipher3; print('SQLCipher is installed!')" +``` + +## Fallback Mode + +If SQLCipher is not available, Local Deep Research will fall back to using regular SQLite databases. However, this means your data will not be encrypted at rest. A warning will be displayed when running without encryption. + +## Security Notes + +- Each user's database is encrypted with their password +- There is no password recovery mechanism - if a user forgets their password, their data cannot be recovered +- The encryption uses SQLCipher's default settings with AES-256 +- API keys and sensitive data are only stored in the encrypted user databases + +## Troubleshooting + +### Linux: "Package not found" + +If your distribution doesn't have SQLCipher in its repositories, you may need to build from source or use a third-party repository. + +### macOS: "Library not loaded" + +Make sure you've set the LDFLAGS and CPPFLAGS environment variables as shown above. + +### Windows: Build errors + +Ensure you're using the Visual Studio Native Tools Command Prompt and have all required dependencies installed. + +### Python: "No module named pysqlcipher3" + +Try using the alternative package: +```bash +pip install sqlcipher3 +``` + +## For Developers + +To add SQLCipher to an automated installation script: + +```bash +#!/bin/bash +# For Ubuntu/Debian +if command -v apt-get &> /dev/null; then + sudo apt-get update + sudo apt-get install -y sqlcipher libsqlcipher-dev +fi + +# For macOS with Homebrew +if command -v brew &> /dev/null; then + brew install sqlcipher +fi + +# Install Python package +pip install pysqlcipher3 || pip install sqlcipher3 +```
docs/troubleshooting-openai-api-key.md+249 −0 added@@ -0,0 +1,249 @@ +# Troubleshooting OpenAI API Key Configuration + +This guide helps troubleshoot common issues with OpenAI API key configuration in Local Deep Research v1.0+. + +## Quick Test + +Run the end-to-end test to verify your configuration: + +```bash +# Using command line arguments +python tests/test_openai_api_key_e2e.py \ + --username YOUR_USERNAME \ + --password YOUR_PASSWORD \ + --api-key YOUR_OPENAI_API_KEY + +# Using environment variables +export LDR_USERNAME=your_username +export LDR_PASSWORD=your_password +export OPENAI_API_KEY=sk-your-api-key +python tests/test_openai_api_key_e2e.py +``` + +## Common Issues and Solutions + +### 1. "No API key found" + +**Symptoms:** +- Error message about missing API key +- Research fails to start + +**Solutions:** + +1. **Via Web Interface:** + - Login to LDR web interface + - Go to Settings + - Select "OpenAI" as LLM Provider + - Enter your API key in the "OpenAI API Key" field + - Click Save + +2. **Via Environment Variable:** + ```bash + export OPENAI_API_KEY=sk-your-api-key + python -m local_deep_research.web.app + ``` + +3. **Programmatically:** + ```python + from local_deep_research.settings import CachedSettingsManager + from local_deep_research.database.session_context import get_user_db_session + + with get_user_db_session(username="user", password="pass") as session: + settings_manager = CachedSettingsManager(session, "user") + settings_manager.set_setting("llm.provider", "openai") + settings_manager.set_setting("llm.openai.api_key", "sk-your-api-key") + ``` + +### 2. "Invalid API key" + +**Symptoms:** +- 401 Unauthorized errors +- "Incorrect API key provided" messages + +**Solutions:** + +1. **Verify API Key Format:** + - OpenAI keys start with `sk-` + - Should be around 51 characters long + - No extra spaces or quotes + +2. **Check API Key Validity:** + ```bash + # Test directly with curl + curl https://api.openai.com/v1/models \ + -H "Authorization: Bearer YOUR_API_KEY" + ``` + +3. **Regenerate API Key:** + - Go to https://platform.openai.com/api-keys + - Create a new API key + - Update in LDR settings + +### 3. "Rate limit exceeded" + +**Symptoms:** +- 429 errors +- "You exceeded your current quota" messages + +**Solutions:** + +1. **Check OpenAI Usage:** + - Visit https://platform.openai.com/usage + - Verify you have available credits + +2. **Add Payment Method:** + - OpenAI requires payment info for API access + - Add at https://platform.openai.com/account/billing + +3. **Use Different Model:** + ```python + settings_manager.set_setting("llm.model", "gpt-3.5-turbo") # Cheaper + # Instead of gpt-4 which is more expensive + ``` + +### 4. "Settings not persisting" + +**Symptoms:** +- API key needs to be re-entered after restart +- Settings revert to defaults + +**Solutions:** + +1. **Ensure Proper Shutdown:** + - Use Ctrl+C to stop server (not kill -9) + - Wait for "Server stopped" message + +2. **Check Database Permissions:** + ```bash + ls -la encrypted_databases/ + # Should show your user database with write permissions + ``` + +3. **Verify Settings Save:** + ```python + # After setting, verify it was saved + saved_key = settings_manager.get_setting("llm.openai.api_key") + print(f"Saved key: {'*' * 20 if saved_key else 'Not saved'}") + ``` + +### 5. "API key not being used" + +**Symptoms:** +- Settings show OpenAI configured but different LLM is used +- API key is saved but not applied + +**Solutions:** + +1. **Check Provider Setting:** + ```python + provider = settings_manager.get_setting("llm.provider") + print(f"Current provider: {provider}") # Should be "openai" + ``` + +2. **Verify Settings Snapshot:** + ```python + settings_snapshot = settings_manager.get_all_settings() + print("Provider:", settings_snapshot.get("llm.provider", {}).get("value")) + print("API Key:", "Set" if settings_snapshot.get("llm.openai.api_key", {}).get("value") else "Not set") + ``` + +3. **Force Provider Selection:** + ```python + # In research call + result = quick_summary( + query="Test", + settings_snapshot=settings_snapshot, + provider="openai", # Force OpenAI + model_name="gpt-3.5-turbo" + ) + ``` + +## Testing Your Configuration + +### 1. Simple API Test + +```python +from local_deep_research.config.llm_config import get_llm +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +with get_user_db_session(username="user", password="pass") as session: + settings_manager = CachedSettingsManager(session, "user") + settings_snapshot = settings_manager.get_all_settings() + + # Test LLM initialization + try: + llm = get_llm(settings_snapshot=settings_snapshot) + print("✓ LLM initialized successfully") + + # Test response + from langchain.schema import HumanMessage + response = llm.invoke([HumanMessage(content="Say hello")]) + print(f"✓ Response: {response.content}") + except Exception as e: + print(f"✗ Error: {e}") +``` + +### 2. Full Research Test + +```python +from local_deep_research.api.research_functions import quick_summary + +result = quick_summary( + query="What is OpenAI?", + settings_snapshot=settings_snapshot, + iterations=1, + questions_per_iteration=1 +) + +print(f"Research ID: {result['research_id']}") +print(f"Summary: {result['summary'][:200]}...") +``` + +## Advanced Configuration + +### Using Azure OpenAI + +```python +settings_manager.set_setting("llm.provider", "openai") +settings_manager.set_setting("llm.openai.api_key", "your-azure-key") +settings_manager.set_setting("llm.openai.api_base", "https://your-resource.openai.azure.com/") +settings_manager.set_setting("llm.model", "your-deployment-name") +``` + +### Using OpenAI-Compatible Endpoints + +```python +settings_manager.set_setting("llm.provider", "openai") +settings_manager.set_setting("llm.openai.api_key", "your-api-key") +settings_manager.set_setting("llm.openai.api_base", "https://your-endpoint.com/v1") +``` + +### Organization ID + +```python +settings_manager.set_setting("llm.openai.organization", "org-your-org-id") +``` + +## Getting Help + +1. **Run Diagnostic Test:** + ```bash + python tests/test_openai_api_key_e2e.py --verbose + ``` + +2. **Check Logs:** + ```bash + # Look for OpenAI-related errors + grep -i "openai\|api.*key" logs/ldr.log + ``` + +3. **Community Support:** + - GitHub Issues: https://github.com/LearningCircuit/local-deep-research/issues + - Discord: https://discord.gg/ttcqQeFcJ3 + +4. **API Key Best Practices:** + - Never commit API keys to version control + - Use environment variables for production + - Rotate keys regularly + - Set usage limits in OpenAI dashboard
examples/api_usage/http/http_api_examples.py+347 −275 modified@@ -1,325 +1,397 @@ #!/usr/bin/env python3 """ -HTTP API Examples for Local Deep Research +HTTP API Examples for Local Deep Research v1.0+ -This script demonstrates how to use the LDR HTTP API endpoints. -Make sure the LDR server is running before running these examples: - python -m src.local_deep_research.web.app +This script demonstrates comprehensive usage of the LDR HTTP API with authentication. +Includes examples for research, settings management, and batch operations. + +Requirements: +- LDR v1.0+ (with authentication features) +- User account created through web interface +- LDR server running: python -m local_deep_research.web.app """ -import requests -import json import time -from typing import Dict, Any +from typing import Any, Dict, List +import requests -# Base URL for the API -BASE_URL = "http://localhost:5000/api/v1" +# Configuration +BASE_URL = "http://localhost:5000" +USERNAME = "your_username" # Change this! +PASSWORD = "your_password" # Change this! -def check_health() -> None: - """Check if the API server is running.""" - try: - response = requests.get(f"{BASE_URL}/health") - print(f"Health check: {response.json()}") - except requests.exceptions.ConnectionError: - print("Error: Cannot connect to API server. Make sure it's running:") - print(" python -m src.local_deep_research.web.app") - exit(1) - - -def quick_summary_example() -> Dict[str, Any]: - """Example: Generate a quick summary of a topic.""" - print("\n=== Quick Summary Example ===") - - payload = { - "query": "What are the latest advances in quantum computing?", - "search_tool": "wikipedia", # Optional: specify search engine - "iterations": 1, # Optional: number of research iterations - "questions_per_iteration": 2, # Optional: questions per iteration - } - - response = requests.post( - f"{BASE_URL}/quick_summary", - json=payload, - headers={"Content-Type": "application/json"}, - ) +class LDRClient: + """Client for interacting with LDR API v1.0+ with authentication""" - if response.status_code == 200: - result = response.json() - print(f"Summary: {result['summary'][:500]}...") - print(f"Number of findings: {len(result.get('findings', []))}") - print(f"Research iterations: {result.get('iterations', 0)}") - return result - else: - print(f"Error: {response.status_code} - {response.text}") - return {} - - -def detailed_research_example() -> Dict[str, Any]: - """Example: Perform detailed research on a topic.""" - print("\n=== Detailed Research Example ===") - - payload = { - "query": "Impact of AI on software development", - "search_tool": "auto", # Auto-select best search engine - "iterations": 2, - "questions_per_iteration": 3, - "search_strategy": "source_based", # Optional: specify strategy - } - - response = requests.post( - f"{BASE_URL}/detailed_research", - json=payload, - headers={"Content-Type": "application/json"}, - ) + def __init__(self, base_url: str = BASE_URL): + self.base_url = base_url + self.session = requests.Session() + self.csrf_token = None + self.username = None - if response.status_code == 200: - result = response.json() - print(f"Query: {result['query']}") - print(f"Research ID: {result['research_id']}") - print(f"Summary length: {len(result['summary'])} characters") - print(f"Sources found: {len(result.get('sources', []))}") - - # Print metadata - if "metadata" in result: - print("\nMetadata:") - for key, value in result["metadata"].items(): - print(f" {key}: {value}") - - return result - else: - print(f"Error: {response.status_code} - {response.text}") - return {} - - -def generate_report_example() -> Dict[str, Any]: - """Example: Generate a comprehensive research report.""" - print("\n=== Generate Report Example ===") - print("Note: This can take several minutes to complete...") - - payload = { - "query": "Future of renewable energy", - "searches_per_section": 2, - "iterations": 1, - "provider": "openai_endpoint", # Optional: LLM provider - "model_name": "llama-3.3-70b-instruct", # Optional: model - "temperature": 0.7, # Optional: generation temperature - } - - # Start the report generation - response = requests.post( - f"{BASE_URL}/generate_report", - json=payload, - headers={"Content-Type": "application/json"}, - timeout=300, # 5 minute timeout - ) + def login(self, username: str, password: str) -> bool: + """Authenticate with the LDR server.""" + response = self.session.post( + f"{self.base_url}/auth/login", + json={"username": username, "password": password}, + ) - if response.status_code == 200: - result = response.json() - - # Save the report to a file - if "content" in result: - with open("generated_report.md", "w", encoding="utf-8") as f: - f.write(result["content"]) - print("Report saved to: generated_report.md") - - # Show report preview - print("\nReport preview (first 500 chars):") - print(result["content"][:500] + "...") - - # Show metadata - if "metadata" in result: - print("\nReport metadata:") - for key, value in result["metadata"].items(): - print(f" {key}: {value}") - - return result - else: - print(f"Error: {response.status_code} - {response.text}") - return {} - - -def search_with_retriever_example() -> Dict[str, Any]: - """Example: Using custom retrievers via HTTP API.""" - print("\n=== Search with Custom Retriever Example ===") - print("Note: This example shows the API structure but won't work") - print("without a real retriever implementation on the server side.") - - # This demonstrates the API structure, but actual retrievers - # need to be registered on the server side - payload = { - "query": "company policies on remote work", - "search_tool": "company_docs", # Use a named retriever - "iterations": 1, - } - - response = requests.post( - f"{BASE_URL}/quick_summary", - json=payload, - headers={"Content-Type": "application/json"}, - ) + if response.status_code == 200: + self.username = username + # Get CSRF token + csrf_response = self.session.get(f"{self.base_url}/auth/csrf-token") + self.csrf_token = csrf_response.json()["csrf_token"] + return True + return False + + def logout(self) -> None: + """Logout from the server.""" + if self.csrf_token: + self.session.post( + f"{self.base_url}/auth/logout", + headers={"X-CSRF-Token": self.csrf_token}, + ) - if response.status_code == 200: - result = response.json() - print("Found information from custom retriever") - return result - else: - print( - f"Expected error (retriever not registered): {response.status_code}" - ) - return {} + def _get_headers(self) -> Dict[str, str]: + """Get headers with CSRF token.""" + return {"X-CSRF-Token": self.csrf_token} if self.csrf_token else {} + def check_health(self) -> Dict[str, Any]: + """Check API health status.""" + response = self.session.get(f"{self.base_url}/auth/check") + return response.json() -def get_available_search_engines() -> Dict[str, Any]: - """Example: Get list of available search engines.""" - print("\n=== Available Search Engines ===") + def start_research(self, query: str, **kwargs) -> Dict[str, Any]: + """Start a new research task.""" + payload = { + "query": query, + "model": kwargs.get("model"), + "search_engines": kwargs.get("search_engines", ["wikipedia"]), + "iterations": kwargs.get("iterations", 2), + "questions_per_iteration": kwargs.get("questions_per_iteration", 3), + "temperature": kwargs.get("temperature", 0.7), + "local_context": kwargs.get("local_context", 2000), + "web_context": kwargs.get("web_context", 2000), + } - response = requests.get(f"{BASE_URL}/search_engines") + response = self.session.post( + f"{self.base_url}/research/api/start", + json=payload, + headers=self._get_headers(), + ) + + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to start research: {response.text}") - if response.status_code == 200: - engines = response.json() - print("Available search engines:") - for name, info in engines.items(): - if isinstance(info, dict): - print( - f" - {name}: {info.get('description', 'No description')}" + def get_research_status(self, research_id: str) -> Dict[str, Any]: + """Get the status of a research task.""" + response = self.session.get( + f"{self.base_url}/research/api/research/{research_id}/status" + ) + return response.json() + + def get_research_result(self, research_id: str) -> Dict[str, Any]: + """Get the results of a completed research task.""" + response = self.session.get( + f"{self.base_url}/research/api/research/{research_id}/result" + ) + return response.json() + + def wait_for_research( + self, research_id: str, timeout: int = 300 + ) -> Dict[str, Any]: + """Wait for research to complete and return results.""" + start_time = time.time() + + while time.time() - start_time < timeout: + status = self.get_research_status(research_id) + + if status.get("status") == "completed": + return self.get_research_result(research_id) + elif status.get("status") == "failed": + raise Exception( + f"Research failed: {status.get('error', 'Unknown error')}" ) - else: - print(f" - {name}") - return engines - else: - print(f"Error: {response.status_code} - {response.text}") - return {} + print( + f" Status: {status.get('status', 'unknown')} - {status.get('progress', 'N/A')}" + ) + time.sleep(3) -def batch_research_example() -> None: - """Example: Perform multiple research queries in batch.""" - print("\n=== Batch Research Example ===") + raise TimeoutError( + f"Research {research_id} timed out after {timeout} seconds" + ) - queries = [ - "Impact of 5G on IoT", - "Blockchain in supply chain", - "Edge computing trends", - ] + def get_settings(self) -> Dict[str, Any]: + """Get all user settings.""" + response = self.session.get(f"{self.base_url}/settings/api") + return response.json() - results = [] + def get_setting(self, key: str) -> Any: + """Get a specific setting value.""" + response = self.session.get(f"{self.base_url}/settings/api/{key}") + if response.status_code == 200: + return response.json() + return None + + def update_setting(self, key: str, value: Any) -> bool: + """Update a setting value.""" + response = self.session.put( + f"{self.base_url}/settings/api/{key}", + json={"value": value}, + headers=self._get_headers(), + ) + return response.status_code in [200, 201] - for query in queries: - print(f"\nResearching: {query}") + def get_history(self, limit: int = 10) -> List[Dict[str, Any]]: + """Get research history.""" + response = self.session.get( + f"{self.base_url}/history/api", params={"limit": limit} + ) + data = response.json() + return data.get("items", data.get("history", [])) - payload = { - "query": query, - "search_tool": "wikipedia", - "iterations": 1, - "questions_per_iteration": 1, - } + def get_available_models(self) -> Dict[str, str]: + """Get available LLM providers and models.""" + response = self.session.get( + f"{self.base_url}/settings/api/available-models" + ) + data = response.json() + return data.get("providers", data.get("models", {})) - response = requests.post( - f"{BASE_URL}/quick_summary", - json=payload, - headers={"Content-Type": "application/json"}, + def get_available_search_engines(self) -> List[str]: + """Get available search engines.""" + response = self.session.get( + f"{self.base_url}/settings/api/available-search-engines" ) + data = response.json() + return data.get("engines", data.get("engine_options", [])) - if response.status_code == 200: - result = response.json() - results.append( - { - "query": query, - "summary": result["summary"][:200] + "...", - "findings_count": len(result.get("findings", [])), - } - ) - print(f" ✓ Completed - {len(result['summary'])} chars") - else: - print(f" ✗ Failed - {response.status_code}") - # Be nice to the API - add a small delay between requests - time.sleep(1) +def example_quick_research(client: LDRClient) -> None: + """Example: Quick research with minimal parameters.""" + print("\n=== Example 1: Quick Research ===") - # Display batch results - print("\n=== Batch Results Summary ===") - for r in results: - print(f"\nQuery: {r['query']}") - print(f"Findings: {r['findings_count']}") - print(f"Summary: {r['summary']}") + research = client.start_research( + query="What are the key principles of machine learning?", + iterations=1, + questions_per_iteration=2, + ) + print(f"Started research ID: {research['research_id']}") -def stream_research_example() -> None: - """Example: Stream research progress (if supported by server).""" - print("\n=== Streaming Research Example ===") - print("Note: This shows how streaming would work if implemented") + # Wait for completion + result = client.wait_for_research(research["research_id"]) - # This is a conceptual example - actual streaming depends on server implementation - payload = { - "query": "Latest developments in AI ethics", - "stream": True, # Request streaming responses - } + print(f"\nSummary: {result['summary'][:500]}...") + print(f"Sources: {len(result.get('sources', []))}") + print(f"Findings: {len(result.get('findings', []))}") - try: - response = requests.post( - f"{BASE_URL}/quick_summary", - json=payload, - headers={"Content-Type": "application/json"}, - stream=True, - ) - if response.status_code == 200: - for line in response.iter_lines(): - if line: - # Parse streaming JSON responses - data = json.loads(line.decode("utf-8")) - if "progress" in data: - print(f"Progress: {data['progress']}") - elif "result" in data: - print("Final result received") - else: - print(f"Streaming not supported or error: {response.status_code}") +def example_detailed_research(client: LDRClient) -> None: + """Example: Detailed research with multiple search engines.""" + print("\n=== Example 2: Detailed Research ===") - except Exception as e: - print(f"Streaming example failed: {e}") - print("This is expected if the server doesn't support streaming") + # Check available search engines + engines = client.get_available_search_engines() + print(f"Available search engines: {engines}") + # Use multiple engines + selected_engines = ( + ["wikipedia", "arxiv"] if "arxiv" in engines else ["wikipedia"] + ) -def main(): - """Run all examples.""" - print("=== Local Deep Research HTTP API Examples ===") - print(f"Using API at: {BASE_URL}") + research = client.start_research( + query="Impact of climate change on global food security", + search_engines=selected_engines, + iterations=3, + questions_per_iteration=4, + temperature=0.7, + ) - # Check if server is running - check_health() + print(f"Started detailed research ID: {research['research_id']}") - # Run examples - try: - # Basic examples - quick_summary_example() - time.sleep(2) # Rate limiting + # Monitor progress + result = client.wait_for_research(research["research_id"], timeout=600) - detailed_research_example() - time.sleep(2) + print(f"\nTitle: {result.get('query', 'N/A')}") + print(f"Summary length: {len(result['summary'])} characters") + print(f"Sources: {len(result.get('sources', []))}") - # Get available engines - get_available_search_engines() - time.sleep(2) + # Show some findings + findings = result.get("findings", []) + if findings: + print("\nTop findings:") + for i, finding in enumerate(findings[:3], 1): + print(f"{i}. {finding.get('text', 'N/A')[:100]}...") - # Advanced examples - search_with_retriever_example() - time.sleep(2) - batch_research_example() - time.sleep(2) +def example_settings_management(client: LDRClient) -> None: + """Example: Managing user settings.""" + print("\n=== Example 3: Settings Management ===") - stream_research_example() - time.sleep(2) + # Get current settings + settings = client.get_settings() + settings_data = settings.get("settings", {}) - # Long-running example (optional - uncomment to run) - # generate_report_example() + # Display current LLM configuration + llm_provider = settings_data.get("llm.provider", {}).get("value", "Not set") + llm_model = settings_data.get("llm.model", {}).get("value", "Not set") - except KeyboardInterrupt: - print("\nExamples interrupted by user") - except Exception as e: - print(f"\nError running examples: {e}") + print(f"Current LLM Provider: {llm_provider}") + print(f"Current LLM Model: {llm_model}") + + # Get available models + models = client.get_available_models() + print(f"\nAvailable providers: {list(models.keys())}") + + # Example: Update temperature setting + current_temp = settings_data.get("llm.temperature", {}).get("value", 0.7) + print(f"\nCurrent temperature: {current_temp}") + + # Update temperature (example - uncomment to actually update) + # success = client.update_setting("llm.temperature", 0.5) + # print(f"Temperature update: {'Success' if success else 'Failed'}") + + +def example_batch_research(client: LDRClient) -> None: + """Example: Running multiple research tasks in batch.""" + print("\n=== Example 4: Batch Research ===") + + queries = [ + "What is quantum entanglement?", + "How does CRISPR gene editing work?", + "What are the applications of blockchain technology?", + ] + + research_ids = [] - print("\n=== Examples completed ===") + # Start all research tasks + for query in queries: + try: + research = client.start_research( + query=query, iterations=1, questions_per_iteration=2 + ) + research_ids.append( + { + "id": research["research_id"], + "query": query, + "status": "started", + } + ) + print(f"Started: {query} (ID: {research['research_id']})") + except Exception as e: + print(f"Failed to start '{query}': {e}") + + # Wait for all to complete + print("\nWaiting for batch completion...") + completed = 0 + + while completed < len(research_ids): + for research in research_ids: + if research["status"] != "completed": + try: + status = client.get_research_status(research["id"]) + if status.get("status") == "completed": + research["status"] = "completed" + completed += 1 + print(f"✓ Completed: {research['query']}") + except Exception: + pass + + if completed < len(research_ids): + time.sleep(3) + + # Get all results + print("\nBatch Results Summary:") + for research in research_ids: + try: + result = client.get_research_result(research["id"]) + print(f"\n{research['query']}:") + print(f" - Summary: {result['summary'][:150]}...") + print(f" - Sources: {len(result.get('sources', []))}") + except Exception as e: + print(f" - Error getting results: {e}") + + +def example_research_history(client: LDRClient) -> None: + """Example: Viewing research history.""" + print("\n=== Example 5: Research History ===") + + history = client.get_history(limit=5) + + if not history: + print("No research history found.") + return + + print(f"Found {len(history)} recent research items:\n") + + for item in history: + created = item.get("created_at", "Unknown date") + query = item.get("query", "Unknown query") + status = item.get("status", "Unknown") + research_id = item.get("id", item.get("research_id", "N/A")) + + print(f"ID: {research_id}") + print(f"Query: {query}") + print(f"Date: {created}") + print(f"Status: {status}") + print("-" * 40) + + +def main(): + """Run all examples.""" + print("=== LDR HTTP API v1.0 Examples ===") + + # Create client + client = LDRClient(BASE_URL) + + # Check if we need to update credentials + if USERNAME == "your_username": + print( + "\n⚠️ WARNING: Please update USERNAME and PASSWORD in this script!" + ) + print("Steps:") + print("1. Start server: python -m local_deep_research.web.app") + print("2. Open: http://localhost:5000") + print("3. Register an account") + print("4. Update USERNAME and PASSWORD in this script") + return + + try: + # Login + print(f"\nLogging in as: {USERNAME}") + if not client.login(USERNAME, PASSWORD): + print("❌ Login failed! Please check your credentials.") + return + + print("✅ Login successful") + + # Check health + health = client.check_health() + print(f"Authenticated: {health.get('authenticated', False)}") + print(f"Username: {health.get('username', 'N/A')}") + + # Run examples + example_quick_research(client) + example_detailed_research(client) + example_settings_management(client) + example_batch_research(client) + example_research_history(client) + + except requests.exceptions.ConnectionError: + print("\n❌ Cannot connect to LDR server!") + print("Make sure the server is running:") + print(" python -m local_deep_research.web.app") + except Exception as e: + print(f"\n❌ Error: {e}") + finally: + # Always logout + client.logout() + print("\n✅ Logged out") if __name__ == "__main__":
examples/api_usage/http/simple_http_example.py+140 −36 modified@@ -1,43 +1,147 @@ #!/usr/bin/env python3 """ -Simple HTTP API Example for Local Deep Research +Simple HTTP API Example for Local Deep Research v1.0+ -Quick example showing how to use the LDR API with Python requests library. +This example shows how to use the LDR API with authentication. +Requires LDR v1.0+ with authentication features. """ import requests +import time +import sys -# Make sure LDR server is running: python -m src.local_deep_research.web.app -API_URL = "http://localhost:5000/api/v1" - -# Example 1: Quick Summary -print("=== Quick Summary ===") -response = requests.post( - f"{API_URL}/quick_summary", json={"query": "What is machine learning?"} -) - -if response.status_code == 200: - result = response.json() - print(f"Summary: {result['summary'][:300]}...") - print(f"Found {len(result.get('findings', []))} findings") -else: - print(f"Error: {response.status_code}") - -# Example 2: Detailed Research -print("\n=== Detailed Research ===") -response = requests.post( - f"{API_URL}/detailed_research", - json={ - "query": "Impact of climate change on agriculture", - "iterations": 2, - "search_tool": "wikipedia", - }, -) - -if response.status_code == 200: - result = response.json() - print(f"Research ID: {result['research_id']}") - print(f"Summary length: {len(result['summary'])} characters") - print(f"Sources: {len(result.get('sources', []))}") -else: - print(f"Error: {response.status_code}") +# Configuration +API_URL = "http://localhost:5000" +USERNAME = "your_username" # Change this! +PASSWORD = "your_password" # Change this! + + +def main(): + # Create a session to persist cookies + session = requests.Session() + + print("=== LDR HTTP API Example ===") + print(f"Connecting to: {API_URL}") + + # Step 1: Login + print("\n1. Authenticating...") + login_response = session.post( + f"{API_URL}/auth/login", + json={"username": USERNAME, "password": PASSWORD}, + ) + + if login_response.status_code != 200: + print(f"❌ Login failed: {login_response.text}") + print("\nPlease ensure:") + print("- The server is running: python -m local_deep_research.web.app") + print("- You have created an account through the web interface") + print("- You have updated USERNAME and PASSWORD in this script") + sys.exit(1) + + print("✅ Login successful") + + # Step 2: Get CSRF token + print("\n2. Getting CSRF token...") + csrf_response = session.get(f"{API_URL}/auth/csrf-token") + csrf_token = csrf_response.json()["csrf_token"] + headers = {"X-CSRF-Token": csrf_token} + print("✅ CSRF token obtained") + + # Example 1: Quick Summary (using the start endpoint) + print("\n=== Example 1: Quick Summary ===") + research_request = { + "query": "What is machine learning?", + "model": None, # Will use default from settings + "search_engines": ["wikipedia"], # Fast for demo + "iterations": 1, + "questions_per_iteration": 2, + } + + # Start research + start_response = session.post( + f"{API_URL}/research/api/start", json=research_request, headers=headers + ) + + if start_response.status_code != 200: + print(f"❌ Failed to start research: {start_response.text}") + sys.exit(1) + + research_data = start_response.json() + research_id = research_data["research_id"] + print(f"✅ Research started with ID: {research_id}") + + # Poll for results + print("\nWaiting for results...") + while True: + status_response = session.get( + f"{API_URL}/research/api/research/{research_id}/status" + ) + + if status_response.status_code == 200: + status = status_response.json() + print(f" Status: {status.get('status', 'unknown')}") + + if status.get("status") == "completed": + break + elif status.get("status") == "failed": + print( + f"❌ Research failed: {status.get('error', 'Unknown error')}" + ) + sys.exit(1) + + time.sleep(2) + + # Get results + results_response = session.get( + f"{API_URL}/research/api/research/{research_id}/result" + ) + + if results_response.status_code == 200: + results = results_response.json() + print(f"\n📝 Summary: {results['summary'][:300]}...") + print(f"📚 Sources: {len(results.get('sources', []))} found") + print(f"🔍 Findings: {len(results.get('findings', []))} findings") + + # Example 2: Check Settings + print("\n=== Example 2: Current Settings ===") + settings_response = session.get(f"{API_URL}/settings/api") + + if settings_response.status_code == 200: + settings = settings_response.json()["settings"] + + # Show some key settings + llm_provider = settings.get("llm.provider", {}).get("value", "Not set") + llm_model = settings.get("llm.model", {}).get("value", "Not set") + + print(f"LLM Provider: {llm_provider}") + print(f"LLM Model: {llm_model}") + + # Example 3: Get Research History + print("\n=== Example 3: Research History ===") + history_response = session.get(f"{API_URL}/history/api") + + if history_response.status_code == 200: + history = history_response.json() + items = history.get("items", history.get("history", [])) + + print(f"Found {len(items)} research items") + for item in items[:3]: # Show first 3 + print( + f"- {item.get('query', 'Unknown query')} ({item.get('created_at', 'Unknown date')})" + ) + + # Logout + print("\n4. Logging out...") + session.post(f"{API_URL}/auth/logout", headers=headers) + print("✅ Logged out successfully") + + +if __name__ == "__main__": + print("Make sure the LDR server is running:") + print(" python -m local_deep_research.web.app\n") + + if USERNAME == "your_username": + print("⚠️ WARNING: Please update USERNAME and PASSWORD in this script!") + print(" Create an account through the web interface first.\n") + + main()
examples/api_usage/programmatic/advanced_features_example.py+611 −0 added@@ -0,0 +1,611 @@ +#!/usr/bin/env python3 +""" +Advanced Features Example for Local Deep Research + +This example demonstrates advanced programmatic features including: +1. generate_report() - Create comprehensive markdown reports +2. Export formats - Save reports in different formats +3. Result analysis - Extract and analyze research findings +4. Keyword extraction - Identify key topics and concepts +""" + +import json +from typing import Dict, List, Any + +from local_deep_research.api import ( + generate_report, + detailed_research, + quick_summary, +) +from local_deep_research.api.settings_utils import create_settings_snapshot + + +def demonstrate_report_generation(): + """ + Generate a comprehensive research report using generate_report(). + + This function creates a structured markdown report with: + - Executive summary + - Detailed findings organized by sections + - Source citations + - Conclusions and recommendations + """ + print("=" * 70) + print("GENERATE COMPREHENSIVE REPORT") + print("=" * 70) + print(""" +This demonstrates the generate_report() function which: +- Creates a structured markdown report +- Performs multiple searches per section +- Organizes findings into coherent sections +- Includes citations and references + """) + + # Configure settings for programmatic mode + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + "llm.temperature": 0.5, # Lower for more focused output + } + ) + + # Generate a comprehensive report + print( + "Generating report on 'Applications of Machine Learning in Healthcare'..." + ) + report = generate_report( + query="Applications of Machine Learning in Healthcare", + output_file="ml_healthcare_report.md", + searches_per_section=2, # Multiple searches per section for depth + settings_snapshot=settings, + iterations=2, + questions_per_iteration=3, + ) + + print("\n✓ Report generated successfully!") + print(f" - Report length: {len(report['content'])} characters") + print( + f" - File saved to: {report.get('file_path', 'ml_healthcare_report.md')}" + ) + + # Show first part of report + print("\nReport preview (first 500 chars):") + print("-" * 40) + print(report["content"][:500] + "...") + + return report + + +def demonstrate_export_formats(): + """ + Show how to export research results in different formats. + + Demonstrates: + - Markdown export (default) + - JSON export for programmatic processing + - Custom formatting with templates + """ + print("\n" + "=" * 70) + print("EXPORT FORMATS") + print("=" * 70) + print(""" +Exporting research in different formats: +- Markdown: Human-readable reports +- JSON: Structured data for processing +- Custom: Template-based formatting + """) + + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + } + ) + + # Get research results + result = detailed_research( + query="Renewable energy technologies", + settings_snapshot=settings, + iterations=1, + questions_per_iteration=2, + ) + + # Export as JSON + json_file = "research_results.json" + with open(json_file, "w", encoding="utf-8") as f: + json.dump(result, f, indent=2, default=str) + print(f"\n✓ JSON export saved to: {json_file}") + print(f" - Contains: {len(result.get('findings', []))} findings") + print(f" - Sources: {len(result.get('sources', []))} sources") + + # Export as Markdown + md_content = format_as_markdown(result) + md_file = "research_results.md" + with open(md_file, "w", encoding="utf-8") as f: + f.write(md_content) + print(f"\n✓ Markdown export saved to: {md_file}") + print(f" - Length: {len(md_content)} characters") + + # Export as custom format (e.g., BibTeX-like citations) + citations = extract_citations(result) + cite_file = "research_citations.txt" + with open(cite_file, "w", encoding="utf-8") as f: + for i, citation in enumerate(citations, 1): + f.write(f"[{i}] {citation}\n") + print(f"\n✓ Citations export saved to: {cite_file}") + print(f" - Total citations: {len(citations)}") + + return result + + +def demonstrate_result_analysis(): + """ + Analyze research results to extract insights and patterns. + + Shows how to: + - Extract key findings + - Identify recurring themes + - Analyze source reliability + - Generate statistics + """ + print("\n" + "=" * 70) + print("RESULT ANALYSIS") + print("=" * 70) + print(""" +Analyzing research results to extract: +- Key findings and insights +- Common themes and patterns +- Source statistics +- Quality metrics + """) + + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + } + ) + + # Perform research + result = detailed_research( + query="Impact of artificial intelligence on employment", + settings_snapshot=settings, + search_strategy="source-based", + iterations=2, + questions_per_iteration=3, + ) + + # Analyze findings + analysis = analyze_findings(result) + + print("\n📊 Research Analysis:") + print(f" - Total findings: {analysis['total_findings']}") + print(f" - Unique sources: {analysis['unique_sources']}") + print(f" - Questions explored: {analysis['total_questions']}") + print(f" - Iterations completed: {analysis['iterations']}") + + print("\n🔍 Finding Categories:") + for category, count in analysis["categories"].items(): + print(f" - {category}: {count} findings") + + print("\n📈 Source Distribution:") + for source_type, count in analysis["source_types"].items(): + print(f" - {source_type}: {count} sources") + + # Extract themes + themes = extract_themes(result) + print("\n🎯 Key Themes Identified:") + for i, theme in enumerate(themes[:5], 1): + print(f" {i}. {theme}") + + return analysis + + +def demonstrate_keyword_extraction(): + """ + Extract keywords and key concepts from research results. + + Demonstrates: + - Keyword extraction from findings + - Concept identification + - Topic clustering + - Trend analysis + """ + print("\n" + "=" * 70) + print("KEYWORD & CONCEPT EXTRACTION") + print("=" * 70) + print(""" +Extracting keywords and concepts: +- Important terms and phrases +- Technical concepts +- Named entities +- Trend indicators + """) + + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + } + ) + + # Quick research for keyword extraction + result = quick_summary( + query="Quantum computing breakthroughs 2024", + settings_snapshot=settings, + iterations=1, + questions_per_iteration=3, + ) + + # Extract keywords + keywords = extract_keywords(result) + + print("\n🔑 Top Keywords:") + for keyword, frequency in keywords[:10]: + print(f" - {keyword}: {frequency} occurrences") + + # Extract concepts + concepts = extract_concepts(result) + + print("\n💡 Key Concepts:") + for i, concept in enumerate(concepts[:5], 1): + print(f" {i}. {concept}") + + # Identify technical terms + technical_terms = extract_technical_terms(result) + + print("\n🔬 Technical Terms:") + for term in technical_terms[:8]: + print(f" - {term}") + + return keywords, concepts + + +def format_as_markdown(result: Dict[str, Any]) -> str: + """Convert research results to markdown format.""" + md = f"# Research Report: {result['query']}\n\n" + md += f"**Research ID:** {result.get('research_id', 'N/A')}\n\n" + + # Summary + md += "## Summary\n\n" + md += result.get("summary", "No summary available") + "\n\n" + + # Findings + md += "## Key Findings\n\n" + findings = result.get("findings", []) + for i, finding in enumerate(findings, 1): + finding_text = finding if isinstance(finding, str) else str(finding) + md += f"{i}. {finding_text}\n\n" + + # Sources + md += "## Sources\n\n" + sources = result.get("sources", []) + for i, source in enumerate(sources, 1): + source_text = source if isinstance(source, str) else str(source) + md += f"- [{i}] {source_text}\n" + + # Metadata + md += "\n## Metadata\n\n" + metadata = result.get("metadata", {}) + for key, value in metadata.items(): + md += f"- **{key}:** {value}\n" + + return md + + +def extract_citations(result: Dict[str, Any]) -> List[str]: + """Extract citations from research results.""" + citations = [] + sources = result.get("sources", []) + + for source in sources: + if isinstance(source, dict): + # Extract URL or title + citation = source.get("url", source.get("title", str(source))) + else: + citation = str(source) + citations.append(citation) + + return citations + + +def analyze_findings(result: Dict[str, Any]) -> Dict[str, Any]: + """Analyze research findings for patterns and statistics.""" + findings = result.get("findings", []) + sources = result.get("sources", []) + questions = result.get("questions", {}) + + # Categorize findings (simplified) + categories = { + "positive": 0, + "negative": 0, + "neutral": 0, + "technical": 0, + } + + for finding in findings: + finding_text = str(finding).lower() + if any( + word in finding_text + for word in ["benefit", "improve", "enhance", "positive"] + ): + categories["positive"] += 1 + elif any( + word in finding_text + for word in ["risk", "challenge", "negative", "concern"] + ): + categories["negative"] += 1 + elif any( + word in finding_text + for word in ["algorithm", "system", "technology", "method"] + ): + categories["technical"] += 1 + else: + categories["neutral"] += 1 + + # Analyze sources + source_types = {} + for source in sources: + source_text = str(source).lower() + if "wikipedia" in source_text: + source_type = "Wikipedia" + elif "arxiv" in source_text: + source_type = "ArXiv" + elif "github" in source_text: + source_type = "GitHub" + else: + source_type = "Other" + source_types[source_type] = source_types.get(source_type, 0) + 1 + + return { + "total_findings": len(findings), + "unique_sources": len(sources), + "total_questions": sum(len(qs) for qs in questions.values()), + "iterations": result.get("iterations", 0), + "categories": categories, + "source_types": source_types, + } + + +def extract_themes(result: Dict[str, Any]) -> List[str]: + """Extract main themes from research results.""" + # Simplified theme extraction based on common patterns + themes = [] + summary = result.get("summary", "") + findings = result.get("findings", []) + + # Combine text for analysis + full_text = summary + " ".join(str(f) for f in findings) + + # Simple theme patterns (in production, use NLP libraries) + theme_patterns = { + "automation": ["automation", "automated", "automatic"], + "job displacement": ["job loss", "unemployment", "displacement"], + "skill requirements": ["skills", "training", "education"], + "economic impact": ["economy", "economic", "gdp", "growth"], + "innovation": ["innovation", "innovative", "breakthrough"], + } + + for theme, keywords in theme_patterns.items(): + if any(keyword in full_text.lower() for keyword in keywords): + themes.append(theme.title()) + + return themes + + +def extract_keywords(result: Dict[str, Any]) -> List[tuple]: + """Extract keywords with frequency from research results.""" + from collections import Counter + import re + + # Combine all text + summary = result.get("summary", "") + findings = " ".join(str(f) for f in result.get("findings", [])) + full_text = f"{summary} {findings}".lower() + + # Simple word extraction (in production, use NLP libraries) + words = re.findall(r"\b[a-z]{4,}\b", full_text) + + # Filter common words + stopwords = { + "that", + "this", + "with", + "from", + "have", + "been", + "were", + "which", + "their", + "about", + } + words = [w for w in words if w not in stopwords] + + # Count frequencies + word_freq = Counter(words) + + return word_freq.most_common(20) + + +def extract_concepts(result: Dict[str, Any]) -> List[str]: + """Extract key concepts from research results.""" + concepts = [] + summary = result.get("summary", "") + + # Simple concept patterns (in production, use NLP for entity extraction) + concept_patterns = [ + r"quantum \w+", + r"\w+ computing", + r"\w+ algorithm", + r"machine learning", + r"artificial intelligence", + r"\w+ technology", + ] + + import re + + for pattern in concept_patterns: + matches = re.findall(pattern, summary.lower()) + concepts.extend(matches) + + # Deduplicate and clean + concepts = list(set(concepts)) + + return concepts[:10] + + +def extract_technical_terms(result: Dict[str, Any]) -> List[str]: + """Extract technical terms from research results.""" + technical_terms = [] + + # Common technical term patterns + tech_indicators = [ + "algorithm", + "system", + "protocol", + "framework", + "architecture", + "quantum", + "neural", + "network", + "model", + "optimization", + ] + + summary = result.get("summary", "").lower() + import re + + for indicator in tech_indicators: + # Find words containing or adjacent to technical indicators + pattern = rf"\b\w*{indicator}\w*\b" + matches = re.findall(pattern, summary) + technical_terms.extend(matches) + + # Deduplicate + technical_terms = list(set(technical_terms)) + + return technical_terms + + +def demonstrate_batch_research(): + """ + Show how to perform batch research on multiple topics. + + Useful for: + - Comparative analysis + - Trend monitoring + - Systematic reviews + """ + print("\n" + "=" * 70) + print("BATCH RESEARCH PROCESSING") + print("=" * 70) + print(""" +Processing multiple research queries: +- Efficient batch processing +- Comparative analysis +- Result aggregation + """) + + settings = create_settings_snapshot( + overrides={ + "programmatic_mode": True, + "search.tool": "wikipedia", + } + ) + + # Topics for batch research + topics = [ + "Solar energy innovations", + "Wind power technology", + "Hydrogen fuel cells", + ] + + batch_results = {} + + print("\n📚 Batch Research:") + for topic in topics: + print(f"\n Researching: {topic}") + result = quick_summary( + query=topic, + settings_snapshot=settings, + iterations=1, + questions_per_iteration=2, + ) + batch_results[topic] = result + print(f" ✓ Found {len(result.get('findings', []))} findings") + + # Aggregate results + print("\n📊 Aggregate Analysis:") + total_findings = sum( + len(r.get("findings", [])) for r in batch_results.values() + ) + total_sources = sum( + len(r.get("sources", [])) for r in batch_results.values() + ) + + print(f" - Total topics researched: {len(topics)}") + print(f" - Total findings: {total_findings}") + print(f" - Total sources: {total_sources}") + print(f" - Average findings per topic: {total_findings / len(topics):.1f}") + + # Save batch results + batch_file = "batch_research_results.json" + with open(batch_file, "w", encoding="utf-8") as f: + json.dump(batch_results, f, indent=2, default=str) + print(f"\n✓ Batch results saved to: {batch_file}") + + return batch_results + + +def main(): + """Run all advanced feature demonstrations.""" + print("=" * 70) + print("LOCAL DEEP RESEARCH - ADVANCED FEATURES DEMONSTRATION") + print("=" * 70) + print(""" +This example demonstrates advanced programmatic features: +1. Report generation with generate_report() +2. Multiple export formats +3. Result analysis and insights +4. Keyword and concept extraction +5. Batch research processing + """) + + # Run demonstrations + demonstrate_report_generation() + demonstrate_export_formats() + demonstrate_result_analysis() + demonstrate_keyword_extraction() + demonstrate_batch_research() + + print("\n" + "=" * 70) + print("DEMONSTRATION COMPLETE") + print("=" * 70) + print(""" +✓ All advanced features demonstrated successfully! + +Key Takeaways: +1. generate_report() creates comprehensive markdown reports +2. Results can be exported in multiple formats (JSON, MD, custom) +3. Analysis tools extract insights, themes, and patterns +4. Keyword extraction identifies important terms and concepts +5. Batch processing enables systematic research + +Files created: +- ml_healthcare_report.md - Full research report +- research_results.json - Structured research data +- research_results.md - Markdown formatted results +- research_citations.txt - Extracted citations +- batch_research_results.json - Batch research results + +Next Steps: +- Customize report templates for your domain +- Integrate with data visualization tools +- Build automated research pipelines +- Create domain-specific analysis functions + """) + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/custom_llm_retriever_example.py+207 −0 added@@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Example of using a custom LLM with a custom retriever in Local Deep Research. + +This demonstrates how to integrate your own LLM implementation and custom +retrieval system for programmatic access. +""" + +from typing import List, Dict +from langchain_ollama import ChatOllama +from langchain.schema import Document +from langchain_community.vectorstores import FAISS +from langchain_community.embeddings import OllamaEmbeddings + +# Import the search system +from local_deep_research.search_system import AdvancedSearchSystem + +# Re-enable logging after import +from loguru import logger +import sys + +logger.remove() +logger.add(sys.stderr, level="INFO", format="{time} {level} {message}") +logger.enable("local_deep_research") + + +class CustomRetriever: + """Custom retriever that can fetch from multiple sources.""" + + def __init__(self): + # Initialize with sample documents for demonstration + self.documents = [ + { + "content": "Quantum computing uses quantum bits (qubits) that can exist in superposition, " + "allowing parallel computation of multiple states simultaneously.", + "title": "Quantum Computing Fundamentals", + "source": "quantum_basics.pdf", + "metadata": {"topic": "quantum", "year": 2024}, + }, + { + "content": "Machine learning algorithms can be categorized into supervised, unsupervised, " + "and reinforcement learning approaches, each suited for different tasks.", + "title": "ML Algorithm Categories", + "source": "ml_overview.pdf", + "metadata": {"topic": "ml", "year": 2024}, + }, + { + "content": "Neural networks are inspired by biological neurons and consist of interconnected " + "nodes that process information through weighted connections.", + "title": "Neural Network Architecture", + "source": "nn_architecture.pdf", + "metadata": {"topic": "neural_networks", "year": 2023}, + }, + { + "content": "Natural language processing enables computers to understand, interpret, and " + "generate human language, powering applications like chatbots and translation.", + "title": "NLP Applications", + "source": "nlp_apps.pdf", + "metadata": {"topic": "nlp", "year": 2024}, + }, + ] + + # Create embeddings for similarity search + logger.info("Initializing custom retriever with embeddings...") + self.embeddings = OllamaEmbeddings(model="nomic-embed-text") + + # Create vector store from documents + docs = [ + Document( + page_content=doc["content"], + metadata={ + "title": doc["title"], + "source": doc["source"], + **doc["metadata"], + }, + ) + for doc in self.documents + ] + self.vectorstore = FAISS.from_documents(docs, self.embeddings) + + def retrieve(self, query: str, k: int = 3) -> List[Dict]: + """Custom retrieval logic.""" + logger.info(f"Custom Retriever: Searching for '{query}'") + + # Use vector similarity search + similar_docs = self.vectorstore.similarity_search(query, k=k) + + # Convert to expected format + results = [] + for i, doc in enumerate(similar_docs): + results.append( + { + "title": doc.metadata.get("title", f"Document {i + 1}"), + "link": doc.metadata.get("source", "custom_source"), + "snippet": doc.page_content[:150] + "...", + "full_content": doc.page_content, + "rank": i + 1, + "metadata": doc.metadata, + } + ) + + logger.info( + f"Custom Retriever: Found {len(results)} relevant documents" + ) + return results + + +class CustomSearchEngine: + """Adapter to integrate custom retriever with the search system.""" + + def __init__(self, retriever: CustomRetriever, settings_snapshot=None): + self.retriever = retriever + self.settings_snapshot = settings_snapshot or {} + + def run(self, query: str, research_context=None) -> List[Dict]: + """Execute search using custom retriever.""" + return self.retriever.retrieve(query, k=5) + + +def main(): + """Demonstrate custom LLM and retriever integration.""" + print("=== Custom LLM and Retriever Example ===\n") + + # 1. Create custom LLM (just using regular Ollama for simplicity) + print("1. Initializing LLM...") + llm = ChatOllama(model="gemma3:12b", temperature=0.7) + + # 2. Create custom retriever + print("2. Setting up custom retriever...") + custom_retriever = CustomRetriever() + + # 3. Create settings + settings = { + "search.iterations": 2, + "search.questions_per_iteration": 3, + "search.strategy": "source-based", + "rate_limiting.enabled": False, # Disable rate limiting for custom setup + } + + # 4. Create search engine adapter + print("3. Creating search engine adapter...") + search_engine = CustomSearchEngine(custom_retriever, settings) + + # 5. Initialize the search system + print("4. Initializing AdvancedSearchSystem with custom components...") + # Pass programmatic_mode=True to avoid database dependencies + search_system = AdvancedSearchSystem( + llm=llm, + search=search_engine, + settings_snapshot=settings, + programmatic_mode=True, + ) + + # 6. Run research queries + queries = [ + "How do quantum computers differ from classical computers?", + "What are the main types of machine learning algorithms?", + ] + + for query in queries: + print(f"\n{'=' * 60}") + print(f"Research Query: {query}") + print("=" * 60) + + result = search_system.analyze_topic(query) + + # Display results + print("\n=== FINDINGS ===") + print(result["formatted_findings"]) + + # Show metadata + print("\n=== SEARCH METADATA ===") + print(f"• Total findings: {len(result['findings'])}") + print(f"• Iterations: {result['iterations']}") + + # Get actual sources from all_links_of_system or search_results + all_links = result.get("all_links_of_system", []) + for finding in result.get("findings", []): + if "search_results" in finding and finding["search_results"]: + all_links = finding["search_results"] + break + + print(f"• Sources found: {len(all_links)}") + if all_links and len(all_links) > 0: + print("\n=== SOURCES ===") + for i, link in enumerate(all_links[:5], 1): # Show first 5 + if isinstance(link, dict): + title = link.get("title", "No title") + url = link.get("link", link.get("source", "Unknown")) + print(f" [{i}] {title}") + print(f" URL: {url}") + + # Show generated questions + if result.get("questions_by_iteration"): + print("\n=== RESEARCH QUESTIONS GENERATED ===") + for iteration, questions in result[ + "questions_by_iteration" + ].items(): + print(f"\nIteration {iteration}:") + for q in questions[:3]: # Show first 3 questions + print(f" • {q}") + + print("\n✓ Custom LLM and Retriever integration successful!") + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/hybrid_search_example.py+403 −0 added@@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +Hybrid Search Example for Local Deep Research + +This example demonstrates how to combine multiple search sources: +1. Multiple named retrievers for different document types +2. Combining custom retrievers with web search +3. Analyzing and comparing sources from different origins +""" + +from typing import List +from langchain.schema import Document, BaseRetriever +from langchain_community.vectorstores import FAISS +from langchain_community.embeddings import OllamaEmbeddings + +from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api.settings_utils import create_settings_snapshot + + +class TechnicalDocsRetriever(BaseRetriever): + """Mock retriever for technical documentation.""" + + def get_relevant_documents(self, query: str) -> List[Document]: + """Return mock technical documents.""" + # In a real scenario, this would search actual technical docs + return [ + Document( + page_content=f"Technical specification for {query}: Implementation requires careful consideration of system architecture, performance metrics, and scalability factors.", + metadata={ + "source": "tech_docs", + "type": "specification", + "title": f"Technical Spec: {query}", + }, + ), + Document( + page_content=f"Best practices for {query}: Follow industry standards, implement proper error handling, and ensure comprehensive testing coverage.", + metadata={ + "source": "tech_docs", + "type": "best_practices", + "title": f"Best Practices: {query}", + }, + ), + ] + + async def aget_relevant_documents(self, query: str) -> List[Document]: + """Async version.""" + return self.get_relevant_documents(query) + + +class BusinessDocsRetriever(BaseRetriever): + """Mock retriever for business/strategy documents.""" + + def get_relevant_documents(self, query: str) -> List[Document]: + """Return mock business documents.""" + return [ + Document( + page_content=f"Business implications of {query}: Consider market impact, ROI analysis, and strategic alignment with organizational goals.", + metadata={ + "source": "business_docs", + "type": "strategy", + "title": f"Business Strategy: {query}", + }, + ), + Document( + page_content=f"Cost-benefit analysis for {query}: Initial investment requirements, expected returns, and risk assessment factors.", + metadata={ + "source": "business_docs", + "type": "analysis", + "title": f"Cost Analysis: {query}", + }, + ), + ] + + async def aget_relevant_documents(self, query: str) -> List[Document]: + """Async version.""" + return self.get_relevant_documents(query) + + +def create_knowledge_base_retriever() -> BaseRetriever: + """Create a FAISS-based retriever with sample knowledge base documents.""" + documents = [ + Document( + page_content="Machine learning models require training data, validation strategies, and performance metrics for evaluation.", + metadata={"source": "ml_knowledge_base", "topic": "ml_basics"}, + ), + Document( + page_content="Cloud computing provides scalable infrastructure, reducing capital expenditure and enabling flexible resource allocation.", + metadata={ + "source": "cloud_knowledge_base", + "topic": "cloud_benefits", + }, + ), + Document( + page_content="Agile methodology emphasizes iterative development, customer collaboration, and responding to change.", + metadata={"source": "project_knowledge_base", "topic": "agile"}, + ), + Document( + page_content="Data privacy regulations like GDPR require explicit consent, data minimization, and user rights management.", + metadata={ + "source": "compliance_knowledge_base", + "topic": "privacy", + }, + ), + ] + + # Create embeddings and vector store + embeddings = OllamaEmbeddings(model="nomic-embed-text") + vectorstore = FAISS.from_documents(documents, embeddings) + return vectorstore.as_retriever(search_kwargs={"k": 2}) + + +def demonstrate_multiple_retrievers(): + """Show how to use multiple named retrievers for different document types.""" + print("=" * 70) + print("MULTIPLE NAMED RETRIEVERS") + print("=" * 70) + print(""" +Using multiple specialized retrievers: +- Technical documentation retriever +- Business documentation retriever +- Knowledge base retriever +Each provides different perspectives on the same topic. + """) + + # Create different retrievers + tech_retriever = TechnicalDocsRetriever() + business_retriever = BusinessDocsRetriever() + kb_retriever = create_knowledge_base_retriever() + + # Configure settings + settings = create_settings_snapshot( + { + "search.tool": "auto", # Will use all provided retrievers + } + ) + + # Use multiple retrievers in research + result = quick_summary( + query="Implementing machine learning in production", + settings_snapshot=settings, + retrievers={ + "technical": tech_retriever, + "business": business_retriever, + "knowledge_base": kb_retriever, + }, + search_tool="auto", # Use all retrievers + iterations=2, + questions_per_iteration=2, + programmatic_mode=True, + ) + + print("\nResearch Summary (first 400 chars):") + print(result["summary"][:400] + "...") + + # Analyze sources by type + sources = result.get("sources", []) + print(f"\nTotal sources found: {len(sources)}") + + # Group sources by retriever + source_types = {} + for source in sources: + if isinstance(source, dict): + source_type = source.get("metadata", {}).get("source", "unknown") + else: + source_type = "other" + source_types[source_type] = source_types.get(source_type, 0) + 1 + + print("\nSources by retriever:") + for stype, count in source_types.items(): + print(f" - {stype}: {count} sources") + + return result + + +def demonstrate_retriever_plus_web(): + """Show how to combine custom retrievers with web search.""" + print("\n" + "=" * 70) + print("RETRIEVER + WEB SEARCH COMBINATION") + print("=" * 70) + print(""" +Combining internal knowledge with web search: +- Internal: Custom retriever with proprietary knowledge +- External: Wikipedia for general context +This provides both specific and general information. + """) + + # Create internal knowledge retriever + internal_retriever = create_knowledge_base_retriever() + + # Configure settings to use both retriever and web + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", # Also use Wikipedia + } + ) + + # Research combining internal and external sources + result = detailed_research( + query="Best practices for cloud migration", + settings_snapshot=settings, + retrievers={ + "internal_kb": internal_retriever, + }, + search_tool="wikipedia", # Also search Wikipedia + search_strategy="source-based", + iterations=2, + questions_per_iteration=3, + programmatic_mode=True, + ) + + print(f"\nResearch ID: {result['research_id']}") + print(f"Summary length: {len(result['summary'])} characters") + + # Analyze source distribution + sources = result.get("sources", []) + internal_sources = 0 + external_sources = 0 + + for source in sources: + if isinstance(source, dict) and "knowledge_base" in str(source): + internal_sources += 1 + else: + external_sources += 1 + + print("\nSource distribution:") + print(f" - Internal knowledge base: {internal_sources} sources") + print(f" - External (Wikipedia): {external_sources} sources") + + # Show how different sources complement each other + print("\nComplementary insights from hybrid search:") + print( + " - Internal sources provide: Specific procedures, proprietary knowledge" + ) + print( + " - External sources provide: Industry context, general best practices" + ) + + return result + + +def demonstrate_source_analysis(): + """Show how to analyze and compare sources from different origins.""" + print("\n" + "=" * 70) + print("SOURCE ANALYSIS AND COMPARISON") + print("=" * 70) + print(""" +Analyzing source quality and relevance: +- Track source origins +- Compare information consistency +- Identify unique insights from each source type + """) + + # Create multiple retrievers + tech_retriever = TechnicalDocsRetriever() + business_retriever = BusinessDocsRetriever() + + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", + } + ) + + # Run research with detailed source tracking + result = quick_summary( + query="Artificial intelligence implementation strategies", + settings_snapshot=settings, + retrievers={ + "technical": tech_retriever, + "business": business_retriever, + }, + search_tool="wikipedia", # Also use web search + iterations=2, + questions_per_iteration=2, + programmatic_mode=True, + ) + + # Detailed source analysis + print("\nSource Analysis:") + sources = result.get("sources", []) + + # Categorize sources + source_categories = {"technical": [], "business": [], "web": []} + + for source in sources: + if isinstance(source, dict): + source_type = source.get("metadata", {}).get("source", "") + if "tech" in source_type: + source_categories["technical"].append(source) + elif "business" in source_type: + source_categories["business"].append(source) + else: + source_categories["web"].append(source) + else: + source_categories["web"].append(source) + + # Report on each category + for category, category_sources in source_categories.items(): + print(f"\n{category.upper()} Sources ({len(category_sources)}):") + if category_sources: + for i, source in enumerate(category_sources[:2], 1): # Show first 2 + if isinstance(source, dict): + title = source.get("metadata", {}).get("title", "Untitled") + print(f" {i}. {title}") + else: + print(f" {i}. {str(source)[:60]}...") + + # Show findings breakdown + findings = result.get("findings", []) + print(f"\nTotal findings: {len(findings)}") + print("Findings provide integrated insights from all source types") + + return result + + +def demonstrate_meta_search_config(): + """Show how to use meta search configuration for complex setups.""" + print("\n" + "=" * 70) + print("META SEARCH CONFIGURATION") + print("=" * 70) + print(""" +Using meta search for sophisticated search strategies: +- Combine multiple search engines +- Configure aggregation and deduplication +- Control search priority and weighting + """) + + # Create retrievers + tech_retriever = TechnicalDocsRetriever() + + settings = create_settings_snapshot({}) + + # Advanced meta search configuration + result = quick_summary( + query="Quantum computing applications", + settings_snapshot=settings, + retrievers={ + "tech_docs": tech_retriever, + }, + search_tool="meta", # Use meta search + meta_search_config={ + "retrievers": ["tech_docs"], # Include custom retriever + "engines": ["wikipedia", "arxiv"], # Also search these + "aggregate": True, # Combine results + "deduplicate": True, # Remove duplicate content + "max_results_per_engine": 5, # Limit per source + }, + iterations=2, + questions_per_iteration=3, + programmatic_mode=True, + ) + + print("\nMeta search results:") + print(f" - Total sources: {len(result.get('sources', []))}") + print(f" - Summary length: {len(result.get('summary', ''))} chars") + print(f" - Findings: {len(result.get('findings', []))}") + + print("\nMeta search advantages:") + print(" - Comprehensive coverage from multiple sources") + print(" - Automatic deduplication of similar content") + print(" - Balanced perspective from different source types") + + return result + + +def main(): + """Run all hybrid search demonstrations.""" + print("=" * 70) + print("LOCAL DEEP RESEARCH - HYBRID SEARCH DEMONSTRATION") + print("=" * 70) + print(""" +This example shows how to combine multiple search sources: +- Custom retrievers for proprietary knowledge +- Web search engines for public information +- Meta search for sophisticated strategies + """) + + # Run demonstrations + demonstrate_multiple_retrievers() + demonstrate_retriever_plus_web() + demonstrate_source_analysis() + demonstrate_meta_search_config() + + print("\n" + "=" * 70) + print("KEY TAKEAWAYS") + print("=" * 70) + print(""" +1. Multiple Retrievers: Use specialized retrievers for different document types +2. Hybrid Search: Combine internal knowledge with web search for comprehensive results +3. Source Analysis: Track and analyze sources to understand information origin +4. Meta Search: Configure complex search strategies with aggregation and deduplication + +Best Practices: +- Name your retrievers descriptively for easy tracking +- Balance internal and external sources based on your needs +- Use source analysis to verify information consistency +- Configure meta search for optimal result aggregation + """) + + print("\n✓ Hybrid search demonstration complete!") + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/minimal_working_example.py+88 −0 added@@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Minimal working example for programmatic access to Local Deep Research. + +This shows how to use the core functionality without database dependencies. +""" + +from langchain_ollama import ChatOllama +from local_deep_research.search_system import AdvancedSearchSystem + +# Re-enable logging after import (it gets disabled in __init__.py) +from loguru import logger +import sys + +logger.remove() +logger.add(sys.stderr, level="WARNING", format="{time} {level} {message}") +logger.enable("local_deep_research") + + +class MinimalSearchEngine: + """Minimal search engine that returns hardcoded results.""" + + def __init__(self, settings_snapshot=None): + self.settings_snapshot = settings_snapshot or {} + + def run(self, query, research_context=None): + """Return some fake search results.""" + return [ + { + "title": "Introduction to AI", + "link": "https://example.com/ai-intro", + "snippet": "Artificial Intelligence (AI) is the simulation of human intelligence...", + "full_content": "Full article about AI basics...", + "rank": 1, + }, + { + "title": "Machine Learning Explained", + "link": "https://example.com/ml-explained", + "snippet": "Machine learning is a subset of AI that enables systems to learn...", + "full_content": "Detailed explanation of machine learning...", + "rank": 2, + }, + ] + + +def main(): + """Minimal example of programmatic access.""" + print("=== Minimal Local Deep Research Example ===\n") + + # 1. Create LLM + print("1. Creating Ollama LLM...") + llm = ChatOllama(model="gemma3:12b") + + # 2. Create minimal search engine + print("2. Creating minimal search engine...") + + # Settings for search system (without programmatic_mode) + settings = { + "search.iterations": 1, + "search.strategy": "direct", + } + + search = MinimalSearchEngine(settings) + + # 3. Create search system + print("3. Creating AdvancedSearchSystem...") + # IMPORTANT: Pass programmatic_mode=True to avoid database dependencies + system = AdvancedSearchSystem( + llm=llm, + search=search, + settings_snapshot=settings, + programmatic_mode=True, + ) + + # 4. Run a search + print("\n4. Running search...") + result = system.analyze_topic("What is artificial intelligence?") + + # 5. Show results + print("\n=== RESULTS ===") + print(f"Found {len(result['findings'])} findings") + print(f"\nSummary:\n{result['current_knowledge']}") + + print("\n✓ Success! Programmatic access works without database.") + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/programmatic_access.ipynb+0 −583 removedexamples/api_usage/programmatic/README.md+238 −0 added@@ -0,0 +1,238 @@ +# Local Deep Research - Programmatic API Examples + +This directory contains examples demonstrating how to use Local Deep Research programmatically without requiring authentication or database access. + +## Quick Start + +All examples use the programmatic API that bypasses authentication: + +```python +from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api.settings_utils import create_settings_snapshot + +# Create settings for programmatic mode +settings = create_settings_snapshot({ + "search.tool": "wikipedia" +}) + +# Run research +result = quick_summary( + "your topic", + settings_snapshot=settings, + programmatic_mode=True +) +``` + +## Examples Overview + +| Example | Purpose | Key Features | Difficulty | +|---------|---------|--------------|------------| +| **minimal_working_example.py** | Simplest possible example | Basic setup, minimal code | Beginner | +| **simple_programmatic_example.py** | Common use cases with the new API | quick_summary, detailed_research, generate_report, custom parameters | Beginner | +| **search_strategies_example.py** | Demonstrates search strategies | source-based vs focused-iteration strategies | Intermediate | +| **hybrid_search_example.py** | Combine multiple search sources | Multiple retrievers, web + retriever combo | Intermediate | +| **advanced_features_example.py** | Advanced programmatic features | generate_report, export formats, result analysis, keyword extraction | Advanced | +| **custom_llm_retriever_example.py** | Custom LLM and retriever integration | Ollama, custom retrievers, FAISS | Advanced | +| **searxng_example.py** | Web search with SearXNG | SearXNG integration, error handling | Advanced | + +## Example Details + +### minimal_working_example.py +**Purpose:** Show the absolute minimum code needed to use LDR programmatically. +- Creates a simple LLM and search engine +- Runs a basic search +- No external dependencies beyond Ollama + +### simple_programmatic_example.py +**Purpose:** Demonstrate the main API functions with practical examples. +- `quick_summary()` - Fast research with summary +- `detailed_research()` - Comprehensive research with findings +- `generate_report()` - Create full markdown reports +- Custom search parameters +- Different search tools (Wikipedia, auto, etc.) + +### search_strategies_example.py +**Purpose:** Explain and demonstrate the two main search strategies. +- **source-based**: Comprehensive research with detailed citations +- **focused-iteration**: Iterative refinement of research questions +- Side-by-side comparison of strategies +- When to use each strategy + +### hybrid_search_example.py +**Purpose:** Show how to combine multiple search sources for comprehensive research. +- Multiple named retrievers for different document types +- Combining custom retrievers with web search +- Source analysis and tracking +- Meta search configuration + +### advanced_features_example.py +**Purpose:** Demonstrate advanced programmatic features and analysis capabilities. +- `generate_report()` - Create comprehensive markdown reports +- Export formats - JSON, Markdown, custom formats +- Result analysis - Extract insights and patterns +- Keyword extraction - Identify key terms and concepts +- Batch research - Process multiple queries efficiently + +### custom_llm_retriever_example.py +**Purpose:** Advanced integration with custom components. +- Custom LLM implementation (using Ollama) +- Custom retriever with embeddings +- Vector store integration (FAISS) +- Direct use of AdvancedSearchSystem + +### searxng_example.py +**Purpose:** Web search integration using SearXNG. +- SearXNG configuration +- Error handling and fallbacks +- Real-time web search +- Direct use of search engines + +## Key Concepts + +### Programmatic Mode +All examples use `programmatic_mode=True` as an explicit parameter to bypass authentication: +```python +result = quick_summary( + query="your topic", + settings_snapshot=settings, + programmatic_mode=True +) +``` + +### Search Strategies +- **source-based**: Best for academic research, fact-checking +- **focused-iteration**: Best for exploratory research, complex topics + +### Search Tools +Available search tools include: +- `wikipedia` - Wikipedia search +- `arxiv` - Academic papers +- `searxng` - Web search via SearXNG +- `auto` - Automatically select best tool +- `meta` - Combine multiple tools + +### Custom Retrievers +You can provide your own retrievers: +```python +result = quick_summary( + query="topic", + retrievers={"my_docs": custom_retriever}, + search_tool="my_docs", + settings_snapshot=settings, + programmatic_mode=True +) +``` + +## API Functions + +### `quick_summary()` +Generate a quick research summary: +```python +from local_deep_research.api import quick_summary +from local_deep_research.api.settings_utils import create_settings_snapshot + +settings = create_settings_snapshot({}) +result = quick_summary( + query="Your research question", + settings_snapshot=settings, + search_tool="wikipedia", + iterations=2, + programmatic_mode=True +) +``` + +### `detailed_research()` +Perform in-depth research with multiple iterations: +```python +from local_deep_research.api import detailed_research + +result = detailed_research( + query="Your research question", + settings_snapshot=settings, + search_strategy="source-based", + iterations=3, + questions_per_iteration=5, + programmatic_mode=True +) +``` + +### `generate_report()` +Generate comprehensive markdown reports with structured sections: +```python +from local_deep_research.api import generate_report +from local_deep_research.api.settings_utils import create_settings_snapshot + +settings = create_settings_snapshot(overrides={"programmatic_mode": True}) +result = generate_report( + query="Your research question", + settings_snapshot=settings, + output_file="report.md", + searches_per_section=3 +) +``` + +## Requirements + +- Python 3.8+ +- Local Deep Research installed +- Ollama (for most examples) +- SearXNG instance (for searxng_example.py) + +## Running the Examples + +1. Install Local Deep Research: + ```bash + pip install -e . + ``` + +2. Start Ollama (if using Ollama examples): + ```bash + ollama serve + ollama pull gemma3:12b + ollama pull nomic-embed-text # For embeddings + ``` + +3. Run any example: + ```bash + python minimal_working_example.py + python simple_programmatic_example.py + python search_strategies_example.py + ``` + +## Troubleshooting + +### "No settings context available" Error +Make sure to pass `settings_snapshot` and `programmatic_mode` to all API functions: +```python +settings = create_settings_snapshot({}) +result = quick_summary( + "topic", + settings_snapshot=settings, + programmatic_mode=True +) +``` + +### Ollama Connection Error +Ensure Ollama is running: +```bash +ollama serve +``` + +### SearXNG Connection Error +Start a SearXNG instance or use the fallback in the example: +```bash +docker run -p 8080:8080 searxng/searxng +``` + + +## Contributing + +When adding new examples: +1. Focus on demonstrating specific features +2. Include clear comments explaining the code +3. Handle errors gracefully +4. Update this README with the new example + +## License + +See the main project LICENSE file.
examples/api_usage/programmatic/retriever_usage_example.py+0 −199 removed@@ -1,199 +0,0 @@ -""" -Example of using LangChain retrievers with LDR. - -This example shows how to use any LangChain retriever as a search engine in LDR. -""" - -from typing import List -from langchain.schema import Document, BaseRetriever -from langchain.vectorstores import FAISS -from langchain.embeddings import OpenAIEmbeddings - -# Import LDR functions -from local_deep_research.api.research_functions import ( - quick_summary, - detailed_research, -) - - -# Example 1: Simple mock retriever for testing -class MockRetriever(BaseRetriever): - """Mock retriever for demonstration.""" - - def get_relevant_documents(self, query: str) -> List[Document]: - """Return mock documents.""" - return [ - Document( - page_content=f"This is a mock document about {query}. It contains relevant information.", - metadata={ - "title": f"Document about {query}", - "source": "mock_db", - }, - ), - Document( - page_content=f"Another document discussing {query} in detail.", - metadata={ - "title": f"Detailed analysis of {query}", - "source": "mock_db", - }, - ), - ] - - async def aget_relevant_documents(self, query: str) -> List[Document]: - """Async version.""" - return self.get_relevant_documents(query) - - -def example_single_retriever(): - """Example using a single retriever.""" - print("=== Example 1: Single Retriever ===") - - # Create a mock retriever - retriever = MockRetriever() - - # Use it with LDR - result = quick_summary( - query="What are the best practices for ML deployment?", - retrievers={"mock_kb": retriever}, - search_tool="mock_kb", # Use only this retriever - iterations=2, - questions_per_iteration=3, - ) - - print(f"Summary: {result['summary'][:200]}...") - print(f"Sources: {len(result.get('sources', []))} sources found") - - -def example_multiple_retrievers(): - """Example using multiple retrievers.""" - print("\n=== Example 2: Multiple Retrievers ===") - - # Create multiple mock retrievers - tech_retriever = MockRetriever() - business_retriever = MockRetriever() - - # Use them with LDR - result = detailed_research( - query="What are the business and technical implications of ML deployment?", - retrievers={ - "tech_docs": tech_retriever, - "business_docs": business_retriever, - }, - search_tool="auto", # Use all retrievers - iterations=3, - ) - - print(f"Research ID: {result['research_id']}") - print(f"Summary: {result['summary'][:200]}...") - print(f"Findings: {len(result['findings'])} findings") - - -def example_hybrid_search(): - """Example mixing retrievers with web search.""" - print("\n=== Example 3: Hybrid Search (Retriever + Web) ===") - - # Create retriever - retriever = MockRetriever() - - # Use retriever + web search - result = quick_summary( - query="Compare our internal ML practices with industry standards", - retrievers={"internal_kb": retriever}, - search_tool="auto", # Will use both retriever and web search - search_engines=[ - "internal_kb", - "wikipedia", - "searxng", - ], # Specify which to use - iterations=3, - ) - - print(f"Summary: {result['summary'][:200]}...") - - -def example_real_vector_store(): - """Example with a real vector store (requires OpenAI API key).""" - print("\n=== Example 4: Real Vector Store ===") - print("This example requires OpenAI API key to be set") - - try: - # Create embeddings and vector store - embeddings = OpenAIEmbeddings() - - # Create some sample documents - texts = [ - "Machine learning deployment requires careful consideration of infrastructure.", - "Model versioning is crucial for production ML systems.", - "Monitoring and alerting are essential for ML in production.", - "A/B testing helps validate model improvements.", - "Feature stores centralize feature computation and storage.", - ] - - # Create vector store - vectorstore = FAISS.from_texts(texts, embeddings) - retriever = vectorstore.as_retriever(search_kwargs={"k": 3}) - - # Use with LDR - result = quick_summary( - query="What are the key considerations for ML deployment?", - retrievers={"ml_knowledge": retriever}, - search_tool="ml_knowledge", - iterations=2, - ) - - print(f"Summary: {result['summary'][:200]}...") - - except Exception as e: - print(f"Skipping real vector store example: {e}") - - -def example_selective_retriever_usage(): - """Example showing how to selectively use different retrievers.""" - print("\n=== Example 5: Selective Retriever Usage ===") - - # Create specialized retrievers - retrievers = { - "technical": MockRetriever(), - "business": MockRetriever(), - "legal": MockRetriever(), - } - - # Query 1: Technical only - result1 = quick_summary( - query="How to implement distributed training?", - retrievers=retrievers, - search_tool="technical", # Use only technical retriever - ) - print(f"Technical query result: {result1['summary'][:100]}...") - - # Query 2: Business only - result2 = quick_summary( - query="What is the ROI of ML investments?", - retrievers=retrievers, - search_tool="business", # Use only business retriever - ) - print(f"Business query result: {result2['summary'][:100]}...") - - # Query 3: All retrievers - result3 = quick_summary( - query="What are the implications of ML adoption?", - retrievers=retrievers, - search_tool="auto", # Use all retrievers - ) - print(f"Comprehensive query result: {result3['summary'][:100]}...") - - -if __name__ == "__main__": - print("LangChain Retriever Integration Examples") - print("=" * 50) - - # Run examples - example_single_retriever() - example_multiple_retrievers() - example_hybrid_search() - example_selective_retriever_usage() - - # Uncomment to run vector store example (requires OpenAI API key) - # example_real_vector_store() - - print("\n✅ Examples completed!")
examples/api_usage/programmatic/search_strategies_example.py+225 −0 added@@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Search Strategies Example for Local Deep Research + +This example demonstrates the two main search strategies: +1. source-based: Comprehensive research with source citation +2. focused-iteration: Iterative refinement of research questions + +Each strategy has different strengths and use cases. +""" + +from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api.settings_utils import create_settings_snapshot + + +def demonstrate_source_based_strategy(): + """ + Source-based strategy: + - Focuses on gathering and synthesizing information from multiple sources + - Provides detailed citations and source tracking + - Best for: Academic research, fact-checking, comprehensive reports + """ + print("=" * 70) + print("SOURCE-BASED STRATEGY") + print("=" * 70) + print(""" +This strategy: +- Systematically searches for sources related to your topic +- Synthesizes information across multiple sources +- Provides detailed citations for all claims +- Ideal for research requiring source verification + """) + + # Configure settings for programmatic mode + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", # Using Wikipedia for demonstration + } + ) + + # Run research with source-based strategy + result = detailed_research( + query="What are the main causes of climate change?", + settings_snapshot=settings, + search_strategy="source-based", # Explicitly set strategy + iterations=2, # Number of research iterations + questions_per_iteration=3, # Questions to explore per iteration + programmatic_mode=True, + ) + + print(f"Research ID: {result['research_id']}") + print("\nSummary (first 500 chars):") + print(result["summary"][:500] + "...") + + # Show sources found + sources = result.get("sources", []) + print(f"\nSources found: {len(sources)}") + if sources: + print("\nFirst 3 sources:") + for i, source in enumerate(sources[:3], 1): + print(f" {i}. {source}") + + # Show the questions that were researched + questions = result.get("questions", {}) + print(f"\nQuestions researched across {len(questions)} iterations:") + for iteration, qs in questions.items(): + print(f"\n Iteration {iteration}:") + for q in qs[:2]: # Show first 2 questions per iteration + print(f" - {q}") + + return result + + +def demonstrate_focused_iteration_strategy(): + """ + Focused-iteration strategy: + - Iteratively refines the research based on previous findings + - Adapts questions based on what's been learned + - Best for: Deep dives, evolving research questions, exploratory research + """ + print("\n" + "=" * 70) + print("FOCUSED-ITERATION STRATEGY") + print("=" * 70) + print(""" +This strategy: +- Starts with initial research on the topic +- Analyzes findings to generate more targeted questions +- Iteratively refines understanding through multiple rounds +- Ideal for complex topics requiring deep exploration + """) + + # Configure settings + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", + } + ) + + # Run research with focused-iteration strategy + result = quick_summary( + query="How do neural networks learn?", + settings_snapshot=settings, + search_strategy="focused-iteration", # Use focused iteration + iterations=3, # More iterations for deeper exploration + questions_per_iteration=2, # Fewer but more focused questions + temperature=0.7, # Slightly higher for creative question generation + programmatic_mode=True, + ) + + print("\nSummary (first 500 chars):") + print(result["summary"][:500] + "...") + + # Show how questions evolved + questions = result.get("questions", {}) + if questions: + print("\nQuestion evolution across iterations:") + for iteration, qs in questions.items(): + print(f"\n Iteration {iteration}:") + for q in qs: + print(f" - {q}") + + # Show findings + findings = result.get("findings", []) + print(f"\nKey findings: {len(findings)}") + if findings: + print("\nFirst 2 findings:") + for i, finding in enumerate(findings[:2], 1): + text = ( + finding.get("text", "N/A") + if isinstance(finding, dict) + else str(finding) + ) + print(f" {i}. {text[:150]}...") + + return result + + +def compare_strategies(): + """ + Direct comparison of both strategies on the same topic. + """ + print("\n" + "=" * 70) + print("STRATEGY COMPARISON") + print("=" * 70) + print( + "\nComparing both strategies on the same topic: 'Quantum Computing Applications'\n" + ) + + settings = create_settings_snapshot( + { + "search.tool": "wikipedia", + } + ) + + # Same topic, different strategies + topic = "Quantum computing applications in cryptography" + + print("1. Source-based approach:") + source_result = quick_summary( + query=topic, + settings_snapshot=settings, + search_strategy="source-based", + iterations=2, + questions_per_iteration=3, + programmatic_mode=True, + ) + print(f" - Sources found: {len(source_result.get('sources', []))}") + print(f" - Summary length: {len(source_result.get('summary', ''))} chars") + print(f" - Findings: {len(source_result.get('findings', []))}") + + print("\n2. Focused-iteration approach:") + focused_result = quick_summary( + query=topic, + settings_snapshot=settings, + search_strategy="focused-iteration", + iterations=2, + questions_per_iteration=3, + programmatic_mode=True, + ) + print(f" - Sources found: {len(focused_result.get('sources', []))}") + print( + f" - Summary length: {len(focused_result.get('summary', ''))} chars" + ) + print(f" - Findings: {len(focused_result.get('findings', []))}") + + print("\n" + "=" * 70) + print("WHEN TO USE EACH STRATEGY") + print("=" * 70) + print(""" +Use SOURCE-BASED when you need: +- Comprehensive coverage with citations +- Academic or professional research +- Fact-checking and verification +- Documentation with source tracking + +Use FOCUSED-ITERATION when you need: +- Deep exploration of complex topics +- Adaptive research that evolves +- Discovery of unexpected connections +- Exploratory or investigative research + """) + + +def main(): + """Run all demonstrations.""" + print("=" * 70) + print("LOCAL DEEP RESEARCH - SEARCH STRATEGIES DEMONSTRATION") + print("=" * 70) + + # Demonstrate each strategy + demonstrate_source_based_strategy() + demonstrate_focused_iteration_strategy() + + # Compare strategies + compare_strategies() + + print("\n✓ Search strategies demonstration complete!") + print("\nNote: Both strategies can be combined with different search tools") + print( + "(wikipedia, arxiv, searxng, etc.) and custom parameters for optimal results." + ) + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/searxng_example.py+176 −0 added@@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Example of using SearXNG search engine with Local Deep Research. + +This demonstrates how to use SearXNG for web search in programmatic mode. +Note: Requires a running SearXNG instance. +""" + +import os +from langchain_ollama import ChatOllama +from local_deep_research.search_system import AdvancedSearchSystem +from local_deep_research.web_search_engines.engines.search_engine_searxng import ( + SearXNGSearchEngine, +) + +# Re-enable logging +from loguru import logger +import sys + +logger.remove() +logger.add(sys.stderr, level="INFO", format="{time} {level} {message}") +logger.enable("local_deep_research") + + +def main(): + """Demonstrate using SearXNG with Local Deep Research.""" + print("=== SearXNG Search Engine Example ===\n") + + # Check if SearXNG URL is configured + searxng_url = os.getenv("SEARXNG_URL", "http://localhost:8080") + print(f"Using SearXNG instance at: {searxng_url}") + print( + "(Set SEARXNG_URL environment variable to use a different instance)\n" + ) + + # 1. Create LLM + print("1. Setting up Ollama LLM...") + llm = ChatOllama(model="gemma3:12b", temperature=0.3) + + # 2. Configure settings + settings = { + "search.iterations": 2, + "search.questions_per_iteration": 3, + "search.strategy": "source-based", + "rate_limiting.enabled": False, # Disable rate limiting for demo + # SearXNG specific settings + "search_engines.searxng.base_url": searxng_url, + "search_engines.searxng.timeout": 30, + "search_engines.searxng.categories": ["general", "science"], + "search_engines.searxng.engines": ["google", "duckduckgo", "bing"], + "search_engines.searxng.language": "en", + "search_engines.searxng.time_range": "", # all time + "search_engines.searxng.safesearch": 0, # 0=off, 1=moderate, 2=strict + } + + # 3. Create SearXNG search engine + print("2. Initializing SearXNG search engine...") + try: + search_engine = SearXNGSearchEngine(settings_snapshot=settings) + + # Test the connection + print(" Testing SearXNG connection...") + test_results = search_engine.run("test query", research_context={}) + if test_results: + print( + f" ✓ SearXNG is working! Got {len(test_results)} test results." + ) + else: + print(" ⚠ SearXNG returned no results for test query.") + except Exception as e: + print(f"\n⚠ Error connecting to SearXNG: {e}") + print("\nPlease ensure SearXNG is running. You can start it with:") + print(" docker run -p 8888:8080 searxng/searxng") + print("\nFalling back to mock search engine for demonstration...") + + # Fallback to mock search engine + class MockSearchEngine: + def __init__(self, settings_snapshot=None): + self.settings_snapshot = settings_snapshot or {} + + def run(self, query, research_context=None): + return [ + { + "title": f"Result for: {query}", + "link": "https://example.com/result", + "snippet": f"This is a mock result for the query: {query}. " + "In a real scenario, SearXNG would provide actual web search results.", + "full_content": "Full content would be fetched here...", + "rank": 1, + } + ] + + search_engine = MockSearchEngine(settings) + + # 4. Create the search system + print("3. Creating AdvancedSearchSystem...") + # Pass programmatic_mode=True to disable database dependencies + search_system = AdvancedSearchSystem( + llm=llm, + search=search_engine, + settings_snapshot=settings, + programmatic_mode=True, + ) + + # 5. Run research queries + queries = [ + "What are the latest developments in quantum computing in 2024?", + "How does CRISPR gene editing technology work?", + ] + + for query in queries: + print(f"\n{'=' * 60}") + print(f"Research Query: {query}") + print("=" * 60) + + try: + result = search_system.analyze_topic(query) + + # Display results + print("\n=== RESEARCH FINDINGS ===") + if result.get("formatted_findings"): + print(result["formatted_findings"]) + else: + print( + "Summary:", result.get("current_knowledge", "No findings") + ) + + # Show metadata + print("\n=== METADATA ===") + print(f"• Iterations completed: {result.get('iterations', 0)}") + print(f"• Total findings: {len(result.get('findings', []))}") + + # Show search sources from all_links_of_system or search_results in findings + all_links = result.get("all_links_of_system", []) + + # Also check findings for search_results + for finding in result.get("findings", []): + if "search_results" in finding and finding["search_results"]: + all_links = finding["search_results"] + break + + if all_links: + print(f"• Sources found: {len(all_links)}") + for i, link in enumerate( + all_links[:5], 1 + ): # Show first 5 sources + if isinstance(link, dict): + title = link.get("title", "No title") + url = link.get("link", "Unknown") + print(f" [{i}] {title}") + print(f" {url}") + + # Show generated questions + if result.get("questions_by_iteration"): + print("\n=== RESEARCH QUESTIONS ===") + for iteration, questions in result[ + "questions_by_iteration" + ].items(): + print(f"Iteration {iteration}:") + for q in questions[ + :2 + ]: # Show first 2 questions per iteration + print(f" • {q}") + + except Exception as e: + logger.exception("Error during research") + print(f"\n⚠ Error: {e}") + + print("\n✓ SearXNG integration example completed!") + print( + "\nNote: For best results, ensure SearXNG is properly configured with multiple search engines." + ) + + +if __name__ == "__main__": + main()
examples/api_usage/programmatic/simple_programmatic_example.py+35 −7 modified@@ -5,11 +5,33 @@ Quick example showing how to use the LDR Python API directly. """ -from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api import ( + detailed_research, + quick_summary, + generate_report, +) +from local_deep_research.api.settings_utils import ( + create_settings_snapshot, +) + +# Use default settings with minimal overrides +# This provides all necessary settings with sensible defaults +settings_snapshot = create_settings_snapshot( + overrides={ + "search.tool": "wikipedia", # Use Wikipedia for this example + } +) + +# Alternative: Use completely default settings +# settings_snapshot = get_default_settings_snapshot() # Example 1: Quick Summary print("=== Quick Summary ===") -result = quick_summary("What is machine learning?") +result = quick_summary( + "What is machine learning?", + settings_snapshot=settings_snapshot, + programmatic_mode=True, +) print(f"Summary: {result['summary'][:300]}...") print(f"Found {len(result.get('findings', []))} findings") @@ -20,6 +42,8 @@ iterations=2, search_tool="wikipedia", search_strategy="source_based", + settings_snapshot=settings_snapshot, + programmatic_mode=True, ) print(f"Research ID: {result['research_id']}") print(f"Summary length: {len(result['summary'])} characters") @@ -35,23 +59,27 @@ temperature=0.5, # Lower temperature for focused results provider="openai_endpoint", # Specify LLM provider model_name="llama-3.3-70b-instruct", # Specify model + settings_snapshot=settings_snapshot, + programmatic_mode=True, ) print(f"Completed {result['iterations']} iterations") print( f"Generated {sum(len(qs) for qs in result.get('questions', {}).values())} questions" ) # Example 4: Generate and Save a Report -print("\n=== Generate Report (Optional - Uncomment to run) ===") +print("\n=== Generate Report ===") print("Note: Report generation can take several minutes") -# Uncomment the following to generate a full report: -""" + +# Generate a comprehensive report report = generate_report( query="Future of artificial intelligence", output_file="ai_future_report.md", # Save directly to file searches_per_section=2, - iterations=1 + iterations=1, + settings_snapshot=settings_snapshot, # Now works with programmatic mode! ) print(f"Report saved to: {report.get('file_path', 'ai_future_report.md')}") print(f"Report length: {len(report['content'])} characters") -""" +print("Report preview (first 300 chars):") +print(report["content"][:300] + "...")
examples/api_usage/programmatic/test_direct_import.py+30 −0 added@@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Test importing search_system directly without going through __init__.py""" + +import sys + +# Try importing the search_system module directly +try: + print("Attempting to import search_system module directly...") + from local_deep_research import search_system + + print("✓ search_system module imported!") + + # Now try to access AdvancedSearchSystem + print("\nTrying to access AdvancedSearchSystem class...") + AdvancedSearchSystem = search_system.AdvancedSearchSystem + print("✓ Got AdvancedSearchSystem class!") + +except Exception as e: + print(f"✗ Failed: {e}") + import traceback + + traceback.print_exc() + +# Also try a more direct import +try: + print("\nAttempting direct file import...") + sys.path.insert(0, "src") + print("✓ Direct import worked!") +except Exception as e: + print(f"✗ Direct import failed: {e}")
examples/api_usage/README.md+96 −12 modified@@ -2,14 +2,22 @@ This directory contains examples for using LDR through different interfaces. +## Important: Authentication Required (v2.0+) + +Since LDR v2.0, all API access requires authentication due to per-user encrypted databases. You must: + +1. Create a user account through the web interface +2. Authenticate before making API calls +3. Pass settings_snapshot for programmatic access + ## Directory Structure - **`programmatic/`** - Direct Python API usage (import from `local_deep_research.api`) - `programmatic_access.ipynb` - Jupyter notebook with comprehensive examples - `retriever_usage_example.py` - Using LangChain retrievers with LDR - **`http/`** - HTTP REST API usage (requires running server) - - `simple_http_example.py` - Quick start example + - `simple_http_example.py` - Quick start example (needs updating for auth) - `http_api_examples.py` - Comprehensive examples including batch processing ## Quick Start @@ -18,27 +26,52 @@ This directory contains examples for using LDR through different interfaces. ```python from local_deep_research.api import quick_summary - -result = quick_summary("What is quantum computing?") -print(result["summary"]) +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +# Authenticate and get settings +with get_user_db_session(username="your_username", password="your_password") as session: + settings_manager = CachedSettingsManager(session, "your_username") + settings_snapshot = settings_manager.get_all_settings() + + # Use the API + result = quick_summary( + "What is quantum computing?", + settings_snapshot=settings_snapshot + ) + print(result["summary"]) ``` ### HTTP API (REST) First, start the server: ```bash -python -m src.local_deep_research.web.app +python -m local_deep_research.web.app ``` -Then use the API: +Then authenticate and use the API: ```python import requests -response = requests.post( - "http://localhost:5000/api/v1/quick_summary", - json={"query": "What is quantum computing?"} +# Create session for cookie persistence +session = requests.Session() + +# Login +session.post( + "http://localhost:5000/auth/login", + json={"username": "your_username", "password": "your_password"} +) + +# Get CSRF token +csrf_token = session.get("http://localhost:5000/auth/csrf-token").json()["csrf_token"] + +# Make API request +response = session.post( + "http://localhost:5000/research/api/start", + json={"query": "What is quantum computing?"}, + headers={"X-CSRF-Token": csrf_token} ) -print(response.json()["summary"]) +print(response.json()) ``` ## Which API Should I Use? @@ -48,19 +81,63 @@ print(response.json()["summary"]) - ✅ Full access to all features and parameters - ✅ Can pass Python objects (like LangChain retrievers) - ❌ Requires LDR to be installed in your environment + - ❌ Requires database session and settings snapshot - **HTTP API**: Use when accessing LDR from other languages or remote systems - ✅ Language agnostic - works with any HTTP client - ✅ Can run LDR on a separate server - ✅ Easy to scale and deploy - ❌ Limited to JSON-serializable parameters - ❌ Requires running the web server + - ❌ Requires authentication and CSRF tokens + +## API Changes in v2.0 + +### Breaking Changes + +1. **Authentication Required**: All endpoints now require login +2. **Settings Snapshot**: Programmatic API needs `settings_snapshot` parameter +3. **New Endpoints**: API routes moved (e.g., `/api/v1/quick_summary` → `/research/api/start`) +4. **CSRF Protection**: POST/PUT/DELETE requests need CSRF token + +### Migration Guide + +#### Old (v1.x): +```python +# Programmatic +from local_deep_research.api import quick_summary +result = quick_summary("query") + +# HTTP +curl -X POST http://localhost:5000/api/v1/quick_summary \ + -d '{"query": "test"}' +``` + +#### New (v2.0+): +```python +# Programmatic - with authentication and settings +with get_user_db_session(username, password) as session: + settings_manager = CachedSettingsManager(session, username) + settings_snapshot = settings_manager.get_all_settings() + result = quick_summary("query", settings_snapshot=settings_snapshot) + +# HTTP - with authentication and CSRF +# See examples above +``` ## Running the Examples +### Prerequisites + +1. Install LDR: `pip install local-deep-research` +2. Create a user account: + - Start server: `python -m local_deep_research.web.app` + - Open http://localhost:5000 and register +3. Configure your LLM provider in settings + ### Programmatic Examples ```bash -# Run the retriever example +# Update credentials in the example files first! python examples/api_usage/programmatic/retriever_usage_example.py # Or use the Jupyter notebook @@ -70,9 +147,16 @@ jupyter notebook examples/api_usage/programmatic/programmatic_access.ipynb ### HTTP Examples ```bash # First, start the LDR server -python -m src.local_deep_research.web.app +python -m local_deep_research.web.app # In another terminal, run the examples +# Note: These need to be updated for v2.0 authentication! python examples/api_usage/http/simple_http_example.py python examples/api_usage/http/http_api_examples.py ``` + +## Need Help? + +- See the [API Quick Start Guide](../../docs/api-quickstart.md) +- Check the [FAQ](../../docs/faq.md) +- Join our [Discord](https://discord.gg/ttcqQeFcJ3) for support
examples/api_usage/UPGRADE_NOTICE.md+65 −0 added@@ -0,0 +1,65 @@ +# Important: Examples Updated for LDR v1.0 + +## Authentication Required + +Starting with LDR v1.0, all API access requires authentication due to the new per-user encrypted database architecture. + +## Updated Examples + +The following examples have been updated for v1.0: + +### ✅ Updated Examples: +- `http/simple_http_example.py` - Basic HTTP API usage with authentication +- `http/http_api_examples.py` - Comprehensive HTTP API examples with LDRClient class +- `programmatic/retriever_usage_example.py` - LangChain retriever integration with auth +- `programmatic/programmatic_access_v1.py` - NEW: Complete programmatic API examples + +### ⚠️ Needs Manual Update: +- `programmatic/programmatic_access.ipynb` - Jupyter notebook (see programmatic_access_v1.py for reference) + +## Quick Migration Guide + +### Old Code (pre-v1.0): +```python +from local_deep_research.api import quick_summary +result = quick_summary("query") +``` + +### New Code (v1.0+): +```python +from local_deep_research.api import quick_summary +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +with get_user_db_session(username="user", password="pass") as session: + settings_manager = CachedSettingsManager(session, "user") + settings_snapshot = settings_manager.get_all_settings() + result = quick_summary("query", settings_snapshot=settings_snapshot) +``` + +## Before Running Examples + +1. **Create an account**: + ```bash + python -m local_deep_research.web.app + # Open http://localhost:5000 and register + ``` + +2. **Configure LLM provider** in Settings (e.g., OpenAI, Anthropic, Ollama) + +3. **Update credentials** in the example files: + - Change `USERNAME = "your_username"` to your actual username + - Change `PASSWORD = "your_password"` to your actual password + +## Common Issues + +- **"No settings context available"**: Pass `settings_snapshot` to API functions +- **"Encrypted database requires password"**: Use `get_user_db_session()` with credentials +- **"CSRF token missing"**: Get CSRF token before POST/PUT/DELETE requests +- **404 errors**: Check new endpoint paths (e.g., `/research/api/start`) + +## Need Help? + +- See [Migration Guide](../../docs/MIGRATION_GUIDE_v1.md) for detailed changes +- Check [API Quick Start](../../docs/api-quickstart.md) for authentication details +- Join our [Discord](https://discord.gg/ttcqQeFcJ3) for support
examples/benchmarks/browsecomp/run_browsecomp_fixed_v2.py+9 −8 modified@@ -11,12 +11,13 @@ import random import re import sys +from pathlib import Path from typing import Optional import pandas as pd # Set up Python path -current_dir = os.path.dirname(os.path.abspath(__file__)) +current_dir = str(Path(__file__).parent.resolve()) sys.path.insert(0, current_dir) try: @@ -70,10 +71,10 @@ def decrypt(ciphertext_b64: str, password: str) -> str: try: encrypted = base64.b64decode(ciphertext_b64) key = derive_key(password, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes(a ^ b for a, b in zip(encrypted, key, strict=False)) return decrypted.decode() except Exception as e: - print(f"Error decrypting data: {str(e)}") + print(f"Error decrypting data: {e!s}") return f"Error: Could not decrypt data: {str(e)[:100]}" @@ -91,8 +92,8 @@ def run_browsecomp_evaluation( Run the BrowseComp evaluation using Local Deep Research. """ # Ensure output directory exists - os.makedirs(output_dir, exist_ok=True) - output_path = os.path.join(output_dir, output_file) + Path(output_dir).mkdir(parents=True, exist_ok=True) + output_path = str(Path(output_dir) / output_file) # Load BrowseComp dataset print(f"Loading dataset from {dataset_path}") @@ -113,7 +114,7 @@ def run_browsecomp_evaluation( print(f"Sampled {num_examples} examples from {len(df)} total examples") # Remove output file if it exists to avoid appending - if os.path.exists(output_path): + if Path(output_path).exists(): os.remove(output_path) results = [] @@ -216,7 +217,7 @@ def run_browsecomp_evaluation( ) except Exception as e: - print(f"Error processing question {i + 1}: {str(e)}") + print(f"Error processing question {i + 1}: {e!s}") # In case of error, write a placeholder result result = { "id": example.get("id", f"q{i}"), @@ -245,7 +246,7 @@ def run_browsecomp_evaluation( "search_tool": search_tool, } - report_path = os.path.join(output_dir, "browsecomp_summary.json") + report_path = str(Path(output_dir) / "browsecomp_summary.json") with open(report_path, "w") as f: json.dump(report, f, indent=2)
examples/benchmarks/claude_grading/benchmark.py+43 −61 modified@@ -12,84 +12,70 @@ - Provides detailed metrics and accuracy reports """ -import logging import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path + # Set up Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "src")) +src_dir = str((Path(__file__).parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") +# Note: Database configuration is now per-user +# For benchmarks, API keys should be provided via environment variables +# or configuration files rather than relying on a shared database -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Logger is already imported from loguru def setup_grading_config(): """ - Create a custom evaluation configuration that uses the local database + Create a custom evaluation configuration that uses environment variables for API keys and specifically uses Claude 3 Sonnet for grading. Returns: Dict containing the evaluation configuration """ - # Import necessary function to get database settings - try: - from local_deep_research.utilities.db_utils import get_db_setting - except ImportError as e: - print(f"Error importing database utilities: {e}") - print("Current sys.path:", sys.path) - return None # Create config that uses Claude 3 Sonnet via Anthropic directly - # This will use the API key from the database # Only use parameters that get_llm() accepts evaluation_config = { "model_name": "claude-3-sonnet-20240229", # Correct Anthropic model name "provider": "anthropic", # Use Anthropic directly "temperature": 0, # Zero temp for consistent evaluation } - # Check if anthropic API key is available in the database - try: - anthropic_key = get_db_setting("llm.anthropic.api_key") - if anthropic_key: + # Check if anthropic API key is available in environment + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + if anthropic_key: + print( + "Found Anthropic API key in environment, will use Claude 3 Sonnet for grading" + ) + else: + print( + "Warning: No Anthropic API key found in ANTHROPIC_API_KEY environment variable" + ) + print("Checking for alternative providers...") + + # Try OpenRouter as a fallback + openrouter_key = os.environ.get("OPENROUTER_API_KEY") + if openrouter_key: print( - "Found Anthropic API key in database, will use Claude 3 Sonnet for grading" + "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" ) + evaluation_config = { + "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format + "provider": "openai_endpoint", + "openai_endpoint_url": "https://openrouter.ai/api/v1", + "temperature": 0, + } else: - print("Warning: No Anthropic API key found in database") - print("Checking for alternative providers...") - - # Try OpenRouter as a fallback - openrouter_key = get_db_setting("llm.openai_endpoint.api_key") - if openrouter_key: - print( - "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" - ) - evaluation_config = { - "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format - "provider": "openai_endpoint", - "openai_endpoint_url": "https://openrouter.ai/api/v1", - "temperature": 0, - } - except Exception as e: - print(f"Error checking for API keys: {e}") + print("ERROR: No API keys found in environment variables") + print("Please set either ANTHROPIC_API_KEY or OPENROUTER_API_KEY") + return None return evaluation_config @@ -159,11 +145,9 @@ def custom_get_evaluation_llm(custom_config=None): traceback.print_exc() # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "benchmark_results", f"claude_grading_{timestamp}" - ) - os.makedirs(output_dir, exist_ok=True) + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str(Path("benchmark_results") / f"claude_grading_{timestamp}") + Path(output_dir).mkdir(parents=True, exist_ok=True) config = { "search_strategy": strategy, @@ -190,7 +174,7 @@ def custom_get_evaluation_llm(custom_config=None): simpleqa_results = simpleqa.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) simpleqa_duration = time.time() - simpleqa_start @@ -203,7 +187,7 @@ def custom_get_evaluation_llm(custom_config=None): # Save results import json - with open(os.path.join(output_dir, "simpleqa_results.json"), "w") as f: + with open(Path(output_dir) / "simpleqa_results.json", "w") as f: json.dump(simpleqa_results, f, indent=2) except Exception as e: print(f"Error during SimpleQA evaluation: {e}") @@ -226,7 +210,7 @@ def custom_get_evaluation_llm(custom_config=None): browsecomp_results = browsecomp.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) browsecomp_duration = time.time() - browsecomp_start @@ -237,9 +221,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"BrowseComp metrics: {browsecomp_results.get('metrics', {})}") # Save results - with open( - os.path.join(output_dir, "browsecomp_results.json"), "w" - ) as f: + with open(Path(output_dir) / "browsecomp_results.json", "w") as f: json.dump(browsecomp_results, f, indent=2) except Exception as e: print(f"Error during BrowseComp evaluation: {e}") @@ -262,7 +244,7 @@ def custom_get_evaluation_llm(custom_config=None): composite_results = composite.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "composite"), + output_dir=str(Path(output_dir) / "composite"), ) composite_duration = time.time() - composite_start @@ -272,7 +254,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"Composite score: {composite_results.get('score', 0):.4f}") # Save results - with open(os.path.join(output_dir, "composite_results.json"), "w") as f: + with open(Path(output_dir) / "composite_results.json", "w") as f: json.dump(composite_results, f, indent=2) except Exception as e: print(f"Error during composite evaluation: {e}")
examples/benchmarks/gemini/run_gemini_benchmark_fixed.py+10 −8 modified@@ -3,10 +3,10 @@ Fixed benchmark with Gemini 2.0 Flash via OpenRouter """ -import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path # Import the benchmark functions from local_deep_research.benchmarks.benchmark_functions import ( @@ -62,11 +62,13 @@ def run_benchmark(examples=1): """Run benchmarks with Gemini 2.0 Flash""" try: # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "../../benchmark_results", f"gemini_eval_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path(__file__).parent.parent.parent + / "benchmark_results" + / f"gemini_eval_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) # Setup the Gemini configuration gemini_config = setup_gemini_config() @@ -82,7 +84,7 @@ def run_benchmark(examples=1): search_tool="searxng", evaluation_model=gemini_config["model_name"], evaluation_provider=gemini_config["provider"], - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) simpleqa_duration = time.time() - simpleqa_start @@ -110,7 +112,7 @@ def run_benchmark(examples=1): search_tool="searxng", evaluation_model=gemini_config["model_name"], evaluation_provider=gemini_config["provider"], - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) browsecomp_duration = time.time() - browsecomp_start
examples/benchmarks/run_browsecomp.py+12 −11 modified@@ -21,6 +21,7 @@ import re import sys import time +from pathlib import Path from typing import Any, Dict from loguru import logger @@ -44,11 +45,11 @@ def decrypt(ciphertext_b64: str, password: str) -> str: try: encrypted = base64.b64decode(ciphertext_b64) key = derive_key(password, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes(a ^ b for a, b in zip(encrypted, key, strict=False)) return decrypted.decode() except Exception as e: - logger.error(f"Error decrypting data: {str(e)}") - return f"Error: Could not decrypt data - {str(e)}" + logger.exception(f"Error decrypting data: {e!s}") + return f"Error: Could not decrypt data - {e!s}" def run_browsecomp_with_canary( @@ -83,16 +84,16 @@ def run_browsecomp_with_canary( # Set up output files timestamp = time.strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - output_dir, f"browsecomp_{timestamp}_results.jsonl" + results_file = str( + Path(output_dir) / f"browsecomp_{timestamp}_results.jsonl" ) - evaluation_file = os.path.join( - output_dir, f"browsecomp_{timestamp}_evaluation.jsonl" + evaluation_file = str( + Path(output_dir) / f"browsecomp_{timestamp}_evaluation.jsonl" ) # Make sure output files don't exist for file in [results_file, evaluation_file]: - if os.path.exists(file): + if Path(file).exists(): os.remove(file) # Process each example @@ -173,7 +174,7 @@ def run_browsecomp_with_canary( f.write(json.dumps(result) + "\n") except Exception as e: - logger.error(f"Error processing example {i + 1}: {str(e)}") + logger.exception(f"Error processing example {i + 1}: {e!s}") # Create error result error_result = { @@ -212,7 +213,7 @@ def run_browsecomp_with_canary( dataset_type="browsecomp", ) except Exception as e: - logger.error(f"Evaluation failed: {str(e)}") + logger.exception(f"Evaluation failed: {e!s}") evaluation_results = [] # Calculate basic metrics @@ -266,7 +267,7 @@ def main(): parser.add_argument( "--output-dir", type=str, - default=os.path.join("examples", "benchmarks", "results", "browsecomp"), + default=str(Path("examples") / "benchmarks" / "results" / "browsecomp"), help="Output directory", )
examples/benchmarks/run_gemini_benchmark.py+9 −6 modified@@ -17,6 +17,7 @@ import os import time from datetime import datetime +from pathlib import Path # Import the benchmark functionality from local_deep_research.benchmarks.benchmark_functions import ( @@ -61,11 +62,13 @@ def run_benchmark(args): os.environ["LDR_LLM__OPENAI_ENDPOINT_URL"] = config["openai_endpoint_url"] # Create timestamp for output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - base_output_dir = os.path.join( - "examples", "benchmarks", "results", f"gemini_{timestamp}" + from datetime import timezone + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + base_output_dir = str( + Path("examples") / "benchmarks" / "results" / f"gemini_{timestamp}" ) - os.makedirs(base_output_dir, exist_ok=True) + Path(base_output_dir).mkdir(parents=True, exist_ok=True) # Configure benchmark settings results = {} @@ -76,7 +79,7 @@ def run_benchmark(args): { "name": "SimpleQA", "function": evaluate_simpleqa, - "output_dir": os.path.join(base_output_dir, "simpleqa"), + "output_dir": str(Path(base_output_dir) / "simpleqa"), } ) @@ -85,7 +88,7 @@ def run_benchmark(args): { "name": "BrowseComp", "function": evaluate_browsecomp, - "output_dir": os.path.join(base_output_dir, "browsecomp"), + "output_dir": str(Path(base_output_dir) / "browsecomp"), } )
examples/benchmarks/run_resumable_parallel_benchmark.py+32 −31 modified@@ -20,7 +20,7 @@ import os import sys import time -from datetime import datetime +from datetime import datetime, UTC from pathlib import Path from typing import Any, Dict, Optional, Tuple @@ -38,19 +38,16 @@ ) from local_deep_research.benchmarks.runners import format_query - # Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) +project_root = str(Path(__file__).parent.parent.parent.resolve()) logger.enable("local_deep_research") def load_existing_results(results_file: str) -> Dict[str, Dict]: """Load existing results from JSONL file.""" results = {} - if os.path.exists(results_file): + if Path(results_file).exists(): logger.info(f"Loading existing results from: {results_file}") with open(results_file, "r") as f: for line in f: @@ -74,8 +71,8 @@ def find_latest_results_file( ) -> Optional[str]: """Find the most recent results file for a dataset.""" # First try dataset subdirectory - dataset_dir = os.path.join(output_dir, dataset_type) - if os.path.exists(dataset_dir): + dataset_dir = str(Path(output_dir) / dataset_type) + if Path(dataset_dir).exists(): pattern = f"{dataset_type}_*_results.jsonl" files = list(Path(dataset_dir).glob(pattern)) if files: @@ -112,15 +109,15 @@ def run_resumable_benchmark( ) # Determine output files - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_results.jsonl" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + results_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_results.jsonl" ) - evaluation_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_evaluation.jsonl" + evaluation_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_evaluation.jsonl" ) - report_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_report.md" + report_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_report.md" ) # Load existing results if resuming @@ -222,7 +219,7 @@ def run_resumable_benchmark( f.write(json.dumps(result) + "\n") except Exception as e: - logger.error(f"Error processing example {i + 1}: {str(e)}") + logger.exception("Error processing example") error_count += 1 # Create error result @@ -274,7 +271,7 @@ def run_resumable_benchmark( "errors": error_count, } except Exception as e: - logger.error(f"Error during evaluation: {str(e)}") + logger.exception("Error during evaluation") return { "accuracy": 0, "metrics": {}, @@ -298,7 +295,7 @@ def run_simpleqa_benchmark_wrapper(args: Tuple) -> Dict[str, Any]: results = run_resumable_benchmark( dataset_type="simpleqa", num_examples=num_examples, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), search_config=search_config, evaluation_config=evaluation_config, resume_from=resume_from, @@ -325,7 +322,7 @@ def run_browsecomp_benchmark_wrapper(args: Tuple) -> Dict[str, Any]: results = run_resumable_benchmark( dataset_type="browsecomp", num_examples=num_examples, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), search_config=browsecomp_config, evaluation_config=evaluation_config, resume_from=resume_from, @@ -411,19 +408,23 @@ def main(): # Determine output directory if args.resume_from: # Create new directory but link to old results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - project_root, "benchmark_results", f"resumed_benchmark_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path(project_root) + / "benchmark_results" + / f"resumed_benchmark_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) logger.info( f"Resuming from {args.resume_from}, new results in {output_dir}" ) else: # Create new timestamp directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - project_root, "benchmark_results", f"parallel_benchmark_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path(project_root) + / "benchmark_results" + / f"parallel_benchmark_{timestamp}" ) os.makedirs(output_dir, exist_ok=True) logger.info(f"Starting new benchmark in: {output_dir}") @@ -516,8 +517,8 @@ def main(): print( f"BrowseComp benchmark completed: {result['new_results']} new, {result['reused_results']} reused" ) - except Exception as e: - logger.error(f"Error in {dataset_name} benchmark: {e}") + except Exception: + logger.exception("Error in benchmark") # Calculate total time total_duration = time.time() - total_start_time @@ -566,12 +567,12 @@ def main(): } with open( - os.path.join(output_dir, "parallel_benchmark_summary.json"), "w" + Path(output_dir) / "parallel_benchmark_summary.json", "w" ) as f: json.dump(summary, f, indent=2) - except Exception as e: - logger.error(f"Error saving summary: {e}") + except Exception: + logger.exception("Error saving summary") return 0
examples/benchmarks/run_simpleqa.py+2 −2 modified@@ -14,8 +14,8 @@ """ import argparse -import os import sys +from pathlib import Path # Import the benchmark functionality from local_deep_research.benchmarks.benchmark_functions import evaluate_simpleqa @@ -39,7 +39,7 @@ def main(): parser.add_argument( "--output-dir", type=str, - default=os.path.join("examples", "benchmarks", "results", "simpleqa"), + default=str(Path("examples") / "benchmarks" / "results" / "simpleqa"), help="Output directory", ) parser.add_argument(
examples/benchmarks/scripts/run_benchmark_with_claude_grading.py+43 −62 modified@@ -12,84 +12,69 @@ - Provides detailed metrics and accuracy reports """ -import logging import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path + # Set up Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "src")) +src_dir = str((Path(__file__).parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") +# Note: Database configuration is now per-user +# For benchmarks, API keys should be provided via environment variables +# or configuration files rather than relying on a shared database -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Logger is already imported from loguru def setup_grading_config(): """ - Create a custom evaluation configuration that uses the local database + Create a custom evaluation configuration that uses environment variables for API keys and specifically uses Claude 3 Sonnet for grading. Returns: Dict containing the evaluation configuration """ - # Import necessary function to get database settings - try: - from local_deep_research.utilities.db_utils import get_db_setting - except ImportError as e: - print(f"Error importing database utilities: {e}") - print("Current sys.path:", sys.path) - return None - # Create config that uses Claude 3 Sonnet via Anthropic directly - # This will use the API key from the database # Only use parameters that get_llm() accepts evaluation_config = { "model_name": "claude-3-sonnet-20240229", # Correct Anthropic model name "provider": "anthropic", # Use Anthropic directly "temperature": 0, # Zero temp for consistent evaluation } - # Check if anthropic API key is available in the database - try: - anthropic_key = get_db_setting("llm.anthropic.api_key") - if anthropic_key: + # Check if anthropic API key is available in environment + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + if anthropic_key: + print( + "Found Anthropic API key in environment, will use Claude 3 Sonnet for grading" + ) + else: + print( + "Warning: No Anthropic API key found in ANTHROPIC_API_KEY environment variable" + ) + print("Checking for alternative providers...") + + # Try OpenRouter as a fallback + openrouter_key = os.environ.get("OPENROUTER_API_KEY") + if openrouter_key: print( - "Found Anthropic API key in database, will use Claude 3 Sonnet for grading" + "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" ) + evaluation_config = { + "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format + "provider": "openai_endpoint", + "openai_endpoint_url": "https://openrouter.ai/api/v1", + "temperature": 0, + } else: - print("Warning: No Anthropic API key found in database") - print("Checking for alternative providers...") - - # Try OpenRouter as a fallback - openrouter_key = get_db_setting("llm.openai_endpoint.api_key") - if openrouter_key: - print( - "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" - ) - evaluation_config = { - "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format - "provider": "openai_endpoint", - "openai_endpoint_url": "https://openrouter.ai/api/v1", - "temperature": 0, - } - except Exception as e: - print(f"Error checking for API keys: {e}") + print("ERROR: No API keys found in environment variables") + print("Please set either ANTHROPIC_API_KEY or OPENROUTER_API_KEY") + return None return evaluation_config @@ -159,11 +144,9 @@ def custom_get_evaluation_llm(custom_config=None): traceback.print_exc() # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "benchmark_results", f"claude_grading_{timestamp}" - ) - os.makedirs(output_dir, exist_ok=True) + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str(Path("benchmark_results") / f"claude_grading_{timestamp}") + Path(output_dir).mkdir(parents=True, exist_ok=True) config = { "search_strategy": strategy, @@ -190,7 +173,7 @@ def custom_get_evaluation_llm(custom_config=None): simpleqa_results = simpleqa.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) simpleqa_duration = time.time() - simpleqa_start @@ -203,7 +186,7 @@ def custom_get_evaluation_llm(custom_config=None): # Save results import json - with open(os.path.join(output_dir, "simpleqa_results.json"), "w") as f: + with open(Path(output_dir) / "simpleqa_results.json", "w") as f: json.dump(simpleqa_results, f, indent=2) except Exception as e: print(f"Error during SimpleQA evaluation: {e}") @@ -226,7 +209,7 @@ def custom_get_evaluation_llm(custom_config=None): browsecomp_results = browsecomp.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) browsecomp_duration = time.time() - browsecomp_start @@ -237,9 +220,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"BrowseComp metrics: {browsecomp_results.get('metrics', {})}") # Save results - with open( - os.path.join(output_dir, "browsecomp_results.json"), "w" - ) as f: + with open(Path(output_dir) / "browsecomp_results.json", "w") as f: json.dump(browsecomp_results, f, indent=2) except Exception as e: print(f"Error during BrowseComp evaluation: {e}") @@ -262,7 +243,7 @@ def custom_get_evaluation_llm(custom_config=None): composite_results = composite.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "composite"), + output_dir=str(Path(output_dir) / "composite"), ) composite_duration = time.time() - composite_start @@ -272,7 +253,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"Composite score: {composite_results.get('score', 0):.4f}") # Save results - with open(os.path.join(output_dir, "composite_results.json"), "w") as f: + with open(Path(output_dir) / "composite_results.json", "w") as f: json.dump(composite_results, f, indent=2) except Exception as e: print(f"Error during composite evaluation: {e}")
examples/benchmarks/scripts/run_focused_benchmark_fixed.py+42 −61 modified@@ -9,84 +9,67 @@ accesses the database for API keys, and uses Claude Anthropic 3.7 for grading. """ -import logging import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path + # Set up Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "src")) +src_dir = str((Path(__file__).parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Use environment variables for configuration +# The system should be configured with proper environment variables: +# - ANTHROPIC_API_KEY for Anthropic API access +# - OPENROUTER_API_KEY for OpenRouter API access (if used) +# - LDR_DATA_DIR for data directory location (if needed) +data_dir = os.environ.get("LDR_DATA_DIR", str(Path(src_dir) / "data")) def setup_grading_config(): """ - Create a custom evaluation configuration that uses the local database + Create a custom evaluation configuration that uses environment variables for API keys and specifically uses Claude Anthropic 3.7 Sonnet for grading. Returns: Dict containing the evaluation configuration """ - # Import necessary function to get database settings - try: - from local_deep_research.utilities.db_utils import get_db_setting - except ImportError as e: - print(f"Error importing database utilities: {e}") - print("Current sys.path:", sys.path) - return None + # No need to import database utilities anymore # Create config that uses Claude 3 Sonnet via Anthropic directly - # This will use the API key from the database + # This will use the API key from environment variables # Only use parameters that get_llm() accepts evaluation_config = { "model_name": "claude-3-sonnet-20240229", # Correct Anthropic model name "provider": "anthropic", # Use Anthropic directly "temperature": 0, # Zero temp for consistent evaluation } - # Check if anthropic API key is available in the database - try: - anthropic_key = get_db_setting("llm.anthropic.api_key") - if anthropic_key: + # Check if anthropic API key is available in environment + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + if anthropic_key: + print( + "Found Anthropic API key in environment, will use Claude 3.7 Sonnet for grading" + ) + else: + print("Warning: No Anthropic API key found in environment") + print("Checking for alternative providers...") + + # Try OpenRouter as a fallback + openrouter_key = os.environ.get("OPENROUTER_API_KEY") + if openrouter_key: print( - "Found Anthropic API key in database, will use Claude 3.7 Sonnet for grading" + "Found OpenRouter API key, will use OpenRouter with Claude 3.7 Sonnet" ) - else: - print("Warning: No Anthropic API key found in database") - print("Checking for alternative providers...") - - # Try OpenRouter as a fallback - openrouter_key = get_db_setting("llm.openai_endpoint.api_key") - if openrouter_key: - print( - "Found OpenRouter API key, will use OpenRouter with Claude 3.7 Sonnet" - ) - evaluation_config = { - "model_name": "anthropic/claude-3-7-sonnet", # OpenRouter format - "provider": "openai_endpoint", - "openai_endpoint_url": "https://openrouter.ai/api/v1", - "temperature": 0, - } - except Exception as e: - print(f"Error checking for API keys: {e}") + evaluation_config = { + "model_name": "anthropic/claude-3-7-sonnet", # OpenRouter format + "provider": "openai_endpoint", + "openai_endpoint_url": "https://openrouter.ai/api/v1", + "temperature": 0, + } return evaluation_config @@ -156,9 +139,9 @@ def custom_get_evaluation_llm(custom_config=None): traceback.print_exc() # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join("benchmark_results", f"direct_eval_{timestamp}") - os.makedirs(output_dir, exist_ok=True) + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str(Path("benchmark_results") / f"direct_eval_{timestamp}") + Path(output_dir).mkdir(parents=True, exist_ok=True) config = { "search_strategy": strategy, @@ -185,7 +168,7 @@ def custom_get_evaluation_llm(custom_config=None): simpleqa_results = simpleqa.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) simpleqa_duration = time.time() - simpleqa_start @@ -198,7 +181,7 @@ def custom_get_evaluation_llm(custom_config=None): # Save results import json - with open(os.path.join(output_dir, "simpleqa_results.json"), "w") as f: + with open(Path(output_dir) / "simpleqa_results.json", "w") as f: json.dump(simpleqa_results, f, indent=2) except Exception as e: print(f"Error during SimpleQA evaluation: {e}") @@ -221,7 +204,7 @@ def custom_get_evaluation_llm(custom_config=None): browsecomp_results = browsecomp.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) browsecomp_duration = time.time() - browsecomp_start @@ -232,9 +215,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"BrowseComp metrics: {browsecomp_results.get('metrics', {})}") # Save results - with open( - os.path.join(output_dir, "browsecomp_results.json"), "w" - ) as f: + with open(Path(output_dir) / "browsecomp_results.json", "w") as f: json.dump(browsecomp_results, f, indent=2) except Exception as e: print(f"Error during BrowseComp evaluation: {e}") @@ -257,7 +238,7 @@ def custom_get_evaluation_llm(custom_config=None): composite_results = composite.evaluate( config, num_examples=examples, - output_dir=os.path.join(output_dir, "composite"), + output_dir=str(Path(output_dir) / "composite"), ) composite_duration = time.time() - composite_start @@ -267,7 +248,7 @@ def custom_get_evaluation_llm(custom_config=None): print(f"Composite score: {composite_results.get('score', 0):.4f}") # Save results - with open(os.path.join(output_dir, "composite_results.json"), "w") as f: + with open(Path(output_dir) / "composite_results.json", "w") as f: json.dump(composite_results, f, indent=2) except Exception as e: print(f"Error during composite evaluation: {e}")
examples/benchmarks/scripts/run_grader_only.py+38 −54 modified@@ -7,82 +7,66 @@ """ import argparse -import logging import os import sys import time +from pathlib import Path + # Set up Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "src")) +src_dir = str((Path(__file__).parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Use environment variables for configuration +# The system should be configured with proper environment variables: +# - ANTHROPIC_API_KEY for Anthropic API access +# - OPENROUTER_API_KEY for OpenRouter API access (if used) +# - LDR_DATA_DIR for data directory location (if needed) +data_dir = os.environ.get("LDR_DATA_DIR", str(Path(src_dir) / "data")) def setup_grading_config(): """ - Create a custom evaluation configuration that uses the local database + Create a custom evaluation configuration that uses environment variables for API keys and specifically uses Claude 3 Sonnet for grading. Returns: Dict containing the evaluation configuration """ - # Import necessary function to get database settings - try: - from local_deep_research.utilities.db_utils import get_db_setting - except ImportError as e: - print(f"Error importing database utilities: {e}") - print("Current sys.path:", sys.path) - return None + # No need to import database utilities anymore # Create config that uses Claude 3 Sonnet via Anthropic directly + # This will use the API key from environment variables # Only use parameters that get_llm() accepts evaluation_config = { "model_name": "claude-3-sonnet-20240229", # Correct Anthropic model name "provider": "anthropic", # Use Anthropic directly "temperature": 0, # Zero temp for consistent evaluation } - # Check if anthropic API key is available in the database - try: - anthropic_key = get_db_setting("llm.anthropic.api_key") - if anthropic_key: + # Check if anthropic API key is available in environment + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + if anthropic_key: + print( + "Found Anthropic API key in environment, will use Claude 3 Sonnet for grading" + ) + else: + print("Warning: No Anthropic API key found in environment") + print("Checking for alternative providers...") + + # Try OpenRouter as a fallback + openrouter_key = os.environ.get("OPENROUTER_API_KEY") + if openrouter_key: print( - "Found Anthropic API key in database, will use Claude 3 Sonnet for grading" + "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" ) - else: - print("Warning: No Anthropic API key found in database") - print("Checking for alternative providers...") - - # Try OpenRouter as a fallback - openrouter_key = get_db_setting("llm.openai_endpoint.api_key") - if openrouter_key: - print( - "Found OpenRouter API key, will use OpenRouter with Claude 3 Sonnet" - ) - evaluation_config = { - "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format - "provider": "openai_endpoint", - "openai_endpoint_url": "https://openrouter.ai/api/v1", - "temperature": 0, - } - except Exception as e: - print(f"Error checking for API keys: {e}") + evaluation_config = { + "model_name": "anthropic/claude-3-sonnet-20240229", # OpenRouter format + "provider": "openai_endpoint", + "openai_endpoint_url": "https://openrouter.ai/api/v1", + "temperature": 0, + } return evaluation_config @@ -142,12 +126,12 @@ def custom_get_evaluation_llm(custom_config=None): traceback.print_exc() # Create the evaluation output path - results_dir = os.path.dirname(results_path) - results_filename = os.path.basename(results_path) + results_dir = str(Path(results_path).parent) + results_filename = Path(results_path).name evaluation_filename = results_filename.replace( "_results.jsonl", "_evaluation.jsonl" ) - evaluation_path = os.path.join(results_dir, evaluation_filename) + evaluation_path = str(Path(results_dir) / evaluation_filename) # Run the grading print("Starting grading of benchmark results...") @@ -227,10 +211,10 @@ def generate_summary(evaluation_path, output_dir=None): # Determine output directory if output_dir is None: - output_dir = os.path.dirname(evaluation_path) + output_dir = str(Path(evaluation_path).parent) # Generate report - report_path = os.path.join(output_dir, "evaluation_report.md") + report_path = str(Path(output_dir) / "evaluation_report.md") generate_report( metrics=metrics, output_file=report_path, @@ -286,7 +270,7 @@ def main(): args = parser.parse_args() # Check if the results file exists - if not os.path.exists(args.results): + if not Path(args.results).exists(): print(f"Error: Results file not found: {args.results}") return 1
examples/detailed_report_how_to_improve_retrieval_augmented_generation_in_p.md+1800 −1800 modifiedexamples/elasticsearch_search_example.py+8 −11 modified@@ -3,27 +3,24 @@ 展示如何索引文档和搜索数据。(Demonstrates how to index documents and search data.) """ -import logging import sys from pathlib import Path +from loguru import logger + # 添加项目根目录到 Python 路径 (Add project root directory to Python path) sys.path.append(str(Path(__file__).parent.parent)) # Import after adding project root to path -from src.local_deep_research.utilities.es_utils import ( # noqa: E402 +from src.local_deep_research.utilities.es_utils import ( ElasticsearchManager, ) -from src.local_deep_research.web_search_engines.engines.search_engine_elasticsearch import ( # noqa: E402 +from src.local_deep_research.web_search_engines.engines.search_engine_elasticsearch import ( ElasticsearchSearchEngine, ) # 配置日志 (Configure logging) -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Loguru automatically handles logging configuration def index_sample_documents(): @@ -169,10 +166,10 @@ def main(): advanced_search_examples(index_name) except Exception as e: - logger.error( - f"运行示例时出错: {str(e)}" + logger.exception( + f"运行示例时出错: {e!s}" ) # Error running example: {str(e)} - logger.error( + logger.info( "请确保 Elasticsearch 正在运行,默认地址为 http://localhost:9200" ) # Make sure Elasticsearch is running, default address is http://localhost:9200
examples/example_browsecomp.py+6 −7 modified@@ -4,15 +4,14 @@ This helps debug issues with the BrowseComp dataset. """ -import logging import sys -# Configure logging -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger() +from loguru import logger + +# Logger is already imported from loguru +# Set debug level for this script +logger.remove() +logger.add(sys.stderr, level="DEBUG") # Add path to import local_deep_research sys.path.append(".")
examples/llm_integration/advanced_custom_llm.py+6 −5 modified@@ -9,14 +9,15 @@ """ import time -from typing import List, Optional, Any, Dict -from langchain_core.language_models import BaseChatModel -from langchain_core.messages import BaseMessage, AIMessage -from langchain_core.outputs import ChatResult, ChatGeneration +from typing import Any, Dict, List, Optional + from langchain_core.callbacks import CallbackManagerForLLMRun +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult from loguru import logger -from local_deep_research.api import quick_summary, detailed_research +from local_deep_research.api import detailed_research, quick_summary class RetryLLM(BaseChatModel):
examples/llm_integration/basic_custom_llm.py+6 −4 modified@@ -5,11 +5,13 @@ with LDR's research functions. """ +from typing import Any, List, Optional + from langchain_core.language_models import BaseChatModel -from langchain_core.messages import BaseMessage, AIMessage -from langchain_core.outputs import ChatResult, ChatGeneration -from typing import List, Optional, Any -from local_deep_research.api import quick_summary, detailed_research +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult + +from local_deep_research.api import detailed_research, quick_summary class CustomLLM(BaseChatModel):
examples/llm_integration/mock_llm_example.py+6 −5 modified@@ -9,13 +9,14 @@ - CI/CD pipelines """ -from typing import List, Optional, Any, Dict -from langchain_core.language_models import BaseChatModel -from langchain_core.messages import BaseMessage, AIMessage -from langchain_core.outputs import ChatResult, ChatGeneration import json +from typing import Any, Dict, List, Optional + +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult -from local_deep_research.api import quick_summary, generate_report +from local_deep_research.api import generate_report, quick_summary class MockLLM(BaseChatModel):
examples/optimization/browsecomp_optimization.py+14 −18 modified@@ -15,34 +15,30 @@ """ import json -import logging -import os import sys from datetime import datetime +from pathlib import Path + from local_deep_research.benchmarks.optimization import optimize_parameters # Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) - -# Configure logging to see progress -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) def main(): # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "examples", "optimization", "results", f"browsecomp_opt_{timestamp}" + from datetime import timezone + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"browsecomp_opt_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print( f"Starting BrowseComp optimization - results will be saved to {output_dir}" @@ -96,7 +92,7 @@ def main(): } with open( - os.path.join(output_dir, "browsecomp_optimization_summary.json"), "w" + Path(output_dir) / "browsecomp_optimization_summary.json", "w" ) as f: json.dump(summary, f, indent=2)
examples/optimization/example_multi_benchmark.py+12 −13 modified@@ -5,20 +5,19 @@ SimpleQA and BrowseComp benchmarks with custom weights. """ -import logging import os import sys from datetime import datetime +from pathlib import Path from typing import Any, Dict + # Print current directory and python path for debugging print(f"Current directory: {os.getcwd()}") print(f"Python path: {sys.path}") # Add appropriate paths -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) -) +sys.path.insert(0, str(Path(__file__).parent.parent.resolve())) try: # Try to import from the local module structure @@ -109,9 +108,7 @@ def optimize_for_speed(*args, **kwargs): }, 0.67 -# Set up logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +# Loguru automatically handles logging configuration def print_optimization_results(params: Dict[str, Any], score: float): @@ -129,7 +126,9 @@ def print_optimization_results(params: Dict[str, Any], score: float): def main(): """Run the multi-benchmark optimization examples.""" # Create a timestamp-based directory for results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + from datetime import timezone + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") output_dir = f"optimization_demo_{timestamp}" os.makedirs(output_dir, exist_ok=True) @@ -141,7 +140,7 @@ def main(): params1, score1 = optimize_parameters( query=query, n_trials=3, # Using a small number for quick demonstration - output_dir=os.path.join(output_dir, "simpleqa_only"), + output_dir=str(Path(output_dir) / "simpleqa_only"), ) print_optimization_results(params1, score1) @@ -150,7 +149,7 @@ def main(): params2, score2 = optimize_parameters( query=query, n_trials=3, # Using a small number for quick demonstration - output_dir=os.path.join(output_dir, "browsecomp_only"), + output_dir=str(Path(output_dir) / "browsecomp_only"), benchmark_weights={"browsecomp": 1.0}, ) print_optimization_results(params2, score2) @@ -160,7 +159,7 @@ def main(): params3, score3 = optimize_parameters( query=query, n_trials=5, # Using a small number for quick demonstration - output_dir=os.path.join(output_dir, "weighted_combination"), + output_dir=str(Path(output_dir) / "weighted_combination"), benchmark_weights={ "simpleqa": 0.6, # 60% weight for SimpleQA "browsecomp": 0.4, # 40% weight for BrowseComp @@ -173,7 +172,7 @@ def main(): params4, score4 = optimize_for_quality( query=query, n_trials=3, - output_dir=os.path.join(output_dir, "quality_focused"), + output_dir=str(Path(output_dir) / "quality_focused"), benchmark_weights={"simpleqa": 0.6, "browsecomp": 0.4}, ) print_optimization_results(params4, score4) @@ -183,7 +182,7 @@ def main(): params5, score5 = optimize_for_speed( query=query, n_trials=3, - output_dir=os.path.join(output_dir, "speed_focused"), + output_dir=str(Path(output_dir) / "speed_focused"), benchmark_weights={"simpleqa": 0.5, "browsecomp": 0.5}, ) print_optimization_results(params5, score5)
examples/optimization/example_optimization.py+13 −17 modified@@ -14,32 +14,28 @@ """ import json -import logging -import os -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path + # Import the optimization functionality from local_deep_research.benchmarks.optimization import ( optimize_parameters, ) -# Configure logging to see progress -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) +# Loguru automatically handles logging configuration def main(): # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "examples", - "optimization", - "results", - f"optimization_results_{timestamp}", + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"optimization_results_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print( f"Starting quick optimization demo - results will be saved to {output_dir}" @@ -72,7 +68,7 @@ def main(): query="SimpleQA quick demo", # Task descriptor search_tool="searxng", # Using SearXNG n_trials=2, # Just 2 trials for quick demo - output_dir=os.path.join(output_dir, "demo"), + output_dir=str(Path(output_dir) / "demo"), param_space=param_space, # Limited parameter space metric_weights={"quality": 0.5, "speed": 0.5}, ) @@ -86,7 +82,7 @@ def main(): "demo": {"parameters": balanced_params, "score": balanced_score}, } - with open(os.path.join(output_dir, "optimization_summary.json"), "w") as f: + with open(Path(output_dir) / "optimization_summary.json", "w") as f: json.dump(summary, f, indent=2) print(f"\nDemo complete! Results saved to {output_dir}")
examples/optimization/example_quick_optimization.py+12 −14 modified@@ -14,20 +14,15 @@ """ import json -import logging -import os import random import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, Tuple -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) +from loguru import logger -logger = logging.getLogger(__name__) +# Loguru automatically handles logging configuration def simulate_optimization( @@ -192,11 +187,14 @@ def optimize_for_quality( def main(): # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - "examples", "optimization", "results", f"optimization_demo_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"optimization_demo_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print( f"Starting quick optimization demo - results will be saved to {output_dir}" @@ -259,7 +257,7 @@ def main(): }, } - with open(os.path.join(output_dir, "optimization_summary.json"), "w") as f: + with open(Path(output_dir) / "optimization_summary.json", "w") as f: json.dump(summary, f, indent=2) print(
examples/optimization/gemini_optimization.py+14 −16 modified@@ -19,10 +19,12 @@ import argparse import json -import logging import os import sys -from datetime import datetime +from datetime import datetime, timezone +from pathlib import Path + +from loguru import logger # Import the optimization functionality from local_deep_research.benchmarks.optimization import ( @@ -31,13 +33,6 @@ optimize_parameters, ) -# Configure logging to see progress -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - def setup_gemini_config(api_key=None): """ @@ -101,14 +96,17 @@ def main(): return 1 # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") if args.output_dir: output_dir = args.output_dir else: - output_dir = os.path.join( - "examples", "optimization", "results", f"gemini_opt_{timestamp}" + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"gemini_opt_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print( f"Starting optimization with Gemini 2.0 Flash - results will be saved to {output_dir}" @@ -197,15 +195,15 @@ def main(): } with open( - os.path.join(output_dir, "gemini_optimization_summary.json"), "w" + Path(output_dir) / "gemini_optimization_summary.json", "w" ) as f: json.dump(summary, f, indent=2) print(f"\nOptimization complete! Results saved to {output_dir}") print(f"Recommended parameters for {args.mode} mode: {best_params}") - except Exception as e: - logger.exception(f"Error during optimization: {e}") + except Exception: + logger.exception("Error during optimization") return 1 return 0
examples/optimization/llm_multi_benchmark.py+12 −13 modified@@ -13,7 +13,8 @@ import argparse import os import sys -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, Optional from loguru import logger @@ -112,15 +113,15 @@ def main(): args = parser.parse_args() # Create timestamp-based directory for results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") if args.output_dir: output_dir = args.output_dir else: - output_dir = os.path.join( - "examples", - "optimization", - "results", - f"llm_multi_benchmark_{timestamp}", + output_dir = str( + Path("examples") + / "optimization" + / "results" + / f"llm_multi_benchmark_{timestamp}" ) os.makedirs(output_dir, exist_ok=True) @@ -215,9 +216,7 @@ def main(): # Save results to file import json - with open( - os.path.join(output_dir, "multi_benchmark_results.json"), "w" - ) as f: + with open(Path(output_dir) / "multi_benchmark_results.json", "w") as f: json.dump( { "timestamp": timestamp, @@ -235,11 +234,11 @@ def main(): ) print( - f"Results saved to {os.path.join(output_dir, 'multi_benchmark_results.json')}" + f"Results saved to {Path(output_dir) / 'multi_benchmark_results.json'}" ) - except Exception as e: - logger.error(f"Error running optimization: {e}") + except Exception: + logger.exception("Error running optimization") import traceback traceback.print_exc()
examples/optimization/multi_benchmark_simulation.py+6 −12 modified@@ -6,19 +6,13 @@ """ import json -import logging -import os import random import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, Optional, Tuple -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +from loguru import logger class BenchmarkSimulator: @@ -340,9 +334,9 @@ def print_optimization_results(params: Dict[str, Any], score: float): def main(): """Run the multi-benchmark optimization simulation.""" # Create a timestamp-based directory for results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") output_dir = "optimization_sim_" + timestamp - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) print("\n🔬 Multi-Benchmark Optimization Simulation 🔬") print(f"Results will be saved to: {output_dir}") @@ -393,7 +387,7 @@ def main(): }, } - results_file = os.path.join(output_dir, "multi_benchmark_results.json") + results_file = str(Path(output_dir) / "multi_benchmark_results.json") with open(results_file, "w") as f: # Convert all values to serializable types json.dump(
examples/optimization/multi_benchmark_speed_demo.py+2 −2 modified@@ -13,12 +13,12 @@ python ../examples/optimization/multi_benchmark_speed_demo.py """ -import os import sys +from pathlib import Path from typing import Any, Dict # Add src directory to Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")) +src_dir = str((Path(__file__).parent.parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir)
examples/optimization/run_gemini_benchmark.py+19 −23 modified@@ -15,25 +15,19 @@ """ import argparse -import logging -import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, List, Optional +from loguru import logger + # Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Loguru automatically handles logging configuration def setup_gemini_config(api_key: Optional[str] = None) -> Dict[str, Any]: @@ -117,13 +111,15 @@ def run_benchmarks( return {"error": "Failed to set up Gemini configuration"} # Create timestamp for output - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") if not output_dir: - output_dir = os.path.join( - project_root, "benchmark_results", f"gemini_eval_{timestamp}" + output_dir = str( + Path(project_root) + / "benchmark_results" + / f"gemini_eval_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) # Set benchmark list if not benchmarks: @@ -148,7 +144,7 @@ def run_benchmarks( search_model=gemini_config["model_name"], search_provider=gemini_config["provider"], endpoint_url=gemini_config["endpoint_url"], - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), ) elif benchmark.lower() == "browsecomp": logger.info( @@ -162,7 +158,7 @@ def run_benchmarks( search_model=gemini_config["model_name"], search_provider=gemini_config["provider"], endpoint_url=gemini_config["endpoint_url"], - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), ) else: logger.warning(f"Unknown benchmark: {benchmark}") @@ -185,7 +181,7 @@ def run_benchmarks( } except Exception as e: - logger.error(f"Error running {benchmark} benchmark: {e}") + logger.exception(f"Error running {benchmark} benchmark") import traceback traceback.print_exc() @@ -217,7 +213,7 @@ def run_benchmarks( logger.info("=" * 50) # Save summary to a file - summary_file = os.path.join(output_dir, "benchmark_summary.json") + summary_file = str(Path(output_dir) / "benchmark_summary.json") try: import json @@ -250,8 +246,8 @@ def run_benchmarks( indent=2, ) logger.info(f"Summary saved to {summary_file}") - except Exception as e: - logger.error(f"Error saving summary: {e}") + except Exception: + logger.exception("Error saving summary") return { "status": "complete",
examples/optimization/run_multi_benchmark.py+32 −37 modified@@ -13,25 +13,25 @@ python ../examples/optimization/run_multi_benchmark.py """ -import logging import os import sys -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict +from loguru import logger + # Add src directory to Python path -src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")) +src_dir = str((Path(__file__).parent.parent / "src").resolve()) if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Set the data directory with the database -data_dir = os.path.join(src_dir, "data") -if os.path.exists(os.path.join(data_dir, "ldr.db")): - print(f"Found database at {os.path.join(data_dir, 'ldr.db')}") - # Set environment variable to use this database - os.environ["LDR_DATA_DIR"] = data_dir -else: - print(f"Warning: Database not found at {os.path.join(data_dir, 'ldr.db')}") +# Use environment variables for configuration +# The system should be configured with proper environment variables: +# - ANTHROPIC_API_KEY for Anthropic API access +# - OPENROUTER_API_KEY for OpenRouter API access (if used) +# - LDR_DATA_DIR for data directory location (if needed) +data_dir = os.environ.get("LDR_DATA_DIR", str(Path(src_dir) / "data")) # Import benchmark optimization functions try: @@ -45,13 +45,6 @@ print("Current sys.path:", sys.path) sys.exit(1) -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - def print_optimization_results(params: Dict[str, Any], score: float): """Print optimization results in a nicely formatted way.""" @@ -68,16 +61,18 @@ def print_optimization_results(params: Dict[str, Any], score: float): def main(): """Run multi-benchmark optimization examples.""" # Create a timestamp-based directory for results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") # Put results in the data directory for easier access - if os.path.isdir(data_dir): - output_dir = os.path.join( - data_dir, "optimization_results", "multi_benchmark_" + timestamp + if Path(data_dir).is_dir(): + output_dir = str( + Path(data_dir) + / "optimization_results" + / f"multi_benchmark_{timestamp}" ) else: - output_dir = os.path.join( - "optimization_results", "multi_benchmark_" + timestamp + output_dir = str( + Path("optimization_results") / f"multi_benchmark_{timestamp}" ) os.makedirs(output_dir, exist_ok=True) @@ -154,7 +149,7 @@ def main(): quality_results = evaluator.evaluate( system_config=mini_system_config, num_examples=1, # Use just 1 example for speed - output_dir=os.path.join(output_dir, "simpleqa_test"), + output_dir=str(Path(output_dir) / "simpleqa_test"), ) print("Benchmark evaluation complete!") @@ -173,14 +168,14 @@ def main(): params1, score1 = optimize_parameters( query=query, param_space=tiny_param_space, # Use tiny param space - output_dir=os.path.join(output_dir, "simpleqa_only"), + output_dir=str(Path(output_dir) / "simpleqa_only"), n_trials=1, # Just one trial for testing benchmark_weights={"simpleqa": 1.0}, # SimpleQA only timeout=5, # Limit to 5 seconds ) print_optimization_results(params1, score1) except Exception as e: - logger.error(f"Error running SimpleQA optimization: {e}") + logger.exception("Error running SimpleQA optimization") print(f"Error: {e}") # Run 2: BrowseComp benchmark only (minimal test) @@ -193,7 +188,7 @@ def main(): bc_results = browsecomp_evaluator.evaluate( system_config=mini_system_config, num_examples=1, # Just 1 example for speed - output_dir=os.path.join(output_dir, "browsecomp_test"), + output_dir=str(Path(output_dir) / "browsecomp_test"), ) print("BrowseComp evaluation complete!") @@ -207,7 +202,7 @@ def main(): ) except Exception as e: - logger.error(f"Error running BrowseComp evaluation: {e}") + logger.exception("Error running BrowseComp evaluation") print(f"Error: {e}") # Run 3: Combined benchmark with weights (minimal test) @@ -224,7 +219,7 @@ def main(): combo_results = composite_evaluator.evaluate( system_config=mini_system_config, num_examples=1, # Just 1 example for speed - output_dir=os.path.join(output_dir, "combined_test"), + output_dir=str(Path(output_dir) / "combined_test"), ) print("Combined benchmark evaluation complete!") @@ -239,7 +234,7 @@ def main(): ) except Exception as e: - logger.error(f"Error running combined benchmark evaluation: {e}") + logger.exception("Error running combined benchmark evaluation") print(f"Error: {e}") # Run 4: Combined benchmark with speed optimization @@ -254,7 +249,7 @@ def main(): # Very minimal run with just 1 trial for demonstration params_speed, score_speed = optimize_for_speed( query=query, - output_dir=os.path.join(output_dir, "speed_optimization"), + output_dir=str(Path(output_dir) / "speed_optimization"), n_trials=1, # Just one trial for testing benchmark_weights={"simpleqa": 0.6, "browsecomp": 0.4}, timeout=5, # Limit to 5 seconds @@ -265,8 +260,8 @@ def main(): print("Speed metrics weighting: Quality (20%), Speed (80%)") except Exception as e: - logger.error( - f"Error running speed optimization with multi-benchmark: {e}" + logger.exception( + "Error running speed optimization with multi-benchmark" ) print(f"Error: {e}") @@ -282,7 +277,7 @@ def main(): # Very minimal run with just 1 trial for demonstration params_efficiency, score_efficiency = optimize_for_efficiency( query=query, - output_dir=os.path.join(output_dir, "efficiency_optimization"), + output_dir=str(Path(output_dir) / "efficiency_optimization"), n_trials=1, # Just one trial for testing benchmark_weights={"simpleqa": 0.6, "browsecomp": 0.4}, timeout=5, # Limit to 5 seconds @@ -295,8 +290,8 @@ def main(): ) except Exception as e: - logger.error( - f"Error running efficiency optimization with multi-benchmark: {e}" + logger.exception( + "Error running efficiency optimization with multi-benchmark" ) print(f"Error: {e}")
examples/optimization/run_optimization.py+6 −5 modified@@ -17,7 +17,8 @@ import json import os import sys -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path # Import the optimization functionality from local_deep_research.benchmarks.optimization import ( @@ -36,7 +37,7 @@ def main(): parser.add_argument("query", help="Research query to optimize for") parser.add_argument( "--output-dir", - default=os.path.join("examples", "optimization", "results"), + default=str(Path("examples") / "optimization" / "results"), help="Directory to save results", ) parser.add_argument( @@ -84,8 +85,8 @@ def main(): args = parser.parse_args() # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join(args.output_dir, f"opt_{timestamp}") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str(Path(args.output_dir) / f"opt_{timestamp}") os.makedirs(output_dir, exist_ok=True) print( @@ -185,7 +186,7 @@ def main(): "custom_weights": custom_weights, } - with open(os.path.join(output_dir, "optimization_summary.json"), "w") as f: + with open(Path(output_dir) / "optimization_summary.json", "w") as f: json.dump(summary, f, indent=2) return 0
examples/optimization/run_parallel_benchmark.py+21 −26 modified@@ -15,24 +15,17 @@ import argparse import concurrent.futures -import logging import os import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path -# Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) +from loguru import logger -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Add the src directory to the Python path +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) def run_simpleqa_benchmark( @@ -61,7 +54,7 @@ def run_simpleqa_benchmark( search_model=model, search_provider=provider, endpoint_url=endpoint_url, - output_dir=os.path.join(output_dir, "simpleqa"), + output_dir=str(Path(output_dir) / "simpleqa"), evaluation_provider="ANTHROPIC", evaluation_model="claude-3-7-sonnet-20250219", ) @@ -101,7 +94,7 @@ def run_browsecomp_benchmark( search_model=model, search_provider=provider, endpoint_url=endpoint_url, - output_dir=os.path.join(output_dir, "browsecomp"), + output_dir=str(Path(output_dir) / "browsecomp"), evaluation_provider="ANTHROPIC", evaluation_model="claude-3-7-sonnet-20250219", ) @@ -176,11 +169,13 @@ def main(): args = parser.parse_args() # Create timestamp for unique output directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_dir = os.path.join( - project_root, "benchmark_results", f"parallel_benchmark_{timestamp}" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + output_dir = str( + Path(project_root) + / "benchmark_results" + / f"parallel_benchmark_{timestamp}" ) - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) # Display start information print(f"Starting parallel benchmarks with {args.examples} examples each") @@ -224,15 +219,15 @@ def main(): try: simpleqa_results = simpleqa_future.result() print("SimpleQA benchmark completed successfully") - except Exception as e: - logger.error(f"Error in SimpleQA benchmark: {e}") + except Exception: + logger.exception("Error in SimpleQA benchmark") simpleqa_results = None try: browsecomp_results = browsecomp_future.result() print("BrowseComp benchmark completed successfully") - except Exception as e: - logger.error(f"Error in BrowseComp benchmark: {e}") + except Exception: + logger.exception("Error in BrowseComp benchmark") browsecomp_results = None # Calculate total time @@ -289,12 +284,12 @@ def main(): } with open( - os.path.join(output_dir, "parallel_benchmark_summary.json"), "w" + Path(output_dir) / "parallel_benchmark_summary.json", "w" ) as f: json.dump(summary, f, indent=2) - except Exception as e: - logger.error(f"Error saving summary: {e}") + except Exception: + logger.exception("Error saving summary") return 0
examples/optimization/strategy_benchmark_plan.py+28 −35 modified@@ -1,6 +1,6 @@ #!/usr/bin/env python3 # This script should be run from the project root directory using: -# cd /home/martin/code/LDR/local-deep-research +# cd /path/to/local-deep-research # python -m examples.optimization.strategy_benchmark_plan """ Strategy Benchmark Plan - Comprehensive Optuna-based optimization for search strategies @@ -10,34 +10,29 @@ """ import json -import logging import os import random import sys import time -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, Tuple +from loguru import logger + # Skip flake8 import order checks for this file due to sys.path manipulation # flake8: noqa: E402 # Add the src directory to the Python path before local imports -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) # Now we can import from the local project from local_deep_research.benchmarks.optimization.optuna_optimizer import ( OptunaOptimizer, ) -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Logger is already imported from loguru at the top # Number of examples to use in each benchmark experiment NUM_EXAMPLES = 500 @@ -82,10 +77,10 @@ def run_strategy_comparison(): f"Default questions per iteration from DB: {questions_per_iteration}" ) except Exception as e: - logger.error(f"Error initializing LLM or search settings: {str(e)}") - logger.error("Please check your database configuration") + logger.exception(f"Error initializing LLM or search settings: {e!s}") + logger.info("Please check your database configuration") return {"error": str(e)} - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") base_output_dir = f"strategy_benchmark_results_{timestamp}" os.makedirs(base_output_dir, exist_ok=True) @@ -132,8 +127,8 @@ def run_strategy_comparison(): # ====== EXPERIMENT 1: Quality-focused optimization ====== logger.info("Starting quality-focused benchmark with 500 examples") - quality_output_dir = os.path.join(base_output_dir, "quality_focused") - os.makedirs(quality_output_dir, exist_ok=True) + quality_output_dir = str(Path(base_output_dir) / "quality_focused") + Path(quality_output_dir).mkdir(parents=True, exist_ok=True) # Create optimizer for quality quality_optimizer = OptunaOptimizer( @@ -169,13 +164,13 @@ def run_strategy_comparison(): logger.info(f"Best quality score: {best_quality_score}") logger.info(f"Duration: {quality_end - quality_start} seconds") - with open(os.path.join(quality_output_dir, "results.json"), "w") as f: + with open(Path(quality_output_dir) / "results.json", "w") as f: json.dump(quality_result, f, indent=2) # ====== EXPERIMENT 2: Speed-focused optimization ====== logger.info("Starting speed-focused benchmark with 500 examples") - speed_output_dir = os.path.join(base_output_dir, "speed_focused") - os.makedirs(speed_output_dir, exist_ok=True) + speed_output_dir = str(Path(base_output_dir) / "speed_focused") + Path(speed_output_dir).mkdir(parents=True, exist_ok=True) # Create optimizer for speed speed_optimizer = OptunaOptimizer( @@ -211,13 +206,13 @@ def run_strategy_comparison(): logger.info(f"Best speed score: {best_speed_score}") logger.info(f"Duration: {speed_end - speed_start} seconds") - with open(os.path.join(speed_output_dir, "results.json"), "w") as f: + with open(Path(speed_output_dir) / "results.json", "w") as f: json.dump(speed_result, f, indent=2) # ====== EXPERIMENT 3: Balanced optimization ====== logger.info("Starting balanced benchmark with 500 examples") - balanced_output_dir = os.path.join(base_output_dir, "balanced") - os.makedirs(balanced_output_dir, exist_ok=True) + balanced_output_dir = str(Path(base_output_dir) / "balanced") + Path(balanced_output_dir).mkdir(parents=True, exist_ok=True) # Create optimizer for balanced approach balanced_optimizer = OptunaOptimizer( @@ -253,13 +248,13 @@ def run_strategy_comparison(): logger.info(f"Best balanced score: {best_balanced_score}") logger.info(f"Duration: {balanced_end - balanced_start} seconds") - with open(os.path.join(balanced_output_dir, "results.json"), "w") as f: + with open(Path(balanced_output_dir) / "results.json", "w") as f: json.dump(balanced_result, f, indent=2) # ====== EXPERIMENT 4: Multi-Benchmark (SimpleQA + BrowseComp) ====== logger.info("Starting multi-benchmark optimization with 500 examples") - multi_output_dir = os.path.join(base_output_dir, "multi_benchmark") - os.makedirs(multi_output_dir, exist_ok=True) + multi_output_dir = str(Path(base_output_dir) / "multi_benchmark") + Path(multi_output_dir).mkdir(parents=True, exist_ok=True) # Create optimizer with multi-benchmark weights multi_optimizer = OptunaOptimizer( @@ -296,7 +291,7 @@ def run_strategy_comparison(): logger.info(f"Best multi-benchmark score: {best_multi_score}") logger.info(f"Duration: {multi_end - multi_start} seconds") - with open(os.path.join(multi_output_dir, "results.json"), "w") as f: + with open(Path(multi_output_dir) / "results.json", "w") as f: json.dump(multi_result, f, indent=2) # ====== Save summary of all executions ====== @@ -305,7 +300,7 @@ def run_strategy_comparison(): ) execution_stats["timestamp"] = timestamp - with open(os.path.join(base_output_dir, "summary.json"), "w") as f: + with open(Path(base_output_dir) / "summary.json", "w") as f: json.dump(execution_stats, f, indent=2) # Generate summary report @@ -366,7 +361,7 @@ def generate_summary_report(base_dir, stats): """ # Write summary to file - with open(os.path.join(base_dir, "summary_report.md"), "w") as f: + with open(Path(base_dir) / "summary_report.md", "w") as f: f.write(summary_text) @@ -378,7 +373,7 @@ def run_strategy_simulation(num_examples=10): This fallback simulation mode doesn't require actual database or LLM access, making it useful for testing the script structure. """ - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") sim_output_dir = f"strategy_sim_results_{timestamp}" os.makedirs(sim_output_dir, exist_ok=True) @@ -432,7 +427,7 @@ def run_strategy_simulation(num_examples=10): best_params, best_score = sim_optimizer.optimize(strategy_param_space) except Exception as e: - logger.warning(f"Could not initialize real optimizer: {str(e)}") + logger.warning(f"Could not initialize real optimizer: {e!s}") logger.warning( "Falling back to pure simulation mode (no real benchmarks)" ) @@ -456,9 +451,7 @@ def run_strategy_simulation(num_examples=10): "best_score": best_score, } - with open( - os.path.join(sim_output_dir, "simulation_results.json"), "w" - ) as f: + with open(Path(sim_output_dir) / "simulation_results.json", "w") as f: json.dump(sim_result, f, indent=2) return sim_result
examples/optimization/update_llm_config.py+7 −15 modified@@ -18,23 +18,15 @@ """ import argparse -import logging -import os import sys +from pathlib import Path from typing import Optional -# Add the src directory to the Python path -project_root = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -) -sys.path.insert(0, os.path.join(project_root, "src")) +from loguru import logger -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Add the src directory to the Python path +project_root = str(Path(__file__).parent.parent.parent.resolve()) +sys.path.insert(0, str(Path(project_root) / "src")) def update_llm_configuration( @@ -66,7 +58,7 @@ def update_llm_configuration( update_db_setting, ) except ImportError: - logger.error( + logger.exception( "Could not import database utilities. Make sure you're in the correct directory." ) return False @@ -149,7 +141,7 @@ def update_llm_configuration( return True except Exception as e: - logger.error(f"Error updating LLM configuration: {str(e)}") + logger.exception(f"Error updating LLM configuration: {e!s}") return False
examples/run_benchmark.py+1 −8 modified@@ -6,22 +6,15 @@ """ import argparse -import logging import os + from local_deep_research.api.benchmark_functions import ( compare_configurations, evaluate_browsecomp, evaluate_simpleqa, ) -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - def main(): """Run benchmark examples."""
examples/show_env_vars.py+65 −0 added@@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Example script showing all available environment variables for LDR configuration. +This demonstrates the centralized environment variable management in SettingsManager. +""" + +import sys +import os +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from local_deep_research.settings.manager import SettingsManager + + +def main(): + print("=== Local Deep Research Environment Variables ===\n") + + all_env_vars = SettingsManager.get_all_env_vars() + + for category, vars_dict in all_env_vars.items(): + print(f"\n{category.upper()} VARIABLES:") + print("-" * 50) + + for var_name, description in sorted(vars_dict.items()): + # Check if currently set + current_value = os.environ.get(var_name) + if current_value: + # Mask sensitive values + if any( + sensitive in var_name + for sensitive in ["KEY", "PASSWORD", "SECRET"] + ): + display_value = "***SET***" + else: + display_value = current_value + status = f" [Current: {display_value}]" + else: + status = "" + + print(f" {var_name}") + print(f" {description}{status}") + + print("\n\n=== Environment Variable Formats ===") + print("-" * 50) + print( + "Settings can be overridden via environment variables using this format:" + ) + print(" Setting key: app.host") + print(" Environment variable: LDR_APP__HOST") + print( + "\nNote: Use double underscores (__) to separate setting path components." + ) + + print("\n\n=== Bootstrap Variables ===") + print("-" * 50) + print("The following variables must be set before database access:") + bootstrap_vars = SettingsManager.get_bootstrap_env_vars() + for var in sorted(bootstrap_vars.keys()): + print(f" - {var}") + + +if __name__ == "__main__": + main()
.github/workflows/accessibility-compliance-tests.yml+74 −0 added@@ -0,0 +1,74 @@ +name: Accessibility Tests + +on: + push: + branches: [ main, dev, develop ] + pull_request: + branches: [ main, dev, develop ] + workflow_dispatch: + +jobs: + accessibility-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v3 + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.11' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev + + - name: Install Python dependencies + run: | + pdm sync -d + + - name: Run accessibility compliance tests + run: | + # Run all accessibility tests + pdm run pytest tests/accessibility/ -v --tb=short || true + + # Run specific accessibility test files that exist + pdm run pytest tests/ui_tests/test_accessibility_compliance.py -v --tb=short || true + pdm run pytest tests/ui_tests/test_wcag_compliance.py -v --tb=short || true + pdm run pytest tests/ui_tests/test_keyboard_navigation.py -v --tb=short || true + pdm run pytest tests/ui_tests/test_screen_reader.py -v --tb=short || true + + # Run HTML structure tests that may contain accessibility checks + pdm run pytest tests/test_html_structure.py -v --tb=short || true + + - name: Generate accessibility report + if: always() + run: | + echo "Accessibility Tests Report" + echo "==========================" + echo "Tests check for:" + echo "- WCAG 2.1 Level AA compliance" + echo "- Keyboard navigation support" + echo "- Screen reader compatibility" + echo "- Color contrast ratios" + echo "- ARIA labels and roles" + echo "- Focus management" + + - name: Upload accessibility test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: accessibility-test-results + path: | + tests/accessibility/ + .pytest_cache/
.github/workflows/api-tests.yml+18 −6 modified@@ -35,21 +35,32 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Set up PDM uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev - name: Install dependencies run: | - pdm install - pdm install -d + # Install in development mode to ensure all modules are available + pdm sync -d + # Verify the package is installed correctly + pdm run python -c "import local_deep_research; print('Package imported:', local_deep_research.__file__)" + pdm run python -c "import local_deep_research.memory_cache; print('Memory cache module imported successfully')" - name: Start server for API unit tests run: | export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH echo "Starting server for API unit tests..." - pdm run ldr-web 2>&1 | tee server.log & + pdm run python -m local_deep_research.web.app 2>&1 | tee server.log & SERVER_PID=$! # Wait for server to start @@ -134,8 +145,9 @@ jobs: fi # Now try with pdm run ldr-web - echo "Starting server with pdm run ldr-web..." - pdm run ldr-web 2>&1 | tee server.log & + echo "Starting server with pdm run python..." + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app 2>&1 | tee server.log & SERVER_PID=$! # Give server a moment to start logging
.github/workflows/check-env-vars.yml+34 −0 added@@ -0,0 +1,34 @@ +name: Check Environment Variables + +on: + pull_request: + paths: + - '**.py' + - 'src/local_deep_research/settings/**' + - '.github/workflows/check-env-vars.yml' + push: + branches: + - main + - dev + paths: + - '**.py' + +jobs: + check-env-vars: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install loguru sqlalchemy sqlalchemy-utc platformdirs pydantic + + - name: Run environment variable validation + run: | + python tests/settings/env_vars/test_env_var_usage.py
.github/workflows/critical-ui-tests.yml+163 −0 added@@ -0,0 +1,163 @@ +name: Critical UI Tests + +on: + push: + branches: [ main, dev, develop ] + pull_request: + branches: [ main, dev, develop ] + workflow_dispatch: + +jobs: + critical-ui-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + + # No services needed - using SQLite databases + + steps: + - uses: actions/checkout@v3 + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.11' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y chromium-browser libsqlcipher-dev + + - name: Install Python dependencies + run: | + pdm sync -d + pdm run playwright install chromium + + - name: Set up test directories + run: | + mkdir -p ~/.local/share/local-deep-research/encrypted_databases + mkdir -p ~/.local/share/local-deep-research + + - name: Initialize database + env: + TEST_ENV: true + run: | + cd src + cat > init_db.py << 'EOF' + import os + from pathlib import Path + # Ensure data directory exists + data_dir = Path.home() / '.local' / 'share' / 'local-deep-research' + data_dir.mkdir(parents=True, exist_ok=True) + (data_dir / 'encrypted_databases').mkdir(parents=True, exist_ok=True) + + from local_deep_research.database.auth_db import init_auth_database, get_auth_db_session + from local_deep_research.database.models.auth import User + from local_deep_research.database.encrypted_db import db_manager + + # Initialize auth database + init_auth_database() + + # Create test user in auth database (no password stored) + session = get_auth_db_session() + user = User(username='test_admin') + session.add(user) + session.commit() + session.close() + + # Create user's encrypted database with password + engine = db_manager.create_user_database('test_admin', 'testpass123') + print('Database initialized successfully') + EOF + pdm run python init_db.py + + - name: Start test server + env: + FLASK_ENV: testing + TEST_ENV: true + SECRET_KEY: test-secret-key-for-ci + run: | + cd src + # Start server and get its PID + pdm run python -m local_deep_research.web.app > server.log 2>&1 & + SERVER_PID=$! + echo "Server PID: $SERVER_PID" + + # Wait for server to start + for i in {1..30}; do + if curl -f http://127.0.0.1:5000 2>/dev/null; then + echo "Server is ready after $i seconds" + break + fi + # Check if process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "Server process died!" + echo "Server log:" + cat server.log + exit 1 + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + + # Final check + if ! curl -f http://127.0.0.1:5000 2>/dev/null; then + echo "Server failed to start after 30 seconds" + echo "Server log:" + cat server.log + exit 1 + fi + + - name: Install Node.js dependencies for UI tests + run: | + cd tests/ui_tests + npm install + + - name: Create screenshots directory + run: | + mkdir -p tests/ui_tests/screenshots + + - name: Run critical UI tests + env: + CI: true + HEADLESS: true + run: | + cd tests/ui_tests + + # Critical authentication and research submission tests + echo "Running critical authentication test..." + node test_auth_flow.js || exit 1 + + echo "Running critical research submission tests..." + node test_research_submit.js || exit 1 + node test_research_simple.js || exit 1 + + echo "Running critical export test..." + node test_export_functionality.js || exit 1 + + echo "Running critical concurrent limits test..." + node test_concurrent_limit.js || exit 1 + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: critical-ui-test-artifacts + path: | + tests/ui_tests/screenshots/ + src/server.log + + - name: Generate test report + if: always() + run: | + echo "Critical UI Tests completed" + if [ -f tests/ui_tests/test_report.json ]; then + cat tests/ui_tests/test_report.json + fi
.github/workflows/enhanced-tests.yml+27 −30 modified@@ -23,49 +23,46 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install - pdm install -d + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Run enhanced test framework tests run: | - export LDR_USE_FALLBACK_LLM=true - export CI=true - pdm run pytest -v \ - tests/test_wikipedia_url_security.py \ - tests/test_search_engines_enhanced.py \ - tests/test_utils.py \ - -x \ - --tb=short + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e CI=true \ + -w /app \ + ldr-test \ + sh -c "python -m pytest -v \ + tests/test_wikipedia_url_security.py \ + tests/test_search_engines_enhanced.py \ + tests/test_utils.py \ + -x \ + --tb=short" - name: Run fixture tests run: | - export LDR_USE_FALLBACK_LLM=true - export CI=true - # Test that fixtures can be imported and used - pdm run python -c " + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e CI=true \ + -w /app \ + ldr-test \ + sh -c 'python -c " from tests.fixtures.search_engine_mocks import SearchEngineMocks, validate_wikipedia_url from tests.mock_fixtures import get_mock_search_results from tests.mock_modules import create_mock_llm_config - print('✅ All fixture imports successful') + print(\"✅ All fixture imports successful\") # Test URL validation - assert validate_wikipedia_url('https://en.wikipedia.org/wiki/Test') == True - assert validate_wikipedia_url('https://evil.com/wiki/Test') == False - print('✅ URL validation tests passed') + assert validate_wikipedia_url(\"https://en.wikipedia.org/wiki/Test\") == True + assert validate_wikipedia_url(\"https://evil.com/wiki/Test\") == False + print(\"✅ URL validation tests passed\") # Test mock data results = get_mock_search_results() assert len(results) == 2 - print('✅ Mock data tests passed') - " + print(\"✅ Mock data tests passed\") + "' - name: Upload test results if: always()
.github/workflows/extended-ui-tests.yml+178 −0 added@@ -0,0 +1,178 @@ +name: Extended UI Tests + +on: + push: + branches: [ main, dev ] + pull_request: + types: [opened, synchronize, reopened] + schedule: + # Run nightly at 2 AM UTC + - cron: '0 2 * * *' + workflow_dispatch: + +jobs: + extended-ui-tests: + runs-on: ubuntu-latest + name: Extended UI Test Suite + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev xvfb + + - name: Install dependencies + run: | + pdm sync -d + cd tests/ui_tests && npm install + + - name: Install browser + run: | + npx puppeteer browsers install chrome + + - name: Setup directories + run: | + mkdir -p data + mkdir -p tests/ui_tests/screenshots + mkdir -p tests/ui_tests/results + + - name: Start server + run: | + export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app & + echo $! > server.pid + + # Wait for server + for i in {1..30}; do + if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then + echo "Server ready" + break + fi + sleep 1 + done + + - name: Run critical UI tests + run: | + export DISPLAY=:99 + cd tests/ui_tests + + # Create test runner for extended tests + cat > run_extended_tests.js << 'EOF' + const { spawn } = require('child_process'); + const path = require('path'); + + const tests = [ + // Critical flows not in main test suite + { name: 'Research Submit', file: 'test_research_submit.js' }, + { name: 'Research Cancellation', file: 'test_research_cancellation.js' }, + { name: 'Export Functionality', file: 'test_export_functionality.js' }, + { name: 'Complete Workflow', file: 'test_complete_workflow.js' }, + { name: 'Concurrent Limit', file: 'test_concurrent_limit.js' }, + { name: 'Multi Research', file: 'test_multi_research.js' }, + + // Additional research tests + { name: 'Research Simple', file: 'test_research_simple.js' }, + { name: 'Research Form', file: 'test_research_form.js' }, + { name: 'Research API', file: 'test_research_api.js' }, + + // History and navigation + { name: 'History Page', file: 'test_history_page.js' }, + { name: 'Full Navigation', file: 'test_full_navigation.js' }, + + // Advanced features + { name: 'Queue Simple', file: 'test_queue_simple.js' }, + { name: 'Direct Mode', file: 'test_direct_mode.js' } + ]; + + let passed = 0; + let failed = 0; + + async function runTest(test) { + console.log(`\nRunning: ${test.name}`); + return new Promise((resolve) => { + const testProcess = spawn('node', [test.file], { + stdio: 'inherit', + env: { ...process.env, HEADLESS: 'true' } + }); + + const timeout = setTimeout(() => { + testProcess.kill(); + console.log(`⏱️ ${test.name} timed out`); + failed++; + resolve(); + }, 90000); // 90 second timeout per test + + testProcess.on('close', (code) => { + clearTimeout(timeout); + if (code === 0) { + console.log(`✅ ${test.name} passed`); + passed++; + } else { + console.log(`❌ ${test.name} failed`); + failed++; + } + resolve(); + }); + }); + } + + async function runAll() { + console.log('Starting Extended UI Test Suite\n'); + + for (const test of tests) { + // Check if file exists before trying to run + const fs = require('fs'); + if (fs.existsSync(test.file)) { + await runTest(test); + } else { + console.log(`⚠️ Skipping ${test.name} - file not found`); + } + } + + console.log(`\n📊 Results: ${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); + } + + runAll(); + EOF + + xvfb-run -a -s "-screen 0 1920x1080x24" node run_extended_tests.js + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: extended-ui-screenshots + path: tests/ui_tests/screenshots/ + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: extended-ui-results + path: tests/ui_tests/results/ + + - name: Stop server + if: always() + run: | + if [ -f server.pid ]; then + kill $(cat server.pid) || true + fi
.github/workflows/file-whitelist-check.yml+23 −7 modified@@ -37,9 +37,11 @@ jobs: "^\.gitignore$" "^\.gitkeep$" ".*\.gitkeep$" + ".*\.gitignore$" "^\.pre-commit-config\.yaml$" "^\.isort\.cfg$" "^\.coveragerc$" + "^\.secrets\.baseline$" "^pytest\.ini$" "^LICENSE$" "^README$" @@ -162,11 +164,19 @@ jobs: "src/local_deep_research/advanced_search_system/.*\.py$" "src/local_deep_research/benchmarks/.*\.py$" "src/local_deep_research/web/static/js/components/.*\.js$" + "src/local_deep_research/database/.*\.py$" + "src/local_deep_research/web/queue/.*\.py$" + "src/local_deep_research/api/.*\.py$" + "src/local_deep_research/settings/.*\.py$" + "src/local_deep_research/memory_cache/.*\.py$" + "src/local_deep_research/web/auth/.*\.py$" + "src/local_deep_research/news/.*\.py$" "docs/.*\.md$" "tests/.*\.py$" ".*test.*\.py$" ".*mock.*\.py$" ".*example.*\.py$" + "scripts/audit_.*\.py$" ) # Check if file matches whitelist patterns @@ -198,10 +208,13 @@ jobs: "docs/.*token.*\.md$" "tests/.*\.py$" ".*test.*\.py$" + "\.secrets\.baseline$" + ".*session_passwords\.py$" + ".*change_password\.html$" ) # Check if filename looks suspicious - if echo "$file" | grep -iE "(secret|password|token|api[_-]?key|\.key$|\.pem$|\.p12$|\.pfx$|\.env$)" >/dev/null; then + if echo "$file" | grep -iE "(secret|password|token|\.key$|\.pem$|\.p12$|\.pfx$|\.env$)" >/dev/null; then # Check if filename matches whitelist patterns FILENAME_WHITELISTED=false for pattern in "${SAFE_FILENAME_PATTERNS[@]}"; do @@ -242,18 +255,21 @@ jobs: if [ -f "$file" ] && [ -r "$file" ]; then # Skip HTML files and other safe file types for entropy checks if ! echo "$file" | grep -qE "\.(html|css|js|json|yml|yaml|md)$"; then - # Look for base64-like strings or hex strings that are suspiciously long - if grep -E "[a-zA-Z0-9+/]{40,}={0,2}|[a-f0-9]{40,}" "$file" >/dev/null 2>&1; then - # Exclude common false positives - if ! grep -iE "(sha256|md5|hash|test|example|fixture|integrity)" "$file" >/dev/null 2>&1; then - HIGH_ENTROPY_VIOLATIONS+=("$file") + # Skip news_strategy.py which contains example categories in prompts + if ! echo "$file" | grep -qE "news_strategy\.py$"; then + # Look for base64-like strings or hex strings that are suspiciously long + if grep -E "[a-zA-Z0-9+/]{40,}={0,2}|[a-f0-9]{40,}" "$file" >/dev/null 2>&1; then + # Exclude common false positives + if ! grep -iE "(sha256|md5|hash|test|example|fixture|integrity)" "$file" >/dev/null 2>&1; then + HIGH_ENTROPY_VIOLATIONS+=("$file") + fi fi fi fi fi # Check for hardcoded paths (Unix/Windows) - if ! echo "$file" | grep -qE "(test|mock|example|\.md$|docker|Docker|\.yml$|\.yaml$)"; then + if ! echo "$file" | grep -qE "(test|mock|example|\.md$|docker|Docker|\.yml$|\.yaml$|config/paths\.py$)"; then # Look for absolute paths and user home directories if grep -E "(/home/[a-zA-Z0-9_-]+|/Users/[a-zA-Z0-9_-]+|C:\\\\Users\\\\[a-zA-Z0-9_-]+|/opt/|/var/|/etc/|/usr/local/)" "$file" >/dev/null 2>&1; then # Exclude common false positives and Docker volume mounts
.github/workflows/followup-research-tests.yml+127 −0 added@@ -0,0 +1,127 @@ +name: Follow-up Research Tests + +on: + push: + paths: + - 'src/local_deep_research/followup_research/**' + - 'src/local_deep_research/advanced_search_system/strategies/contextual_followup_strategy.py' + - 'src/local_deep_research/search_system.py' + - 'tests/test_followup_api.py' + - 'tests/ui_tests/test_followup_research.js' + - '.github/workflows/followup-research-tests.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/local_deep_research/followup_research/**' + - 'src/local_deep_research/advanced_search_system/strategies/contextual_followup_strategy.py' + - 'tests/test_followup_api.py' + - 'tests/ui_tests/test_followup_research.js' + +jobs: + test-followup-research: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PDM + uses: pdm-project/setup-pdm@v3 + with: + python-version: '3.11' + cache: true + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install dependencies + run: | + pdm sync -d + cd tests/ui_tests && npm install + + - name: Install browser + run: | + npx playwright install chromium + + - name: Create test directories + run: | + mkdir -p data + mkdir -p tests/ui_tests/screenshots/followup + mkdir -p tests/ui_tests/results/followup + + - name: Start server + run: | + export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app 2>&1 | tee server.log & + echo $! > server.pid + + # Wait for server + for i in {1..30}; do + if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then + echo "Server ready after $i seconds" + break + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + + # Final check + curl -f http://localhost:5000/api/v1/health || (echo "Server failed to start" && cat server.log && exit 1) + + - name: Run API tests + run: | + pdm run pytest tests/test_followup_api.py -v --tb=short + + - name: Run UI tests + env: + HEADLESS: true + DISPLAY: ':99' + run: | + export DISPLAY=:99 + cd tests/ui_tests + + # Run follow-up research UI test + timeout 300 node test_followup_research.js || { + echo "Follow-up research test failed or timed out" + exit 1 + } + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: followup-test-screenshots + path: tests/ui_tests/screenshots/followup/ + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: followup-test-results + path: | + tests/ui_tests/results/followup/ + server.log + + - name: Stop server + if: always() + run: | + if [ -f server.pid ]; then + kill $(cat server.pid) || true + fi + + - name: Test Summary + if: always() + run: | + echo "Follow-up Research Tests completed" + echo "API tests and UI tests have been executed" + if [ -f tests/ui_tests/results/followup/test_report.json ]; then + cat tests/ui_tests/results/followup/test_report.json + fi
.github/workflows/infrastructure-tests.yml+7 −12 modified@@ -22,17 +22,8 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install Python dependencies - run: | - pdm install + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Set up Node.js uses: actions/setup-node@v4 @@ -45,7 +36,11 @@ jobs: - name: Run Python infrastructure tests run: | - pdm run pytest tests/infrastructure_tests/test_*.py -v --color=yes + docker run --rm \ + -v $PWD:/app \ + -w /app \ + ldr-test \ + sh -c "cd /app && python -m pytest tests/infrastructure_tests/test_*.py -v --color=yes --no-header -rN" - name: Run JavaScript infrastructure tests run: |
.github/workflows/label-fixed-in-dev.yml+67 −0 added@@ -0,0 +1,67 @@ +name: Auto-label Fixed Issues in Dev +on: + pull_request: + types: [closed] + branches: [dev] + +jobs: + label-linked-issues: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read + steps: + - name: Add "fixed in dev" label to linked issues + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ''; + const title = pr.title || ''; + + // Combine title and body for searching + const text = `${title} ${body}`; + + // Find issue references with keywords (fixes, closes, resolves, etc.) + const keywordPattern = /(close[sd]?|fix(es|ed)?|resolve[sd]?)\s+#(\d+)/gi; + const matches = text.matchAll(keywordPattern); + + // Also find simple #123 references without keywords + const simplePattern = /#(\d+)/g; + const simpleMatches = text.matchAll(simplePattern); + + // Collect all issue numbers + const issueNumbers = new Set(); + + // Add issues with keywords (definitely linked) + for (const match of matches) { + issueNumbers.add(match[3]); + } + + // Add simple references (might be linked) + for (const match of simpleMatches) { + issueNumbers.add(match[1]); + } + + // Label each issue + for (const issueNumber of issueNumbers) { + try { + console.log(`Adding "fixed in dev" label to issue #${issueNumber}`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parseInt(issueNumber), + labels: ['fixed in dev'] + }); + } catch (error) { + console.log(`Could not label issue #${issueNumber}: ${error.message}`); + // Continue with other issues even if one fails + } + } + + if (issueNumbers.size === 0) { + console.log('No linked issues found in PR'); + } else { + console.log(`Labeled ${issueNumbers.size} issue(s) as "fixed in dev"`); + }
.github/workflows/llm-tests.yml+69 −46 modified@@ -27,45 +27,55 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install - pdm install -d + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Run LLM registry tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - pdm run pytest tests/test_llm/test_llm_registry.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_llm_registry.py -v" - name: Run LLM integration tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - pdm run pytest tests/test_llm/test_llm_integration.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_llm_integration.py -v" - name: Run API LLM integration tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - pdm run pytest tests/test_llm/test_api_llm_integration.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_api_llm_integration.py -v" - name: Run LLM edge case tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - pdm run pytest tests/test_llm/test_llm_edge_cases.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_llm_edge_cases.py -v" - name: Run LLM benchmark tests run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - pdm run pytest tests/test_llm/test_llm_benchmarks.py -v + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -w /app \ + ldr-test \ + sh -c "python -m pytest tests/test_llm/test_llm_benchmarks.py -v" llm-example-tests: runs-on: ubuntu-latest @@ -75,32 +85,45 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Test basic custom LLM example run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - timeout 60s pdm run python examples/llm_integration/basic_custom_llm.py || true + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "timeout 60s python examples/llm_integration/basic_custom_llm.py || true" - name: Test mock LLM example run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - timeout 60s pdm run python examples/llm_integration/mock_llm_example.py || true - - - name: Test advanced custom LLM example + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "timeout 60s python examples/llm_integration/mock_llm_example.py || true" + + - name: Test provider switching example + run: | + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "timeout 60s python examples/llm_integration/switch_providers.py || true" + + - name: Test custom research example run: | - export PYTHONPATH=$PYTHONPATH:$(pwd) - export LDR_USE_FALLBACK_LLM=true - timeout 60s pdm run python examples/llm_integration/advanced_custom_llm.py || true + docker run --rm \ + -v $PWD:/app \ + -e PYTHONPATH=/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -w /app \ + ldr-test \ + sh -c "timeout 60s python examples/llm_integration/custom_research_example.py || true"
.github/workflows/metrics-analytics-tests.yml+175 −0 added@@ -0,0 +1,175 @@ +name: Metrics & Analytics Tests + +on: + push: + paths: + - 'src/local_deep_research/metrics/**' + - 'src/local_deep_research/web/static/js/metrics/**' + - 'src/local_deep_research/web/templates/metrics/**' + - 'tests/ui_tests/test_metrics*.js' + - 'tests/ui_tests/test_cost*.js' + pull_request: + types: [opened, synchronize, reopened] + schedule: + # Run weekly on Sundays at 1 AM UTC + - cron: '0 1 * * 0' + workflow_dispatch: + +jobs: + metrics-analytics-tests: + runs-on: ubuntu-latest + name: Metrics & Analytics Test Suite + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev xvfb + + - name: Install dependencies + run: | + pdm sync -d + cd tests/ui_tests && npm install + + - name: Install browser + run: | + npx puppeteer browsers install chrome + + - name: Setup directories + run: | + mkdir -p data + mkdir -p tests/ui_tests/screenshots/metrics + mkdir -p tests/ui_tests/results/metrics + + - name: Start server + run: | + export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app & + echo $! > server.pid + + # Wait for server + for i in {1..30}; do + if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then + echo "Server ready" + break + fi + sleep 1 + done + + - name: Run metrics and analytics tests + run: | + export DISPLAY=:99 + cd tests/ui_tests + + # Create test runner for metrics tests + cat > run_metrics_tests.js << 'EOF' + const { spawn } = require('child_process'); + + const tests = [ + // Star reviews analytics + { name: 'Star Reviews', file: 'test_star_reviews.js' } + + // TODO: Add these tests as they are implemented: + // { name: 'Metrics Dashboard', file: 'test_metrics_dashboard.js' }, + // { name: 'Cost Analytics', file: 'test_cost_analytics.js' }, + // { name: 'Metrics Verification', file: 'test_metrics_verification.js' }, + // { name: 'Metrics Full Flow', file: 'test_metrics_full_flow.js' }, + // { name: 'Metrics Display', file: 'test_metrics_display.js' }, + // { name: 'Metrics Browser', file: 'test_metrics_browser.js' }, + // { name: 'Metrics with LLM', file: 'test_metrics_with_llm.js' }, + // { name: 'Simple Metrics', file: 'test_simple_metrics.js' }, + // { name: 'Simple Cost', file: 'test_simple_cost.js' } + ]; + + let passed = 0; + let failed = 0; + + async function runTest(test) { + console.log(`\n📊 Running: ${test.name}`); + return new Promise((resolve) => { + const testProcess = spawn('node', [test.file], { + stdio: 'inherit', + env: { ...process.env, HEADLESS: 'true' } + }); + + const timeout = setTimeout(() => { + testProcess.kill(); + console.log(`⏱️ ${test.name} timed out`); + failed++; + resolve(); + }, 60000); // 60 second timeout per test + + testProcess.on('close', (code) => { + clearTimeout(timeout); + if (code === 0) { + console.log(`✅ ${test.name} passed`); + passed++; + } else { + console.log(`❌ ${test.name} failed`); + failed++; + } + resolve(); + }); + }); + } + + async function runAll() { + console.log('Starting Metrics & Analytics Test Suite\n'); + + for (const test of tests) { + // Check if file exists before trying to run + const fs = require('fs'); + if (fs.existsSync(test.file)) { + await runTest(test); + } else { + console.log(`⚠️ Skipping ${test.name} - file not found`); + } + } + + console.log(`\n📈 Results: ${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); + } + + runAll(); + EOF + + xvfb-run -a -s "-screen 0 1920x1080x24" node run_metrics_tests.js + + - name: Upload metrics screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: metrics-screenshots + path: tests/ui_tests/screenshots/metrics/ + + - name: Upload metrics results + if: always() + uses: actions/upload-artifact@v4 + with: + name: metrics-test-results + path: tests/ui_tests/results/metrics/ + + - name: Stop server + if: always() + run: | + if [ -f server.pid ]; then + kill $(cat server.pid) || true + fi
.github/workflows/news-tests.yml+101 −0 added@@ -0,0 +1,101 @@ +name: News Feature Tests + +on: + push: + paths: + - 'src/local_deep_research/web/static/js/news/**' + - 'src/local_deep_research/web/templates/news/**' + - 'src/local_deep_research/news/**' + - 'tests/ui_tests/test_news*.js' + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +jobs: + news-tests: + runs-on: ubuntu-latest + name: News Feature Test Suite + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev xvfb + + - name: Install dependencies + run: | + pdm sync -d + cd tests/ui_tests && npm install + + - name: Install browser + run: | + npx puppeteer browsers install chrome + + - name: Setup directories + run: | + mkdir -p data + mkdir -p tests/ui_tests/screenshots/news + + - name: Start server + run: | + export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH + pdm run python -m local_deep_research.web.app & + echo $! > server.pid + + # Wait for server + for i in {1..30}; do + if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then + echo "Server ready" + break + fi + sleep 1 + done + + - name: Run news feature tests + run: | + export DISPLAY=:99 + cd tests/ui_tests + + # Run all news-related tests + for test in test_news*.js; do + if [ -f "$test" ] && [[ ! "$test" == *"debug"* ]]; then + echo "Running $test..." + xvfb-run -a -s "-screen 0 1920x1080x24" node "$test" || true + fi + done + + - name: Run news API tests + run: | + pdm run pytest tests/test_news/ -v --tb=short || true + + - name: Upload news test screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: news-test-screenshots + path: tests/ui_tests/screenshots/news/ + + - name: Stop server + if: always() + run: | + if [ -f server.pid ]; then + kill $(cat server.pid) || true + fi
.github/workflows/performance-tests.yml+256 −0 added@@ -0,0 +1,256 @@ +name: Performance Tests + +on: + push: + branches: [ main, dev, develop ] + pull_request: + branches: [ main, dev, develop ] + workflow_dispatch: + schedule: + # Run performance tests weekly on Sunday at 3 AM UTC + - cron: '0 3 * * 0' + +jobs: + performance-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + + services: + redis: + image: redis:alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v3 + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.11' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-perf-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-perf- + ${{ runner.os }}-pip- + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y redis-tools + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev + + - name: Install Python dependencies + run: | + pdm sync -d + + - name: Set up test directories + run: | + mkdir -p ~/.local/share/local-deep-research/encrypted_databases + mkdir -p ~/.local/share/local-deep-research + + - name: Initialize database with test data + run: | + cd src + cat > init_perf_db.py << 'EOF' + import os + from pathlib import Path + # Ensure data directory exists + data_dir = Path.home() / '.local' / 'share' / 'local-deep-research' + data_dir.mkdir(parents=True, exist_ok=True) + (data_dir / 'encrypted_databases').mkdir(parents=True, exist_ok=True) + + from local_deep_research.database.auth_db import init_auth_database, get_auth_db_session + from local_deep_research.database.models.auth import User + from local_deep_research.database.encrypted_db import db_manager + + # Initialize auth database + init_auth_database() + + # Create test users and their databases + session = get_auth_db_session() + for i in range(10): + username = f'perftest{i}' + # Create user in auth database (no password stored) + user = User(username=username) + session.add(user) + session.commit() + + # Create user's encrypted database with password + engine = db_manager.create_user_database(username, 'testpass123') + session.close() + print('Databases initialized successfully') + EOF + pdm run python init_perf_db.py + + - name: Start application server + env: + REDIS_URL: redis://localhost:6379 + FLASK_ENV: testing + SECRET_KEY: perf-test-secret-key + run: | + cd src + # Start server and get its PID + pdm run python -m local_deep_research.web.app > server.log 2>&1 & + SERVER_PID=$! + echo "Server PID: $SERVER_PID" + + # Wait for server to start + for i in {1..30}; do + if curl -f http://127.0.0.1:5000 2>/dev/null; then + echo "Server is ready after $i seconds" + break + fi + # Check if process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "Server process died!" + echo "Server log:" + cat server.log + exit 1 + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + + # Final check + if ! curl -f http://127.0.0.1:5000 2>/dev/null; then + echo "Server failed to start after 30 seconds" + echo "Server log:" + cat server.log + exit 1 + fi + + - name: Run database performance tests + run: | + # Note: No dedicated performance test directory exists + # Run any available database tests + pdm run pytest tests/database/test_benchmark_models.py -v || true + + - name: Run API performance tests + run: | + # Note: No dedicated API performance tests exist + echo "Skipping API performance tests - no test files available" + + - name: Run load tests with Locust + run: | + # Note: No performance test directory or locustfile exists + echo "Skipping Locust load tests - no test files available" + + - name: Run memory profiling + run: | + # Profile memory usage of key operations + cat > memory_profile.py << 'EOFSCRIPT' + import tracemalloc + import requests + import json + + tracemalloc.start() + + # Simulate API calls + base_url = 'http://127.0.0.1:5000' + session = requests.Session() + + # Login + session.post(f'{base_url}/auth/login', json={'username': 'perftest0', 'password': 'testpass123'}) + + # Make various API calls + for i in range(10): + session.get(f'{base_url}/api/research/history') + session.get(f'{base_url}/api/user/stats') + + current, peak = tracemalloc.get_traced_memory() + print(f'Current memory usage: {current / 1024 / 1024:.2f} MB') + print(f'Peak memory usage: {peak / 1024 / 1024:.2f} MB') + + tracemalloc.stop() + EOFSCRIPT + pdm run python memory_profile.py + + - name: Run frontend performance tests + run: | + cd tests/ui_tests + npm install + + # Run available metrics tests as performance indicators + echo "Running metrics tests as performance indicators..." + node test_metrics.js || true + + - name: Analyze Python performance hotspots + run: | + # Profile a typical request flow + cat > profile_requests.py << 'EOFSCRIPT' + import requests + import time + + session = requests.Session() + base_url = 'http://127.0.0.1:5000' + + # Login + session.post(f'{base_url}/auth/login', json={'username': 'perftest0', 'password': 'testpass123'}) + + # Simulate typical user actions + for _ in range(5): + session.get(f'{base_url}/api/research/history') + session.get(f'{base_url}/api/models') + time.sleep(0.5) + EOFSCRIPT + pdm run py-spy record -d 10 -o profile.svg -- pdm run python profile_requests.py || true + + - name: Generate performance report + if: always() + run: | + echo "Performance Test Report" + echo "======================" + echo "" + echo "Test Categories:" + echo "- Database query performance" + echo "- API response times" + echo "- Memory usage profiling" + echo "- Load testing results" + echo "- Frontend rendering performance" + echo "" + + # Check for performance regression + if [ -d .benchmarks ]; then + echo "Benchmark History Available" + cat > check_benchmarks.py << 'EOFSCRIPT' + import json + import os + + if os.path.exists('.benchmarks'): + benchmark_files = [f for f in os.listdir('.benchmarks') if f.endswith('.json')] + if benchmark_files: + latest = sorted(benchmark_files)[-1] + with open(f'.benchmarks/{latest}') as f: + data = json.load(f) + if 'benchmarks' in data: + print('Recent benchmark results:') + for bench in data['benchmarks'][:5]: + print(f" {bench.get('name', 'N/A')}: {bench.get('stats', {}).get('mean', 'N/A'):.4f}s") + EOFSCRIPT + pdm run python check_benchmarks.py || true + fi + + - name: Upload performance artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: performance-test-artifacts + path: | + .benchmarks/ + profile.svg + tests/performance/*.html + src/server.log
.github/workflows/pre-commit.yml+6 −1 modified@@ -17,7 +17,12 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev - name: Run pre-commit uses: pre-commit/action@v3.0.1
.github/workflows/publish.yml+11 −1 modified@@ -16,10 +16,20 @@ jobs: with: fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Set up PDM uses: pdm-project/setup-pdm@v4 with: - python-version: '3.10' + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev - name: Build package run: pdm build
.github/workflows/security-tests.yml+143 −0 added@@ -0,0 +1,143 @@ +name: Security Tests + +on: + push: + branches: [ main, dev, develop ] + pull_request: + branches: [ main, dev, develop ] + workflow_dispatch: + schedule: + # Run security tests daily at 2 AM UTC + - cron: '0 2 * * *' + +jobs: + security-tests: + runs-on: ubuntu-latest + timeout-minutes: 25 + + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_security_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-security-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-security- + ${{ runner.os }}-pip- + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.11' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev + + - name: Install dependencies + run: | + pdm sync -d + pdm add bandit safety sqlparse pytest pytest-cov --no-sync + pdm sync + + - name: Run Bandit security linter + run: | + bandit -r src/ -f json -o bandit-report.json || true + if [ -f bandit-report.json ]; then + echo "Bandit security scan completed" + fi + + - name: Check for known vulnerabilities with Safety + run: | + safety check --json > safety-report.json || true + echo "Safety vulnerability scan completed" + + - name: Run SQL injection tests + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_security_db + run: | + # Run SQL injection specific tests + python -m pytest tests/security/test_sql_injection.py -v --tb=short || true + python -m pytest tests/test_sql_injection_prevention.py -v --tb=short || true + + - name: Run XSS prevention tests + run: | + python -m pytest tests/security/test_xss_prevention.py -v --tb=short || true + python -m pytest tests/test_xss_prevention.py -v --tb=short || true + + - name: Run CSRF protection tests + run: | + python -m pytest tests/security/test_csrf_protection.py -v --tb=short || true + python -m pytest tests/test_csrf_protection.py -v --tb=short || true + + - name: Run authentication security tests + run: | + python -m pytest tests/security/test_auth_security.py -v --tb=short || true + python -m pytest tests/test_password_security.py -v --tb=short || true + python -m pytest tests/test_session_security.py -v --tb=short || true + + - name: Run API security tests + run: | + python -m pytest tests/security/test_api_security.py -v --tb=short || true + python -m pytest tests/test_rate_limiting.py -v --tb=short || true + python -m pytest tests/test_api_authentication.py -v --tb=short || true + + - name: Run input validation tests + run: | + python -m pytest tests/security/test_input_validation.py -v --tb=short || true + python -m pytest tests/test_input_sanitization.py -v --tb=short || true + + - name: Check for hardcoded secrets + run: | + # Check for potential secrets in code + grep -r -E "(api[_-]?key|secret[_-]?key|password|token)" src/ --include="*.py" | \ + grep -v -E "(os\.environ|getenv|config\[|placeholder|example|test)" | \ + grep -E "=\s*['\"]" || echo "No hardcoded secrets found" + + - name: Generate security report + if: always() + run: | + echo "Security Test Report" + echo "===================" + echo "" + if [ -f bandit-report.json ]; then + echo "Bandit Security Issues:" + python -c "import json; data=json.load(open('bandit-report.json')); print(f' High: {len([i for i in data.get(\"results\", []) if i[\"issue_severity\"] == \"HIGH\"])}'); print(f' Medium: {len([i for i in data.get(\"results\", []) if i[\"issue_severity\"] == \"MEDIUM\"])}'); print(f' Low: {len([i for i in data.get(\"results\", []) if i[\"issue_severity\"] == \"LOW\"])}')" || true + fi + echo "" + if [ -f safety-report.json ]; then + echo "Known Vulnerabilities:" + python -c "import json; data=json.load(open('safety-report.json')); print(f' Total: {len(data) if isinstance(data, list) else 0}')" || true + fi + + - name: Upload security reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: security-reports + path: | + bandit-report.json + safety-report.json
.github/workflows/tests.yml+63 −68 modified@@ -17,17 +17,8 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Set up Node.js uses: actions/setup-node@v4 @@ -40,8 +31,23 @@ jobs: - name: Run unit tests only run: | - export LDR_USE_FALLBACK_LLM=true - cd tests && pdm run python run_all_tests.py unit-only + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e LDR_TESTING_WITH_MOCKS=true \ + -w /app \ + ldr-test \ + sh -c "python -m pytest -v \ + tests/test_settings_manager.py \ + tests/test_google_pse.py \ + tests/test_wikipedia_url_security.py \ + tests/test_search_engines_enhanced.py \ + tests/test_utils.py \ + tests/test_database_initialization.py \ + tests/rate_limiting/ \ + tests/retriever_integration/ \ + tests/feature_tests/ \ + tests/fix_tests/" - name: Run JavaScript infrastructure tests run: | @@ -56,17 +62,8 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install Python dependencies - run: | - pdm install + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Set up Node.js uses: actions/setup-node@v4 @@ -79,7 +76,11 @@ jobs: - name: Run Python infrastructure tests run: | - pdm run pytest tests/infrastructure_tests/test_*.py -v + docker run --rm \ + -v $PWD:/app \ + -w /app \ + ldr-test \ + sh -c "pytest tests/infrastructure_tests/test_*.py -v" - name: Run JavaScript infrastructure tests run: | @@ -95,23 +96,18 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install - pdm install -d + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Run CI test profile run: | - export LDR_USE_FALLBACK_LLM=true - cd tests && python run_all_tests.py ci --no-server-start + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e LDR_TESTING_WITH_MOCKS=true \ + -w /app \ + ldr-test \ + sh -c "cd tests && python run_all_tests.py ci --no-server-start" # Full tests for PRs to main/dev branches and main branch pushes full-tests: @@ -126,48 +122,41 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - - - name: Install dependencies - run: | - pdm install - pdm install -d + - name: Build Docker image + run: docker build --target ldr-test -t ldr-test . - name: Install Node.js for UI tests uses: actions/setup-node@v4 with: node-version: '18' - name: Install UI test dependencies - run: cd tests && npm install + run: | + export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + cd tests && npm install - name: Install infrastructure test dependencies run: | cd tests/infrastructure_tests && npm install - - name: Start application server + - name: Start application server in Docker run: | - export LDR_USE_FALLBACK_LLM=true - echo "Starting web server..." - pdm run ldr-web 2>&1 | tee server.log & - echo $! > server.pid + docker run -d \ + --name ldr-server \ + -p 5000:5000 \ + -e LDR_USE_FALLBACK_LLM=true \ + ldr-test ldr-web # Wait for server to be ready for i in {1..60}; do if curl -f http://localhost:5000/api/v1/health 2>/dev/null; then echo "Server is ready after $i seconds" break fi - if ! kill -0 $(cat server.pid) 2>/dev/null; then - echo "Server process died!" + if ! docker ps --filter "name=ldr-server" --filter "status=running" -q | grep -q .; then + echo "Server container died!" echo "Server log:" - cat server.log + docker logs ldr-server exit 1 fi echo "Waiting for server... ($i/60)" @@ -178,15 +167,21 @@ jobs: if ! curl -f http://localhost:5000/api/v1/health 2>/dev/null; then echo "Server failed to start after 60 seconds" echo "Server log:" - cat server.log + docker logs ldr-server exit 1 fi - name: Run optimized full test suite (including UI tests) run: | - export LDR_USE_FALLBACK_LLM=true - export CI=true - cd tests && pdm run python run_all_tests.py full + docker run --rm \ + -v $PWD:/app \ + -e LDR_USE_FALLBACK_LLM=true \ + -e LDR_TESTING_WITH_MOCKS=true \ + -e CI=true \ + --network host \ + -w /app \ + ldr-test \ + sh -c "cd tests && python run_all_tests.py full" - name: Run JavaScript infrastructure tests run: | @@ -195,10 +190,8 @@ jobs: - name: Stop server if: always() run: | - if [ -f server.pid ]; then - kill $(cat server.pid) || true - rm server.pid - fi + docker stop ldr-server || true + docker rm ldr-server || true - name: Upload test results and screenshots if: always() @@ -209,3 +202,5 @@ jobs: tests/test_results.json tests/screenshots/ tests/ui_tests/screenshots/ +# Force CI rebuild +# Force CI rebuild - network issue
.github/workflows/text-optimization-tests.yml+57 −0 added@@ -0,0 +1,57 @@ +name: Text Optimization Tests + +on: + push: + paths: + - 'src/local_deep_research/text_optimization/**' + - 'tests/text_optimization/**' + - '.github/workflows/text-optimization-tests.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/local_deep_research/text_optimization/**' + - 'tests/text_optimization/**' + workflow_dispatch: + +jobs: + test-text-optimization: + runs-on: ubuntu-latest + name: Text Optimization Module Tests + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up PDM + uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev + + - name: Install dependencies + run: | + # Install in development mode to ensure all modules are available + pdm sync -d + + - name: Run text optimization tests + run: | + pdm run pytest tests/text_optimization/ -v --tb=short + + - name: Run coverage report + run: | + pdm run pytest tests/text_optimization/ --cov=src/local_deep_research/text_optimization --cov-report=xml --cov-report=term + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: text-optimization + name: text-optimization-coverage
.github/workflows/ui-tests.yml+13 −4 modified@@ -29,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Set up Node.js uses: actions/setup-node@v4 @@ -38,11 +38,18 @@ jobs: - name: Set up PDM uses: pdm-project/setup-pdm@v4 + with: + python-version: '3.12' + + - name: Install system dependencies for SQLCipher + run: | + sudo apt-get update + sudo apt-get install -y libsqlcipher-dev - name: Install dependencies run: | - pdm install - pdm install -d + # Install in development mode to ensure all modules are available + pdm sync -d cd tests && npm install - name: Install browser dependencies @@ -59,8 +66,9 @@ jobs: - name: Start application server run: | export LDR_USE_FALLBACK_LLM=true + export PYTHONPATH=$PWD/src:$PYTHONPATH echo "Starting web server..." - pdm run ldr-web 2>&1 | tee server.log & + pdm run python -m local_deep_research.web.app 2>&1 | tee server.log & echo $! > server.pid - name: Wait for server to be ready @@ -93,6 +101,7 @@ jobs: run: | export LDR_USE_FALLBACK_LLM=true export DISPLAY=:99 + export SKIP_FLAKY_TESTS=true cd tests/ui_tests && xvfb-run -a -s "-screen 0 1920x1080x24" node run_all_tests.js - name: Upload UI test screenshots
.github/workflows/version_check.yml+1 −1 modified@@ -26,7 +26,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13.2' + python-version: '3.12' - name: Check and auto-bump version if: "!contains(github.event.head_commit.message, 'chore: auto-bump version')"
.gitignore+38 −4 modified@@ -5,24 +5,32 @@ # Allow directories (needed for git to traverse) !*/ -# Allow specific source code files -!*.py +# Allow specific source code files (but not .py in root - see below) !*.js !*.html !*.css !*.json -!*.md !*.yml !*.yaml !*.sh !*.cfg !*.ipynb !*.template +# Allow Python files everywhere except root +!**/*.py +/*.py + +# Explicitly allow specific Python files in root that are needed +# (Comment out any you don't need) + # Allow specific project files !LICENSE !README !README.md +!CHANGELOG.md +!CONTRIBUTING.md +!LICENSE.md !Dockerfile !pyproject.toml !pdm.lock @@ -33,6 +41,16 @@ !.pre-commit-config.yaml !.isort.cfg + +# Block JSON files in root directory (except package.json which is explicitly allowed above) +/*.json +!package.json + +# Allow pre-commit hooks +!.pre-commit-hooks/ +!.pre-commit-hooks/*.py + + # Block all other dot files/folders .* .*/ @@ -61,6 +79,12 @@ examples/benchmarks/examples/benchmarks/results/ examples/*/results/ **/results/*/ +# Block test output and JSON files +tests/**/results/ +tests/**/*.json +tests/ui_tests/*.json +tests/ui_tests/results/ + # Still block Python cache and build artifacts even if they match patterns above __pycache__/ **/__pycache__/ @@ -195,4 +219,14 @@ screenshots/ # Ignore cookiecutter-generated files. docker-compose.*.yml -scripts/*.sh +backup/ + +# Security - ignore generated secret keys +.secret_key +.cache_key_secret + +# Allow MD files only in specific directories +!docs/**/*.md +!tests/**/*.md +!examples/**/*.md +!cookiecutter-docker/**/*.md
pdm.lock+1681 −1396 modified.pre-commit-config.yaml+62 −0 modified@@ -21,3 +21,65 @@ repos: args: [ --fix ] # Run the formatter. - id: ruff-format + - repo: local + hooks: + - id: custom-code-checks + name: Custom Code Quality Checks + entry: .pre-commit-hooks/custom-checks.py + language: script + files: \.py$ + description: "Check for loguru usage, logger.exception vs logger.error, raw SQL, redundant {e} in logger.exception, and non-UTC datetime usage" + - id: check-env-vars + name: Environment Variable Access Check + entry: .pre-commit-hooks/check-env-vars.py + language: script + files: \.py$ + description: "Ensure environment variables are accessed through SettingsManager" + - id: file-whitelist-check + name: File Whitelist Security Check + entry: .pre-commit-hooks/file-whitelist-check.sh + language: script + types: [file] + description: "Check for allowed file types and file sizes" + - id: check-deprecated-db-connection + name: Check for deprecated get_db_connection usage + entry: .pre-commit-hooks/check-deprecated-db.py + language: script + files: \.py$ + description: "Ensure code uses per-user database connections instead of deprecated shared database" + - id: check-ldr-db-usage + name: Check for ldr.db usage + entry: .pre-commit-hooks/check-ldr-db.py + language: script + files: \.py$ + description: "Prevent usage of shared ldr.db database - all data must use per-user encrypted databases" + - id: check-research-id-type + name: Check for incorrect research_id type hints + entry: .pre-commit-hooks/check-research-id-type.py + language: script + files: \.py$ + description: "Ensure research_id is always treated as string/UUID, never as int" + - id: check-deprecated-settings-wrapper + name: Check for deprecated get_setting_from_db_main_thread usage + entry: .pre-commit-hooks/check-deprecated-settings-wrapper.py + language: script + files: \.py$ + description: "Prevent usage of redundant get_setting_from_db_main_thread wrapper - use SettingsManager directly" + - id: check-datetime-timezone + name: Check DateTime columns have timezone + entry: scripts/pre_commit/check_datetime_timezone.py + language: script + files: \.py$ + description: "Ensure all SQLAlchemy DateTime columns have timezone=True" + - id: check-session-context-manager + name: Check for try/finally session patterns + entry: .pre-commit-hooks/check-session-context-manager.py + language: script + files: \.py$ + description: "Ensure SQLAlchemy sessions use context managers instead of try/finally blocks" + - id: check-pathlib-usage + name: Check for os.path usage + entry: .pre-commit-hooks/check-pathlib-usage.py + language: script + files: \.py$ + description: "Enforce using pathlib.Path instead of os.path for better cross-platform compatibility"
.pre-commit-hooks/check-deprecated-db.py+112 −0 added@@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to check for usage of deprecated database connection methods. +Ensures code uses per-user database connections instead of the deprecated shared database. +""" + +import sys +import re +import os + +# Set environment variable for pre-commit hooks to allow unencrypted databases +os.environ["LDR_ALLOW_UNENCRYPTED"] = "true" + + +def check_file(filepath): + """Check a single file for deprecated database usage.""" + issues = [] + + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + lines = content.split("\n") + + # Pattern to detect get_db_connection usage + db_connection_pattern = re.compile(r"\bget_db_connection\s*\(") + + # Pattern to detect direct imports of get_db_connection + import_pattern = re.compile( + r"from\s+[\w.]+\s+import\s+.*\bget_db_connection\b" + ) + + # Check for usage + for i, line in enumerate(lines, 1): + if db_connection_pattern.search(line): + issues.append( + f"{filepath}:{i}: Usage of deprecated get_db_connection()" + ) + + if import_pattern.search(line): + issues.append( + f"{filepath}:{i}: Import of deprecated get_db_connection" + ) + + # Also check for patterns that suggest using shared database + if "from ..web.models.database import get_db_connection" in content: + issues.append( + f"{filepath}: Imports deprecated get_db_connection from database module" + ) + + # Check for SQLite connections to shared database + shared_db_pattern = re.compile(r"sqlite3\.connect\s*\([^)]*ldr\.db") + for i, line in enumerate(lines, 1): + if ( + shared_db_pattern.search(line) + and "get_user_db_session" not in content + ): + issues.append( + f"{filepath}:{i}: Direct SQLite connection to shared database - use get_user_db_session() instead" + ) + + return issues + + +def main(): + """Main function to check all provided files.""" + if len(sys.argv) < 2: + print("No files to check") + return 0 + + all_issues = [] + + for filepath in sys.argv[1:]: + # Skip the database.py file itself (it contains the deprecated function definition) + if "web/models/database.py" in filepath: + continue + + # Skip migration scripts and test files that might legitimately need shared DB access + if any( + skip in filepath + for skip in ["migrations/", "tests/", "test_", ".pre-commit-hooks/"] + ): + continue + + issues = check_file(filepath) + all_issues.extend(issues) + + if all_issues: + print("❌ Deprecated database connection usage detected!\n") + print("The shared database (get_db_connection) is deprecated.") + print( + "Please use get_user_db_session(username) for per-user database access.\n" + ) + print("Issues found:") + for issue in all_issues: + print(f" - {issue}") + print("\nExample fix:") + print(" # Old (deprecated):") + print(" conn = get_db_connection()") + print(" cursor = conn.cursor()") + print(" # ... SQL query execution ...") + print() + print(" # New (correct):") + print(" from flask import session") + print(" username = session.get('username', 'anonymous')") + print(" with get_user_db_session(username) as db_session:") + print(" results = db_session.query(Model).filter(...).all()") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-deprecated-settings-wrapper.py+141 −0 added@@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to warn about usage of deprecated get_setting_from_db_main_thread wrapper. + +This function is deprecated because it's redundant - use the SettingsManager directly +with proper session context management instead. + +NOTE: This hook currently only warns about usage to allow gradual migration. +""" + +import ast +import sys +from pathlib import Path +from typing import List, Tuple + + +def check_file(filepath: Path) -> List[Tuple[int, str]]: + """Check a single Python file for deprecated wrapper usage. + + Args: + filepath: Path to the Python file to check + + Returns: + List of (line_number, error_message) tuples + """ + errors = [] + + try: + content = filepath.read_text() + + # Check for imports + if "get_setting_from_db_main_thread" in content: + lines = content.split("\n") + for i, line in enumerate(lines, 1): + if "get_setting_from_db_main_thread" in line: + if "from" in line and "import" in line: + errors.append( + ( + i, + "Importing deprecated get_setting_from_db_main_thread - use SettingsManager with proper session context", + ) + ) + elif not line.strip().startswith("#"): + # Check if it's a function call (not in a comment) + errors.append( + ( + i, + "Using deprecated get_setting_from_db_main_thread - use SettingsManager with get_user_db_session context manager", + ) + ) + + # Also parse the AST to catch any dynamic usage + try: + tree = ast.parse(content) + for node in ast.walk(tree): + if ( + isinstance(node, ast.Name) + and node.id == "get_setting_from_db_main_thread" + ): + errors.append( + ( + node.lineno, + "Reference to deprecated get_setting_from_db_main_thread function", + ) + ) + except SyntaxError: + # File has syntax errors, skip AST check + pass + + except Exception as e: + print(f"Error checking {filepath}: {e}", file=sys.stderr) + + return errors + + +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_errors = [] + + for filepath_str in files_to_check: + filepath = Path(filepath_str) + + # Skip non-Python files + if filepath.suffix != ".py": + continue + + # Skip the file that defines the function (db_utils.py) and this hook itself + if filepath.name in [ + "db_utils.py", + "check-deprecated-settings-wrapper.py", + ]: + continue + + errors = check_file(filepath) + if errors: + all_errors.append((filepath, errors)) + + if all_errors: + print( + "\n⚠️ Warning: Found usage of deprecated get_setting_from_db_main_thread wrapper:\n" + ) + print( + "This function is deprecated and will be removed in a future version." + ) + print( + "For Flask routes/views, use SettingsManager with proper session context:\n" + ) + print( + " from local_deep_research.database.session_context import get_user_db_session" + ) + print( + " from local_deep_research.utilities.db_utils import get_settings_manager" + ) + print("") + print(" with get_user_db_session(username) as db_session:") + print( + " settings_manager = get_settings_manager(db_session, username)" + ) + print(" value = settings_manager.get_setting(key, default)") + print("\nFor background threads, use settings_snapshot pattern.") + print("\nFiles with deprecated usage:") + + for filepath, errors in all_errors: + print(f"\n {filepath}:") + for line_num, error_msg in errors: + print(f" Line {line_num}: {error_msg}") + + # Return 1 to fail and enforce migration + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-env-vars.py+208 −0 added@@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Simple pre-commit hook to check for direct os.environ usage. +This is a lightweight check - comprehensive validation happens in CI. +""" + +import ast +import sys + + +# Files/patterns where direct os.environ access is allowed +ALLOWED_PATTERNS = { + # Configuration and settings + "settings/", + "config/", + # Tests + "test_", + "_test.py", + "tests/", + # Scripts and utilities + "scripts/", + ".pre-commit-hooks/", + # Example and optimization scripts + "examples/", + # Specific modules that need direct access (bootstrap/config) + "log_utils.py", # Logging configuration + "server_config.py", # Server configuration + # Database initialization (needs env vars before DB exists) + "alembic/", + "migrations/", + "encrypted_db.py", + "sqlcipher_utils.py", +} + +# System environment variables that are always allowed +SYSTEM_VARS = { + "PATH", + "HOME", + "USER", + "PYTHONPATH", + "TMPDIR", + "TEMP", + "DEBUG", + "CI", + "GITHUB_ACTIONS", + "TESTING", # External testing flag +} + + +class EnvVarChecker(ast.NodeVisitor): + def __init__(self, filename: str): + self.filename = filename + self.errors = [] + + def visit_Call(self, node): + # Check for os.environ.get() or os.getenv() + is_environ_get = False + env_var_name = None + + # Pattern 1: os.environ.get("VAR_NAME") + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and isinstance(node.func.value, ast.Attribute) + and node.func.value.attr == "environ" + and isinstance(node.func.value.value, ast.Name) + and node.func.value.value.id == "os" + ): + is_environ_get = True + if node.args and isinstance(node.args[0], ast.Constant): + env_var_name = node.args[0].value + + # Pattern 2: os.getenv("VAR_NAME") + elif ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "getenv" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "os" + ): + is_environ_get = True + if node.args and isinstance(node.args[0], ast.Constant): + env_var_name = node.args[0].value + + if is_environ_get and env_var_name: + # Allow system vars + if env_var_name in SYSTEM_VARS: + return self.generic_visit(node) + + # Check if file is in allowed location + if not self._is_file_allowed(): + # For LDR_ vars, suggest using SettingsManager + if env_var_name.startswith("LDR_"): + self.errors.append( + ( + node.lineno, + f"Environment variable '{env_var_name}' should be accessed through SettingsManager, not os.environ", + ) + ) + # For other vars, generic warning + else: + self.errors.append( + ( + node.lineno, + f"Direct access to environment variable '{env_var_name}' - consider using SettingsManager", + ) + ) + + self.generic_visit(node) + + def visit_Subscript(self, node): + # Check for os.environ["VAR_NAME"] pattern + if ( + isinstance(node.value, ast.Attribute) + and node.value.attr == "environ" + and isinstance(node.value.value, ast.Name) + and node.value.value.id == "os" + and isinstance(node.slice, ast.Constant) + ): + env_var_name = node.slice.value + + # Allow system vars + if env_var_name in SYSTEM_VARS: + return self.generic_visit(node) + + if not self._is_file_allowed(): + if env_var_name.startswith("LDR_"): + self.errors.append( + ( + node.lineno, + f"Environment variable '{env_var_name}' should be accessed through SettingsManager, not os.environ", + ) + ) + else: + self.errors.append( + ( + node.lineno, + f"Direct access to environment variable '{env_var_name}' - consider using SettingsManager", + ) + ) + + self.generic_visit(node) + + def _is_file_allowed(self) -> bool: + """Check if this file is allowed to use os.environ directly.""" + for pattern in ALLOWED_PATTERNS: + if pattern in self.filename: + return True + return False + + +def check_file(filename: str) -> bool: + """Check a single Python file for direct env var access.""" + if not filename.endswith(".py"): + return True + + try: + with open(filename, "r", encoding="utf-8") as f: + content = f.read() + except Exception as e: + print(f"Error reading {filename}: {e}") + return False + + try: + tree = ast.parse(content, filename=filename) + checker = EnvVarChecker(filename) + checker.visit(tree) + + if checker.errors: + print(f"\n{filename}:") + for line_num, error in checker.errors: + print(f" Line {line_num}: {error}") + return False + + except SyntaxError: + # Skip files with syntax errors + pass + except Exception as e: + print(f"Error parsing {filename}: {e}") + return False + + return True + + +def main(): + """Main function to check all staged Python files.""" + if len(sys.argv) < 2: + print("Usage: check-env-vars.py <file1> <file2> ...") + sys.exit(1) + + files_to_check = sys.argv[1:] + has_errors = False + + for filename in files_to_check: + if not check_file(filename): + has_errors = True + + if has_errors: + print("\n⚠️ Direct environment variable access detected!") + print("\nFor LDR_ variables, use SettingsManager instead of os.environ") + print("See issue #598 for migration details") + print("\nNote: Full validation runs in CI") + sys.exit(1) + else: + sys.exit(0) + + +if __name__ == "__main__": + main()
.pre-commit-hooks/check-ldr-db.py+93 −0 added@@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to prevent usage of ldr.db (shared database). +All data should be stored in per-user encrypted databases. +""" + +import sys +import re +import os +from pathlib import Path + +# Set environment variable for pre-commit hooks to allow unencrypted databases +os.environ["LDR_ALLOW_UNENCRYPTED"] = "true" + + +def check_file_for_ldr_db(file_path): + """Check if a file contains references to ldr.db.""" + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + except (UnicodeDecodeError, IOError): + # Skip binary files or files we can't read + return [] + + # Pattern to find ldr.db references + pattern = r"ldr\.db" + matches = [] + + for line_num, line in enumerate(content.splitlines(), 1): + if re.search(pattern, line, re.IGNORECASE): + # Skip comments and documentation + stripped = line.strip() + if not ( + stripped.startswith("#") + or stripped.startswith("//") + or stripped.startswith("*") + or stripped.startswith('"""') + or stripped.startswith("'''") + ): + matches.append((line_num, line.strip())) + + return matches + + +def main(): + """Main function to check all Python files for ldr.db usage.""" + # Get all Python files from command line arguments + files_to_check = sys.argv[1:] if len(sys.argv) > 1 else [] + + if not files_to_check: + # If no files specified, check all Python files + src_dir = Path(__file__).parent.parent / "src" + files_to_check = list(src_dir.rglob("*.py")) + + violations = [] + + for file_path in files_to_check: + file_path = Path(file_path) + + # Only skip this hook file itself + if file_path.name == "check-ldr-db.py": + continue + + matches = check_file_for_ldr_db(file_path) + if matches: + violations.append((file_path, matches)) + + if violations: + print("❌ DEPRECATED ldr.db USAGE DETECTED!") + print("=" * 60) + print("The shared ldr.db database is deprecated.") + print("All data must be stored in per-user encrypted databases.") + print("=" * 60) + + for file_path, matches in violations: + print(f"\n📄 {file_path}") + for line_num, line in matches: + print(f" Line {line_num}: {line}") + + print("\n" + "=" * 60) + print("MIGRATION REQUIRED:") + print("1. Store user-specific data in encrypted per-user databases") + print("2. Use get_user_db_session() instead of shared database access") + print("3. See migration guide in documentation") + print("=" * 60) + + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-pathlib-usage.py+217 −0 added@@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to enforce using pathlib.Path instead of os.path. + +This hook checks for os.path usage in Python files and suggests +using pathlib.Path instead for better cross-platform compatibility +and more modern Python code. +""" + +import argparse +import ast +import sys +from pathlib import Path +from typing import List, Tuple + + +class OsPathChecker(ast.NodeVisitor): + """AST visitor to find os.path usage.""" + + def __init__(self, filename: str): + self.filename = filename + self.violations: List[Tuple[int, str]] = [] + self.has_os_import = False + self.has_os_path_import = False + + def visit_Import(self, node: ast.Import) -> None: + """Check for 'import os' statements.""" + for alias in node.names: + if alias.name == "os": + self.has_os_import = True + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + """Check for 'from os import path' or 'from os.path import ...' statements.""" + if node.module == "os" and any( + alias.name == "path" for alias in node.names + ): + self.has_os_path_import = True + self.violations.append( + ( + node.lineno, + "Found 'from os import path' - use 'from pathlib import Path' instead", + ) + ) + elif node.module == "os.path": + self.has_os_path_import = True + imported_names = [alias.name for alias in node.names] + self.violations.append( + ( + node.lineno, + f"Found 'from os.path import {', '.join(imported_names)}' - use pathlib.Path methods instead", + ) + ) + self.generic_visit(node) + + def visit_Attribute(self, node: ast.Attribute) -> None: + """Check for os.path.* usage.""" + if ( + isinstance(node.value, ast.Name) + and node.value.id == "os" + and node.attr == "path" + and self.has_os_import + ): + # This is os.path usage + # Try to get the specific method being called + parent = getattr(node, "parent", None) + if parent and isinstance(parent, ast.Attribute): + method = parent.attr + # Skip os.path.expandvars as it has no pathlib equivalent + if method == "expandvars": + return + suggestion = get_pathlib_equivalent(f"os.path.{method}") + else: + suggestion = "Use pathlib.Path instead" + + self.violations.append( + (node.lineno, f"Found os.path usage - {suggestion}") + ) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + """Check for direct calls to os.path functions.""" + if isinstance(node.func, ast.Attribute): + # Store parent reference for better context + node.func.parent = node + + # Check for os.path.* calls + if ( + isinstance(node.func.value, ast.Attribute) + and isinstance(node.func.value.value, ast.Name) + and node.func.value.value.id == "os" + and node.func.value.attr == "path" + ): + method = node.func.attr + # Skip os.path.expandvars as it has no pathlib equivalent + if method == "expandvars": + return + suggestion = get_pathlib_equivalent(f"os.path.{method}") + self.violations.append( + (node.lineno, f"Found os.path.{method}() - {suggestion}") + ) + self.generic_visit(node) + + +def get_pathlib_equivalent(os_path_call: str) -> str: + """Get the pathlib equivalent for common os.path operations.""" + equivalents = { + "os.path.join": "Use Path() / 'subpath' or Path().joinpath()", + "os.path.exists": "Use Path().exists()", + "os.path.isfile": "Use Path().is_file()", + "os.path.isdir": "Use Path().is_dir()", + "os.path.dirname": "Use Path().parent", + "os.path.basename": "Use Path().name", + "os.path.abspath": "Use Path().resolve()", + "os.path.realpath": "Use Path().resolve()", + "os.path.expanduser": "Use Path().expanduser()", + "os.path.split": "Use Path().parent and Path().name", + "os.path.splitext": "Use Path().stem and Path().suffix", + "os.path.getsize": "Use Path().stat().st_size", + "os.path.getmtime": "Use Path().stat().st_mtime", + "os.path.normpath": "Use Path() - it normalizes automatically", + # Note: os.path.expandvars has no pathlib equivalent and is allowed + "os.path.expandvars": "(No pathlib equivalent - allowed)", + } + return equivalents.get(os_path_call, "Use pathlib.Path equivalent method") + + +def check_file( + filepath: Path, allow_legacy: bool = False +) -> List[Tuple[str, int, str]]: + """ + Check a Python file for os.path usage. + + Args: + filepath: Path to the Python file to check + allow_legacy: If True, only check modified lines (not implemented yet) + + Returns: + List of (filename, line_number, violation_message) tuples + """ + try: + content = filepath.read_text() + tree = ast.parse(content, filename=str(filepath)) + except SyntaxError as e: + print(f"Syntax error in {filepath}: {e}", file=sys.stderr) + return [] + except Exception as e: + print(f"Error reading {filepath}: {e}", file=sys.stderr) + return [] + + checker = OsPathChecker(str(filepath)) + checker.visit(tree) + + return [(str(filepath), line, msg) for line, msg in checker.violations] + + +def main() -> int: + """Main entry point for the pre-commit hook.""" + parser = argparse.ArgumentParser( + description="Check for os.path usage and suggest pathlib alternatives" + ) + parser.add_argument( + "filenames", + nargs="*", + help="Python files to check", + ) + parser.add_argument( + "--allow-legacy", + action="store_true", + help="Allow os.path in existing code (only check new/modified lines)", + ) + + args = parser.parse_args() + + # List of files that are allowed to use os.path (legacy or special cases) + ALLOWED_FILES = { + "src/local_deep_research/utilities/log_utils.py", # May need os.path for low-level operations + "src/local_deep_research/config/paths.py", # Already migrated but may have legacy code + ".pre-commit-hooks/check-pathlib-usage.py", # This file itself + } + + violations = [] + for filename in args.filenames: + filepath = Path(filename) + + # Skip non-Python files + if not filename.endswith(".py"): + continue + + # Skip allowed files + if any(filename.endswith(allowed) for allowed in ALLOWED_FILES): + continue + + file_violations = check_file(filepath, args.allow_legacy) + violations.extend(file_violations) + + if violations: + print("\n❌ Found os.path usage - please use pathlib.Path instead:\n") + for filename, line, message in violations: + print(f" {filename}:{line}: {message}") + + print( + "\n💡 Tip: pathlib.Path provides a more modern and cross-platform API." + ) + print( + " Example: Path('dir') / 'file.txt' instead of os.path.join('dir', 'file.txt')" + ) + print( + "\n📚 See https://docs.python.org/3/library/pathlib.html for more information.\n" + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-research-id-type.py+97 −0 added@@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to check for incorrect research_id type hints. +Research IDs are UUIDs and should always be treated as strings, never as integers. +""" + +import sys +import re +import os + +# Set environment variable for pre-commit hooks to allow unencrypted databases +os.environ["LDR_ALLOW_UNENCRYPTED"] = "true" + + +def check_file(filepath): + """Check a single file for incorrect research_id patterns.""" + errors = [] + + with open(filepath, "r", encoding="utf-8") as f: + lines = f.readlines() + + # Patterns to check for + patterns = [ + # Flask route with int type + ( + r"<int:research_id>", + "Flask route uses <int:research_id> - should be <string:research_id>", + ), + # Type hints with int + ( + r"research_id:\s*int", + "Type hint uses research_id: int - should be research_id: str", + ), + # Function parameters with int conversion + ( + r"int\(research_id\)", + "Converting research_id to int - research IDs are UUIDs/strings", + ), + # Integer comparison patterns + ( + r"research_id\s*==\s*\d+", + "Comparing research_id to integer - research IDs are UUIDs/strings", + ), + ] + + for line_num, line in enumerate(lines, 1): + for pattern, message in patterns: + if re.search(pattern, line): + errors.append(f"{filepath}:{line_num}: {message}") + errors.append(f" {line.strip()}") + + return errors + + +def main(): + """Main entry point.""" + # Get files to check from command line arguments + files_to_check = sys.argv[1:] + + if not files_to_check: + print("No files to check") + return 0 + + all_errors = [] + + for filepath in files_to_check: + # Skip non-Python files + if not filepath.endswith(".py"): + continue + + # Skip test files, migration files, and pre-commit hooks (they might have legitimate int usage) + if ( + "test_" in filepath + or "migration" in filepath.lower() + or ".pre-commit-hooks" in filepath + ): + continue + + errors = check_file(filepath) + all_errors.extend(errors) + + if all_errors: + print("Research ID type errors found:") + print("-" * 80) + for error in all_errors: + print(error) + print("-" * 80) + print( + f"Total errors: {len([e for e in all_errors if not e.startswith(' ')])}" + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/check-session-context-manager.py+188 −0 added@@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Pre-commit hook to detect try/finally session patterns and suggest context managers. + +This hook checks for SQLAlchemy session management patterns that use try/finally +blocks and suggests replacing them with context managers for better resource +management and cleaner code. +""" + +import ast +import sys +from pathlib import Path +from typing import List, Tuple + + +class SessionPatternChecker(ast.NodeVisitor): + """AST visitor to detect try/finally session patterns.""" + + def __init__(self, filename: str): + self.filename = filename + self.issues: List[Tuple[int, str]] = [] + self.functions_and_methods = [] + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """Visit function definitions to check for session patterns.""" + self._check_function_for_pattern(node) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + """Visit async function definitions to check for session patterns.""" + self._check_function_for_pattern(node) + self.generic_visit(node) + + def _check_function_for_pattern(self, func_node) -> None: + """Check a function body for try/finally session patterns.""" + # Look for session = Session() followed by try/finally + for i, stmt in enumerate(func_node.body): + # Check if this is a session assignment + if isinstance(stmt, ast.Assign): + session_var = self._get_session_var_from_assign(stmt) + if session_var: + # Look for a try/finally block that follows + for next_stmt in func_node.body[ + i + 1 : i + 3 + ]: # Check next 2 statements + if ( + isinstance(next_stmt, ast.Try) + and next_stmt.finalbody + ): + # Check if finally has session.close() + if self._has_session_close_in_finally( + next_stmt.finalbody, session_var + ): + self.issues.append( + ( + stmt.lineno, + f"Found try/finally session pattern. Consider using 'with self.Session() as {session_var}:' instead", + ) + ) + break + + def _get_session_var_from_assign(self, assign_node: ast.Assign) -> str: + """Check if an assignment is creating a session and return the variable name.""" + if isinstance(assign_node.value, ast.Call) and self._is_session_call( + assign_node.value + ): + if assign_node.targets and isinstance( + assign_node.targets[0], ast.Name + ): + return assign_node.targets[0].id + return None + + def _is_session_call(self, call_node: ast.Call) -> bool: + """Check if a call node is creating a SQLAlchemy session.""" + # Check for self.Session() pattern + if isinstance(call_node.func, ast.Attribute): + if call_node.func.attr in ( + "Session", + "get_session", + "create_session", + ): + return True + # Check for Session() pattern + elif isinstance(call_node.func, ast.Name): + if call_node.func.id in ( + "Session", + "get_session", + "create_session", + ): + return True + return False + + def _has_session_close_in_finally( + self, finalbody: List[ast.stmt], session_var: str + ) -> bool: + """Check if finally block contains session.close().""" + for stmt in finalbody: + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): + # Check for session.close() pattern + if ( + isinstance(stmt.value.func, ast.Attribute) + and stmt.value.func.attr == "close" + ): + # Check if it's our session variable + if ( + isinstance(stmt.value.func.value, ast.Name) + and stmt.value.func.value.id == session_var + ): + return True + return False + + +def check_file(filepath: Path) -> List[Tuple[str, int, str]]: + """Check a single Python file for try/finally session patterns.""" + issues = [] + + try: + content = filepath.read_text() + tree = ast.parse(content, filename=str(filepath)) + + checker = SessionPatternChecker(str(filepath)) + checker.visit(tree) + + for line_no, message in checker.issues: + issues.append((str(filepath), line_no, message)) + + except SyntaxError as e: + # Skip files with syntax errors + print(f"Syntax error in {filepath}: {e}", file=sys.stderr) + except Exception as e: + print(f"Error checking {filepath}: {e}", file=sys.stderr) + + return issues + + +def main(): + """Main entry point for the pre-commit hook.""" + # Get list of files to check from command line arguments + files_to_check = sys.argv[1:] if len(sys.argv) > 1 else [] + + if not files_to_check: + print("No files to check") + return 0 + + all_issues = [] + + for filepath_str in files_to_check: + filepath = Path(filepath_str) + + # Skip non-Python files + if not filepath.suffix == ".py": + continue + + # Skip test files and migration files + if "test" in filepath.parts or "migration" in filepath.parts: + continue + + issues = check_file(filepath) + all_issues.extend(issues) + + # Report issues + if all_issues: + print( + "\n❌ Found try/finally session patterns that should use context managers:\n" + ) + for filepath, line_no, message in all_issues: + print(f" {filepath}:{line_no}: {message}") + + print("\n💡 Tip: Replace try/finally blocks with context managers:") + print(" Before:") + print(" session = self.Session()") + print(" try:") + print(" # operations") + print(" session.commit()") + print(" finally:") + print(" session.close()") + print("\n After:") + print(" with self.Session() as session:") + print(" # operations") + print(" session.commit()") + print("\n") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.pre-commit-hooks/custom-checks.py+466 −0 added@@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +""" +Custom pre-commit hook for Local Deep Research project. +Checks for: +1. If loguru is used instead of standard logging +2. If logger.exception is used instead of logger.error for error handling +3. That no raw SQL is used, only ORM methods +4. That ORM models (classes inheriting from Base) are defined in models/ folders +5. That logger.exception doesn't include redundant {e} in the message +""" + +import ast +import sys +import re +import os +from typing import List, Tuple + +# Set environment variable for pre-commit hooks to allow unencrypted databases +os.environ["LDR_ALLOW_UNENCRYPTED"] = "true" + + +class CustomCodeChecker(ast.NodeVisitor): + def __init__(self, filename: str): + self.filename = filename + self.errors = [] + self.has_loguru_import = False + self.has_standard_logging_import = False + self.in_except_handler = False + self.has_base_import = False + self.has_declarative_base_import = False + + def visit_Import(self, node): + for alias in node.names: + if alias.name == "logging": + self.has_standard_logging_import = True + # Allow standard logging in specific files that need it + if not ( + "log_utils.py" in self.filename + or "app_factory.py" in self.filename + ): + self.errors.append( + ( + node.lineno, + "Use loguru instead of standard logging library", + ) + ) + elif alias.name == "loguru": + self.has_loguru_import = True + self.generic_visit(node) + + def visit_ImportFrom(self, node): + if node.module == "logging": + self.has_standard_logging_import = True + # Allow standard logging in specific files that need it + if not ( + "log_utils.py" in self.filename + or "app_factory.py" in self.filename + ): + self.errors.append( + ( + node.lineno, + "Use loguru instead of standard logging library", + ) + ) + elif node.module == "loguru": + self.has_loguru_import = True + elif node.module and "sqlalchemy" in node.module: + # Check for SQLAlchemy ORM imports + for name in node.names: + if name.name == "declarative_base": + self.has_declarative_base_import = True + # Also check for database.models.base imports + elif node.module and ( + "models.base" in node.module or "models" in node.module + ): + for name in node.names: + if name.name == "Base": + self.has_base_import = True + self.generic_visit(node) + + def visit_Try(self, node): + # Visit try body normally (not in exception handler) + for child in node.body: + self.visit(child) + + # Visit exception handlers with the flag set + for handler in node.handlers: + self.visit(handler) + + # Visit else and finally clauses normally + for child in node.orelse: + self.visit(child) + for child in node.finalbody: + self.visit(child) + + def visit_ExceptHandler(self, node): + # Track when we're inside an exception handler + old_in_except = self.in_except_handler + self.in_except_handler = True + # Only visit the body of the exception handler + for child in node.body: + self.visit(child) + self.in_except_handler = old_in_except + + def visit_Call(self, node): + # Check for logger.error usage in exception handlers (should use logger.exception instead) + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "error" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "logger" + and self.in_except_handler + ): + # Skip if the error message indicates it's not actually an exception context + # (e.g., "Cannot queue error update" which is a logic error, not an exception) + skip_patterns = [ + "Cannot queue", + "no username provided", + "Path validation error", + "not available. Please install", # ImportError messages about missing packages + ] + + # Try to check if this is in a conditional (if/else) rather than direct except body + # Check both Constant (regular strings) and JoinedStr (f-strings) + if node.args: + if isinstance(node.args[0], ast.Constant): + error_msg = str(node.args[0].value) + if any(pattern in error_msg for pattern in skip_patterns): + self.generic_visit(node) + return + elif isinstance(node.args[0], ast.JoinedStr): + # For f-strings, check the string parts + for value in node.args[0].values: + if isinstance(value, ast.Constant) and any( + pattern in str(value.value) + for pattern in skip_patterns + ): + self.generic_visit(node) + return + + self.errors.append( + ( + node.lineno, + "Use logger.exception() instead of logger.error() in exception handlers", + ) + ) + self.generic_visit(node) + + def visit_ClassDef(self, node): + # Check if this class inherits from Base (SQLAlchemy model) + for base in node.bases: + base_name = "" + if isinstance(base, ast.Name): + base_name = base.id + elif isinstance(base, ast.Attribute): + base_name = base.attr + + if base_name == "Base": + # This is an ORM model - check if it's in the models folder + if ( + "/models/" not in self.filename + and not self.filename.endswith("/models.py") + ): + # Allow exceptions for test files and migrations + if not ( + "test" in self.filename.lower() + or "migration" in self.filename.lower() + or "migrate" in self.filename.lower() + or "alembic" in self.filename.lower() + ): + self.errors.append( + ( + node.lineno, + f"ORM model '{node.name}' should be defined in a models/ folder, not in {self.filename}", + ) + ) + self.generic_visit(node) + + +def check_raw_sql(content: str, filename: str) -> List[Tuple[int, str]]: + """Check for raw SQL usage patterns.""" + errors = [] + lines = content.split("\n") + + # Skip checking this file itself (contains regex patterns that look like SQL) + if "custom-checks.py" in filename: + return errors + + # More specific patterns for database execute calls to avoid false positives + db_execute_patterns = [ + r"cursor\.execute\s*\(", # cursor.execute() + r"cursor\.executemany\s*\(", # cursor.executemany() + r"conn\.execute\s*\(", # connection.execute() + r"connection\.execute\s*\(", # connection.execute() + r"session\.execute\s*\(\s*[\"']", # session.execute() with raw SQL string + ] + + # SQL statement patterns (only check if they appear to be raw SQL strings) + sql_statement_patterns = [ + r"[\"']\s*SELECT\s+.*FROM\s+", # Raw SELECT in strings + r"[\"']\s*INSERT\s+INTO\s+", # Raw INSERT in strings + r"[\"']\s*UPDATE\s+.*SET\s+", # Raw UPDATE in strings + r"[\"']\s*DELETE\s+FROM\s+", # Raw DELETE in strings + r"[\"']\s*CREATE\s+TABLE\s+", # Raw CREATE TABLE in strings + r"[\"']\s*DROP\s+TABLE\s+", # Raw DROP TABLE in strings + r"[\"']\s*ALTER\s+TABLE\s+", # Raw ALTER TABLE in strings + ] + + # Allowed patterns (ORM usage and legitimate cases) + allowed_patterns = [ + r"session\.query\(", + r"\.filter\(", + r"\.filter_by\(", + r"\.join\(", + r"\.order_by\(", + r"\.group_by\(", + r"\.add\(", + r"\.merge\(", + r"Query\(", + r"relationship\(", + r"Column\(", + r"Table\(", + r"text\(", # SQLAlchemy text() function for raw SQL + r"#.*SQL", # Comments mentioning SQL + r"\"\"\".*SQL", # Docstrings mentioning SQL + r"'''.*SQL", # Docstrings mentioning SQL + r"f[\"'].*{", # f-strings (often used for dynamic ORM queries) + ] + + for line_num, line in enumerate(lines, 1): + line_stripped = line.strip() + + # Skip comments, docstrings, and empty lines + if ( + line_stripped.startswith("#") + or line_stripped.startswith('"""') + or line_stripped.startswith("'''") + or not line_stripped + ): + continue + + # Check if line has allowed patterns first + has_allowed_pattern = any( + re.search(pattern, line, re.IGNORECASE) + for pattern in allowed_patterns + ) + + if has_allowed_pattern: + continue + + # Check for database execute patterns + for pattern in db_execute_patterns: + if re.search(pattern, line, re.IGNORECASE): + # Check if this might be acceptable (in migrations or tests) + is_migration = ( + "migration" in filename.lower() + or "migrate" in filename.lower() + or "alembic" in filename.lower() + or "/migrations/" in filename + ) + is_test = "test" in filename.lower() + + # Allow raw SQL in database utility files that need direct access + is_db_util = ( + "sqlcipher_utils.py" in filename + or "socket_service.py" in filename + or "thread_local_session.py" in filename + or "encrypted_db.py" in filename + ) + + # Allow raw SQL in migrations, db utils, and all test files + if not (is_migration or is_db_util or is_test): + errors.append( + ( + line_num, + f"Raw SQL execute detected: '{line_stripped[:50]}...'. Use ORM methods instead.", + ) + ) + + # Check for SQL statement patterns + for pattern in sql_statement_patterns: + if re.search(pattern, line, re.IGNORECASE): + # Check if this might be acceptable (in migrations or tests) + is_migration = ( + "migration" in filename.lower() + or "migrate" in filename.lower() + or "alembic" in filename.lower() + or "/migrations/" in filename + ) + is_test = "test" in filename.lower() + + # Allow raw SQL in database utility files that need direct access + is_db_util = ( + "sqlcipher_utils.py" in filename + or "socket_service.py" in filename + or "thread_local_session.py" in filename + or "encrypted_db.py" in filename + ) + + # Allow raw SQL in migrations, db utils, and all test files + if not (is_migration or is_db_util or is_test): + errors.append( + ( + line_num, + f"Raw SQL statement detected: '{line_stripped[:50]}...'. Use ORM methods instead.", + ) + ) + + return errors + + +def check_datetime_usage(content: str, filename: str) -> List[Tuple[int, str]]: + """Check for non-UTC datetime usage.""" + errors = [] + lines = content.split("\n") + + # Patterns to detect problematic datetime usage + datetime_patterns = [ + # datetime.now() without timezone + ( + r"datetime\.now\s*\(\s*\)", + "Use datetime.now(UTC) or utc_now() instead of datetime.now()", + ), + # datetime.utcnow() - deprecated + ( + r"datetime\.utcnow\s*\(\s*\)", + "datetime.utcnow() is deprecated. Use datetime.now(UTC) or utc_now() instead", + ), + ] + + # Files where we allow datetime.now() for specific reasons + allowed_files = [ + "test_", # Test files + "mock_", # Mock files + "/tests/", # Test directories + ] + + # Check if this file is allowed to use datetime.now() + is_allowed = any(pattern in filename.lower() for pattern in allowed_files) + + if not is_allowed: + for line_num, line in enumerate(lines, 1): + line_stripped = line.strip() + + # Skip comments and docstrings + if ( + line_stripped.startswith("#") + or line_stripped.startswith('"""') + or line_stripped.startswith("'''") + or not line_stripped + ): + continue + + # Check for problematic patterns + for pattern, message in datetime_patterns: + if re.search(pattern, line): + # Check if it's already using UTC + if ( + "datetime.now(UTC)" not in line + and "timezone.utc" not in line + ): + errors.append((line_num, message)) + + return errors + + +def check_file(filename: str) -> bool: + """Check a single Python file for violations.""" + if not filename.endswith(".py"): + return True + + try: + with open(filename, "r", encoding="utf-8") as f: + content = f.read() + except UnicodeDecodeError: + # Skip binary files + return True + except Exception as e: + print(f"Error reading {filename}: {e}") + return False + + # Check for logger.exception with redundant {e} + lines = content.split("\n") + for i, line in enumerate(lines, 1): + # Match logger.exception with f-string containing {e}, {exc}, {ex}, etc. + if re.search( + r'logger\.exception\s*\(\s*[fF]?["\'].*\{(?:e|ex|exc|exception)\}.*["\']', + line, + ): + print( + f"{filename}:{i}: logger.exception automatically includes exception details, remove {{e}} from message" + ) + return False + + # Parse AST for logging checks + try: + tree = ast.parse(content, filename=filename) + checker = CustomCodeChecker(filename) + checker.visit(tree) + + # Check for raw SQL + sql_errors = check_raw_sql(content, filename) + checker.errors.extend(sql_errors) + + # Check for datetime usage + datetime_errors = check_datetime_usage(content, filename) + checker.errors.extend(datetime_errors) + + if checker.errors: + print(f"\n{filename}:") + for line_num, error in checker.errors: + print(f" Line {line_num}: {error}") + return False + + except SyntaxError: + # Skip files with syntax errors (they'll be caught by other tools) + pass + except Exception as e: + print(f"Error parsing {filename}: {e}") + return False + + return True + + +def main(): + """Main function to check all staged Python files.""" + if len(sys.argv) < 2: + print("Usage: custom-checks.py <file1> <file2> ...") + sys.exit(1) + + files_to_check = sys.argv[1:] + has_errors = False + + print("Running custom code checks...") + + for filename in files_to_check: + if not check_file(filename): + has_errors = True + + if has_errors: + print("\n❌ Custom checks failed. Please fix the issues above.") + print("\nGuidelines:") + print("1. Use 'from loguru import logger' instead of standard logging") + print( + "2. Use 'logger.exception()' instead of 'logger.error()' in exception handlers" + ) + print( + "3. Use ORM methods instead of raw SQL execute() calls and SQL strings" + ) + print(" - Allowed: session.query(), .filter(), .add(), etc.") + print(" - Raw SQL is permitted in migration files and schema tests") + print( + "4. Define ORM models (classes inheriting from Base) in models/ folders" + ) + print( + " - Models should be in files like models/user.py or database/models/" + ) + print(" - Exception: Test files and migration files") + sys.exit(1) + else: + print("✅ All custom checks passed!") + sys.exit(0) + + +if __name__ == "__main__": + main()
.pre-commit-hooks/file-whitelist-check.sh+110 −0 added@@ -0,0 +1,110 @@ +#!/bin/bash +# Pre-commit hook adapted from GitHub workflow file-whitelist-check.yml +# Only checks the files being committed, not all files + +# Define allowed file extensions and specific files +ALLOWED_PATTERNS=( + "\.py$" + "\.js$" + "\.html$" + "\.css$" + "\.json$" + "\.md$" + "\.yml$" + "\.yaml$" + "\.sh$" + "\.cfg$" + "\.flake8$" + "\.ipynb$" + "\.template$" + "^\.gitignore$" + "^\.gitkeep$" + ".*\.gitkeep$" + ".*\.gitignore$" + "^\.pre-commit-config\.yaml$" + "^\.isort\.cfg$" + "^\.coveragerc$" + "^\.secrets\.baseline$" + "^pytest\.ini$" + "^LICENSE$" + "^README$" + "^README\.md$" + "^CONTRIBUTING\.md$" + "^SECURITY\.md$" + "^Dockerfile$" + "^pyproject\.toml$" + "^pdm\.lock$" + "^package\.json$" + "^MANIFEST\.in$" + "^\.github/CODEOWNERS$" + "^\.github/.*\.(yml|yaml|md)$" + "installers/.*\.(bat|ps1|iss|ico)$" + "docs/.*\.(png|jpg|jpeg|gif|svg)$" + "docs/.*\.ps1$" + "src/local_deep_research/web/static/sounds/.*\.mp3$" +) + +WHITELIST_VIOLATIONS=() +LARGE_FILES=() + +echo "🔍 Running file whitelist security checks..." + +# Process each file passed as argument +for file in "$@"; do + # Skip if file doesn't exist (deleted files) + if [ ! -f "$file" ]; then + continue + fi + + # 1. Whitelist check + ALLOWED=false + for pattern in "${ALLOWED_PATTERNS[@]}"; do + if echo "$file" | grep -qE "$pattern"; then + ALLOWED=true + break + fi + done + + if [ "$ALLOWED" = "false" ]; then + WHITELIST_VIOLATIONS+=("$file") + fi + + # 2. Large file check (>1MB) + FILE_SIZE=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo 0) + if [ "$FILE_SIZE" -gt 1048576 ]; then + LARGE_FILES+=("$file ($(echo $FILE_SIZE | awk '{printf "%.1fMB", $1/1024/1024}'))") + fi +done + +# Report violations +TOTAL_VIOLATIONS=0 + +if [ ${#WHITELIST_VIOLATIONS[@]} -gt 0 ]; then + echo "" + echo "❌ WHITELIST VIOLATIONS - File types not allowed in repository:" + for violation in "${WHITELIST_VIOLATIONS[@]}"; do + echo " 🚫 $violation" + done + TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#WHITELIST_VIOLATIONS[@]})) +fi + +if [ ${#LARGE_FILES[@]} -gt 0 ]; then + echo "" + echo "❌ LARGE FILES (>1MB) - Files too big for repository:" + for violation in "${LARGE_FILES[@]}"; do + echo " 📏 $violation" + done + TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#LARGE_FILES[@]})) +fi + +if [ $TOTAL_VIOLATIONS -eq 0 ]; then + echo "✅ All file whitelist checks passed!" + exit 0 +else + echo "" + echo "💡 To fix these issues:" + echo " - Add allowed file types to ALLOWED_PATTERNS" + echo " - Use Git LFS for large files" + echo "" + exit 1 +fi
__pypackages__/.gitignore+2 −0 added@@ -0,0 +1,2 @@ +* +!.gitignore
pyproject.toml+19 −5 modified@@ -36,6 +36,7 @@ dependencies = [ "flask-cors>=3.0.10", "flask-socketio>=5.1.1", "sqlalchemy>=1.4.23", + "sqlalchemy-utc>=0.14.0", "wikipedia", "arxiv>=1.4.3", "pypdf", @@ -67,6 +68,15 @@ dependencies = [ "kaleido==0.2.1", "aiohttp>=3.9.0", "tenacity>=8.0.0", + "apscheduler>=3.10.0", + "rich>=13.0.0", + "click>=8.0.0", + "flask-login>=0.6.3", + "dogpile.cache>=1.2.0", + "redis>=4.0.0", + "msgpack>=1.0.0", + "sqlcipher3-binary>=0.5.4; sys_platform == 'linux'", + "sqlcipher3>=0.5.0; sys_platform != 'linux'", ] [project.urls] @@ -86,6 +96,9 @@ include-package-data = true + + + [tool.pdm] distribution = true version = { source = "file", path = "src/local_deep_research/__version__.py" } @@ -96,17 +109,18 @@ include_packages = ["torch", "torch*"] [dependency-groups] dev = [ - "pre-commit>=4.2.0", + "pre-commit>=4.3.0", "jupyter>=1.1.1", "cookiecutter>=2.6.0", "pandas>=2.2.3", "optuna>=4.3.0", - "pytest-mock>=3.14.0", - "pytest>=8.3.5", - "pytest-cov>=6.1.1", + "pytest-mock>=3.14.1", + "pytest>=8.4.1", + "pytest-cov>=6.2.1", "pytest-timeout>=2.3.1", - "pytest-asyncio>=0.23.0", + "pytest-asyncio>=1.0.0", "ruff>=0.11.12", + "freezegun>=1.5.2", ] [tool.pytest.ini_options]
README.md+34 −18 modified@@ -55,14 +55,15 @@ It aims to help researchers, students, and professionals find accurate informati ### 🛠️ Advanced Capabilities - **[LangChain Integration](docs/LANGCHAIN_RETRIEVER_INTEGRATION.md)** - Use any vector store as a search engine -- **[REST API](docs/api-quickstart.md)** - Language-agnostic HTTP access +- **[REST API](docs/api-quickstart.md)** - Authenticated HTTP access with per-user databases - **[Benchmarking](docs/BENCHMARKING.md)** - Test and optimize your configuration - **[Analytics Dashboard](docs/analytics-dashboard.md)** - Track costs, performance, and usage metrics - **Real-time Updates** - WebSocket support for live research progress - **Export Options** - Download results as PDF or Markdown - **Research History** - Save, search, and revisit past research - **Adaptive Rate Limiting** - Intelligent retry system that learns optimal wait times - **Keyboard Shortcuts** - Navigate efficiently (ESC, Ctrl+Shift+1-5) +- **Per-User Encrypted Databases** - Secure, isolated data storage for each user ### 🌐 Search Sources @@ -94,7 +95,7 @@ It aims to help researchers, students, and professionals find accurate informati docker run -d -p 8080:8080 --name searxng searxng/searxng # Step 2: Pull and run Local Deep Research (Please build your own docker on ARM) -docker run -d -p 5000:5000 --name local-deep-research --volume 'deep-research:/install/.venv/lib/python3.13/site-packages/data/' localdeepresearch/local-deep-research +docker run -d -p 5000:5000 --name local-deep-research --volume 'deep-research:/data' -e LDR_DATA_DIR=/data localdeepresearch/local-deep-research ``` ### Option 2: Docker Compose (Recommended) @@ -174,25 +175,40 @@ python -m local_deep_research.web.app ### Python API ```python from local_deep_research.api import quick_summary - -# Simple usage -result = quick_summary("What are the latest advances in quantum computing?") -print(result["summary"]) - -# Advanced usage with custom configuration -result = quick_summary( - query="Impact of AI on healthcare", - search_tool="searxng", - search_strategy="focused-iteration", - iterations=2 -) +from local_deep_research.settings import CachedSettingsManager +from local_deep_research.database.session_context import get_user_db_session + +# Authentication required - use with user session +with get_user_db_session(username="your_username", password="your_password") as session: + settings_manager = CachedSettingsManager(session, "your_username") + settings_snapshot = settings_manager.get_all_settings() + + # Simple usage with settings + result = quick_summary( + query="What are the latest advances in quantum computing?", + settings_snapshot=settings_snapshot + ) + print(result["summary"]) ``` ### HTTP API -```bash -curl -X POST http://localhost:5000/api/v1/quick_summary \ - -H "Content-Type: application/json" \ - -d '{"query": "Explain CRISPR gene editing"}' +```python +import requests + +# Create session and authenticate +session = requests.Session() +session.post("http://localhost:5000/auth/login", + json={"username": "user", "password": "pass"}) + +# Get CSRF token +csrf = session.get("http://localhost:5000/auth/csrf-token").json()["csrf_token"] + +# Make API request +response = session.post( + "http://localhost:5000/research/api/start", + json={"query": "Explain CRISPR gene editing"}, + headers={"X-CSRF-Token": csrf} +) ``` [More Examples →](examples/api_usage/)
scripts/check_benchmark_db.py+125 −0 added@@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Check if benchmark runs exist in the database.""" + +import sys +from pathlib import Path + +# Add the parent directory to the path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from local_deep_research.database.session_context import get_user_db_session +from local_deep_research.database.models.benchmark import ( + BenchmarkRun, + BenchmarkResult, +) +from loguru import logger + + +def check_benchmark_database(): + """Check for benchmark runs in the database.""" + try: + # Try to find the most recent benchmark user + import glob + from local_deep_research.config.paths import get_data_directory + + data_dir = get_data_directory() + db_pattern = str( + Path(data_dir) / "encrypted_databases" / "benchmark_*.db" + ) + benchmark_dbs = glob.glob(db_pattern) + + if benchmark_dbs: + # Get the most recent benchmark user + latest_db = max( + benchmark_dbs, key=lambda x: Path(x).stat().st_mtime + ) + username = Path(latest_db).stem + print(f"Checking database for user: {username}") + else: + # Try to find most recent user database + all_dbs = glob.glob( + str(Path(data_dir) / "encrypted_databases" / "*.db") + ) + if all_dbs: + latest_db = max(all_dbs, key=lambda x: Path(x).stat().st_mtime) + username = Path(latest_db).stem + print( + f"No benchmark users found, using most recent user: {username}" + ) + else: + # Fallback to test user + username = "test" + print("No user databases found, using test user") + + # Use the user's database + with get_user_db_session(username) as session: + # Count total benchmark runs + total_runs = session.query(BenchmarkRun).count() + print(f"\nTotal benchmark runs: {total_runs}") + + if total_runs > 0: + # Get latest benchmark run + latest_run = ( + session.query(BenchmarkRun) + .order_by(BenchmarkRun.created_at.desc()) + .first() + ) + + print("\nLatest benchmark run:") + print(f" ID: {latest_run.id}") + print(f" Name: {latest_run.run_name}") + print(f" Status: {latest_run.status.value}") + print(f" Created: {latest_run.created_at}") + print(f" Total examples: {latest_run.total_examples}") + print(f" Completed examples: {latest_run.completed_examples}") + + # Count results for latest run + results_count = ( + session.query(BenchmarkResult) + .filter(BenchmarkResult.benchmark_run_id == latest_run.id) + .count() + ) + print(f" Results: {results_count}") + + # Show benchmark configuration + print("\nBenchmark configuration:") + print(f" Search config: {latest_run.search_config}") + print(f" Datasets: {latest_run.datasets_config}") + + # Show first few results if any + if results_count > 0: + print("\nFirst 3 results:") + results = ( + session.query(BenchmarkResult) + .filter( + BenchmarkResult.benchmark_run_id == latest_run.id + ) + .limit(3) + .all() + ) + + for i, result in enumerate(results, 1): + print(f"\n Result {i}:") + print(f" Question: {result.question[:100]}...") + print(f" Dataset: {result.dataset_type.value}") + print( + f" Processing time: {result.processing_time:.2f}s" + if result.processing_time + else " Processing time: N/A" + ) + print(f" Correct: {result.is_correct}") + + return True + else: + print("No benchmark runs found in database") + return False + + except Exception as e: + logger.exception("Error checking benchmark database") + print(f"Error: {e}") + return False + + +if __name__ == "__main__": + success = check_benchmark_database() + sys.exit(0 if success else 1)
scripts/check_metrics.py+102 −0 added@@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Check if metrics are being saved in the database.""" + +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.local_deep_research.database.encrypted_db import db_manager +from src.local_deep_research.database.models import TokenUsage, SearchCall + + +def check_user_metrics(username: str, password: str): + """Check metrics for a specific user.""" + print(f"\n🔍 Checking metrics for user: {username}") + print("=" * 60) + + try: + # Open database + if not db_manager.open_user_database(username, password): + print("❌ Failed to open database") + return + + # Get session + session = db_manager.get_session(username) + if not session: + print("❌ Failed to get session") + return + + # Check token usage + token_count = session.query(TokenUsage).count() + print(f"\n📊 Token Usage Records: {token_count}") + + if token_count > 0: + # Get recent token usage + recent_tokens = ( + session.query(TokenUsage) + .order_by(TokenUsage.created_at.desc()) + .limit(5) + .all() + ) + print("\n Recent token usage:") + for token in recent_tokens: + print( + f" - {token.created_at}: {token.model_name} - {token.total_tokens} tokens" + ) + print( + f" Phase: {token.research_phase}, Status: {token.success_status}" + ) + + # Check search calls + search_count = session.query(SearchCall).count() + print(f"\n🔎 Search Call Records: {search_count}") + + if search_count > 0: + # Get recent searches + recent_searches = ( + session.query(SearchCall) + .order_by(SearchCall.created_at.desc()) + .limit(5) + .all() + ) + print("\n Recent searches:") + for search in recent_searches: + print( + f" - {search.created_at}: {search.search_engine} - {search.query[:50]}..." + ) + print(f" Results: {search.results_returned}") + + session.close() + + except Exception as e: + print(f"❌ Error: {e}") + import traceback + + traceback.print_exc() + finally: + if username in db_manager.connections: + db_manager.connections.pop(username) + + +def main(): + """Check metrics for test users.""" + # Check for a specific test user (modify as needed) + test_users = [ + ("simple_1751323627595", "password"), # Latest test user + # Add more test users as needed + ] + + for username, password in test_users: + try: + check_user_metrics(username, password) + except Exception as e: + print(f"Failed to check {username}: {e}") + + print("\n" + "=" * 60) + print("✅ Metrics check complete") + + +if __name__ == "__main__": + main()
scripts/check_research_db.py+68 −0 added@@ -0,0 +1,68 @@ +#!/usr/bin/env python +"""Check if there are any researches in the database.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.resolve())) + +from src.local_deep_research.database.models import ResearchLog +from src.local_deep_research.utilities.db_utils import get_db_session + + +def check_researches(): + """Check for researches in the database.""" + # Note: This checks the shared database, not per-user databases + try: + # Check for the most recently created test user + # The test creates a user with pattern "simple_" + timestamp + + # Look for any user databases that might have been created + db_dir = ( + Path.home() + / ".local" + / "share" + / "local-deep-research" + / "encrypted_databases" + ) + if db_dir.exists(): + db_files = [ + f.name + for f in db_dir.iterdir() + if f.name.endswith(".db") + and not f.name.endswith("-wal") + and not f.name.endswith("-shm") + ] + print(f"Found {len(db_files)} user database(s)") + + # Try with the most recent test user + session = get_db_session(username="simple_1751271039631") + count = session.query(ResearchLog).count() + if count > 0: + print(f"✓ Found {count} research(es) in database") + latest = ( + session.query(ResearchLog) + .order_by(ResearchLog.created_at.desc()) + .first() + ) + if latest: + print( + f" Latest: {latest.title[:50] if latest.title else 'No title'}... (created at {latest.created_at})" + ) + return 0 + else: + print("✗ No researches found in database") + return 1 + except Exception as e: + print(f"✗ Error checking database: {e}") + import traceback + + traceback.print_exc() + return 1 + finally: + if "session" in locals(): + session.close() + + +if __name__ == "__main__": + sys.exit(check_researches())
scripts/dev/kill_servers.py+15 −14 modified@@ -3,6 +3,7 @@ import subprocess import sys import time +from pathlib import Path import psutil @@ -95,17 +96,17 @@ def start_flask_server(port=5000): # Get the virtual environment Python executable # Try multiple common venv locations venv_paths = [ - os.path.join("venv_dev", "bin", "python"), # Linux/Mac - os.path.join("venv", "bin", "python"), # Linux/Mac - os.path.join(".venv", "Scripts", "python.exe"), # Windows - os.path.join("venv_dev", "Scripts", "python.exe"), # Windows - os.path.join("venv", "Scripts", "python.exe"), # Windows + Path("venv_dev") / "bin" / "python", # Linux/Mac + Path("venv") / "bin" / "python", # Linux/Mac + Path(".venv") / "Scripts" / "python.exe", # Windows + Path("venv_dev") / "Scripts" / "python.exe", # Windows + Path("venv") / "Scripts" / "python.exe", # Windows ] venv_path = None for path in venv_paths: - if os.path.exists(path): - venv_path = path + if path.exists(): + venv_path = str(path) break if not venv_path: @@ -222,7 +223,7 @@ def start_flask_server(port=5000): return None except Exception as e: - print(f"Error starting Flask server: {str(e)}") + print(f"Error starting Flask server: {e!s}") return None @@ -235,15 +236,15 @@ def start_flask_server_windows(port=5000): # Get the virtual environment Python executable # Try multiple common venv locations venv_paths = [ - os.path.join("venv_dev", "Scripts", "python.exe"), # Windows - os.path.join("venv", "Scripts", "python.exe"), # Windows - os.path.join(".venv", "Scripts", "python.exe"), # Windows + Path("venv_dev") / "Scripts" / "python.exe", # Windows + Path("venv") / "Scripts" / "python.exe", # Windows + Path(".venv") / "Scripts" / "python.exe", # Windows ] venv_path = None for path in venv_paths: - if os.path.exists(path): - venv_path = path + if path.exists(): + venv_path = str(path) break if not venv_path: @@ -270,7 +271,7 @@ def start_flask_server_windows(port=5000): return True except Exception as e: - print(f"Error starting Flask server: {str(e)}") + print(f"Error starting Flask server: {e!s}") return None
scripts/dev/restart_server.sh+30 −0 added@@ -0,0 +1,30 @@ +#!/bin/bash +# Script to restart the LDR server + +echo "Stopping existing LDR server..." +pkill -f "python -m local_deep_research.web.app" 2>/dev/null || echo "No existing server found" + +# Wait a moment for the process to stop +sleep 1 + +echo "Starting LDR server..." +# Change to the script's parent directory (project root) +cd "$(dirname "$0")/../.." + +# Start server in background and detach from terminal +(nohup pdm run python -m local_deep_research.web.app > /tmp/ldr_server.log 2>&1 &) & +SERVER_PID=$! + +# Give it a moment to start +sleep 2 + +echo "Server started. PID: $SERVER_PID" +echo "Logs: /tmp/ldr_server.log" +echo "URL: http://127.0.0.1:5000" +echo "" +echo "To check server status: ps aux | grep 'python -m local_deep_research.web.app'" +echo "To view logs: tail -f /tmp/ldr_server.log" +echo "To stop server: pkill -f 'python -m local_deep_research.web.app'" + +# Exit immediately - don't wait for background process +exit 0
scripts/ollama_entrypoint.sh+27 −0 added@@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +# 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 gemma3:12b with ollama pull..." +ollama pull gemma3:12b +# Check if the model was pulled successfully +if [ $? -eq 0 ]; then + echo "Model pulled successfully." +else + echo "Failed to pull model." + exit 1 +fi + +# Run ollama forever. +sleep infinity
scripts/pre_commit/check_datetime_timezone.py+141 −0 added@@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Pre-commit hook to ensure all datetime columns use UtcDateTime for SQLite compatibility.""" + +import ast +import re +import sys +from pathlib import Path +from typing import List, Tuple + + +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") 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 + + # Check if file imports UtcDateTime (if it uses any DateTime columns) + has_utc_datetime_import = ( + "from sqlalchemy_utc import UtcDateTime" in content + or "from sqlalchemy_utc import utcnow, UtcDateTime" in content + ) + + # Parse the AST to find Column definitions with DateTime + try: + tree = ast.parse(content) + except SyntaxError: + # Not valid Python, skip + return violations + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + # Check if this is a Column call + if isinstance(node.func, ast.Name) and node.func.id == "Column": + # Check if first argument is DateTime + if node.args and isinstance(node.args[0], ast.Call): + datetime_call = node.args[0] + if ( + isinstance(datetime_call.func, ast.Name) + and datetime_call.func.id == "DateTime" + ): + # This should be UtcDateTime instead + line_num = node.lineno + if 0 <= line_num - 1 < len(lines): + violations.append( + ( + line_num, + lines[line_num - 1].strip(), + "Use UtcDateTime instead of DateTime for SQLite compatibility", + ) + ) + elif ( + isinstance(datetime_call.func, ast.Name) + and datetime_call.func.id == "UtcDateTime" + ): + # This is correct, but check if import exists + 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", + ) + ) + + # Also check for func.now() usage which should be utcnow() + 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", + ) + ) + # Check for datetime.utcnow or datetime.now(UTC) in 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) + + # Only check Python files in database/models directories + if file_path.suffix == ".py" and ( + "database/models" in str(file_path) or "models" in file_path.parts + ): + violations = check_datetime_columns(file_path) + if violations: + all_violations.append((file_path, violations)) + + if all_violations: + print("\n❌ DateTime 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(" Example: ") + print(" from sqlalchemy_utc import UtcDateTime, utcnow") + print(" Column(UtcDateTime, default=utcnow(), ...)\n") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())
.secrets.baseline+430 −0 added@@ -0,0 +1,430 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": { + "docs/elasticsearch_search_engine.md": [ + { + "type": "Secret Keyword", + "filename": "docs/elasticsearch_search_engine.md", + "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", + "is_verified": false, + "line_number": 34 + } + ], + "examples/elasticsearch_search_example.py": [ + { + "type": "Secret Keyword", + "filename": "examples/elasticsearch_search_example.py", + "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", + "is_verified": false, + "line_number": 37 + } + ], + "examples/optimization/gemini_optimization.py": [ + { + "type": "Secret Keyword", + "filename": "examples/optimization/gemini_optimization.py", + "hashed_secret": "22035eae7902ee4a696f64d1cef8668b6a12e7d3", + "is_verified": false, + "line_number": 14 + } + ], + "src/local_deep_research/advanced_search_system/answer_decoding/browsecomp_answer_decoder.py": [ + { + "type": "Base64 High Entropy String", + "filename": "src/local_deep_research/advanced_search_system/answer_decoding/browsecomp_answer_decoder.py", + "hashed_secret": "e508b4d6483c95a02bf8e5bbdb285e386e3db6f3", + "is_verified": false, + "line_number": 295 + } + ], + "src/local_deep_research/benchmarks/datasets/browsecomp.py": [ + { + "type": "Base64 High Entropy String", + "filename": "src/local_deep_research/benchmarks/datasets/browsecomp.py", + "hashed_secret": "1d278d3c888d1a2fa7eed622bfc02927ce4049af", + "is_verified": false, + "line_number": 90 + } + ], + "src/local_deep_research/web/app_factory.py": [ + { + "type": "Secret Keyword", + "filename": "src/local_deep_research/web/app_factory.py", + "hashed_secret": "66b62fe5726610b1af887a103441fb0c338147da", + "is_verified": false, + "line_number": 55 + } + ], + "src/local_deep_research/web/templates/base.html": [ + { + "type": "Base64 High Entropy String", + "filename": "src/local_deep_research/web/templates/base.html", + "hashed_secret": "ba694a3ebbdda9771f1e2cf97f847ee08f355f91", + "is_verified": false, + "line_number": 55 + } + ], + "tests/api_tests/test_browser_endpoint.py": [ + { + "type": "Secret Keyword", + "filename": "tests/api_tests/test_browser_endpoint.py", + "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", + "is_verified": false, + "line_number": 29 + } + ], + "tests/api_tests/test_research_creation.py": [ + { + "type": "Secret Keyword", + "filename": "tests/api_tests/test_research_creation.py", + "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", + "is_verified": false, + "line_number": 29 + } + ], + "tests/api_tests/test_simple_auth.py": [ + { + "type": "Secret Keyword", + "filename": "tests/api_tests/test_simple_auth.py", + "hashed_secret": "c18006fc138809314751cd1991f1e0b820fabd37", + "is_verified": false, + "line_number": 23 + } + ], + "tests/api_tests/test_without_csrf.py": [ + { + "type": "Secret Keyword", + "filename": "tests/api_tests/test_without_csrf.py", + "hashed_secret": "c18006fc138809314751cd1991f1e0b820fabd37", + "is_verified": false, + "line_number": 21 + } + ], + "tests/auth_tests/test_auth_decorators.py": [ + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_decorators.py", + "hashed_secret": "d4e0e04792fd434b5dc9c4155c178f66edcf4ed3", + "is_verified": false, + "line_number": 19 + } + ], + "tests/auth_tests/test_auth_integration.py": [ + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "c18006fc138809314751cd1991f1e0b820fabd37", + "is_verified": false, + "line_number": 100 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "e38ad214943daad1d64c102faec29de4afe9da3d", + "is_verified": false, + "line_number": 116 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "2aa60a8ff7fcd473d321e0146afd9e26df395147", + "is_verified": false, + "line_number": 135 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "fb47035803a93e720cd9209dd885770a83de1265", + "is_verified": false, + "line_number": 224 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_integration.py", + "hashed_secret": "6211dfd2282903503f9d5ae63cf0f4322989cbf1", + "is_verified": false, + "line_number": 236 + } + ], + "tests/auth_tests/test_auth_routes.py": [ + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "c18006fc138809314751cd1991f1e0b820fabd37", + "is_verified": false, + "line_number": 81 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "e38ad214943daad1d64c102faec29de4afe9da3d", + "is_verified": false, + "line_number": 125 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "2aa60a8ff7fcd473d321e0146afd9e26df395147", + "is_verified": false, + "line_number": 126 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "1ed4fad3f8115f0a53b0f3482e7c64a8bbebe4dc", + "is_verified": false, + "line_number": 164 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "d8ecf7db8fc9ec9c31bc5c9ae2929cc599c75f8d", + "is_verified": false, + "line_number": 206 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "fb47035803a93e720cd9209dd885770a83de1265", + "is_verified": false, + "line_number": 241 + }, + { + "type": "Secret Keyword", + "filename": "tests/auth_tests/test_auth_routes.py", + "hashed_secret": "6211dfd2282903503f9d5ae63cf0f4322989cbf1", + "is_verified": false, + "line_number": 252 + } + ], + "tests/database/test_benchmark_models.py": [ + { + "type": "Hex High Entropy String", + "filename": "tests/database/test_benchmark_models.py", + "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", + "is_verified": false, + "line_number": 43 + } + ], + "tests/database/test_database_init.py": [ + { + "type": "Secret Keyword", + "filename": "tests/database/test_database_init.py", + "hashed_secret": "5a0aee0f3af308cd6d74d617fde6592c2bc94fa3", + "is_verified": false, + "line_number": 77 + } + ], + "tests/ui_tests/auth_helper.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/auth_helper.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 8 + } + ], + "tests/ui_tests/test_check_research_thread.py": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_check_research_thread.py", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 10 + } + ], + "tests/ui_tests/test_concurrent_limit.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_concurrent_limit.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 109 + } + ], + "tests/ui_tests/test_multi_research.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_multi_research.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 185 + } + ], + "tests/ui_tests/test_queue_simple.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_queue_simple.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 125 + } + ], + "tests/ui_tests/test_research_minimal_debug.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_research_minimal_debug.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 27 + } + ], + "tests/ui_tests/test_simple_auth.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_simple_auth.js", + "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", + "is_verified": false, + "line_number": 22 + } + ], + "tests/ui_tests/test_simple_research.js": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_simple_research.js", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 23 + } + ], + "tests/ui_tests/test_simple_research_api.py": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_simple_research_api.py", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 11 + } + ], + "tests/ui_tests/test_trace_error.py": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_trace_error.py", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 41 + } + ], + "tests/ui_tests/test_uuid_fresh_db.py": [ + { + "type": "Secret Keyword", + "filename": "tests/ui_tests/test_uuid_fresh_db.py", + "hashed_secret": "e23c1eb5519569ad665816aa83caa924dca2c12a", + "is_verified": false, + "line_number": 82 + } + ] + }, + "generated_at": "2025-06-29T08:53:26Z" +}
src/local_deep_research/advanced_search_system/answer_decoding/browsecomp_answer_decoder.py+1 −3 modified@@ -9,13 +9,11 @@ """ import base64 -import logging +from loguru import logger import re import urllib.parse from typing import Optional, Tuple -logger = logging.getLogger(__name__) - class BrowseCompAnswerDecoder: """
src/local_deep_research/advanced_search_system/candidate_exploration/adaptive_explorer.py+1 −1 modified@@ -219,7 +219,7 @@ def _generate_query_with_strategy( return self._direct_search_query(base_query) except Exception as e: - logger.error( + logger.exception( f"Error generating query with strategy {strategy}: {e}" ) return None
src/local_deep_research/advanced_search_system/candidate_exploration/base_explorer.py+6 −6 modified@@ -144,8 +144,8 @@ def _execute_search(self, query: str) -> Dict: logger.warning(f"Unknown search result format: {type(results)}") return {"results": [], "query": query} - except Exception as e: - logger.error(f"Error executing search '{query}': {e}") + except Exception: + logger.exception(f"Error executing search '{query}'") return {"results": []} def _extract_candidates_from_results( @@ -220,8 +220,8 @@ def _generate_answer_candidates( return answers[:5] # Limit to 5 candidates max - except Exception as e: - logger.error(f"Error generating answer candidates: {e}") + except Exception: + logger.exception("Error generating answer candidates") return [] def _extract_entity_names( @@ -262,8 +262,8 @@ def _extract_entity_names( return names[:5] # Limit to top 5 per text - except Exception as e: - logger.error(f"Error extracting entity names: {e}") + except Exception: + logger.exception("Error extracting entity names") return [] def _should_continue_exploration(
src/local_deep_research/advanced_search_system/candidate_exploration/parallel_explorer.py+5 −3 modified@@ -106,7 +106,9 @@ def explore( ) except Exception as e: - logger.error(f"Error processing query '{query}': {e}") + logger.exception( + f"Error processing query '{query}': {e}" + ) # Add new candidates all_candidates.extend(round_candidates) @@ -216,8 +218,8 @@ def _generate_query_variations(self, base_query: str) -> List[str]: return queries[:4] - except Exception as e: - logger.error(f"Error generating query variations: {e}") + except Exception: + logger.exception("Error generating query variations") return [] def _generate_candidate_based_queries(
src/local_deep_research/advanced_search_system/candidate_exploration/progressive_explorer.py+5 −9 modified@@ -3,11 +3,12 @@ """ import concurrent.futures -import logging from dataclasses import dataclass, field from typing import Dict, List, Set, Tuple -logger = logging.getLogger(__name__) +from loguru import logger + +from ...utilities.thread_context import preserve_research_context @dataclass @@ -236,21 +237,16 @@ def _parallel_search( """Execute searches in parallel and return results.""" results = [] - # Import context preservation utility - from ...utilities.thread_context import ( - create_context_preserving_wrapper, - ) - def search_query(query): try: search_results = self.search_engine.run(query) return (query, search_results or []) except Exception as e: - logger.error(f"Error searching '{query}': {str(e)}") + logger.exception(f"Error searching '{query}': {e!s}") return (query, []) # Create context-preserving wrapper for the search function - context_aware_search = create_context_preserving_wrapper(search_query) + context_aware_search = preserve_research_context(search_query) # Run searches in parallel with concurrent.futures.ThreadPoolExecutor(
src/local_deep_research/advanced_search_system/constraint_checking/base_constraint_checker.py+3 −3 modified@@ -117,6 +117,6 @@ def _calculate_weighted_score( """Calculate weighted average score.""" if not constraint_scores or not weights: return 0.0 - return sum(s * w for s, w in zip(constraint_scores, weights)) / sum( - weights - ) + return sum( + s * w for s, w in zip(constraint_scores, weights, strict=False) + ) / sum(weights)
src/local_deep_research/advanced_search_system/constraint_checking/constraint_checker.py+3 −3 modified@@ -196,9 +196,9 @@ def check_candidate( if detailed_results: weights = [r["weight"] for r in detailed_results] scores = [r["score"] for r in detailed_results] - total_score = sum(s * w for s, w in zip(scores, weights)) / sum( - weights - ) + total_score = sum( + s * w for s, w in zip(scores, weights, strict=False) + ) / sum(weights) logger.info(f"Final score for {candidate.name}: {total_score:.2%}")
src/local_deep_research/advanced_search_system/constraint_checking/constraint_satisfaction_tracker.py+1 −2 modified@@ -11,11 +11,10 @@ 4. Constraint difficulty analysis """ -import logging from dataclasses import dataclass from typing import Dict, List -logger = logging.getLogger(__name__) +from loguru import logger @dataclass
src/local_deep_research/advanced_search_system/constraint_checking/evidence_analyzer.py+2 −2 modified@@ -96,8 +96,8 @@ def analyze_evidence_dual_confidence( source=evidence.get("source", "search"), ) - except Exception as e: - logger.error(f"Error analyzing evidence: {e}") + except Exception: + logger.exception("Error analyzing evidence") # Default to high uncertainty return ConstraintEvidence( positive_confidence=0.1,
src/local_deep_research/advanced_search_system/constraint_checking/intelligent_constraint_relaxer.py+1 −3 modified@@ -8,11 +8,9 @@ complex multi-constraint queries that may not have perfect matches. """ -import logging +from loguru import logger from typing import Dict, List -logger = logging.getLogger(__name__) - class IntelligentConstraintRelaxer: """
src/local_deep_research/advanced_search_system/constraint_checking/strict_checker.py+2 −2 modified@@ -187,8 +187,8 @@ def _evaluate_constraint_strictly( match = re.search(r"(\d*\.?\d+)", response) if match: return max(0.0, min(float(match.group(1)), 1.0)) - except Exception as e: - logger.error(f"Error in strict evaluation: {e}") + except Exception: + logger.exception("Error in strict evaluation") return 0.0 # Default to fail on error
src/local_deep_research/advanced_search_system/constraint_checking/threshold_checker.py+2 −2 modified@@ -207,7 +207,7 @@ def _check_constraint_satisfaction( score = float(match.group(1)) return max(0.0, min(score, 1.0)) - except Exception as e: - logger.error(f"Error checking constraint satisfaction: {e}") + except Exception: + logger.exception("Error checking constraint satisfaction") return 0.5 # Default to neutral if parsing fails
src/local_deep_research/advanced_search_system/constraints/__init__.py+1 −1 modified@@ -3,4 +3,4 @@ from .base_constraint import Constraint, ConstraintType from .constraint_analyzer import ConstraintAnalyzer -__all__ = ["Constraint", "ConstraintType", "ConstraintAnalyzer"] +__all__ = ["Constraint", "ConstraintAnalyzer", "ConstraintType"]
src/local_deep_research/advanced_search_system/evidence/base_evidence.py+2 −2 modified@@ -3,7 +3,7 @@ """ from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, UTC from enum import Enum from typing import Any, Dict, Optional @@ -47,7 +47,7 @@ class Evidence: reasoning: Optional[str] = None raw_text: Optional[str] = None timestamp: str = field( - default_factory=lambda: datetime.utcnow().isoformat() + default_factory=lambda: datetime.now(UTC).isoformat() ) metadata: Dict[str, Any] = field(default_factory=dict)
src/local_deep_research/advanced_search_system/evidence/__init__.py+1 −1 modified@@ -6,7 +6,7 @@ __all__ = [ "Evidence", - "EvidenceType", "EvidenceEvaluator", "EvidenceRequirements", + "EvidenceType", ]
src/local_deep_research/advanced_search_system/filters/cross_engine_filter.py+20 −3 modified@@ -7,7 +7,6 @@ from loguru import logger -from ...utilities.db_utils import get_db_setting from ...utilities.search_utilities import remove_think_tags from .base_filter import BaseFilter @@ -21,6 +20,7 @@ def __init__( max_results=None, default_reorder=True, default_reindex=True, + settings_snapshot=None, ): """ Initialize the cross-engine filter. @@ -30,13 +30,30 @@ def __init__( max_results: Maximum number of results to keep after filtering default_reorder: Default setting for reordering results by relevance default_reindex: Default setting for reindexing results after filtering + settings_snapshot: Settings snapshot for thread context """ super().__init__(model) # Get max_results from database settings if not provided if max_results is None: - max_results = int( - get_db_setting("search.cross_engine_max_results", 100) + # Import from thread_settings to avoid database dependencies + from ...config.thread_settings import ( + get_setting_from_snapshot, + NoSettingsContextError, ) + + try: + max_results = get_setting_from_snapshot( + "search.cross_engine_max_results", + default=100, + settings_snapshot=settings_snapshot, + ) + # Ensure we have an integer + if max_results is not None: + max_results = int(max_results) + else: + max_results = 100 + except (NoSettingsContextError, TypeError, ValueError): + max_results = 100 # Explicit default self.max_results = max_results self.default_reorder = default_reorder self.default_reindex = default_reindex
src/local_deep_research/advanced_search_system/filters/followup_relevance_filter.py+165 −0 added@@ -0,0 +1,165 @@ +""" +Follow-up Relevance Filter + +Filters and ranks past research sources based on their relevance +to follow-up questions. +""" + +from typing import Dict, List +from loguru import logger + +from .base_filter import BaseFilter +from ...utilities.search_utilities import remove_think_tags + + +class FollowUpRelevanceFilter(BaseFilter): + """ + Filters past research sources by relevance to follow-up questions. + + This filter analyzes sources from previous research and determines + which ones are most relevant to the new follow-up question. + """ + + def filter_results( + self, results: List[Dict], query: str, max_results: int = 10, **kwargs + ) -> List[Dict]: + """ + Filter search results by relevance to the follow-up query. + + Args: + results: List of source dictionaries from past research + query: The follow-up query + max_results: Maximum number of results to return (default: 10) + **kwargs: Additional parameters: + - past_findings: Summary of past findings for context + - original_query: The original research query + + Returns: + Filtered list of relevant sources + """ + if not results: + return [] + + past_findings = kwargs.get("past_findings", "") + original_query = kwargs.get("original_query", "") + + # Use LLM to select relevant sources + relevant_indices = self._select_relevant_sources( + results, query, past_findings, max_results, original_query + ) + + # Return selected sources + filtered = [results[i] for i in relevant_indices if i < len(results)] + + logger.info( + f"Filtered {len(results)} sources to {len(filtered)} relevant ones " + f"for follow-up query. Kept indices: {relevant_indices}" + ) + + return filtered + + def _select_relevant_sources( + self, + sources: List[Dict], + query: str, + context: str, + max_results: int, + original_query: str = "", + ) -> List[int]: + """ + Select relevant sources using LLM. + + Args: + sources: List of source dictionaries + query: The follow-up query + context: Past findings context + max_results: Maximum number of sources to select + original_query: The original research query + + Returns: + List of indices of relevant sources + """ + if not self.model: + # If no model available, return first max_results + return list(range(min(max_results, len(sources)))) + + # Build source list for LLM + source_list = [] + for i, source in enumerate(sources): + title = source.get("title") or "Unknown" + url = source.get("url") or "" + snippet = ( + source.get("snippet") or source.get("content_preview") or "" + )[:150] + source_list.append( + f"{i}. {title}\n URL: {url}\n Content: {snippet}" + ) + + sources_text = "\n\n".join(source_list) + + # Include context if available for better selection + context_section = "" + if context or original_query: + parts = [] + if original_query: + parts.append(f"Original research question: {original_query}") + if context: + parts.append(f"Previous research findings:\n{context}") + + context_section = f""" +Previous Research Context: +{chr(10).join(parts)} + +--- +""" + + prompt = f""" +Select the most relevant sources for answering this follow-up question based on the previous research context. +{context_section} +Follow-up question: "{query}" + +Available sources from previous research: +{sources_text} + +Instructions: +- Select sources that are most relevant to the follow-up question given the context +- Consider which sources directly address the question or provide essential information +- Think about what the user is asking for in relation to the previous findings +- Return ONLY a JSON array of source numbers (e.g., [0, 2, 5, 7]) +- Do not include any explanation or other text + +Return the indices of relevant sources as a JSON array:""" + + try: + response = self.model.invoke(prompt) + content = remove_think_tags(response.content).strip() + + # Parse JSON response + import json + + try: + indices = json.loads(content) + # Validate it's a list of integers + if not isinstance(indices, list): + raise ValueError("Response is not a list") + indices = [ + int(i) + for i in indices + if isinstance(i, (int, float)) and int(i) < len(sources) + ] + + except (json.JSONDecodeError, ValueError) as parse_error: + logger.debug( + f"Failed to parse JSON, attempting regex fallback: {parse_error}" + ) + # Fallback to regex extraction + import re + + numbers = re.findall(r"\d+", content) + indices = [int(n) for n in numbers if int(n) < len(sources)] + + return indices + except Exception as e: + logger.debug(f"LLM source selection failed: {e}") + # Fallback to first max_results sources + return list(range(min(max_results, len(sources))))
src/local_deep_research/advanced_search_system/filters/journal_reputation_filter.py+69 −18 modified@@ -6,11 +6,13 @@ from langchain_core.language_models.chat_models import BaseChatModel from loguru import logger from methodtools import lru_cache +from sqlalchemy.orm import Session from ...config.llm_config import get_llm +from ...database.models import Journal +from ...database.session_context import get_user_db_session from ...search_system import AdvancedSearchSystem -from ...utilities.db_utils import get_db_session, get_db_setting -from ...web.database.models import Journal +from ...utilities.thread_context import get_search_context from ...web_search_engines.search_engine_factory import create_search_engine from .base_filter import BaseFilter @@ -35,6 +37,7 @@ def __init__( max_context: int | None = None, exclude_non_published: bool | None = None, quality_reanalysis_period: timedelta | None = None, + settings_snapshot: Dict[str, Any] | None = None, ): """ Args: @@ -49,6 +52,7 @@ def __init__( don't have an associated journal publication. quality_reanalysis_period: Period at which to update journal quality assessments. + settings_snapshot: Settings snapshot for thread context. """ super().__init__(model) @@ -58,40 +62,64 @@ def __init__( self.__threshold = reliability_threshold if self.__threshold is None: + # Import here to avoid circular import + from ...config.search_config import get_setting_from_snapshot + self.__threshold = int( - get_db_setting("search.journal_reputation.threshold", 4) + get_setting_from_snapshot( + "search.journal_reputation.threshold", + 4, + settings_snapshot=settings_snapshot, + ) ) self.__max_context = max_context if self.__max_context is None: self.__max_context = int( - get_db_setting("search.journal_reputation.max_context", 3000) + get_setting_from_snapshot( + "search.journal_reputation.max_context", + 3000, + settings_snapshot=settings_snapshot, + ) ) self.__exclude_non_published = exclude_non_published if self.__exclude_non_published is None: self.__exclude_non_published = bool( - get_db_setting( - "search.journal_reputation.exclude_non_published", False + get_setting_from_snapshot( + "search.journal_reputation.exclude_non_published", + False, + settings_snapshot=settings_snapshot, ) ) self.__quality_reanalysis_period = quality_reanalysis_period if self.__quality_reanalysis_period is None: self.__quality_reanalysis_period = timedelta( days=int( - get_db_setting( - "search.journal_reputation.reanalysis_period", 365 + get_setting_from_snapshot( + "search.journal_reputation.reanalysis_period", + 365, + settings_snapshot=settings_snapshot, ) ) ) + # Store settings_snapshot for later use + self.__settings_snapshot = settings_snapshot + # SearXNG is required so we can search the open web for reputational # information. - self.__engine = create_search_engine("searxng", llm=self.model) + self.__engine = create_search_engine( + "searxng", llm=self.model, settings_snapshot=settings_snapshot + ) if self.__engine is None: raise JournalFilterError("SearXNG initialization failed.") @classmethod def create_default( - cls, model: BaseChatModel | None = None, *, engine_name: str + cls, + model: BaseChatModel | None = None, + *, + engine_name: str, + settings_snapshot: Dict[str, Any] | None = None, ) -> Optional["JournalReputationFilter"]: """ Initializes a default configuration of the filter based on the settings. @@ -100,30 +128,50 @@ def create_default( model: Explicitly specify the LLM to use. engine_name: The name of the search engine. Will be used to check the enablement status for that engine. + settings_snapshot: Settings snapshot for thread context. Returns: The filter that it created, or None if filtering is disabled in the settings, or misconfigured. """ + # Import here to avoid circular import + from ...config.search_config import get_setting_from_snapshot + if not bool( - get_db_setting( + get_setting_from_snapshot( f"search.engine.web.{engine_name}.journal_reputation.enabled", True, + settings_snapshot=settings_snapshot, ) ): return None try: # Initialize the filter with default settings. - return JournalReputationFilter(model=model) + return JournalReputationFilter( + model=model, settings_snapshot=settings_snapshot + ) except JournalFilterError: - logger.error( + logger.exception( "SearXNG is not configured, but is required for " "journal reputation filtering. Disabling filtering." ) return None + @staticmethod + def __db_session() -> Session: + """ + Returns: + The database session to use. + + """ + context = get_search_context() + username = context.get("username") + password = context.get("user_password") + + return get_user_db_session(username=username, password=password) + def __make_search_system(self) -> AdvancedSearchSystem: """ Creates a new `AdvancedSearchSystem` instance. @@ -136,8 +184,9 @@ def __make_search_system(self) -> AdvancedSearchSystem: llm=self.model, search=self.__engine, # We clamp down on the default iterations and questions for speed. - max_iterations=2, + max_iterations=1, questions_per_iteration=3, + settings_snapshot=self.__settings_snapshot, ) @lru_cache(maxsize=1024) @@ -193,7 +242,9 @@ def __analyze_journal_reputation(self, journal_name: str) -> int: try: reputation_score = int(response.strip()) except ValueError: - logger.error("Failed to parse reputation score from LLM response.") + logger.exception( + "Failed to parse reputation score from LLM response." + ) raise ValueError( "Failed to parse reputation score from LLM response." ) @@ -209,7 +260,7 @@ def __add_journal_to_db(self, *, name: str, quality: int) -> None: quality: The quality assessment for the journal. """ - with get_db_session() as db_session: + with self.__db_session() as db_session: journal = db_session.query(Journal).filter_by(name=name).first() if journal is not None: journal.quality = quality @@ -275,7 +326,7 @@ def __check_result(self, result: Dict[str, Any]) -> bool: journal_name = self.__clean_journal_name(journal_name) # Check the database first. - with get_db_session() as session: + with self.__db_session() as session: journal = ( session.query(Journal).filter_by(name=journal_name).first() ) @@ -306,7 +357,7 @@ def filter_results( try: return list(filter(self.__check_result, results)) except Exception as e: - logger.error( + logger.exception( f"Journal quality filtering failed: {e}, {traceback.format_exc()}" ) return results
src/local_deep_research/advanced_search_system/findings/base_findings.py+0 −3 modified@@ -3,14 +3,11 @@ Defines the common interface and shared functionality for different findings management approaches. """ -import logging from abc import ABC, abstractmethod from typing import Dict, List from langchain_core.language_models import BaseLLM -logger = logging.getLogger(__name__) - class BaseFindingsRepository(ABC): """Abstract base class for all findings repositories."""
src/local_deep_research/advanced_search_system/findings/repository.py+13 −20 modified@@ -2,7 +2,7 @@ Findings repository for managing research findings. """ -import logging +from loguru import logger from typing import Dict, List, Union from langchain_core.documents import Document @@ -11,8 +11,6 @@ from ...utilities.search_utilities import format_findings from .base_findings import BaseFindingsRepository -logger = logging.getLogger(__name__) - def format_links(links: List[Dict]) -> str: """Format a list of links into a readable string. @@ -161,9 +159,8 @@ def format_findings_to_text( logger.info("Successfully formatted final report.") return formatted_report except Exception as e: - logger.error( - f"Error occurred during final report formatting: {str(e)}", - exc_info=True, + logger.exception( + f"Error occurred during final report formatting: {e!s}" ) # Fallback: return just the synthesized content if formatting fails return f"Error during final formatting. Raw Synthesized Content:\n\n{synthesized_content}" @@ -380,9 +377,8 @@ def target(): # Return only the synthesized content from the LLM return synthesized_content except TimeoutError as timeout_error: - logger.error( - f"LLM invocation timed out during synthesis for query '{query}': {timeout_error}", - exc_info=True, + logger.exception( + f"LLM invocation timed out during synthesis for query '{query}': {timeout_error}" ) # Return more specific error about timeout return "Error: Final answer synthesis failed due to LLM timeout. Please check your LLM service or try with a smaller query scope." @@ -421,17 +417,15 @@ def signal_handler(signum, frame): # Return only the synthesized content from the LLM return synthesized_content except TimeoutError as timeout_error: - logger.error( - f"LLM invocation timed out during synthesis for query '{query}': {timeout_error}", - exc_info=True, + logger.exception( + f"LLM invocation timed out during synthesis for query '{query}': {timeout_error}" ) # Return more specific error about timeout return "Error: Final answer synthesis failed due to LLM timeout. Please check your LLM service or try with a smaller query scope." except Exception as invoke_error: - logger.error( - f"LLM invocation failed during synthesis for query '{query}': {invoke_error}", - exc_info=True, + logger.exception( + f"LLM invocation failed during synthesis for query '{query}': {invoke_error}" ) # Attempt to determine the type of error @@ -474,13 +468,12 @@ def signal_handler(signum, frame): return "Error: Failed to synthesize final answer due to authentication issues. Please check your API keys." else: # Generic error with details - return f"Error: Failed to synthesize final answer. LLM error: {str(invoke_error)}" + return f"Error: Failed to synthesize final answer. LLM error: {invoke_error!s}" except Exception as e: # Catch potential errors during prompt construction or logging itself - logger.error( - f"Error preparing or executing synthesis for query '{query}': {str(e)}", - exc_info=True, + logger.exception( + f"Error preparing or executing synthesis for query '{query}': {e!s}" ) # Return a specific error message for synthesis failure - return f"Error: Failed to synthesize final answer from knowledge. Details: {str(e)}" + return f"Error: Failed to synthesize final answer from knowledge. Details: {e!s}"
src/local_deep_research/advanced_search_system/knowledge/base_knowledge.py+1 −3 modified@@ -2,14 +2,12 @@ Base class for knowledge extraction and generation. """ -import logging +from loguru import logger from abc import ABC, abstractmethod from typing import List from langchain_core.language_models.chat_models import BaseChatModel -logger = logging.getLogger(__name__) - class BaseKnowledgeGenerator(ABC): """Base class for generating knowledge from text."""
src/local_deep_research/advanced_search_system/knowledge/followup_context_manager.py+415 −0 added@@ -0,0 +1,415 @@ +""" +Follow-up Context Manager + +Manages and processes past research context for follow-up questions. +This is a standalone class that doesn't inherit from BaseKnowledgeGenerator +to avoid implementing many abstract methods. +""" + +from typing import Dict, List, Any, Optional +from loguru import logger + +from langchain_core.language_models.chat_models import BaseChatModel +from ...utilities.search_utilities import remove_think_tags + + +class FollowUpContextHandler: + """ + Manages past research context for follow-up research. + + This class handles: + 1. Loading and structuring past research data + 2. Summarizing findings for follow-up context + 3. Extracting relevant information for new searches + 4. Building comprehensive context for strategies + """ + + def __init__( + self, model: BaseChatModel, settings_snapshot: Optional[Dict] = None + ): + """ + Initialize the context manager. + + Args: + model: Language model for processing context + settings_snapshot: Optional settings snapshot + """ + self.model = model + self.settings_snapshot = settings_snapshot or {} + self.past_research_cache = {} + + def build_context( + self, research_data: Dict[str, Any], follow_up_query: str + ) -> Dict[str, Any]: + """ + Build comprehensive context from past research. + + Args: + research_data: Past research data including findings, sources, etc. + follow_up_query: The follow-up question being asked + + Returns: + Structured context dictionary for follow-up research + """ + logger.info(f"Building context for follow-up: {follow_up_query}") + + # Extract all components + context = { + "parent_research_id": research_data.get("research_id", ""), + "original_query": research_data.get("query", ""), + "follow_up_query": follow_up_query, + "past_findings": self._extract_findings(research_data), + "past_sources": self._extract_sources(research_data), + "key_entities": self._extract_entities(research_data), + "summary": self._create_summary(research_data, follow_up_query), + "report_content": research_data.get("report_content", ""), + "formatted_findings": research_data.get("formatted_findings", ""), + "all_links_of_system": research_data.get("all_links_of_system", []), + "metadata": self._extract_metadata(research_data), + } + + return context + + def _extract_findings(self, research_data: Dict) -> str: + """ + Extract and format findings from past research. + + Args: + research_data: Past research data + + Returns: + Formatted findings string + """ + findings_parts = [] + + # Check various possible locations for findings + if formatted := research_data.get("formatted_findings"): + findings_parts.append(formatted) + + if report := research_data.get("report_content"): + # Take first part of report if no formatted findings + if not findings_parts: + findings_parts.append(report[:2000]) + + if not findings_parts: + return "No previous findings available" + + combined = "\n\n".join(findings_parts) + return combined + + def _extract_sources(self, research_data: Dict) -> List[Dict]: + """ + Extract and structure sources from past research. + + Args: + research_data: Past research data + + Returns: + List of source dictionaries + """ + sources = [] + seen_urls = set() + + # Check all possible source fields + for field in ["resources", "all_links_of_system", "past_links"]: + if field_sources := research_data.get(field, []): + for source in field_sources: + url = source.get("url", "") + # Avoid duplicates by URL + if url and url not in seen_urls: + sources.append(source) + seen_urls.add(url) + elif not url: + # Include sources without URLs (shouldn't happen but be safe) + sources.append(source) + + return sources + + def _extract_entities(self, research_data: Dict) -> List[str]: + """ + Extract key entities from past research. + + Args: + research_data: Past research data + + Returns: + List of key entities + """ + findings = self._extract_findings(research_data) + + if not findings or not self.model: + return [] + + prompt = f""" +Extract key entities (names, places, organizations, concepts) from these research findings: + +{findings[:2000]} + +Return up to 10 most important entities, one per line. +""" + + try: + response = self.model.invoke(prompt) + entities = [ + line.strip() + for line in remove_think_tags(response.content) + .strip() + .split("\n") + if line.strip() + ] + return entities[:10] + except Exception as e: + logger.warning(f"Failed to extract entities: {e}") + return [] + + def _create_summary(self, research_data: Dict, follow_up_query: str) -> str: + """ + Create a targeted summary of past research relevant to the follow-up question. + This is used internally for building context. + + Args: + research_data: Past research data + follow_up_query: The follow-up question + + Returns: + Targeted summary for context building + """ + findings = self._extract_findings(research_data) + original_query = research_data.get("query", "") + + # For internal context, create a brief targeted summary + return self._generate_summary( + findings=findings, + query=follow_up_query, + original_query=original_query, + max_sentences=5, + purpose="context", + ) + + def _extract_metadata(self, research_data: Dict) -> Dict: + """ + Extract metadata from past research. + + Args: + research_data: Past research data + + Returns: + Metadata dictionary + """ + return { + "strategy": research_data.get("strategy", ""), + "mode": research_data.get("mode", ""), + "created_at": research_data.get("created_at", ""), + "research_meta": research_data.get("research_meta", {}), + } + + def summarize_for_followup( + self, findings: str, query: str, max_length: int = 1000 + ) -> str: + """ + Create a concise summary of findings for external use (e.g., in prompts). + This creates a length-constrained summary suitable for inclusion in LLM prompts. + + Args: + findings: Past research findings + query: Follow-up query + max_length: Maximum length of summary in characters + + Returns: + Concise summary constrained to max_length + """ + # Use the shared summary generation with specific parameters for external use + return self._generate_summary( + findings=findings, + query=query, + original_query=None, + max_sentences=max_length + // 100, # Approximate sentences based on length + purpose="prompt", + max_length=max_length, + ) + + def _generate_summary( + self, + findings: str, + query: str, + original_query: Optional[str] = None, + max_sentences: int = 5, + purpose: str = "context", + max_length: Optional[int] = None, + ) -> str: + """ + Shared summary generation logic. + + Args: + findings: Research findings to summarize + query: Follow-up query + original_query: Original research query (optional) + max_sentences: Maximum number of sentences + purpose: Purpose of summary ("context" or "prompt") + max_length: Maximum character length (optional) + + Returns: + Generated summary + """ + if not findings: + return "" + + # If findings are already short enough, return as-is + if max_length and len(findings) <= max_length: + return findings + + if not self.model: + # Fallback without model + if max_length: + return findings[:max_length] + "..." + return findings[:500] + "..." + + # Build prompt based on purpose + if purpose == "context" and original_query: + prompt = f""" +Create a brief summary of previous research findings that are relevant to this follow-up question: + +Original research question: "{original_query}" +Follow-up question: "{query}" + +Previous findings: +{findings[:3000]} + +Provide a {max_sentences}-sentence summary focusing on aspects relevant to the follow-up question. +""" + else: + prompt = f""" +Summarize these research findings in relation to the follow-up question: + +Follow-up question: "{query}" + +Findings: +{findings[:4000]} + +Create a summary of {max_sentences} sentences that captures the most relevant information. +""" + + try: + response = self.model.invoke(prompt) + summary = remove_think_tags(response.content).strip() + + # Apply length constraint if specified + if max_length and len(summary) > max_length: + summary = summary[:max_length] + "..." + + return summary + except Exception as e: + logger.warning(f"Summary generation failed: {e}") + # Fallback to truncation + if max_length: + return findings[:max_length] + "..." + return findings[:500] + "..." + + def identify_gaps( + self, research_data: Dict, follow_up_query: str + ) -> List[str]: + """ + Identify information gaps that the follow-up should address. + + Args: + research_data: Past research data + follow_up_query: Follow-up question + + Returns: + List of identified gaps + """ + findings = self._extract_findings(research_data) + + if not findings or not self.model: + return [] + + prompt = f""" +Based on the previous research and the follow-up question, identify information gaps: + +Previous research findings: +{findings[:2000]} + +Follow-up question: "{follow_up_query}" + +What specific information is missing or needs clarification? List up to 5 gaps, one per line. +""" + + try: + response = self.model.invoke(prompt) + gaps = [ + line.strip() + for line in remove_think_tags(response.content) + .strip() + .split("\n") + if line.strip() + ] + return gaps[:5] + except Exception as e: + logger.warning(f"Failed to identify gaps: {e}") + return [] + + def format_for_settings_snapshot( + self, context: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Format context for inclusion in settings snapshot. + Only includes essential metadata, not actual content. + + Args: + context: Full context dictionary + + Returns: + Minimal metadata for settings snapshot + """ + # Only include minimal metadata in settings snapshot + # Settings snapshot should be for settings, not data + return { + "followup_metadata": { + "parent_research_id": context.get("parent_research_id"), + "is_followup": True, + "has_context": bool(context.get("past_findings")), + } + } + + def get_relevant_context_for_llm( + self, context: Dict[str, Any], max_tokens: int = 2000 + ) -> str: + """ + Get a concise version of context for LLM prompts. + + Args: + context: Full context dictionary + max_tokens: Approximate maximum tokens + + Returns: + Concise context string + """ + parts = [] + + # Add original and follow-up queries + parts.append(f"Original research: {context.get('original_query', '')}") + parts.append( + f"Follow-up question: {context.get('follow_up_query', '')}" + ) + + # Add summary + if summary := context.get("summary"): + parts.append(f"\nPrevious findings summary:\n{summary}") + + # Add key entities + if entities := context.get("key_entities"): + parts.append(f"\nKey entities: {', '.join(entities[:5])}") + + # Add source count + if sources := context.get("past_sources"): + parts.append(f"\nAvailable sources: {len(sources)}") + + result = "\n".join(parts) + + # Truncate if needed (rough approximation: 4 chars per token) + max_chars = max_tokens * 4 + if len(result) > max_chars: + result = result[:max_chars] + "..." + + return result
src/local_deep_research/advanced_search_system/knowledge/standard_knowledge.py+5 −7 modified@@ -2,14 +2,12 @@ Standard knowledge generator implementation. """ -import logging -from datetime import datetime +from loguru import logger +from datetime import datetime, UTC from typing import List from .base_knowledge import BaseKnowledgeGenerator -logger = logging.getLogger(__name__) - class StandardKnowledge(BaseKnowledgeGenerator): """Standard knowledge generator implementation.""" @@ -22,7 +20,7 @@ def generate_knowledge( questions: List[str] = None, ) -> str: """Generate knowledge based on query and context.""" - now = datetime.now() + now = datetime.now(UTC) current_time = now.strftime("%Y-%m-%d") logger.info("Generating knowledge...") @@ -95,7 +93,7 @@ def generate_sub_knowledge(self, sub_query: str, context: str = "") -> str: response = self.model.invoke(prompt) return response.content except Exception as e: - logger.error(f"Error generating sub-knowledge: {str(e)}") + logger.exception(f"Error generating sub-knowledge: {e!s}") return "" def generate(self, query: str, context: str) -> str: @@ -137,7 +135,7 @@ def compress_knowledge( ) return compressed_knowledge except Exception as e: - logger.error(f"Error compressing knowledge: {str(e)}") + logger.exception(f"Error compressing knowledge: {e!s}") return current_knowledge # Return original if compression fails def format_citations(self, links: List[str]) -> str:
src/local_deep_research/advanced_search_system/questions/atomic_fact_question.py+1 −3 modified@@ -3,13 +3,11 @@ Decomposes complex queries into atomic, independently searchable facts. """ -import logging +from loguru import logger from typing import Dict, List from .base_question import BaseQuestionGenerator -logger = logging.getLogger(__name__) - class AtomicFactQuestionGenerator(BaseQuestionGenerator): """
src/local_deep_research/advanced_search_system/questions/base_question.py+0 −3 modified@@ -3,12 +3,9 @@ Defines the common interface and shared functionality for different question generation approaches. """ -import logging from abc import ABC, abstractmethod from typing import Dict, List -logger = logging.getLogger(__name__) - class BaseQuestionGenerator(ABC): """Abstract base class for all question generators."""
src/local_deep_research/advanced_search_system/questions/browsecomp_question.py+2 −3 modified@@ -2,13 +2,12 @@ BrowseComp-specific question generation that creates progressive, entity-focused searches. """ -import logging import re from typing import Dict, List -from .base_question import BaseQuestionGenerator +from loguru import logger -logger = logging.getLogger(__name__) +from .base_question import BaseQuestionGenerator class BrowseCompQuestionGenerator(BaseQuestionGenerator):
src/local_deep_research/advanced_search_system/questions/decomposition_question.py+2 −4 modified@@ -1,12 +1,10 @@ -import logging from typing import List from langchain_core.language_models import BaseLLM +from loguru import logger from .base_question import BaseQuestionGenerator -logger = logging.getLogger(__name__) - class DecompositionQuestionGenerator(BaseQuestionGenerator): """Question generator for decomposing complex queries into sub-queries.""" @@ -298,7 +296,7 @@ def generate_questions( return sub_queries[: self.max_subqueries] # Limit to max_subqueries except Exception as e: - logger.error(f"Error generating sub-questions: {str(e)}") + logger.exception(f"Error generating sub-questions: {e!s}") # Fallback to basic questions in case of error return self._generate_default_questions(query)
src/local_deep_research/advanced_search_system/questions/entity_aware_question.py+6 −7 modified@@ -2,13 +2,12 @@ Entity-aware question generation for improved entity identification. """ -import logging -from datetime import datetime +from datetime import datetime, UTC from typing import List -from .base_question import BaseQuestionGenerator +from loguru import logger -logger = logging.getLogger(__name__) +from .base_question import BaseQuestionGenerator class EntityAwareQuestionGenerator(BaseQuestionGenerator): @@ -22,7 +21,7 @@ def generate_questions( questions_by_iteration: dict = None, ) -> List[str]: """Generate questions with entity-aware search patterns.""" - now = datetime.now() + now = datetime.now(UTC) current_time = now.strftime("%Y-%m-%d") questions_by_iteration = questions_by_iteration or {} @@ -60,7 +59,7 @@ def generate_questions( Query: {query} Today: {current_time} -Past questions: {str(questions_by_iteration)} +Past questions: {questions_by_iteration!s} Current knowledge: {current_knowledge} Create direct search queries that combine the key identifying features to find the specific name/entity. @@ -180,5 +179,5 @@ def generate_sub_questions( return questions except Exception as e: - logger.error(f"Error generating sub-questions: {str(e)}") + logger.exception(f"Error generating sub-questions: {e!s}") return []
src/local_deep_research/advanced_search_system/questions/followup/base_followup_question.py+91 −0 added@@ -0,0 +1,91 @@ +""" +Base class for follow-up question generators. + +This extends the standard question generator interface to handle +follow-up research that includes previous research context. +""" + +from abc import abstractmethod +from typing import Dict, List +from ..base_question import BaseQuestionGenerator + + +class BaseFollowUpQuestionGenerator(BaseQuestionGenerator): + """ + Abstract base class for follow-up question generators. + + These generators create contextualized queries that incorporate + previous research findings and context. + """ + + def __init__(self, model): + """ + Initialize the follow-up question generator. + + Args: + model: The language model to use for question generation + """ + super().__init__(model) + self.follow_up_context = {} + + def set_follow_up_context(self, context: Dict): + """ + Set the follow-up research context. + + Args: + context: Dictionary containing: + - past_findings: Previous research findings + - original_query: The original research query + - follow_up_query: The follow-up question from user + - past_sources: Sources from previous research + - key_entities: Key entities identified + """ + self.follow_up_context = context + + @abstractmethod + def generate_contextualized_query( + self, + follow_up_query: str, + original_query: str, + past_findings: str, + **kwargs, + ) -> str: + """ + Generate a contextualized query for follow-up research. + + Args: + follow_up_query: The user's follow-up question + original_query: The original research query + past_findings: The findings from previous research + **kwargs: Additional context parameters + + Returns: + str: A contextualized query that includes previous context + """ + pass + + def generate_questions( + self, + current_knowledge: str, + query: str, + questions_per_iteration: int, + questions_by_iteration: Dict[int, List[str]], + ) -> List[str]: + """ + Generate questions for follow-up research. + + For follow-up research, we typically return a single contextualized + query rather than multiple questions, as the context is already rich. + + Args: + current_knowledge: The accumulated knowledge so far + query: The research query (already contextualized) + questions_per_iteration: Number of questions to generate + questions_by_iteration: Previous questions + + Returns: + List[str]: List containing the contextualized query + """ + # For follow-up research, the query is already contextualized + # Just return it as a single-item list + return [query]
src/local_deep_research/advanced_search_system/questions/followup/__init__.py+14 −0 added@@ -0,0 +1,14 @@ +""" +Follow-up Question Generators Package + +This package contains specialized question generators for follow-up research +that builds upon previous research context. +""" + +from .base_followup_question import BaseFollowUpQuestionGenerator +from .simple_followup_question import SimpleFollowUpQuestionGenerator + +__all__ = [ + "BaseFollowUpQuestionGenerator", + "SimpleFollowUpQuestionGenerator", +]
src/local_deep_research/advanced_search_system/questions/followup/llm_followup_question.py+93 −0 added@@ -0,0 +1,93 @@ +""" +LLM-based follow-up question generator. + +This implementation uses an LLM to intelligently reformulate follow-up +questions based on the previous research context. +""" + +from typing import Dict, List +from loguru import logger +from .base_followup_question import BaseFollowUpQuestionGenerator + + +class LLMFollowUpQuestionGenerator(BaseFollowUpQuestionGenerator): + """ + LLM-based follow-up question generator. + + This generator uses an LLM to reformulate follow-up questions + based on the previous research context, creating more targeted + and effective search queries. + + NOTE: This is a placeholder for future implementation. + Currently falls back to simple concatenation. + """ + + def generate_contextualized_query( + self, + follow_up_query: str, + original_query: str, + past_findings: str, + **kwargs, + ) -> str: + """ + Generate a contextualized query using LLM reformulation. + + Future implementation will: + 1. Analyze the follow-up query in context of past findings + 2. Identify information gaps + 3. Reformulate for more effective searching + 4. Generate multiple targeted search questions + + Args: + follow_up_query: The user's follow-up question + original_query: The original research query + past_findings: The findings from previous research + **kwargs: Additional context parameters + + Returns: + str: An LLM-reformulated contextualized query + """ + # TODO: Implement LLM-based reformulation + # For now, fall back to simple concatenation + logger.warning( + "LLM-based follow-up question generation not yet implemented, " + "falling back to simple concatenation" + ) + + from .simple_followup_question import SimpleFollowUpQuestionGenerator + + simple_generator = SimpleFollowUpQuestionGenerator(self.model) + return simple_generator.generate_contextualized_query( + follow_up_query, original_query, past_findings, **kwargs + ) + + def generate_questions( + self, + current_knowledge: str, + query: str, + questions_per_iteration: int, + questions_by_iteration: Dict[int, List[str]], + ) -> List[str]: + """ + Generate multiple targeted questions for follow-up research. + + Future implementation will generate multiple specific questions + based on the follow-up query and context. + + Args: + current_knowledge: The accumulated knowledge so far + query: The research query + questions_per_iteration: Number of questions to generate + questions_by_iteration: Previous questions + + Returns: + List[str]: List of targeted follow-up questions + """ + # TODO: Implement multi-question generation + # For now, return single contextualized query + return super().generate_questions( + current_knowledge, + query, + questions_per_iteration, + questions_by_iteration, + )
src/local_deep_research/advanced_search_system/questions/followup/simple_followup_question.py+65 −0 added@@ -0,0 +1,65 @@ +""" +Simple concatenation-based follow-up question generator. + +This implementation preserves the current behavior of concatenating +the previous research context with the follow-up query without using +an LLM to reformulate. +""" + +from loguru import logger +from .base_followup_question import BaseFollowUpQuestionGenerator + + +class SimpleFollowUpQuestionGenerator(BaseFollowUpQuestionGenerator): + """ + Simple follow-up question generator that concatenates context. + + This generator creates a contextualized query by directly concatenating + the previous research findings with the follow-up question, without + any LLM-based reformulation. This ensures the follow-up query is + understood in the context of previous research. + """ + + def generate_contextualized_query( + self, + follow_up_query: str, + original_query: str, + past_findings: str, + **kwargs, + ) -> str: + """ + Generate a contextualized query by simple concatenation. + + This method preserves the exact user query while providing full + context from previous research. This ensures queries like + "provide data in a table" are understood as referring to the + previous findings, not as new searches. + + Args: + follow_up_query: The user's follow-up question + original_query: The original research query + past_findings: The findings from previous research + **kwargs: Additional context parameters (unused) + + Returns: + str: A contextualized query with previous research embedded + """ + # Simply concatenate the context with the query - no LLM interpretation needed + # Highlight importance at top, actual request at bottom + contextualized = f"""IMPORTANT: This is a follow-up request. Focus on addressing the specific user request at the bottom of this prompt using the previous research context provided below. + +Previous research query: {original_query} + +Previous findings: +{past_findings} + +--- +USER'S FOLLOW-UP REQUEST: {follow_up_query} +Please address this specific request using the context and findings above. +---""" + + logger.info( + f"Created contextualized query with {len(past_findings)} chars of context" + ) + + return contextualized
src/local_deep_research/advanced_search_system/questions/__init__.py+5 −3 modified@@ -5,13 +5,15 @@ from .browsecomp_question import BrowseCompQuestionGenerator from .decomposition_question import DecompositionQuestionGenerator from .entity_aware_question import EntityAwareQuestionGenerator +from .news_question import NewsQuestionGenerator from .standard_question import StandardQuestionGenerator __all__ = [ + "AtomicFactQuestionGenerator", "BaseQuestionGenerator", - "StandardQuestionGenerator", + "BrowseCompQuestionGenerator", "DecompositionQuestionGenerator", - "AtomicFactQuestionGenerator", "EntityAwareQuestionGenerator", - "BrowseCompQuestionGenerator", + "NewsQuestionGenerator", + "StandardQuestionGenerator", ]
src/local_deep_research/advanced_search_system/questions/news_question.py+49 −0 added@@ -0,0 +1,49 @@ +""" +News question generation implementation. +""" + +from datetime import datetime, UTC +from typing import List, Dict + +from loguru import logger + +from .base_question import BaseQuestionGenerator + + +class NewsQuestionGenerator(BaseQuestionGenerator): + """News-specific question generator for aggregating current news.""" + + def generate_questions( + self, + current_knowledge: str, + query: str, + questions_per_iteration: int = 8, + questions_by_iteration: Dict[int, List[str]] = None, + ) -> List[str]: + """Generate news-specific search queries.""" + date_str = datetime.now(UTC).strftime("%B %d, %Y") + + logger.info("Generating news search queries...") + + # Build diverse news queries + base_queries = [ + f"breaking news today {date_str}", + f"major incidents casualties today {date_str}", + f"unexpected news surprising today {date_str}", + "economic news market movement today", + f"political announcements today {date_str}", + "technology breakthrough announcement today", + "natural disaster emergency today", + "international news global impact today", + ] + + # If user provided specific focus, add those queries + if query and query != "latest important news today": + focus_queries = [ + f"{query} {date_str}", + f"{query} breaking news today", + f"{query} latest developments", + ] + return focus_queries + base_queries[:5] + + return base_queries[:questions_per_iteration]
src/local_deep_research/advanced_search_system/questions/standard_question.py+6 −7 modified@@ -2,13 +2,12 @@ Standard question generation implementation. """ -import logging -from datetime import datetime +from datetime import datetime, UTC from typing import List -from .base_question import BaseQuestionGenerator +from loguru import logger -logger = logging.getLogger(__name__) +from .base_question import BaseQuestionGenerator class StandardQuestionGenerator(BaseQuestionGenerator): @@ -22,7 +21,7 @@ def generate_questions( questions_by_iteration: dict = None, ) -> List[str]: """Generate follow-up questions based on current knowledge.""" - now = datetime.now() + now = datetime.now(UTC) current_time = now.strftime("%Y-%m-%d") questions_by_iteration = questions_by_iteration or {} @@ -32,7 +31,7 @@ def generate_questions( prompt = f"""Critically reflect current knowledge (e.g., timeliness), what {questions_per_iteration} high-quality internet search questions remain unanswered to exactly answer the query? Query: {query} Today: {current_time} - Past questions: {str(questions_by_iteration)} + Past questions: {questions_by_iteration!s} Knowledge: {current_knowledge} Include questions that critically reflect current knowledge. \n\n\nFormat: One question per line, e.g. \n Q: question1 \n Q: question2\n\n""" @@ -121,5 +120,5 @@ def generate_sub_questions( # Limit to at most 5 sub-questions return sub_questions[:5] except Exception as e: - logger.error(f"Error generating sub-questions: {str(e)}") + logger.exception(f"Error generating sub-questions: {e!s}") return []
src/local_deep_research/advanced_search_system/source_management/diversity_manager.py+5 −5 modified@@ -5,8 +5,8 @@ import re from collections import defaultdict from dataclasses import dataclass, field -from datetime import datetime -from typing import Dict, List, Optional, Set, Tuple, Any +from datetime import datetime, UTC +from typing import Any, Dict, List, Optional, Set, Tuple from langchain_core.language_models import BaseChatModel @@ -75,7 +75,7 @@ def analyze_source( if url in self.source_profiles: profile = self.source_profiles[url] profile.evidence_count += 1 - profile.last_accessed = datetime.utcnow() + profile.last_accessed = datetime.now(UTC) return profile # Extract domain @@ -105,7 +105,7 @@ def analyze_source( temporal_coverage=temporal_coverage, geographic_focus=geographic_focus, evidence_count=1, - last_accessed=datetime.utcnow(), + last_accessed=datetime.now(UTC), ) self.source_profiles[url] = profile @@ -606,7 +606,7 @@ def track_source_effectiveness( profile.effectiveness.append( { - "timestamp": datetime.utcnow(), + "timestamp": datetime.now(UTC), "evidence_quality": evidence_quality, "constraint_satisfied": constraint_satisfied, }
src/local_deep_research/advanced_search_system/strategies/adaptive_decomposition_strategy.py+2 −2 modified@@ -559,6 +559,6 @@ def _calculate_confidence(self) -> float: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - from datetime import datetime + from datetime import datetime, timezone - return datetime.utcnow().isoformat() + return datetime.now(timezone.utc).isoformat()
src/local_deep_research/advanced_search_system/strategies/base_strategy.py+24 −3 modified@@ -15,9 +15,18 @@ class BaseSearchStrategy(ABC): def __init__( self, all_links_of_system=None, + settings_snapshot=None, questions_by_iteration=None, + search_original_query: bool = True, ): - """Initialize the base strategy with common attributes.""" + """Initialize the base strategy with common attributes. + + Args: + all_links_of_system: List to store all discovered links + settings_snapshot: Settings snapshot for configuration + questions_by_iteration: Dictionary of questions by iteration + search_original_query: Whether to include the original query in the first iteration + """ self.progress_callback = None # Create a new dict if None is provided (avoiding mutable default argument) self.questions_by_iteration = ( @@ -27,6 +36,18 @@ def __init__( self.all_links_of_system = ( all_links_of_system if all_links_of_system is not None else [] ) + self.settings_snapshot = settings_snapshot or {} + self.search_original_query = search_original_query + + def get_setting(self, key: str, default=None): + """Get a setting value from the snapshot.""" + if key in self.settings_snapshot: + value = self.settings_snapshot[key] + # Extract value from dict structure if needed + if isinstance(value, dict) and "value" in value: + return value["value"] + return value + return default def set_progress_callback( self, callback: Callable[[str, int, dict], None] @@ -98,7 +119,7 @@ def _handle_search_error( Returns: List: Empty list to continue processing """ - error_msg = f"Error during search: {str(error)}" + error_msg = f"Error during search: {error!s}" logger.error(f"SEARCH ERROR: {error_msg}") self._update_progress( error_msg, @@ -118,7 +139,7 @@ def _handle_analysis_error( question: The question being analyzed progress_base: The current progress percentage """ - error_msg = f"Error analyzing results: {str(error)}" + error_msg = f"Error analyzing results: {error!s}" logger.info(f"ANALYSIS ERROR: {error_msg}") self._update_progress( error_msg,
src/local_deep_research/advanced_search_system/strategies/browsecomp_entity_strategy.py+18 −12 modified@@ -106,9 +106,17 @@ class BrowseCompEntityStrategy(BaseSearchStrategy): """ def __init__( - self, model=None, search=None, all_links_of_system=None, **kwargs + self, + model, + search, + all_links_of_system=None, + settings_snapshot=None, + **kwargs, ): - super().__init__(all_links_of_system=all_links_of_system) + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) # Store model and search engine self.model = model @@ -304,10 +312,8 @@ async def search( return answer, metadata except Exception as e: - logger.error( - f"Error in BrowseComp entity search: {e}", exc_info=True - ) - return f"Search failed: {str(e)}", {"error": str(e)} + logger.exception("Error in BrowseComp entity search") + return f"Search failed: {e!s}", {"error": str(e)} def _identify_entity_type(self, query: str) -> str: """Identify what type of entity we're looking for.""" @@ -827,8 +833,8 @@ async def _cached_search(self, query: str) -> List[Dict]: logger.debug(f"Cached new search results for: {query[:50]}...") return normalized_results - except Exception as e: - logger.error(f"Search failed for query '{query}': {e}") + except Exception: + logger.exception(f"Search failed for query '{query}'") return [] async def _generate_entity_answer( @@ -922,9 +928,9 @@ def analyze_topic(self, query: str) -> Dict: return asyncio.run(self._analyze_topic_async(query)) except Exception as e: - logger.error(f"Error in analyze_topic: {e}") + logger.exception("Error in analyze_topic") return { - "findings": [f"Error analyzing query: {str(e)}"], + "findings": [f"Error analyzing query: {e!s}"], "iterations": 0, "questions": {}, "entities_found": 0, @@ -1021,9 +1027,9 @@ async def _analyze_topic_async(self, query: str) -> Dict: } except Exception as e: - logger.error(f"Error in async topic analysis: {e}") + logger.exception("Error in async topic analysis") return { - "findings": [f"Analysis failed: {str(e)}"], + "findings": [f"Analysis failed: {e!s}"], "iterations": 0, "questions": {}, "entities_found": 0,
src/local_deep_research/advanced_search_system/strategies/browsecomp_optimized_strategy.py+4 −3 modified@@ -6,7 +6,7 @@ """ from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List, Optional from langchain_core.language_models import BaseChatModel @@ -53,9 +53,10 @@ def __init__( confidence_threshold: float = 0.90, max_iterations: int = 1, # This is for source-based strategy iterations questions_per_iteration: int = 3, # This is for source-based strategy questions + settings_snapshot=None, ): """Initialize the BrowseComp-optimized strategy.""" - super().__init__(all_links_of_system) + super().__init__(all_links_of_system, settings_snapshot) self.model = model self.search = search self.max_browsecomp_iterations = max_browsecomp_iterations @@ -775,4 +776,4 @@ def _synthesize_final_answer(self, original_query: str) -> Dict: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat()
src/local_deep_research/advanced_search_system/strategies/concurrent_dual_confidence_strategy.py+6 −8 modified@@ -240,8 +240,8 @@ def _progressive_search_with_concurrent_eval(self): self.state.evaluation_futures.append(future) self.state.total_evaluated += 1 - except Exception as e: - logger.error(f"Search error in iteration {iteration}: {e}") + except Exception: + logger.exception(f"Search error in iteration {iteration}") # Check completed evaluations self._check_evaluation_results() @@ -318,10 +318,8 @@ def _evaluate_candidate_thread( return (candidate, score) - except Exception as e: - logger.error( - f"Error evaluating {candidate.name}: {e}", exc_info=True - ) + except Exception: + logger.exception("Error evaluating candidate") return (candidate, 0.0) def _check_evaluation_results(self): @@ -334,8 +332,8 @@ def _check_evaluation_results(self): try: future.result() # Result is already processed in the thread - except Exception as e: - logger.error(f"Failed to get future result: {e}") + except Exception: + logger.exception("Failed to get future result") # Remove completed futures for future in completed:
src/local_deep_research/advanced_search_system/strategies/constrained_search_strategy.py+10 −12 modified@@ -8,7 +8,7 @@ 4. Narrowing down the candidate pool step by step """ -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List from langchain_core.language_models import BaseChatModel @@ -45,6 +45,7 @@ def __init__( max_search_iterations: int = 2, questions_per_iteration: int = 3, min_candidates_per_stage: int = 20, # Need more candidates before filtering + settings_snapshot=None, ): """Initialize the constrained search strategy.""" super().__init__( @@ -57,6 +58,7 @@ def __init__( evidence_threshold=evidence_threshold, max_search_iterations=max_search_iterations, questions_per_iteration=questions_per_iteration, + settings_snapshot=settings_snapshot, ) self.min_candidates_per_stage = min_candidates_per_stage @@ -440,9 +442,8 @@ def _generate_constraint_specific_queries( ] ) - elif ( - constraint.type == ConstraintType.EVENT - or hasattr(constraint.type, "value") + elif constraint.type == ConstraintType.EVENT or ( + hasattr(constraint.type, "value") and constraint.type.value == "temporal" ): # Time-based constraints @@ -641,11 +642,8 @@ def _extract_relevant_candidates( return candidates[:50] # Limit per search - except Exception as e: - logger.error(f"Error extracting candidates: {e}") - import traceback - - logger.error(traceback.format_exc()) + except Exception: + logger.exception("Error extracting candidates") return [] def _quick_evidence_check( @@ -1205,9 +1203,9 @@ def _simple_search(self, search_query: str) -> Dict: "search_results": [], } except Exception as e: - logger.error(f"Simple search error: {e}") + logger.exception("Simple search error") return { - "current_knowledge": f"Search error: {str(e)}", + "current_knowledge": f"Search error: {e!s}", "search_results": [], } @@ -1295,7 +1293,7 @@ def _validate_search_results( def _get_timestamp(self) -> str: """Get current timestamp.""" - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat() def _group_similar_candidates( self, candidates: List[Candidate]
src/local_deep_research/advanced_search_system/strategies/constraint_parallel_strategy.py+8 −10 modified@@ -177,8 +177,8 @@ def _detect_entity_type(self) -> str: entity_type = self.model.invoke(prompt).content.strip() logger.info(f"LLM determined entity type: {entity_type}") return entity_type - except Exception as e: - logger.error(f"Failed to detect entity type: {e}") + except Exception: + logger.exception("Failed to detect entity type") return "unknown entity" def _run_parallel_constraint_searches(self): @@ -226,7 +226,7 @@ def _run_parallel_constraint_searches(self): self._submit_candidates_for_evaluation(candidates) except Exception as e: - logger.error( + logger.exception( f"Search failed for constraint {constraint.value}: {e}" ) @@ -266,8 +266,8 @@ def _run_constraint_search( ) return candidates - except Exception as e: - logger.error(f"Error in constraint search: {e}", exc_info=True) + except Exception: + logger.exception("Error in constraint search") return [] def _build_constraint_query(self, constraint: Constraint) -> str: @@ -362,10 +362,8 @@ def _evaluate_candidate_thread( return (candidate, score) - except Exception as e: - logger.error( - f"Error evaluating {candidate.name}: {e}", exc_info=True - ) + except Exception: + logger.exception(f"Error evaluating {candidate.name}") return (candidate, 0.0) def _verify_entity_type_match(self, candidate: Candidate) -> float: @@ -417,7 +415,7 @@ def _verify_entity_type_match(self, candidate: Candidate) -> float: return 0.5 # Default to middle value on parsing error except Exception as e: - logger.error( + logger.exception( f"Error verifying entity type for {candidate_name}: {e}" ) return 0.5 # Default to middle value on error
src/local_deep_research/advanced_search_system/strategies/direct_search_strategy.py+12 −16 modified@@ -7,18 +7,17 @@ 3. Minimal LLM calls for efficiency """ -import logging from typing import Dict +from loguru import logger + from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search + +# Model and search should be provided by AdvancedSearchSystem from ..filters.cross_engine_filter import CrossEngineFilter from ..findings.repository import FindingsRepository from .base_strategy import BaseSearchStrategy -logger = logging.getLogger(__name__) - class DirectSearchStrategy(BaseSearchStrategy): """ @@ -33,8 +32,8 @@ class DirectSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, include_text_content: bool = True, use_cross_engine_filter: bool = True, @@ -45,8 +44,8 @@ def __init__( ): """Initialize with minimal components for efficiency.""" super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + self.search = search + self.model = model self.progress_callback = None self.include_text_content = include_text_content @@ -189,13 +188,10 @@ def analyze_topic(self, query: str) -> Dict: ) except Exception as e: - import traceback - - error_msg = f"Error in direct search: {str(e)}" - logger.error(error_msg) - logger.error(traceback.format_exc()) - synthesized_content = f"Error: {str(e)}" - formatted_findings = f"Error: {str(e)}" + error_msg = f"Error in direct search: {e!s}" + logger.exception(error_msg) + synthesized_content = f"Error: {e!s}" + formatted_findings = f"Error: {e!s}" finding = { "phase": "Error", "content": synthesized_content,
src/local_deep_research/advanced_search_system/strategies/dual_confidence_strategy.py+9 −6 modified@@ -141,8 +141,8 @@ def _analyze_evidence_dual_confidence( source=evidence.get("source", "search"), ) - except Exception as e: - logger.error(f"Error analyzing evidence: {e}") + except Exception: + logger.exception("Error analyzing evidence") # Default to high uncertainty return ConstraintEvidence( positive_confidence=0.1, @@ -198,7 +198,9 @@ def _gather_evidence_for_constraint( } ) except Exception as e: - logger.error(f"Error gathering evidence for {query}: {e}") + logger.exception( + f"Error gathering evidence for {query}: {e}" + ) return evidence @@ -271,7 +273,8 @@ def _evaluate_candidate_immediately(self, candidate: Candidate) -> float: for c in self.constraint_ranking[: len(constraint_scores)] ] total_score = sum( - s * w for s, w in zip(constraint_scores, weights) + s * w + for s, w in zip(constraint_scores, weights, strict=False) ) / sum(weights) # Log detailed breakdown @@ -315,6 +318,6 @@ def _evaluate_candidate_immediately(self, candidate: Candidate) -> float: return total_score - except Exception as e: - logger.error(f"Error evaluating {candidate.name}: {e}") + except Exception: + logger.exception(f"Error evaluating candidate: {candidate.name}") return 0.0
src/local_deep_research/advanced_search_system/strategies/dual_confidence_with_rejection.py+6 −5 modified@@ -136,7 +136,8 @@ def _evaluate_candidate_immediately(self, candidate) -> float: for c in self.constraint_ranking[: len(constraint_scores)] ] total_score = sum( - s * w for s, w in zip(constraint_scores, weights) + s * w + for s, w in zip(constraint_scores, weights, strict=False) ) / sum(weights) # Log detailed breakdown @@ -174,8 +175,8 @@ def _evaluate_candidate_immediately(self, candidate) -> float: return total_score - except Exception as e: - logger.error(f"Error evaluating {candidate.name}: {e}") + except Exception: + logger.exception(f"Error evaluating {candidate.name}") return 0.0 def _evaluate_candidate_with_constraint_checker(self, candidate) -> float: @@ -214,6 +215,6 @@ def _evaluate_candidate_with_constraint_checker(self, candidate) -> float: return result.total_score - except Exception as e: - logger.error(f"Error evaluating {candidate.name}: {e}") + except Exception: + logger.exception(f"Error evaluating {candidate.name}") return 0.0
src/local_deep_research/advanced_search_system/strategies/early_stop_constrained_strategy.py+12 −8 modified@@ -94,15 +94,15 @@ def _parallel_search(self, combinations: List) -> List[Candidate]: }, ) - except Exception as e: - logger.error(f"Search failed for {combo.query}: {e}") + except Exception: + logger.exception(f"Search failed for {combo.query}") # Wait for evaluation futures to complete for future in concurrent.futures.as_completed(evaluation_futures): try: future.result() - except Exception as e: - logger.error(f"Evaluation failed: {e}") + except Exception: + logger.exception("Evaluation failed") return all_candidates @@ -181,7 +181,9 @@ def _evaluate_candidate_immediately(self, candidate: Candidate) -> float: return total_score except Exception as e: - logger.error(f"Error evaluating candidate {candidate.name}: {e}") + logger.exception( + f"Error evaluating candidate {candidate.name}: {e}" + ) return 0.0 def _progressive_constraint_search(self): @@ -280,7 +282,9 @@ def _gather_evidence_for_constraint( ) return evidence except Exception as e: - logger.error(f"Error gathering evidence for {candidate.name}: {e}") + logger.exception( + f"Error gathering evidence for {candidate.name}: {e}" + ) return [] def _extract_evidence_from_results( @@ -316,8 +320,8 @@ def _extract_evidence_from_results( ), } ) - except Exception as e: - logger.error(f"Error extracting evidence: {e}") + except Exception: + logger.exception("Error extracting evidence") return evidence
src/local_deep_research/advanced_search_system/strategies/entity_aware_source_strategy.py+4 −5 modified@@ -2,14 +2,13 @@ Entity-aware source-based search strategy for improved entity identification. """ -import logging from typing import Dict +from loguru import logger + from ..questions.entity_aware_question import EntityAwareQuestionGenerator from .source_based_strategy import SourceBasedSearchStrategy -logger = logging.getLogger(__name__) - class EntityAwareSourceStrategy(SourceBasedSearchStrategy): """ @@ -23,8 +22,8 @@ class EntityAwareSourceStrategy(SourceBasedSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, include_text_content: bool = True, use_cross_engine_filter: bool = True,
src/local_deep_research/advanced_search_system/strategies/evidence_based_strategy.py+4 −3 modified@@ -5,7 +5,7 @@ and systematically gathers evidence to score each candidate. """ -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List from langchain_core.language_models import BaseChatModel @@ -44,9 +44,10 @@ def __init__( evidence_threshold: float = 0.6, max_search_iterations: int = 2, # For source-based sub-searches questions_per_iteration: int = 3, + settings_snapshot=None, ): """Initialize the evidence-based strategy.""" - super().__init__(all_links_of_system) + super().__init__(all_links_of_system, settings_snapshot) self.model = model self.search = search self.max_iterations = max_iterations @@ -1219,7 +1220,7 @@ def _format_final_synthesis(self, answer: str, confidence: int) -> str: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat() def _calculate_evidence_coverage(self) -> float: """Calculate how much evidence we've collected across all candidates."""
src/local_deep_research/advanced_search_system/strategies/evidence_based_strategy_v2.py+2 −2 modified@@ -10,7 +10,7 @@ import math from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List, Optional, Set, Tuple from langchain_core.language_models import BaseChatModel @@ -828,7 +828,7 @@ def _update_source_profile(self, source_name: str, confidence: float): if source_name in self.source_profiles: profile = self.source_profiles[source_name] profile.usage_count += 1 - profile.last_used = datetime.utcnow() + profile.last_used = datetime.now(UTC) # Update success rate based on confidence alpha = 0.3
src/local_deep_research/advanced_search_system/strategies/focused_iteration_strategy.py+37 −33 modified@@ -20,19 +20,22 @@ """ import concurrent.futures -import logging from typing import Dict, List +from loguru import logger + from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search + +# Model and search should be provided by AdvancedSearchSystem +from ...utilities.thread_context import ( + preserve_research_context, + get_search_context, +) from ..candidate_exploration import ProgressiveExplorer from ..findings.repository import FindingsRepository from ..questions import BrowseCompQuestionGenerator from .base_strategy import BaseSearchStrategy -logger = logging.getLogger(__name__) - class FocusedIterationStrategy(BaseSearchStrategy): """ @@ -49,23 +52,30 @@ class FocusedIterationStrategy(BaseSearchStrategy): def __init__( self, - model=None, - search=None, + model, + search, citation_handler=None, all_links_of_system=None, max_iterations: int = 8, # OPTIMAL FOR SIMPLEQA: 90%+ accuracy achieved questions_per_iteration: int = 5, # OPTIMAL FOR SIMPLEQA: proven config use_browsecomp_optimization: bool = True, # True for 90%+ accuracy with forced_answer handler + settings_snapshot=None, ): """Initialize with components optimized for focused iteration.""" - super().__init__(all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__(all_links_of_system, settings_snapshot) + self.search = search + self.model = model self.progress_callback = None - # Configuration - ensure these are integers - self.max_iterations = int(max_iterations) - self.questions_per_iteration = int(questions_per_iteration) + # Configuration - ensure these are integers with defaults + self.max_iterations = ( + int(max_iterations) if max_iterations is not None else 3 + ) + self.questions_per_iteration = ( + int(questions_per_iteration) + if questions_per_iteration is not None + else 3 + ) self.use_browsecomp_optimization = use_browsecomp_optimization # Initialize specialized components @@ -349,33 +359,29 @@ def analyze_topic(self, query: str) -> Dict: return result except Exception as e: - logger.error(f"Error in focused iteration search: {str(e)}") + logger.exception(f"Error in focused iteration search: {e!s}") import traceback - logger.error(traceback.format_exc()) + logger.exception(traceback.format_exc()) return self._create_error_response(str(e)) def _execute_parallel_searches(self, queries: List[str]) -> List[Dict]: """Execute searches in parallel (like source-based strategy).""" all_results = [] - # Import context preservation utility - from ...utilities.thread_context import ( - create_context_preserving_wrapper, - ) - def search_question(q): try: - result = self.search.run(q) + # Get the current research context to pass explicitly + + current_context = get_search_context() + result = self.search.run(q, research_context=current_context) return {"question": q, "results": result or []} except Exception as e: - logger.error(f"Error searching '{q}': {str(e)}") + logger.exception(f"Error searching '{q}': {e!s}") return {"question": q, "results": [], "error": str(e)} # Create context-preserving wrapper for the search function - context_aware_search = create_context_preserving_wrapper( - search_question - ) + context_aware_search = preserve_research_context(search_question) # Run searches in parallel with concurrent.futures.ThreadPoolExecutor( @@ -399,11 +405,6 @@ def _execute_parallel_searches_with_progress( completed_searches = 0 total_searches = len(queries) - # Import context preservation utility - from ...utilities.thread_context import ( - create_context_preserving_wrapper, - ) - def search_question_with_progress(q): nonlocal completed_searches try: @@ -418,7 +419,10 @@ def search_question_with_progress(q): }, ) - result = self.search.run(q) + # Get the current research context to pass explicitly + + current_context = get_search_context() + result = self.search.run(q, research_context=current_context) completed_searches += 1 # Report completion of this search @@ -441,7 +445,7 @@ def search_question_with_progress(q): } except Exception as e: completed_searches += 1 - logger.error(f"Error searching '{q}': {str(e)}") + logger.exception(f"Error searching '{q}': {e!s}") self._update_progress( f"Search failed for '{q[:30]}{'...' if len(q) > 30 else ''}': {str(e)[:50]}", None, @@ -460,7 +464,7 @@ def search_question_with_progress(q): } # Create context-preserving wrapper for the search function - context_aware_search_with_progress = create_context_preserving_wrapper( + context_aware_search_with_progress = preserve_research_context( search_question_with_progress )
src/local_deep_research/advanced_search_system/strategies/followup/enhanced_contextual_followup.py+387 −0 added@@ -0,0 +1,387 @@ +""" +Enhanced Contextual Follow-Up Strategy + +An improved version of the contextual follow-up strategy that better leverages +past research context, reformulates questions, and reuses sources effectively. +""" + +from typing import Dict, List, Optional, Any +from loguru import logger + +from ..base_strategy import BaseSearchStrategy +from ...filters.followup_relevance_filter import FollowUpRelevanceFilter +from ...knowledge.followup_context_manager import FollowUpContextHandler +from ...questions.followup.simple_followup_question import ( + SimpleFollowUpQuestionGenerator, +) + + +class EnhancedContextualFollowUpStrategy(BaseSearchStrategy): + """ + Enhanced strategy for follow-up research that intelligently uses past context. + + This strategy: + 1. Reformulates follow-up questions based on past findings + 2. Filters and reuses relevant sources from previous research + 3. Passes complete context to the delegate strategy + 4. Optimizes search to avoid redundancy + """ + + def __init__( + self, + model, + search, + delegate_strategy: BaseSearchStrategy, + all_links_of_system=None, + settings_snapshot=None, + research_context: Optional[Dict] = None, + **kwargs, + ): + """ + Initialize the enhanced contextual follow-up strategy. + + Args: + model: The LLM model to use + search: The search engine + delegate_strategy: The strategy to delegate actual search to + all_links_of_system: Accumulated links from past searches + settings_snapshot: Settings configuration + research_context: Context from previous research + """ + super().__init__(all_links_of_system, settings_snapshot) + + self.model = model + self.search = search + self.delegate_strategy = delegate_strategy + self.research_context = research_context or {} + + # Initialize components + self.relevance_filter = FollowUpRelevanceFilter(model) + self.context_manager = FollowUpContextHandler(model) + + # Initialize question generator for creating contextualized queries + self.question_generator = SimpleFollowUpQuestionGenerator(model) + + # For follow-up research, we ALWAYS want to combine sources + # This is the whole point of follow-up - building on previous research + self.combine_sources = True + + # Build comprehensive context + self.full_context = self._build_full_context() + + logger.info( + f"EnhancedContextualFollowUpStrategy initialized with " + f"{len(self.full_context.get('past_sources', []))} past sources" + ) + + def _build_full_context(self) -> Dict[str, Any]: + """ + Build comprehensive context from research data. + + Returns: + Full context dictionary + """ + # Use context manager to build structured context + if self.research_context: + # The follow-up query will be passed to analyze_topic later + # For now, use empty string since we're just building initial context + follow_up_query = "" + context = self.context_manager.build_context( + self.research_context, follow_up_query + ) + + # Also ensure we get the sources from the research context + if "past_sources" not in context or not context["past_sources"]: + # Try to get from various fields in research_context + sources = [] + for field in ["resources", "all_links_of_system", "past_links"]: + if ( + field in self.research_context + and self.research_context[field] + ): + sources.extend(self.research_context[field]) + context["past_sources"] = sources + + # Ensure we have the findings + if "past_findings" not in context or not context["past_findings"]: + context["past_findings"] = self.research_context.get( + "past_findings", "" + ) + + # Ensure we have the original query + context["original_query"] = self.research_context.get( + "original_query", "" + ) + else: + context = { + "past_sources": [], + "past_findings": "", + "summary": "", + "key_entities": [], + "all_links_of_system": [], + "original_query": "", + } + + return context + + def analyze_topic(self, query: str) -> Dict: + """ + Analyze a follow-up topic with enhanced context processing. + + This strategy: + 1. Reformulates the question based on past findings + 2. Filters relevant past sources to reuse + 3. Hands over to the delegate strategy with enhanced context + + Args: + query: The follow-up question to research + + Returns: + Research findings with context enhancement + """ + logger.info(f"Starting enhanced follow-up search for: {query}") + + # Update the context with the actual follow-up query + self.full_context["follow_up_query"] = query + + # Log what context we have + logger.info( + f"Context summary: {len(self.full_context.get('past_sources', []))} past sources, " + f"findings length: {len(self.full_context.get('past_findings', ''))}, " + f"original query: {self.full_context.get('original_query', 'N/A')}" + ) + + self._update_progress( + "Analyzing past research context", 10, {"phase": "context_analysis"} + ) + + # Step 1: Skip reformulation - we'll use the original query with full context + # This avoids LLM misinterpretation of queries like "provide data in a table" + # The context will make it clear what the query refers to + + self._update_progress( + "Preparing contextualized query", + 20, + {"phase": "context_preparation", "original_query": query}, + ) + + # Step 2: Filter relevant sources from past research using original query + relevant_sources = self._filter_relevant_sources(query) + + self._update_progress( + f"Identified {len(relevant_sources)} relevant past sources", + 30, + { + "phase": "source_filtering", + "relevant_sources": len(relevant_sources), + "total_past_sources": len( + self.full_context.get("past_sources", []) + ), + }, + ) + + # Step 3: Inject the relevant sources into delegate strategy + # This gives the delegate strategy a head start with pre-filtered sources + self._inject_context_into_delegate(relevant_sources, query) + + self._update_progress( + "Handing over to research strategy", + 40, + {"phase": "delegate_handover"}, + ) + + # Step 4: Create a query that includes FULL context from previous research + # Use question generator to create contextualized query + past_findings = self.full_context.get("past_findings", "") + original_research_query = self.full_context.get("original_query", "") + + contextualized_query = ( + self.question_generator.generate_contextualized_query( + follow_up_query=query, + original_query=original_research_query, + past_findings=past_findings, + ) + ) + + # Let the delegate strategy (from user's settings) do the actual research + # with the contextualized query and pre-injected sources + result = self.delegate_strategy.analyze_topic(contextualized_query) + + # Step 5: Get past sources for metadata (always needed) + all_past_sources = self.full_context.get("past_sources", []) + + # Step 6: Optionally combine old sources with new ones + # Only do this if the setting is enabled to avoid breaking existing reports + if self.combine_sources: + logger.info( + f"Combining sources: self.combine_sources={self.combine_sources}" + ) + + # Ensure we have all sources from both researches + if "all_links_of_system" not in result: + result["all_links_of_system"] = [] + + # Log initial state + new_sources_count = len(result.get("all_links_of_system", [])) + logger.info( + f"Initial state: {new_sources_count} new sources, {len(all_past_sources)} past sources to combine" + ) + + # Create a set of URLs already in the result to avoid duplicates + existing_urls = { + link.get("url") + for link in result.get("all_links_of_system", []) + } + + # Add all past sources that aren't already in the result + added_count = 0 + for source in all_past_sources: + url = source.get("url") + if url and url not in existing_urls: + # Mark it as from previous research + enhanced_source = source.copy() + enhanced_source["from_past_research"] = True + result["all_links_of_system"].append(enhanced_source) + existing_urls.add(url) + added_count += 1 + + logger.info( + f"Source combination complete: Added {added_count} past sources, total now {len(result['all_links_of_system'])} sources" + ) + else: + logger.info( + f"Source combination skipped: self.combine_sources={self.combine_sources}" + ) + + # Step 7: Add metadata about the follow-up enhancement + if "metadata" not in result: + result["metadata"] = {} + + result["metadata"]["follow_up_enhancement"] = { + "original_query": query, + "contextualized": True, + "sources_reused": len(relevant_sources), + "total_past_sources": len(all_past_sources), + "parent_research_id": self.full_context.get( + "parent_research_id", "" + ), + } + + self._update_progress( + "Enhanced follow-up search complete", + 100, + { + "phase": "complete", + "sources_reused": len(relevant_sources), + "total_sources": len(result.get("all_links_of_system", [])), + }, + ) + + logger.info( + f"Enhanced results: {len(relevant_sources)} past sources reused, " + f"{len(result.get('all_links_of_system', []))} total sources found" + ) + + return result + + def _filter_relevant_sources(self, query: str) -> List[Dict]: + """ + Filter past sources for relevance to the follow-up query. + + Args: + query: The reformulated follow-up query + + Returns: + List of relevant sources + """ + past_sources = self.full_context.get("past_sources", []) + + if not past_sources: + return [] + + # Filter sources using the relevance filter + # Get max sources from settings or use default + max_followup_sources = self.settings_snapshot.get( + "search.max_followup_sources", {} + ).get("value", 15) + + relevant = self.relevance_filter.filter_results( + results=past_sources, + query=query, + max_results=max_followup_sources, + threshold=0.3, + past_findings=self.full_context.get("past_findings", ""), + original_query=self.full_context.get("original_query", ""), + ) + + logger.info( + f"Filtered {len(past_sources)} past sources to " + f"{len(relevant)} relevant ones" + ) + + return relevant + + def _inject_context_into_delegate( + self, relevant_sources: List[Dict], reformulated_query: str + ): + """ + Inject context and sources into the delegate strategy. + + Args: + relevant_sources: Filtered relevant sources + reformulated_query: The reformulated query + """ + # Initialize delegate's all_links_of_system if needed + if not self.delegate_strategy.all_links_of_system: + self.delegate_strategy.all_links_of_system = [] + + # Add relevant sources to the beginning (high priority) + existing_urls = { + link.get("url") + for link in self.delegate_strategy.all_links_of_system + } + + injected_count = 0 + for source in relevant_sources: + url = source.get("url") + if url and url not in existing_urls: + # Add source with enhanced metadata + enhanced_source = source.copy() + enhanced_source["from_past_research"] = True + enhanced_source["follow_up_relevance"] = source.get( + "relevance_score", 1.0 + ) + + self.delegate_strategy.all_links_of_system.insert( + 0, enhanced_source + ) + existing_urls.add(url) + injected_count += 1 + + logger.info( + f"Injected {injected_count} relevant past sources into delegate strategy" + ) + + # Pass context to delegate if it supports it + if hasattr(self.delegate_strategy, "set_followup_context"): + self.delegate_strategy.set_followup_context( + { + "reformulated_query": reformulated_query, + "past_findings_summary": self.full_context.get( + "summary", "" + ), + "key_entities": self.full_context.get("key_entities", []), + "sources_injected": injected_count, + } + ) + + def set_progress_callback(self, callback): + """ + Set progress callback for both wrapper and delegate. + + Args: + callback: Progress callback function + """ + super().set_progress_callback(callback) + if self.delegate_strategy: + self.delegate_strategy.set_progress_callback(callback)
src/local_deep_research/advanced_search_system/strategies/followup/__init__.py+10 −0 added@@ -0,0 +1,10 @@ +""" +Follow-up Research Strategies + +This package contains specialized strategies for handling follow-up research +that builds upon previous research results. +""" + +from .enhanced_contextual_followup import EnhancedContextualFollowUpStrategy + +__all__ = ["EnhancedContextualFollowUpStrategy"]
src/local_deep_research/advanced_search_system/strategies/improved_evidence_based_strategy.py+2 −2 modified@@ -11,7 +11,7 @@ import itertools from collections import defaultdict from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List, Set from langchain_core.language_models import BaseChatModel @@ -476,7 +476,7 @@ def _execute_tracked_search( constraint_ids=[c.id for c in constraints], results_count=len(results.get("all_links_of_system", [])), candidates_found=candidates_found, - timestamp=datetime.utcnow().isoformat(), + timestamp=datetime.now(UTC).isoformat(), strategy_type=strategy_type, ) self.search_attempts.append(attempt)
src/local_deep_research/advanced_search_system/strategies/__init__.py+14 −14 modified@@ -21,23 +21,23 @@ from .standard_strategy import StandardSearchStrategy __all__ = [ - "BaseSearchStrategy", - "StandardSearchStrategy", - "ParallelSearchStrategy", - "ParallelConstrainedStrategy", - "SourceBasedSearchStrategy", - "RapidSearchStrategy", - "IterDRAGStrategy", - "RecursiveDecompositionStrategy", "AdaptiveDecompositionStrategy", - "SmartDecompositionStrategy", - "IterativeReasoningStrategy", - "BrowseCompOptimizedStrategy", + "BaseSearchStrategy", "BrowseCompEntityStrategy", - "EvidenceBasedStrategy", + "BrowseCompOptimizedStrategy", + "ConstraintParallelStrategy", "DualConfidenceStrategy", "DualConfidenceWithRejectionStrategy", - "ConstraintParallelStrategy", - "ModularStrategy", + "EvidenceBasedStrategy", "FocusedIterationStrategy", + "IterDRAGStrategy", + "IterativeReasoningStrategy", + "ModularStrategy", + "ParallelConstrainedStrategy", + "ParallelSearchStrategy", + "RapidSearchStrategy", + "RecursiveDecompositionStrategy", + "SmartDecompositionStrategy", + "SourceBasedSearchStrategy", + "StandardSearchStrategy", ]
src/local_deep_research/advanced_search_system/strategies/iterative_reasoning_strategy.py+2 −2 modified@@ -10,7 +10,7 @@ """ from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, List, Optional from langchain_core.language_models import BaseChatModel @@ -757,4 +757,4 @@ def _synthesize_final_answer(self) -> Dict: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat()
src/local_deep_research/advanced_search_system/strategies/iterdrag_strategy.py+30 −16 modified@@ -3,15 +3,15 @@ """ import json -from datetime import datetime +from datetime import datetime, UTC from typing import Dict, List from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search -from ...utilities.db_utils import get_db_setting + +# Model and search should be provided by AdvancedSearchSystem +from ...config.thread_settings import get_setting_from_snapshot from ...utilities.search_utilities import extract_links_from_search_results from ..findings.repository import FindingsRepository from ..knowledge.standard_knowledge import StandardKnowledge @@ -24,11 +24,12 @@ class IterDRAGStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, max_iterations=3, subqueries_per_iteration=2, all_links_of_system=None, + settings_snapshot=None, ): """Initialize the IterDRAG strategy with search and LLM. @@ -38,10 +39,14 @@ def __init__( max_iterations: Maximum number of iterations to run subqueries_per_iteration: Number of sub-queries to generate per iteration all_links_of_system: Optional list of links to initialize with + settings_snapshot: Settings snapshot for thread context """ - super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) + self.search = search + self.model = model self.max_iterations = max_iterations self.subqueries_per_iteration = subqueries_per_iteration @@ -71,7 +76,7 @@ def _generate_subqueries( try: # Format context for question generation context = f"""Current Query: {query} -Current Date: {datetime.now().strftime("%Y-%m-%d")} +Current Date: {datetime.now(UTC).strftime("%Y-%m-%d")} Past Questions: {self.questions_by_iteration} Current Knowledge: {current_knowledge} @@ -82,7 +87,12 @@ def _generate_subqueries( return self.question_generator.generate_questions( query, context, - int(get_db_setting("search.questions_per_iteration")), + int( + get_setting_from_snapshot( + "search.questions_per_iteration", + settings_snapshot=self.settings_snapshot, + ) + ), ) except Exception: logger.exception("Error generating sub-queries") @@ -369,7 +379,7 @@ def analyze_topic(self, query: str) -> Dict: # Create an error finding error_finding = { "phase": "Final synthesis error", - "content": f"Error during synthesis: {str(e)}", + "content": f"Error during synthesis: {e!s}", "question": query, "search_results": [], "documents": [], @@ -410,7 +420,7 @@ def analyze_topic(self, query: str) -> Dict: {chr(10).join(key_findings[:5]) if key_findings else "No valid findings were generated."} ## Error Information -The system encountered an error during final synthesis: {str(e)} +The system encountered an error during final synthesis: {e!s} This is an automatically generated fallback response. """ @@ -423,15 +433,19 @@ def analyze_topic(self, query: str) -> Dict: The system encountered multiple errors while processing your query: "{query}" -Primary error: {str(e)} -Fallback error: {str(fallback_error)} +Primary error: {e!s} +Fallback error: {fallback_error!s} Please try again with a different query or contact support. """ # Compress knowledge if needed if ( - get_db_setting("general.knowledge_accumulation", "ITERATION") + get_setting_from_snapshot( + "general.knowledge_accumulation", + "ITERATION", + settings_snapshot=self.settings_snapshot, + ) == "ITERATION" ): try:
src/local_deep_research/advanced_search_system/strategies/llm_driven_modular_strategy.py+21 −21 modified@@ -190,8 +190,8 @@ def _parse_decomposition(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse decomposition: {e}") + except Exception: + logger.exception("Failed to parse decomposition") # Fallback to simple structure return { @@ -210,8 +210,8 @@ def _parse_combinations(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse combinations: {e}") + except Exception: + logger.exception("Failed to parse combinations") # Fallback return [ @@ -228,8 +228,8 @@ def _parse_creative_searches(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse creative searches: {e}") + except Exception: + logger.exception("Failed to parse creative searches") # Fallback return [ @@ -246,8 +246,8 @@ def _parse_optimized_searches(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse optimized searches: {e}") + except Exception: + logger.exception("Failed to parse optimized searches") # Fallback return { @@ -309,8 +309,8 @@ async def quick_confidence_check(self, candidate, constraints): try: response = await self.model.ainvoke(prompt) return self._parse_confidence(response.content) - except Exception as e: - logger.error(f"Quick confidence check failed: {e}") + except Exception: + logger.exception("Quick confidence check failed") return { "positive_confidence": 0.5, "negative_confidence": 0.3, @@ -351,8 +351,8 @@ def _parse_confidence(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse confidence: {e}") + except Exception: + logger.exception("Failed to parse confidence") return { "positive_confidence": 0.5, @@ -459,14 +459,14 @@ def analyze_topic(self, query: str) -> Dict: } except Exception as e: - logger.error(f"Error in analyze_topic: {e}") + logger.exception("Error in analyze_topic") import traceback - logger.error(f"Traceback: {traceback.format_exc()}") + logger.exception(f"Traceback: {traceback.format_exc()}") return { "findings": [], "iterations": 0, - "final_answer": f"Analysis failed: {str(e)}", + "final_answer": f"Analysis failed: {e!s}", "metadata": {"error": str(e)}, "links": [], "questions_by_iteration": [], @@ -726,7 +726,7 @@ async def search( evaluated_candidates.append(candidate) except Exception as e: - logger.error( + logger.exception( f"Error evaluating candidate {candidate.name}: {e}" ) continue @@ -779,11 +779,11 @@ async def search( return answer, metadata except Exception as e: - logger.error(f"Error in LLM-driven search: {e}") + logger.exception("Error in LLM-driven search") import traceback - logger.error(f"Traceback: {traceback.format_exc()}") - return f"Search failed: {str(e)}", {"error": str(e)} + logger.exception(f"Traceback: {traceback.format_exc()}") + return f"Search failed: {e!s}", {"error": str(e)} async def _generate_final_answer(self, query, best_candidate, constraints): """Generate comprehensive final answer""" @@ -853,8 +853,8 @@ def _gather_evidence_for_constraint(self, candidate, constraint): return evidence - except Exception as e: - logger.error(f"Error gathering evidence: {e}") + except Exception: + logger.exception("Error gathering evidence") # Fallback to mock evidence return [ {
src/local_deep_research/advanced_search_system/strategies/modular_strategy.py+29 −27 modified@@ -158,8 +158,8 @@ def _parse_decomposition(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse decomposition: {e}") + except Exception: + logger.exception("Failed to parse decomposition") # If parsing fails, return empty dict - let the system handle gracefully logger.warning( @@ -175,8 +175,8 @@ def _parse_combinations(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse combinations: {e}") + except Exception: + logger.exception("Failed to parse combinations") # If parsing fails, return empty list - let the system handle gracefully logger.warning("Failed to parse LLM combinations, returning empty list") @@ -220,8 +220,8 @@ async def quick_confidence_check(self, candidate, constraints): try: response = await self.model.ainvoke(prompt) return self._parse_confidence(response.content) - except Exception as e: - logger.error(f"Quick confidence check failed: {e}") + except Exception: + logger.exception("Quick confidence check failed") return { "positive_confidence": 0.5, "negative_confidence": 0.3, @@ -262,8 +262,8 @@ def _parse_confidence(self, content): if start != -1 and end != -1: json_str = content[start:end] return json.loads(json_str) - except Exception as e: - logger.error(f"Failed to parse confidence: {e}") + except Exception: + logger.exception("Failed to parse confidence") return { "positive_confidence": 0.5, @@ -294,9 +294,13 @@ def __init__( early_stopping: bool = True, # Enable early stopping by default llm_constraint_processing: bool = True, # Enable LLM-driven constraint processing by default immediate_evaluation: bool = True, # Enable immediate candidate evaluation by default + settings_snapshot=None, **kwargs, ): - super().__init__(all_links_of_system=all_links_of_system) + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) self.model = model self.search_engine = search @@ -548,7 +552,7 @@ async def search( result = future.result() batch_results.append(result) except Exception as e: - logger.error( + logger.exception( f"❌ Parallel search failed for '{query[:30]}...': {e}" ) batch_results.append(e) @@ -559,7 +563,9 @@ async def search( # Process batch results - QUEUE CANDIDATES FOR BACKGROUND EVALUATION for j, result in enumerate(batch_results): if isinstance(result, Exception): - logger.error(f"❌ Search failed: {batch[j]} - {result}") + logger.exception( + f"❌ Search failed: {batch[j]} - {result}" + ) continue candidates = self.candidate_explorer._extract_candidates_from_results( @@ -647,7 +653,7 @@ async def search( evaluated_candidates.append(candidate) except Exception as e: - logger.error( + logger.exception( f"💥 Error evaluating candidate {candidate.name}: {e}" ) continue @@ -777,11 +783,8 @@ async def search( return answer, metadata except Exception as e: - logger.error(f"💥 Error in enhanced modular search: {e}") - import traceback - - logger.error(f"🔍 Traceback: {traceback.format_exc()}") - return f"Search failed: {str(e)}", {"error": str(e)} + logger.exception("💥 Error in enhanced modular search") + return f"Search failed: {e!s}", {"error": str(e)} async def _generate_final_answer( self, query: str, best_candidate, constraints @@ -999,8 +1002,8 @@ def _gather_evidence_for_constraint(self, candidate, constraint): return evidence - except Exception as e: - logger.error(f"Error gathering evidence: {e}", exc_info=True) + except Exception: + logger.exception("Error gathering evidence") # Return empty list instead of mock evidence return [] @@ -1079,10 +1082,12 @@ async def _background_candidate_evaluation( ) except Exception as e: - logger.error(f"💥 Error evaluating {candidate.name}: {e}") + logger.exception( + f"💥 Error evaluating {candidate.name}: {e}" + ) - except Exception as e: - logger.error(f"💥 Background evaluation error: {e}") + except Exception: + logger.exception("💥 Background evaluation error") def analyze_topic(self, query: str) -> Dict: """ @@ -1128,14 +1133,11 @@ def analyze_topic(self, query: str) -> Dict: } except Exception as e: - logger.error(f"Error in analyze_topic: {e}") - import traceback - - logger.error(f"Traceback: {traceback.format_exc()}") + logger.exception("Error in analyze_topic") return { "findings": [], "iterations": 0, - "final_answer": f"Analysis failed: {str(e)}", + "final_answer": f"Analysis failed: {e!s}", "metadata": {"error": str(e)}, "links": [], "questions_by_iteration": [],
src/local_deep_research/advanced_search_system/strategies/news_strategy.py+284 −0 added@@ -0,0 +1,284 @@ +""" +News aggregation search strategy for LDR. +Uses optimized prompts and search patterns for news aggregation. +""" + +from typing import List, Dict, Any, Optional +from datetime import datetime, UTC +import json +import re +from loguru import logger + +from .base_strategy import BaseSearchStrategy +from ..questions.news_question import NewsQuestionGenerator + + +class NewsAggregationStrategy(BaseSearchStrategy): + """ + Specialized search strategy for news aggregation. + Uses single iteration with multiple parallel searches for broad coverage. + """ + + def __init__(self, model, search, all_links_of_system=None, **kwargs): + super().__init__(all_links_of_system=all_links_of_system) + self.model = model + self.search = search + self.strategy_name = "news_aggregation" + self.max_iterations = 1 # News needs broad coverage, not deep iteration + self.questions_per_iteration = 8 # More parallel searches for news + self.question_generator = NewsQuestionGenerator(self.model) + + def generate_questions(self, query: str, context: str) -> List[str]: + """Generate news-specific search queries using the NewsQuestionGenerator""" + return self.question_generator.generate_questions( + current_knowledge=context, + query=query, + questions_per_iteration=self.questions_per_iteration, + questions_by_iteration=self.questions_by_iteration, + ) + + async def analyze_findings( + self, all_findings: List[Dict] + ) -> Dict[str, Any]: + """Analyze search results to extract and structure news items""" + + if not all_findings: + return { + "status": "No news found", + "news_items": [], + "answer": "No significant news stories found for the specified criteria.", + } + + # Format findings for LLM analysis + snippets = [] + for i, finding in enumerate( + all_findings[:50] + ): # Limit to 50 for token efficiency + snippet = { + "id": i + 1, + "url": finding.get("url", ""), + "title": finding.get("title", ""), + "snippet": finding.get("snippet", "")[:300] + if finding.get("snippet") + else "", + "content": finding.get("content", "")[:500] + if finding.get("content") + else "", + } + snippets.append(snippet) + + # Create structured prompt for news extraction + prompt = self._create_news_analysis_prompt(snippets) + + try: + response = self.model.invoke(prompt) + content = ( + response.content + if hasattr(response, "content") + else str(response) + ) + + # Extract JSON from response + news_data = self._extract_json_from_response(content) + + if news_data and "news_items" in news_data: + return { + "status": "Success", + "news_items": news_data["news_items"], + "answer": self._format_news_summary( + news_data["news_items"] + ), + } + else: + # Fallback to simple extraction + return self._fallback_news_extraction(snippets) + + except Exception: + logger.exception("Error analyzing news findings") + return self._fallback_news_extraction(snippets) + + def _create_news_analysis_prompt(self, snippets: List[Dict]) -> str: + """Create the analysis prompt for news extraction""" + + snippet_text = "\n\n".join( + [ + f"[{s['id']}] Source: {s['url']}\n" + f"Title: {s['title']}\n" + f"Content: {s['snippet'] or s['content']}" + for s in snippets + ] + ) + + return f""" +Analyze these news snippets from search results and create a structured news report. +Today's date: {datetime.now(UTC).strftime("%B %d, %Y")} + +{snippet_text} + +Create a structured JSON response with the 10 most important news stories: +{{ + "news_items": [ + {{ + "headline": "8 words max describing the story", + "category": "War/Security/Economy/Tech/Politics/Health/Environment/Other", + "source_url": "url from snippets", + "source_id": "[number] from above", + "summary": "3 clear sentences about what happened", + "analysis": "Why this matters and what happens next (2 sentences)", + "impact_score": 1-10, + "entities": {{"people": ["names"], "places": ["locations"], "orgs": ["organizations"]}}, + "topics": ["topic1", "topic2"], + "time_ago": "estimated time (2 hours ago, yesterday, etc)", + "is_developing": true/false, + "surprising_element": "what makes this unexpected or notable (if any)" + }} + ] +}} + +PRIORITIZE: +1. Stories with casualties or significant human impact +2. Economic impacts over $1 billion +3. Major political or diplomatic developments +4. Unexpected or surprising events +5. Breaking developments from the last 24 hours + +Only include stories that are truly newsworthy and significant. +Ensure variety across different categories when possible. +""" + + def _extract_json_from_response(self, content: str) -> Optional[Dict]: + """Extract JSON from LLM response""" + try: + # Try to find JSON in the response + json_match = re.search(r"\{.*\}", content, re.DOTALL) + if json_match: + return json.loads(json_match.group()) + except Exception: + logger.exception("Error extracting JSON") + return None + + def _format_news_summary(self, news_items: List[Dict]) -> str: + """Format news items into a readable summary""" + + if not news_items: + return "No significant news stories found." + + # Group by category + by_category = {} + for item in news_items: + cat = item.get("category", "Other") + if cat not in by_category: + by_category[cat] = [] + by_category[cat].append(item) + + # Build summary + parts = [f"Found {len(news_items)} significant news stories:\n"] + + for category, items in by_category.items(): + parts.append(f"\n**{category}** ({len(items)} stories):") + for item in items[:3]: # Top 3 per category + parts.append( + f"- {item['headline']} " + f"(Impact: {item.get('impact_score', 'N/A')}/10)" + ) + + # Add top story details + if news_items: + top_story = max(news_items, key=lambda x: x.get("impact_score", 0)) + parts.append(f"\n**Top Story**: {top_story['headline']}") + parts.append(f"{top_story.get('summary', 'No summary available')}") + + return "\n".join(parts) + + def _fallback_news_extraction(self, snippets: List[Dict]) -> Dict[str, Any]: + """Simple fallback extraction when JSON parsing fails""" + + news_items = [] + for s in snippets[:10]: + if s["title"] and len(s["title"]) > 10: + news_items.append( + { + "headline": s["title"][:60], + "category": "Other", + "source_url": s["url"], + "summary": s["snippet"] or "No summary available", + "impact_score": 5, + } + ) + + return { + "status": "Fallback extraction", + "news_items": news_items, + "answer": f"Found {len(news_items)} news stories (simplified extraction)", + } + + def analyze_topic(self, query: str) -> Dict: + """ + Analyze a topic for news aggregation. + + Args: + query: The news query or focus area + + Returns: + Dict containing news findings and formatted output + """ + import asyncio + + # Generate news-specific search queries + questions = self.generate_questions(query, "") + self.questions_by_iteration[0] = questions + + all_findings = [] + + # Search for each question + for i, question in enumerate(questions): + self._update_progress( + f"Searching for: {question}", + int((i / len(questions)) * 50), + {"phase": "search", "question": question}, + ) + + try: + if self.search: + results = self.search.run(question) + if results: + all_findings.extend(results) + except Exception: + logger.exception("Search error") + continue + + # Analyze findings - handle both sync and async contexts + try: + # Check if we're already in an async context + loop = asyncio.get_event_loop() + if loop.is_running(): + # We're in an async context, create a task + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + asyncio.run, self.analyze_findings(all_findings) + ) + analysis = future.result() + else: + # We're in a sync context, use run_until_complete + analysis = loop.run_until_complete( + self.analyze_findings(all_findings) + ) + except RuntimeError: + # No event loop, create one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + analysis = loop.run_until_complete( + self.analyze_findings(all_findings) + ) + + return { + "findings": all_findings, + "iterations": 1, + "questions": self.questions_by_iteration, + "formatted_findings": analysis.get("answer", "No news found"), + "current_knowledge": analysis.get("answer", ""), + "news_items": analysis.get("news_items", []), + "status": analysis.get("status", "Unknown"), + }
src/local_deep_research/advanced_search_system/strategies/parallel_constrained_strategy.py+10 −10 modified@@ -350,8 +350,8 @@ def _parallel_search( "total_so_far": len(all_candidates), }, ) - except Exception as e: - logger.error(f"Search failed for {combo.query}: {e}") + except Exception: + logger.exception(f"Search failed for {combo.query}") return all_candidates @@ -385,8 +385,8 @@ def _execute_combination_search( ) return candidates - except Exception as e: - logger.error(f"Error in combination search: {e}", exc_info=True) + except Exception: + logger.exception("Error in combination search") return [] def _quick_extract_candidates( @@ -421,8 +421,8 @@ def _quick_extract_candidates( if name and len(name) > 2: candidates.append(Candidate(name=name)) return candidates[:15] - except Exception as e: - logger.error(f"Entity extraction failed: {e}") + except Exception: + logger.exception("Entity extraction failed") return [] def _validate_hard_constraints( @@ -464,8 +464,8 @@ def _validate_hard_constraints( ) return filtered - except Exception as e: - logger.error(f"Hard constraint validation failed: {e}") + except Exception: + logger.exception("Hard constraint validation failed") return candidates[:10] # Return top candidates if validation fails def _detect_entity_type(self) -> str: @@ -501,6 +501,6 @@ def _detect_entity_type(self) -> str: entity_type = self.model.invoke(prompt).content.strip() logger.info(f"LLM determined entity type: {entity_type}") return entity_type - except Exception as e: - logger.error(f"Failed to detect entity type: {e}") + except Exception: + logger.exception("Failed to detect entity type") return "unknown entity"
src/local_deep_research/advanced_search_system/strategies/parallel_search_strategy.py+29 −18 modified@@ -8,9 +8,8 @@ from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search -from ...utilities.db_utils import get_db_setting + +# Model and search should be provided by AdvancedSearchSystem from ...utilities.search_utilities import extract_links_from_search_results from ..filters.cross_engine_filter import CrossEngineFilter from ..findings.repository import FindingsRepository @@ -26,15 +25,16 @@ class ParallelSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, include_text_content: bool = True, use_cross_engine_filter: bool = True, filter_reorder: bool = True, filter_reindex: bool = True, cross_engine_max_results: int = None, all_links_of_system=None, + settings_snapshot=None, ): """Initialize with optional dependency injection for testing. @@ -48,10 +48,14 @@ def __init__( filter_reindex: Whether to update result indices after filtering cross_engine_max_results: Maximum number of results to keep after cross-engine filtering all_links_of_system: Optional list of links to initialize with + settings_snapshot: Settings snapshot for thread context """ - super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) + self.search = search + self.model = model self.progress_callback = None # Note: questions_by_iteration is already initialized by parent class self.include_text_content = include_text_content @@ -65,6 +69,7 @@ def __init__( max_results=cross_engine_max_results, default_reorder=filter_reorder, default_reindex=filter_reindex, + settings_snapshot=settings_snapshot, ) # Set include_full_content on the search engine if it supports it @@ -116,7 +121,7 @@ def analyze_topic(self, query: str) -> Dict: } # Determine number of iterations to run - iterations_to_run = get_db_setting("search.iterations") + iterations_to_run = self.get_setting("search.iterations") logger.debug("Selected amount of iterations: " + str(iterations_to_run)) iterations_to_run = int(iterations_to_run) try: @@ -148,12 +153,15 @@ def analyze_topic(self, query: str) -> Dict: context = f"""Iteration: {1} of {iterations_to_run}""" else: context = "" + questions_per_iter = self.get_setting( + "search.questions_per_iteration" + ) questions = self.question_generator.generate_questions( current_knowledge=context, query=query, - questions_per_iteration=int( - get_db_setting("search.questions_per_iteration") - ), + questions_per_iteration=int(questions_per_iter) + if questions_per_iter is not None + else 3, questions_by_iteration=self.questions_by_iteration, ) @@ -183,12 +191,15 @@ def analyze_topic(self, query: str) -> Dict: Iteration: {iteration} of {iterations_to_run}""" # Generate questions + questions_per_iter = self.get_setting( + "search.questions_per_iteration" + ) questions = self.question_generator.generate_questions( current_knowledge=context, query=query, - questions_per_iteration=int( - get_db_setting("search.questions_per_iteration") - ), + questions_per_iteration=int(questions_per_iter) + if questions_per_iter is not None + else 3, questions_by_iteration=self.questions_by_iteration, ) @@ -424,10 +435,10 @@ def search_question(q): ) except Exception as e: - error_msg = f"Error in research process: {str(e)}" + error_msg = f"Error in research process: {e!s}" logger.exception(error_msg) - synthesized_content = f"Error: {str(e)}" - formatted_findings = f"Error: {str(e)}" + synthesized_content = f"Error: {e!s}" + formatted_findings = f"Error: {e!s}" finding = { "phase": "Error", "content": synthesized_content,
src/local_deep_research/advanced_search_system/strategies/rapid_search_strategy.py+16 −12 modified@@ -7,8 +7,8 @@ from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search + +# Model and search should be provided by AdvancedSearchSystem from ...utilities.search_utilities import extract_links_from_search_results from ..findings.repository import FindingsRepository from ..knowledge.standard_knowledge import StandardKnowledge @@ -24,15 +24,19 @@ class RapidSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, all_links_of_system=None, + settings_snapshot=None, ): """Initialize with optional dependency injection for testing.""" - super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) + self.search = search + self.model = model self.progress_callback = None # Note: questions_by_iteration is already initialized by parent class @@ -121,7 +125,7 @@ def analyze_topic(self, query: str) -> Dict: # No findings added here - just collecting data except Exception as e: - error_msg = f"Error during initial search: {str(e)}" + error_msg = f"Error during initial search: {e!s}" logger.exception(f"SEARCH ERROR: {error_msg}") self._update_progress( error_msg, 15, {"phase": "search_error", "error": str(e)} @@ -197,7 +201,7 @@ def analyze_topic(self, query: str) -> Dict: # No findings added here - just collecting data except Exception as e: - error_msg = f"Error during search: {str(e)}" + error_msg = f"Error during search: {e!s}" logger.exception(f"SEARCH ERROR: {error_msg}") self._update_progress( error_msg, @@ -266,10 +270,10 @@ def analyze_topic(self, query: str) -> Dict: findings.append(finding) except Exception as e: - error_msg = f"Error synthesizing final answer: {str(e)}" + error_msg = f"Error synthesizing final answer: {e!s}" logger.exception(error_msg) - synthesized_content = f"Error generating synthesis: {str(e)}" - formatted_findings = f"Error: {str(e)}" + synthesized_content = f"Error generating synthesis: {e!s}" + formatted_findings = f"Error: {e!s}" finding = { "phase": "Error", "content": synthesized_content,
src/local_deep_research/advanced_search_system/strategies/recursive_decomposition_strategy.py+2 −2 modified@@ -487,6 +487,6 @@ def _use_source_based_strategy(self, query: str) -> Dict: def _get_timestamp(self) -> str: """Get current timestamp for findings.""" - from datetime import datetime + from datetime import datetime, UTC - return datetime.utcnow().isoformat() + return datetime.now(UTC).isoformat()
src/local_deep_research/advanced_search_system/strategies/smart_decomposition_strategy.py+7 −4 modified@@ -47,6 +47,7 @@ def __init__( model: BaseChatModel, search: Any, all_links_of_system: List[str], + settings_snapshot=None, **kwargs, ): """Initialize the smart decomposition strategy. @@ -55,9 +56,10 @@ def __init__( model: The language model to use search: The search engine instance all_links_of_system: List to store all encountered links + settings_snapshot: Settings snapshot for thread context **kwargs: Additional parameters for sub-strategies """ - super().__init__(all_links_of_system) + super().__init__(all_links_of_system, settings_snapshot) self.model = model self.search = search self.strategy_params = kwargs @@ -95,9 +97,10 @@ def analyze_topic(self, query: str) -> Dict: return self._use_evidence_strategy(query) elif query_type == QueryType.CONSTRAINT_BASED: return self._use_evidence_strategy(query) - elif query_type == QueryType.HIERARCHICAL: - return self._use_recursive_strategy(query) - elif query_type in [QueryType.COMPARATIVE, QueryType.EXPLORATORY]: + elif query_type == QueryType.HIERARCHICAL or query_type in [ + QueryType.COMPARATIVE, + QueryType.EXPLORATORY, + ]: return self._use_recursive_strategy(query) else: # FACTUAL or unknown return self._use_adaptive_strategy(query)
src/local_deep_research/advanced_search_system/strategies/smart_query_strategy.py+17 −15 modified@@ -101,8 +101,8 @@ def _generate_smart_query(self, constraints: List[Constraint]) -> str: logger.info(f"LLM generated query: {query}") return query - except Exception as e: - logger.error(f"Failed to generate smart query: {e}") + except Exception: + logger.exception("Failed to generate smart query") return self._build_standard_query(constraints) def _build_standard_query(self, constraints: List[Constraint]) -> str: @@ -179,7 +179,9 @@ def _execute_combination_search(self, combo) -> List: f"Query '{query}' found {len(candidates)} candidates" ) except Exception as e: - logger.error(f"Search failed for query '{query}': {e}") + logger.exception( + f"Search failed for query '{query}': {e}" + ) else: # Use single query from parent implementation candidates = super()._execute_combination_search(combo) @@ -250,8 +252,8 @@ def _generate_query_variations( unique_queries = [fallback] return unique_queries[: self.queries_per_combination] - except Exception as e: - logger.error(f"Failed to generate query variations: {e}") + except Exception: + logger.exception("Failed to generate query variations") # Fallback to single query return [self._build_standard_query(constraints)] @@ -290,8 +292,8 @@ def _extract_candidates_from_results(self, results: Dict) -> List: logger.info(f"Extracted {len(candidates)} candidates from results") - except Exception as e: - logger.error(f"Error extracting candidates: {e}") + except Exception: + logger.exception("Error extracting candidates") return candidates @@ -344,8 +346,8 @@ def _perform_entity_seeding(self): # Immediately search for these seeds self._search_entity_seeds() - except Exception as e: - logger.error(f"Error generating entity seeds: {e}") + except Exception: + logger.exception("Error generating entity seeds") def _search_entity_seeds(self): """Search for the entity seeds directly.""" @@ -381,8 +383,8 @@ def _search_entity_seeds(self): self.candidates = [] self.candidates.append(candidate) - except Exception as e: - logger.error(f"Error searching for seed {seed}: {e}") + except Exception: + logger.exception(f"Error searching for seed {seed}") def _try_direct_property_search(self): """Try direct searches for high-weight property constraints.""" @@ -441,8 +443,8 @@ def _try_direct_property_search(self): if hasattr(self, "_evaluate_candidate_immediately"): self._evaluate_candidate_immediately(candidate) - except Exception as e: - logger.error(f"Property search error: {e}") + except Exception: + logger.exception("Property search error") def _perform_entity_name_search(self): """Last resort: search for entity names directly with constraints.""" @@ -486,8 +488,8 @@ def _perform_entity_name_search(self): ): return - except Exception as e: - logger.error(f"Entity name search error: {e}") + except Exception: + logger.exception("Entity name search error") def _progressive_constraint_search(self): """Override to add entity seeding and property search."""
src/local_deep_research/advanced_search_system/strategies/source_based_strategy.py+51 −30 modified@@ -4,11 +4,14 @@ from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search -from ...utilities.db_utils import get_db_setting +# LLM and search instances should be passed via constructor, not imported + +# Removed get_db_setting import - using settings_snapshot instead +from ...utilities.thread_context import ( + preserve_research_context, + get_search_context, +) from ...utilities.threading_utils import thread_context, thread_with_app_context -from ...utilities.thread_context import preserve_research_context from ..filters.cross_engine_filter import CrossEngineFilter from ..findings.repository import FindingsRepository from ..questions.atomic_fact_question import AtomicFactQuestionGenerator @@ -24,8 +27,8 @@ class SourceBasedSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, include_text_content: bool = True, use_cross_engine_filter: bool = True, @@ -34,15 +37,22 @@ def __init__( cross_engine_max_results: int = None, all_links_of_system=None, use_atomic_facts: bool = False, + settings_snapshot=None, + search_original_query: bool = True, ): """Initialize with optional dependency injection for testing.""" - # Pass the links list to the parent class - super().__init__(all_links_of_system=all_links_of_system) - # Use provided model and search, or fall back to defaults - # Note: If model/search are provided, they should already have the proper context - self.model = model if model is not None else get_llm() - self.search = search if search is not None else get_search() + # Pass the links list and settings to the parent class + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + search_original_query=search_original_query, + ) + + # Model and search are always provided by AdvancedSearchSystem + self.model = model + self.search = search # Note: progress_callback and questions_by_iteration are already set by parent class + self.include_text_content = include_text_content self.use_cross_engine_filter = use_cross_engine_filter self.filter_reorder = filter_reorder @@ -54,6 +64,7 @@ def __init__( max_results=cross_engine_max_results, default_reorder=filter_reorder, default_reindex=filter_reindex, + settings_snapshot=settings_snapshot, ) # Set include_full_content on the search engine if it supports it @@ -119,7 +130,7 @@ def analyze_topic(self, query: str) -> Dict: } # Determine number of iterations to run - iterations_to_run = get_db_setting("search.iterations", 2) + iterations_to_run = self.get_setting("search.iterations", 2) logger.debug("Selected amount of iterations: " + str(iterations_to_run)) iterations_to_run = int(iterations_to_run) try: @@ -156,16 +167,24 @@ def analyze_topic(self, query: str) -> Dict: current_knowledge=context, query=query, questions_per_iteration=int( - get_db_setting("search.questions_per_iteration") + self.get_setting( + "search.questions_per_iteration", 5 + ) # Default to 5 if not set ), questions_by_iteration=self.questions_by_iteration, ) - # Always include the original query for the first iteration - if query not in questions: - all_questions = [query] + questions - else: - all_questions = questions + # Include original query if enabled and not already present + all_questions = ( + [query] + questions + if self.search_original_query and query not in questions + else questions + ) + + if not self.search_original_query: + logger.info( + "search_original_query=False - skipping original query" + ) self.questions_by_iteration[iteration] = all_questions logger.info( @@ -189,7 +208,9 @@ def analyze_topic(self, query: str) -> Dict: current_knowledge=context, query=query, questions_per_iteration=int( - get_db_setting("search.questions_per_iteration", 2) + self.get_setting( + "search.questions_per_iteration", 2 + ) ), questions_by_iteration=self.questions_by_iteration, ) @@ -215,10 +236,13 @@ def analyze_topic(self, query: str) -> Dict: @preserve_research_context def search_question(q): try: - result = self.search.run(q) + current_context = get_search_context() + result = self.search.run( + q, research_context=current_context + ) return {"question": q, "results": result or []} except Exception as e: - logger.error(f"Error searching for '{q}': {str(e)}") + logger.exception(f"Error searching for '{q}': {e!s}") return {"question": q, "results": [], "error": str(e)} # Run searches in parallel @@ -326,7 +350,7 @@ def search_question(q): reorder=True, # Always reorder in final filtering reindex=True, # Always reindex in final filtering max_results=int( - get_db_setting("search.final_max_results") or 100 + self.get_setting("search.final_max_results", 100) ), start_index=len(self.all_links_of_system), ) @@ -394,13 +418,10 @@ def search_question(q): ) except Exception as e: - import traceback - - error_msg = f"Error in research process: {str(e)}" - logger.error(error_msg) - logger.error(traceback.format_exc()) - synthesized_content = f"Error: {str(e)}" - formatted_findings = f"Error: {str(e)}" + error_msg = f"Error in research process: {e!s}" + logger.exception(error_msg) + synthesized_content = f"Error: {e!s}" + formatted_findings = f"Error: {e!s}" finding = { "phase": "Error", "content": synthesized_content,
src/local_deep_research/advanced_search_system/strategies/standard_strategy.py+30 −15 modified@@ -4,9 +4,9 @@ from loguru import logger from ...citation_handler import CitationHandler -from ...config.llm_config import get_llm -from ...config.search_config import get_search -from ...utilities.db_utils import get_db_setting + +# Model and search should be provided by AdvancedSearchSystem +from ...config.thread_settings import get_setting_from_snapshot from ...utilities.enums import KnowledgeAccumulationApproach from ...utilities.search_utilities import extract_links_from_search_results from ..findings.repository import FindingsRepository @@ -20,24 +20,38 @@ class StandardSearchStrategy(BaseSearchStrategy): def __init__( self, - search=None, - model=None, + search, + model, citation_handler=None, all_links_of_system=None, + settings_snapshot=None, ): """Initialize with optional dependency injection for testing.""" - super().__init__(all_links_of_system=all_links_of_system) - self.search = search or get_search() - self.model = model or get_llm() + super().__init__( + all_links_of_system=all_links_of_system, + settings_snapshot=settings_snapshot, + ) + self.search = search + self.model = model # Get iterations setting - self.max_iterations = int(get_db_setting("search.iterations")) + self.max_iterations = int( + get_setting_from_snapshot( + "search.iterations", settings_snapshot=settings_snapshot + ) + ) self.questions_per_iteration = int( - get_db_setting("search.questions_per_iteration") + get_setting_from_snapshot( + "search.questions_per_iteration", + settings_snapshot=settings_snapshot, + ) ) self.context_limit = int( - get_db_setting("general.knowledge_accumulation_context_limit") + get_setting_from_snapshot( + "general.knowledge_accumulation_context_limit", + settings_snapshot=settings_snapshot, + ) ) # Note: questions_by_iteration is already initialized by parent class @@ -123,9 +137,10 @@ def analyze_topic(self, query: str) -> Dict: logger.info(f"Generated questions: {questions}") question_count = len(questions) - knowledge_accumulation = get_db_setting( + knowledge_accumulation = get_setting_from_snapshot( "general.knowledge_accumulation", "ITERATION", + settings_snapshot=self.settings_snapshot, ) for q_idx, question in enumerate(questions): question_progress_base = iteration_progress_base + ( @@ -158,7 +173,7 @@ def analyze_topic(self, query: str) -> Dict: else: search_results = self.search.run(question) except Exception as e: - error_msg = f"Error during search: {str(e)}" + error_msg = f"Error during search: {e!s}" logger.exception(f"SEARCH ERROR: {error_msg}") self._handle_search_error( error_msg, question_progress_base + 10 @@ -247,7 +262,7 @@ def analyze_topic(self, query: str) -> Dict: {"phase": "analysis_complete"}, ) except Exception as e: - error_msg = f"Error analyzing results: {str(e)}" + error_msg = f"Error analyzing results: {e!s}" logger.exception(f"ANALYSIS ERROR: {error_msg}") self._handle_search_error( error_msg, question_progress_base + 10 @@ -274,7 +289,7 @@ def analyze_topic(self, query: str) -> Dict: ) logger.info("FINISHED ITERATION - Compressing Knowledge") except Exception as e: - error_msg = f"Error compressing knowledge: {str(e)}" + error_msg = f"Error compressing knowledge: {e!s}" logger.exception(f"COMPRESSION ERROR: {error_msg}") self._handle_search_error( error_msg, int((iteration / total_iterations) * 100 - 3)
src/local_deep_research/advanced_search_system/tools/base_tool.py+1 −3 modified@@ -3,12 +3,10 @@ Defines the common interface and shared functionality for different tools. """ -import logging +from loguru import logger from abc import ABC, abstractmethod from typing import Any, Dict -logger = logging.getLogger(__name__) - class BaseTool(ABC): """Abstract base class for all agent-compatible tools."""
src/local_deep_research/api/benchmark_functions.py+9 −10 modified@@ -4,7 +4,8 @@ This module provides functions for running benchmarks programmatically. """ -import logging +from loguru import logger +from pathlib import Path from typing import Any, Dict, List, Optional from ..benchmarks import ( @@ -15,8 +16,6 @@ run_simpleqa_benchmark, ) -logger = logging.getLogger(__name__) - def evaluate_simpleqa( num_examples: int = 100, @@ -220,7 +219,7 @@ def compare_configurations( benchmark_result = run_benchmark( dataset_type=dataset_type, num_examples=num_examples, - output_dir=os.path.join(output_dir, config_name.replace(" ", "_")), + output_dir=str(Path(output_dir) / config_name.replace(" ", "_")), search_config=search_config, run_evaluation=True, ) @@ -235,8 +234,8 @@ def compare_configurations( import time timestamp = time.strftime("%Y%m%d_%H%M%S") - report_file = os.path.join( - output_dir, f"comparison_{dataset_type}_{timestamp}.md" + report_file = str( + Path(output_dir) / f"comparison_{dataset_type}_{timestamp}.md" ) with open(report_file, "w") as f: @@ -282,11 +281,11 @@ def compare_configurations( # Export the API functions __all__ = [ - "evaluate_simpleqa", + "calculate_metrics", + "compare_configurations", "evaluate_browsecomp", + "evaluate_simpleqa", + "generate_report", "get_available_benchmarks", - "compare_configurations", "run_benchmark", # For advanced users - "calculate_metrics", - "generate_report", ]
src/local_deep_research/api/__init__.py+21 −2 modified@@ -9,10 +9,29 @@ generate_report, quick_summary, ) +from .settings_utils import ( + create_settings_snapshot, + get_default_settings_snapshot, + extract_setting_value, +) + +from ..news import ( + get_news_feed, + research_news_item, + save_news_preferences, + get_news_categories, +) __all__ = [ - "quick_summary", + "analyze_documents", "detailed_research", "generate_report", - "analyze_documents", + "quick_summary", + "create_settings_snapshot", + "get_default_settings_snapshot", + "extract_setting_value", + "get_news_feed", + "research_news_item", + "save_news_preferences", + "get_news_categories", ]
src/local_deep_research/api/research_functions.py+166 −12 modified@@ -3,16 +3,20 @@ Provides programmatic access to search and research capabilities. """ -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Callable, Dict, Optional, Union from loguru import logger +from local_deep_research.settings.logger import log_settings from ..config.llm_config import get_llm from ..config.search_config import get_search from ..report_generator import IntegratedReportGenerator from ..search_system import AdvancedSearchSystem +from ..utilities.db_utils import no_db_settings +from ..utilities.thread_context import set_search_context from ..utilities.search_utilities import remove_think_tags +from .settings_utils import create_settings_snapshot def _init_search_system( @@ -27,6 +31,12 @@ def _init_search_system( questions_per_iteration: int = 1, retrievers: Optional[Dict[str, Any]] = None, llms: Optional[Dict[str, Any]] = None, + username: Optional[str] = None, + research_id: Optional[Union[int, str]] = None, + research_context: Optional[Dict[str, Any]] = None, + programmatic_mode: bool = True, + search_original_query: bool = True, + **kwargs: Any, ) -> AdvancedSearchSystem: """ Initializes the advanced search system with specified parameters. This function sets up @@ -48,6 +58,8 @@ def _init_search_system( search_strategy: The name of the search strategy to use. retrievers: Optional dictionary of {name: retriever} pairs to use as search engines llms: Optional dictionary of {name: llm} pairs to use as language models + programmatic_mode: If True, disables database operations and metrics tracking + search_original_query: Whether to include the original query in the first iteration of search Returns: AdvancedSearchSystem: An instance of the configured AdvancedSearchSystem. @@ -70,18 +82,36 @@ def _init_search_system( register_llm(name, llm_instance) logger.info(f"Registered {len(llms)} LLMs: {list(llms.keys())}") + # Extract settings_snapshot from kwargs if available + settings_snapshot = kwargs.get("settings_snapshot") + # Get language model with custom temperature llm = get_llm( temperature=temperature, openai_endpoint_url=openai_endpoint_url, model_name=model_name, provider=provider, + research_id=research_id, + research_context=research_context, + settings_snapshot=settings_snapshot, ) - # Set the search engine if specified + # Set the search engine if specified or get from settings search_engine = None + settings_snapshot = kwargs.get("settings_snapshot") + + # If no search_tool provided, get from settings_snapshot + if not search_tool and settings_snapshot: + search_tool = settings_snapshot.get("search.tool") + if search_tool: - search_engine = get_search(search_tool, llm_instance=llm) + search_engine = get_search( + search_tool, + llm_instance=llm, + username=username, + settings_snapshot=settings_snapshot, + programmatic_mode=programmatic_mode, + ) if search_engine is None: logger.warning( f"Could not create search engine '{search_tool}', using default." @@ -90,7 +120,15 @@ def _init_search_system( # Create search system with custom parameters logger.info("Search strategy: {}", search_strategy) system = AdvancedSearchSystem( - llm=llm, search=search_engine, strategy_name=search_strategy + llm=llm, + search=search_engine, + strategy_name=search_strategy, + username=username, + research_id=research_id, + research_context=research_context, + settings_snapshot=settings_snapshot, + programmatic_mode=programmatic_mode, + search_original_query=search_original_query, ) # Override default settings with user-provided values @@ -104,11 +142,20 @@ def _init_search_system( return system +@no_db_settings def quick_summary( query: str, research_id: Optional[Union[int, str]] = None, retrievers: Optional[Dict[str, Any]] = None, llms: Optional[Dict[str, Any]] = None, + username: Optional[str] = None, + provider: Optional[str] = None, + api_key: Optional[str] = None, + temperature: Optional[float] = None, + max_search_results: Optional[int] = None, + settings: Optional[Dict[str, Any]] = None, + settings_override: Optional[Dict[str, Any]] = None, + search_original_query: bool = True, **kwargs: Any, ) -> Dict[str, Any]: """ @@ -119,7 +166,15 @@ def quick_summary( research_id: Optional research ID (int or UUID string) for tracking metrics retrievers: Optional dictionary of {name: retriever} pairs to use as search engines llms: Optional dictionary of {name: llm} pairs to use as language models - **kwargs: Configuration for the search system. Will be forwarded to + provider: LLM provider to use (e.g., 'openai', 'anthropic'). For programmatic API only. + api_key: API key for the provider. For programmatic API only. + temperature: LLM temperature (0.0-1.0). For programmatic API only. + max_search_results: Maximum number of search results to return. For programmatic API only. + settings: Base settings dict to use instead of defaults. For programmatic API only. + settings_override: Dictionary of settings to override (e.g., {"llm.max_tokens": 4000}). For programmatic API only. + search_original_query: Whether to include the original query in the first iteration of search. + Set to False for news searches to avoid sending long subscription prompts to search engines. + **kwargs: Additional configuration for the search system. Will be forwarded to `_init_search_system()`. Returns: @@ -128,9 +183,51 @@ def quick_summary( - 'findings': List of detailed findings from each search - 'iterations': Number of iterations performed - 'questions': Questions generated during research + + Examples: + # Simple usage with defaults + result = quick_summary("What is quantum computing?") + + # With custom provider + result = quick_summary( + "What is quantum computing?", + provider="anthropic", + api_key="sk-ant-..." + ) + + # With advanced settings + result = quick_summary( + "What is quantum computing?", + temperature=0.2, + settings_override={"search.engines.arxiv.enabled": True} + ) """ logger.info("Generating quick summary for query: %s", query) + # Only create settings snapshot if not already provided (programmatic API) + if "settings_snapshot" not in kwargs: + # Build kwargs for create_settings_snapshot from explicit parameters + snapshot_kwargs = {} + if provider is not None: + snapshot_kwargs["provider"] = provider + if api_key is not None: + snapshot_kwargs["api_key"] = api_key + if temperature is not None: + snapshot_kwargs["temperature"] = temperature + if max_search_results is not None: + snapshot_kwargs["max_search_results"] = max_search_results + + # Create settings snapshot for programmatic use + kwargs["settings_snapshot"] = create_settings_snapshot( + base_settings=settings, + overrides=settings_override, + **snapshot_kwargs, + ) + log_settings( + kwargs["settings_snapshot"], + "Created settings snapshot for programmatic API", + ) + # Generate a research_id if none provided if research_id is None: import uuid @@ -155,21 +252,27 @@ def quick_summary( register_llm(name, llm_instance) logger.info(f"Registered {len(llms)} LLMs: {list(llms.keys())}") - # Set search context with research_id - from ..metrics.search_tracker import set_search_context - search_context = { "research_id": research_id, # Pass UUID or integer directly "research_query": query, "research_mode": kwargs.get("research_mode", "quick"), "research_phase": "init", "search_iteration": 0, "search_engine_selected": kwargs.get("search_tool"), + "username": username, # Include username for metrics tracking + "user_password": kwargs.get( + "user_password" + ), # Include password for metrics tracking } set_search_context(search_context) # Remove research_mode from kwargs before passing to _init_search_system init_kwargs = {k: v for k, v in kwargs.items() if k != "research_mode"} + # Make sure username is passed to the system + init_kwargs["username"] = username + init_kwargs["research_id"] = research_id + init_kwargs["research_context"] = search_context + init_kwargs["search_original_query"] = search_original_query system = _init_search_system(llms=llms, **init_kwargs) # Perform the search and analysis @@ -192,13 +295,20 @@ def quick_summary( } +@no_db_settings def generate_report( query: str, output_file: Optional[str] = None, progress_callback: Optional[Callable] = None, searches_per_section: int = 2, retrievers: Optional[Dict[str, Any]] = None, llms: Optional[Dict[str, Any]] = None, + provider: Optional[str] = None, + api_key: Optional[str] = None, + temperature: Optional[float] = None, + max_search_results: Optional[int] = None, + settings: Optional[Dict[str, Any]] = None, + settings_override: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> Dict[str, Any]: """ @@ -212,14 +322,59 @@ def generate_report( section in the report. retrievers: Optional dictionary of {name: retriever} pairs to use as search engines llms: Optional dictionary of {name: llm} pairs to use as language models + provider: LLM provider to use (e.g., 'openai', 'anthropic'). For programmatic API only. + api_key: API key for the provider. For programmatic API only. + temperature: LLM temperature (0.0-1.0). For programmatic API only. + max_search_results: Maximum number of search results to return. For programmatic API only. + settings: Base settings dict to use instead of defaults. For programmatic API only. + settings_override: Dictionary of settings to override. For programmatic API only. + **kwargs: Additional configuration for the search system. Returns: Dictionary containing the research report with keys: - 'content': The full report content in markdown format - 'metadata': Report metadata including generated timestamp and query + - 'file_path': Path to saved file (if output_file was provided) + + Examples: + # Simple usage with settings snapshot + from local_deep_research.api.settings_utils import create_settings_snapshot + settings = create_settings_snapshot({"programmatic_mode": True}) + result = generate_report("AI research", settings_snapshot=settings) + + # Save to file + result = generate_report( + "AI research", + output_file="report.md", + settings_snapshot=settings + ) """ logger.info("Generating comprehensive research report for query: %s", query) + # Only create settings snapshot if not already provided (programmatic API) + if "settings_snapshot" not in kwargs: + # Build kwargs for create_settings_snapshot from explicit parameters + snapshot_kwargs = {} + if provider is not None: + snapshot_kwargs["provider"] = provider + if api_key is not None: + snapshot_kwargs["api_key"] = api_key + if temperature is not None: + snapshot_kwargs["temperature"] = temperature + if max_search_results is not None: + snapshot_kwargs["max_search_results"] = max_search_results + + # Create settings snapshot for programmatic use + kwargs["settings_snapshot"] = create_settings_snapshot( + base_settings=settings, + overrides=settings_override, + **snapshot_kwargs, + ) + log_settings( + kwargs["settings_snapshot"], + "Created settings snapshot for programmatic API", + ) + # Register retrievers if provided if retrievers: from ..web_search_engines.retriever_registry import retriever_registry @@ -263,6 +418,7 @@ def generate_report( return report +@no_db_settings def detailed_research( query: str, research_id: Optional[Union[int, str]] = None, @@ -311,9 +467,6 @@ def detailed_research( register_llm(name, llm_instance) logger.info(f"Registered {len(llms)} LLMs: {list(llms.keys())}") - # Set search context - from ..metrics.search_tracker import set_search_context - search_context = { "research_id": research_id, "research_query": query, @@ -341,14 +494,15 @@ def detailed_research( "formatted_findings": results.get("formatted_findings", ""), "sources": results.get("all_links_of_system", []), "metadata": { - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "search_tool": kwargs.get("search_tool", "auto"), "iterations_requested": kwargs.get("iterations", 1), "strategy": kwargs.get("search_strategy", "source_based"), }, } +@no_db_settings def analyze_documents( query: str, collection_name: str,
src/local_deep_research/api/settings_utils.py+280 −0 added@@ -0,0 +1,280 @@ +""" +Utilities for managing settings in the programmatic API. + +This module provides functions to create settings snapshots for the API +without requiring database access, reusing the same mechanisms as the +web interface. +""" + +import os +import copy +from typing import Any, Dict, Optional, Union +from loguru import logger + +from ..settings import SettingsManager +from ..settings.base import ISettingsManager + + +class InMemorySettingsManager(ISettingsManager): + """ + In-memory settings manager that doesn't require database access. + + This is used for the programmatic API to provide settings without + needing a database connection. + """ + + # Type mapping from UI elements to Python types (same as SettingsManager) + _UI_ELEMENT_TO_SETTING_TYPE = { + "text": str, + # JSON should already be parsed + "json": lambda x: x, + "password": str, + "select": str, + "number": float, + "range": float, + "checkbox": bool, + } + + def __init__(self): + """Initialize with default settings from JSON file.""" + # Create a base manager to get default settings + self._base_manager = SettingsManager(db_session=None) + self._settings = {} + self._load_defaults() + + def _get_typed_value(self, setting_data: Dict[str, Any], value: Any) -> Any: + """ + Convert a value to the appropriate type based on the setting's ui_element. + + Args: + setting_data: The setting metadata containing ui_element + value: The value to convert + + Returns: + The typed value, or the original value if conversion fails + """ + ui_element = setting_data.get("ui_element", "text") + setting_type = self._UI_ELEMENT_TO_SETTING_TYPE.get(ui_element) + + if setting_type is None: + logger.warning( + f"Unknown ui_element type: {ui_element}, returning value as-is" + ) + return value + + try: + # Special handling for checkbox/bool with string values + if ui_element == "checkbox" and isinstance(value, str): + return value.lower() in ("true", "1", "yes", "on") + return setting_type(value) + except (ValueError, TypeError) as e: + logger.warning( + f"Failed to convert value {value} to type {setting_type}: {e}" + ) + return value + + def _load_defaults(self): + """Load default settings from the JSON file.""" + # Get default settings from the base manager + defaults = self._base_manager.default_settings + + # Convert to the format expected by get_all_settings + for key, setting_data in defaults.items(): + self._settings[key] = setting_data.copy() + + # Check environment variable override + env_key = f"LDR_{key.upper().replace('.', '_')}" + env_value = os.environ.get(env_key) + if env_value is not None: + # Use the typed value conversion + self._settings[key]["value"] = self._get_typed_value( + setting_data, env_value + ) + + def get_setting( + self, key: str, default: Any = None, check_env: bool = True + ) -> Any: + """Get a setting value.""" + if key in self._settings: + setting_data = self._settings[key] + value = setting_data.get("value", default) + # Ensure the value has the correct type + return self._get_typed_value(setting_data, value) + return default + + def set_setting(self, key: str, value: Any, commit: bool = True) -> bool: + """Set a setting value (in memory only).""" + if key in self._settings: + # Validate and convert the value to the correct type + typed_value = self._get_typed_value(self._settings[key], value) + self._settings[key]["value"] = typed_value + return True + return False + + def get_all_settings(self) -> Dict[str, Any]: + """Get all settings with metadata.""" + return copy.deepcopy(self._settings) + + def load_from_defaults_file( + self, commit: bool = True, **kwargs: Any + ) -> None: + """Reload defaults (already done in __init__).""" + self._load_defaults() + + def create_or_update_setting( + self, setting: Union[Dict[str, Any], Any], commit: bool = True + ) -> Optional[Any]: + """Create or update a setting (in memory only).""" + if isinstance(setting, dict) and "key" in setting: + key = setting["key"] + # If the setting has a value, ensure it has the correct type + if "value" in setting: + typed_value = self._get_typed_value(setting, setting["value"]) + setting = setting.copy() # Don't modify the original + setting["value"] = typed_value + self._settings[key] = setting + return setting + return None + + def delete_setting(self, key: str, commit: bool = True) -> bool: + """Delete a setting (in memory only).""" + if key in self._settings: + del self._settings[key] + return True + return False + + def import_settings( + self, + settings_data: Dict[str, Any], + commit: bool = True, + overwrite: bool = True, + delete_extra: bool = False, + ) -> None: + """Import settings from a dictionary.""" + if delete_extra: + self._settings.clear() + + for key, value in settings_data.items(): + if overwrite or key not in self._settings: + # Ensure proper type handling for imported settings + if isinstance(value, dict) and "value" in value: + typed_value = self._get_typed_value(value, value["value"]) + value = value.copy() + value["value"] = typed_value + self._settings[key] = value + + +def get_default_settings_snapshot() -> Dict[str, Any]: + """ + Get a complete settings snapshot with default values. + + This uses the same mechanism as the web interface but without + requiring database access. Environment variables are checked + for overrides. + + Returns: + Dict mapping setting keys to their values and metadata + """ + manager = InMemorySettingsManager() + return manager.get_all_settings() + + +def create_settings_snapshot( + base_settings: Optional[Dict[str, Any]] = None, + overrides: Optional[Dict[str, Any]] = None, + **kwargs, +) -> Dict[str, Any]: + """ + Create a settings snapshot for the programmatic API. + + Args: + base_settings: Base settings dict (defaults to get_default_settings_snapshot()) + overrides: Dict of setting overrides (e.g., {"llm.provider": "openai"}) + **kwargs: Common setting shortcuts: + - provider: Maps to "llm.provider" + - api_key: Maps to "llm.{provider}.api_key" + - temperature: Maps to "llm.temperature" + - max_search_results: Maps to "search.max_results" + - search_engines: Maps to enabled search engines + + Returns: + Complete settings snapshot for use with the API + """ + # Start with base settings or defaults + if base_settings is None: + settings = get_default_settings_snapshot() + else: + settings = copy.deepcopy(base_settings) + + # Apply overrides if provided + if overrides: + for key, value in overrides.items(): + if key in settings: + if isinstance(settings[key], dict) and "value" in settings[key]: + settings[key]["value"] = value + else: + settings[key] = value + else: + # Create a simple setting entry for unknown keys + settings[key] = {"value": value} + + # Handle common kwargs shortcuts + if "provider" in kwargs: + provider = kwargs["provider"] + if "llm.provider" in settings: + settings["llm.provider"]["value"] = provider + else: + settings["llm.provider"] = {"value": provider} + + # Handle api_key if provided + if "api_key" in kwargs: + api_key = kwargs["api_key"] + api_key_setting = f"llm.{provider}.api_key" + if api_key_setting in settings: + settings[api_key_setting]["value"] = api_key + else: + settings[api_key_setting] = {"value": api_key} + + if "temperature" in kwargs: + if "llm.temperature" in settings: + settings["llm.temperature"]["value"] = kwargs["temperature"] + else: + settings["llm.temperature"] = {"value": kwargs["temperature"]} + + if "max_search_results" in kwargs: + if "search.max_results" in settings: + settings["search.max_results"]["value"] = kwargs[ + "max_search_results" + ] + else: + settings["search.max_results"] = { + "value": kwargs["max_search_results"] + } + + # Add any other common shortcuts here... + + return settings + + +def extract_setting_value( + settings_snapshot: Dict[str, Any], key: str, default: Any = None +) -> Any: + """ + Extract a setting value from a settings snapshot. + + Args: + settings_snapshot: Settings snapshot dict + key: Setting key (e.g., "llm.provider") + default: Default value if not found + + Returns: + The setting value + """ + if settings_snapshot is None: + return default + if key in settings_snapshot: + setting = settings_snapshot[key] + if isinstance(setting, dict) and "value" in setting: + return setting["value"] + return setting + return default
src/local_deep_research/benchmarks/benchmark_functions.py+21 −21 modified@@ -4,10 +4,13 @@ This module provides functions for running benchmarks programmatically. """ -import logging -import os +from pathlib import Path from typing import Any, Dict, List, Optional +from loguru import logger + +from ..config.thread_settings import get_setting_from_snapshot + from ..benchmarks import ( calculate_metrics, generate_report, @@ -16,8 +19,6 @@ run_simpleqa_benchmark, ) -logger = logging.getLogger(__name__) - def evaluate_simpleqa( num_examples: int = 100, @@ -71,12 +72,12 @@ def evaluate_simpleqa( if endpoint_url: search_config["openai_endpoint_url"] = endpoint_url - # Check environment variables for additional configuration - if env_model := os.environ.get("LDR_SEARCH_MODEL"): + # Check settings for additional configuration + if env_model := get_setting_from_snapshot("llm.model"): search_config["model_name"] = env_model - if env_provider := os.environ.get("LDR_SEARCH_PROVIDER"): + if env_provider := get_setting_from_snapshot("llm.provider"): search_config["provider"] = env_provider - if env_url := os.environ.get("LDR_ENDPOINT_URL"): + if env_url := get_setting_from_snapshot("llm.openai_endpoint.url"): search_config["openai_endpoint_url"] = env_url # Set up evaluation configuration if needed @@ -159,12 +160,12 @@ def evaluate_browsecomp( if endpoint_url: search_config["openai_endpoint_url"] = endpoint_url - # Check environment variables for additional configuration - if env_model := os.environ.get("LDR_SEARCH_MODEL"): + # Check settings for additional configuration + if env_model := get_setting_from_snapshot("llm.model"): search_config["model_name"] = env_model - if env_provider := os.environ.get("LDR_SEARCH_PROVIDER"): + if env_provider := get_setting_from_snapshot("llm.provider"): search_config["provider"] = env_provider - if env_url := os.environ.get("LDR_ENDPOINT_URL"): + if env_url := get_setting_from_snapshot("llm.openai_endpoint.url"): search_config["openai_endpoint_url"] = env_url # Set up evaluation configuration if needed @@ -260,9 +261,8 @@ def compare_configurations( ] # Create output directory - import os - os.makedirs(output_dir, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) # Run benchmarks for each configuration results = [] @@ -285,7 +285,7 @@ def compare_configurations( benchmark_result = run_benchmark( dataset_type=dataset_type, num_examples=num_examples, - output_dir=os.path.join(output_dir, config_name.replace(" ", "_")), + output_dir=str(Path(output_dir) / config_name.replace(" ", "_")), search_config=search_config, run_evaluation=True, ) @@ -300,8 +300,8 @@ def compare_configurations( import time timestamp = time.strftime("%Y%m%d_%H%M%S") - report_file = os.path.join( - output_dir, f"comparison_{dataset_type}_{timestamp}.md" + report_file = str( + Path(output_dir) / f"comparison_{dataset_type}_{timestamp}.md" ) with open(report_file, "w") as f: @@ -347,11 +347,11 @@ def compare_configurations( # Export the API functions __all__ = [ - "evaluate_simpleqa", + "calculate_metrics", + "compare_configurations", "evaluate_browsecomp", + "evaluate_simpleqa", + "generate_report", "get_available_benchmarks", - "compare_configurations", "run_benchmark", # For advanced users - "calculate_metrics", - "generate_report", ]
src/local_deep_research/benchmarks/cli/benchmark_commands.py+13 −12 modified@@ -5,16 +5,18 @@ """ import argparse -import logging +# import logging - replaced with loguru +import sys +from loguru import logger + +from ...config.paths import get_data_directory from .. import ( get_available_datasets, run_browsecomp_benchmark, run_simpleqa_benchmark, ) -logger = logging.getLogger(__name__) - def setup_benchmark_parser(subparsers): """ @@ -52,8 +54,8 @@ def setup_benchmark_parser(subparsers): benchmark_parent.add_argument( "--output-dir", type=str, - default="data/benchmark_results", - help="Directory to save results (default: data/benchmark_results)", + default=str(get_data_directory() / "benchmark_results"), + help="Directory to save results (default: user data directory/benchmark_results)", ) benchmark_parent.add_argument( "--human-eval", @@ -135,7 +137,7 @@ def setup_benchmark_parser(subparsers): compare_parser.add_argument( "--output-dir", type=str, - default="data/benchmark_results/comparison", + default=str(get_data_directory() / "benchmark_results" / "comparison"), help="Directory to save comparison results", ) compare_parser.set_defaults(func=compare_configs_cli) @@ -337,12 +339,11 @@ def main(): # Parse arguments args = parser.parse_args() - # Set up logging - log_level = logging.DEBUG if args.verbose else logging.INFO - logging.basicConfig( - level=log_level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) + # Set up logging with loguru + if args.verbose: + logger.add(sys.stderr, level="DEBUG") + else: + logger.add(sys.stderr, level="INFO") # Run command if hasattr(args, "func"):
src/local_deep_research/benchmarks/cli/__init__.py+1 −1 modified@@ -5,8 +5,8 @@ running benchmarks and optimization tasks. """ -from .benchmark_commands import main as benchmark_main from .benchmark_commands import ( + main as benchmark_main, setup_benchmark_parser, )
src/local_deep_research/benchmarks/cli.py+13 −14 modified@@ -6,21 +6,20 @@ """ import argparse -import logging + +# import logging - replaced with loguru +from loguru import logger import os +from pathlib import Path import sys -from datetime import datetime +from datetime import datetime, UTC +from ..config.paths import get_data_directory from .comparison import compare_configurations from .efficiency import ResourceMonitor, SpeedProfiler from .optimization import optimize_parameters -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +# Configure logging - using loguru instead def parse_args(): @@ -50,7 +49,7 @@ def parse_args(): optimize_parser.add_argument("query", help="Research query to optimize for") optimize_parser.add_argument( "--output-dir", - default="data/optimization_results", + default=str(get_data_directory() / "optimization_results"), help="Directory to save results", ) optimize_parser.add_argument("--model", help="Model name for the LLM") @@ -192,7 +191,7 @@ def run_comparison(args): logger.error("No configurations found in the file") return 1 except Exception as e: - logger.error(f"Error loading configurations file: {str(e)}") + logger.exception(f"Error loading configurations file: {e!s}") return 1 # Run comparison @@ -311,9 +310,9 @@ def run_profiling(args): print(f"Average CPU: {resource_results.get('process_cpu_avg', 0):.1f}%") # Save results - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - args.output_dir, f"profiling_results_{timestamp}.json" + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + results_file = str( + Path(args.output_dir) / f"profiling_results_{timestamp}.json" ) with open(results_file, "w") as f: @@ -349,7 +348,7 @@ def run_profiling(args): speed_profiler.stop() resource_monitor.stop() - logger.error(f"Error during profiling: {str(e)}") + logger.exception(f"Error during profiling: {e!s}") return 1
src/local_deep_research/benchmarks/comparison/evaluator.py+26 −28 modified@@ -6,14 +6,15 @@ """ import json -import logging import os -from datetime import datetime +from datetime import datetime, UTC +from pathlib import Path from typing import Any, Dict, List, Optional import matplotlib.pyplot as plt -from matplotlib.patches import Circle, RegularPolygon import numpy as np +from loguru import logger +from matplotlib.patches import Circle, RegularPolygon from local_deep_research.benchmarks.efficiency.resource_monitor import ( ResourceMonitor, @@ -31,8 +32,6 @@ from local_deep_research.config.search_config import get_search from local_deep_research.search_system import AdvancedSearchSystem -logger = logging.getLogger(__name__) - def compare_configurations( query: str, @@ -110,8 +109,8 @@ def compare_configurations( logger.info(f"Completed repetition {rep + 1} for {config_name}") except Exception as e: - logger.error( - f"Error in {config_name}, repetition {rep + 1}: {str(e)}" + logger.exception( + f"Error in {config_name}, repetition {rep + 1}: {e!s}" ) # Add error info but continue with other configurations config_results.append({"error": str(e), "success": False}) @@ -181,22 +180,21 @@ def compare_configurations( ), "repetitions": repetitions, "metric_weights": metric_weights, - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "results": sorted_results, } # Save results to file - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - result_file = os.path.join( - output_dir, f"comparison_results_{timestamp}.json" - ) + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + result_file = str(Path(output_dir) / f"comparison_results_{timestamp}.json") with open(result_file, "w") as f: json.dump(comparison_report, f, indent=2) # Generate visualizations - visualizations_dir = os.path.join(output_dir, "visualizations") - os.makedirs(visualizations_dir, exist_ok=True) + visualizations_dir = Path(output_dir) / "visualizations" + visualizations_dir.mkdir(parents=True, exist_ok=True) + visualizations_dir = str(visualizations_dir) _create_comparison_visualizations( comparison_report, output_dir=visualizations_dir, timestamp=timestamp @@ -330,7 +328,7 @@ def _evaluate_single_configuration( resource_monitor.stop() # Log the error - logger.error(f"Error evaluating configuration: {str(e)}") + logger.exception("Error evaluating configuration") # Return error information return { @@ -443,7 +441,7 @@ def _create_comparison_visualizations( plt.grid(axis="x", linestyle="--", alpha=0.7) plt.tight_layout() plt.savefig( - os.path.join(output_dir, f"overall_score_comparison_{timestamp}.png") + str(Path(output_dir) / f"overall_score_comparison_{timestamp}.png") ) plt.close() @@ -455,7 +453,7 @@ def _create_comparison_visualizations( quality_metrics, "quality_metrics", "Quality Metrics Comparison", - os.path.join(output_dir, f"quality_metrics_comparison_{timestamp}.png"), + str(Path(output_dir) / f"quality_metrics_comparison_{timestamp}.png"), ) # 3. Speed metrics comparison @@ -466,7 +464,7 @@ def _create_comparison_visualizations( speed_metrics, "speed_metrics", "Speed Metrics Comparison", - os.path.join(output_dir, f"speed_metrics_comparison_{timestamp}.png"), + str(Path(output_dir) / f"speed_metrics_comparison_{timestamp}.png"), ) # 4. Resource metrics comparison @@ -481,22 +479,20 @@ def _create_comparison_visualizations( resource_metrics, "resource_metrics", "Resource Usage Comparison", - os.path.join( - output_dir, f"resource_metrics_comparison_{timestamp}.png" - ), + str(Path(output_dir) / f"resource_metrics_comparison_{timestamp}.png"), ) # 5. Spider chart for multi-dimensional comparison _create_spider_chart( successful_results, config_names, - os.path.join(output_dir, f"spider_chart_comparison_{timestamp}.png"), + str(Path(output_dir) / f"spider_chart_comparison_{timestamp}.png"), ) # 6. Pareto frontier chart for quality vs. speed _create_pareto_chart( successful_results, - os.path.join(output_dir, f"pareto_chart_comparison_{timestamp}.png"), + str(Path(output_dir) / f"pareto_chart_comparison_{timestamp}.png"), ) @@ -726,13 +722,13 @@ def unit_poly_verts(num_vars): plt.close() except Exception as e: - logger.error(f"Error creating spider chart: {str(e)}") + logger.exception("Error creating spider chart") # Create a text-based chart as fallback plt.figure(figsize=(10, 6)) plt.text( 0.5, 0.5, - f"Spider chart could not be created: {str(e)}", + f"Spider chart could not be created: {e!s}", horizontalalignment="center", verticalalignment="center", ) @@ -781,9 +777,9 @@ def _create_pareto_chart(results: List[Dict[str, Any]], output_path: str): # Identify Pareto frontier pareto_points = [] - for i, (q, s) in enumerate(zip(quality_scores, speed_scores)): + for i, (q, s) in enumerate(zip(quality_scores, speed_scores, strict=False)): is_pareto = True - for q2, s2 in zip(quality_scores, speed_scores): + for q2, s2 in zip(quality_scores, speed_scores, strict=False): if q2 > q and s2 > s: # Dominated is_pareto = False break @@ -795,7 +791,9 @@ def _create_pareto_chart(results: List[Dict[str, Any]], output_path: str): pareto_speed = [speed_scores[i] for i in pareto_points] # Sort pareto points for line drawing - pareto_sorted = sorted(zip(pareto_quality, pareto_speed, pareto_points)) + pareto_sorted = sorted( + zip(pareto_quality, pareto_speed, pareto_points, strict=False) + ) pareto_quality = [p[0] for p in pareto_sorted] pareto_speed = [p[1] for p in pareto_sorted] pareto_indices = [p[2] for p in pareto_sorted]
src/local_deep_research/benchmarks/datasets/base.py+5 −7 modified@@ -5,15 +5,13 @@ with benchmark datasets in a maintainable, extensible way. """ -import logging +from loguru import logger import random from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional import pandas as pd -logger = logging.getLogger(__name__) - class BenchmarkDataset(ABC): """Base class for all benchmark datasets. @@ -119,8 +117,8 @@ def load(self) -> List[Dict[str, Any]]: try: processed = self.process_example(example) processed_examples.append(processed) - except Exception as e: - logger.error(f"Error processing example {i}: {e}") + except Exception: + logger.exception(f"Error processing example {i}") # Sample if needed if self.num_examples and self.num_examples < len( @@ -141,8 +139,8 @@ def load(self) -> List[Dict[str, Any]]: self._is_loaded = True return self.examples - except Exception as e: - logger.error(f"Error loading dataset: {e}") + except Exception: + logger.exception("Error loading dataset") raise def get_examples(self) -> List[Dict[str, Any]]:
src/local_deep_research/benchmarks/datasets/browsecomp.py+4 −5 modified@@ -5,14 +5,13 @@ which contains encrypted data that needs special handling. """ -import logging from typing import Any, Dict +from loguru import logger + from .base import BenchmarkDataset from .utils import decrypt, get_known_answer_map -logger = logging.getLogger(__name__) - class BrowseCompDataset(BenchmarkDataset): """BrowseComp benchmark dataset. @@ -122,8 +121,8 @@ def process_example(self, example: Dict[str, Any]) -> Dict[str, Any]: processed["correct_answer"] = decrypted_answer logger.debug(f"Final answer: {decrypted_answer[:50]}...") - except Exception as e: - logger.error(f"Error decrypting example: {e}") + except Exception: + logger.exception("Error decrypting example") return processed
src/local_deep_research/benchmarks/datasets/custom_dataset_template.py+1 −3 modified@@ -5,13 +5,11 @@ Copy this file and modify it to create your own dataset class. """ -import logging +from loguru import logger from typing import Any, Dict from .base import BenchmarkDataset -logger = logging.getLogger(__name__) - class CustomDataset(BenchmarkDataset): """Template for a custom benchmark dataset.
src/local_deep_research/benchmarks/datasets.py+1 −3 modified@@ -14,11 +14,9 @@ 3. Use a manual mapping for specific encrypted strings that have been verified """ -from .datasets import load_dataset - # Re-export the get_available_datasets function # Re-export the default dataset URLs -from .datasets import DEFAULT_DATASET_URLS, get_available_datasets +from .datasets import DEFAULT_DATASET_URLS, get_available_datasets, load_dataset # Re-export the load_dataset function __all__ = ["DEFAULT_DATASET_URLS", "get_available_datasets", "load_dataset"]
src/local_deep_research/benchmarks/datasets/simpleqa.py+2 −3 modified@@ -4,12 +4,11 @@ This module provides a class for the SimpleQA benchmark dataset. """ -import logging from typing import Any, Dict -from .base import BenchmarkDataset +from loguru import logger -logger = logging.getLogger(__name__) +from .base import BenchmarkDataset class SimpleQADataset(BenchmarkDataset):
src/local_deep_research/benchmarks/datasets/utils.py+12 −8 modified@@ -7,10 +7,9 @@ import base64 import hashlib -import logging from typing import Dict -logger = logging.getLogger(__name__) +from loguru import logger def derive_key(password: str, length: int) -> bytes: @@ -32,7 +31,8 @@ def decrypt(ciphertext_b64: str, password: str) -> str: # Skip if the string doesn't look like base64 if not all( - c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" + c + in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" # pragma: allowlist secret for c in ciphertext_b64 ): return ciphertext_b64 @@ -41,7 +41,7 @@ def decrypt(ciphertext_b64: str, password: str) -> str: try: encrypted = base64.b64decode(ciphertext_b64) key = derive_key(password, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes(a ^ b for a, b in zip(encrypted, key, strict=False)) # Check if the result looks like valid text result = decrypted.decode("utf-8", errors="replace") @@ -53,15 +53,17 @@ def decrypt(ciphertext_b64: str, password: str) -> str: ) return result except Exception as e: - logger.debug(f"Standard decryption failed: {str(e)}") + logger.debug(f"Standard decryption failed: {e!s}") # Alternative method - try using just the first part of the password try: if len(password) > 30: alt_password = password.split()[0] # Use first word encrypted = base64.b64decode(ciphertext_b64) key = derive_key(alt_password, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes( + a ^ b for a, b in zip(encrypted, key, strict=False) + ) result = decrypted.decode("utf-8", errors="replace") if ( @@ -81,7 +83,9 @@ def decrypt(ciphertext_b64: str, password: str) -> str: guid_part = password.split("GUID")[1].strip() encrypted = base64.b64decode(ciphertext_b64) key = derive_key(guid_part, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes( + a ^ b for a, b in zip(encrypted, key, strict=False) + ) result = decrypted.decode("utf-8", errors="replace") if ( @@ -100,7 +104,7 @@ def decrypt(ciphertext_b64: str, password: str) -> str: hardcoded_key = "MHGGF2022!" # Known key for BrowseComp dataset encrypted = base64.b64decode(ciphertext_b64) key = derive_key(hardcoded_key, len(encrypted)) - decrypted = bytes(a ^ b for a, b in zip(encrypted, key)) + decrypted = bytes(a ^ b for a, b in zip(encrypted, key, strict=False)) result = decrypted.decode("utf-8", errors="replace") if all(32 <= ord(c) < 127 for c in result[:50]) and " " in result[:50]:
src/local_deep_research/benchmarks/efficiency/__init__.py+1 −1 modified@@ -13,6 +13,6 @@ ) __all__ = [ - "SpeedProfiler", "ResourceMonitor", + "SpeedProfiler", ]
src/local_deep_research/benchmarks/efficiency/resource_monitor.py+3 −4 modified@@ -5,13 +5,12 @@ system resource usage during the research process. """ -import logging import threading import time from contextlib import contextmanager from typing import Any, Dict -logger = logging.getLogger(__name__) +from loguru import logger # Try to import psutil, but don't fail if not available try: @@ -161,7 +160,7 @@ def _monitor_resources(self): ) except Exception as e: - logger.error(f"Error monitoring resources: {str(e)}") + logger.exception(f"Error monitoring resources: {e!s}") # Sleep until next sampling interval time.sleep(self.sampling_interval) @@ -415,5 +414,5 @@ def check_system_resources() -> Dict[str, Any]: return result except Exception as e: - logger.error(f"Error checking system resources: {str(e)}") + logger.exception(f"Error checking system resources: {e!s}") return {"error": str(e), "available": False}
src/local_deep_research/benchmarks/efficiency/speed_profiler.py+1 −3 modified@@ -5,13 +5,11 @@ of different components and processes in the research system. """ -import logging +from loguru import logger import time from contextlib import contextmanager from typing import Any, Callable, Dict -logger = logging.getLogger(__name__) - class SpeedProfiler: """
src/local_deep_research/benchmarks/evaluators/base.py+4 −7 modified@@ -5,13 +5,10 @@ must implement, establishing a common interface for different benchmark types. """ -import logging -import os from abc import ABC, abstractmethod +from pathlib import Path from typing import Any, Dict -logger = logging.getLogger(__name__) - class BaseBenchmarkEvaluator(ABC): """ @@ -69,6 +66,6 @@ def _create_subdirectory(self, output_dir: str) -> str: Returns: Path to the benchmark-specific directory """ - benchmark_dir = os.path.join(output_dir, self.name) - os.makedirs(benchmark_dir, exist_ok=True) - return benchmark_dir + benchmark_dir = Path(output_dir) / self.name + benchmark_dir.mkdir(parents=True, exist_ok=True) + return str(benchmark_dir)
src/local_deep_research/benchmarks/evaluators/browsecomp.py+3 −4 modified@@ -5,14 +5,13 @@ benchmark, which tests browsing comprehension capabilities. """ -import logging from typing import Any, Dict +from loguru import logger + from ..runners import run_browsecomp_benchmark from .base import BaseBenchmarkEvaluator -logger = logging.getLogger(__name__) - class BrowseCompEvaluator(BaseBenchmarkEvaluator): """ @@ -74,7 +73,7 @@ def evaluate( } except Exception as e: - logger.error(f"Error in BrowseComp evaluation: {str(e)}") + logger.exception(f"Error in BrowseComp evaluation: {e!s}") # Return error information return {
src/local_deep_research/benchmarks/evaluators/composite.py+4 −5 modified@@ -5,15 +5,14 @@ with weighted scores to provide a comprehensive evaluation. """ -import logging from typing import Any, Dict, Optional +from loguru import logger + # Import specific evaluator implementations from .browsecomp import BrowseCompEvaluator from .simpleqa import SimpleQAEvaluator -logger = logging.getLogger(__name__) - class CompositeBenchmarkEvaluator: """ @@ -107,8 +106,8 @@ def evaluate( combined_score += weighted_contribution except Exception as e: - logger.error( - f"Error running {benchmark_name} benchmark: {str(e)}" + logger.exception( + f"Error running {benchmark_name} benchmark: {e!s}" ) all_results[benchmark_name] = { "benchmark_type": benchmark_name,
src/local_deep_research/benchmarks/evaluators/__init__.py+1 −1 modified@@ -12,7 +12,7 @@ __all__ = [ "BaseBenchmarkEvaluator", - "SimpleQAEvaluator", "BrowseCompEvaluator", "CompositeBenchmarkEvaluator", + "SimpleQAEvaluator", ]
src/local_deep_research/benchmarks/evaluators/simpleqa.py+11 −14 modified@@ -6,19 +6,16 @@ """ import json -import logging -import os +from loguru import logger +from pathlib import Path import time from typing import Any, Dict - from ..datasets.base import DatasetRegistry from ..metrics import calculate_metrics, generate_report from ..runners import run_simpleqa_benchmark # Keep for backward compatibility from .base import BaseBenchmarkEvaluator -logger = logging.getLogger(__name__) - class SimpleQAEvaluator(BaseBenchmarkEvaluator): """ @@ -89,7 +86,7 @@ def evaluate( } except Exception as e: - logger.error(f"Error in SimpleQA evaluation: {str(e)}") + logger.exception(f"Error in SimpleQA evaluation: {e!s}") # Return error information return { @@ -134,14 +131,14 @@ def _run_with_dataset_class( # Set up output files timestamp = time.strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - output_dir, f"simpleqa_{timestamp}_results.jsonl" + results_file = str( + Path(output_dir) / f"simpleqa_{timestamp}_results.jsonl" ) - evaluation_file = os.path.join( - output_dir, f"simpleqa_{timestamp}_evaluation.jsonl" + evaluation_file = str( + Path(output_dir) / f"simpleqa_{timestamp}_evaluation.jsonl" ) - report_file = os.path.join( - output_dir, f"simpleqa_{timestamp}_report.md" + report_file = str( + Path(output_dir) / f"simpleqa_{timestamp}_report.md" ) # Process each example @@ -222,7 +219,7 @@ def _run_with_dataset_class( f.write(json.dumps(result) + "\n") except Exception as e: - logger.error(f"Error processing example {i + 1}: {str(e)}") + logger.exception(f"Error processing example {i + 1}: {e!s}") # Create error result error_result = { @@ -286,7 +283,7 @@ def _run_with_dataset_class( } except Exception as e: - logger.error(f"Error in direct dataset evaluation: {str(e)}") + logger.exception(f"Error in direct dataset evaluation: {e!s}") return { "status": "error", "dataset_type": "simpleqa",
src/local_deep_research/benchmarks/graders.py+38 −15 modified@@ -5,8 +5,8 @@ """ import json -import logging -import os +from loguru import logger +from pathlib import Path import re from typing import Any, Callable, Dict, List, Optional @@ -15,7 +15,6 @@ from ..config.llm_config import get_llm from .templates import BROWSECOMP_GRADER_TEMPLATE, SIMPLEQA_GRADER_TEMPLATE -logger = logging.getLogger(__name__) # Default evaluation configuration using Claude 3.7 Sonnet via OpenRouter DEFAULT_EVALUATION_CONFIG = { @@ -27,13 +26,17 @@ } -def get_evaluation_llm(custom_config: Optional[Dict[str, Any]] = None): +def get_evaluation_llm( + custom_config: Optional[Dict[str, Any]] = None, + settings_snapshot: Optional[Dict[str, Any]] = None, +): """ Get an LLM for evaluation purposes using Claude 3.7 Sonnet via OpenRouter by default, which can be overridden with custom settings. Args: custom_config: Optional custom configuration that overrides defaults + settings_snapshot: Optional settings snapshot for thread-safe access Returns: An LLM instance for evaluation @@ -65,10 +68,26 @@ def get_evaluation_llm(custom_config: Optional[Dict[str, Any]] = None): # Check if we're using openai_endpoint but don't have an API key configured if filtered_config.get("provider") == "openai_endpoint": - # Try to get API key from database settings first, then environment - from ..utilities.db_utils import get_db_setting + # Try to get API key from settings snapshot or environment + api_key = None - api_key = get_db_setting("llm.openai_endpoint.api_key") + if settings_snapshot: + # Get from settings snapshot for thread safety + api_key_setting = settings_snapshot.get( + "llm.openai_endpoint.api_key" + ) + if api_key_setting: + api_key = ( + api_key_setting.get("value") + if isinstance(api_key_setting, dict) + else api_key_setting + ) + else: + # No settings snapshot available + logger.warning( + "No settings snapshot provided for benchmark grader. " + "API key must be provided via settings_snapshot for thread safety." + ) if not api_key: logger.warning( @@ -122,6 +141,7 @@ def grade_single_result( result_data: Dict[str, Any], dataset_type: str = "simpleqa", evaluation_config: Optional[Dict[str, Any]] = None, + settings_snapshot: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Grade a single benchmark result using LLM. @@ -130,12 +150,13 @@ def grade_single_result( result_data: Dictionary containing result data with keys: id, problem, correct_answer, response, extracted_answer dataset_type: Type of dataset evaluation_config: Optional custom config for evaluation LLM + settings_snapshot: Optional settings snapshot for thread-safe access Returns: Dictionary with grading results """ # Get evaluation LLM - evaluation_llm = get_evaluation_llm(evaluation_config) + evaluation_llm = get_evaluation_llm(evaluation_config, settings_snapshot) # Select appropriate template template = ( @@ -253,12 +274,12 @@ def grade_single_result( return graded_result except Exception as e: - logger.error(f"Error grading single result: {str(e)}") + logger.exception(f"Error grading single result: {e!s}") return { "grading_error": str(e), "is_correct": False, "graded_confidence": "0", - "grader_response": f"Grading failed: {str(e)}", + "grader_response": f"Grading failed: {e!s}", } @@ -300,8 +321,9 @@ def grade_results( results.append(json.loads(line)) # Remove output file if it exists - if os.path.exists(output_file): - os.remove(output_file) + output_path = Path(output_file) + if output_path.exists(): + output_path.unlink() graded_results = [] correct_count = 0 @@ -447,7 +469,7 @@ def grade_results( ) except Exception as e: - logger.error(f"Error grading result {idx + 1}: {str(e)}") + logger.exception(f"Error grading result {idx + 1}: {e!s}") # Handle error error_result = result.copy() @@ -499,8 +521,9 @@ def human_evaluation( results.append(json.loads(line)) # Remove output file if it exists - if os.path.exists(output_file): - os.remove(output_file) + output_path = Path(output_file) + if output_path.exists(): + output_path.unlink() human_graded_results = [] correct_count = 0
src/local_deep_research/benchmarks/metrics/calculation.py+10 −14 modified@@ -6,15 +6,13 @@ """ import json -import logging -import os +from loguru import logger +from pathlib import Path import tempfile import time -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict, Optional -logger = logging.getLogger(__name__) - def calculate_metrics(results_file: str) -> Dict[str, Any]: """ @@ -34,7 +32,7 @@ def calculate_metrics(results_file: str) -> Dict[str, Any]: if line.strip(): results.append(json.loads(line)) except Exception as e: - logger.error(f"Error loading results file: {e}") + logger.exception("Error loading results file") return {"error": str(e)} if not results: @@ -57,7 +55,7 @@ def calculate_metrics(results_file: str) -> Dict[str, Any]: # Average confidence if available confidence_values = [] for r in results: - if "confidence" in r and r["confidence"]: + if r.get("confidence"): try: confidence_values.append(int(r["confidence"])) except (ValueError, TypeError): @@ -83,7 +81,7 @@ def calculate_metrics(results_file: str) -> Dict[str, Any]: "average_confidence": avg_confidence, "error_count": error_count, "error_rate": error_rate, - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(UTC).isoformat(), } # If we have category information, calculate per-category metrics @@ -171,20 +169,18 @@ def evaluate_benchmark_quality( } except Exception as e: - logger.error(f"Error in benchmark evaluation: {str(e)}") + logger.exception(f"Error in benchmark evaluation: {e!s}") return {"accuracy": 0.0, "quality_score": 0.0, "error": str(e)} finally: # Clean up temporary directory if we created it - if temp_dir and os.path.exists(temp_dir): + if temp_dir and Path(temp_dir).exists(): import shutil try: shutil.rmtree(temp_dir) except Exception as e: - logger.warning( - f"Failed to clean up temporary directory: {str(e)}" - ) + logger.warning(f"Failed to clean up temporary directory: {e!s}") def measure_execution_time( @@ -252,7 +248,7 @@ def measure_execution_time( } except Exception as e: - logger.error(f"Error in speed measurement: {str(e)}") + logger.exception(f"Error in speed measurement: {e!s}") return {"average_time": 0.0, "speed_score": 0.0, "error": str(e)}
src/local_deep_research/benchmarks/metrics/__init__.py+2 −2 modified@@ -15,10 +15,10 @@ from .reporting import generate_report __all__ = [ + "calculate_combined_score", "calculate_metrics", "calculate_quality_metrics", - "calculate_speed_metrics", "calculate_resource_metrics", - "calculate_combined_score", + "calculate_speed_metrics", "generate_report", ]
src/local_deep_research/benchmarks/metrics/reporting.py+8 −6 modified@@ -5,11 +5,13 @@ """ import json -import logging -from datetime import datetime + +# import logging - replaced with loguru +from loguru import logger +from datetime import datetime, UTC from typing import Any, Dict, Optional -logger = logging.getLogger(__name__) +# logger = logging.getLogger(__name__) - using loguru logger directly def generate_report( @@ -39,8 +41,8 @@ def generate_report( for line in f: if line.strip(): results.append(json.loads(line)) - except Exception as e: - logger.error(f"Error loading results for report: {e}") + except Exception: + logger.exception("Error loading results for report") results = [] # Sample up to 5 correct and 5 incorrect examples @@ -140,7 +142,7 @@ def generate_report( ) # Add timestamp - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") report.extend( [ "## Metadata",
src/local_deep_research/benchmarks/metrics/visualization.py+2 −3 modified@@ -5,12 +5,11 @@ of benchmark and optimization results. """ -import logging +from loguru import logger from typing import Dict, List, Optional import numpy as np -logger = logging.getLogger(__name__) # Check if matplotlib is available try: @@ -207,7 +206,7 @@ def plot_quality_vs_speed( # Sort pareto points by speed score pareto_points.sort() if pareto_points: - pareto_x, pareto_y = zip(*pareto_points) + pareto_x, pareto_y = zip(*pareto_points, strict=False) ax.plot(pareto_x, pareto_y, "k--", label="Pareto Frontier") ax.scatter(pareto_x, pareto_y, c="red", s=50, alpha=0.8) except Exception as e:
src/local_deep_research/benchmarks/models/__init__.py+3 −3 modified@@ -1,6 +1,6 @@ """Benchmark database models for ORM.""" -from .benchmark_models import ( +from ...database.models import ( BenchmarkConfig, BenchmarkProgress, BenchmarkResult, @@ -10,10 +10,10 @@ ) __all__ = [ - "BenchmarkRun", - "BenchmarkResult", "BenchmarkConfig", "BenchmarkProgress", + "BenchmarkResult", + "BenchmarkRun", "BenchmarkStatus", "DatasetType", ]
src/local_deep_research/benchmarks/optimization/api.py+5 −8 modified@@ -5,20 +5,17 @@ without having to directly work with the optimizer classes. """ -import logging -import os +from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple # No metrics imports needed here, they're used in the OptunaOptimizer from .optuna_optimizer import OptunaOptimizer -logger = logging.getLogger(__name__) - def optimize_parameters( query: str, param_space: Optional[Dict[str, Any]] = None, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -82,7 +79,7 @@ def optimize_parameters( def optimize_for_speed( query: str, n_trials: int = 20, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -148,7 +145,7 @@ def optimize_for_speed( def optimize_for_quality( query: str, n_trials: int = 30, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -193,7 +190,7 @@ def optimize_for_quality( def optimize_for_efficiency( query: str, n_trials: int = 25, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None,
src/local_deep_research/benchmarks/optimization/__init__.py+6 −6 modified@@ -23,12 +23,12 @@ __all__ = [ "OptunaOptimizer", - "optimize_parameters", - "optimize_for_speed", - "optimize_for_quality", - "optimize_for_efficiency", + "calculate_combined_score", "calculate_quality_metrics", - "calculate_speed_metrics", "calculate_resource_metrics", - "calculate_combined_score", + "calculate_speed_metrics", + "optimize_for_efficiency", + "optimize_for_quality", + "optimize_for_speed", + "optimize_parameters", ]
src/local_deep_research/benchmarks/optimization/metrics.py+2 −2 modified@@ -13,8 +13,8 @@ ) __all__ = [ + "calculate_combined_score", "calculate_quality_metrics", - "calculate_speed_metrics", "calculate_resource_metrics", - "calculate_combined_score", + "calculate_speed_metrics", ]
src/local_deep_research/benchmarks/optimization/optuna_optimizer.py+73 −63 modified@@ -7,10 +7,10 @@ """ import json -import logging import os +from pathlib import Path import time -from datetime import datetime +from datetime import datetime, UTC from functools import partial from typing import Any, Callable, Dict, List, Optional, Tuple @@ -27,14 +27,14 @@ from local_deep_research.benchmarks.efficiency.speed_profiler import ( SpeedProfiler, ) +from loguru import logger + from local_deep_research.benchmarks.evaluators import ( CompositeBenchmarkEvaluator, ) # Import benchmark evaluator components -logger = logging.getLogger(__name__) - # Try to import visualization libraries, but don't fail if not available try: import matplotlib.pyplot as plt @@ -124,7 +124,7 @@ def __init__( } # Generate a unique study name if not provided - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") self.study_name = study_name or f"ldr_opt_{timestamp}" # Create output directory @@ -358,7 +358,7 @@ def _objective( "result": result, "score": result.get("score", 0), "duration": duration, - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(UTC).isoformat(), } self.trials_history.append(trial_info) @@ -384,7 +384,7 @@ def _objective( return result["score"] except Exception as e: - logger.error(f"Error in trial {trial.number}: {str(e)}") + logger.exception(f"Error in trial {trial.number}: {e!s}") # Update callback with error if self.progress_callback: @@ -440,7 +440,7 @@ def _run_experiment(self, params: Dict[str, Any]) -> Dict[str, Any]: # Evaluate quality using composite benchmark evaluator # Use a small number of examples for efficiency - benchmark_dir = os.path.join(self.output_dir, "benchmark_temp") + benchmark_dir = str(Path(self.output_dir) / "benchmark_temp") quality_results = self.benchmark_evaluator.evaluate( system_config=system_config, num_examples=5, # Small number for optimization efficiency @@ -482,7 +482,7 @@ def _run_experiment(self, params: Dict[str, Any]) -> Dict[str, Any]: speed_profiler.stop() # Log error - logger.error(f"Error in experiment: {str(e)}") + logger.exception(f"Error in experiment: {e!s}") # Return error information return {"error": str(e), "score": 0.0, "success": False} @@ -503,11 +503,11 @@ def _optimization_callback(self, study: optuna.Study, trial: optuna.Trial): def _save_results(self): """Save the optimization results to disk.""" # Create a timestamp for filenames - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") # Save trial history - history_file = os.path.join( - self.output_dir, f"{self.study_name}_history.json" + history_file = str( + Path(self.output_dir) / f"{self.study_name}_history.json" ) with open(history_file, "w") as f: # Convert numpy values to native Python types for JSON serialization @@ -534,8 +534,8 @@ def _save_results(self): and hasattr(self.study, "best_params") and self.study.best_params ): - best_params_file = os.path.join( - self.output_dir, f"{self.study_name}_best_params.json" + best_params_file = str( + Path(self.output_dir) / f"{self.study_name}_best_params.json" ) with open(best_params_file, "w") as f: json.dump( @@ -557,8 +557,8 @@ def _save_results(self): # Save the Optuna study if self.study: - study_file = os.path.join( - self.output_dir, f"{self.study_name}_study.pkl" + study_file = str( + Path(self.output_dir) / f"{self.study_name}_study.pkl" ) joblib.dump(self.study, study_file) @@ -577,8 +577,9 @@ def _create_visualizations(self): return # Create directory for visualizations - viz_dir = os.path.join(self.output_dir, "visualizations") - os.makedirs(viz_dir, exist_ok=True) + viz_dir = Path(self.output_dir) / "visualizations" + viz_dir.mkdir(parents=True, exist_ok=True) + viz_dir = str(viz_dir) # Create Optuna visualizations self._create_optuna_visualizations(viz_dir) @@ -598,20 +599,21 @@ def _create_quick_visualizations(self): return # Create directory for visualizations - viz_dir = os.path.join(self.output_dir, "visualizations") - os.makedirs(viz_dir, exist_ok=True) + viz_dir = Path(self.output_dir) / "visualizations" + viz_dir.mkdir(parents=True, exist_ok=True) + viz_dir = str(viz_dir) # Create optimization history only (faster than full visualization) try: fig = plot_optimization_history(self.study) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_optimization_history_current.png", + str( + Path(viz_dir) + / f"{self.study_name}_optimization_history_current.png" ) ) except Exception as e: - logger.error(f"Error creating optimization history plot: {str(e)}") + logger.exception(f"Error creating optimization history plot: {e!s}") def _create_optuna_visualizations(self, viz_dir: str): """ @@ -620,44 +622,46 @@ def _create_optuna_visualizations(self, viz_dir: str): Args: viz_dir: Directory to save visualizations """ - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") # 1. Optimization history try: fig = plot_optimization_history(self.study) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_optimization_history_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_optimization_history_{timestamp}.png" ) ) except Exception as e: - logger.error(f"Error creating optimization history plot: {str(e)}") + logger.exception(f"Error creating optimization history plot: {e!s}") # 2. Parameter importances try: fig = plot_param_importances(self.study) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_param_importances_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_param_importances_{timestamp}.png" ) ) except Exception as e: - logger.error(f"Error creating parameter importances plot: {str(e)}") + logger.exception( + f"Error creating parameter importances plot: {e!s}" + ) # 3. Slice plot for each parameter try: for param_name in self.study.best_params.keys(): fig = plot_slice(self.study, [param_name]) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_slice_{param_name}_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_slice_{param_name}_{timestamp}.png" ) ) except Exception as e: - logger.error(f"Error creating slice plots: {str(e)}") + logger.exception(f"Error creating slice plots: {e!s}") # 4. Contour plots for important parameter pairs try: @@ -672,17 +676,17 @@ def _create_optuna_visualizations(self, viz_dir: str): self.study, params=[param_names[i], param_names[j]] ) fig.write_image( - os.path.join( - viz_dir, - f"{self.study_name}_contour_{param_names[i]}_{param_names[j]}_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_contour_{param_names[i]}_{param_names[j]}_{timestamp}.png" ) ) except Exception as e: logger.warning( - f"Error creating contour plot for {param_names[i]} vs {param_names[j]}: {str(e)}" + f"Error creating contour plot for {param_names[i]} vs {param_names[j]}: {e!s}" ) except Exception as e: - logger.error(f"Error creating contour plots: {str(e)}") + logger.exception(f"Error creating contour plots: {e!s}") def _create_custom_visualizations(self, viz_dir: str): """ @@ -694,7 +698,7 @@ def _create_custom_visualizations(self, viz_dir: str): if not self.trials_history: return - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") # Create quality vs speed plot self._create_quality_vs_speed_plot(viz_dir, timestamp) @@ -746,7 +750,10 @@ def _create_quality_vs_speed_plot(self, viz_dir: str, timestamp: str): # Create scatter plot with size based on iterations*questions sizes = [ - i * q * 5 for i, q in zip(iterations_values, questions_values) + i * q * 5 + for i, q in zip( + iterations_values, questions_values, strict=False + ) ] scatter = plt.scatter( quality_scores, @@ -781,7 +788,7 @@ def _create_quality_vs_speed_plot(self, viz_dir: str, timestamp: str): # Add annotations for key points for i, (q, s, label) in enumerate( - zip(quality_scores, speed_scores, labels) + zip(quality_scores, speed_scores, labels, strict=False) ): if i % max(1, len(quality_scores) // 5) == 0: # Label ~5 points plt.annotate( @@ -824,14 +831,14 @@ def _create_quality_vs_speed_plot(self, viz_dir: str, timestamp: str): # Save the figure plt.tight_layout() plt.savefig( - os.path.join( - viz_dir, - f"{self.study_name}_quality_vs_speed_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_quality_vs_speed_{timestamp}.png" ) ) plt.close() except Exception as e: - logger.error(f"Error creating quality vs speed plot: {str(e)}") + logger.exception(f"Error creating quality vs speed plot: {e!s}") def _create_parameter_evolution_plots(self, viz_dir: str, timestamp: str): """Create plots showing how parameter values evolve over trials.""" @@ -903,14 +910,14 @@ def _create_parameter_evolution_plots(self, viz_dir: str, timestamp: str): # Save the figure plt.tight_layout() plt.savefig( - os.path.join( - viz_dir, - f"{self.study_name}_param_evolution_{param_name}_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_param_evolution_{param_name}_{timestamp}.png" ) ) plt.close() except Exception as e: - logger.error(f"Error creating parameter evolution plots: {str(e)}") + logger.exception(f"Error creating parameter evolution plots: {e!s}") def _create_duration_vs_score_plot(self, viz_dir: str, timestamp: str): """Create a plot showing trial duration vs score.""" @@ -946,7 +953,8 @@ def _create_duration_vs_score_plot(self, viz_dir: str, timestamp: str): # Total questions per trial total_questions = [ - i * q for i, q in zip(trial_iterations, trial_questions) + i * q + for i, q in zip(trial_iterations, trial_questions, strict=False) ] # Create scatter plot with size based on total questions @@ -968,7 +976,9 @@ def _create_duration_vs_score_plot(self, viz_dir: str, timestamp: str): plt.grid(True, linestyle="--", alpha=0.7) # Add trial number annotations for selected points - for i, (d, s) in enumerate(zip(trial_durations, trial_scores)): + for i, (d, s) in enumerate( + zip(trial_durations, trial_scores, strict=False) + ): if ( i % max(1, len(trial_durations) // 5) == 0 ): # Annotate ~5 points @@ -982,20 +992,20 @@ def _create_duration_vs_score_plot(self, viz_dir: str, timestamp: str): # Save the figure plt.tight_layout() plt.savefig( - os.path.join( - viz_dir, - f"{self.study_name}_duration_vs_score_{timestamp}.png", + str( + Path(viz_dir) + / f"{self.study_name}_duration_vs_score_{timestamp}.png" ) ) plt.close() except Exception as e: - logger.error(f"Error creating duration vs score plot: {str(e)}") + logger.exception(f"Error creating duration vs score plot: {e!s}") def optimize_parameters( query: str, param_space: Optional[Dict[str, Any]] = None, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -1059,7 +1069,7 @@ def optimize_parameters( def optimize_for_speed( query: str, n_trials: int = 20, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -1125,7 +1135,7 @@ def optimize_for_speed( def optimize_for_quality( query: str, n_trials: int = 30, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None, @@ -1170,7 +1180,7 @@ def optimize_for_quality( def optimize_for_efficiency( query: str, n_trials: int = 25, - output_dir: str = os.path.join("data", "optimization_results"), + output_dir: str = str(Path("data") / "optimization_results"), model_name: Optional[str] = None, provider: Optional[str] = None, search_tool: Optional[str] = None,
src/local_deep_research/benchmarks/runners.py+13 −13 modified@@ -5,8 +5,9 @@ """ import json -import logging +from loguru import logger import os +from pathlib import Path import time from typing import Any, Callable, Dict, Optional @@ -17,8 +18,6 @@ from .metrics import calculate_metrics, generate_report from .templates import BROWSECOMP_QUERY_TEMPLATE -logger = logging.getLogger(__name__) - def format_query(question: str, dataset_type: str = "simpleqa") -> str: """ @@ -109,20 +108,21 @@ def run_benchmark( # Set up output files timestamp = time.strftime("%Y%m%d_%H%M%S") - results_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_results.jsonl" + results_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_results.jsonl" ) - evaluation_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_evaluation.jsonl" + evaluation_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_evaluation.jsonl" ) - report_file = os.path.join( - output_dir, f"{dataset_type}_{timestamp}_report.md" + report_file = str( + Path(output_dir) / f"{dataset_type}_{timestamp}_report.md" ) # Make sure output files don't exist for file in [results_file, evaluation_file, report_file]: - if os.path.exists(file): - os.remove(file) + file_path = Path(file) + if file_path.exists(): + file_path.unlink() # Progress tracking total_examples = len(dataset) @@ -245,7 +245,7 @@ def run_benchmark( ) except Exception as e: - logger.error(f"Error processing example {i + 1}: {str(e)}") + logger.exception(f"Error processing example {i + 1}: {e!s}") # Create error result error_result = { @@ -318,7 +318,7 @@ def run_benchmark( ), ) except Exception as e: - logger.error(f"Automated evaluation failed: {str(e)}") + logger.exception(f"Automated evaluation failed: {e!s}") if progress_callback: progress_callback(
src/local_deep_research/benchmarks/web_api/benchmark_routes.py+0 −0 modifiedsrc/local_deep_research/benchmarks/web_api/benchmark_service.py+783 −439 modifiedsrc/local_deep_research/benchmarks/web_api/__init__.py+1 −1 modified@@ -1,6 +1,6 @@ """Benchmark web API package.""" -from .benchmark_service import BenchmarkService from .benchmark_routes import benchmark_bp +from .benchmark_service import BenchmarkService __all__ = ["BenchmarkService", "benchmark_bp"]
src/local_deep_research/citation_handler.py+26 −9 modifiedsrc/local_deep_research/citation_handlers/base_citation_handler.py+12 −1 modifiedsrc/local_deep_research/citation_handlers/forced_answer_citation_handler.py+15 −3 modifiedsrc/local_deep_research/citation_handlers/__init__.py+1 −1 modifiedsrc/local_deep_research/citation_handlers/precision_extraction_handler.py+54 −24 modifiedsrc/local_deep_research/citation_handlers/standard_citation_handler.py+15 −2 modifiedsrc/local_deep_research/cli/__init__.py+0 −0 addedsrc/local_deep_research/config/default_settings/database_settings.py+18 −0 addedsrc/local_deep_research/config/llm_config.py+366 −98 modifiedsrc/local_deep_research/config/paths.py+152 −0 addedsrc/local_deep_research/config/queue_config.py+19 −0 addedsrc/local_deep_research/config/search_config.py+91 −16 modifiedsrc/local_deep_research/config/thread_settings.py+91 −0 addedsrc/local_deep_research/database/auth_db.py+56 −0 addedsrc/local_deep_research/database/credential_store_base.py+117 −0 addedsrc/local_deep_research/database/encrypted_db.py+593 −0 addedsrc/local_deep_research/database/encryption_check.py+112 −0 addedsrc/local_deep_research/database/initialize.py+127 −0 addedsrc/local_deep_research/database/models/active_research.py+53 −0 addedsrc/local_deep_research/database/models/auth.py+39 −0 addedsrc/local_deep_research/database/models/base.py+7 −0 addedsrc/local_deep_research/database/models/benchmark.py+15 −16 renamedsrc/local_deep_research/database/models/cache.py+100 −0 addedsrc/local_deep_research/database/models/__init__.py+117 −0 addedsrc/local_deep_research/database/models/logs.py+74 −0 addedsrc/local_deep_research/database/models/memory_queue.py+285 −0 addedsrc/local_deep_research/database/models/metrics.py+187 −0 addedsrc/local_deep_research/database/models/news.py+262 −0 addedsrc/local_deep_research/database/models/providers.py+33 −0 addedsrc/local_deep_research/database/models/queued_research.py+34 −0 addedsrc/local_deep_research/database/models/queue.py+42 −0 addedsrc/local_deep_research/database/models/rate_limiting.py+52 −0 addedsrc/local_deep_research/database/models/reports.py+124 −0 addedsrc/local_deep_research/database/models/research.py+315 −0 addedsrc/local_deep_research/database/models/settings.py+108 −0 addedsrc/local_deep_research/database/models/user_base.py+9 −0 addedsrc/local_deep_research/database/models/user_news_search_history.py+47 −0 addedsrc/local_deep_research/database/queue_service.py+176 −0 addedsrc/local_deep_research/database/session_context.py+212 −0 addedsrc/local_deep_research/database/session_passwords.py+95 −0 addedsrc/local_deep_research/database/sqlcipher_utils.py+245 −0 addedsrc/local_deep_research/database/temp_auth.py+87 −0 addedsrc/local_deep_research/database/thread_local_session.py+183 −0 addedsrc/local_deep_research/database/thread_metrics.py+166 −0 addedsrc/local_deep_research/defaults/default_settings.json+411 −2 modifiedsrc/local_deep_research/defaults/__init__.py+1 −2 modifiedsrc/local_deep_research/domain_classifier/classifier.py+488 −0 addedsrc/local_deep_research/domain_classifier/__init__.py+6 −0 addedsrc/local_deep_research/domain_classifier/models.py+46 −0 addedsrc/local_deep_research/error_handling/error_reporter.py+27 −2 modifiedsrc/local_deep_research/error_handling/__init__.py+1 −1 modifiedsrc/local_deep_research/error_handling/report_generator.py+5 −4 modifiedsrc/local_deep_research/followup_research/__init__.py+15 −0 addedsrc/local_deep_research/followup_research/models.py+52 −0 addedsrc/local_deep_research/followup_research/routes.py+316 −0 addedsrc/local_deep_research/followup_research/service.py+214 −0 addedsrc/local_deep_research/__init__.py+1 −32 modified@@ -2,47 +2,16 @@ Local Deep Research - A tool for conducting deep research using AI. """ -__author__ = "Your Name" +__author__ = "LearningCircuit" __description__ = "A tool for conducting deep research using AI" from loguru import logger from .__version__ import __version__ -from .config.llm_config import get_llm -from .config.search_config import get_search -from .report_generator import get_report_generator -from .web.app import main -from .web.database.migrations import ensure_database_initialized -from .setup_data_dir import setup_data_dir # Disable logging by default to not interfere with user setup. logger.disable("local_deep_research") -# Initialize database. -setup_data_dir() -ensure_database_initialized() - - -def get_advanced_search_system(strategy_name: str = "iterdrag"): - """ - Get an instance of the advanced search system. - - Args: - strategy_name: The name of the search strategy to use ("standard" or "iterdrag") - - Returns: - AdvancedSearchSystem: An instance of the advanced search system - """ - from .search_system import AdvancedSearchSystem - - return AdvancedSearchSystem(strategy_name=strategy_name) - - __all__ = [ - "get_llm", - "get_search", - "get_report_generator", - "get_advanced_search_system", - "main", "__version__", ]
src/local_deep_research/llm/__init__.py+6 −6 modifiedsrc/local_deep_research/llm/llm_registry.py+21 −15 modifiedsrc/local_deep_research/memory_cache/app_integration.py+36 −0 addedsrc/local_deep_research/memory_cache/cached_services.py+282 −0 addedsrc/local_deep_research/memory_cache/config.py+252 −0 addedsrc/local_deep_research/memory_cache/flask_integration.py+167 −0 addedsrc/local_deep_research/memory_cache/__init__.py+1 −0 addedsrc/local_deep_research/metrics/database.py+47 −34 modifiedsrc/local_deep_research/metrics/db_models.py+0 −111 removedsrc/local_deep_research/metrics/__init__.py+2 −2 modifiedsrc/local_deep_research/metrics/migrate_add_provider_to_token_usage.py+0 −148 removedsrc/local_deep_research/metrics/migrate_call_stack_tracking.py+0 −105 removedsrc/local_deep_research/metrics/migrate_enhanced_tracking.py+0 −75 removedsrc/local_deep_research/metrics/migrate_research_ratings.py+0 −31 removedsrc/local_deep_research/metrics/models.py+0 −61 removedsrc/local_deep_research/metrics/pricing/cost_calculator.py+3 −4 modifiedsrc/local_deep_research/metrics/pricing/__init__.py+1 −1 modifiedsrc/local_deep_research/metrics/pricing/pricing_cache.py+9 −44 modifiedsrc/local_deep_research/metrics/query_utils.py+6 −6 modifiedsrc/local_deep_research/metrics/search_tracker.py+70 −57 modifiedsrc/local_deep_research/metrics/token_counter.py+1294 −427 modifiedsrc/local_deep_research/news/api.py+1300 −0 addedsrc/local_deep_research/news/core/base_card.py+371 −0 addedsrc/local_deep_research/news/core/card_factory.py+349 −0 addedsrc/local_deep_research/news/core/card_storage.py+209 −0 addedsrc/local_deep_research/news/core/__init__.py+0 −0 addedsrc/local_deep_research/news/core/news_analyzer.py+448 −0 addedsrc/local_deep_research/news/core/relevance_service.py+156 −0 addedsrc/local_deep_research/news/core/search_integration.py+158 −0 addedsrc/local_deep_research/news/core/storage_manager.py+456 −0 addedsrc/local_deep_research/news/core/storage.py+269 −0 addedsrc/local_deep_research/news/core/utils.py+53 −0 addedsrc/local_deep_research/news/exceptions.py+191 −0 addedsrc/local_deep_research/news/flask_api.py+1478 −0 addedsrc/local_deep_research/news/__init__.py+40 −0 addedsrc/local_deep_research/__version__.py+1 −1 modified@@ -1 +1 @@ -__version__ = "0.6.7" +__version__ = "1.0.0"
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.