memory-os/scripts/context_enhancer.py

756 lines
30 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Context Enhancer — HYBRID search (semantic + BM25) on knowledge_base_hybrid
for prompt enrichment.
Runs as a synchronous function (fast, <1s) before each Hermes response.
If Qdrant is offline or embedding fails, returns "" (fail-open).
Usage:
python3 context_enhancer.py "your query here"
python3 context_enhancer.py --top-k 5 --threshold 0.50 "deploy docker"
python3 context_enhancer.py --hybrid-off "your query here" # forces dense-only
"""
import os
import sys
import json
import uuid
import hashlib
import sqlite3
import subprocess
import requests
import argparse
import re
import glob
from typing import List, Dict, Optional, Tuple
from pathlib import Path
import time
from datetime import datetime, timezone
# ─── Config ────────────────────────────────────────────────────────────────
OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY")
if not OPENROUTER_KEY:
env_path = Path.home() / ".env"
if env_path.exists():
for line in env_path.read_text().splitlines():
if line.startswith("OPENROUTER_API_KEY="):
OPENROUTER_KEY = line.split("=", 1)[1].strip().strip('"')
break
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
COLLECTION = os.environ.get("QDRANT_COLLECTION", "knowledge_base")
EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "qwen/qwen3-embedding-8b")
TOP_K_DEFAULT = 3
SCORE_THRESHOLD_DEFAULT = 0.55
MAX_TEXT_LEN = 8000
REQUEST_TIMEOUT = 10
# FastEmbed BM25 config
FASTEMBED_VENV = os.environ.get("FASTEMBED_VENV", "")
# Default: use current Python if venv not configured
_FASTEMBED_PYTHON = FASTEMBED_VENV if FASTEMBED_VENV else sys.executable
_FASTEMBED_SITEPKGS = os.environ.get(
"FASTEMBED_SITEPKGS",
os.path.join(os.path.dirname(sys.executable), "../lib/python3.12/site-packages")
)
BM25_MODEL = "Qdrant/bm25"
# Lineage config
LINEAGE_DB = os.environ.get(
"STATE_DB_PATH",
os.path.expanduser("~/.hermes/state.db")
)
# Telemetry config
TELEMETRY_LOG = os.environ.get(
"TELEMETRY_LOG_PATH",
os.path.expanduser("~/.hermes/logs/query-telemetry.jsonl")
)
TELEMETRY_MAX_BYTES = 10 * 1024 * 1024 # 10MB rotation
# ─── Lineage Registration ───────────────────────────────────────────────────
def register_lineage(
session_id: str,
query: str,
retrieved_chunk_ids: List[str],
generation_context_hash: str,
generation_model: str = "unknown",
) -> Optional[str]:
"""
Register generation provenance in the lineage DB.
Fail-open: if it fails, log error and return None. Never breaks the critical path.
"""
lineage_id = str(uuid.uuid4())
try:
with sqlite3.connect(LINEAGE_DB) as conn:
conn.execute(
"""
INSERT INTO lineage (lineage_id, session_id, query, retrieved_chunk_ids,
generation_model, generation_context_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(lineage_id, session_id, query,
json.dumps(retrieved_chunk_ids, ensure_ascii=False),
generation_model, generation_context_hash)
)
conn.commit()
return lineage_id
except Exception as e:
print(f"[LINEAGE-WARNING] Failed to register lineage: {e}", file=sys.stderr)
return None
# ─── Telemetry ─────────────────────────────────────────────────────────────
def _rotate_telemetry_if_needed() -> None:
"""Rotate telemetry file if >10MB. Rename to .1 and restart."""
try:
if os.path.exists(TELEMETRY_LOG) and os.path.getsize(TELEMETRY_LOG) > TELEMETRY_MAX_BYTES:
rotated = TELEMETRY_LOG + ".1"
if os.path.exists(rotated):
os.remove(rotated)
os.rename(TELEMETRY_LOG, rotated)
except Exception as e:
print(f"[TELEMETRY-WARNING] Rotation failed: {e}", file=sys.stderr)
def emit_telemetry(record: dict) -> None:
"""
Append-only JSONL for query telemetry.
Fail-open: never breaks the critical path.
"""
try:
os.makedirs(os.path.dirname(TELEMETRY_LOG), exist_ok=True)
_rotate_telemetry_if_needed()
with open(TELEMETRY_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as e:
print(f"[TELEMETRY-WARNING] Failed to write telemetry: {e}", file=sys.stderr)
def estimate_tokens(text: str) -> int:
"""Simple estimate: ~1.3 tokens per word (basic heuristic)."""
return int(len(text.split()) * 1.3)
# ─── Prompt Injection Sanitization ──────────────────────────────────────────
_INJECTION_PATTERNS_CE = [
# "ignore all previous/prior instructions/directives"
(re.compile(r"(?i)\bignore\s+all\s+(previous|prior)\s+(instructions|directives|commands|messages|prompts|context)"),
"[REDACTED]"),
# "you are/will now become/act/acting as (a/an) AI/assistant..."
(re.compile(r"(?i)\byou\s+(are|will\s+now)\s+(now\s+)?(become|act|acting)\s+as\s+(a\s+|an\s+)?(AI\s+assistant|assistant|AI|agent|LLM|chatbot|model|system)"),
"[REDACTED]"),
# "new instructions/directives/commands follow/above/below"
(re.compile(r"(?i)\bnew\s+(instructions|directives|commands)\s+(follow|above|below)"),
"[REDACTED]"),
# Template injection: {{...}}, ${...}
(re.compile(r"\{\{.*?\}\}|\$\{.*?\}"), "[REDACTED]"),
# Markdown/javascript data: URLs in links and images
(re.compile(r"(?i)(javascript|data)\s*:"), "sanitized:"),
# Known system prefixes
(re.compile(r"(?i)\[IMPORTANT:.*?\]|\[SYSTEM:.*?\]"), "[REDACTED]"),
# Control characters (keep newlines and tabs)
(re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]"), ""),
]
def _strip_prompt_injection(text: str) -> str:
"""Strip known prompt-injection patterns from retrieved text.
Fail-open: never raises. Empty/null input returns empty string.
"""
if not text:
return ""
try:
result = str(text)
for pattern, replacement in _INJECTION_PATTERNS_CE:
result = pattern.sub(replacement, result)
# Normalize excessive whitespace (4+ newlines → 3, 8+ spaces → 1)
result = re.sub(r"\n{4,}", "\n\n\n", result)
result = re.sub(r" {8,}", " ", result)
return result.strip()
except Exception:
return str(text)[:400]
# ─── Core ───────────────────────────────────────────────────────────────────
def embed_query(text: str) -> Optional[List[float]]:
"""Generate dense embedding via OpenRouter qwen/qwen3-embedding-8b."""
if not OPENROUTER_KEY:
return None
try:
resp = requests.post(
"https://openrouter.ai/api/v1/embeddings",
headers={
"Authorization": f"Bearer {OPENROUTER_KEY}",
"Content-Type": "application/json"
},
json={
"model": EMBEDDING_MODEL,
"input": text[:MAX_TEXT_LEN]
},
timeout=REQUEST_TIMEOUT
)
resp.raise_for_status()
return resp.json()["data"][0]["embedding"]
except Exception as e:
# Fail-open: log silently, return None
print(f"[CE-ERROR] Embedding dense failed: {e}", file=sys.stderr)
return None
def embed_query_sparse(text: str) -> Optional[Tuple[List[int], List[float]]]:
"""
Generate sparse BM25 embedding via FastEmbed (subprocess in ai-lab venv).
Query text passes via stdin — never embedded in a -c code string.
Fail-open: if it fails, return None. Caller falls back to dense-only.
"""
try:
result = subprocess.run(
[_FASTEMBED_PYTHON, "-c", """\
import os, sys, json
sys.path.insert(0, os.environ["FASTEMBED_SITEPKGS"])
from fastembed.sparse import SparseTextEmbedding
query = sys.stdin.read()
model = SparseTextEmbedding(model_name="Qdrant/bm25")
sparse = list(model.embed([query]))[0]
print(json.dumps({"indices": sparse.indices.tolist(), "values": sparse.values.tolist()}))
"""],
input=text,
capture_output=True, text=True, timeout=15
)
data = json.loads(result.stdout.strip())
return data["indices"], data["values"]
except Exception as e:
print(f"[CE-ERROR] Embedding sparse failed: {e}", file=sys.stderr)
return None
def search_knowledge_base(
dense_vector: List[float],
sparse_vector: Optional[Tuple[List[int], List[float]]] = None,
top_k: int = TOP_K_DEFAULT,
score_threshold: float = SCORE_THRESHOLD_DEFAULT
) -> List[Dict]:
"""
Hybrid search in Qdrant: dense (semantic) + sparse (BM25 keyword)
via prefetch + RRF. If sparse fails, fall back to dense-only.
"""
try:
if sparse_vector is not None:
# Hybrid: prefetch dense + prefetch sparse → RRF
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/query",
headers={"Content-Type": "application/json"},
json={
"prefetch": [
{"query": dense_vector, "using": "dense", "limit": top_k * 3},
{"query": {"indices": sparse_vector[0], "values": sparse_vector[1]},
"using": "sparse", "limit": top_k * 3},
],
"query": {"fusion": "rrf"},
"limit": top_k * 2,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT + 5,
)
else:
# Fallback: dense-only (collections with compatible named vectors)
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/query",
headers={"Content-Type": "application/json"},
json={
"query": dense_vector,
"using": "dense",
"limit": top_k,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT,
)
resp.raise_for_status()
data = resp.json()
results = []
raw_results = data.get("result", {})
# API /points/search returns direct list; API /points/query returns {"points": [...]}
points = raw_results if isinstance(raw_results, list) else raw_results.get("points", [])
for r in points:
score = r.get("score", 0)
if score < score_threshold:
continue
payload = r.get("payload", {})
results.append({
"id": r.get("id", "unknown"),
"score": score,
"title": payload.get("title", "Untitled"),
"content_preview": _strip_prompt_injection((payload.get("text", "") or "")[:400]),
"source": payload.get("source", "unknown"),
"tags": payload.get("tags", [])
})
return results
except Exception as e:
print(f"[CE-ERROR] Qdrant search failed: {e}", file=sys.stderr)
return []
# ─── Fallback: Lexical Search in Vault ──────────────────────────────────────
def tokenize_query(text: str) -> List[str]:
"""Tokenize query into relevant terms (lowercase, alphanumeric)."""
# Remove basic Portuguese/English stopwords
stopwords = {"o", "a", "os", "as", "um", "uma", "de", "da", "do", "em", "no", "na", "para", "com", "por", "que", "se", "e", "ou", "mas", "the", "a", "an", "is", "are", "was", "were", "be", "been", "have", "has", "had", "do", "does", "did", "will", "would", "could", "should"}
tokens = re.findall(r'\b[a-zA-Z0-9]+\b', text.lower())
return [t for t in tokens if t not in stopwords and len(t) > 2]
def lexical_search_in_vault(
query_terms: List[str],
top_k: int = TOP_K_DEFAULT,
vault_root: str = os.environ.get("WIKI_PATH", os.path.expanduser("~/vault/wiki"))
) -> List[Dict]:
"""
Lexical search in .md files under vault/wiki/.
Rank by term match density / document size.
Return top-k simulated chunks (or entire file if small).
"""
if not query_terms:
return []
md_files = glob.glob(f"{vault_root}/**/*.md", recursive=True)
if not md_files:
return []
scored = []
for filepath in md_files:
try:
text = Path(filepath).read_text(encoding="utf-8", errors="replace").lower()
if not text:
continue
matches = 0
for term in query_terms:
matches += text.count(term)
if matches == 0:
continue
# Heuristic: matches / sqrt(word_count) — favors concise documents
word_count = max(1, len(text.split()))
density = matches / (word_count ** 0.5) # sqrt(word_count) to not over-penalize medium texts
scored.append({
"filepath": filepath,
"matches": matches,
"word_count": word_count,
"density": density,
"title": Path(filepath).stem,
"text": text[:2000], # truncate for return
})
except Exception:
continue
if not scored:
return []
# Sort by descending density
scored.sort(key=lambda x: x["density"], reverse=True)
results = []
for i, item in enumerate(scored[:top_k]):
# Simulate a result chunk
results.append({
"id": f"lexical-{hashlib.md5(item['filepath'].encode()).hexdigest()[:16]}",
"score": round(min(1.0, item["density"]), 2),
"title": item["title"],
"content_preview": _strip_prompt_injection(item["text"][:400]),
"source": f"vault-{item['filepath'].replace(vault_root, '').lstrip('/')[:40]}",
"tags": ["fallback", "lexical"],
"fallback_level": "lexical",
})
if results:
print(f"[CE-FALLBACK] Lexical search returned {len(results)} results from vault", file=sys.stderr)
return results
# ─── Fallback: SQLite Keyword Search ────────────────────────────────────────
def sqlite_keyword_search(
query_terms: List[str],
top_k: int = TOP_K_DEFAULT
) -> List[Dict]:
"""
Search terms in the lineage table (query field) and other state tables.
Last resort — does not replace vault.
"""
if not query_terms:
return []
try:
with sqlite3.connect(LINEAGE_DB) as conn:
conn.row_factory = sqlite3.Row
c = conn.cursor()
results = []
# Search in lineage.query
placeholders = " OR ".join(["query LIKE ?"] * len(query_terms))
params = [f"%{term}%" for term in query_terms]
c.execute(
f"""
SELECT lineage_id, session_id, query, generation_context_hash, created_at
FROM lineage
WHERE {placeholders}
ORDER BY created_at DESC
LIMIT ?
""",
params + [top_k * 2]
)
for row in c.fetchall():
results.append({
"id": f"sqlite-{row['lineage_id'][:16]}",
"score": 0.5,
"title": f"Lineage {row['lineage_id'][:8]}...",
"content_preview": _strip_prompt_injection((row["query"] or "")[:400]),
"source": f"sqlite-history-{row['session_id']}",
"tags": ["fallback", "sqlite"],
"fallback_level": "sqlite",
})
if results:
print(f"[CE-FALLBACK] SQLite keyword search returned {len(results)} results", file=sys.stderr)
return results[:top_k]
except Exception as e:
print(f"[CE-FALLBACK] SQLite search failed: {e}", file=sys.stderr)
return []
# ─── Fallback Wrapper ──────────────────────────────────────────────────────
def search_with_fallback(
dense_vector: Optional[List[float]] = None,
sparse_vector: Optional[Tuple[List[int], List[float]]] = None,
query_text: str = "",
top_k: int = TOP_K_DEFAULT,
score_threshold: float = SCORE_THRESHOLD_DEFAULT
) -> Tuple[List[Dict], str, float, float]:
"""
4-level fallback cascade:
1. Hybrid (dense + sparse + RRF) — normal mode
2. Dense-only — if sparse fails or is None
3. Lexical-only — if Qdrant goes down (ConnectionError, Timeout)
4. SQLite keyword — if vault is inaccessible
Returns (results, fallback_level, qdrant_latency_ms, fallback_latency_ms).
"""
fallback_level = "hybrid"
qdrant_latency_ms = 0.0
t0 = time.perf_counter()
# Level 1 or 2: Qdrant (hybrid if sparse available, otherwise dense-only)
try:
t_q0 = time.perf_counter()
if sparse_vector is not None:
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/query",
headers={"Content-Type": "application/json"},
json={
"prefetch": [
{"query": dense_vector, "using": "dense", "limit": top_k * 3},
{"query": {"indices": sparse_vector[0], "values": sparse_vector[1]},
"using": "sparse", "limit": top_k * 3},
],
"query": {"fusion": "rrf"},
"limit": top_k * 2,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT + 5,
)
fallback_level = "hybrid"
else:
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/query",
headers={"Content-Type": "application/json"},
json={
"query": dense_vector,
"using": "dense",
"limit": top_k,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT,
)
fallback_level = "dense-only"
resp.raise_for_status()
qdrant_latency_ms = (time.perf_counter() - t_q0) * 1000
data = resp.json()
results = []
raw_results = data.get("result", {})
points = raw_results if isinstance(raw_results, list) else raw_results.get("points", [])
for r in points:
score = r.get("score", 0)
if score < score_threshold:
continue
payload = r.get("payload", {})
results.append({
"id": r.get("id", "unknown"),
"score": score,
"title": payload.get("title", "Untitled"),
"content_preview": _strip_prompt_injection((payload.get("text", "") or "")[:400]),
"source": payload.get("source", "unknown"),
"tags": payload.get("tags", [])
})
if results:
fallback_latency_ms = (time.perf_counter() - t0) * 1000
return results, fallback_level, qdrant_latency_ms, fallback_latency_ms
except requests.exceptions.ConnectionError:
print("[CE-FALLBACK] Qdrant unavailable (ConnectionError), falling back to lexical search.", file=sys.stderr)
except requests.exceptions.Timeout:
print("[CE-FALLBACK] Qdrant timeout, falling back to lexical search.", file=sys.stderr)
except Exception as e:
# Unexpected Qdrant error (e.g. SparseIndexError, 5xx, etc.)
# If sparse_vector existed, it might be a sparse error — try dense-only
if sparse_vector is not None and "sparse" in str(e).lower():
print(f"[CE-FALLBACK] Sparse index failed ('{e}'), trying dense-only...", file=sys.stderr)
try:
t_q2 = time.perf_counter()
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/search",
headers={"Content-Type": "application/json"},
json={
"vector": dense_vector,
"using": "dense",
"limit": top_k,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT,
)
resp.raise_for_status()
qdrant_latency_ms = (time.perf_counter() - t_q2) * 1000
data = resp.json()
results = []
raw_results = data.get("result", {})
points = raw_results if isinstance(raw_results, list) else raw_results.get("points", [])
for r in points:
score = r.get("score", 0)
if score < score_threshold:
continue
payload = r.get("payload", {})
results.append({
"id": r.get("id", "unknown"),
"score": score,
"title": payload.get("title", "Untitled"),
"content_preview": _strip_prompt_injection((payload.get("text", "") or "")[:400]),
"source": payload.get("source", "unknown"),
"tags": payload.get("tags", [])
})
if results:
fallback_latency_ms = (time.perf_counter() - t0) * 1000
print("[CE-FALLBACK] Dense-only worked after sparse error.", file=sys.stderr)
return results, "dense-only", qdrant_latency_ms, fallback_latency_ms
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
print("[CE-FALLBACK] Qdrant also unavailable for dense-only — lexical fallback.", file=sys.stderr)
except Exception as e2:
print(f"[CE-FALLBACK] Dense-only also failed: {e2}", file=sys.stderr)
else:
print(f"[CE-FALLBACK] Qdrant general error ({e}), falling back to lexical.", file=sys.stderr)
# Level 3: Lexical search in vault
terms = tokenize_query(query_text)
lexical_results = lexical_search_in_vault(terms, top_k=top_k)
if lexical_results:
fallback_latency_ms = (time.perf_counter() - t0) * 1000
return lexical_results, "lexical", qdrant_latency_ms, fallback_latency_ms
# Level 4: SQLite keyword search
sqlite_results = sqlite_keyword_search(terms, top_k=top_k)
if sqlite_results:
fallback_latency_ms = (time.perf_counter() - t0) * 1000
return sqlite_results, "sqlite", qdrant_latency_ms, fallback_latency_ms
# Nothing worked
fallback_latency_ms = (time.perf_counter() - t0) * 1000
print("[CE-FALLBACK] All fallback levels exhausted.", file=sys.stderr)
return [], "none", qdrant_latency_ms, fallback_latency_ms
def update_last_accessed_at(chunk_ids: list) -> None:
"""
Update last_accessed_at on chunks returned by search.
Reset decay for chunks that are actually being used.
Fail-open: never breaks the critical query path.
"""
if not chunk_ids:
return
try:
now = datetime.now().astimezone().isoformat()
requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/payload",
headers={"Content-Type": "application/json"},
json={
"points": chunk_ids,
"payload": {"last_accessed_at": now},
},
timeout=5,
)
except Exception as e:
print(f"[CE-WARNING] Failed to update last_accessed_at: {e}", file=sys.stderr)
def enhance_prompt(
user_message: str,
top_k: int = TOP_K_DEFAULT,
score_threshold: float = SCORE_THRESHOLD_DEFAULT,
format_mode: str = "markdown",
hybrid: bool = True,
session_id: Optional[str] = None,
generation_model: str = "unknown",
) -> str:
"""
Return a context block with relevant vault content (hybrid dense+BM25),
or an empty string if nothing relevant found / services offline.
Register lineage with chunk provenance and generated context hash.
"""
# Resolve session_id
if session_id is None:
session_id = os.environ.get("HERMES_SESSION_ID", "standalone")
# Skip very short or irrelevant queries
if len(user_message.strip()) < 5:
return ""
# Very short or social query — skip
social_keywords = {"hi", "hello", "hey", "yo", "sup", "ok", "thanks", "bye", "okay", "great"}
if user_message.strip().lower() in social_keywords:
return ""
dense_vector = embed_query(user_message)
sparse_vector = None
if hybrid:
sparse_vector = embed_query_sparse(user_message)
# Fallback cascade: hybrid → dense-only → lexical → sqlite
hits, fallback_level, qdrant_latency_ms, fallback_latency_ms = search_with_fallback(
dense_vector=dense_vector,
sparse_vector=sparse_vector,
query_text=user_message,
top_k=top_k,
score_threshold=score_threshold
)
# If empty and we had no embedding, try pure lexical
if not hits and dense_vector is None:
terms = tokenize_query(user_message)
lexical_results = lexical_search_in_vault(terms, top_k=top_k)
if lexical_results:
hits = lexical_results
fallback_level = "lexical"
if not hits:
# Emit telemetry even when empty (records that search ran and returned nothing)
emit_telemetry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"session_id": session_id,
"query": user_message,
"retrieval_mode": fallback_level,
"retrieved_chunk_ids": [],
"semantic_scores": [],
"rerank_scores": [],
"final_context_tokens": 0,
"fallback_level": {"hybrid": 0, "dense-only": 1, "lexical": 2, "sqlite": 3, "none": -1}.get(fallback_level, -1),
"llm_response_quality": None,
"qdrant_latency_ms": round(qdrant_latency_ms, 2),
"fallback_latency_ms": round(fallback_latency_ms, 2),
})
return ""
# Update last_accessed_at for returned chunks (reset decay on real usage)
retrieved_chunk_ids = [h["id"] for h in hits]
update_last_accessed_at(retrieved_chunk_ids)
if format_mode == "markdown":
lines = ["\n## Relevant Context from Vault\n"]
for i, h in enumerate(hits, 1):
lines.append(f"### [{i}] {h['title']} (score: {h['score']:.2f})")
lines.append(f"- **Source:** `{h['source']}` | Tags: {', '.join(str(t) for t in h['tags'])}")
lines.append(f"- **Excerpt:** {h['content_preview']}...")
lines.append("")
context_str = "\n".join(lines)
elif format_mode == "compact":
parts = []
for h in hits:
parts.append(f"[{h['title'][:40]}] (s:{h['score']:.2f})")
context_str = "\n".join(parts)
else:
context_str = json.dumps(hits, indent=2, ensure_ascii=False)
generation_context_hash = hashlib.sha256(context_str.encode("utf-8")).hexdigest()[:32]
register_lineage(
session_id=session_id,
query=user_message,
retrieved_chunk_ids=retrieved_chunk_ids,
generation_context_hash=generation_context_hash,
generation_model=generation_model,
)
# Extract scores for telemetry
semantic_scores = [h.get("score", 0) for h in hits]
# For rerank_scores, use the same scores (internal Qdrant RRF, not exposed via simple API)
rerank_scores = semantic_scores[:]
final_context_tokens = estimate_tokens(context_str)
emit_telemetry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"session_id": session_id,
"query": user_message,
"retrieval_mode": fallback_level,
"retrieved_chunk_ids": retrieved_chunk_ids,
"semantic_scores": semantic_scores,
"rerank_scores": rerank_scores,
"final_context_tokens": final_context_tokens,
"fallback_level": {"hybrid": 0, "dense-only": 1, "lexical": 2, "sqlite": 3, "none": -1}.get(fallback_level, -1),
"llm_response_quality": None,
"qdrant_latency_ms": round(qdrant_latency_ms, 2),
"fallback_latency_ms": round(fallback_latency_ms, 2),
})
return context_str
def main():
parser = argparse.ArgumentParser(description="Context Enhancer — Hybrid search in knowledge_base")
parser.add_argument("query", nargs="?", help="User message/query")
parser.add_argument("--top-k", type=int, default=TOP_K_DEFAULT, help=f"Number of results (default: {TOP_K_DEFAULT})")
parser.add_argument("--threshold", type=float, default=SCORE_THRESHOLD_DEFAULT, help=f"Minimum score (default: {SCORE_THRESHOLD_DEFAULT})")
parser.add_argument("--format", choices=["markdown", "compact", "json"], default="markdown", help="Output format")
parser.add_argument("--silent", action="store_true", help="Silent — return empty on error")
parser.add_argument("--hybrid-off", action="store_true", help="Force dense-only (ignore BM25)")
args = parser.parse_args()
if not args.query:
args.query = sys.stdin.read().strip() or "how ARQ worker works"
try:
result = enhance_prompt(
args.query,
top_k=args.top_k,
score_threshold=args.threshold,
format_mode=args.format,
hybrid=not args.hybrid_off,
generation_model=os.environ.get("HERMES_MODEL", "unknown"),
)
print(result)
except Exception as e:
if not args.silent:
print(f"[CE-ERROR] {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()