26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
301 lines
11 KiB
Python
301 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Test embedding model download and hybrid search end-to-end."""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
BINARY = os.path.join(os.path.dirname(__file__), "..", "target", "release", "lean-ctx")
|
|
PASS = 0
|
|
FAIL = 0
|
|
|
|
class McpClient:
|
|
def __init__(self, binary, cwd):
|
|
self.proc = subprocess.Popen(
|
|
[binary],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
cwd=cwd,
|
|
bufsize=0,
|
|
)
|
|
time.sleep(0.3)
|
|
|
|
def send(self, obj):
|
|
line = json.dumps(obj).encode() + b"\n"
|
|
self.proc.stdin.write(line)
|
|
self.proc.stdin.flush()
|
|
|
|
def recv(self, timeout=60):
|
|
import select as sel
|
|
fd = self.proc.stdout.fileno()
|
|
deadline = time.time() + timeout
|
|
buf = b""
|
|
while time.time() < deadline:
|
|
remaining = max(0.1, deadline - time.time())
|
|
ready, _, _ = sel.select([fd], [], [], min(remaining, 1.0))
|
|
if ready:
|
|
chunk = os.read(fd, 65536)
|
|
if not chunk:
|
|
return None
|
|
buf += chunk
|
|
if buf.startswith(b"Content-Length:"):
|
|
header_end = buf.find(b"\r\n\r\n")
|
|
if header_end == -1:
|
|
header_end = buf.find(b"\n\n")
|
|
delim_len = 2
|
|
else:
|
|
delim_len = 4
|
|
if header_end >= 0:
|
|
header = buf[:header_end].decode()
|
|
for hline in header.split("\n"):
|
|
if hline.strip().lower().startswith("content-length:"):
|
|
clen = int(hline.split(":", 1)[1].strip())
|
|
body_start = header_end + delim_len
|
|
if len(buf) >= body_start + clen:
|
|
body = buf[body_start:body_start + clen]
|
|
return json.loads(body)
|
|
continue
|
|
if b"\n" in buf:
|
|
line, rest = buf.split(b"\n", 1)
|
|
if line.strip():
|
|
try:
|
|
return json.loads(line)
|
|
except json.JSONDecodeError:
|
|
buf = rest
|
|
continue
|
|
return None
|
|
|
|
def request(self, method, params, req_id, timeout=60):
|
|
self.send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
|
|
return self.recv(timeout=timeout)
|
|
|
|
def notify(self, method, params=None):
|
|
obj = {"jsonrpc": "2.0", "method": method}
|
|
if params:
|
|
obj["params"] = params
|
|
self.send(obj)
|
|
|
|
def close(self):
|
|
self.proc.terminate()
|
|
try:
|
|
self.proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.kill()
|
|
return self.proc.stderr.read()
|
|
|
|
def check(name, response, condition_fn):
|
|
global PASS, FAIL
|
|
try:
|
|
if condition_fn(response):
|
|
print(f" \033[32mPASS\033[0m: {name}")
|
|
PASS += 1
|
|
return True
|
|
else:
|
|
print(f" \033[31mFAIL\033[0m: {name}")
|
|
if response:
|
|
print(f" Response: {json.dumps(response, ensure_ascii=False)[:500]}")
|
|
else:
|
|
print(f" Response: None")
|
|
FAIL += 1
|
|
return False
|
|
except Exception as e:
|
|
print(f" \033[31mFAIL\033[0m: {name} — exception: {e}")
|
|
FAIL += 1
|
|
return False
|
|
|
|
def get_text(resp):
|
|
if not resp or "result" not in resp:
|
|
return ""
|
|
result = resp["result"]
|
|
if isinstance(result, dict):
|
|
content = result.get("content", [])
|
|
return "".join(c.get("text", "") for c in content if c.get("type") == "text")
|
|
return str(result)
|
|
|
|
def main():
|
|
global PASS, FAIL
|
|
|
|
model_dir = os.path.expanduser("~/.lean-ctx/models")
|
|
model_exists = (
|
|
os.path.exists(os.path.join(model_dir, "model.onnx")) and
|
|
os.path.exists(os.path.join(model_dir, "vocab.txt"))
|
|
)
|
|
|
|
print("\n" + "=" * 60)
|
|
print(" Embedding Model + Hybrid Search E2E Test")
|
|
print("=" * 60)
|
|
|
|
if model_exists:
|
|
model_size = os.path.getsize(os.path.join(model_dir, "model.onnx"))
|
|
vocab_size = os.path.getsize(os.path.join(model_dir, "vocab.txt"))
|
|
print(f"\n Model: {model_size / 1024 / 1024:.1f}MB")
|
|
print(f" Vocab: {vocab_size / 1024:.0f}KB")
|
|
else:
|
|
print("\n Model not yet downloaded.")
|
|
print(" Starting server to trigger auto-download...")
|
|
print(" (This may take 30-60 seconds on first run)")
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
project_dir = os.path.join(tmpdir, "project")
|
|
src_dir = os.path.join(project_dir, "src")
|
|
os.makedirs(src_dir)
|
|
|
|
with open(os.path.join(src_dir, "main.rs"), "w") as f:
|
|
f.write("""fn calculate_fibonacci(n: u64) -> u64 {
|
|
if n <= 1 { return n; }
|
|
let mut a = 0u64;
|
|
let mut b = 1u64;
|
|
for _ in 2..=n { let c = a + b; a = b; b = c; }
|
|
b
|
|
}
|
|
fn main() {
|
|
println!("fib(10) = {}", calculate_fibonacci(10));
|
|
}
|
|
""")
|
|
|
|
with open(os.path.join(src_dir, "auth.rs"), "w") as f:
|
|
f.write("""pub struct AuthToken { pub user_id: String, pub permissions: Vec<String> }
|
|
pub fn validate_jwt_token(token: &str) -> Result<AuthToken, String> {
|
|
if token.is_empty() { return Err("Empty token".into()); }
|
|
Ok(AuthToken { user_id: "u1".into(), permissions: vec!["read".into()] })
|
|
}
|
|
pub fn check_permission(token: &AuthToken, required: &str) -> bool {
|
|
token.permissions.iter().any(|p| p == required)
|
|
}
|
|
""")
|
|
|
|
with open(os.path.join(src_dir, "utils.rs"), "w") as f:
|
|
f.write("""pub fn format_duration(seconds: u64) -> String {
|
|
format!("{:02}:{:02}:{:02}", seconds / 3600, (seconds % 3600) / 60, seconds % 60)
|
|
}
|
|
pub fn parse_csv_line(line: &str) -> Vec<String> {
|
|
line.split(',').map(|s| s.trim().to_string()).collect()
|
|
}
|
|
""")
|
|
|
|
client = McpClient(BINARY, project_dir)
|
|
|
|
# Initialize
|
|
print("\n--- Initializing server ---")
|
|
resp = client.request("initialize", {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "embedding-test", "version": "1.0.0"}
|
|
}, 1)
|
|
check("Server initializes", resp, lambda r: r is not None and "result" in r)
|
|
client.notify("notifications/initialized")
|
|
|
|
if not model_exists:
|
|
print("\n--- Waiting for model download ---")
|
|
wait_start = time.time()
|
|
max_wait = 120
|
|
while time.time() - wait_start < max_wait:
|
|
if os.path.exists(os.path.join(model_dir, "model.onnx")) and \
|
|
os.path.exists(os.path.join(model_dir, "vocab.txt")):
|
|
elapsed = time.time() - wait_start
|
|
print(f" Model downloaded in {elapsed:.1f}s")
|
|
break
|
|
|
|
tmp_file = os.path.join(model_dir, "model.onnx.tmp")
|
|
if os.path.exists(tmp_file):
|
|
size = os.path.getsize(tmp_file)
|
|
print(f" Downloading: {size / 1024 / 1024:.1f}MB...", end="\r")
|
|
|
|
time.sleep(2)
|
|
else:
|
|
print(f"\n WARNING: Model download did not complete in {max_wait}s")
|
|
|
|
model_ready = (
|
|
os.path.exists(os.path.join(model_dir, "model.onnx")) and
|
|
os.path.exists(os.path.join(model_dir, "vocab.txt"))
|
|
)
|
|
check("Embedding model available", model_ready, lambda r: r)
|
|
|
|
if model_ready:
|
|
model_size = os.path.getsize(os.path.join(model_dir, "model.onnx"))
|
|
vocab_lines = len(open(os.path.join(model_dir, "vocab.txt")).readlines())
|
|
print(f" Model: {model_size / 1024 / 1024:.1f}MB, Vocab: {vocab_lines} tokens")
|
|
check("Model size > 20MB", model_size, lambda s: s > 20_000_000)
|
|
check("Vocab has > 25K tokens", vocab_lines, lambda v: v > 25_000)
|
|
|
|
# Reindex with embeddings
|
|
print("\n--- Reindex with embedding generation ---")
|
|
resp = client.request("tools/call", {
|
|
"name": "ctx_semantic_search",
|
|
"arguments": {"query": "", "path": project_dir, "action": "reindex"}
|
|
}, 2, timeout=60)
|
|
text = get_text(resp)
|
|
print(f" Output: {text}")
|
|
|
|
check("Reindex completes", resp, lambda r: r is not None)
|
|
if model_ready:
|
|
check("Embeddings generated during reindex", resp,
|
|
lambda r: "embedding" in text.lower())
|
|
|
|
# Search — should be hybrid mode now
|
|
print("\n--- Hybrid search test ---")
|
|
resp = client.request("tools/call", {
|
|
"name": "ctx_semantic_search",
|
|
"arguments": {"query": "fibonacci number calculation", "path": project_dir, "top_k": 5}
|
|
}, 3, timeout=30)
|
|
text = get_text(resp)
|
|
print(f" Output: {text[:300]}")
|
|
|
|
check("Search returns results", resp, lambda r: r is not None)
|
|
if model_ready:
|
|
check("Search uses HYBRID mode", resp,
|
|
lambda r: "hybrid" in text.lower())
|
|
check("Finds fibonacci", resp,
|
|
lambda r: "fibonacci" in text.lower() or "main.rs" in text.lower())
|
|
|
|
# Cross-domain search
|
|
print("\n--- Cross-domain semantic search ---")
|
|
queries = [
|
|
("authentication JWT verify", "auth"),
|
|
("parse data comma separated", "csv"),
|
|
("time format hours minutes", "duration"),
|
|
]
|
|
for query, expected in queries:
|
|
resp = client.request("tools/call", {
|
|
"name": "ctx_semantic_search",
|
|
"arguments": {"query": query, "path": project_dir, "top_k": 3}
|
|
}, 40 + queries.index((query, expected)), timeout=15)
|
|
text = get_text(resp)
|
|
check(f"'{query}' → matches '{expected}'", resp,
|
|
lambda r, e=expected: e.lower() in get_text(r).lower())
|
|
|
|
# Final metrics
|
|
print("\n--- Embedding telemetry ---")
|
|
resp = client.request("tools/call", {
|
|
"name": "ctx_metrics",
|
|
"arguments": {}
|
|
}, 5)
|
|
text = get_text(resp)
|
|
|
|
check("Telemetry shows search data", resp,
|
|
lambda r: "search queries" in text.lower() or "Search queries" in text)
|
|
if model_ready:
|
|
check("Telemetry shows embedding data", resp,
|
|
lambda r: "embedding" in text.lower())
|
|
|
|
stderr = client.close()
|
|
|
|
print(f"\n{'=' * 60}")
|
|
total = PASS + FAIL
|
|
if FAIL == 0:
|
|
print(f"\033[32m ALL {total} TESTS PASSED\033[0m")
|
|
else:
|
|
print(f"\033[31m {PASS}/{total} passed, {FAIL} FAILED\033[0m")
|
|
if stderr:
|
|
print(f"\nServer stderr:")
|
|
print(stderr.decode(errors="replace")[-800:])
|
|
print(f"{'=' * 60}\n")
|
|
sys.exit(1 if FAIL > 0 else 0)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|