This commit is contained in:
retoor 2026-01-29 03:16:39 +01:00
parent 5dd6e8ebfd
commit 4971d36204
10 changed files with 512 additions and 241 deletions

View File

@ -1,10 +1,12 @@
from typing import Dict, List, Any
import logging
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse
from ..core import Cache, ExaClient, OpenRouterClient, MarkdownRenderer, settings
logger = logging.getLogger(__name__)
def _format_results_as_markdown(query: str, results: List[Dict]) -> str:
if not results:
@ -43,60 +45,159 @@ async def home(request: Request):
@router.get("/ai", response_class=HTMLResponse)
async def search_ai(request: Request, q: str = Query(""), num: int = 10):
query = q.strip() if q else ""
if not query:
return request.state.templates.TemplateResponse("home.html", {"request": request})
cached = cache.get(query, "ai", num)
if cached:
markdown_answer = cached.get("markdown_answer", "")
html_answer = renderer.render(markdown_answer)
search_results = cached.get("search_results", [])
search_type = cached.get("search_type", "web")
else:
try:
search_data = exa.answer_ai(query, num)
search_results = search_data.get("results", [])
search_type = search_data.get("search_type", "web")
markdown_answer = openrouter.format_results_as_markdown(query, search_results)
logger.info(f"AI search request for: '{query}' (num={num})")
try:
cached = cache.get(query, "ai", num)
if cached:
logger.info(f"Cache hit for AI search: '{query}'")
markdown_answer = cached.get("markdown_answer", "")
html_answer = renderer.render(markdown_answer)
cache.set(query, "ai", num, {
"html_answer": html_answer,
search_results = cached.get("search_results", [])
search_type = cached.get("search_type", "web")
else:
logger.info(f"Cache miss for AI search: '{query}'. Fetching from Exa.")
try:
search_data = exa.answer_ai(query, num)
search_results = search_data.get("results", [])
search_type = search_data.get("search_type", "web")
if not search_results:
logger.warning(f"No results found from Exa for: '{query}'")
raise Exception("No results found from Exa")
logger.info(f"Generating AI answer with OpenRouter for: '{query}'")
markdown_answer = await openrouter.format_results_as_markdown(query, search_results)
html_answer = renderer.render(markdown_answer)
cache.set(query, "ai", num, {
"html_answer": html_answer,
"search_results": search_results,
"search_type": search_type,
"markdown_answer": markdown_answer
})
except Exception as e:
logger.error(f"AI search failed, falling back to web search for: '{query}'. Error: {e}")
# Fallback to standard web search
fallback_results = exa.search_web(query, num)
markdown_answer = _format_results_as_markdown(query, fallback_results)
html_answer = renderer.render(markdown_answer)
result_data = {
"html_answer": html_answer,
"search_results": fallback_results,
"search_type": "web",
"markdown_answer": markdown_answer,
"fallback": True
}
cache.set(query, "ai", num, result_data)
search_results = fallback_results
search_type = "web"
return request.state.templates.TemplateResponse(
"results_ai.html",
{
"request": request,
"query": query,
"answer": html_answer,
"search_results": search_results,
"search_type": search_type,
"markdown_answer": markdown_answer
})
except Exception as e:
fallback_results = exa.search_web(query, num)
markdown_answer = _format_results_as_markdown(query, fallback_results)
html_answer = renderer.render(markdown_answer)
cache.set(query, "ai", num, {
"html_answer": html_answer,
"search_results": fallback_results,
"search_type": "web",
"markdown_answer": markdown_answer,
"fallback": True
})
search_results = fallback_results
search_type = "web"
return request.state.templates.TemplateResponse(
"results_ai.html",
{
"request": request,
"query": query,
"answer": html_answer,
"search_results": search_results,
"search_type": search_type
}
)
"search_type": search_type
}
)
except Exception as e:
logger.error(f"Critical error in /ai endpoint: {e}")
return request.state.templates.TemplateResponse(
"home.html",
{"request": request, "error": "An internal error occurred. Please try again."}
)
@router.get("/api/ai")
@ -109,56 +210,67 @@ async def api_ai(q: str = Query(""), num: int = Query(10)):
"query": ""
}
cached = cache.get(query, "ai", num)
if cached:
markdown_answer = cached.get("markdown_answer", "")
html_answer = renderer.render(markdown_answer)
search_results = cached.get("search_results", [])
search_type = cached.get("search_type", "web")
return {
"html_answer": html_answer,
"markdown_answer": markdown_answer,
"search_results": search_results,
"search_type": search_type,
"cached": True
}
try:
search_data = exa.answer_ai(query, num)
search_results = search_data.get("results", [])
search_type = search_data.get("search_type", "web")
cached = cache.get(query, "ai", num)
markdown_answer = openrouter.format_results_as_markdown(query, search_results)
html_answer = renderer.render(markdown_answer)
if cached:
markdown_answer = cached.get("markdown_answer", "")
html_answer = renderer.render(markdown_answer)
search_results = cached.get("search_results", [])
search_type = cached.get("search_type", "web")
return {
"html_answer": html_answer,
"markdown_answer": markdown_answer,
"search_results": search_results,
"search_type": search_type,
"cached": True
}
result = {
"html_answer": html_answer,
"markdown_answer": markdown_answer,
"search_results": search_results,
"search_type": search_type,
"cached": False
}
try:
search_data = exa.answer_ai(query, num)
search_results = search_data.get("results", [])
search_type = search_data.get("search_type", "web")
cache.set(query, "ai", num, result)
return result
if not search_results:
raise Exception("No results found from Exa")
markdown_answer = await openrouter.format_results_as_markdown(query, search_results)
html_answer = renderer.render(markdown_answer)
result = {
"html_answer": html_answer,
"markdown_answer": markdown_answer,
"search_results": search_results,
"search_type": search_type,
"cached": False
}
cache.set(query, "ai", num, result)
return result
except Exception as e:
logger.error(f"API AI search failed, falling back: {e}")
fallback_results = exa.search_web(query, num)
markdown_answer = _format_results_as_markdown(query, fallback_results)
html_answer = renderer.render(markdown_answer)
result = {
"html_answer": html_answer,
"markdown_answer": markdown_answer,
"search_results": fallback_results,
"search_type": "web",
"cached": False,
"error": str(e),
"fallback": True
}
cache.set(query, "ai", num, result)
return result
except Exception as e:
fallback_results = exa.search_web(query, num)
markdown_answer = _format_results_as_markdown(query, fallback_results)
html_answer = renderer.render(markdown_answer)
result = {
"html_answer": html_answer,
"markdown_answer": markdown_answer,
"search_results": fallback_results,
"search_type": "web",
"cached": False,
"error": str(e),
"fallback": True
}
cache.set(query, "ai", num, result)
return result
logger.error(f"Critical error in /api/ai endpoint: {e}")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(e)}
)
@router.get("/search", response_class=HTMLResponse)
@ -168,17 +280,27 @@ async def search_web(request: Request, q: str = Query(""), num: int = 10):
if not query:
return request.state.templates.TemplateResponse("home.html", {"request": request})
cached_results = cache.get(query, "web", num)
if cached_results:
results = cached_results.get("results", [])
else:
results = exa.search_web(query, num)
cache.set(query, "web", num, {"results": results})
logger.info(f"Web search request for: '{query}' (num={num})")
try:
cached_results = cache.get(query, "web", num)
if cached_results:
logger.info(f"Cache hit for web search: '{query}'")
results = cached_results.get("results", [])
else:
logger.info(f"Cache miss for web search: '{query}'. Fetching from Exa.")
results = exa.search_web(query, num)
cache.set(query, "web", num, {"results": results})
return request.state.templates.TemplateResponse(
"results_web.html",
{"request": request, "query": query, "results": results},
)
return request.state.templates.TemplateResponse(
"results_web.html",
{"request": request, "query": query, "results": results},
)
except Exception as e:
logger.error(f"Error in /search endpoint: {e}")
return request.state.templates.TemplateResponse(
"home.html",
{"request": request, "error": "Search failed. Please try again."}
)
@router.get("/images", response_class=HTMLResponse)
@ -188,17 +310,24 @@ async def search_images(request: Request, q: str = Query(""), num: int = 20):
if not query:
return request.state.templates.TemplateResponse("home.html", {"request": request})
cached_results = cache.get(query, "images", num)
if cached_results:
results = cached_results.get("results", [])
else:
results = exa.search_images(query, num)
cache.set(query, "images", num, {"results": results})
try:
cached_results = cache.get(query, "images", num)
if cached_results:
results = cached_results.get("results", [])
else:
results = exa.search_images(query, num)
cache.set(query, "images", num, {"results": results})
return request.state.templates.TemplateResponse(
"results_images.html",
{"request": request, "query": query, "results": results},
)
return request.state.templates.TemplateResponse(
"results_images.html",
{"request": request, "query": query, "results": results},
)
except Exception as e:
logger.error(f"Error in /images endpoint: {e}")
return request.state.templates.TemplateResponse(
"home.html",
{"request": request, "error": "Image search failed. Please try again."}
)
@router.get("/api/search")
@ -211,13 +340,20 @@ async def api_search(q: str = Query(""), num: int = Query(10)):
"results": []
}
cached_results = cache.get(query, "web", num)
if cached_results:
return {"results": cached_results.get("results", []), "cached": True}
try:
cached_results = cache.get(query, "web", num)
if cached_results:
return {"results": cached_results.get("results", []), "cached": True}
results = exa.search_web(query, num)
cache.set(query, "web", num, {"results": results})
return {"results": results, "cached": False}
results = exa.search_web(query, num)
cache.set(query, "web", num, {"results": results})
return {"results": results, "cached": False}
except Exception as e:
logger.error(f"Error in /api/search endpoint: {e}")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(e)}
)
@router.get("/api/images")
@ -230,13 +366,20 @@ async def api_images(q: str = Query(""), num: int = Query(20)):
"results": []
}
cached_results = cache.get(query, "images", num)
if cached_results:
return {"results": cached_results.get("results", []), "cached": True}
try:
cached_results = cache.get(query, "images", num)
if cached_results:
return {"results": cached_results.get("results", []), "cached": True}
results = exa.search_images(query, num)
cache.set(query, "images", num, {"results": results})
return {"results": results, "cached": False}
results = exa.search_images(query, num)
cache.set(query, "images", num, {"results": results})
return {"results": results, "cached": False}
except Exception as e:
logger.error(f"Error in /api/images endpoint: {e}")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(e)}
)
@router.get("/api/mixed")
@ -244,10 +387,17 @@ async def api_mixed(q: str = Query(...), num: int = Query(10)):
if not q.strip():
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
cached_results = cache.get(q, "mixed", num)
if cached_results:
return cached_results
try:
cached_results = cache.get(q, "mixed", num)
if cached_results:
return cached_results
results = exa.search_mixed(q, num)
cache.set(q, "mixed", num, {"results": results})
return results
results = exa.search_mixed(q, num)
cache.set(q, "mixed", num, {"results": results})
return results
except Exception as e:
logger.error(f"Error in /api/mixed endpoint: {e}")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(e)}
)

