Import documentation: CLAUDE.md
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,816 @@
|
||||
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:
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
```python
|
||||
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`
|
||||
|
||||
```python
|
||||
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:**
|
||||
```python
|
||||
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:**
|
||||
```python
|
||||
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`
|
||||
|
||||
```python
|
||||
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`
|
||||
|
||||
```python
|
||||
_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."
|
||||
|
||||
```python
|
||||
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`
|
||||
|
||||
```python
|
||||
_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:
|
||||
```python
|
||||
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:
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
source /srv/Axon/venv/bin/activate && python -m pytest tests/ -v
|
||||
```
|
||||
Expected: **61 passed**.
|
||||
Reference in New Issue
Block a user