Files
Axon/CLAUDE.md
T
arsam 2d52bc04b2 Import documentation: CLAUDE.md
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:06:55 +03:30

30 KiB
Raw Blame History

MAIN PROMPT: "Build me a private, self-hosted AI search API called "Axon" — a better alternative to Tavily. It should be a FastAPI backend that I can run on my Linux server.

Stack

  • Python 3.11+
  • FastAPI
  • trafilatura (content extraction)
  • httpx (async HTTP)
  • sentence-transformers with BAAI/bge-reranker-v2-m3 (reranking)
  • redis (caching)
  • pydantic v2 (schemas)
  • uvicorn (server)

Search Sources

Implement all three and merge results, deduplicating by URL:

  1. Brave Search API (primary) — via BRAVE_API_KEY env var
  2. DuckDuckGo — using the duckduckgo-search Python package, no key needed
  3. Serper.dev (fallback/secondary) — via SERPER_API_KEY env var, only called if Brave returns fewer than 5 results

Project Structure

axon/ ├── main.py ├── config.py ├── models.py ├── routers/ │ ├── search.py │ ├── extract.py │ └── research.py ├── services/ │ ├── sources/ │ │ ├── brave.py │ │ ├── ddg.py │ │ └── serper.py │ ├── scraper.py │ ├── reranker.py │ ├── synthesizer.py │ └── cache.py ├── middleware/ │ └── auth.py ├── .env.example ├── requirements.txt └── README.md

Endpoints

POST /search

