electric-horses-infra/stacks/eh-search/app/cache.py
Benjamin Weinlich b3813ed6ac feat(stacks/eh-search): add site-search FastAPI service
Mirrors /opt/ai-apps/eh-search/ on the server, including the full
FastAPI app (intent routing, FTS+fuzzy+substring hybrid, multi-source
federation across vehicles + blog + brands + pages + static + tag
bridge), SQL schema (Postgres materialized view with german_unaccent
text search, pg_trgm for fuzzy), Dockerfile and compose.

Sanitized the hardcoded password in sql/01_init.sql — replaced with
REPLACE_ME_BEFORE_APPLYING placeholder since this repo is public.

The eh-search service binds only on the private network (10.0.0.8:8200)
and is reachable only via Pegasus nginx proxy at /api/search.

Refs OP#1094 OP#1105 OP#1112 OP#1116 OP#1117
2026-04-11 22:19:39 +02:00

80 lines
1.8 KiB
Python

"""Redis cache wrapper with namespaced keys."""
import hashlib
import json
from typing import Any
import redis.asyncio as redis_async
from app.config import settings
_redis: redis_async.Redis | None = None
NAMESPACE = "search:"
async def init_redis() -> None:
global _redis
_redis = redis_async.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=settings.redis_db,
decode_responses=True,
socket_timeout=2,
)
await _redis.ping()
async def close_redis() -> None:
global _redis
if _redis is not None:
await _redis.close()
_redis = None
def get_redis() -> redis_async.Redis:
if _redis is None:
raise RuntimeError("Redis not initialized")
return _redis
def _hash(value: str) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
def key_query(query: str) -> str:
return f"{NAMESPACE}q:{_hash(query.lower())}"
def key_suggest(prefix: str) -> str:
return f"{NAMESPACE}suggest:{_hash(prefix.lower())}"
def key_empty(query: str) -> str:
return f"{NAMESPACE}empty:{_hash(query.lower())}"
def key_top_brands() -> str:
return f"{NAMESPACE}top:brands"
async def get_json(key: str) -> Any | None:
data = await get_redis().get(key)
if data is None:
return None
try:
return json.loads(data)
except (json.JSONDecodeError, TypeError):
return None
async def set_json(key: str, value: Any, ttl: int) -> None:
await get_redis().set(key, json.dumps(value, default=str), ex=ttl)
async def invalidate_all() -> int:
"""Delete all keys in our namespace. Returns count deleted."""
r = get_redis()
count = 0
async for key in r.scan_iter(match=f"{NAMESPACE}*", count=200):
await r.delete(key)
count += 1
return count