webfetch-llm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
webfetch/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ webfetch: replicate LLM web-search tool calls cheaply by owning the full
3
+ search -> fetch -> rank pipeline.
4
+
5
+ Quick start (tool mode - the calling model is the extractor):
6
+
7
+ from webfetch import Pipeline, SqliteCache, WEB_SEARCH_TOOL, handle_web_search
8
+
9
+ pipeline = Pipeline(cache=SqliteCache())
10
+ text = handle_web_search({"query": "Fluke 87V DC voltage accuracy"},
11
+ pipeline=pipeline)
12
+
13
+ Structured extraction mode (webfetch calls a cheap LLM for JSON output):
14
+
15
+ from webfetch.extract import ClaudeExtractor
16
+ specs = pipeline.run(query, keys={"accuracy": "..."}, extractor=ClaudeExtractor())
17
+
18
+ The extract package is intentionally NOT imported here - the root import
19
+ must work with core dependencies only.
20
+ """
21
+
22
+ from webfetch.cache import AbstractCache, CacheMatch, SqliteCache
23
+ from webfetch.pipeline import Pipeline, SearchChunksResult
24
+ from webfetch.rank import Chunk
25
+ from webfetch.receipts import get_counters, savings_report
26
+ from webfetch.search import SearchResult, get_search_adapter
27
+ from webfetch.semcache import SemanticSqliteCache
28
+ from webfetch.tool import WEB_SEARCH_TOOL, get_default_pipeline, handle_web_search
29
+
30
+ __version__ = "0.1.0"
31
+
32
+ __all__ = [
33
+ "get_counters",
34
+ "savings_report",
35
+ "Pipeline",
36
+ "SearchChunksResult",
37
+ "AbstractCache",
38
+ "CacheMatch",
39
+ "SqliteCache",
40
+ "SemanticSqliteCache",
41
+ "Chunk",
42
+ "SearchResult",
43
+ "get_search_adapter",
44
+ "WEB_SEARCH_TOOL",
45
+ "handle_web_search",
46
+ "get_default_pipeline",
47
+ "__version__",
48
+ ]
webfetch/cache.py ADDED
@@ -0,0 +1,347 @@
1
+ """
2
+ Transparent cache layer for the webfetch pipeline.
3
+
4
+ Two cache layers, both in one sqlite database:
5
+
6
+ - pages: url -> extracted page text. The big win - fetching is the slowest
7
+ pipeline stage and page text is reusable across different queries
8
+ that hit the same URL.
9
+ - queries: query key -> final ranked chunks. Makes repeated identical queries
10
+ (same query + provider + n_results) near-instant.
11
+
12
+ The pipeline works identically with or without a cache (pass cache=None), so
13
+ this layer is purely an optimization - never a behavior change. Failed
14
+ fetches are NOT cached: a dead URL today might work tomorrow, and caching
15
+ None would hide recoverable failures for the whole TTL.
16
+
17
+ Backend is stdlib sqlite3 rather than diskcache - one less dependency, and
18
+ the access pattern (key lookup, small rows, low write volume) needs nothing
19
+ more. WAL mode + a single lock keeps it safe under the concurrent fetch pool.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import sqlite3
28
+ import threading
29
+ import time
30
+ from abc import ABC, abstractmethod
31
+ from dataclasses import asdict, dataclass
32
+
33
+ from webfetch.config import (
34
+ DEFAULT_CACHE_DB,
35
+ DEFAULT_CACHE_TTL_DAYS,
36
+ DEFAULT_FRESHNESS,
37
+ TTL_BY_FRESHNESS,
38
+ )
39
+ from webfetch.rank.base import Chunk
40
+
41
+
42
+ @dataclass
43
+ class CacheMatch:
44
+ """A successful query-cache lookup with provenance.
45
+
46
+ Attributes:
47
+ chunks: The cached ranked chunks.
48
+ kind: "exact" (same normalized query) or "semantic" (paraphrase
49
+ match via the semantic cache).
50
+ matched_query: The cached query text that matched. None for exact
51
+ matches (it is the incoming query itself).
52
+ similarity: Bi-encoder cosine to the matched query (semantic only).
53
+ age_secs: Seconds since the cached entry was stored, if known.
54
+ """
55
+
56
+ chunks: list[Chunk]
57
+ kind: str
58
+ matched_query: str | None = None
59
+ similarity: float | None = None
60
+ age_secs: float | None = None
61
+ freshness: str | None = None
62
+
63
+
64
+ def make_query_key(query: str, provider: str, n_results: int) -> str:
65
+ """Build a deterministic cache key for a search query.
66
+
67
+ Args:
68
+ query: The search query string (whitespace-normalized, lowercased -
69
+ "Fluke 87V" and "fluke 87v " should hit the same entry).
70
+ provider: Search provider name (different providers return different
71
+ URLs, so results are not interchangeable).
72
+ n_results: Number of search results requested.
73
+
74
+ Returns:
75
+ A sha256 hex digest usable as a primary key.
76
+ """
77
+ normalized = " ".join(query.lower().split())
78
+ raw = f"{normalized}|{provider}|{n_results}"
79
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
80
+
81
+
82
+ class AbstractCache(ABC):
83
+ """Interface for pipeline caches.
84
+
85
+ Two layers: page text (keyed by URL) and final ranked chunks (keyed by
86
+ a query key from `make_query_key`). Implementations decide storage and
87
+ expiry; the pipeline only calls these four methods.
88
+ """
89
+
90
+ @abstractmethod
91
+ def get_page(self, url: str) -> str | None:
92
+ """Return cached extracted text for a URL, or None on miss/expiry."""
93
+ ...
94
+
95
+ @abstractmethod
96
+ def set_page(self, url: str, text: str) -> None:
97
+ """Cache extracted text for a URL."""
98
+ ...
99
+
100
+ @abstractmethod
101
+ def get_chunks(self, key: str) -> list[Chunk] | None:
102
+ """Return cached ranked chunks for a query key, or None on miss/expiry."""
103
+ ...
104
+
105
+ @abstractmethod
106
+ def set_chunks(self, key: str, chunks: list[Chunk]) -> None:
107
+ """Cache the final ranked chunks for a query key."""
108
+ ...
109
+
110
+ def lookup(self, query: str, provider: str, n_results: int,
111
+ freshness: str | None = None) -> CacheMatch | None:
112
+ """Look up cached chunks for a query, with provenance.
113
+
114
+ Default implementation is exact-match only with flat TTL (the
115
+ freshness hint is ignored). SqliteCache and semantic caches override
116
+ this with per-class TTLs - the pipeline calls only this method, so
117
+ cache implementations stay swappable.
118
+
119
+ Args:
120
+ query: The incoming search query.
121
+ provider: Search provider name (part of the cache key).
122
+ n_results: Requested result count (part of the cache key).
123
+ freshness: Optional caller-side volatility class; implementations
124
+ may use it to tighten expiry (min of stored and hinted TTL).
125
+
126
+ Returns:
127
+ A CacheMatch on hit, None on miss.
128
+ """
129
+ chunks = self.get_chunks(make_query_key(query, provider, n_results))
130
+ if chunks is None:
131
+ return None
132
+ return CacheMatch(chunks=chunks, kind="exact")
133
+
134
+ def store(self, query: str, provider: str, n_results: int,
135
+ chunks: list[Chunk], freshness: str | None = None) -> None:
136
+ """Store the final ranked chunks for a query.
137
+
138
+ Args:
139
+ query: The search query that produced these chunks.
140
+ provider: Search provider name.
141
+ n_results: Requested result count.
142
+ chunks: Final ranked chunks to cache.
143
+ freshness: Volatility class to store with the row (drives its
144
+ TTL on later reads). Ignored by the default implementation.
145
+ """
146
+ self.set_chunks(make_query_key(query, provider, n_results), chunks)
147
+
148
+
149
+ class SqliteCache(AbstractCache):
150
+ """Sqlite-backed cache with lazy TTL expiry.
151
+
152
+ Expired rows are deleted on read rather than by a background sweeper -
153
+ simpler, and stale rows that are never read again cost only disk space.
154
+
155
+ Args:
156
+ db_path: Path to the sqlite file. "~" is expanded; parent dirs are
157
+ created. Defaults to config.DEFAULT_CACHE_DB.
158
+ ttl_days: Rows older than this are treated as misses and deleted.
159
+ """
160
+
161
+ def __init__(
162
+ self,
163
+ db_path: str = DEFAULT_CACHE_DB,
164
+ ttl_days: int = DEFAULT_CACHE_TTL_DAYS,
165
+ ) -> None:
166
+ path = os.path.expanduser(db_path)
167
+ parent = os.path.dirname(path)
168
+ if parent:
169
+ os.makedirs(parent, exist_ok=True)
170
+
171
+ self._ttl_secs = ttl_days * 86400
172
+ # check_same_thread=False + one lock: fetch workers write pages from
173
+ # pool threads. A single connection behind a lock is plenty for this
174
+ # write volume and avoids per-thread connection management.
175
+ self._conn = sqlite3.connect(path, check_same_thread=False)
176
+ self._lock = threading.Lock()
177
+ with self._lock:
178
+ # WAL lets readers proceed during writes and is more crash-safe.
179
+ self._conn.execute("PRAGMA journal_mode=WAL")
180
+ self._conn.execute(
181
+ "CREATE TABLE IF NOT EXISTS pages ("
182
+ "url TEXT PRIMARY KEY, text TEXT NOT NULL, created_at REAL NOT NULL)"
183
+ )
184
+ self._conn.execute(
185
+ "CREATE TABLE IF NOT EXISTS queries ("
186
+ "key TEXT PRIMARY KEY, chunks_json TEXT NOT NULL, "
187
+ "created_at REAL NOT NULL, freshness TEXT)"
188
+ )
189
+ # Migration for cache dbs created before volatility-aware TTLs:
190
+ # NULL freshness rows are treated as DEFAULT_FRESHNESS on read.
191
+ cols = {r[1] for r in self._conn.execute("PRAGMA table_info(queries)")}
192
+ if "freshness" not in cols:
193
+ self._conn.execute("ALTER TABLE queries ADD COLUMN freshness TEXT")
194
+ # Lifetime usage counters (cost receipts) - live with the cache
195
+ # because it is the one durable file webfetch owns.
196
+ self._conn.execute(
197
+ "CREATE TABLE IF NOT EXISTS stats ("
198
+ "key TEXT PRIMARY KEY, value REAL NOT NULL)"
199
+ )
200
+ self._conn.commit()
201
+
202
+ def bump_stats(self, **deltas: float) -> None:
203
+ """Accumulate usage counters (see webfetch.receipts).
204
+
205
+ Args:
206
+ **deltas: Counter name -> increment, e.g.
207
+ bump_stats(searches_total=1, cache_hits_exact=1).
208
+ """
209
+ with self._lock:
210
+ for key, delta in deltas.items():
211
+ self._conn.execute(
212
+ "INSERT INTO stats (key, value) VALUES (?, ?) "
213
+ "ON CONFLICT(key) DO UPDATE SET value = value + ?",
214
+ (key, float(delta), float(delta)),
215
+ )
216
+ self._conn.commit()
217
+
218
+ def get_stats(self) -> dict[str, float]:
219
+ """Return all lifetime usage counters."""
220
+ with self._lock:
221
+ return dict(self._conn.execute("SELECT key, value FROM stats"))
222
+
223
+ def _get_with_age(self, table: str, key_col: str, value_col: str,
224
+ key: str) -> tuple[str, float] | None:
225
+ """Shared read path: (value, age_secs) or None, deleting expired rows."""
226
+ with self._lock:
227
+ row = self._conn.execute(
228
+ f"SELECT {value_col}, created_at FROM {table} WHERE {key_col} = ?",
229
+ (key,),
230
+ ).fetchone()
231
+ if row is None:
232
+ return None
233
+ value, created_at = row
234
+ age = time.time() - created_at
235
+ if age > self._ttl_secs:
236
+ self._conn.execute(f"DELETE FROM {table} WHERE {key_col} = ?", (key,))
237
+ self._conn.commit()
238
+ return None
239
+ return value, age
240
+
241
+ def _get(self, table: str, key_col: str, value_col: str, key: str) -> str | None:
242
+ """Shared read path: return the value or None, deleting expired rows."""
243
+ hit = self._get_with_age(table, key_col, value_col, key)
244
+ return hit[0] if hit is not None else None
245
+
246
+ def _effective_ttl_secs(self, stored: str | None, hint: str | None) -> float:
247
+ """Per-class TTL: min(stored class, caller hint, flat ceiling).
248
+
249
+ Resolved at read time so retuning TTL_BY_FRESHNESS applies to rows
250
+ already cached. Legacy rows (NULL freshness) use DEFAULT_FRESHNESS.
251
+ """
252
+ ttl = float(TTL_BY_FRESHNESS.get(stored or DEFAULT_FRESHNESS, self._ttl_secs))
253
+ if hint in TTL_BY_FRESHNESS:
254
+ ttl = min(ttl, TTL_BY_FRESHNESS[hint])
255
+ return min(ttl, self._ttl_secs)
256
+
257
+ def _get_query_row(self, key: str, hint: str | None = None,
258
+ ) -> tuple[str, float, str | None] | None:
259
+ """Read a queries row with per-class expiry.
260
+
261
+ A row is DELETED only when expired for its OWN stored class - a
262
+ stricter caller hint produces a miss but leaves the row intact,
263
+ since other callers may still be served by it.
264
+
265
+ Returns:
266
+ (chunks_json, age_secs, stored_freshness) or None on miss/expiry.
267
+ """
268
+ with self._lock:
269
+ row = self._conn.execute(
270
+ "SELECT chunks_json, created_at, freshness FROM queries WHERE key = ?",
271
+ (key,),
272
+ ).fetchone()
273
+ if row is None:
274
+ return None
275
+ value, created_at, stored = row
276
+ age = time.time() - created_at
277
+ if age > self._effective_ttl_secs(stored, None):
278
+ self._conn.execute("DELETE FROM queries WHERE key = ?", (key,))
279
+ self._conn.commit()
280
+ return None
281
+ if age > self._effective_ttl_secs(stored, hint):
282
+ return None
283
+ return value, age, stored
284
+
285
+ def _set(self, table: str, key_col: str, value_col: str, key: str,
286
+ value: str) -> None:
287
+ """Shared write path: upsert with a fresh timestamp."""
288
+ with self._lock:
289
+ self._conn.execute(
290
+ f"INSERT OR REPLACE INTO {table} ({key_col}, {value_col}, created_at) "
291
+ "VALUES (?, ?, ?)",
292
+ (key, value, time.time()),
293
+ )
294
+ self._conn.commit()
295
+
296
+ def get_page(self, url: str) -> str | None:
297
+ """Return cached extracted text for a URL, or None on miss/expiry."""
298
+ return self._get("pages", "url", "text", url)
299
+
300
+ def set_page(self, url: str, text: str) -> None:
301
+ """Cache extracted text for a URL."""
302
+ self._set("pages", "url", "text", url, text)
303
+
304
+ def get_chunks(self, key: str) -> list[Chunk] | None:
305
+ """Return cached ranked chunks for a query key, or None on miss/expiry."""
306
+ hit = self._get_query_row(key)
307
+ if hit is None:
308
+ return None
309
+ return [Chunk(**d) for d in json.loads(hit[0])]
310
+
311
+ def lookup(self, query: str, provider: str, n_results: int,
312
+ freshness: str | None = None) -> CacheMatch | None:
313
+ """Exact-match lookup with per-class TTL and age provenance."""
314
+ hit = self._get_query_row(
315
+ make_query_key(query, provider, n_results), hint=freshness)
316
+ if hit is None:
317
+ return None
318
+ raw, age, stored = hit
319
+ return CacheMatch(
320
+ chunks=[Chunk(**d) for d in json.loads(raw)],
321
+ kind="exact", age_secs=age, freshness=stored,
322
+ )
323
+
324
+ def store(self, query: str, provider: str, n_results: int,
325
+ chunks: list[Chunk], freshness: str | None = None) -> None:
326
+ """Store chunks with their volatility class."""
327
+ with self._lock:
328
+ self._conn.execute(
329
+ "INSERT OR REPLACE INTO queries "
330
+ "(key, chunks_json, created_at, freshness) VALUES (?, ?, ?, ?)",
331
+ (make_query_key(query, provider, n_results),
332
+ json.dumps([asdict(c) for c in chunks]), time.time(), freshness),
333
+ )
334
+ self._conn.commit()
335
+
336
+ def set_chunks(self, key: str, chunks: list[Chunk]) -> None:
337
+ """Cache the final ranked chunks for a query key."""
338
+ self._set("queries", "key", "chunks_json", key,
339
+ json.dumps([asdict(c) for c in chunks]))
340
+
341
+ def close(self) -> None:
342
+ """Close the underlying sqlite connection."""
343
+ with self._lock:
344
+ self._conn.close()
345
+
346
+
347
+ __all__ = ["AbstractCache", "CacheMatch", "SqliteCache", "make_query_key"]