Main search endpoint. Request body: { "query": "string", "max_results": 10, // default 10, max 20 "search_depth": "basic" | "advanced", // basic = no scraping, advanced = full scrape + rerank "include_answer": false, // if true, synthesize an answer via LLM "include_raw_content": false, "domains_whitelist": [], // only return results from these domains "domains_blacklist": [], // never return results from these domains "topic": "general" | "news", "time_range": "day" | "week" | "month" | "year" | null }

Response: { "query": "string", "answer": "string | null", "results": [ { "title": "string", "url": "string", "content": "string", // snippet or extracted text "score": 0.95, // relevance score 0-1 "published_date": "string | null", "favicon": "string | null" } ], "response_time": 1.23, "credits_used": 1 }

POST /extract

Extract clean content from a URL or list of URLs. Request body: { "urls": ["https://example.com"], "include_images": false }

Response: { "results": [ { "url": "string", "title": "string", "content": "string", "success": true, "error": null } ] }

POST /research

Deep research endpoint. Given a topic, auto-generate 3-5 sub-queries, run /search on each, deduplicate, rerank everything together, synthesize a final report. Request body: { "query": "string", "depth": "basic" | "deep", // basic = 3 sub-queries, deep = 5 sub-queries "output_format": "markdown" | "json" }

Response: { "query": "string", "report": "string", // markdown or JSON report "sources": [...], // all sources used "sub_queries": [...], // the generated sub-queries "response_time": 12.4 }

GET /health

Returns server status, redis connection status, reranker model load status.

Core Logic Details

Search Flow (advanced depth)

  1. Query all enabled sources concurrently with asyncio.gather
  2. Merge and deduplicate results by URL
  3. For each result URL, scrape full content using trafilatura (async, with 5s timeout per URL, max 10 concurrent scrapes using asyncio.Semaphore)
  4. Chunk content to max 512 tokens per chunk
  5. Run bge-reranker-v2-m3 to score all chunks against the original query
  6. Take the top-scoring chunk per URL as that result's content
  7. Sort results by score descending
  8. If include_answer is true, send top 5 results as context to LLM via LITELLM_BASE_URL + LITELLM_API_KEY and get a synthesized answer

Search Flow (basic depth)

  1. Same as above steps 1-2 (multi-source fetch + dedup)
  2. Skip scraping — use the snippet returned by the search APIs directly
  3. Still run reranker on snippets
  4. Return results

Caching

  • Use Redis to cache search results for 10 minutes (key = hash of query + params)
  • Cache extracted content per URL for 1 hour
  • If REDIS_URL is not set, fall back to an in-memory dict cache

Auth

  • API key auth via X-API-Key header
  • Keys stored in API_KEYS env var as a comma-separated list
  • If API_KEYS is not set, skip auth entirely (dev mode)

LLM Synthesis

  • Call LLM via LITELLM_BASE_URL + LITELLM_API_KEY (standard OpenAI-compatible format)
  • Model set via SYNTHESIS_MODEL env var, default "claude-sonnet-4-6"
  • System prompt: "You are a precise research assistant. Answer the user's query based only on the provided search results. Cite sources using [1], [2] notation. Be concise and factual."

Config (.env.example)

BRAVE_API_KEY= SERPER_API_KEY= REDIS_URL=redis://localhost:6379 API_KEYS=your-secret-key-here LITELLM_BASE_URL=http://localhost:4000 LITELLM_API_KEY= SYNTHESIS_MODEL=claude-sonnet-4-6 RERANKER_MODEL=BAAI/bge-reranker-v2-m3 PORT=8000 MAX_CONCURRENT_SCRAPES=10 SCRAPE_TIMEOUT=5

Error Handling

  • Every endpoint must return proper HTTP status codes
  • Scraping failures for individual URLs should be silently skipped, not crash the request
  • Source API failures (Brave down, etc.) should be caught and logged, fall through to remaining sources
  • Return 503 only if ALL sources fail

Requirements

  • All I/O must be fully async
  • The reranker model should be loaded once at startup into memory, not per-request
  • Log all requests with query, source count, scrape count, rerank score range, and response time
  • Add CORS middleware allowing all origins
  • Write a clean README with setup instructions and example curl commands for all endpoints"

Axon Search API — Complete Project Reference

What This Is

Axon is a self-hosted, private AI search API — built as a better alternative to Tavily. It is a FastAPI backend meant to run on a Linux server. It aggregates results from multiple search engines, scrapes full page content, reranks with a local ML model, and optionally synthesizes an AI-written answer using any OpenAI-compatible LLM endpoint.

The goal: POST /search with a query → get back ranked, cleaned, optionally AI-answered results. No rate limits you don't control. No external service owning your queries.


How It Is Being Built

The project is being implemented via Subagent-Driven Development (one subagent per task, reviewed after each). The implementation plan lives at:

docs/superpowers/plans/2026-06-18-axon-search-api.md

Progress ledger (survives context compression):

.git/sdd/progress.md

Overall Architecture

Request
  │
  ▼
APIKeyMiddleware          ← checks X-API-Key header
  │
  ├── POST /search        ← routers/search.py
  │     │
  │     ├── get_cache()                  check Redis / in-memory
  │     ├── search_brave() ──┐
  │     ├── search_ddg()   ──┼── asyncio.gather (concurrent)
  │     └── search_serper() ─┘  (serper only if brave < 5 results)
  │           │
  │           ├── deduplicate by URL
  │           ├── filter whitelist/blacklist
  │           ├── scrape_urls() [if advanced depth]
  │           ├── rerank_results()   ← CrossEncoder scores all chunks
  │           ├── synthesize_answer() [if include_answer=True]
  │           └── store in cache, return
  │
  ├── POST /extract       ← routers/extract.py  [NOT BUILT YET]
  │     └── per-URL: httpx fetch → trafilatura extract → cache 1h
  │
  ├── POST /research      ← routers/research.py [NOT BUILT YET]
  │     ├── generate_sub_queries() via LLM
  │     ├── run /search for each sub-query
  │     ├── deduplicate all results
  │     └── synthesize_report()
  │
  └── GET /health         ← main.py             [NOT BUILT YET]
        └── returns redis status + reranker load status

Current Build Status

✅ DONE — 10 of 14 Tasks

All of the following have been implemented, reviewed by a subagent, and fixed where needed.


Task 1 — Project Scaffold ✅

Commits: 556b771..176e389

Files created:

  • requirements.txt — all pip dependencies
  • .env.example — all configurable env vars with defaults
  • .gitignore
  • axon/__init__.py, axon/middleware/__init__.py, axon/routers/__init__.py, axon/services/__init__.py, axon/services/sources/__init__.py
  • tests/__init__.py
  • axon/config.py — Settings class and settings singleton

axon/config.py — the full thing, verbatim:

from typing import Optional
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    brave_api_key: Optional[str] = None
    serper_api_key: Optional[str] = None
    redis_url: Optional[str] = "redis://localhost:6379"
    api_keys: Optional[str] = None
    litellm_base_url: str = "http://localhost:4000"
    litellm_api_key: Optional[str] = None
    synthesis_model: str = "claude-sonnet-4-6"
    reranker_model: str = "BAAI/bge-reranker-v2-m3"
    port: int = 8000
    max_concurrent_scrapes: int = 10
    scrape_timeout: int = 5

    @property
    def api_key_list(self) -> list[str]:
        if not self.api_keys:
            return []
        return [k.strip() for k in self.api_keys.split(",") if k.strip()]

    model_config = {"env_file": ".env", "extra": "ignore"}

settings = Settings()

Task 2 — Pydantic Models ✅

Commits: 176e389..b8e6a51
File: axon/models.py

All 8 schemas. Key ones:

class SearchRequest(BaseModel):
    query: str
    max_results: int = Field(default=10, ge=1, le=20)
    search_depth: Literal["basic", "advanced"] = "basic"
    include_answer: bool = False
    include_raw_content: bool = False
    domains_whitelist: list[str] = []
    domains_blacklist: list[str] = []
    topic: Literal["general", "news"] = "general"
    time_range: Optional[Literal["day", "week", "month", "year"]] = None

class SearchResult(BaseModel):
    title: str
    url: str
    content: str
    score: float = 0.5
    published_date: Optional[str] = None
    favicon: Optional[str] = None

class SearchResponse(BaseModel):
    query: str
    answer: Optional[str] = None
    results: list[SearchResult]
    response_time: float
    credits_used: int = 1

class ExtractRequest(BaseModel):
    urls: list[str]
    include_images: bool = False

class ExtractResult(BaseModel):
    url: str
    title: Optional[str] = None
    content: str = ""
    success: bool
    error: Optional[str] = None

class ExtractResponse(BaseModel):
    results: list[ExtractResult]

class ResearchRequest(BaseModel):
    query: str
    depth: Literal["basic", "deep"] = "basic"
    output_format: Literal["markdown", "json"] = "markdown"

class ResearchResponse(BaseModel):
    query: str
    report: str
    sources: list[SearchResult]
    sub_queries: list[str]
    response_time: float

Task 3 — Cache Service ✅

Commits: b8e6a51..200085c
File: axon/services/cache.py

Two backends with identical async interface:

  • InMemoryCache — dict with TTL timestamps. Used when Redis is unavailable or REDIS_URL unset.
  • RedisCache — wraps redis.asyncio client. Uses setex for TTL.

Key functions:

async def get_cache() -> InMemoryCache | RedisCache:
    # Singleton. Tries Redis on first call; falls back to in-memory on failure.

def make_cache_key(prefix: str, **kwargs) -> str:
    # SHA-256 of JSON-serialized kwargs. Returns "prefix:<hex>"
    # Example: make_cache_key("search", query="hello") → "search:abc123..."

TTLs used by callers (not enforced here):

  • Search results: 600 seconds (10 minutes)
  • Extracted content: 3600 seconds (1 hour)

Task 4 — Brave Search Source ✅

Commits: 200085c..5da0eb9
File: axon/services/sources/brave.py

async def search_brave(
    query: str,
    count: int = 10,
    topic: str = "general",
    time_range: str | None = None,
) -> list[SearchResult]:
  • Returns [] immediately if settings.brave_api_key is falsy
  • Returns [] on any HTTP/network error (never raises)
  • topic="news" → hits /res/v1/news/search; else /res/v1/web/search
  • Freshness param map: day→pd, week→pw, month→pm, year→py
  • Header: X-Subscription-Token
  • Parses description field (falls back to extra_snippets[0]) for content
  • Provides favicon from meta_url.favicon

Task 5 — DDG + Serper Sources ✅

Commits: 5da0eb9..5a13863
Files: axon/services/sources/ddg.py, axon/services/sources/serper.py

DDG:

async def search_ddg(query, count=10, topic="general", time_range=None) -> list[SearchResult]:
  • Uses duckduckgo-search package via asyncio.to_thread() (blocking call)
  • Text results: url = item["href"], content = item["body"]
  • News results: url = item["url"], content = item["body"]
  • Timelimit map: day→d, week→w, month→m, year→y
  • No API key needed

Serper:

async def search_serper(query, count=10, topic="general", time_range=None) -> list[SearchResult]:
  • Returns [] if settings.serper_api_key is falsy
  • topic="news" → https://google.serper.dev/news; else /search
  • TBS map: day→qdr:d, week→qdr:w, month→qdr:m, year→qdr:y
  • Header: X-API-KEY
  • Parses organic or news key in response
  • count capped at 10 for Serper (their limit per call)

Task 6 — Scraper Service ✅ (+ SSRF Fix)

Commits: 5a13863..aaa7ffa, then SSRF fix at 42216c4
File: axon/services/scraper.py

async def scrape_url(url: str, timeout: int = 5) -> str | None:
    # Validates URL first (SSRF check), then fetches, then extracts with trafilatura
    # Returns None on any failure

async def scrape_urls(
    urls: list[str],
    max_concurrent: int = 10,
    timeout: int = 5,
) -> dict[str, str | None]:
    # Concurrent batch scrape with asyncio.Semaphore
    # Individual failures become None values in the dict

SSRF Protection (added mid-build after automated security review flagged it):

  • _is_safe_url(url) runs before every fetch
  • Checks scheme is http or https only
  • Resolves hostname via socket.getaddrinfo, rejects any result that is:
    • Loopback (127.x.x.x, ::1)
    • Private (10.x, 172.16-31.x, 192.168.x)
    • Link-local (169.254.x.x — blocks AWS metadata at 169.254.169.254)
    • Multicast, reserved, or unspecified
  • follow_redirects=False (redirects to internal IPs are a common SSRF bypass)
  • trafilatura called via asyncio.to_thread() (CPU-bound extraction)

Task 7 — Reranker Service ✅

Commits: aaa7ffa..acc7a3c
File: axon/services/reranker.py

_model = None  # loaded at startup, stays in memory

def load_reranker() -> None:
    # Loads BAAI/bge-reranker-v2-m3 CrossEncoder into _model
    # ~1GB download on first run, cached by HuggingFace after that

def get_reranker() -> "CrossEncoder | None":
    return _model

def chunk_text(text: str, max_tokens: int = 512) -> list[str]:
    # Splits into chunks of ~384 words (512 tokens × 0.75 words/token)
    # Empty string → [""]

async def rerank_results(
    query: str,
    contents: list[tuple[str, str]],  # (url, text) pairs
) -> list[tuple[str, str, float]]:
    # For each URL: chunks text, scores all chunks vs query
    # Picks best chunk per URL (argmax)
    # Sigmoid-normalizes raw CrossEncoder logits to [0, 1]
    # Returns sorted list of (url, best_chunk, score) — descending
    # model.predict() runs via asyncio.to_thread() (blocking/CPU)

Score normalization: sigmoid(x) = 1 / (1 + exp(-x)) — maps CrossEncoder logits to 0–1 range.


Task 8 — Synthesizer Service ✅

Commits: acc7a3c..6d9a057
File: axon/services/synthesizer.py

All LLM calls go to settings.litellm_base_url + "/chat/completions" (standard OpenAI format). Authorization: Bearer <key> header is only set if settings.litellm_api_key is non-empty.

System prompt used for all synthesis:

"You are a precise research assistant. Answer the user's query based only on the provided search results. Cite sources using [1], [2] notation. Be concise and factual."

async def synthesize_answer(query: str, results: list[SearchResult]) -> str:
    # Uses top 5 results as context
    # Returns "No search results available..." if results is empty
    # Returns "Answer synthesis failed: <error>" on LLM error

async def generate_sub_queries(query: str, count: int = 3) -> list[str]:
    # Asks LLM to output exactly `count` queries, one per line
    # On LLM error: returns [query, f"{query} overview", f"{query} recent developments"][:count]

async def synthesize_report(query, results, output_format="markdown") -> str:
    # Uses top 20 results as context
    # markdown format → instructs "## Summary, ## Key Findings, ## Conclusion"
    # json format → instructs keys: summary, key_findings, conclusions
    # Returns "Report synthesis failed: <error>" on LLM error
    # 60s timeout (longer than answer synthesis at 30s)

Task 9 — Auth Middleware ✅

Commits: 6d9a057..bc49aa1
File: axon/middleware/auth.py

_EXEMPT = {"/health", "/docs", "/openapi.json", "/redoc"}

class APIKeyMiddleware(BaseHTTPMiddleware):
    # If settings.api_key_list is empty → pass everything through (dev mode)
    # If path in _EXEMPT → pass through regardless
    # Otherwise → require X-API-Key header matching one of the keys
    # Invalid/missing → 401 {"detail": "Invalid or missing API key"}

api_key_list is a @property on Settings that computes from settings.api_keys (a raw comma-separated string). When monkeypatching in tests, patch settings.api_keys (the underlying field), not api_key_list (the property).


Task 10 — Search Router ✅ (commit done, review pending)

Commit: 4193bba
File: axon/routers/search.py

Exports deduplicate_results() — used by the research router (Task 12).

Full search flow, in order:

  1. Build cache key from all request params → check cache; return immediately if hit
  2. search_brave + search_ddg via asyncio.gather (concurrent)
  3. If len(brave_results) < 5: also call search_serper
  4. Merge all: brave + ddg + serper
  5. If all empty: raise HTTPException(503)
  6. deduplicate_results() → keep first URL occurrence
  7. _filter() → apply domains_whitelist / domains_blacklist
  8. Slice to max_results
  9. If search_depth == "advanced": scrape_urls() → replace result.content with scraped text where available
  10. rerank_results(query, [(url, content), ...]) → updates scores
  11. Sort by score descending
  12. If not include_raw_content: truncate content to 500 chars
  13. If include_answer: synthesize_answer()
  14. Log: query, source_count, scrape_count, [min_score, max_score], response_time
  15. Store response in cache (TTL=600), return

❌ NOT DONE — Tasks 11–14

These four files do not exist yet:


Task 11 — Extract Router ❌

File to create: axon/routers/extract.py
Endpoint: POST /extract

What it needs to do:

  • Accept ExtractRequest(urls: list[str], include_images: bool)
  • For each URL: check cache (key="extract:<sha256 of url>", TTL=3600), fetch if miss
  • Fetch via httpx, extract text with trafilatura.extract() via asyncio.to_thread()
  • Also try trafilatura.extract_metadata() to get title
  • Individual URL failures: ExtractResult(success=False, error="...")
  • Return ExtractResponse(results=[...])
  • All URLs processed concurrently via asyncio.gather
  • Cache successful extractions for 1 hour

Task 12 — Research Router ❌

File to create: axon/routers/research.py
Endpoint: POST /research

What it needs to do:

  • Accept ResearchRequest(query, depth, output_format)
  • depth="basic" → 3 sub-queries; depth="deep" → 5 sub-queries
  • Call generate_sub_queries(query, count) from synthesizer
  • Run search() from routers/search.py for each sub-query (with search_depth="advanced", include_raw_content=True)
  • Sub-query searches run concurrently via asyncio.gather(..., return_exceptions=True)
  • Failed sub-query searches are skipped (logged, not crashed)
  • Merge all results → deduplicate_results()
  • Call synthesize_report(query, all_results, output_format)
  • Return ResearchResponse(query, report, sources, sub_queries, response_time)

Import note: from axon.routers.search import search, deduplicate_results — calling the Python function directly, not making an HTTP request.


Task 13 — Main App + Health ❌

File to create: axon/main.py

What it needs to do:

from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

@asynccontextmanager
async def lifespan(app):
    load_reranker()   # loads BAAI/bge-reranker-v2-m3 once at startup
    yield

app = FastAPI(title="Axon Search API", version="1.0.0", lifespan=lifespan)

# CORS: allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
# Auth: add APIKeyMiddleware
# Routers: include search.router, extract.router, research.router

@app.get("/health")
async def health():
    cache = await get_cache()
    return {
        "status": "ok",
        "redis": "connected" | "in-memory fallback",
        "reranker": "loaded" | "not loaded",
    }

To run the server:

uvicorn axon.main:app --host 0.0.0.0 --port 8000

Task 14 — README ❌

File to create: README.md

Must include: setup steps, env var table, curl examples for all 4 endpoints (search, extract, research, health), source fallback logic explanation, performance notes.


File Tree — Current State

/srv/Axon/
├── .env.example
├── .gitignore
├── CLAUDE.md                        ← this file
├── requirements.txt
├── docs/
│   └── superpowers/plans/
│       └── 2026-06-18-axon-search-api.md
├── axon/
│   ├── __init__.py
│   ├── config.py                    ✅
│   ├── models.py                    ✅
│   ├── main.py                      ❌ MISSING
│   ├── middleware/
│   │   ├── __init__.py
│   │   └── auth.py                  ✅
│   ├── routers/
│   │   ├── __init__.py
│   │   ├── search.py                ✅
│   │   ├── extract.py               ❌ MISSING
│   │   └── research.py              ❌ MISSING
│   └── services/
│       ├── __init__.py
│       ├── cache.py                 ✅
│       ├── reranker.py              ✅
│       ├── scraper.py               ✅ (includes SSRF protection)
│       ├── synthesizer.py           ✅
│       └── sources/
│           ├── __init__.py
│           ├── brave.py             ✅
│           ├── ddg.py               ✅
│           └── serper.py            ✅
└── tests/
    ├── __init__.py
    ├── test_config.py               ✅  3 tests
    ├── test_models.py               ✅  6 tests
    ├── test_cache.py                ✅  7 tests
    ├── test_sources.py              ✅ 10 tests (brave + ddg + serper)
    ├── test_scraper.py              ✅  4 tests
    ├── test_reranker.py             ✅  7 tests
    ├── test_synthesizer.py          ✅  4 tests
    ├── test_auth.py                 ✅  5 tests
    ├── test_search_router.py        ✅  4 tests
    ├── test_extract_router.py       ❌ MISSING
    └── test_research_router.py      ❌ MISSING (covered in test_search_router.py per plan)

Total tests passing right now: 50


Environment & Running Tests

# The venv is at /srv/Axon/venv (Python 3.14)
source /srv/Axon/venv/bin/activate

# Run all tests
python -m pytest tests/ -v

# Run a specific file
python -m pytest tests/test_search_router.py -v

# pytest-asyncio version: 1.4.0 (strict mode)
# ALL async tests must be decorated with @pytest.mark.asyncio

Known warnings (not errors):

  • Starlette currently emits a TestClient deprecation warning about its httpx integration.

Environment Variables (from .env.example)

Variable Required Default Purpose
BRAVE_API_KEY No — Enables Brave search source
SERPER_API_KEY No — Enables Serper fallback source
REDIS_URL No redis://localhost:6379 Cache backend; omit for in-memory
API_KEYS No — Comma-separated auth keys; unset = no auth
LITELLM_BASE_URL No http://localhost:4000 OpenAI-compatible LLM endpoint
LITELLM_API_KEY No — Bearer token for LLM endpoint
SYNTHESIS_MODEL No claude-sonnet-4-6 Model used for answer/report synthesis
RERANKER_MODEL No BAAI/bge-reranker-v2-m3 HuggingFace CrossEncoder model
PORT No 8000 uvicorn listen port
MAX_CONCURRENT_SCRAPES No 10 asyncio.Semaphore limit for scraping
SCRAPE_TIMEOUT No 5 Per-URL timeout in seconds

Coding Rules (enforced across all tasks)

  1. Python 3.11+ types only — str | None not Optional[str], list[T] not List[T], dict[K,V] not Dict[K,V]. No imports from typing for these.
  2. Pydantic v2 — model_dump() and model_dump_json() only. Never .dict() or .json().
  3. All I/O is async — httpx.AsyncClient, redis.asyncio, asyncio.to_thread() for blocking calls.
  4. Never raise from service functions — sources, scraper, synthesizer all catch all exceptions and return empty/None/error strings.
  5. Tests use @pytest.mark.asyncio — no anyio markers.
  6. No Optional imports — this kept appearing in subagent output and was fixed each time.

Gotchas & Lessons Learned During Build

  • @pytest.mark.anyio vs asyncio — subagents kept using anyio. Every test file was checked for this. Always @pytest.mark.asyncio.
  • Optional[str] vs str | None — the plan's own code examples had Optional in them (for illustration). Subagents copied it. Had to fix in Tasks 3, 6.
  • importlib.reload in tests — one subagent used it to reset module state for monkeypatching. Fragile and banned. Replaced with direct monkeypatch.setattr().
  • SSRF — automated security review flagged scraper.py after Task 6's commit. Fixed in Task 7's commit window by adding _is_safe_url() with full IP blocklist.
  • Review of auth tests — reviewer incorrectly flagged monkeypatching settings.api_keys as wrong (said it should patch api_key_list). But api_key_list is a @property — you can't patch it directly. Patching the underlying field is correct. Confirmed all 5 tests pass.
  • Venv path — the project uses /srv/Axon/venv. pytest-asyncio there is version 1.4.0. Must source venv/bin/activate before running any Python commands.

Current Final State

Tasks 11-14 are now complete:

  1. axon/routers/extract.py implements POST /extract with per-URL caching, trafilatura extraction, metadata title extraction, concurrent processing, and SSRF protection via the scraper URL safety check.
  2. axon/routers/research.py implements POST /research by generating sub-queries, running advanced searches concurrently, skipping failed sub-searches, deduplicating sources, and synthesizing a final report.
  3. axon/main.py creates the runnable FastAPI app with CORS, API key middleware, all routers, lifespan reranker loading, and GET /health.
  4. README.md documents setup, configuration, endpoint curl examples, source fallback behavior, caching, and performance notes.

Additional cleanup:

  • axon/config.py and axon/models.py now use str | None style types instead of Optional[...].
  • axon/services/cache.py now uses timezone-aware UTC datetimes, removing the Python 3.14 datetime.utcnow() warning.
  • axon/services/sources/ddg.py now prefers the current ddgs package and falls back to duckduckgo_search; ddgs>=9.14.0 is in requirements.txt.
  • axon/services/reranker.py serializes model prediction and caps reranking to 4 chunks per document. This prevents /research from generating unbounded cross-encoder work on huge pages.
  • axon/routers/research.py runs sub-searches sequentially with 5 results per sub-query. This avoids OOM on the current server while preserving deep multi-query research behavior.
  • axon/middleware/rate_limit.py adds per-key/IP rate limiting.
  • axon/middleware/request_context.py adds X-Request-ID and records lightweight metrics.
  • axon/routers/crawl.py and axon/routers/map.py add Tavily-style bounded same-site crawl/map endpoints.
  • axon/services/scraper.py now has reusable safe fetch, extraction, URL normalization, and same-site link discovery helpers with retries.
  • axon/services/index.py adds a private SQLite FTS index; POST /ingest, POST /index/search, and normal /search can use private indexed content.
  • axon/services/jobs.py and axon/routers/jobs.py add in-process background jobs for research/crawl/map.
  • axon/routers/tavily.py adds /tavily/search, /tavily/extract, /tavily/crawl, and /tavily/map compatibility routes.
  • axon/routers/validate.py and citation helpers add basic citation coverage validation.
  • Browser extraction fallback is optional via ENABLE_BROWSER_FALLBACK=true if Playwright is installed.

Systemd deployment completed:

  • Unit file: /etc/systemd/system/axon.service
  • Enabled: yes
  • Runtime env file: /srv/Axon/.env (ignored by git, mode 600)
  • LiteLLM model configured: AR/openai/gpt-oss-120b:free
  • Service command: /srv/Axon/venv/bin/uvicorn axon.main:app --host 0.0.0.0 --port 8000

Live endpoint verification completed through systemd:

  • GET /health -> 200, Redis connected, reranker loaded
  • GET /openapi.json -> 200, paths include /extract, /health, /metrics, /crawl, /map, /research, /search
  • POST /extract with https://example.com -> 200
  • POST /search basic with answer synthesis -> 200 using LiteLLM
  • POST /search advanced -> 200, completed in about 28 seconds
  • POST /research basic markdown -> 200, 15 sources, completed in about 163 seconds

Operational note:

  • The BGE reranker is memory-heavy. Before reranker chunk capping, /research OOM-killed the service at roughly 4.2 GB. After capping and sequential research searches, the tested /research run peaked around 2 GB and completed successfully.

The git branch is master. The project is runnable with:

source /srv/Axon/venv/bin/activate
uvicorn axon.main:app --host 0.0.0.0 --port 8000

To run a full test suite health check right now:

source /srv/Axon/venv/bin/activate && python -m pytest tests/ -v

Expected: 61 passed.