Overhaul docs retrieval and web search quality
Replace the abandoned llms-txt-mcp/Chroma docs backend with an in-repo MCP service: SQLite WAL + FTS5 + sentence-transformer embeddings, transactional source replacement, persisted state across restarts, singleflight refresh with conditional requests, hybrid lexical/semantic ranking with exact-duplicate collapse, source/host filters, and explicit-by-default content retrieval. Add docs_rebuild and a docs-rebuild CLI command. Add deterministic llms-full.txt snapshot generation for machine-local menus with hash-validated provenance manifests; lifecycle commands promote a local menu to its snapshot only when the manifest validates. Switch public source profiles to content-bearing llms-full.txt feeds. Improve web search: bounded provider fallback with per-attempt diagnostics and cancellation, an optional Brave Search API provider, strict SearXNG engine selection, capped link/media extraction, and a real engine=browser renderer that routes every request through the existing SSRF vetting while blocking WebSockets, non-GET traffic, and private destinations. Extend release checks with offline unit suites and isolated candidate container tests for both images.
This commit is contained in:
22
scripts/docs-rebuild.mjs
Normal file
22
scripts/docs-rebuild.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import { rpc } from "../docker/web-search/mcp-probe.mjs";
|
||||
|
||||
const [url, ...sources] = process.argv.slice(2);
|
||||
if (!url) throw new Error("usage: node scripts/docs-rebuild.mjs <mcp-url> [source ...]");
|
||||
|
||||
await rpc(url, 1, "initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "context-kit-docs-rebuild", version: "1" }
|
||||
}, 10_000);
|
||||
const result = await rpc(url, 2, "tools/call", {
|
||||
name: "docs_rebuild",
|
||||
arguments: sources.length ? { sources } : {}
|
||||
}, 600_000);
|
||||
if (result.isError) {
|
||||
const text = (result.content || []).map(part => part.text || "").join("\n");
|
||||
throw new Error(text || "docs_rebuild failed");
|
||||
}
|
||||
const structured = result.structuredContent || JSON.parse(
|
||||
(result.content || []).find(part => part.type === "text")?.text || "{}"
|
||||
);
|
||||
console.log(JSON.stringify(structured, null, 2));
|
||||
309
scripts/docs_snapshot.py
Normal file
309
scripts/docs_snapshot.py
Normal file
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
GENERATOR_VERSION = "1"
|
||||
_LINK = re.compile(r"^\s*[-*]\s+\[([^]]+)]\(([^)]+)\)(?::\s*(.*))?\s*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MenuEntry:
|
||||
title: str
|
||||
url: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FetchedPage:
|
||||
requested_url: str
|
||||
resolved_url: str
|
||||
body: bytes
|
||||
content_type: str
|
||||
etag: str | None
|
||||
last_modified: str | None
|
||||
|
||||
|
||||
def parse_menu(content: str, source_url: str = "") -> list[MenuEntry]:
|
||||
entries: list[MenuEntry] = []
|
||||
for line in content.splitlines():
|
||||
match = _LINK.match(line)
|
||||
if match:
|
||||
entries.append(
|
||||
MenuEntry(
|
||||
title=match.group(1).strip(),
|
||||
url=urljoin(source_url, match.group(2).strip()),
|
||||
description=(match.group(3) or "").strip(),
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
class _ReadableHTML(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.all_parts: list[str] = []
|
||||
self.main_parts: list[str] = []
|
||||
self.main_depth = 0
|
||||
self.skip_depth = 0
|
||||
self.heading_level = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs) -> None:
|
||||
tag = tag.lower()
|
||||
if tag in {"script", "style", "svg", "noscript", "nav", "footer"}:
|
||||
self.skip_depth += 1
|
||||
return
|
||||
if tag in {"main", "article"}:
|
||||
self.main_depth += 1
|
||||
if self.skip_depth:
|
||||
return
|
||||
if tag in {"p", "div", "section", "br", "table", "tr", "pre"}:
|
||||
self._append("\n")
|
||||
elif tag == "li":
|
||||
self._append("\n- ")
|
||||
elif tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
|
||||
self.heading_level = int(tag[1])
|
||||
self._append(f"\n\n{'#' * self.heading_level} ")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.lower()
|
||||
if tag in {"script", "style", "svg", "noscript", "nav", "footer"} and self.skip_depth:
|
||||
self.skip_depth -= 1
|
||||
return
|
||||
if not self.skip_depth and tag in {"p", "div", "section", "li", "tr", "pre", "h1", "h2", "h3", "h4", "h5", "h6"}:
|
||||
self._append("\n")
|
||||
if tag in {"main", "article"} and self.main_depth:
|
||||
self.main_depth -= 1
|
||||
if tag.startswith("h"):
|
||||
self.heading_level = 0
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not self.skip_depth:
|
||||
self._append(data)
|
||||
|
||||
def _append(self, text: str) -> None:
|
||||
self.all_parts.append(text)
|
||||
if self.main_depth:
|
||||
self.main_parts.append(text)
|
||||
|
||||
def rendered(self) -> str:
|
||||
preferred = self.main_parts if any(part.strip() for part in self.main_parts) else self.all_parts
|
||||
text = html.unescape("".join(preferred)).replace("\r", "")
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
text = re.sub(r" *\n *", "\n", text)
|
||||
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
|
||||
|
||||
def page_to_markdown(page: FetchedPage) -> str:
|
||||
text = page.body.decode("utf-8", errors="replace")
|
||||
content_type = page.content_type.lower()
|
||||
if "html" not in content_type and not re.search(r"<html|<main|<article", text[:1000], re.I):
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
parser = _ReadableHTML()
|
||||
parser.feed(text)
|
||||
return parser.rendered()
|
||||
|
||||
|
||||
class CachedFetcher:
|
||||
def __init__(self, cache_dir: Path, offline: bool = False, timeout: float = 30):
|
||||
self.cache_dir = cache_dir
|
||||
self.offline = offline
|
||||
self.timeout = timeout
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def fetch(self, url: str) -> FetchedPage:
|
||||
key = hashlib.sha256(url.encode()).hexdigest()
|
||||
body_path = self.cache_dir / f"{key}.body"
|
||||
metadata_path = self.cache_dir / f"{key}.json"
|
||||
metadata = json.loads(metadata_path.read_text()) if metadata_path.exists() else {}
|
||||
if self.offline:
|
||||
if not body_path.exists():
|
||||
raise RuntimeError(f"offline cache miss: {url}")
|
||||
return self._cached(url, body_path, metadata)
|
||||
|
||||
headers = {}
|
||||
if metadata.get("etag"):
|
||||
headers["If-None-Match"] = metadata["etag"]
|
||||
if metadata.get("last_modified"):
|
||||
headers["If-Modified-Since"] = metadata["last_modified"]
|
||||
headers["User-Agent"] = "context-kit-snapshot/1.0"
|
||||
try:
|
||||
response = urlopen(Request(url, headers=headers), timeout=self.timeout)
|
||||
except HTTPError as error:
|
||||
if error.code != 304:
|
||||
raise
|
||||
response = error
|
||||
if response.status == 304:
|
||||
if not body_path.exists():
|
||||
raise RuntimeError(f"HTTP 304 without cached body: {url}")
|
||||
return self._cached(url, body_path, metadata)
|
||||
body = response.read()
|
||||
metadata = {
|
||||
"requested_url": url,
|
||||
"resolved_url": response.geturl(),
|
||||
"content_type": response.headers.get("content-type", ""),
|
||||
"etag": response.headers.get("etag"),
|
||||
"last_modified": response.headers.get("last-modified"),
|
||||
"sha256": hashlib.sha256(body).hexdigest(),
|
||||
}
|
||||
atomic_write(body_path, body)
|
||||
atomic_write(metadata_path, (json.dumps(metadata, sort_keys=True, indent=2) + "\n").encode())
|
||||
return self._cached(url, body_path, metadata)
|
||||
|
||||
@staticmethod
|
||||
def _cached(url: str, body_path: Path, metadata: dict) -> FetchedPage:
|
||||
return FetchedPage(
|
||||
requested_url=url,
|
||||
resolved_url=metadata.get("resolved_url", url),
|
||||
body=body_path.read_bytes(),
|
||||
content_type=metadata.get("content_type", "text/plain"),
|
||||
etag=metadata.get("etag"),
|
||||
last_modified=metadata.get("last_modified"),
|
||||
)
|
||||
|
||||
|
||||
def build_snapshot(menu_path: Path, fetcher) -> dict:
|
||||
menu = menu_path.read_text()
|
||||
entries = parse_menu(menu)
|
||||
if not entries:
|
||||
raise RuntimeError(f"no markdown links in {menu_path}")
|
||||
sections: list[str] = []
|
||||
documents: list[dict] = []
|
||||
failures: list[str] = []
|
||||
for entry in entries:
|
||||
try:
|
||||
page = fetcher.fetch(entry.url)
|
||||
content = page_to_markdown(page)
|
||||
if not content:
|
||||
raise RuntimeError("extracted content is empty")
|
||||
sections.append(f"# {entry.title}\n\nSource: {page.resolved_url}\n\n{content}")
|
||||
documents.append(
|
||||
{
|
||||
"title": entry.title,
|
||||
"requested_url": entry.url,
|
||||
"resolved_url": page.resolved_url,
|
||||
"content_sha256": hashlib.sha256(content.encode()).hexdigest(),
|
||||
"source_sha256": hashlib.sha256(page.body).hexdigest(),
|
||||
"etag": page.etag,
|
||||
"last_modified": page.last_modified,
|
||||
}
|
||||
)
|
||||
except Exception as error:
|
||||
failures.append(f"{entry.url}: {error}")
|
||||
if failures:
|
||||
raise RuntimeError("snapshot fetch failed; previous output preserved:\n" + "\n".join(failures))
|
||||
|
||||
output = ("\n\n".join(sections).strip() + "\n").encode()
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"generator_version": GENERATOR_VERSION,
|
||||
"menu": menu_path.name,
|
||||
"menu_sha256": hashlib.sha256(menu.encode()).hexdigest(),
|
||||
"output_sha256": hashlib.sha256(output).hexdigest(),
|
||||
"document_count": len(documents),
|
||||
"documents": documents,
|
||||
}
|
||||
output_path = menu_path.with_name("llms-full.txt")
|
||||
manifest_path = menu_path.with_name("llms-full.provenance.json")
|
||||
atomic_write(output_path, output)
|
||||
atomic_write(manifest_path, (json.dumps(manifest, sort_keys=True, indent=2) + "\n").encode())
|
||||
return {"menu": str(menu_path), "output": str(output_path), **manifest}
|
||||
|
||||
|
||||
def validate_snapshot(output_path: Path) -> dict:
|
||||
manifest_path = output_path.with_name("llms-full.provenance.json")
|
||||
if not output_path.is_file() or not manifest_path.is_file():
|
||||
raise RuntimeError("snapshot or provenance manifest is missing")
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
output_hash = hashlib.sha256(output_path.read_bytes()).hexdigest()
|
||||
if manifest.get("output_sha256") != output_hash:
|
||||
raise RuntimeError("snapshot hash does not match provenance manifest")
|
||||
menu_path = output_path.with_name(str(manifest.get("menu") or "llms.txt"))
|
||||
if not menu_path.is_file():
|
||||
raise RuntimeError("snapshot source menu is missing")
|
||||
menu_hash = hashlib.sha256(menu_path.read_bytes()).hexdigest()
|
||||
if manifest.get("menu_sha256") != menu_hash:
|
||||
raise RuntimeError("menu hash does not match provenance manifest")
|
||||
return manifest
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def snapshot_menus(menus: list[Path], fetcher) -> dict:
|
||||
"""Snapshot every menu independently so one bad directory cannot block the rest."""
|
||||
report: dict = {"snapshots": [], "skipped": [], "failures": []}
|
||||
for menu in menus:
|
||||
if not parse_menu(menu.read_text()):
|
||||
report["skipped"].append({"menu": str(menu), "reason": "no markdown links"})
|
||||
continue
|
||||
try:
|
||||
report["snapshots"].append(build_snapshot(menu, fetcher))
|
||||
except Exception as error:
|
||||
report["failures"].append({"menu": str(menu), "error": str(error)})
|
||||
return report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build deterministic content snapshots from local llms.txt menus.")
|
||||
parser.add_argument("--source-root", type=Path)
|
||||
parser.add_argument("--cache-dir", type=Path)
|
||||
parser.add_argument("--only", action="append", default=[])
|
||||
parser.add_argument("--offline", action="store_true")
|
||||
parser.add_argument("--validate-output", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.validate_output:
|
||||
print(json.dumps(validate_snapshot(args.validate_output), sort_keys=True))
|
||||
return
|
||||
if not args.source_root:
|
||||
parser.error("--source-root is required unless --validate-output is used")
|
||||
cache_dir = args.cache_dir or args.source_root / ".snapshot-cache"
|
||||
menus = sorted(args.source_root.glob("*/llms.txt"))
|
||||
if args.only:
|
||||
selected = set(args.only)
|
||||
menus = [menu for menu in menus if menu.parent.name in selected]
|
||||
if not menus:
|
||||
raise SystemExit("no matching llms.txt menus")
|
||||
|
||||
fetcher = CachedFetcher(cache_dir, offline=args.offline)
|
||||
try:
|
||||
report = snapshot_menus(menus, fetcher)
|
||||
finally:
|
||||
fetcher.close()
|
||||
print(json.dumps(report, sort_keys=True, indent=2))
|
||||
if report["failures"]:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
11
scripts/fixtures/docs/local-sources/fixture/llms-full.txt
Normal file
11
scripts/fixtures/docs/local-sources/fixture/llms-full.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
# Environment Variables
|
||||
|
||||
Source: https://docs.example.test/environment
|
||||
|
||||
`CONTEXT_KIT_EXACT_IDENTIFIER_20260724` enables the deterministic candidate fixture.
|
||||
|
||||
# Durable Persistence
|
||||
|
||||
Source: https://docs.example.test/persistence
|
||||
|
||||
Checkpoints preserve graph state across process restarts.
|
||||
1
scripts/fixtures/docs/sources.txt
Normal file
1
scripts/fixtures/docs/sources.txt
Normal file
@@ -0,0 +1 @@
|
||||
http://127.0.0.1:8769/fixture/llms-full.txt
|
||||
52
scripts/fixtures/web/mock-server.mjs
Normal file
52
scripts/fixtures/web/mock-server.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import http from "node:http";
|
||||
|
||||
let websocketUpgrades = 0;
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://mock-search.test");
|
||||
if (url.pathname === "/search") {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({
|
||||
results: [{
|
||||
title: "Deterministic Search Result",
|
||||
url: "https://example.test/result",
|
||||
content: `fixture result for ${url.searchParams.get("q")}`
|
||||
}]
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/dynamic") {
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(`<!doctype html><html><head><title>Dynamic Fixture</title></head>
|
||||
<body><main id="content">initial content</main>
|
||||
<script>document.getElementById("content").textContent = "BROWSER_RENDERED_MARKER";</script>
|
||||
</body></html>`);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/redirect-private") {
|
||||
response.writeHead(302, { Location: "http://127.0.0.1:8765/private" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/websocket-attempt") {
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(`<!doctype html><html><body><main id="content">starting</main>
|
||||
<script>
|
||||
const socket = new WebSocket("ws://mock-search.test:8080/socket");
|
||||
socket.onerror = () => { document.getElementById("content").textContent = "WEBSOCKET_BLOCKED"; };
|
||||
</script></body></html>`);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/ws-count") {
|
||||
response.writeHead(200, { "Content-Type": "text/plain" });
|
||||
response.end(String(websocketUpgrades));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
|
||||
server.listen(8080, "0.0.0.0");
|
||||
server.on("upgrade", (_request, socket) => {
|
||||
websocketUpgrades += 1;
|
||||
socket.destroy();
|
||||
});
|
||||
@@ -267,7 +267,7 @@ bash -n scripts/release-check
|
||||
bash -n scripts/test-compose-upgrade.sh
|
||||
bash -n scripts/test-lifecycle.sh
|
||||
sh -n docker/docs/entrypoint.sh
|
||||
check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js docker/web-search/mcp-probe.mjs docker/web-search/http-entrypoint.mjs scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs scripts/test-web-search-http.mjs
|
||||
check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js docker/web-search/overrides/brave.js docker/web-search/overrides/browser-fetch.js docker/web-search/overrides/registry.js docker/web-search/mcp-probe.mjs docker/web-search/http-entrypoint.mjs scripts/docs-rebuild.mjs scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs scripts/test-docs-candidate.mjs scripts/test-web-search-candidate.mjs scripts/test-web-search-http.mjs scripts/test-web-search-quality.mjs
|
||||
|
||||
node -e 'const fs=require("node:fs"); JSON.parse(fs.readFileSync("snippets/opencode.json", "utf8")); JSON.parse(fs.readFileSync("snippets/claude.mcp.json", "utf8"));'
|
||||
CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install opencode > "${tmp_dir}/opencode-default.json"
|
||||
@@ -296,6 +296,8 @@ assert_redaction_check_does_not_disclose_matches
|
||||
bash scripts/test-compose-upgrade.sh
|
||||
bash scripts/test-lifecycle.sh
|
||||
node scripts/test-web-search-http.mjs
|
||||
node scripts/test-web-search-quality.mjs
|
||||
python3 scripts/test-doc-snapshots.py
|
||||
|
||||
bin/context-kit redaction-check
|
||||
docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml config >/dev/null
|
||||
@@ -313,6 +315,12 @@ assert_hostile_requests_rejected
|
||||
node scripts/smoke-web-search.mjs bin/context-kit web-search
|
||||
node scripts/smoke-docs.mjs bin/context-kit docs
|
||||
node scripts/smoke-repomix.mjs bin/context-kit repomix
|
||||
docker run --rm --entrypoint python "${CONTEXT_KIT_DOCS_IMAGE}" -m unittest discover -s /opt/context-kit/tests -t /opt/context-kit
|
||||
CONTEXT_KIT_DOCS_CANDIDATE_IMAGE="${CONTEXT_KIT_DOCS_IMAGE}" \
|
||||
CONTEXT_KIT_DOCS_TEST_MODELS="${CONTEXT_KIT_DATA_DIR}/models" \
|
||||
bash scripts/test-docs-candidate.sh
|
||||
CONTEXT_KIT_WEB_SEARCH_CANDIDATE_IMAGE="${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
|
||||
bash scripts/test-web-search-candidate.sh
|
||||
assert_web_search_backend_supervision
|
||||
|
||||
printf 'pass release-check\n'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { requireToolSuccess, runSmoke } from "./mcp-smoke-client.mjs";
|
||||
import { requireToolSuccess, runSmoke, textFrom } from "./mcp-smoke-client.mjs";
|
||||
|
||||
const live = process.env.CONTEXT_KIT_LIVE_CHECKS === "1";
|
||||
const localSourceSmokeUrl = process.env.CONTEXT_KIT_LOCAL_SOURCE_SMOKE_URL;
|
||||
@@ -12,7 +12,8 @@ runSmoke({
|
||||
const toolNames = await client.requireTools(["docs_query", "docs_sources"]);
|
||||
|
||||
const sources = requireToolSuccess("docs_sources", await client.callTool("docs_sources"));
|
||||
if (!Array.isArray(sources?.structuredContent?.result)) {
|
||||
const sourcesPayload = sources?.structuredContent || JSON.parse(textFrom(sources) || "null");
|
||||
if (typeof sourcesPayload?.source_count !== "number") {
|
||||
const sourcesText = JSON.stringify(sources);
|
||||
throw new Error(`docs_sources returned unexpected payload: ${sourcesText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
100
scripts/test-doc-snapshots.py
Normal file
100
scripts/test-doc-snapshots.py
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from docs_snapshot import FetchedPage, build_snapshot, page_to_markdown, snapshot_menus, validate_snapshot
|
||||
|
||||
|
||||
class FakeFetcher:
|
||||
def __init__(self, pages: dict[str, FetchedPage | Exception]):
|
||||
self.pages = pages
|
||||
|
||||
def fetch(self, url: str) -> FetchedPage:
|
||||
result = self.pages[url]
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
|
||||
def page(url: str, body: str, content_type: str = "text/html") -> FetchedPage:
|
||||
return FetchedPage(url, url, body.encode(), content_type, '"fixture"', "Wed, 01 Jan 2025 00:00:00 GMT")
|
||||
|
||||
|
||||
class SnapshotTest(unittest.TestCase):
|
||||
def test_html_extraction_prefers_main_and_discards_navigation(self) -> None:
|
||||
rendered = page_to_markdown(
|
||||
page(
|
||||
"https://example.test/page",
|
||||
"<html><nav>Noise</nav><main><h1>API</h1><p>Useful content.</p></main></html>",
|
||||
)
|
||||
)
|
||||
self.assertNotIn("Noise", rendered)
|
||||
self.assertIn("# API", rendered)
|
||||
self.assertIn("Useful content.", rendered)
|
||||
|
||||
def test_snapshot_and_manifest_are_deterministic(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
menu = Path(directory) / "fixture" / "llms.txt"
|
||||
menu.parent.mkdir()
|
||||
menu.write_text("# Menu\n\n- [API](https://example.test/api)\n")
|
||||
fetcher = FakeFetcher(
|
||||
{"https://example.test/api": page("https://example.test/api", "<main><h1>API</h1><p>Stable.</p></main>")}
|
||||
)
|
||||
|
||||
first = build_snapshot(menu, fetcher)
|
||||
first_output = menu.with_name("llms-full.txt").read_bytes()
|
||||
first_manifest = menu.with_name("llms-full.provenance.json").read_bytes()
|
||||
second = build_snapshot(menu, fetcher)
|
||||
|
||||
self.assertEqual(first["output_sha256"], second["output_sha256"])
|
||||
self.assertEqual(first_output, menu.with_name("llms-full.txt").read_bytes())
|
||||
self.assertEqual(first_manifest, menu.with_name("llms-full.provenance.json").read_bytes())
|
||||
self.assertEqual(first["output_sha256"], validate_snapshot(menu.with_name("llms-full.txt"))["output_sha256"])
|
||||
|
||||
menu.with_name("llms-full.txt").write_text("tampered\n")
|
||||
with self.assertRaisesRegex(RuntimeError, "does not match"):
|
||||
validate_snapshot(menu.with_name("llms-full.txt"))
|
||||
|
||||
def test_failed_build_preserves_last_good_snapshot(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
menu = Path(directory) / "fixture" / "llms.txt"
|
||||
menu.parent.mkdir()
|
||||
menu.write_text("# Menu\n\n- [API](https://example.test/api)\n")
|
||||
output = menu.with_name("llms-full.txt")
|
||||
output.write_text("last good\n")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "previous output preserved"):
|
||||
build_snapshot(menu, FakeFetcher({"https://example.test/api": RuntimeError("offline")}))
|
||||
|
||||
self.assertEqual("last good\n", output.read_text())
|
||||
|
||||
def test_one_bad_menu_does_not_block_other_directories(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for name, body in [
|
||||
("good", "# Menu\n\n- [API](https://example.test/api)\n"),
|
||||
("prose-only", "# Workspace Notes\n\nNo links here, just prose.\n"),
|
||||
("broken", "# Menu\n\n- [Down](https://example.test/down)\n"),
|
||||
]:
|
||||
(root / name).mkdir()
|
||||
(root / name / "llms.txt").write_text(body)
|
||||
fetcher = FakeFetcher({
|
||||
"https://example.test/api": page("https://example.test/api", "<main><h1>API</h1><p>Stable.</p></main>"),
|
||||
"https://example.test/down": RuntimeError("host unreachable"),
|
||||
})
|
||||
|
||||
report = snapshot_menus(sorted(root.glob("*/llms.txt")), fetcher)
|
||||
|
||||
self.assertEqual(1, len(report["snapshots"]))
|
||||
self.assertTrue((root / "good" / "llms-full.txt").exists())
|
||||
self.assertEqual(1, len(report["skipped"]))
|
||||
self.assertIn("prose-only", report["skipped"][0]["menu"])
|
||||
self.assertEqual(1, len(report["failures"]))
|
||||
self.assertIn("broken", report["failures"][0]["menu"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
43
scripts/test-docs-candidate.mjs
Normal file
43
scripts/test-docs-candidate.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { probeMcp, rpc } from "../docker/web-search/mcp-probe.mjs";
|
||||
|
||||
const url = process.argv[2];
|
||||
if (!url) throw new Error("usage: node scripts/test-docs-candidate.mjs <mcp-url>");
|
||||
|
||||
const required = ["docs_query", "docs_rebuild", "docs_refresh", "docs_sources"];
|
||||
const tools = await probeMcp(url, { timeoutMs: 10_000, expectedTools: required });
|
||||
assert.deepEqual(tools, required);
|
||||
|
||||
function structured(result) {
|
||||
if (result.structuredContent) return result.structuredContent;
|
||||
const text = (result.content || []).find(part => part.type === "text")?.text;
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
const refreshed = structured(await rpc(url, 3, "tools/call", {
|
||||
name: "docs_refresh",
|
||||
arguments: { force: true }
|
||||
}, 120_000));
|
||||
assert.equal(refreshed.sources[0].status, "updated", JSON.stringify(refreshed.sources[0]));
|
||||
assert(refreshed.sources[0].document_count >= 2);
|
||||
|
||||
const search = structured(await rpc(url, 4, "tools/call", {
|
||||
name: "docs_query",
|
||||
arguments: { query: "CONTEXT_KIT_EXACT_IDENTIFIER_20260724", limit: 3 }
|
||||
}, 30_000));
|
||||
assert.equal(search.search_results[0].title, "Environment Variables");
|
||||
assert.deepEqual(search.retrieved_content, {});
|
||||
|
||||
const identifier = search.search_results[0].id;
|
||||
const retrieved = structured(await rpc(url, 5, "tools/call", {
|
||||
name: "docs_query",
|
||||
arguments: {
|
||||
query: "CONTEXT_KIT_EXACT_IDENTIFIER_20260724",
|
||||
retrieve_ids: [identifier],
|
||||
max_bytes: 12_000
|
||||
}
|
||||
}, 30_000));
|
||||
assert(retrieved.retrieved_content[identifier].content.includes("CONTEXT_KIT_EXACT_IDENTIFIER_20260724"));
|
||||
|
||||
console.log("pass docs candidate transport, refresh, hybrid search, and explicit retrieval");
|
||||
40
scripts/test-docs-candidate.sh
Normal file
40
scripts/test-docs-candidate.sh
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
IMAGE="${CONTEXT_KIT_DOCS_CANDIDATE_IMAGE:-context-kit/docs-mcp:quality-20260724}"
|
||||
MODELS="${CONTEXT_KIT_DOCS_TEST_MODELS:-${CONTEXT_KIT_DATA_DIR:-${HOME}/.local/share/context-kit}/models}"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
CONTAINER="context-kit-docs-quality-$RANDOM-$$"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
|
||||
rm -rf "${TMP_DIR}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "${TMP_DIR}/data"
|
||||
docker run -d --name "${CONTAINER}" \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-p 127.0.0.1::8000 \
|
||||
-e HF_HUB_OFFLINE=1 \
|
||||
-e TRANSFORMERS_OFFLINE=1 \
|
||||
-e DOCS_MCP_PREINDEX=0 \
|
||||
-v "${TMP_DIR}/data:/data" \
|
||||
-v "${MODELS}:/models:ro" \
|
||||
-v "${ROOT}/scripts/fixtures/docs/sources.txt:/etc/context-kit/docs-sources.txt:ro" \
|
||||
-v "${ROOT}/scripts/fixtures/docs/local-sources:/etc/context-kit/local-sources:ro" \
|
||||
"${IMAGE}" >/dev/null
|
||||
|
||||
binding="$(docker port "${CONTAINER}" 8000/tcp)"
|
||||
port="${binding##*:}"
|
||||
for _ in {1..120}; do
|
||||
if curl -fsS "http://127.0.0.1:${port}/status" >/dev/null 2>&1; then
|
||||
node "${ROOT}/scripts/test-docs-candidate.mjs" "http://127.0.0.1:${port}/mcp"
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
|
||||
docker logs "${CONTAINER}" >&2
|
||||
exit 1
|
||||
@@ -259,7 +259,7 @@ new_case() {
|
||||
unset CONTEXT_KIT_DOCKER_CIDFILE CONTEXT_KIT_RUNTIME_DIR FAKE_DOCS_UID FAKE_WEB_UID \
|
||||
FAKE_REPLACEMENT_REQUIRED FAKE_RESTART_FAIL FAKE_DROP_RUNNING FAKE_SEARXNG_FAIL \
|
||||
FAKE_WEB_SEARCH_FAIL FAKE_DOCS_FAIL FAKE_LEGACY_CONTAINER FAKE_CLIENT_OWNER_MISMATCH \
|
||||
FAKE_EXPECT_DOCS_SOURCES FAKE_EXPECT_DOCS_SOURCES_ABSENT
|
||||
FAKE_EXPECT_DOCS_SOURCES FAKE_EXPECT_DOCS_SOURCES_ABSENT CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR
|
||||
mkdir -p "${FAKE_DOCKER_STATE}" "${HOME}"
|
||||
: > "${FAKE_DOCKER_LOG}"
|
||||
}
|
||||
@@ -432,6 +432,34 @@ grep -F 'https://example.test/llms.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.tx
|
||||
|| fail_test "restart did not regenerate the bind-mounted docs source list"
|
||||
assert_no_docs_sources_artifacts
|
||||
|
||||
new_case snapshot-promotion
|
||||
seed_service searxng
|
||||
seed_service web-search-mcp
|
||||
seed_service docs-mcp
|
||||
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR="${CASE_ROOT}/local-sources"
|
||||
mkdir -p "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich"
|
||||
printf '# source menu\n' > "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms.txt"
|
||||
printf '# generated snapshot\n' > "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms-full.txt"
|
||||
menu_hash="$(sha256sum "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms.txt")"
|
||||
menu_hash="${menu_hash%% *}"
|
||||
output_hash="$(sha256sum "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms-full.txt")"
|
||||
output_hash="${output_hash%% *}"
|
||||
printf '{"menu":"llms.txt","menu_sha256":"%s","output_sha256":"%s"}\n' \
|
||||
"${menu_hash}" "${output_hash}" \
|
||||
> "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms-full.provenance.json"
|
||||
printf 'http://127.0.0.1:8769/immich/llms.txt\n' > "${CASE_ROOT}/sources.txt"
|
||||
export CONTEXT_KIT_DOCS_SOURCES="${CASE_ROOT}/sources.txt"
|
||||
"${CONTEXT_KIT}" restart
|
||||
grep -F 'http://127.0.0.1:8769/immich/llms-full.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" >/dev/null \
|
||||
|| fail_test "restart did not promote a local menu to its generated full snapshot"
|
||||
if grep -Fx 'http://127.0.0.1:8769/immich/llms.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" >/dev/null; then
|
||||
fail_test "restart retained the menu URL despite an available full snapshot"
|
||||
fi
|
||||
printf '# inconsistent snapshot\n' > "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/immich/llms-full.txt"
|
||||
"${CONTEXT_KIT}" restart
|
||||
grep -Fx 'http://127.0.0.1:8769/immich/llms.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" >/dev/null \
|
||||
|| fail_test "restart promoted a snapshot whose provenance hash did not match"
|
||||
|
||||
new_case restart-failure
|
||||
seed_service searxng stopped
|
||||
seed_service web-search-mcp
|
||||
|
||||
59
scripts/test-web-search-candidate.mjs
Normal file
59
scripts/test-web-search-candidate.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { probeMcp, rpc } from "../docker/web-search/mcp-probe.mjs";
|
||||
|
||||
const url = process.argv[2];
|
||||
if (!url) throw new Error("usage: node scripts/test-web-search-candidate.mjs <mcp-url>");
|
||||
|
||||
await probeMcp(url, { timeoutMs: 10_000 });
|
||||
|
||||
function payload(result) {
|
||||
const text = (result.content || []).find(part => part.type === "text")?.text;
|
||||
return text ? JSON.parse(text) : result.structuredContent;
|
||||
}
|
||||
|
||||
const search = payload(await rpc(url, 3, "tools/call", {
|
||||
name: "search_web",
|
||||
arguments: { q: "candidate diagnostic fixture", limit: 3, provider: "searxng" }
|
||||
}, 30_000));
|
||||
assert.equal(search.items[0].title, "Deterministic Search Result");
|
||||
assert.equal(search.providerUsed, "searxng");
|
||||
assert.equal(search.diagnostics.attempts[0].status, "success");
|
||||
assert.equal(search.diagnostics.attempts[0].result_count, 1);
|
||||
|
||||
const httpFetch = payload(await rpc(url, 4, "tools/call", {
|
||||
name: "fetch_url",
|
||||
arguments: { url: "http://mock-search.test:8080/dynamic", engine: "http", format: "text" }
|
||||
}, 30_000));
|
||||
assert(!httpFetch.content.includes("BROWSER_RENDERED_MARKER"));
|
||||
|
||||
const browserFetch = payload(await rpc(url, 5, "tools/call", {
|
||||
name: "fetch_url",
|
||||
arguments: { url: "http://mock-search.test:8080/dynamic", engine: "browser", format: "text", timeout_ms: 20_000 }
|
||||
}, 60_000));
|
||||
assert(browserFetch.content.includes("BROWSER_RENDERED_MARKER"));
|
||||
|
||||
const blocked = await rpc(url, 6, "tools/call", {
|
||||
name: "fetch_url",
|
||||
arguments: { url: "http://127.0.0.1:8765/private", engine: "browser" }
|
||||
}, 30_000);
|
||||
assert.equal(blocked.isError, true);
|
||||
assert((blocked.content || []).some(part => part.text?.includes("Blocked localhost/private URL")));
|
||||
|
||||
const blockedRedirect = await rpc(url, 7, "tools/call", {
|
||||
name: "fetch_url",
|
||||
arguments: { url: "http://mock-search.test:8080/redirect-private", engine: "browser" }
|
||||
}, 30_000);
|
||||
assert.equal(blockedRedirect.isError, true);
|
||||
|
||||
await rpc(url, 8, "tools/call", {
|
||||
name: "fetch_url",
|
||||
arguments: { url: "http://mock-search.test:8080/websocket-attempt", engine: "browser", fresh: true }
|
||||
}, 30_000);
|
||||
const websocketCount = payload(await rpc(url, 9, "tools/call", {
|
||||
name: "fetch_url",
|
||||
arguments: { url: "http://mock-search.test:8080/ws-count", engine: "http", format: "text", fresh: true }
|
||||
}, 30_000));
|
||||
assert.equal(websocketCount.content.trim(), "0");
|
||||
|
||||
console.log("pass web-search candidate diagnostics, browser rendering, and SSRF rejection");
|
||||
38
scripts/test-web-search-candidate.sh
Normal file
38
scripts/test-web-search-candidate.sh
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
IMAGE="${CONTEXT_KIT_WEB_SEARCH_CANDIDATE_IMAGE:-context-kit/web-search-mcp:quality-20260724}"
|
||||
NETWORK="context-kit-web-quality-$RANDOM-$$"
|
||||
MOCK="${NETWORK}-mock"
|
||||
SERVER="${NETWORK}-server"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "${SERVER}" "${MOCK}" >/dev/null 2>&1 || true
|
||||
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
docker network create --subnet 203.0.113.0/24 "${NETWORK}" >/dev/null
|
||||
docker run -d --name "${MOCK}" --network "${NETWORK}" --ip 203.0.113.10 \
|
||||
--network-alias mock-search.test \
|
||||
-v "${ROOT}/scripts/fixtures/web/mock-server.mjs:/fixture/mock-server.mjs:ro" \
|
||||
node:22-bookworm-slim node /fixture/mock-server.mjs >/dev/null
|
||||
docker run -d --name "${SERVER}" --network "${NETWORK}" --ip 203.0.113.11 \
|
||||
-p 127.0.0.1::8000 \
|
||||
-e SEARXNG_URL=http://mock-search.test:8080 \
|
||||
-e DEFAULT_SEARCH_PROVIDER=searxng \
|
||||
"${IMAGE}" >/dev/null
|
||||
|
||||
binding="$(docker port "${SERVER}" 8000/tcp)"
|
||||
port="${binding##*:}"
|
||||
for _ in {1..120}; do
|
||||
if curl -fsS "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then
|
||||
node "${ROOT}/scripts/test-web-search-candidate.mjs" "http://127.0.0.1:${port}/mcp"
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
|
||||
docker logs "${SERVER}" >&2
|
||||
exit 1
|
||||
61
scripts/test-web-search-quality.mjs
Normal file
61
scripts/test-web-search-quality.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { boundFetchCollections } from "../docker/web-search/overrides/bounds.mjs";
|
||||
import {
|
||||
attemptProvider,
|
||||
classifyProviderError
|
||||
} from "../docker/web-search/overrides/diagnostics.mjs";
|
||||
|
||||
assert.deepEqual(classifyProviderError(new Error("HTTP 429 rate limit")), {
|
||||
category: "rate_limited",
|
||||
message: "HTTP 429 rate limit"
|
||||
});
|
||||
assert.equal(classifyProviderError(new Error("captcha challenge")).category, "blocked");
|
||||
|
||||
const unavailable = await attemptProvider({ name: "brave", configured: false }, "q", 3, "en");
|
||||
assert.equal(unavailable.diagnostic.status, "unavailable");
|
||||
assert.equal(unavailable.diagnostic.result_count, 0);
|
||||
|
||||
const empty = await attemptProvider({
|
||||
name: "empty",
|
||||
async search() { return []; }
|
||||
}, "q", 3, "en");
|
||||
assert.equal(empty.diagnostic.status, "empty");
|
||||
|
||||
const failed = await attemptProvider({
|
||||
name: "failed",
|
||||
async search() { throw new Error("network socket failed"); }
|
||||
}, "q", 3, "en");
|
||||
assert.equal(failed.diagnostic.status, "error");
|
||||
assert.equal(failed.diagnostic.error.category, "network");
|
||||
|
||||
let underlyingAborted = false;
|
||||
const timedOut = await attemptProvider({
|
||||
name: "slow",
|
||||
async search(_query, _limit, _lang, signal) {
|
||||
await new Promise((resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
underlyingAborted = true;
|
||||
reject(signal.reason);
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
}, "q", 3, "en", { timeoutMs: 20 });
|
||||
assert.equal(timedOut.diagnostic.error.category, "timeout");
|
||||
assert.equal(underlyingAborted, true);
|
||||
|
||||
const result = boundFetchCollections({
|
||||
links: Array.from({ length: 550 }, (_, index) => ({ url: `https://example.test/${index}` })),
|
||||
media: {
|
||||
images: Array.from({ length: 250 }, (_, index) => ({ url: `https://example.test/${index}.png` })),
|
||||
videos: [],
|
||||
audio: []
|
||||
},
|
||||
warnings: []
|
||||
});
|
||||
assert.equal(result.links.length, 500);
|
||||
assert.equal(result.media.images.length, 200);
|
||||
assert(result.warnings.some(warning => warning.includes("links truncated")));
|
||||
assert(result.warnings.some(warning => warning.includes("images truncated")));
|
||||
|
||||
console.log("pass web-search diagnostics and collection bounds tests");
|
||||
Reference in New Issue
Block a user