Keep the local index fresh from the change feed and improve ranking
- Track a feed cursor in the local index: reindex takes the head cursor before listing and commits it with the snapshot; reads refresh from /changes (bounded, throttled) and apply creates, updates, archives, restores and hard deletes atomically with the cursor. Existing cursorless indexes bootstrap tracking on first read. Failures answer locally with explicit freshness warnings; 400 triggers a full reindex, 404 falls back to timestamp sync. - Rank by a weighted score: exact identity (including aliases and paths), term coverage including body, intent, bm25, authority, a status penalty for superseded/completed records and mild recency. - Return query-centred FTS5 snippets (never for E2E nodes). - FTS schema v2: porter stemming, prefix index, title column, rowid deletes, two-character terms, domain words no longer stopwords; v1 indexes upgrade in place. - Hyphenated alias/path literals match as adjacent-token phrases and rank as exact identity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7eaaffb63f
commit
0938b3026e
@@ -0,0 +1,404 @@
|
||||
"""Change-feed driven local index freshness against a disposable HTTP provider.
|
||||
|
||||
The provider implements the node list, single-node read and change-feed
|
||||
routes with synthetic data. The real request, index and search code run.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
||||
|
||||
|
||||
def make_node(name, minute, *, content=None, archived=False):
|
||||
stamp = f"2026-09-01T12:{minute:02d}:00Z"
|
||||
return {
|
||||
"id": name, "project_id": "project-1", "full_path": f"/notes/{name}",
|
||||
"folder_path": "/notes", "name": name, "type": "note",
|
||||
"metadata": {}, "tags": [], "aliases": [], "links": [],
|
||||
"content_text": content if content is not None else name,
|
||||
"updated_at": stamp, "archived_at": stamp if archived else None,
|
||||
}
|
||||
|
||||
|
||||
class Provider:
|
||||
def __init__(self):
|
||||
self.nodes = {}
|
||||
self.events = []
|
||||
self.requests = []
|
||||
self.fail = {} # route kind -> HTTP status
|
||||
self.on_list = None
|
||||
|
||||
def put(self, node, kind):
|
||||
self.nodes[node["id"]] = copy.deepcopy(node)
|
||||
self.events.append({"type": kind, "node_id": node["id"], "full_path": node["full_path"],
|
||||
"new_revision": node["updated_at"]})
|
||||
|
||||
def delete(self, node_id):
|
||||
node = self.nodes.pop(node_id)
|
||||
self.events.append({"type": "node_deleted", "node_id": node_id, "full_path": node["full_path"],
|
||||
"new_revision": None})
|
||||
|
||||
def count(self, kind):
|
||||
return sum(1 for item in self.requests if item[0] == kind)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def feed(tmp_path, monkeypatch):
|
||||
provider = Provider()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def reply(self, status, payload):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(payload).encode())
|
||||
|
||||
def do_GET(self):
|
||||
url = urlsplit(self.path)
|
||||
query = parse_qs(url.query)
|
||||
if url.path.endswith("/changes"):
|
||||
kind = "changes"
|
||||
elif url.path.endswith("/nodes"):
|
||||
kind = "list"
|
||||
else:
|
||||
kind = "get"
|
||||
provider.requests.append((kind, url.path, query))
|
||||
if kind in provider.fail:
|
||||
status = provider.fail[kind]
|
||||
return self.reply(status, {"error": {"code": "test_failure", "message": "injected"}})
|
||||
if kind == "changes":
|
||||
after = query.get("after", ["head"])[0]
|
||||
if after == "head":
|
||||
start = len(provider.events)
|
||||
elif re.fullmatch(r"c\d+", after):
|
||||
start = int(after[1:])
|
||||
else:
|
||||
return self.reply(400, {"error": {"code": "invalid_cursor", "message": "bad cursor"}})
|
||||
limit = int(query.get("limit", ["100"])[0])
|
||||
page = provider.events[start:start + limit]
|
||||
items = [{**event, "cursor": f"c{start + index + 1}", "project_id": "project-1",
|
||||
"changed_fields": [], "created_at": "2026-09-01T12:00:00Z"}
|
||||
for index, event in enumerate(page)]
|
||||
end = start + len(page)
|
||||
return self.reply(200, {"items": items, "next_cursor": f"c{end}",
|
||||
"has_more": end < len(provider.events)})
|
||||
if kind == "list":
|
||||
if provider.on_list is not None:
|
||||
hook, provider.on_list = provider.on_list, None
|
||||
hook()
|
||||
rows = [copy.deepcopy(n) for n in provider.nodes.values() if not n["archived_at"]]
|
||||
rows.sort(key=lambda n: (n["updated_at"], n["id"]), reverse=True)
|
||||
after = query.get("updated_after", [""])[0]
|
||||
if after:
|
||||
rows = [n for n in provider.nodes.values() if n["updated_at"] > after]
|
||||
return self.reply(200, {"items": rows, "next_cursor": None})
|
||||
node_id = url.path.rsplit("/", 1)[-1]
|
||||
if node_id not in provider.nodes:
|
||||
return self.reply(404, {"error": {"code": "not_found", "message": "missing"}})
|
||||
return self.reply(200, copy.deepcopy(provider.nodes[node_id]))
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all")
|
||||
monkeypatch.syspath_prepend(str(SCRIPT.parent))
|
||||
spec = importlib.util.spec_from_file_location("feed_sync_test_worker", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
mod.AUTH_DIR = tmp_path / "project-state"
|
||||
mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite"
|
||||
mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.lock"
|
||||
mod.BASE_URL = f"http://127.0.0.1:{server.server_port}"
|
||||
mod.TOKEN = "disposable-test-token"
|
||||
mod.AUTH = {"account_id": "test-account"}
|
||||
mod.DEFAULT_PROJECT_ID = "project-1"
|
||||
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||
mod.refresh_public_auth_state = lambda: None
|
||||
mod.require_unlocked = lambda _name: None
|
||||
mod.resolve_project_id = lambda args: args.get("project_id", "project-1")
|
||||
mod.project_encryption_state = lambda _project_id: "standard"
|
||||
try:
|
||||
yield mod, provider
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def local_ids(mod, text):
|
||||
return [item["id"] for item in mod.search_local_index({"q": text, "limit": 10})["items"]]
|
||||
|
||||
|
||||
def seed(mod, provider, *names):
|
||||
for index, name in enumerate(names):
|
||||
provider.put(make_node(name, index), "node_created")
|
||||
result = mod.reindex_project({})
|
||||
assert result["graph_complete"] is True
|
||||
return result
|
||||
|
||||
|
||||
def test_reindex_takes_head_cursor_before_listing_and_replays_idempotently(feed):
|
||||
mod, provider = feed
|
||||
provider.put(make_node("alpha", 1), "node_created")
|
||||
|
||||
def concurrent_write():
|
||||
provider.put(make_node("alpha", 2, content="alpha concurrent revision"), "node_updated")
|
||||
|
||||
provider.on_list = concurrent_write
|
||||
mod.reindex_project({})
|
||||
|
||||
kinds = [item[0] for item in provider.requests]
|
||||
assert kinds[:2] == ["changes", "list"]
|
||||
assert provider.requests[0][2]["after"] == ["head"]
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] == "c1"
|
||||
assert local_ids(mod, "concurrent") == ["alpha"]
|
||||
|
||||
result = mod.sync_local_index_from_feed("project-1")
|
||||
|
||||
assert (result["events"], result["skipped_current"], result["indexed"]) == (1, 1, 0)
|
||||
assert provider.count("get") == 0 # revision already indexed: no refetch
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] == "c2"
|
||||
|
||||
|
||||
def test_feed_applies_create_update_archive_restore_and_delete(feed):
|
||||
mod, provider = feed
|
||||
seed(mod, provider, "keepme", "archiveme", "deleteme", "restoreme")
|
||||
provider.put(make_node("restoreme", 5, archived=True), "node_archived")
|
||||
provider.put(make_node("freshnode", 10, content="brandnewterm"), "node_created")
|
||||
provider.put(make_node("keepme", 11, content="keepme revisedterm"), "node_updated")
|
||||
provider.put(make_node("archiveme", 12, archived=True), "node_archived")
|
||||
provider.delete("deleteme")
|
||||
provider.put(make_node("restoreme", 13), "node_restored")
|
||||
|
||||
result = mod.sync_local_index_from_feed("project-1")
|
||||
|
||||
assert result["status"] == "synced"
|
||||
assert local_ids(mod, "brandnewterm") == ["freshnode"]
|
||||
assert local_ids(mod, "revisedterm") == ["keepme"]
|
||||
assert local_ids(mod, "archiveme") == []
|
||||
assert local_ids(mod, "deleteme") == []
|
||||
assert local_ids(mod, "restoreme") == ["restoreme"]
|
||||
with mod.index_connect() as conn:
|
||||
ids = {row[0] for row in conn.execute("SELECT node_id FROM local_index_nodes")}
|
||||
# Archived rows match a full rebuild, which lists only active nodes.
|
||||
assert ids == {"keepme", "freshnode", "restoreme"}
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] == f"c{len(provider.events)}"
|
||||
|
||||
|
||||
def test_feed_failure_never_advances_cursor_and_retry_recovers(feed):
|
||||
mod, provider = feed
|
||||
seed(mod, provider, "stable")
|
||||
before = mod.local_index_feed_state("project-1")["feed_cursor"]
|
||||
provider.put(make_node("newcomer", 10, content="latecomerterm"), "node_created")
|
||||
provider.fail["get"] = 503
|
||||
|
||||
with pytest.raises(mod.SafeToolError):
|
||||
mod.sync_local_index_from_feed("project-1")
|
||||
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] == before
|
||||
assert local_ids(mod, "latecomerterm") == []
|
||||
del provider.fail["get"]
|
||||
mod.sync_local_index_from_feed("project-1")
|
||||
assert local_ids(mod, "latecomerterm") == ["newcomer"]
|
||||
|
||||
|
||||
def test_invalid_feed_cursor_triggers_full_reindex(feed):
|
||||
mod, provider = feed
|
||||
seed(mod, provider, "stable")
|
||||
with mod.index_connect() as conn:
|
||||
conn.execute("UPDATE local_index_state SET feed_cursor = 'expired-cursor'")
|
||||
provider.nodes["hardgone"] = make_node("hardgone", 3) # present only in the full listing
|
||||
|
||||
result = mod.sync_local_index({})
|
||||
|
||||
assert result["mode"] == "full_reindex"
|
||||
assert result["reason"] == "feed_cursor_invalid"
|
||||
assert local_ids(mod, "hardgone") == ["hardgone"]
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] == f"c{len(provider.events)}"
|
||||
|
||||
|
||||
def test_old_server_without_feed_falls_back_to_timestamp_sync(feed):
|
||||
mod, provider = feed
|
||||
seed(mod, provider, "stable")
|
||||
provider.fail["changes"] = 404
|
||||
provider.nodes["later"] = make_node("later", 30, content="timestampterm")
|
||||
|
||||
result = mod.sync_local_index({})
|
||||
|
||||
assert result["mode"] == "incremental"
|
||||
assert result["feed"] == "unsupported"
|
||||
assert local_ids(mod, "timestampterm") == ["later"]
|
||||
|
||||
|
||||
def test_reindex_on_old_server_without_feed_still_rebuilds(feed):
|
||||
mod, provider = feed
|
||||
provider.fail["changes"] = 404
|
||||
provider.put(make_node("legacy", 1), "node_created")
|
||||
|
||||
result = mod.reindex_project({})
|
||||
|
||||
assert result["graph_complete"] is True
|
||||
assert local_ids(mod, "legacy") == ["legacy"]
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] is None
|
||||
|
||||
|
||||
def test_search_auto_syncs_from_feed_and_throttles(feed):
|
||||
mod, provider = feed
|
||||
seed(mod, provider, "stable")
|
||||
provider.put(make_node("autosync", 10, content="autosyncterm"), "node_created")
|
||||
|
||||
first = mod.call_tool_impl("nap_discover", {"q": "autosyncterm", "mode": "local"})
|
||||
provider.put(make_node("throttled", 11, content="throttledterm"), "node_created")
|
||||
second = mod.call_tool_impl("nap_discover", {"q": "throttledterm", "mode": "local"})
|
||||
|
||||
assert [item["node_id"] for item in first["items"]] == ["autosync"]
|
||||
assert first["freshness"]["stale"] is False
|
||||
assert first["freshness"]["feed_synced_at"]
|
||||
assert second["items"] == [] # within the throttle window no second feed read
|
||||
assert provider.count("changes") == 2 # reindex head + one automatic sync
|
||||
assert second["freshness"]["feed_sync"] == "throttled"
|
||||
|
||||
|
||||
def test_search_answers_locally_and_marks_stale_when_feed_unavailable(feed):
|
||||
mod, provider = feed
|
||||
seed(mod, provider, "stable")
|
||||
provider.fail["changes"] = 503
|
||||
|
||||
result = mod.call_tool_impl("nap_discover", {"q": "stable", "mode": "local"})
|
||||
|
||||
assert [item["node_id"] for item in result["items"]] == ["stable"]
|
||||
assert result["freshness"]["stale"] is True
|
||||
assert result["freshness"]["reason"] == "server_unavailable"
|
||||
assert result["warnings"][0]["code"] == "server_unavailable"
|
||||
|
||||
|
||||
def test_context_local_read_auto_syncs_from_feed(feed):
|
||||
mod, provider = feed
|
||||
seed(mod, provider, "stable")
|
||||
provider.put(make_node("contextnew", 10), "node_created")
|
||||
|
||||
result = mod.call_tool_impl("nap_context", {"paths": ["/notes/contextnew"], "mode": "hybrid"})
|
||||
|
||||
assert provider.count("changes") == 2
|
||||
assert "contextnew" in json.dumps(result)
|
||||
assert result["freshness"]["feed_sync"] == "synced"
|
||||
|
||||
|
||||
def cursorless_complete_index(mod, provider, *names):
|
||||
for index, name in enumerate(names):
|
||||
node = make_node(name, index)
|
||||
provider.put(node, "node_created")
|
||||
mod.index_node(node)
|
||||
mod.set_local_graph_index_complete("project-1", True)
|
||||
|
||||
|
||||
def test_cursorless_index_bootstraps_on_first_search_then_refreshes(feed):
|
||||
mod, provider = feed
|
||||
cursorless_complete_index(mod, provider, "manual")
|
||||
provider.nodes["gapnode"] = make_node("gapnode", 20, content="gapterm") # changed before tracking
|
||||
|
||||
first = mod.call_tool_impl("nap_discover", {"q": "gapterm", "mode": "local"})
|
||||
|
||||
assert [kind for kind, *_ in provider.requests] == ["changes", "list"]
|
||||
assert provider.requests[0][2]["after"] == ["head"]
|
||||
assert [item["node_id"] for item in first["items"]] == ["gapnode"]
|
||||
assert first["freshness"]["feed_sync"] == "tracking_initialized"
|
||||
assert not first.get("warnings")
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] == "c1"
|
||||
|
||||
provider.put(make_node("manual", 30, content="manual remoteupdateterm"), "node_updated")
|
||||
throttled = mod.call_tool_impl("nap_discover", {"q": "remoteupdateterm", "mode": "local"})
|
||||
assert throttled["items"] == [] and len(provider.requests) == 2 # no HTTP inside the window
|
||||
|
||||
mod.FEED_AUTO_SYNC_STATE.clear() # the throttle window elapses
|
||||
later = mod.call_tool_impl("nap_discover", {"q": "remoteupdateterm", "mode": "local"})
|
||||
assert [item["node_id"] for item in later["items"]] == ["manual"]
|
||||
assert later["freshness"]["feed_sync"] == "synced"
|
||||
|
||||
|
||||
def test_bootstrap_failure_answers_locally_warns_and_does_not_retry(feed):
|
||||
mod, provider = feed
|
||||
cursorless_complete_index(mod, provider, "manual")
|
||||
provider.fail["changes"] = 503
|
||||
|
||||
first = mod.call_tool_impl("nap_discover", {"q": "manual", "mode": "local"})
|
||||
second = mod.call_tool_impl("nap_discover", {"q": "manual", "mode": "local"})
|
||||
|
||||
for result in (first, second):
|
||||
assert [item["node_id"] for item in result["items"]] == ["manual"]
|
||||
assert [w["code"] for w in result["warnings"]] == ["index_not_tracking"]
|
||||
assert result["warnings"][0]["next_action"] == "nap_index_sync"
|
||||
assert result["freshness"]["stale"] is True
|
||||
assert provider.count("changes") == 1 # one attempt per retry window
|
||||
assert provider.count("list") == 0
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] is None
|
||||
|
||||
|
||||
def test_bootstrap_budget_exhaustion_stays_untracked_and_applies_nothing(feed, monkeypatch):
|
||||
mod, provider = feed
|
||||
cursorless_complete_index(mod, provider, "manual")
|
||||
provider.nodes["gapnode"] = make_node("gapnode", 20, content="gapterm")
|
||||
monkeypatch.setattr(mod, "FEED_AUTO_SYNC_MAX_PAGES", 0)
|
||||
|
||||
result = mod.call_tool_impl("nap_discover", {"q": "gapterm", "mode": "local"})
|
||||
|
||||
assert result["items"] == []
|
||||
assert [w["code"] for w in result["warnings"]] == ["index_not_tracking"]
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] is None
|
||||
|
||||
|
||||
def test_bootstrap_on_old_server_marks_unsupported_without_retry(feed):
|
||||
mod, provider = feed
|
||||
cursorless_complete_index(mod, provider, "manual")
|
||||
provider.fail["changes"] = 404
|
||||
|
||||
first = mod.call_tool_impl("nap_discover", {"q": "manual", "mode": "local"})
|
||||
mod.call_tool_impl("nap_discover", {"q": "manual", "mode": "local"})
|
||||
|
||||
assert first["freshness"]["feed_sync"] == "unsupported"
|
||||
assert not first.get("warnings")
|
||||
assert provider.count("changes") == 1 and provider.count("list") == 0
|
||||
|
||||
|
||||
def test_feed_sync_is_bounded_by_pages_and_resumes(feed):
|
||||
mod, provider = feed
|
||||
seed(mod, provider, "stable")
|
||||
start = len(provider.events)
|
||||
for index in range(3):
|
||||
provider.put(make_node(f"bounded{index}", 10 + index), "node_created")
|
||||
|
||||
first = mod.sync_local_index_from_feed("project-1", max_pages=2, page_limit=1)
|
||||
|
||||
assert (first["status"], first["pages"], first["has_more"]) == ("partial", 2, True)
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] == f"c{start + 2}"
|
||||
second = mod.sync_local_index_from_feed("project-1", max_pages=5, page_limit=1)
|
||||
assert second["status"] == "synced"
|
||||
assert local_ids(mod, "bounded2") == ["bounded2"]
|
||||
|
||||
|
||||
def test_explicit_sync_initializes_feed_tracking_for_cursorless_index(feed):
|
||||
mod, provider = feed
|
||||
provider.put(make_node("manual", 1), "node_created")
|
||||
mod.index_node(make_node("manual", 1))
|
||||
mod.set_local_graph_index_complete("project-1", True)
|
||||
|
||||
result = mod.sync_local_index({})
|
||||
|
||||
assert result["mode"] == "incremental"
|
||||
assert result["feed"] == "initialized"
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] == "c1"
|
||||
provider.delete("manual")
|
||||
mod.sync_local_index({})
|
||||
assert local_ids(mod, "manual") == [] # hard delete observed through the feed
|
||||
@@ -34,6 +34,12 @@ def memory(tmp_path, monkeypatch):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if urlsplit(self.path).path.endswith("/changes"):
|
||||
# Model a server without the change feed; the rebuild must not depend on it.
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"error":{"code":"not_found","message":"no change feed"}}')
|
||||
return
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
provider["requests"].append(query)
|
||||
offset = int(query.get("cursor", ["0"])[0])
|
||||
|
||||
@@ -886,6 +886,9 @@ def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod):
|
||||
calls = []
|
||||
|
||||
def fake_request(method, path, payload=None, **kwargs):
|
||||
if "/changes?" in path:
|
||||
# Server without the change feed: sync uses the timestamp path.
|
||||
raise mod.SafeToolError("not_found", "no change feed", status=404)
|
||||
calls.append(path)
|
||||
assert method == "GET"
|
||||
assert "view=full" in path
|
||||
|
||||
@@ -211,11 +211,19 @@ def record_revoked_grant(mod):
|
||||
|
||||
def test_local_answers_warn_when_login_grant_is_revoked(mod):
|
||||
build_complete_index(mod)
|
||||
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(AssertionError("no HTTP expected"))
|
||||
|
||||
def empty_feed(_method, path, *_args, **_kwargs):
|
||||
if "/changes?" in path:
|
||||
return {"items": [], "next_cursor": "c0", "has_more": False}
|
||||
return {"items": [], "next_cursor": None}
|
||||
|
||||
# A healthy read starts change-feed tracking for the cursorless index.
|
||||
mod.request_json = empty_feed
|
||||
healthy = mod.discover_memory({"folder_path": "/plans", "view": "paths"})
|
||||
assert not healthy.get("warnings")
|
||||
assert not (healthy.get("freshness") or {}).get("stale")
|
||||
assert healthy["freshness"]["feed_sync"] == "tracking_initialized"
|
||||
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(AssertionError("no HTTP expected"))
|
||||
|
||||
record_revoked_grant(mod)
|
||||
state_text = (mod.AUTH_DIR / "auth-failure.json").read_text()
|
||||
|
||||
@@ -153,6 +153,18 @@ def indexed_node(path, *, node_type="note", tags=None, status="active", content=
|
||||
}
|
||||
|
||||
|
||||
def feed_provider(calls):
|
||||
"""Synthetic server: empty change feed and no newer nodes."""
|
||||
|
||||
def request(method, path, payload=None, **_kwargs):
|
||||
calls.append((method, path))
|
||||
if "/changes?" in path:
|
||||
return {"items": [], "next_cursor": "c0", "has_more": False}
|
||||
return {"items": [], "next_cursor": None}
|
||||
|
||||
return request
|
||||
|
||||
|
||||
def configure_local_index(mod, tmp_path):
|
||||
mod.AUTH_DIR = tmp_path
|
||||
mod.INDEX_PATH = tmp_path / "index.sqlite"
|
||||
@@ -208,11 +220,14 @@ def test_complete_local_compact_browse_uses_keyset_cursor_without_http(tmp_path)
|
||||
item["updated_at"] = f"2026-08-2{index}T00:00:00Z"
|
||||
mod.index_node(item)
|
||||
mod.set_local_graph_index_complete("project-1", True)
|
||||
calls = []
|
||||
mod.TOKEN = "disposable-test-token" # synthetic login; requests go to feed_provider only
|
||||
mod.request_json = feed_provider(calls)
|
||||
# The first read starts change-feed tracking for this cursorless index.
|
||||
mod.discover_memory({"folder_path": "/plans", "limit": 1, "view": "paths"})
|
||||
assert [path.split("?")[0].rsplit("/", 1)[-1] for _method, path in calls] == ["changes", "nodes"]
|
||||
calls.clear()
|
||||
|
||||
def unexpected_request(*_args, **_kwargs):
|
||||
raise AssertionError("complete local compact browse must not make an HTTP request")
|
||||
|
||||
mod.request_json = unexpected_request
|
||||
first = mod.discover_memory(
|
||||
{
|
||||
"folder_path": "/plans",
|
||||
@@ -241,6 +256,9 @@ def test_complete_local_compact_browse_uses_keyset_cursor_without_http(tmp_path)
|
||||
)
|
||||
assert not ({item["node_id"] for item in first["items"]} & {item["node_id"] for item in second["items"]})
|
||||
assert all(item["node_id"] for item in first["items"] + second["items"])
|
||||
# Steady state inside the refresh throttle window: no HTTP at all.
|
||||
assert calls == []
|
||||
assert first["freshness"]["feed_sync"] == "throttled"
|
||||
|
||||
|
||||
def test_local_compact_browse_cursor_is_bound_to_filters(tmp_path):
|
||||
@@ -482,11 +500,13 @@ def test_complete_filtered_local_search_stays_off_network(tmp_path):
|
||||
)
|
||||
)
|
||||
mod.set_local_graph_index_complete("project-1", True)
|
||||
calls = []
|
||||
mod.TOKEN = "disposable-test-token" # synthetic login; requests go to feed_provider only
|
||||
mod.request_json = feed_provider(calls)
|
||||
mod.search_memory({"q": "facturación", "tag": "billing", "limit": 1, "view": "summary"})
|
||||
assert len(calls) == 2 # one-time change-feed bootstrap: head + catch-up
|
||||
calls.clear()
|
||||
|
||||
def unexpected_request(*_args, **_kwargs):
|
||||
raise AssertionError("a complete filtered local result must not use the network")
|
||||
|
||||
mod.request_json = unexpected_request
|
||||
result = mod.search_memory(
|
||||
{
|
||||
"q": "facturación",
|
||||
@@ -500,6 +520,8 @@ def test_complete_filtered_local_search_stays_off_network(tmp_path):
|
||||
assert [item["full_path"] for item in result["items"]] == [
|
||||
"/documentation/facturacion-electronica"
|
||||
]
|
||||
assert calls == [] # a complete, tracked, recently refreshed index stays off the network
|
||||
assert not result.get("warnings")
|
||||
|
||||
|
||||
def test_discovery_schema_documents_dynamic_sort_and_intent():
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Local search quality: FTS schema v2, weighted ranking and query snippets.
|
||||
|
||||
Every test runs the real index, query planner and ranking code against a
|
||||
disposable SQLite file; no network is used.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
||||
|
||||
|
||||
def load_module(tmp_path):
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
spec = importlib.util.spec_from_file_location("napseer_mcp_server_search_quality_test", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
mod.AUTH_DIR = tmp_path
|
||||
mod.INDEX_PATH = tmp_path / "index.sqlite"
|
||||
mod.INDEX_LOCK_PATH = tmp_path / "index.lock"
|
||||
mod.resolve_project_id = lambda _args: "project-1"
|
||||
mod.project_encryption_state = lambda _project_id: "standard"
|
||||
|
||||
def unexpected_request(*_args, **_kwargs):
|
||||
raise AssertionError("local search quality tests must not use the network")
|
||||
|
||||
mod.request_json = unexpected_request
|
||||
return mod
|
||||
|
||||
|
||||
def indexed_node(path, *, content="", status="active", updated_at="2026-08-22T00:00:00Z", **extra):
|
||||
name = path.rsplit("/", 1)[-1]
|
||||
node = {
|
||||
"id": f"node-{path.strip('/').replace('/', '-')}",
|
||||
"project_id": "project-1",
|
||||
"full_path": path,
|
||||
"folder_path": path.rsplit("/", 1)[0] or None,
|
||||
"name": name,
|
||||
"title": name,
|
||||
"type": "note",
|
||||
"tags": [],
|
||||
"aliases": [],
|
||||
"links": [],
|
||||
"metadata": {"status": status},
|
||||
"content_text": content,
|
||||
"encryption_state": "legacy_plaintext",
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
node.update(extra)
|
||||
return node
|
||||
|
||||
|
||||
def paths(result):
|
||||
return [item["full_path"] for item in result["items"]]
|
||||
|
||||
|
||||
# --- FTS schema v2 ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_stemming_recalls_other_inflections(tmp_path):
|
||||
mod = load_module(tmp_path)
|
||||
mod.index_node(indexed_node("/notes/release-log", content="The deployment pipeline promotes images."))
|
||||
|
||||
assert paths(mod.search_local_index({"q": "deploying", "limit": 5})) == ["/notes/release-log"]
|
||||
assert paths(mod.search_local_index({"q": "configured deployments", "limit": 5})) == ["/notes/release-log"]
|
||||
|
||||
|
||||
def test_prefix_query_uses_prefix_index_and_matches(tmp_path):
|
||||
mod = load_module(tmp_path)
|
||||
mod.index_node(indexed_node("/notes/cluster", content="kubernetes control plane"))
|
||||
|
||||
with mod.index_connect() as conn:
|
||||
sql = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE name = 'local_index_fts'"
|
||||
).fetchone()["sql"]
|
||||
assert "porter unicode61 remove_diacritics 2" in sql
|
||||
assert "prefix = '2 3'" in sql
|
||||
assert paths(mod.search_local_index({"q": "kuber", "limit": 5})) == ["/notes/cluster"]
|
||||
|
||||
|
||||
def test_two_character_terms_are_searchable(tmp_path):
|
||||
mod = load_module(tmp_path)
|
||||
mod.index_node(indexed_node("/notes/note-a", content="db migration runbook for postgres cutover steps"))
|
||||
mod.index_node(indexed_node("/notes/note-b", content="migration", updated_at="2026-09-01T00:00:00Z"))
|
||||
|
||||
analysis = mod.analyze_search_query("db migration")
|
||||
result = mod.search_local_index({"q": "db migration", "limit": 5})
|
||||
|
||||
assert "db" in analysis["terms"]
|
||||
assert paths(result)[0] == "/notes/note-a"
|
||||
|
||||
|
||||
def test_domain_words_are_not_stop_words():
|
||||
mod = load_module(pathlib.Path("/nonexistent"))
|
||||
concepts = mod.analyze_search_query("plan work decide")["concepts"]
|
||||
|
||||
assert {"plan", "work", "decide"} <= set(concepts)
|
||||
|
||||
|
||||
def test_single_node_fts_delete_uses_rowid_not_scan(tmp_path, monkeypatch):
|
||||
mod = load_module(tmp_path)
|
||||
node = indexed_node("/notes/rowid-target", content="first version")
|
||||
mod.index_node(node)
|
||||
statements = []
|
||||
original_open = mod.open_index_connection
|
||||
|
||||
def traced_open():
|
||||
conn = original_open()
|
||||
conn.set_trace_callback(statements.append)
|
||||
return conn
|
||||
|
||||
monkeypatch.setattr(mod, "open_index_connection", traced_open)
|
||||
mod.index_node({**node, "content_text": "second version"})
|
||||
mod.remove_indexed_node(node["id"])
|
||||
|
||||
fts_deletes = [s for s in statements if "DELETE FROM local_index_fts" in s]
|
||||
assert fts_deletes
|
||||
assert all("rowid" in s and "node_id" not in s for s in fts_deletes)
|
||||
with mod.index_connect() as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM local_index_fts").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM local_index_nodes").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_reindexing_a_node_replaces_its_single_fts_row(tmp_path):
|
||||
mod = load_module(tmp_path)
|
||||
node = indexed_node("/notes/replaced", content="alpha")
|
||||
mod.index_node(node)
|
||||
mod.index_node({**node, "content_text": "omega"})
|
||||
|
||||
with mod.index_connect() as conn:
|
||||
rows = conn.execute("SELECT rowid, content_text FROM local_index_fts").fetchall()
|
||||
mapped = conn.execute("SELECT fts_rowid FROM local_index_nodes").fetchone()[0]
|
||||
assert [row["content_text"] for row in rows] == ["omega"]
|
||||
assert rows[0]["rowid"] == mapped
|
||||
|
||||
|
||||
V1_SCHEMA = [
|
||||
"""CREATE TABLE local_index_nodes (
|
||||
node_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, full_path TEXT NOT NULL,
|
||||
name TEXT NOT NULL, folder_path TEXT, node_type TEXT NOT NULL, tags_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL, indexed_at TEXT NOT NULL, title TEXT, status TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0, aliases_json TEXT NOT NULL DEFAULT '[]')""",
|
||||
"""CREATE TABLE local_index_state (
|
||||
project_id TEXT PRIMARY KEY, graph_complete INTEGER NOT NULL DEFAULT 0, graph_completed_at TEXT)""",
|
||||
"""CREATE VIRTUAL TABLE local_index_fts USING fts5(
|
||||
node_id UNINDEXED, project_id UNINDEXED, full_path, name, folder_path, node_type,
|
||||
tags, aliases, links, metadata_text, content_text)""",
|
||||
]
|
||||
|
||||
|
||||
def test_v1_index_upgrades_in_place_and_keeps_completeness(tmp_path):
|
||||
index_path = tmp_path / "index.sqlite"
|
||||
conn = sqlite3.connect(str(index_path))
|
||||
for statement in V1_SCHEMA:
|
||||
conn.execute(statement)
|
||||
conn.execute(
|
||||
"INSERT INTO local_index_nodes VALUES ('n1','project-1','/notes/legacy','legacy','/notes','note','[]',"
|
||||
"'2026-08-01T00:00:00Z','2026-08-01T00:00:00Z','Legacy title','active',0,'[]')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO local_index_fts VALUES ('n1','project-1','/notes/legacy','legacy','/notes','note','','','',"
|
||||
"'','the deployment runbook body')"
|
||||
)
|
||||
conn.execute("INSERT INTO local_index_state VALUES ('project-1', 1, '2026-08-01T00:00:00Z')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
mod = load_module(tmp_path)
|
||||
result = mod.search_local_index({"q": "deploying", "limit": 5})
|
||||
|
||||
assert paths(result) == ["/notes/legacy"]
|
||||
with mod.index_connect() as conn:
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == mod.INDEX_SCHEMA_VERSION
|
||||
assert conn.execute("SELECT COUNT(*) FROM local_index_fts").fetchone()[0] == 1
|
||||
assert "porter" in conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE name = 'local_index_fts'"
|
||||
).fetchone()[0]
|
||||
assert conn.execute("SELECT fts_rowid FROM local_index_nodes").fetchone()[0] is not None
|
||||
assert mod.local_index_diagnostics("project-1")["graph_complete"] is True
|
||||
# The upgrade drops no rows and a legacy-migrated index has no feed cursor yet.
|
||||
assert mod.local_index_feed_state("project-1")["feed_cursor"] is None
|
||||
|
||||
|
||||
# --- ranking --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_body_full_coverage_beats_authority_path_partial_match(tmp_path):
|
||||
mod = load_module(tmp_path)
|
||||
mod.index_node(indexed_node("/rules/quasar", content="unrelated governance text"))
|
||||
mod.index_node(
|
||||
indexed_node(
|
||||
"/work/current/notes-a",
|
||||
content="The quasar rollout checklist is kept here with every gate listed.",
|
||||
)
|
||||
)
|
||||
|
||||
result = mod.search_local_index({"q": "quasar rollout checklist", "limit": 5})
|
||||
|
||||
assert paths(result) == ["/work/current/notes-a", "/rules/quasar"]
|
||||
|
||||
|
||||
def test_superseded_status_is_demoted_unless_requested(tmp_path):
|
||||
mod = load_module(tmp_path)
|
||||
mod.index_node(
|
||||
indexed_node("/decisions/retry-a", content="gateway retry budget", status="superseded",
|
||||
updated_at="2026-09-10T00:00:00Z")
|
||||
)
|
||||
mod.index_node(
|
||||
indexed_node("/decisions/retry-b", content="gateway retry budget", status="active",
|
||||
updated_at="2026-08-01T00:00:00Z")
|
||||
)
|
||||
|
||||
default = mod.search_local_index({"q": "gateway retry budget", "limit": 5})
|
||||
requested = mod.search_local_index({"q": "superseded gateway retry budget", "limit": 5})
|
||||
|
||||
assert paths(default) == ["/decisions/retry-b", "/decisions/retry-a"]
|
||||
assert paths(requested)[0] == "/decisions/retry-a"
|
||||
|
||||
|
||||
def test_explicit_exact_identity_stays_on_top_of_requested_intent(tmp_path):
|
||||
mod = load_module(tmp_path)
|
||||
mod.index_node(indexed_node("/notes/cache-invalidation", content="cache invalidation"))
|
||||
mod.index_node(indexed_node("/decisions/cache-layer", content="cache invalidation " * 5))
|
||||
|
||||
result = mod.search_local_index({"q": "cache invalidation", "intent": "decision", "limit": 5})
|
||||
|
||||
assert paths(result)[0] == "/notes/cache-invalidation"
|
||||
assert "exact_identity" in result["items"][0]["match_reason"]
|
||||
|
||||
|
||||
# --- snippets -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_local_search_returns_query_centred_snippet(tmp_path):
|
||||
mod = load_module(tmp_path)
|
||||
filler = "Unrelated operational background sentence. " * 20
|
||||
mod.index_node(
|
||||
indexed_node("/notes/long-note", content=f"{filler}The zanzibar failover drill runs weekly. {filler}")
|
||||
)
|
||||
mod.set_local_graph_index_complete("project-1", True)
|
||||
|
||||
local = mod.search_local_index({"q": "zanzibar", "limit": 5})
|
||||
envelope = mod.search_memory({"q": "zanzibar", "limit": 5, "view": "summary", "mode": "local"})
|
||||
|
||||
assert "zanzibar" in local["items"][0]["snippet"].lower()
|
||||
assert "zanzibar" in envelope["items"][0]["snippet"].lower()
|
||||
assert "zanzibar" in envelope["items"][0]["preview"].lower()
|
||||
|
||||
|
||||
def test_e2e_nodes_have_no_snippet_or_body_index(tmp_path):
|
||||
mod = load_module(tmp_path)
|
||||
mod.index_node(
|
||||
indexed_node(
|
||||
"/notes/zanzibar-sealed",
|
||||
content="plaintext-secret-marker zanzibar",
|
||||
encryption_state="encrypted",
|
||||
encrypted_content_envelope={"ciphertext": "opaque"},
|
||||
)
|
||||
)
|
||||
|
||||
assert mod.search_local_index({"q": "plaintext-secret-marker", "limit": 5})["items"] == []
|
||||
result = mod.search_local_index({"q": "zanzibar", "limit": 5})
|
||||
assert paths(result) == ["/notes/zanzibar-sealed"]
|
||||
assert not result["items"][0].get("snippet")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["memory-source-policy", "memory source policy"])
|
||||
def test_hyphenated_identity_query_matches_tokenized_path(tmp_path, query):
|
||||
mod = load_module(tmp_path)
|
||||
mod.index_node(indexed_node("/rules/memory-source-policy", content="Canonical memory rule."))
|
||||
mod.index_node(indexed_node("/notes/other", content="policy about memory elsewhere"))
|
||||
|
||||
assert paths(mod.search_local_index({"q": query, "limit": 5}))[0] == "/rules/memory-source-policy"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query, expected",
|
||||
[
|
||||
("existing-alias", "node-notes-first"),
|
||||
("secondaryhandle", "node-notes-second"),
|
||||
("/notes/existing-work", "node-notes-existing-work"),
|
||||
("existing-work", "node-notes-existing-work"),
|
||||
],
|
||||
)
|
||||
def test_discover_local_literal_alias_or_path_is_top_exact_identity(tmp_path, query, expected):
|
||||
mod = load_module(tmp_path)
|
||||
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||
mod.require_unlocked = lambda _name: None
|
||||
mod.index_node(indexed_node("/notes/first", content="body", aliases=["existing-alias"]))
|
||||
mod.index_node(indexed_node("/notes/second", content="body", aliases=["secondaryhandle"]))
|
||||
mod.index_node(indexed_node("/notes/existing-work", content="body"))
|
||||
# Authoritative distractor whose body repeats the words of every literal.
|
||||
mod.index_node(
|
||||
indexed_node(
|
||||
"/rules/distractor",
|
||||
content="existing alias existing work secondaryhandle notes " * 4,
|
||||
)
|
||||
)
|
||||
mod.set_local_graph_index_complete("project-1", True)
|
||||
|
||||
result = mod.call_tool_impl("nap_discover", {"q": query, "mode": "local", "view": "summary"})
|
||||
|
||||
assert result["items"][0]["node_id"] == expected
|
||||
assert "exact_identity" in result["items"][0]["match_reason"]
|
||||
Reference in New Issue
Block a user