View File

@ -1,45 +1,75 @@
import hashlib
import json
import logging
from datetime import datetime, timedelta
from typing import Optional
import dataset
logger = logging.getLogger(__name__)
class Cache:
def __init__(self, db_path: str):
self.db = dataset.connect(f"sqlite:///{db_path}")
self.table = self.db["cache"]
try:
self.db = dataset.connect(f"sqlite:///{db_path}")
self.table = self.db["cache"]
# Create index if table exists
if self.table is not None:
self.table.create_index(["cache_key"])
except Exception as e:
logger.error(f"Failed to connect to cache database: {e}")
self.db = None
self.table = None
def _generate_key(self, query: str, search_type: str, num_results: int) -> str:
key_string = f"{query}:{search_type}:{num_results}"
return hashlib.md5(key_string.encode()).hexdigest()
def get(self, query: str, search_type: str, num_results: int) -> Optional[dict]:
cache_key = self._generate_key(query, search_type, num_results)
cached = self.table.find_one(cache_key=cache_key)
if not self.table:
return None
if cached:
cached_at = datetime.fromisoformat(cached["cached_at"])
if datetime.now() - cached_at < timedelta(hours=24):
return json.loads(cached["results_json"])
else:
self.table.delete(cache_key=cache_key)
try:
cache_key = self._generate_key(query, search_type, num_results)
cached = self.table.find_one(cache_key=cache_key)
if cached:
cached_at = datetime.fromisoformat(cached["cached_at"])
if datetime.now() - cached_at < timedelta(hours=24):
return json.loads(cached["results_json"])
else:
self.table.delete(cache_key=cache_key)
except Exception as e:
logger.error(f"Error retrieving from cache: {e}")
return None
return None
def set(self, query: str, search_type: str, num_results: int, results: dict) -> None:
cache_key = self._generate_key(query, search_type, num_results)
self.table.upsert(
{
"cache_key": cache_key,
"search_type": search_type,
"query_text": query,
"results_json": json.dumps(results),
"cached_at": datetime.now().isoformat(),
},
["cache_key"],
)
if not self.table:
return
try:
cache_key = self._generate_key(query, search_type, num_results)
self.table.upsert(
{
"cache_key": cache_key,
"search_type": search_type,
"query_text": query,
"results_json": json.dumps(results),
"cached_at": datetime.now().isoformat(),
},
["cache_key"],
)
except Exception as e:
logger.error(f"Error writing to cache: {e}")
def clear(self) -> None:
self.table.delete()
if not self.table:
return
try:
self.table.delete()
except Exception as e:
logger.error(f"Error clearing cache: {e}")

