diff --git a/konjoai/adapters/base.py b/konjoai/adapters/base.py index c3257e6..287f090 100644 --- a/konjoai/adapters/base.py +++ b/konjoai/adapters/base.py @@ -12,6 +12,7 @@ * K1: Every method either returns a value or raises. No silent swallowing of exceptions. """ + from __future__ import annotations from collections.abc import AsyncIterator, Iterator @@ -21,6 +22,7 @@ # ── VectorStoreAdapter ──────────────────────────────────────────────────────── + @runtime_checkable class VectorStoreAdapter(Protocol): """A pluggable vector database / similarity search backend. @@ -30,11 +32,11 @@ class VectorStoreAdapter(Protocol): def upsert( self, - vectors: list[np.ndarray], + embeddings: list[np.ndarray], payloads: list[dict], ids: list[str] | None = None, ) -> int: - """Write *vectors* with *payloads* into the store. + """Write *embeddings* with *payloads* into the store. Returns the number of vectors successfully indexed. """ @@ -64,6 +66,7 @@ def count(self) -> int: # ── EmbedderAdapter ─────────────────────────────────────────────────────────── + @runtime_checkable class EmbedderAdapter(Protocol): """A text-to-vector embedding backend. @@ -93,6 +96,7 @@ def dim(self) -> int: # ── GeneratorAdapter ────────────────────────────────────────────────────────── + @runtime_checkable class GeneratorAdapter(Protocol): """An LLM generation backend. @@ -128,6 +132,7 @@ async def stream(self, question: str, context: str) -> AsyncIterator[str]: # ── RetrieverAdapter ────────────────────────────────────────────────────────── + @runtime_checkable class RetrieverAdapter(Protocol): """A hybrid retrieval backend. diff --git a/konjoai/api/routes/cache.py b/konjoai/api/routes/cache.py index 5ee0cb3..0dad757 100644 --- a/konjoai/api/routes/cache.py +++ b/konjoai/api/routes/cache.py @@ -14,6 +14,7 @@ All routes return HTTP 404 when ``cache_enabled`` is False (K3). """ + from __future__ import annotations import asyncio @@ -146,6 +147,7 @@ async def warm_cache(body: WarmRequest) -> WarmResponse: class _Resp: """Minimal stand-in that satisfies cache.store()'s response.answer lookup.""" + def __init__(self, answer: str) -> None: self.answer = answer @@ -155,7 +157,8 @@ def __init__(self, answer: str) -> None: questions = [p.question for p in body.pairs] try: vecs: np.ndarray = await asyncio.to_thread( - encoder.encode, questions # type: ignore[arg-type] + encoder.encode, + questions, # type: ignore[arg-type] ) except Exception as exc: # noqa: BLE001 logger.warning("cache warm: batch encode failed: %s", exc) @@ -179,7 +182,9 @@ def __init__(self, answer: str) -> None: logger.info( "cache warm complete — warmed=%d dup=%d err=%d", - warmed, skipped_dup, skipped_err, + warmed, + skipped_dup, + skipped_err, ) return WarmResponse( warmed=warmed, @@ -265,12 +270,16 @@ def _kmeans_cluster( """Lloyd's k-means on L2-normalised embeddings. Pure numpy. Max 50 lines.""" questions = [e[0] for e in entries] hit_counts = np.array([e[2] for e in entries], dtype=np.float32) - - # Stack + normalise all embeddings to unit sphere vecs = np.vstack([SemanticCache._l2_norm(e[1]) for e in entries]) # (n, dim) - # Initialise centroids via k-means++ seeding rng = np.random.default_rng(seed=42) + centroids_arr = _kmeanspp_init(vecs, k, rng) + labels = _lloyd_iterations(vecs, centroids_arr, k, max_iter) + return _summarise_clusters(vecs, labels, centroids_arr, questions, hit_counts, k) + + +def _kmeanspp_init(vecs: np.ndarray, k: int, rng: np.random.Generator) -> np.ndarray: + """Seed *k* centroids on the unit sphere via k-means++ (probability-weighted).""" centroids = [vecs[rng.integers(len(vecs))].copy()] for _ in range(k - 1): dists = np.array([min(float(1 - np.dot(c, v)) for c in centroids) for v in vecs]) @@ -278,13 +287,14 @@ def _kmeans_cluster( total = dists.sum() probs = dists / total if total > 0 else np.ones(len(vecs)) / len(vecs) centroids.append(vecs[rng.choice(len(vecs), p=probs)].copy()) - centroids_arr = np.array(centroids) # (k, dim) + return np.array(centroids) # (k, dim) + - # Lloyd iterations +def _lloyd_iterations(vecs: np.ndarray, centroids_arr: np.ndarray, k: int, max_iter: int) -> np.ndarray: + """Run Lloyd's algorithm in place on *centroids_arr*; return final labels.""" labels = np.zeros(len(vecs), dtype=int) for _ in range(max_iter): - sims = vecs @ centroids_arr.T # (n, k) cosine similarities - new_labels = np.argmax(sims, axis=1) + new_labels = np.argmax(vecs @ centroids_arr.T, axis=1) # cosine similarities if np.array_equal(new_labels, labels): break labels = new_labels @@ -294,24 +304,33 @@ def _kmeans_cluster( c = vecs[mask].mean(axis=0) norm = np.linalg.norm(c) centroids_arr[ci] = c / norm if norm > 1e-10 else c + return labels - # Build output — one dict per cluster + +def _summarise_clusters( + vecs: np.ndarray, + labels: np.ndarray, + centroids_arr: np.ndarray, + questions: list[str], + hit_counts: np.ndarray, + k: int, +) -> list[dict[str, object]]: + """Build one summary dict per non-empty cluster, sorted by descending size.""" result: list[dict[str, object]] = [] for ci in range(k): mask = labels == ci if not mask.any(): continue cluster_qs = [questions[i] for i in np.where(mask)[0]] - cluster_hits = hit_counts[mask] - member_sims = float((vecs[mask] @ centroids_arr[ci]).mean()) - result.append({ - "cluster_id": ci, - "size": int(mask.sum()), - "avg_hit_count": round(float(cluster_hits.mean()), 2), - "avg_centroid_similarity": round(member_sims, 4), - "representative_questions": cluster_qs[:5], - }) - + result.append( + { + "cluster_id": ci, + "size": int(mask.sum()), + "avg_hit_count": round(float(hit_counts[mask].mean()), 2), + "avg_centroid_similarity": round(float((vecs[mask] @ centroids_arr[ci]).mean()), 4), + "representative_questions": cluster_qs[:5], + } + ) result.sort(key=lambda c: c["size"], reverse=True) return result @@ -364,12 +383,14 @@ async def batch_search(body: SearchQuery) -> dict[str, object]: continue sim = float(np.dot(q_norm, SemanticCache._l2_norm(entry_vec.question_vec))) answer = resp.answer if hasattr(resp, "answer") else str(resp) - scored.append({ - "question": orig_q, - "answer": answer[:256], - "similarity": round(sim, 4), - "hit_count": hits, - }) + scored.append( + { + "question": orig_q, + "answer": answer[:256], + "similarity": round(sim, 4), + "hit_count": hits, + } + ) scored.sort(key=lambda m: m["similarity"], reverse=True) results.append({"query_index": i, "query": q, "matches": scored[: body.top_k]}) @@ -481,7 +502,9 @@ class ReportPoisoningRequest(BaseModel): """Body for a manual cache-poisoning report.""" question_hash: str = Field( - ..., min_length=8, max_length=64, + ..., + min_length=8, + max_length=64, description="16-hex SHA-256 prefix of the question (OWASP — no raw text).", ) reason: str = Field(..., min_length=1, max_length=256) @@ -510,9 +533,7 @@ async def report_poisoning(body: ReportPoisoningRequest) -> ReportPoisoningRespo tenant = body.tenant_id or "anonymous" store = get_poisoning_report_store() await asyncio.to_thread(store.record, tenant, body.question_hash, body.reason) - report_hash = hashlib.sha256( - f"{tenant}:{body.question_hash}:{body.reason}".encode() - ).hexdigest()[:16] + report_hash = hashlib.sha256(f"{tenant}:{body.question_hash}:{body.reason}".encode()).hexdigest()[:16] return ReportPoisoningResponse(recorded=True, report_hash=report_hash) @@ -531,8 +552,7 @@ async def poisoning_reports( return { "count": len(reports), "reports": [ - {"tenant_id": r.tenant_id, "question_hash": r.question_hash, - "reason": r.reason, "timestamp": r.timestamp} + {"tenant_id": r.tenant_id, "question_hash": r.question_hash, "reason": r.reason, "timestamp": r.timestamp} for r in reports ], } @@ -562,13 +582,10 @@ async def preview_rewrite(body: RewriteRequest) -> dict[str, object]: rewriter: QueryRewriter = await asyncio.to_thread(get_rewriter) result = await asyncio.to_thread(rewriter.explain, body.question) return { - "original": result.original, + "original": result.original, "rewritten": result.rewritten, - "changed": result.changed, - "steps": [ - {"name": s.name, "before": s.before, "after": s.after, "changed": s.changed} - for s in result.steps - ], + "changed": result.changed, + "steps": [{"name": s.name, "before": s.before, "after": s.after, "changed": s.changed} for s in result.steps], } @@ -577,8 +594,8 @@ async def rewrite_config() -> dict[str, object]: """Return the active query-rewrite configuration.""" settings = get_settings() return { - "enabled": getattr(settings, "cache_query_rewrite_enabled", False), - "steps": getattr(settings, "cache_query_rewrite_steps", []), + "enabled": getattr(settings, "cache_query_rewrite_enabled", False), + "steps": getattr(settings, "cache_query_rewrite_steps", []), "step_names": (await asyncio.to_thread(get_rewriter)).step_names, } @@ -615,7 +632,11 @@ async def list_suspicious( for f in findings: await asyncio.to_thread( store.flag, - f["entry_hash"], f["question"], f["reason"], f["score"], f["signal"], + f["entry_hash"], + f["question"], + f["reason"], + f["score"], + f["signal"], ) return {"count": len(findings), "suspicious": findings} @@ -630,11 +651,11 @@ async def list_flagged() -> dict[str, object]: "flags": [ { "entry_hash": f.entry_hash, - "question": f.question, - "reason": f.reason, - "score": f.score, - "signal": f.signal, - "status": f.status, + "question": f.question, + "reason": f.reason, + "score": f.score, + "signal": f.signal, + "status": f.status, } for f in flags ], @@ -672,8 +693,11 @@ async def reject_suspicious(entry_hash: str) -> dict[str, object]: cache = _require_memory_cache() with cache._lock: # type: ignore[attr-defined] # The flag store uses normalised keys; scan exact dict for match - to_delete = [k for k in cache._exact # type: ignore[attr-defined] - if hashlib.sha256(k.encode()).hexdigest()[:16] == entry_hash] + to_delete = [ + k + for k in cache._exact # type: ignore[attr-defined] + if hashlib.sha256(k.encode()).hexdigest()[:16] == entry_hash + ] for k in to_delete: with cache._lock: # type: ignore[attr-defined] cache._lru.pop(k, None) # type: ignore[attr-defined] @@ -708,9 +732,9 @@ async def register_peer(body: RegisterPeerRequest) -> dict[str, object]: registry = get_peer_registry() node = await asyncio.to_thread(registry.register, body.url, name=body.name, auth_token=body.auth_token) return { - "peer_id": node.peer_id, - "url": node.url, - "name": node.name, + "peer_id": node.peer_id, + "url": node.url, + "name": node.name, "registered_at": node.registered_at, } diff --git a/konjoai/cache/analytics.py b/konjoai/cache/analytics.py index 3fa2784..6f70556 100644 --- a/konjoai/cache/analytics.py +++ b/konjoai/cache/analytics.py @@ -14,6 +14,7 @@ route after each lookup. Routes that want analytics snapshots call ``cache.analytics_snapshot()``. """ + from __future__ import annotations import math @@ -27,7 +28,7 @@ class AccessRecord: """One cache access event — immutable once created.""" - timestamp: float # time.monotonic() — not wall-clock; use for intervals only + timestamp: float # time.monotonic() — not wall-clock; use for intervals only latency_ms: float is_hit: bool similarity: float # cosine similarity for hits; 0.0 for misses @@ -108,80 +109,78 @@ def compute_analytics(records: list[AccessRecord], hours: float = 24.0) -> dict: if not window: return _empty_analytics(hours) - hits = [r for r in window if r.is_hit] - misses= [r for r in window if not r.is_hit] + hits = [r for r in window if r.is_hit] + misses = [r for r in window if not r.is_hit] total = len(window) hit_rate = len(hits) / total if total else 0.0 - all_lat = sorted(r.latency_ms for r in window) - hit_lat = sorted(r.latency_ms for r in hits) - miss_lat = sorted(r.latency_ms for r in misses) + all_lat = sorted(r.latency_ms for r in window) + hit_lat = sorted(r.latency_ms for r in hits) + miss_lat = sorted(r.latency_ms for r in misses) def _stats(vals: list[float]) -> dict: if not vals: return {"p50": 0.0, "p90": 0.0, "p99": 0.0, "mean": 0.0, "min": 0.0, "max": 0.0} return { - "p50": round(_percentile(vals, 50), 3), - "p90": round(_percentile(vals, 90), 3), - "p99": round(_percentile(vals, 99), 3), - "mean": round(sum(vals) / len(vals), 3), - "min": round(vals[0], 3), - "max": round(vals[-1], 3), + "p50": round(_percentile(vals, 50), 3), + "p90": round(_percentile(vals, 90), 3), + "p99": round(_percentile(vals, 99), 3), + "mean": round(sum(vals) / len(vals), 3), + "min": round(vals[0], 3), + "max": round(vals[-1], 3), } - # Similarity distribution — 5 buckets: [0–0.2), [0.2–0.4), …, [0.8–1.0] - sim_vals = [r.similarity for r in hits] + return { + "window_hours": hours, + "total_accesses": total, + "hit_count": len(hits), + "miss_count": len(misses), + "hit_rate": round(hit_rate, 4), + "latency": {"all": _stats(all_lat), "hits": _stats(hit_lat), "misses": _stats(miss_lat)}, + "similarity_distribution": _similarity_distribution(hits), + "hourly_hit_rate": _hourly_hit_rate(window, hours), + } + + +def _similarity_distribution(hits: list[AccessRecord]) -> list[dict]: + """Return a 5-bucket histogram of hit similarity scores over [0, 1].""" sim_histo = [0, 0, 0, 0, 0] - for s in sim_vals: - bucket = min(4, int(s * 5)) - sim_histo[bucket] += 1 - sim_distribution = [ - {"range": f"{i*0.2:.1f}–{(i+1)*0.2:.1f}", "count": sim_histo[i]} - for i in range(5) - ] + for r in hits: + sim_histo[min(4, int(r.similarity * 5))] += 1 + return [{"range": f"{i * 0.2:.1f}–{(i + 1) * 0.2:.1f}", "count": sim_histo[i]} for i in range(5)] - # Hourly breakdown — bucket records into integer-hour offsets from the oldest + +def _hourly_hit_rate(window: list[AccessRecord], hours: float) -> list[dict]: + """Bucket records into one-hour offsets and return per-bucket hit rates.""" now = time.monotonic() n_buckets = max(1, int(math.ceil(hours))) - bucket_hits = [0] * n_buckets + bucket_hits = [0] * n_buckets bucket_totals = [0] * n_buckets for r in window: - age_hours = (now - r.timestamp) / 3600.0 - idx = min(n_buckets - 1, int(age_hours)) - reverse_idx = n_buckets - 1 - idx # 0 = most recent hour + idx = min(n_buckets - 1, int((now - r.timestamp) / 3600.0)) + reverse_idx = n_buckets - 1 - idx # 0 = most recent hour bucket_totals[reverse_idx] += 1 if r.is_hit: bucket_hits[reverse_idx] += 1 - hourly = [] - for i in range(n_buckets): - t = bucket_totals[i] - hourly.append({ - "hour_offset": i - (n_buckets - 1), # negative = past - "count": t, - "hit_rate": round(bucket_hits[i] / t, 4) if t else 0.0, - }) - - return { - "window_hours": hours, - "total_accesses": total, - "hit_count": len(hits), - "miss_count": len(misses), - "hit_rate": round(hit_rate, 4), - "latency": {"all": _stats(all_lat), "hits": _stats(hit_lat), "misses": _stats(miss_lat)}, - "similarity_distribution": sim_distribution, - "hourly_hit_rate": hourly, - } + return [ + { + "hour_offset": i - (n_buckets - 1), # negative = past + "count": bucket_totals[i], + "hit_rate": round(bucket_hits[i] / bucket_totals[i], 4) if bucket_totals[i] else 0.0, + } + for i in range(n_buckets) + ] def _empty_analytics(hours: float) -> dict: empty_stats = {"p50": 0.0, "p90": 0.0, "p99": 0.0, "mean": 0.0, "min": 0.0, "max": 0.0} return { - "window_hours": hours, - "total_accesses": 0, - "hit_count": 0, - "miss_count": 0, - "hit_rate": 0.0, - "latency": {"all": empty_stats, "hits": empty_stats, "misses": empty_stats}, - "similarity_distribution": [{"range": f"{i*0.2:.1f}–{(i+1)*0.2:.1f}", "count": 0} for i in range(5)], - "hourly_hit_rate": [], + "window_hours": hours, + "total_accesses": 0, + "hit_count": 0, + "miss_count": 0, + "hit_rate": 0.0, + "latency": {"all": empty_stats, "hits": empty_stats, "misses": empty_stats}, + "similarity_distribution": [{"range": f"{i * 0.2:.1f}–{(i + 1) * 0.2:.1f}", "count": 0} for i in range(5)], + "hourly_hit_rate": [], } diff --git a/konjoai/cache/suspicious.py b/konjoai/cache/suspicious.py index f8b7bc4..1df7b33 100644 --- a/konjoai/cache/suspicious.py +++ b/konjoai/cache/suspicious.py @@ -26,6 +26,7 @@ than 2×k entries (not enough data for reliable clustering). K5: pure stdlib + numpy. """ + from __future__ import annotations import hashlib @@ -43,11 +44,11 @@ class SuspiciousFlag: """A single flagged cache entry awaiting review.""" - entry_hash: str # SHA-256 prefix of the normalised question - question: str # the (normalised) question text - reason: str # human-readable detection reason - score: float # outlier score (higher = more suspicious) - signal: str # "embedding_outlier" | "hit_count_anomaly" | "answer_length_anomaly" + entry_hash: str # SHA-256 prefix of the normalised question + question: str # the (normalised) question text + reason: str # human-readable detection reason + score: float # outlier score (higher = more suspicious) + signal: str # "embedding_outlier" | "hit_count_anomaly" | "answer_length_anomaly" status: Literal["pending", "approved", "rejected"] = "pending" created_at: float = field(default_factory=time.monotonic) @@ -72,9 +73,7 @@ def flag(self, entry_hash: str, question: str, reason: str, score: float, signal with self._lock: if len(self._flags) >= self.MAX_FLAGS and entry_hash not in self._flags: # Evict the oldest resolved flag to make room - oldest_resolved = next( - (k for k, f in self._flags.items() if f.status != "pending"), None - ) + oldest_resolved = next((k for k, f in self._flags.items() if f.status != "pending"), None) if oldest_resolved: del self._flags[oldest_resolved] self._flags[entry_hash] = SuspiciousFlag( @@ -151,10 +150,10 @@ def scan_for_suspicious( if n < max(4, k * 2): return [] - questions = [e[1] for e in entries] - vecs = np.vstack([e[2].ravel() for e in entries]).astype(np.float32) - hit_counts = np.array([e[3] for e in entries], dtype=np.float32) - answers = [e[4].answer if hasattr(e[4], "answer") else str(e[4]) for e in entries] + questions = [e[1] for e in entries] + vecs = np.vstack([e[2].ravel() for e in entries]).astype(np.float32) + hit_counts = np.array([e[3] for e in entries], dtype=np.float32) + answers = [e[4].answer if hasattr(e[4], "answer") else str(e[4]) for e in entries] ans_lengths = np.array([len(a) for a in answers], dtype=np.float32) # L2-normalise vecs for cosine distance @@ -163,73 +162,92 @@ def scan_for_suspicious( unit_vecs = vecs / norms suspicious: list[dict] = [] + suspicious += _embedding_outlier_findings(unit_vecs, questions, k, n, z_threshold) + suspicious += _zscore_findings( + hit_counts, + questions, + z_threshold, + "hit_count_anomaly", + lambda hc, s: f"hit count {hc:.0f} is {s:.2f}σ above mean", + ) + suspicious += _zscore_findings( + ans_lengths, + questions, + z_threshold, + "answer_length_anomaly", + lambda al, s: f"answer length {al:.0f} chars is {s:.2f}σ from mean", + two_sided=True, + ) + + # Deduplicate by entry_hash, keep highest score + by_hash: dict[str, dict] = {} + for item in suspicious: + h = item["entry_hash"] + if h not in by_hash or item["score"] > by_hash[h]["score"]: + by_hash[h] = item + return sorted(by_hash.values(), key=lambda x: x["score"], reverse=True) + - # ── Signal 1: embedding outlier (per-cluster) ───────────────────────── +def _make_finding(question: str, reason: str, score: float, signal: str) -> dict: + """Build a single suspicious-entry record for the API response.""" + return { + "entry_hash": _entry_hash(question.lower().strip()), + "question": question, + "reason": reason, + "score": round(float(score), 3), + "signal": signal, + } + + +def _embedding_outlier_findings( + unit_vecs: np.ndarray, questions: list[str], k: int, n: int, z_threshold: float +) -> list[dict]: + """Flag entries whose embedding is a per-cluster cosine-distance outlier.""" + findings: list[dict] = [] actual_k = min(k, n // 2) centroids, labels = _mini_kmeans(unit_vecs, actual_k, iters=15) for ci in range(actual_k): mask = labels == ci if mask.sum() < 2: continue - cluster_vecs = unit_vecs[mask] - centroid = centroids[ci] - dists = 1.0 - cluster_vecs @ centroid # cosine distance + dists = 1.0 - unit_vecs[mask] @ centroids[ci] # cosine distance mean_d = float(dists.mean()) - std_d = float(dists.std()) + 1e-9 + std_d = float(dists.std()) + 1e-9 for local_i, global_i in enumerate(np.where(mask)[0]): score = (dists[local_i] - mean_d) / std_d if score > z_threshold: - q = questions[global_i] - suspicious.append({ - "entry_hash": _entry_hash(q.lower().strip()), - "question": q, - "reason": f"embedding distance {score:.2f}σ above cluster mean (cluster {ci})", - "score": round(float(score), 3), - "signal": "embedding_outlier", - }) - - # ── Signal 2: hit-count anomaly ──────────────────────────────────────── - hc_mean = float(hit_counts.mean()) - hc_std = float(hit_counts.std()) + 1e-9 - for i, hc in enumerate(hit_counts): - score = (hc - hc_mean) / hc_std - if score > z_threshold: - q = questions[i] - suspicious.append({ - "entry_hash": _entry_hash(q.lower().strip()), - "question": q, - "reason": f"hit count {hc:.0f} is {score:.2f}σ above mean", - "score": round(float(score), 3), - "signal": "hit_count_anomaly", - }) - - # ── Signal 3: answer length anomaly ─────────────────────────────────── - al_mean = float(ans_lengths.mean()) - al_std = float(ans_lengths.std()) + 1e-9 - for i, al in enumerate(ans_lengths): - score = abs(al - al_mean) / al_std - if score > z_threshold: - q = questions[i] - suspicious.append({ - "entry_hash": _entry_hash(q.lower().strip()), - "question": q, - "reason": f"answer length {al:.0f} chars is {score:.2f}σ from mean", - "score": round(float(score), 3), - "signal": "answer_length_anomaly", - }) + findings.append( + _make_finding( + questions[global_i], + f"embedding distance {score:.2f}σ above cluster mean (cluster {ci})", + score, + "embedding_outlier", + ) + ) + return findings - # Deduplicate by entry_hash, keep highest score - by_hash: dict[str, dict] = {} - for item in suspicious: - h = item["entry_hash"] - if h not in by_hash or item["score"] > by_hash[h]["score"]: - by_hash[h] = item - return sorted(by_hash.values(), key=lambda x: x["score"], reverse=True) + +def _zscore_findings( + values: np.ndarray, + questions: list[str], + z_threshold: float, + signal: str, + reason: object, + *, + two_sided: bool = False, +) -> list[dict]: + """Flag entries whose scalar metric is a z-score outlier over the population.""" + mean = float(values.mean()) + std = float(values.std()) + 1e-9 + findings: list[dict] = [] + for i, v in enumerate(values): + score = abs(v - mean) / std if two_sided else (v - mean) / std + if score > z_threshold: + findings.append(_make_finding(questions[i], reason(v, score), score, signal)) + return findings -def _mini_kmeans( - vecs: np.ndarray, k: int, iters: int = 15 -) -> tuple[np.ndarray, np.ndarray]: +def _mini_kmeans(vecs: np.ndarray, k: int, iters: int = 15) -> tuple[np.ndarray, np.ndarray]: """Lightweight k-means++ on L2-normalised unit vectors. Returns (centroids, labels).""" rng = np.random.default_rng(seed=42) n = len(vecs) diff --git a/konjoai/cache/tracing.py b/konjoai/cache/tracing.py index 0d5f2e7..3121b2c 100644 --- a/konjoai/cache/tracing.py +++ b/konjoai/cache/tracing.py @@ -23,6 +23,7 @@ is False in Settings, every function in this module is a no-op. The caller never branches on OTel availability. """ + from __future__ import annotations import logging @@ -42,6 +43,7 @@ try: from opentelemetry import trace as _otel_trace # type: ignore[import-untyped] # noqa: F401 from opentelemetry.trace import NonRecordingSpan # type: ignore[import-untyped] # noqa: F401 + _HAS_OTEL = True except ImportError: _HAS_OTEL = False @@ -55,7 +57,6 @@ def cache_span( operation: str, *, - tracer_name: str = "kyro.cache", enabled: bool = True, ) -> Generator[object, None, None]: """Context manager that wraps a block in an OTel span named ``cache.``. diff --git a/konjoai/generate/generator.py b/konjoai/generate/generator.py index 5bf7aed..bc4b561 100644 --- a/konjoai/generate/generator.py +++ b/konjoai/generate/generator.py @@ -35,24 +35,48 @@ class Generator(Protocol): def generate(self, question: str, context: str) -> GenerationResult: ... -class OpenAIGenerator: - """Generator backed by the OpenAI Chat Completions API.""" +class _BaseGenerator: + """Shared prompt formatting and the sync→async streaming bridge. - def __init__(self, model: str, api_key: str, max_tokens: int = 1024) -> None: - try: - from openai import OpenAI - except ImportError as e: - raise ImportError("openai is required: pip install openai") from e + Concrete backends supply ``generate`` and ``generate_stream``; the async + ``stream`` interface and ``_format_prompt`` helper are common to all. + """ - self._client = OpenAI(api_key=api_key) - self._model = model - self._max_tokens = max_tokens + _model: str + _max_tokens: int + + @staticmethod + def _format_prompt(question: str, context: str) -> str: + """Render the RAG prompt template for the given question and context.""" + return RAG_PROMPT.format(context=context, question=question) + + def generate_stream(self, question: str, context: str) -> Iterator[str]: + """Yield response tokens one at a time. Overridden by each backend.""" + raise NotImplementedError + + async def stream(self, question: str, context: str) -> AsyncIterator[str]: + """Async token-streaming interface; bridges generate_stream() via asyncio.to_thread.""" + sentinel = object() + sync_gen = self.generate_stream(question=question, context=context) + + def _next() -> object: + return next(sync_gen, sentinel) + + while True: + token = await asyncio.to_thread(_next) + if token is sentinel: + break + yield token + + +class _OpenAIChatGenerator(_BaseGenerator): + """Shared generate/stream logic for OpenAI-compatible Chat Completions backends.""" def generate(self, question: str, context: str) -> GenerationResult: - prompt = RAG_PROMPT.format(context=context, question=question) + """Return a single completion from the Chat Completions API.""" resp = self._client.chat.completions.create( model=self._model, - messages=[{"role": "user", "content": prompt}], + messages=[{"role": "user", "content": self._format_prompt(question, context)}], max_tokens=self._max_tokens, ) return GenerationResult( @@ -65,33 +89,32 @@ def generate(self, question: str, context: str) -> GenerationResult: ) def generate_stream(self, question: str, context: str) -> Iterator[str]: - """Yield response tokens one at a time from the OpenAI streaming API.""" - prompt = RAG_PROMPT.format(context=context, question=question) + """Yield response tokens one at a time from the Chat Completions streaming API.""" stream = self._client.chat.completions.create( model=self._model, - messages=[{"role": "user", "content": prompt}], + messages=[{"role": "user", "content": self._format_prompt(question, context)}], max_tokens=self._max_tokens, stream=True, ) for chunk in stream: yield chunk.choices[0].delta.content or "" - async def stream(self, question: str, context: str) -> AsyncIterator[str]: - """Async token-streaming interface; bridges generate_stream() via asyncio.to_thread.""" - sentinel = object() - sync_gen = self.generate_stream(question=question, context=context) - def _next() -> object: - return next(sync_gen, sentinel) +class OpenAIGenerator(_OpenAIChatGenerator): + """Generator backed by the OpenAI Chat Completions API.""" - while True: - token = await asyncio.to_thread(_next) - if token is sentinel: - break - yield token + def __init__(self, model: str, api_key: str, max_tokens: int = 1024) -> None: + try: + from openai import OpenAI + except ImportError as e: + raise ImportError("openai is required: pip install openai") from e + + self._client = OpenAI(api_key=api_key) + self._model = model + self._max_tokens = max_tokens -class AnthropicGenerator: +class AnthropicGenerator(_BaseGenerator): """Generator backed by the Anthropic Messages API.""" def __init__(self, model: str, api_key: str, max_tokens: int = 1024) -> None: @@ -105,11 +128,11 @@ def __init__(self, model: str, api_key: str, max_tokens: int = 1024) -> None: self._max_tokens = max_tokens def generate(self, question: str, context: str) -> GenerationResult: - prompt = RAG_PROMPT.format(context=context, question=question) + """Return a single completion from the Anthropic Messages API.""" resp = self._client.messages.create( model=self._model, max_tokens=self._max_tokens, - messages=[{"role": "user", "content": prompt}], + messages=[{"role": "user", "content": self._format_prompt(question, context)}], ) text = resp.content[0].text if resp.content else "" return GenerationResult( @@ -120,30 +143,15 @@ def generate(self, question: str, context: str) -> GenerationResult: def generate_stream(self, question: str, context: str) -> Iterator[str]: """Yield response tokens one at a time from the Anthropic streaming API.""" - prompt = RAG_PROMPT.format(context=context, question=question) with self._client.messages.stream( model=self._model, max_tokens=self._max_tokens, - messages=[{"role": "user", "content": prompt}], + messages=[{"role": "user", "content": self._format_prompt(question, context)}], ) as stream: yield from stream.text_stream - async def stream(self, question: str, context: str) -> AsyncIterator[str]: - """Async token-streaming interface; bridges generate_stream() via asyncio.to_thread.""" - sentinel = object() - sync_gen = self.generate_stream(question=question, context=context) - - def _next() -> object: - return next(sync_gen, sentinel) - while True: - token = await asyncio.to_thread(_next) - if token is sentinel: - break - yield token - - -class SquishGenerator: +class SquishGenerator(_OpenAIChatGenerator): """Generator backed by a locally-running Squish inference server (OpenAI-compatible API).""" def __init__(self, model: str, base_url: str, max_tokens: int = 1024) -> None: @@ -156,48 +164,6 @@ def __init__(self, model: str, base_url: str, max_tokens: int = 1024) -> None: self._model = model self._max_tokens = max_tokens - def generate(self, question: str, context: str) -> GenerationResult: - prompt = RAG_PROMPT.format(context=context, question=question) - resp = self._client.chat.completions.create( - model=self._model, - messages=[{"role": "user", "content": prompt}], - max_tokens=self._max_tokens, - ) - return GenerationResult( - answer=resp.choices[0].message.content or "", - model=resp.model, - usage={ - "prompt_tokens": resp.usage.prompt_tokens if resp.usage else 0, - "completion_tokens": resp.usage.completion_tokens if resp.usage else 0, - }, - ) - - def generate_stream(self, question: str, context: str) -> Iterator[str]: - """Yield response tokens one at a time from the Squish OpenAI-compatible streaming API.""" - prompt = RAG_PROMPT.format(context=context, question=question) - stream = self._client.chat.completions.create( - model=self._model, - messages=[{"role": "user", "content": prompt}], - max_tokens=self._max_tokens, - stream=True, - ) - for chunk in stream: - yield chunk.choices[0].delta.content or "" - - async def stream(self, question: str, context: str) -> AsyncIterator[str]: - """Async token-streaming interface; bridges generate_stream() via asyncio.to_thread.""" - sentinel = object() - sync_gen = self.generate_stream(question=question, context=context) - - def _next() -> object: - return next(sync_gen, sentinel) - - while True: - token = await asyncio.to_thread(_next) - if token is sentinel: - break - yield token - def get_generator() -> Generator: """Return the module-level singleton generator (lazy init, reads from settings).""" @@ -226,20 +192,13 @@ def get_generator() -> Generator: "Set it in your .env file or environment, or switch to another backend:\n" " GENERATOR_BACKEND=squish" ) - _generator = AnthropicGenerator( - model=s.anthropic_model, api_key=s.anthropic_api_key, max_tokens=s.max_tokens - ) + _generator = AnthropicGenerator(model=s.anthropic_model, api_key=s.anthropic_api_key, max_tokens=s.max_tokens) elif backend == "squish": - _generator = SquishGenerator( - model=s.squish_model, base_url=s.squish_base_url, max_tokens=s.max_tokens - ) + _generator = SquishGenerator(model=s.squish_model, base_url=s.squish_base_url, max_tokens=s.max_tokens) else: - raise ValueError( - f"Unknown generator backend: {backend!r}. " - "Valid values: 'openai', 'anthropic', 'squish'." - ) + raise ValueError(f"Unknown generator backend: {backend!r}. Valid values: 'openai', 'anthropic', 'squish'.") logger.info("Generator initialised: backend=%s", backend) return _generator diff --git a/konjoai/ingest/chunkers.py b/konjoai/ingest/chunkers.py index 2bf7616..43976d1 100644 --- a/konjoai/ingest/chunkers.py +++ b/konjoai/ingest/chunkers.py @@ -24,6 +24,7 @@ def chunk(self, doc: Document) -> list[Chunk]: ... # ── Recursive character splitter ───────────────────────────────────────────── + class RecursiveChunker: """Split text recursively on paragraph → sentence → word boundaries.""" @@ -93,6 +94,7 @@ def _merge(self, parts: list[str], sep: str) -> list[str]: try: import numpy as np # already required by sentence-transformers + _NUMPY_AVAILABLE = True except ImportError: # pragma: no cover _NUMPY_AVAILABLE = False @@ -108,13 +110,39 @@ def _cosine_similarities(embeddings: np.ndarray) -> np.ndarray: ``(N-1,)`` float32 array of adjacent cosine similarities. """ import numpy as np # noqa: PLC0415 — guard already checked at call site + norms = np.linalg.norm(embeddings, axis=1, keepdims=True) norms = np.where(norms == 0.0, 1.0, norms) normed = embeddings / norms return np.einsum("ij,ij->i", normed[:-1], normed[1:]).astype(np.float32) -class SemanticSplitter: +class _EncoderBackedChunker: + """Mixin providing the lazy encoder load shared by embedding-based chunkers.""" + + model_name: str + device: str + + def _get_encoder(self): + """Return the encoder, loading the model lazily on first call.""" + if self._enc is None: + from konjoai.embed.encoder import SentenceEncoder # noqa: PLC0415 + + self._enc = SentenceEncoder(model_name=self.model_name, device=self.device) + return self._enc + + def _encode(self, texts: list[str]) -> np.ndarray: + """Encode *texts*; supports both :class:`SentenceEncoder` and raw callables.""" + import numpy as np # noqa: PLC0415 + + enc = self._get_encoder() + if hasattr(enc, "encode"): + return enc.encode(texts) + result = enc(texts) + return np.array(result, dtype=np.float32) + + +class SemanticSplitter(_EncoderBackedChunker): """Split documents at semantic paragraph boundaries. Embeds every sentence individually (plus an optional context buffer of @@ -160,35 +188,13 @@ def __init__( _encoder=None, ) -> None: if not 0.0 <= similarity_threshold <= 1.0: - raise ValueError( - f"similarity_threshold must be in [0, 1], got {similarity_threshold}" - ) + raise ValueError(f"similarity_threshold must be in [0, 1], got {similarity_threshold}") self.model_name = model_name self.similarity_threshold = similarity_threshold self.buffer_size = buffer_size self.device = device self._enc = _encoder # lazy-loaded on first chunk() call when None - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _get_encoder(self): - """Return the encoder, loading the model lazily on first call.""" - if self._enc is None: - from konjoai.embed.encoder import SentenceEncoder # noqa: PLC0415 - self._enc = SentenceEncoder(model_name=self.model_name, device=self.device) - return self._enc - - def _encode(self, texts: list[str]) -> np.ndarray: - """Encode *texts*; supports both :class:`SentenceEncoder` and raw callables.""" - import numpy as np # noqa: PLC0415 - enc = self._get_encoder() - if hasattr(enc, "encode"): - return enc.encode(texts) - result = enc(texts) - return np.array(result, dtype=np.float32) - # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -227,9 +233,7 @@ def chunk(self, doc: Document) -> list[Chunk]: return self._build_chunks(sentences, split_after, doc) - def _build_chunks( - self, sentences: list[str], split_after: list[int], doc: Document - ) -> list[Chunk]: + def _build_chunks(self, sentences: list[str], split_after: list[int], doc: Document) -> list[Chunk]: """Convert split indices into :class:`Chunk` objects.""" chunks: list[Chunk] = [] start = 0 @@ -281,7 +285,7 @@ def _build_chunks( # ── Late Chunker ────────────────────────────────────────────────────────────── -class LateChunker: +class LateChunker(_EncoderBackedChunker): """Post-embedding semantic chunking (Late Chunking). Implements the Late Chunking technique from *Jina AI (2024)*: @@ -332,9 +336,7 @@ def __init__( _encoder=None, ) -> None: if not 0.0 <= similarity_threshold <= 1.0: - raise ValueError( - f"similarity_threshold must be in [0, 1], got {similarity_threshold}" - ) + raise ValueError(f"similarity_threshold must be in [0, 1], got {similarity_threshold}") if max_chunk_tokens < 1: raise ValueError(f"max_chunk_tokens must be ≥ 1, got {max_chunk_tokens}") self.model_name = model_name @@ -347,20 +349,6 @@ def __init__( # Internal helpers # ------------------------------------------------------------------ - def _get_encoder(self): - if self._enc is None: - from konjoai.embed.encoder import SentenceEncoder # noqa: PLC0415 - self._enc = SentenceEncoder(model_name=self.model_name, device=self.device) - return self._enc - - def _encode(self, texts: list[str]) -> np.ndarray: - import numpy as np # noqa: PLC0415 - enc = self._get_encoder() - if hasattr(enc, "encode"): - return enc.encode(texts) - result = enc(texts) - return np.array(result, dtype=np.float32) - # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -454,6 +442,7 @@ def chunk(self, doc: Document) -> list[Chunk]: # ── Sentence-window chunker ─────────────────────────────────────────────────── + class SentenceWindowChunker: """Anchor on sentences; add a window of surrounding sentences as context.""" @@ -486,6 +475,7 @@ def chunk(self, doc: Document) -> list[Chunk]: # ── Factory ─────────────────────────────────────────────────────────────────── + def get_chunker( strategy: str = "recursive", chunk_size: int = 512, @@ -532,6 +522,5 @@ def get_chunker( _encoder=_encoder, ) raise ValueError( - f"Unknown chunking strategy: {strategy!r}. " - "Choose 'recursive', 'sentence_window', 'semantic', or 'late'." + f"Unknown chunking strategy: {strategy!r}. Choose 'recursive', 'sentence_window', 'semantic', or 'late'." ) diff --git a/konjoai/retrieve/crag.py b/konjoai/retrieve/crag.py index 81457c0..ca4a887 100644 --- a/konjoai/retrieve/crag.py +++ b/konjoai/retrieve/crag.py @@ -10,6 +10,7 @@ 4. If there is a CORRECT/AMBIGUOUS mix, keep CORRECT chunks and refine AMBIGUOUS chunks via decomposed sub-queries. """ + from __future__ import annotations import logging @@ -87,9 +88,7 @@ def __init__( max_sub_queries: int = 4, ) -> None: if not (0.0 <= ambiguous_threshold < correct_threshold <= 1.0): - raise ValueError( - "CRAG thresholds must satisfy 0.0 <= ambiguous < correct <= 1.0" - ) + raise ValueError("CRAG thresholds must satisfy 0.0 <= ambiguous < correct <= 1.0") self.correct_threshold = correct_threshold self.ambiguous_threshold = ambiguous_threshold self.max_sub_queries = max_sub_queries @@ -101,60 +100,86 @@ def run(self, query: str, chunks: list[Any]) -> CRAGResult: scored_chunks = self._score_chunks(query, chunks) if not scored_chunks: - return CRAGResult( - selected_chunks=[], - scored_chunks=[], - fallback_chunks=[], - crag_scores=[], - crag_classification=[], - refinement_triggered=False, - fallback_triggered=False, - mean_selected_score=0.0, - ) + return self._empty_result() - classes = [c.classification for c in scored_chunks] - scores = [c.crag_score for c in scored_chunks] + correct_chunks, ambiguous_chunks, incorrect_chunks = self._partition(scored_chunks) - if all(c == CRAGClassification.INCORRECT for c in classes): + if len(incorrect_chunks) == len(scored_chunks): fallback_chunks = self.web_fallback(query) - return CRAGResult( - selected_chunks=fallback_chunks, - scored_chunks=scored_chunks, - fallback_chunks=fallback_chunks, - crag_scores=scores, - crag_classification=[c.value for c in classes], + return self._build_result( + scored_chunks, + fallback_chunks, + fallback_chunks, refinement_triggered=False, fallback_triggered=True, - mean_selected_score=self._mean_score(fallback_chunks), ) - correct_chunks = [ - c for c in scored_chunks if c.classification == CRAGClassification.CORRECT - ] - ambiguous_chunks = [ - c for c in scored_chunks if c.classification == CRAGClassification.AMBIGUOUS - ] - refinement_triggered = bool(ambiguous_chunks and correct_chunks) - refined_chunks: list[CRAGChunk] = [] - if ambiguous_chunks: - refined_chunks = self._refine_ambiguous(query, ambiguous_chunks) + refined_chunks = self._refine_ambiguous(query, ambiguous_chunks) if ambiguous_chunks else [] selected = correct_chunks + refined_chunks - fallback_chunks: list[CRAGChunk] = [] + fallback_chunks = [] fallback_triggered = False - if not selected: fallback_chunks = self.web_fallback(query) selected = fallback_chunks fallback_triggered = True + return self._build_result( + scored_chunks, + selected, + fallback_chunks, + refinement_triggered=refinement_triggered, + fallback_triggered=fallback_triggered, + ) + + @staticmethod + def _partition( + scored_chunks: list[CRAGChunk], + ) -> tuple[list[CRAGChunk], list[CRAGChunk], list[CRAGChunk]]: + """Split scored chunks into (correct, ambiguous, incorrect) by classification.""" + correct: list[CRAGChunk] = [] + ambiguous: list[CRAGChunk] = [] + incorrect: list[CRAGChunk] = [] + for c in scored_chunks: + if c.classification == CRAGClassification.CORRECT: + correct.append(c) + elif c.classification == CRAGClassification.AMBIGUOUS: + ambiguous.append(c) + else: + incorrect.append(c) + return correct, ambiguous, incorrect + + @staticmethod + def _empty_result() -> CRAGResult: + """Return the CRAGResult used when no chunks survive scoring.""" + return CRAGResult( + selected_chunks=[], + scored_chunks=[], + fallback_chunks=[], + crag_scores=[], + crag_classification=[], + refinement_triggered=False, + fallback_triggered=False, + mean_selected_score=0.0, + ) + + def _build_result( + self, + scored_chunks: list[CRAGChunk], + selected: list[CRAGChunk], + fallback_chunks: list[CRAGChunk], + *, + refinement_triggered: bool, + fallback_triggered: bool, + ) -> CRAGResult: + """Assemble a CRAGResult, deriving scores/classification from scored_chunks.""" return CRAGResult( selected_chunks=selected, scored_chunks=scored_chunks, fallback_chunks=fallback_chunks, - crag_scores=scores, - crag_classification=[c.value for c in classes], + crag_scores=[c.crag_score for c in scored_chunks], + crag_classification=[c.classification.value for c in scored_chunks], refinement_triggered=refinement_triggered, fallback_triggered=fallback_triggered, mean_selected_score=self._mean_score(selected), diff --git a/tests/unit/_route_harness.py b/tests/unit/_route_harness.py new file mode 100644 index 0000000..d418faf --- /dev/null +++ b/tests/unit/_route_harness.py @@ -0,0 +1,99 @@ +"""Shared scaffolding for ``/query`` route unit tests. + +Centralises the settings/generator stubs, the FastAPI test-app builder, the +proxy-env scrubber, and the default hybrid/rerank fixtures that every route +test would otherwise duplicate verbatim. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from konjoai.api.routes.query import router +from konjoai.generate.generator import GenerationResult +from konjoai.retrieve.hybrid import HybridResult +from konjoai.retrieve.reranker import RerankResult + +# Every proxy env var that can make TestClient attempt a real network call. +_PROXY_VARS = ( + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "GRPC_PROXY", + "grpc_proxy", +) + + +@dataclass +class SettingsStub: + """Stand-in for ``KyroConfig`` covering the flags the query route reads.""" + + enable_query_router: bool = True + enable_hyde: bool = False + enable_telemetry: bool = True + use_vectro_retriever: bool = False + use_colbert: bool = False + enable_crag: bool = False + enable_self_rag: bool = False + enable_query_decomposition: bool = False + decomposition_max_sub_queries: int = 4 + self_rag_max_iterations: int = 3 + top_k_dense: int = 8 + top_k_sparse: int = 8 + openai_model: str = "stub-model" + request_timeout_seconds: float = 30.0 + enable_graph_rag: bool = False + graph_rag_max_communities: int = 5 + graph_rag_similarity_threshold: float = 0.3 + otel_enabled: bool = False + audit_enabled: bool = False + + +class GeneratorStub: + """Generator that returns a fixed ``base answer`` regardless of input.""" + + def generate(self, question: str, context: str) -> GenerationResult: + """Return a deterministic stub completion.""" + _ = (question, context) + return GenerationResult( + answer="base answer", + model="stub-model", + usage={"prompt_tokens": 10, "completion_tokens": 2}, + ) + + +def make_app() -> FastAPI: + """Return a FastAPI app with only the query router mounted.""" + app = FastAPI() + app.include_router(router) + return app + + +def make_client() -> TestClient: + """Return a TestClient wrapping a fresh query-router app.""" + return TestClient(make_app()) + + +def clear_proxy_env(monkeypatch) -> None: + """Remove every proxy env var so TestClient never hits the network.""" + for var in _PROXY_VARS: + monkeypatch.delenv(var, raising=False) + + +def default_hybrid_results() -> list[HybridResult]: + """Return the two-item hybrid-search result list used across route tests.""" + return [ + HybridResult(content="h1", source="a.txt", rrf_score=0.8, metadata={}), + HybridResult(content="h2", source="b.txt", rrf_score=0.7, metadata={}), + ] + + +def default_reranked_results() -> list[RerankResult]: + """Return the single-item rerank result list used across route tests.""" + return [RerankResult(score=0.95, content="h1", source="a.txt", metadata={})] diff --git a/tests/unit/test_query_crag_route.py b/tests/unit/test_query_crag_route.py index d59ded5..3d1f3d1 100644 --- a/tests/unit/test_query_crag_route.py +++ b/tests/unit/test_query_crag_route.py @@ -1,39 +1,24 @@ from __future__ import annotations -from dataclasses import dataclass from unittest.mock import MagicMock, patch -from fastapi import FastAPI from fastapi.testclient import TestClient -from konjoai.api.routes.query import router from konjoai.generate.generator import GenerationResult from konjoai.retrieve.crag import CRAGChunk, CRAGClassification, CRAGResult -from konjoai.retrieve.hybrid import HybridResult -from konjoai.retrieve.reranker import RerankResult from konjoai.retrieve.router import QueryIntent - -@dataclass -class _SettingsStub: - enable_query_router: bool = True - enable_hyde: bool = False - enable_telemetry: bool = True - use_vectro_retriever: bool = False - use_colbert: bool = False - enable_crag: bool = False - enable_self_rag: bool = False - enable_query_decomposition: bool = False - decomposition_max_sub_queries: int = 4 - top_k_dense: int = 8 - top_k_sparse: int = 8 - openai_model: str = "stub-model" - request_timeout_seconds: float = 30.0 - enable_graph_rag: bool = False - graph_rag_max_communities: int = 5 - graph_rag_similarity_threshold: float = 0.3 - otel_enabled: bool = False - audit_enabled: bool = False +from ._route_harness import ( + SettingsStub, + default_hybrid_results, + default_reranked_results, +) +from ._route_harness import ( + clear_proxy_env as _clear_proxy_env, +) +from ._route_harness import ( + make_app as _make_app, +) class _GeneratorStub: @@ -46,12 +31,6 @@ def generate(self, question: str, context: str) -> GenerationResult: ) -def _make_app() -> FastAPI: - app = FastAPI() - app.include_router(router) - return app - - def _make_crag_result() -> CRAGResult: scored = [ CRAGChunk( @@ -84,19 +63,11 @@ def _make_crag_result() -> CRAGResult: def _base_patches(crag_runner: MagicMock): - hybrid_results = [ - HybridResult(content="h1", source="a.txt", rrf_score=0.8, metadata={}), - HybridResult(content="h2", source="b.txt", rrf_score=0.7, metadata={}), - ] - reranked_results = [ - RerankResult(score=0.95, content="h1", source="a.txt", metadata={}), - ] - return ( - patch("konjoai.api.routes.query.get_settings", return_value=_SettingsStub()), + patch("konjoai.api.routes.query.get_settings", return_value=SettingsStub()), patch("konjoai.retrieve.router.classify_intent", return_value=QueryIntent.RETRIEVAL), - patch("konjoai.retrieve.hybrid.hybrid_search", return_value=hybrid_results), - patch("konjoai.retrieve.reranker.rerank", return_value=reranked_results), + patch("konjoai.retrieve.hybrid.hybrid_search", return_value=default_hybrid_results()), + patch("konjoai.retrieve.reranker.rerank", return_value=default_reranked_results()), patch("konjoai.generate.generator.get_generator", return_value=_GeneratorStub()), patch("konjoai.cache.get_semantic_cache", return_value=None), patch("konjoai.retrieve.crag.get_crag_pipeline", return_value=crag_runner), @@ -104,17 +75,7 @@ def _base_patches(crag_runner: MagicMock): def test_query_use_crag_body_flag_enables_crag(monkeypatch): - for var in ( - "HTTP_PROXY", - "http_proxy", - "HTTPS_PROXY", - "https_proxy", - "ALL_PROXY", - "all_proxy", - "GRPC_PROXY", - "grpc_proxy", - ): - monkeypatch.delenv(var, raising=False) + _clear_proxy_env(monkeypatch) app = _make_app() client = TestClient(app) @@ -146,17 +107,7 @@ def test_query_use_crag_body_flag_enables_crag(monkeypatch): def test_query_use_crag_header_enables_crag(monkeypatch): - for var in ( - "HTTP_PROXY", - "http_proxy", - "HTTPS_PROXY", - "https_proxy", - "ALL_PROXY", - "all_proxy", - "GRPC_PROXY", - "grpc_proxy", - ): - monkeypatch.delenv(var, raising=False) + _clear_proxy_env(monkeypatch) app = _make_app() client = TestClient(app) @@ -185,17 +136,7 @@ def test_query_use_crag_header_enables_crag(monkeypatch): def test_query_skips_crag_without_opt_in(monkeypatch): - for var in ( - "HTTP_PROXY", - "http_proxy", - "HTTPS_PROXY", - "https_proxy", - "ALL_PROXY", - "all_proxy", - "GRPC_PROXY", - "grpc_proxy", - ): - monkeypatch.delenv(var, raising=False) + _clear_proxy_env(monkeypatch) app = _make_app() client = TestClient(app) diff --git a/tests/unit/test_query_decomposition_route.py b/tests/unit/test_query_decomposition_route.py index e026b9d..583389b 100644 --- a/tests/unit/test_query_decomposition_route.py +++ b/tests/unit/test_query_decomposition_route.py @@ -1,87 +1,33 @@ from __future__ import annotations -from dataclasses import dataclass from types import SimpleNamespace from unittest.mock import MagicMock, patch -from fastapi import FastAPI from fastapi.testclient import TestClient -from konjoai.api.routes.query import router -from konjoai.generate.generator import GenerationResult -from konjoai.retrieve.hybrid import HybridResult -from konjoai.retrieve.reranker import RerankResult from konjoai.retrieve.router import QueryIntent - -@dataclass -class _SettingsStub: - enable_query_router: bool = True - enable_hyde: bool = False - enable_telemetry: bool = True - use_vectro_retriever: bool = False - use_colbert: bool = False - enable_crag: bool = False - enable_self_rag: bool = False - enable_query_decomposition: bool = False - decomposition_max_sub_queries: int = 4 - self_rag_max_iterations: int = 3 - top_k_dense: int = 8 - top_k_sparse: int = 8 - openai_model: str = "stub-model" - request_timeout_seconds: float = 30.0 - enable_graph_rag: bool = False - graph_rag_max_communities: int = 5 - graph_rag_similarity_threshold: float = 0.3 - otel_enabled: bool = False - audit_enabled: bool = False - - -class _GeneratorStub: - def generate(self, question: str, context: str) -> GenerationResult: - _ = (question, context) - return GenerationResult( - answer="base answer", - model="stub-model", - usage={"prompt_tokens": 10, "completion_tokens": 2}, - ) - - -def _make_app() -> FastAPI: - app = FastAPI() - app.include_router(router) - return app - - -def _clear_proxy_env(monkeypatch): - for var in ( - "HTTP_PROXY", - "http_proxy", - "HTTPS_PROXY", - "https_proxy", - "ALL_PROXY", - "all_proxy", - "GRPC_PROXY", - "grpc_proxy", - ): - monkeypatch.delenv(var, raising=False) +from ._route_harness import ( + GeneratorStub, + SettingsStub, + default_hybrid_results, + default_reranked_results, +) +from ._route_harness import ( + clear_proxy_env as _clear_proxy_env, +) +from ._route_harness import ( + make_app as _make_app, +) def _base_patches(intent: QueryIntent = QueryIntent.AGGREGATION): - hybrid_results = [ - HybridResult(content="h1", source="a.txt", rrf_score=0.8, metadata={}), - HybridResult(content="h2", source="b.txt", rrf_score=0.7, metadata={}), - ] - reranked_results = [ - RerankResult(score=0.95, content="h1", source="a.txt", metadata={}), - ] - return ( - patch("konjoai.api.routes.query.get_settings", return_value=_SettingsStub()), + patch("konjoai.api.routes.query.get_settings", return_value=SettingsStub()), patch("konjoai.retrieve.router.classify_intent", return_value=intent), - patch("konjoai.retrieve.hybrid.hybrid_search", return_value=hybrid_results), - patch("konjoai.retrieve.reranker.rerank", return_value=reranked_results), - patch("konjoai.generate.generator.get_generator", return_value=_GeneratorStub()), + patch("konjoai.retrieve.hybrid.hybrid_search", return_value=default_hybrid_results()), + patch("konjoai.retrieve.reranker.rerank", return_value=default_reranked_results()), + patch("konjoai.generate.generator.get_generator", return_value=GeneratorStub()), patch("konjoai.cache.get_semantic_cache", return_value=None), ) diff --git a/tests/unit/test_query_route_timeout.py b/tests/unit/test_query_route_timeout.py index 89c4812..6e3d576 100644 --- a/tests/unit/test_query_route_timeout.py +++ b/tests/unit/test_query_route_timeout.py @@ -3,71 +3,43 @@ K2: every hot-path step is observable. K3: graceful degradation — 504 on overrun, not 500. """ + from __future__ import annotations import asyncio -from dataclasses import dataclass from unittest.mock import patch -from fastapi import FastAPI from fastapi.testclient import TestClient -from konjoai.api.routes.query import router from konjoai.retrieve.reranker import RerankResult +from ._route_harness import ( + SettingsStub, +) +from ._route_harness import ( + clear_proxy_env as _clear_proxy_env, +) +from ._route_harness import ( + make_app as _make_app, +) + # ── Shared settings stubs ──────────────────────────────────────────────────── +# enable_query_router=False so the first awaited hot-path step is hybrid_search. + + +def _settings_normal() -> SettingsStub: + """Baseline settings: 30 s timeout, routing disabled.""" + return SettingsStub(enable_query_router=False, top_k_dense=5, top_k_sparse=5) + -@dataclass -class _SettingsNormal: - enable_query_router: bool = False # skip routing so first await is hybrid_search - enable_hyde: bool = False - enable_telemetry: bool = True - use_vectro_retriever: bool = False - use_colbert: bool = False - enable_crag: bool = False - enable_self_rag: bool = False - enable_query_decomposition: bool = False - decomposition_max_sub_queries: int = 4 - self_rag_max_iterations: int = 3 - top_k_dense: int = 5 - top_k_sparse: int = 5 - openai_model: str = "stub-model" - request_timeout_seconds: float = 30.0 - enable_graph_rag: bool = False - graph_rag_max_communities: int = 5 - graph_rag_similarity_threshold: float = 0.3 - otel_enabled: bool = False - audit_enabled: bool = False - - -@dataclass -class _SettingsTimeout: +def _settings_timeout() -> SettingsStub: """10 ms timeout — fires before any real I/O can complete.""" - enable_query_router: bool = False - enable_hyde: bool = False - enable_telemetry: bool = True - use_vectro_retriever: bool = False - use_colbert: bool = False - enable_crag: bool = False - enable_self_rag: bool = False - enable_query_decomposition: bool = False - decomposition_max_sub_queries: int = 4 - self_rag_max_iterations: int = 3 - top_k_dense: int = 5 - top_k_sparse: int = 5 - openai_model: str = "stub-model" - request_timeout_seconds: float = 0.01 # 10 ms — reliably fires before 50 ms sleep - enable_graph_rag: bool = False - graph_rag_max_communities: int = 5 - graph_rag_similarity_threshold: float = 0.3 - otel_enabled: bool = False - audit_enabled: bool = False - - -def _make_app() -> FastAPI: - app = FastAPI() - app.include_router(router) - return app + return SettingsStub( + enable_query_router=False, + top_k_dense=5, + top_k_sparse=5, + request_timeout_seconds=0.01, + ) def _sample_reranked() -> list[RerankResult]: @@ -90,16 +62,15 @@ async def _slow_to_thread(_fn, *_args, **_kwargs): # ── /query timeout tests ────────────────────────────────────────────────────── + def test_query_returns_504_on_timeout(monkeypatch): - for var in ("HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", - "ALL_PROXY", "all_proxy"): - monkeypatch.delenv(var, raising=False) + _clear_proxy_env(monkeypatch) app = _make_app() client = TestClient(app) with ( - patch("konjoai.api.routes.query.get_settings", return_value=_SettingsTimeout()), + patch("konjoai.api.routes.query.get_settings", return_value=_settings_timeout()), patch("konjoai.api.routes.query.asyncio.to_thread", _slow_to_thread), ): resp = client.post("/query", json={"question": "What is refund policy?"}) @@ -114,16 +85,12 @@ def test_query_completes_normally_within_timeout(monkeypatch): from konjoai.generate.generator import GenerationResult from konjoai.retrieve.hybrid import HybridResult - for var in ("HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", - "ALL_PROXY", "all_proxy"): - monkeypatch.delenv(var, raising=False) + _clear_proxy_env(monkeypatch) app = _make_app() client = TestClient(app) - _hybrid = [ - HybridResult(content="Policy text.", source="policy.md", rrf_score=0.8, metadata={}) - ] + _hybrid = [HybridResult(content="Policy text.", source="policy.md", rrf_score=0.8, metadata={})] _gen_result = GenerationResult( answer="You can get a refund within 30 days.", model="stub-model", @@ -135,7 +102,7 @@ def generate(self, question: str, context: str) -> GenerationResult: return _gen_result with ( - patch("konjoai.api.routes.query.get_settings", return_value=_SettingsNormal()), + patch("konjoai.api.routes.query.get_settings", return_value=_settings_normal()), patch("konjoai.retrieve.hybrid.hybrid_search", return_value=_hybrid), patch("konjoai.retrieve.reranker.rerank", return_value=_sample_reranked()), patch("konjoai.generate.generator.get_generator", return_value=_FakeGenerator()), @@ -150,16 +117,15 @@ def generate(self, question: str, context: str) -> GenerationResult: # ── /query/stream timeout tests ─────────────────────────────────────────────── + def test_query_stream_returns_504_on_timeout(monkeypatch): - for var in ("HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", - "ALL_PROXY", "all_proxy"): - monkeypatch.delenv(var, raising=False) + _clear_proxy_env(monkeypatch) app = _make_app() client = TestClient(app) with ( - patch("konjoai.api.routes.query.get_settings", return_value=_SettingsTimeout()), + patch("konjoai.api.routes.query.get_settings", return_value=_settings_timeout()), patch("konjoai.api.routes.query.asyncio.to_thread", _slow_to_thread), ): resp = client.post("/query/stream", json={"question": "Streaming query?"}) @@ -171,15 +137,13 @@ def test_query_stream_returns_504_on_timeout(monkeypatch): def test_query_timeout_detail_includes_duration(monkeypatch): """The 504 detail string must include the configured timeout duration for observability.""" - for var in ("HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", - "ALL_PROXY", "all_proxy"): - monkeypatch.delenv(var, raising=False) + _clear_proxy_env(monkeypatch) app = _make_app() client = TestClient(app) with ( - patch("konjoai.api.routes.query.get_settings", return_value=_SettingsTimeout()), + patch("konjoai.api.routes.query.get_settings", return_value=_settings_timeout()), patch("konjoai.api.routes.query.asyncio.to_thread", _slow_to_thread), ): resp = client.post("/query", json={"question": "Q"}) diff --git a/tests/unit/test_query_self_rag_route.py b/tests/unit/test_query_self_rag_route.py index 36b2468..74b04d8 100644 --- a/tests/unit/test_query_self_rag_route.py +++ b/tests/unit/test_query_self_rag_route.py @@ -1,92 +1,38 @@ from __future__ import annotations -from dataclasses import dataclass from types import SimpleNamespace from unittest.mock import MagicMock, patch -from fastapi import FastAPI from fastapi.testclient import TestClient -from konjoai.api.routes.query import router -from konjoai.generate.generator import GenerationResult -from konjoai.retrieve.hybrid import HybridResult -from konjoai.retrieve.reranker import RerankResult from konjoai.retrieve.router import QueryIntent - -@dataclass -class _SettingsStub: - enable_query_router: bool = True - enable_hyde: bool = False - enable_telemetry: bool = True - use_vectro_retriever: bool = False - use_colbert: bool = False - enable_crag: bool = False - enable_self_rag: bool = False - enable_query_decomposition: bool = False - decomposition_max_sub_queries: int = 4 - self_rag_max_iterations: int = 3 - top_k_dense: int = 8 - top_k_sparse: int = 8 - openai_model: str = "stub-model" - request_timeout_seconds: float = 30.0 - enable_graph_rag: bool = False - graph_rag_max_communities: int = 5 - graph_rag_similarity_threshold: float = 0.3 - otel_enabled: bool = False - audit_enabled: bool = False - - -class _GeneratorStub: - def generate(self, question: str, context: str) -> GenerationResult: - _ = (question, context) - return GenerationResult( - answer="base answer", - model="stub-model", - usage={"prompt_tokens": 10, "completion_tokens": 2}, - ) - - -def _make_app() -> FastAPI: - app = FastAPI() - app.include_router(router) - return app +from ._route_harness import ( + GeneratorStub, + SettingsStub, + default_hybrid_results, + default_reranked_results, +) +from ._route_harness import ( + clear_proxy_env as _clear_proxy_env, +) +from ._route_harness import ( + make_app as _make_app, +) def _base_patches(self_rag_runner: MagicMock): - hybrid_results = [ - HybridResult(content="h1", source="a.txt", rrf_score=0.8, metadata={}), - HybridResult(content="h2", source="b.txt", rrf_score=0.7, metadata={}), - ] - reranked_results = [ - RerankResult(score=0.95, content="h1", source="a.txt", metadata={}), - ] - return ( - patch("konjoai.api.routes.query.get_settings", return_value=_SettingsStub()), + patch("konjoai.api.routes.query.get_settings", return_value=SettingsStub()), patch("konjoai.retrieve.router.classify_intent", return_value=QueryIntent.RETRIEVAL), - patch("konjoai.retrieve.hybrid.hybrid_search", return_value=hybrid_results), - patch("konjoai.retrieve.reranker.rerank", return_value=reranked_results), - patch("konjoai.generate.generator.get_generator", return_value=_GeneratorStub()), + patch("konjoai.retrieve.hybrid.hybrid_search", return_value=default_hybrid_results()), + patch("konjoai.retrieve.reranker.rerank", return_value=default_reranked_results()), + patch("konjoai.generate.generator.get_generator", return_value=GeneratorStub()), patch("konjoai.cache.get_semantic_cache", return_value=None), patch("konjoai.retrieve.self_rag.get_self_rag_pipeline", return_value=self_rag_runner), ) -def _clear_proxy_env(monkeypatch): - for var in ( - "HTTP_PROXY", - "http_proxy", - "HTTPS_PROXY", - "https_proxy", - "ALL_PROXY", - "all_proxy", - "GRPC_PROXY", - "grpc_proxy", - ): - monkeypatch.delenv(var, raising=False) - - def _make_self_rag_result(): return SimpleNamespace( answer="self-rag answer",