Files
settled-reach/tooling/db/qdrant_connector.py
T
jpmschweitzerandClaude Opus 4.6 63bb6ff7c7 chore(db): replace Commonwealth with Settled Reach in tooling and server
Updated docstrings in sqlite_connector, qdrant_connector,
decisions_sync, schema.sql, and two doc comments in generator.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:26:59 +01:00

432 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Settled Reach Qdrant + Ollama Connector — mini MCP for vector search.
Usage:
python3 qdrant_connector.py health
python3 qdrant_connector.py create-collection
python3 qdrant_connector.py search "some query text"
python3 qdrant_connector.py index <id> "text to embed" [--metadata key=value ...]
python3 qdrant_connector.py index-file <filepath>
python3 qdrant_connector.py count
python3 qdrant_connector.py --help
Requires only Python 3 stdlib (no pip dependencies).
"""
import hashlib
import json
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths / Config
# ---------------------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_PATH = SCRIPT_DIR / "config.json"
def load_config():
"""Load config.json."""
with open(CONFIG_PATH, "r") as f:
return json.load(f)
# ---------------------------------------------------------------------------
# HTTP helpers (stdlib only)
# ---------------------------------------------------------------------------
def http_request(url, method="GET", data=None, headers=None, timeout=30):
"""
Perform an HTTP request using urllib. Returns (status_code, parsed_json | raw_text).
"""
hdrs = {"Content-Type": "application/json"}
if headers:
hdrs.update(headers)
body = None
if data is not None:
body = json.dumps(data).encode("utf-8")
req = urllib.request.Request(url, data=body, headers=hdrs, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
try:
return resp.status, json.loads(raw)
except json.JSONDecodeError:
return resp.status, raw
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8") if exc.fp else ""
try:
return exc.code, json.loads(raw)
except json.JSONDecodeError:
return exc.code, raw
except urllib.error.URLError as exc:
raise ConnectionError(f"Cannot reach {url}: {exc.reason}") from exc
# ---------------------------------------------------------------------------
# Embedding helper
# ---------------------------------------------------------------------------
def embed_text(cfg, text):
"""
Call ollama /api/embed to get an embedding vector for the given text.
Returns a list of floats.
"""
url = f"{cfg['ollama_url']}/api/embed"
payload = {"model": cfg["embed_model"], "input": text}
status, resp = http_request(url, method="POST", data=payload)
if status != 200:
raise RuntimeError(f"Ollama embed failed (HTTP {status}): {resp}")
# ollama returns {"embeddings": [[...]]}
embeddings = resp.get("embeddings")
if not embeddings or not embeddings[0]:
raise RuntimeError(f"Ollama returned empty embeddings: {resp}")
return embeddings[0]
# ---------------------------------------------------------------------------
# Qdrant helpers
# ---------------------------------------------------------------------------
def qdrant_create_collection(cfg):
"""Create (or recreate) the Qdrant collection."""
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}"
payload = {
"vectors": {
"size": cfg["embed_dimensions"],
"distance": "Cosine",
}
}
status, resp = http_request(url, method="PUT", data=payload)
return status, resp
def qdrant_upsert(cfg, points):
"""Upsert a list of points into Qdrant."""
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}/points"
payload = {"points": points}
status, resp = http_request(url, method="PUT", data=payload)
return status, resp
def qdrant_search(cfg, vector, limit=5):
"""Search Qdrant by vector."""
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}/points/query"
payload = {"query": vector, "limit": limit, "with_payload": True}
status, resp = http_request(url, method="POST", data=payload)
return status, resp
def qdrant_collection_info(cfg):
"""Get collection info (includes point count)."""
url = f"{cfg['qdrant_url']}/collections/{cfg['collection']}"
status, resp = http_request(url, method="GET")
return status, resp
# ---------------------------------------------------------------------------
# Chunking helper
# ---------------------------------------------------------------------------
def chunk_markdown(text, source_file=""):
"""
Split markdown by headings (# or ##). Returns a list of dicts:
{"heading": str, "text": str, "chunk_index": int, "source_file": str}
"""
# Split on lines that start with one or two hashes
pattern = re.compile(r"^(#{1,2})\s+(.+)$", re.MULTILINE)
matches = list(pattern.finditer(text))
chunks = []
if not matches:
# No headings — treat entire file as one chunk
stripped = text.strip()
if stripped:
chunks.append({
"heading": Path(source_file).stem if source_file else "untitled",
"text": stripped,
"chunk_index": 0,
"source_file": source_file,
})
return chunks
# Text before the first heading
preamble = text[: matches[0].start()].strip()
if preamble:
chunks.append({
"heading": "(preamble)",
"text": preamble,
"chunk_index": 0,
"source_file": source_file,
})
for i, match in enumerate(matches):
heading = match.group(2).strip()
start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = text[start:end].strip()
if body:
chunks.append({
"heading": heading,
"text": body,
"chunk_index": len(chunks),
"source_file": source_file,
})
return chunks
def text_to_point_id(text):
"""Deterministic integer ID from a string (unsigned 64-bit range for Qdrant)."""
h = hashlib.sha256(text.encode("utf-8")).hexdigest()
# Qdrant accepts unsigned 64-bit integer IDs
return int(h[:16], 16)
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_health(cfg):
"""Check connectivity to Qdrant and Ollama."""
results = {}
# Qdrant health
try:
status, resp = http_request(f"{cfg['qdrant_url']}/healthz", method="GET", timeout=5)
results["qdrant"] = {"reachable": True, "status": status, "response": resp}
except ConnectionError as exc:
results["qdrant"] = {"reachable": False, "error": str(exc)}
# Ollama health
try:
status, resp = http_request(f"{cfg['ollama_url']}/api/tags", method="GET", timeout=5)
results["ollama"] = {"reachable": True, "status": status}
# List available models for convenience
if isinstance(resp, dict) and "models" in resp:
results["ollama"]["models"] = [m.get("name", "?") for m in resp["models"]]
except ConnectionError as exc:
results["ollama"] = {"reachable": False, "error": str(exc)}
all_ok = all(v.get("reachable", False) for v in results.values())
return {"ok": all_ok, "services": results}
def cmd_create_collection(cfg):
"""Create the Qdrant collection."""
try:
status, resp = qdrant_create_collection(cfg)
success = status in (200, 201)
return {"ok": success, "status": status, "response": resp}
except ConnectionError as exc:
return {"ok": False, "error": str(exc)}
def cmd_search(cfg, query_text):
"""Embed query text and search Qdrant."""
try:
vector = embed_text(cfg, query_text)
status, resp = qdrant_search(cfg, vector)
if status != 200:
return {"ok": False, "status": status, "error": resp}
# Extract the points from the response
points = resp.get("result", {}).get("points", resp.get("result", []))
results = []
if isinstance(points, list):
for pt in points:
results.append({
"id": pt.get("id"),
"score": pt.get("score"),
"payload": pt.get("payload", {}),
})
return {"ok": True, "query": query_text, "count": len(results), "results": results}
except (ConnectionError, RuntimeError) as exc:
return {"ok": False, "error": str(exc)}
def cmd_index(cfg, point_id_str, text, metadata=None):
"""Embed text and upsert a single point."""
try:
vector = embed_text(cfg, text)
# Build a numeric ID from the provided string
try:
point_id = int(point_id_str)
except ValueError:
point_id = text_to_point_id(point_id_str)
payload = metadata or {}
payload["text"] = text
point = {"id": point_id, "vector": vector, "payload": payload}
status, resp = qdrant_upsert(cfg, [point])
success = status in (200, 201)
return {"ok": success, "status": status, "point_id": point_id, "response": resp}
except (ConnectionError, RuntimeError) as exc:
return {"ok": False, "error": str(exc)}
def cmd_index_file(cfg, filepath):
"""Read a markdown file, chunk it, embed each chunk, and upsert all to Qdrant."""
fpath = Path(filepath).resolve()
if not fpath.exists():
return {"ok": False, "error": f"File not found: {fpath}"}
text = fpath.read_text(encoding="utf-8")
source = str(fpath)
chunks = chunk_markdown(text, source_file=source)
if not chunks:
return {"ok": False, "error": "No content chunks extracted from file"}
points = []
errors = []
for chunk in chunks:
chunk_key = f"{source}::{chunk['heading']}::{chunk['chunk_index']}"
point_id = text_to_point_id(chunk_key)
try:
vector = embed_text(cfg, chunk["text"])
except (ConnectionError, RuntimeError) as exc:
errors.append({"chunk": chunk["heading"], "error": str(exc)})
continue
points.append({
"id": point_id,
"vector": vector,
"payload": {
"source_file": chunk["source_file"],
"heading": chunk["heading"],
"chunk_index": chunk["chunk_index"],
"text": chunk["text"],
},
})
if not points:
return {"ok": False, "error": "All chunks failed to embed", "details": errors}
try:
status, resp = qdrant_upsert(cfg, points)
success = status in (200, 201)
result = {
"ok": success,
"status": status,
"file": source,
"chunks_indexed": len(points),
"chunks_failed": len(errors),
"response": resp,
}
if errors:
result["errors"] = errors
return result
except ConnectionError as exc:
return {"ok": False, "error": str(exc)}
def cmd_count(cfg):
"""Return the point count in the collection."""
try:
status, resp = qdrant_collection_info(cfg)
if status != 200:
return {"ok": False, "status": status, "error": resp}
# Qdrant returns {"result": {"points_count": N, ...}}
result_data = resp.get("result", {})
count = result_data.get("points_count", result_data.get("vectors_count", "unknown"))
return {"ok": True, "collection": cfg["collection"], "points_count": count}
except ConnectionError as exc:
return {"ok": False, "error": str(exc)}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
HELP_TEXT = """\
Settled Reach Qdrant + Ollama Connector
Usage:
qdrant_connector.py health Check Qdrant & Ollama connectivity
qdrant_connector.py create-collection Create the vector collection
qdrant_connector.py search "<query text>" Embed query and search Qdrant
qdrant_connector.py index <id> "<text>" [--metadata k=v ...]
Embed text and upsert one point
qdrant_connector.py index-file <filepath> Chunk a markdown file and index all chunks
qdrant_connector.py count Show point count in collection
qdrant_connector.py --help Show this help message
All output is JSON on stdout. Uses only Python stdlib (no pip install needed).
Config: {config}
""".format(config=CONFIG_PATH)
def parse_metadata(args):
"""Parse --metadata key=value pairs from argument list."""
metadata = {}
i = 0
while i < len(args):
if args[i] == "--metadata" and i + 1 < len(args):
i += 1
while i < len(args) and "=" in args[i] and not args[i].startswith("--"):
key, _, value = args[i].partition("=")
metadata[key] = value
i += 1
else:
i += 1
return metadata
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
print(HELP_TEXT)
sys.exit(0)
cmd = sys.argv[1]
try:
cfg = load_config()
except (FileNotFoundError, json.JSONDecodeError) as exc:
print(json.dumps({"ok": False, "error": f"Config error: {exc}"}, indent=2))
sys.exit(1)
if cmd == "health":
result = cmd_health(cfg)
elif cmd == "create-collection":
result = cmd_create_collection(cfg)
elif cmd == "search":
if len(sys.argv) < 3:
result = {"ok": False, "error": "search requires a query text argument"}
else:
result = cmd_search(cfg, sys.argv[2])
elif cmd == "index":
if len(sys.argv) < 4:
result = {"ok": False, "error": "index requires <id> and <text> arguments"}
else:
metadata = parse_metadata(sys.argv[4:])
result = cmd_index(cfg, sys.argv[2], sys.argv[3], metadata)
elif cmd == "index-file":
if len(sys.argv) < 3:
result = {"ok": False, "error": "index-file requires a <filepath> argument"}
else:
result = cmd_index_file(cfg, sys.argv[2])
elif cmd == "count":
result = cmd_count(cfg)
else:
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
print(json.dumps(result, indent=2))
sys.exit(0 if result.get("ok") else 1)
if __name__ == "__main__":
main()