View File

@ -1,61 +1,83 @@
from typing import List, Dict
import logging
from exa_py import Exa
from .config import settings
logger = logging.getLogger(__name__)
class ExaClient:
def __init__(self):
self.client = Exa(settings.EXA_API_KEY)
def search_web(self, query: str, num_results: int = 10) -> List[Dict]:
response = self.client.search_and_contents(
query,
num_results=num_results,
text=True,
highlights=True,
)
return self._format_results(response.results)
try:
response = self.client.search_and_contents(
query,
num_results=num_results,
text=True,
highlights=True,
)
return self._format_results(response.results)
except Exception as e:
logger.error(f"Exa web search failed: {e}")
return []
def search_images(self, query: str, num_results: int = 20) -> List[Dict]:
response = self.client.search_and_contents(
query,
num_results=num_results,
text=True,
)
return [r for r in self._format_results(response.results) if r.get("image")]
try:
response = self.client.search_and_contents(
query,
num_results=num_results,
text=True,
)
return [r for r in self._format_results(response.results) if r.get("image")]
except Exception as e:
logger.error(f"Exa image search failed: {e}")
return []
def search_mixed(self, query: str, num_results: int = 10) -> Dict[str, List[Dict]]:
response = self.client.search_and_contents(
query,
num_results=num_results * 2,
text=True,
)
results = self._format_results(response.results)
web_results = results[:num_results]
image_results = [r for r in results if r.get("image")][:num_results]
return {"web": web_results, "images": image_results}
try:
response = self.client.search_and_contents(
query,
num_results=num_results * 2,
text=True,
)
results = self._format_results(response.results)
web_results = results[:num_results]
image_results = [r for r in results if r.get("image")][:num_results]
return {"web": web_results, "images": image_results}
except Exception as e:
logger.error(f"Exa mixed search failed: {e}")
return {"web": [], "images": []}
def answer_ai(self, query: str, num_results: int = 10) -> Dict:
image_keywords = ["image", "photo", "picture", "pic", "img", "screenshot", "drawing", "art", "graphic"]
query_lower = query.lower()
for keyword in image_keywords:
if keyword in query_lower:
results = self.search_images(query, num_results)
return {
"results": results,
"search_type": "images"
}
results = self.search_web(query, num_results)
return {
"results": results,
"search_type": "web"
}
try:
for keyword in image_keywords:
if keyword in query_lower:
results = self.search_images(query, num_results)
return {
"results": results,
"search_type": "images"
}
results = self.search_web(query, num_results)
return {
"results": results,
"search_type": "web"
}
except Exception as e:
logger.error(f"Exa answer AI failed: {e}")
return {
"results": [],
"search_type": "web"
}
def _format_results(self, results: List) -> List[Dict]:
formatted = []

