| 1 | """homelab-rag adapter - queries the Darwin RAG bridge (port 9875) directly. |
| 2 | |
| 3 | The RAG bridge is a lightweight HTTP service on CT215 that queries the |
| 4 | homelab-rag SQLite FTS index directly, bypassing the Node.js MCP SSE shim |
| 5 | which takes 70-80s per call due to per-call Node.js process spawning + CIFS reads. |
| 6 | |
| 7 | Bridge endpoint: GET http://REDACTED-IP:9875/search?q=QUERY&limit=N |
| 8 | Returns: JSON array in the same shape as homelab_search results. |
| 9 | |
| 10 | Authority tier: recent-file (homelab-rag indexes files with mtime). |
| 11 | """ |
| 12 | from __future__ import annotations |
| 13 | import os |
| 14 | |
| 15 | from datetime import datetime, timezone |
| 16 | |
| 17 | import httpx |
| 18 | |
| 19 | from models import Hit |
| 20 | from adapters.base import BaseAdapter |
| 21 | |
| 22 | RAG_BRIDGE_URL = os.environ.get("RAG_BRIDGE_URL", "http://REDACTED-IP:9875") |
| 23 | RECENT_CUTOFF_DAYS = 30 |
| 24 | |
| 25 | |
| 26 | def _parse_dt(val) -> datetime | None: |
| 27 | if not val: |
| 28 | return None |
| 29 | try: |
| 30 | if isinstance(val, (int, float)): |
| 31 | ts = val / 1000.0 if val > 1e10 else float(val) |
| 32 | return datetime.fromtimestamp(ts, tz=timezone.utc) |
| 33 | return datetime.fromisoformat(str(val).replace("Z", "+00:00")) |
| 34 | except Exception: |
| 35 | return None |
| 36 | |
| 37 | |
| 38 | def _authority_from_mtime(dt: datetime | None) -> str: |
| 39 | if dt is None: |
| 40 | return "older-note" |
| 41 | age = (datetime.now(timezone.utc) - dt).days |
| 42 | return "recent-file" if age <= RECENT_CUTOFF_DAYS else "older-note" |
| 43 | |
| 44 | |
| 45 | def _parse_bridge_results(results: list[dict]) -> list[Hit]: |
| 46 | """Convert bridge response (list of result dicts) to Hit objects.""" |
| 47 | hits = [] |
| 48 | for r in results: |
| 49 | if not isinstance(r, dict): |
| 50 | continue |
| 51 | mtime = _parse_dt(r.get("mtimeMs") or r.get("mtime") or r.get("last_seen")) |
| 52 | snippet = r.get("text") or r.get("snippet") or r.get("content", "") |
| 53 | hits.append(Hit( |
| 54 | engine="rag", |
| 55 | title=r.get("title") or r.get("relPath", "")[:80], |
| 56 | path=r.get("path") or r.get("relPath", ""), |
| 57 | snippet=str(snippet)[:400], |
| 58 | score=float(r.get("score") or 0.5), |
| 59 | last_seen=mtime, |
| 60 | confidence=0.8, |
| 61 | authority_tier=_authority_from_mtime(mtime), |
| 62 | )) |
| 63 | return hits |
| 64 | |
| 65 | |
| 66 | class RagAdapter(BaseAdapter): |
| 67 | engine = "rag" |
| 68 | authority_tier = "recent-file" |
| 69 | |
| 70 | async def _fetch(self, query: str, k: int) -> list[Hit]: |
| 71 | async with httpx.AsyncClient(timeout=5.0) as client: |
| 72 | resp = await client.get( |
| 73 | f"{RAG_BRIDGE_URL}/search", |
| 74 | params={"q": query, "limit": k}, |
| 75 | ) |
| 76 | resp.raise_for_status() |
| 77 | results = resp.json() |
| 78 | |
| 79 | if not isinstance(results, list): |
| 80 | return [] |
| 81 | |
| 82 | hits = _parse_bridge_results(results) |
| 83 | |
| 84 | seen: set[str] = set() |
| 85 | deduped = [] |
| 86 | for h in hits: |
| 87 | key = h.path or h.snippet[:50] |
| 88 | if key not in seen: |
| 89 | seen.add(key) |
| 90 | deduped.append(h) |
| 91 | return deduped[:k] |