51 lines
1.5 KiB
Python
Raw Normal View History

feat(nadia): Build research package foundation: config, rsearch-only HTTP client, TTL caches Outcome: done Changed: src/typosaurus_sandbox/research/__init__.py:1-28, src/typosaurus_sandbox/research/config.py:1-43, src/typosaurus_sandbox/research/envelopes.py:1-214, src/typosaurus_sandbox/research/cache.py:1-48, src/typosaurus_sandbox/research/client.py:1-196 Verified by: verify() -> "make verify" exit_code 0, compileall OK, 104 tests OK, "verification passed" (pre-existing StarletteDeprecationWarning from fastapi.testclient import in tests/test_api.py, not introduced by this change); live smoke: web/images/ai search, chat with usage, describe GET, describe_raw POST, search-cache hit, content-cache fill+hit all passed; deep envelope parsing validated against captured live response (model, rounds, sources, grades, queries_tried) Findings: - RsearchClient: search(query, source, count, content, type, deep, ai, cache), chat(prompt, system, json_mode, cache), describe(url), describe_upload(bytes, filename, mime_type), describe_raw(bytes, mime_type), get_cached_content(url); failures raise RsearchError(message, status_code) with server error text extracted (504 detail, success:false error). - Caches: search 300s TTL keyed by sorted urlencoded params; content 86400s keyed by result URL; describe 86400s keyed by url:/hash:sha256; threading.Lock guarded; hit/miss logged DEBUG. - ResearchConfig.load reads the "research" key of .env.json; defaults base_url https://rsearch.app.molodetz.nl, timeout 30s, deep timeout 180s, max_concurrency 8, default_count 10. - HTTP is stdlib-only Typosaurus-Run: 4e2afb673c7f4578a12276d9181b982d Typosaurus-Node: 08bc7408f3ce4d25b13634501bb60a4d Typosaurus-Agent: @nadia Refs: #31
2026-08-07 20:48:55 +02:00
# retoor <retoor@molodetz.nl>
import logging
import threading
import time
from dataclasses import dataclass
from typing import Generic, TypeVar
logger = logging.getLogger(__name__)
T = TypeVar("T")
@dataclass
class CacheEntry(Generic[T]):
value: T
expires_at: float
class TTLCache(Generic[T]):
def __init__(self, name: str, ttl_seconds: float) -> None:
self._name = name
self._ttl_seconds = ttl_seconds
self._entries: dict[str, CacheEntry[T]] = {}
self._lock = threading.Lock()
def get(self, key: str) -> T | None:
with self._lock:
entry = self._entries.get(key)
if entry is None:
logger.debug("cache %s miss key=%s", self._name, key)
return None
if time.monotonic() >= entry.expires_at:
del self._entries[key]
logger.debug("cache %s expired key=%s", self._name, key)
return None
logger.debug("cache %s hit key=%s", self._name, key)
return entry.value
def set(self, key: str, value: T) -> None:
with self._lock:
self._entries[key] = CacheEntry(value=value, expires_at=time.monotonic() + self._ttl_seconds)
logger.debug("cache %s set key=%s ttl=%.0fs", self._name, key, self._ttl_seconds)
def clear(self) -> None:
with self._lock:
count = len(self._entries)
self._entries.clear()
logger.debug("cache %s cleared %d entries", self._name, count)