View File

@ -1,14 +1,17 @@
from typing import List, Dict, Any
import json
import logging
from openai import OpenAI
from openai import AsyncOpenAI
from .config import settings
logger = logging.getLogger(__name__)
class OpenRouterClient:
def __init__(self):
self.client = OpenAI(
self.client = AsyncOpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=settings.OPENROUTER_API_KEY
)
@ -22,7 +25,7 @@ class OpenRouterClient:
return "images"
return "web"
def format_results_as_markdown(self, query: str, results: List[Dict]) -> str:
async def format_results_as_markdown(self, query: str, results: List[Dict]) -> str:
search_type = self._determine_search_type(query)
system_prompt = """You are a helpful AI assistant. Your task is to synthesize and summarize information from search results.
@ -57,7 +60,7 @@ Format your response as clean, professional markdown."""
]
try:
response = self.client.chat.completions.create(
response = await self.client.chat.completions.create(
model=settings.OPENROUTER_MODEL,
messages=messages,
temperature=0.1,
@ -66,4 +69,5 @@ Format your response as clean, professional markdown."""
content = response.choices[0].message.content
return content if content else ""
except Exception as e:
raise Exception(f"OpenRouter API error: {e}")
logger.error(f"OpenRouter API error: {e}")
return ""

View File

@ -1,11 +1,20 @@
import logging
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from .api.router import router
from .core import settings
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
class Application:
def __init__(self):
@ -14,6 +23,7 @@ class Application:
self._setup_static_files()
self._setup_templates()
self._setup_routes()
self._setup_exception_handlers()
def _setup_middleware(self):
@self.app.middleware("http")
@ -33,7 +43,6 @@ class Application:
@self.app.get("/manifest.json")
async def manifest():
from fastapi.responses import JSONResponse
return JSONResponse(
{
"name": "Rexa Search",
@ -83,6 +92,28 @@ self.addEventListener('fetch', event => {
"""
return Response(content=sw_content, media_type="application/javascript")
def _setup_exception_handlers(self):
@self.app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(f"Global exception: {exc}", exc_info=True)
# Check if request expects JSON
if request.url.path.startswith("/api") or "application/json" in request.headers.get("accept", ""):
return JSONResponse(
status_code=500,
content={"error": "Internal Server Error", "detail": "An unexpected error occurred."}
)
# Default to HTML error page
return self.templates.TemplateResponse(
"home.html",
{
"request": request,
"error": "An unexpected error occurred. We have logged this issue and will look into it."
},
status_code=500
)
def run(self):
uvicorn.run(
self.app,

View File

@ -219,6 +219,14 @@ a:visited {
margin: 0 0 20px 0;
}
.favicon {
width: 16px;
height: 16px;
margin-right: 8px;
vertical-align: middle;
border-radius: 2px;
}
.result-item h3 {
margin: 0 0 2px 0;
font-size: 16px;

View File

@ -8,7 +8,7 @@
<h1>Rexa Search</h1>
</div>
<form action="/ai" method="get" class="search-form" id="searchForm">
<div class="search-form">
<div class="search-box">
<input
type="text"
@ -17,14 +17,15 @@
placeholder="Ask anything..."
autocomplete="off"
autofocus
onkeypress="if(event.key === 'Enter') performSearch('ai')"
>
</div>
<div class="search-buttons">
<button type="submit" formaction="/ai">AI Search</button>
<button type="submit" formaction="/search">Web Search</button>
<button type="submit" formaction="/images">Image Search</button>
<button type="button" onclick="performSearch('ai')">AI Search</button>
<button type="button" onclick="performSearch('search')">Web Search</button>
<button type="button" onclick="performSearch('images')">Image Search</button>
</div>
</form>
</div>
<div class="search-tips">
<p>Powered by Exa API</p>
@ -32,12 +33,24 @@
</div>
<script>
document.getElementById('searchForm').addEventListener('submit', function(e) {
var query = document.getElementById('searchInput').value.trim();
if (!query) {
e.preventDefault();
return false;
function performSearch(type) {
const queryInput = document.getElementById('searchInput');
const query = queryInput.value.trim();
if (query) {
// Show loading state
const buttons = document.querySelectorAll('.search-buttons button');
buttons.forEach(btn => {
btn.disabled = true;
if (btn.onclick.toString().includes(type)) {
btn.innerText = 'Searching...';
}
});
queryInput.disabled = true;
window.location.href = `/${type}?q=${encodeURIComponent(query)}`;
} else {
queryInput.focus();
}
});
}
</script>
{% endblock %}

View File

@ -49,6 +49,9 @@
{% for result in search_results %}
<div class="result-item">
<h4>
{% if result.favicon %}
<img src="{{ result.favicon }}" alt="" class="favicon">
{% endif %}
<a href="{{ result.url }}" target="_blank" rel="noopener noreferrer">
{{ result.title }}
</a>
@ -75,3 +78,19 @@
{% endif %}
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.querySelector('.search-form-inline').addEventListener('submit', function(e) {
const input = this.querySelector('input[name="q"]');
const button = this.querySelector('button');
if (input.value.trim()) {
input.disabled = true;
button.disabled = true;
button.innerText = 'Searching...';
} else {
e.preventDefault();
}
});
</script>
{% endblock %}

View File

@ -55,3 +55,19 @@
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.querySelector('.search-form-inline').addEventListener('submit', function(e) {
const input = this.querySelector('input[name="q"]');
const button = this.querySelector('button');
if (input.value.trim()) {
input.disabled = true;
button.disabled = true;
button.innerText = 'Searching...';
} else {
e.preventDefault();
}
});
</script>
{% endblock %}

View File

@ -35,6 +35,9 @@
{% for result in results %}
<div class="result-item">
<h3>
{% if result.favicon %}
<img src="{{ result.favicon }}" alt="" class="favicon">
{% endif %}
<a href="{{ result.url }}" target="_blank" rel="noopener noreferrer">{{ result.title }}</a>
</h3>
<div class="result-url">{{ result.url }}</div>
@ -58,43 +61,18 @@
{% endif %}
{% endblock %}
{% if not query %}
<div class="home-container">
<div class="logo">
<h1>Rexa Search</h1>
</div>
<div class="search-tips">
<p>Please enter a search query.</p>
</div>
</div>
{% else %}
<div class="results-stats">
<p>About {{ results|length }} results found</p>
</div>
<div class="results-container">
{% for result in results %}
<div class="result-item">
<h3>
<a href="{{ result.url }}" target="_blank" rel="noopener noreferrer">{{ result.title }}</a>
</h3>
<div class="result-url">{{ result.url }}</div>
<div class="result-snippet">
{% if result.highlights %}
{{ result.highlights[0]|safe }}
{% else %}
{{ result.text }}
{% endif %}
</div>
</div>
{% endfor %}
{% if not results %}
<div class="no-results">
<p>No results found for "{{ query }}"</p>
<p>Try different keywords or check your spelling.</p>
</div>
{% endif %}
</div>
{% endif %}
{% block scripts %}
<script>
document.querySelector('.search-form-inline').addEventListener('submit', function(e) {
const input = this.querySelector('input[name="q"]');
const button = this.querySelector('button');
if (input.value.trim()) {
input.disabled = true;
button.disabled = true;
button.innerText = 'Searching...';
} else {
e.preventDefault();
}
});
</script>
{% endblock %}