mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-26 05:29:43 +00:00
feat(mcp): search_docs — customer documentation as a third search corpus
The MCP server could answer telemetry questions ("why did this session fail")
but not product ones ("how do I deploy the agent", "where is my data stored").
Those answers already exist in the autopilotmonitor-docs bundle; search_docs
makes them semantically searchable.
Deliberately a separate index from search_knowledge: rules and prose answer
different questions, and sharing one topK would dilute both. Registered for
every role — published documentation carries no tenant data — and skipped
entirely when no corpus was baked in, so a doc-less build advertises no
broken tool.
Corpus handling:
- Chunked at h2/h3 and <details>/<summary>, each chunk prefixed with a
"Page > Section > Subsection" breadcrumb so a mid-page hit is self-describing.
- Newlines are normalized before any parsing. This is a correctness
precondition, not cosmetics: the bundle ships CRLF and JS `.` does not match
\r, so /^(#{2,3})\s+(.*)$/ matched zero headings — silently, degrading every
page to unlabelled paragraph splits.
- Two size exceptions: tables are never split (a split table leaves `| --- |`
rows with no header), and unbroken changelog lists split at line level (left
whole, their tails fall outside the model window and become unsearchable).
- Short sections are kept — a one-line answer is often the best hit, and the
breadcrumb supplies the context a short body lacks.
Build and delivery:
- Docs are embedded at image build time into an optional third
PrecomputedSection. Optional so a checkout without the docs repo does not
invalidate the rules and event-type vectors; the reverse (server has docs,
file does not) is staleness and is rejected.
- The deploy workflow checks the public docs repo into the build context and
passes its SHA as DOCS_COMMIT, which /health reports alongside the chunk
count — editing the docs does not update a running server, so staleness is
made checkable rather than memorable.
- Embeddings are rounded to 6 decimals on serialize: 55% smaller index (2.63
vs 5.88 MB) for a ~1e-5 norm error against a 1e-3 tolerance. indexPrecomputed
now asserts unit norm, since the hydrate path never checked it.
Infra: the Bicep template was two changes behind the live deployment (EU
cutover and ACR hardening, both applied directly in Azure). Reconciled against
the running resources — managed-identity image pull replaces the admin
credentials and the acr-password secret — and infra/ is no longer gitignored so
it stops drifting. Container raised to 0.5 vCPU / 1.0 GiB for the cold-start
query-embedder load. The template does not model the mcp.autopilotmonitor.com
custom domain, so a full deployment would remove it; the header warns about it.
Verified: 428 tests pass; 45 pages produce 324 chunks; sample queries hit the
right page top-1/2; all 8 derived URLs return 200 live; the precompute output
is accepted by the boot validator and a mutated corpus is rejected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,22 @@ COPY rules/analyze/ ./rules/analyze/
|
||||
COPY rules/gather/ ./rules/gather/
|
||||
COPY rules/ime-log-patterns/ ./rules/ime-log-patterns/
|
||||
|
||||
# Published customer documentation (autopilotmonitor-docs) — a SEPARATE public
|
||||
# repo that the deploy workflow checks out into ./mcp-docs of the build context.
|
||||
# Same byte-identical requirement as the rules above.
|
||||
#
|
||||
# This COPY is deliberately strict: if the workflow's docs checkout is ever
|
||||
# removed, the build must fail loudly rather than quietly ship an image whose
|
||||
# search_docs tool simply does not exist. For a local `docker build`, clone the
|
||||
# bundle first:
|
||||
# git clone --depth 1 https://github.com/autopilotmonitor/autopilotmonitor-docs mcp-docs
|
||||
COPY mcp-docs/ ./docs/
|
||||
|
||||
# Commit SHA of that docs checkout. Surfaced on /health so a running server can be
|
||||
# diffed against the docs repo HEAD — the bundle changes independently of this
|
||||
# repo, so a stale deploy is otherwise invisible.
|
||||
ARG DOCS_COMMIT=unknown
|
||||
|
||||
# Pre-bake the ~23 MB embedding model AND precompute the search index at image
|
||||
# build time. Embedding the static corpus at boot cost 35-55s of ONNX inference
|
||||
# on the 0.25 vCPU container (measured); hydrating precomputed vectors is
|
||||
@@ -35,7 +51,7 @@ RUN mkdir -p "$HF_CACHE_DIR" /app/search-index && \
|
||||
if [ "$PREBAKE_MODEL" = "1" ]; then \
|
||||
ok=0; \
|
||||
for i in 1 2 3 4 5; do \
|
||||
node dist/precompute-embeddings.js ./rules /app/search-index/search-index.json && { ok=1; break; }; \
|
||||
node dist/precompute-embeddings.js ./rules /app/search-index/search-index.json ./docs && { ok=1; break; }; \
|
||||
echo "embedding precompute attempt $i/5 failed; retrying in 15s"; \
|
||||
sleep 15; \
|
||||
done && \
|
||||
@@ -66,8 +82,16 @@ COPY rules/analyze/ ./rules/analyze/
|
||||
COPY rules/gather/ ./rules/gather/
|
||||
COPY rules/ime-log-patterns/ ./rules/ime-log-patterns/
|
||||
|
||||
# Customer documentation corpus — copied from the build stage rather than the
|
||||
# context so it is provably the same bytes the embeddings were computed over.
|
||||
COPY --from=build /app/docs/ ./docs/
|
||||
|
||||
ARG DOCS_COMMIT=unknown
|
||||
ENV DOCS_COMMIT=${DOCS_COMMIT}
|
||||
|
||||
ENV PORT=8080
|
||||
ENV RULES_DIR=/app/rules
|
||||
ENV DOCS_DIR=/app/docs
|
||||
ENV SEARCH_INDEX_PATH=/app/search-index/search-index.json
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Unit tests for the customer-documentation corpus loader.
|
||||
*
|
||||
* The bundle is authored on Windows and ships CRLF, which is the single most
|
||||
* dangerous property of this input: in JavaScript `.` does not match `\r`, so a
|
||||
* pattern like /^(#{2,3})\s+(.*)$/ silently matches NOTHING on a CRLF file. The
|
||||
* failure is not a crash — headings and frontmatter just stop being recognized
|
||||
* and every page degrades to unlabelled paragraph splits. Several tests below
|
||||
* therefore run the CRLF form on purpose.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadDocsCorpus, parseFrontmatter, docUrl, slugify, docSections } from '../docs-corpus.js';
|
||||
|
||||
async function withBundle<T>(
|
||||
files: Record<string, string>,
|
||||
fn: (root: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'docs-corpus-'));
|
||||
try {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = join(root, rel);
|
||||
await mkdir(join(full, '..'), { recursive: true });
|
||||
await writeFile(full, content, 'utf-8');
|
||||
}
|
||||
return await fn(root);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseFrontmatter', () => {
|
||||
it('reads scalars, folded blocks and inline sequences', () => {
|
||||
const { frontmatter, body } = parseFrontmatter(
|
||||
['---', 'type: Concept', 'tags: [security, gdpr]', 'description: >-', ' First line', ' second line', '---', '', '# Title', 'Body.'].join('\n'),
|
||||
);
|
||||
expect(frontmatter.type).toBe('Concept');
|
||||
expect(frontmatter.tags).toEqual(['security', 'gdpr']);
|
||||
expect(frontmatter.description).toBe('First line second line');
|
||||
expect(body).toContain('# Title');
|
||||
expect(body).not.toContain('type: Concept');
|
||||
});
|
||||
|
||||
it('reads block sequences', () => {
|
||||
const { frontmatter } = parseFrontmatter(['---', 'type: Guide', 'tags:', ' - agent', ' - intune', '---', 'x'].join('\n'));
|
||||
expect(frontmatter.tags).toEqual(['agent', 'intune']);
|
||||
});
|
||||
|
||||
it('parses CRLF frontmatter identically to LF', () => {
|
||||
const lf = ['---', 'type: Concept', 'tags: [a, b]', '---', '', '# T', 'x'].join('\n');
|
||||
expect(parseFrontmatter(lf.replace(/\n/g, '\r\n'))).toEqual(parseFrontmatter(lf));
|
||||
});
|
||||
|
||||
it('returns the whole input as body when there is no frontmatter', () => {
|
||||
const { frontmatter, body } = parseFrontmatter('# Just a title\n\nText.');
|
||||
expect(frontmatter).toEqual({ type: undefined, title: undefined, description: undefined, tags: undefined });
|
||||
expect(body).toBe('# Just a title\n\nText.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docUrl', () => {
|
||||
it('maps a page path to its published URL', () => {
|
||||
expect(docUrl('trust/security-faq.md')).toBe('https://docs.autopilotmonitor.com/trust/security-faq');
|
||||
});
|
||||
|
||||
it('collapses README.md to its folder', () => {
|
||||
expect(docUrl('rules/analyze-rules/README.md')).toBe('https://docs.autopilotmonitor.com/rules/analyze-rules');
|
||||
expect(docUrl('README.md')).toBe('https://docs.autopilotmonitor.com');
|
||||
});
|
||||
|
||||
it('has no URL for index.md — GitBook never publishes the OKF catalog', () => {
|
||||
expect(docUrl('index.md')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('strips markdown and punctuation', () => {
|
||||
expect(slugify('Is the device\'s **IP address** stored?')).toBe('is-the-device-s-ip-address-stored');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadDocsCorpus', () => {
|
||||
const page = (fm: string, body: string) => `---\n${fm}\n---\n\n${body}`;
|
||||
|
||||
it('splits on h2/h3 headings and carries a breadcrumb', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'concepts/roles.md': page(
|
||||
'type: Concept\ntags: [roles]',
|
||||
['# Roles & Permissions', '', 'Intro prose that is long enough to survive the minimum-length filter for chunks.', '', '## Tenant roles', '', 'A tenant admin can do everything, including managing team members and enabling admin mode.', '', '### Operator', '', 'An operator monitors day to day and cannot perform destructive operations at all.'].join('\n'),
|
||||
),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
|
||||
const headings = chunks.map((c) => c.metadata.heading);
|
||||
expect(headings).toContain('Tenant roles');
|
||||
expect(headings).toContain('Tenant roles › Operator');
|
||||
// The page title is prepended to every chunk so a mid-page hit is self-describing.
|
||||
expect(chunks.every((c) => c.text.startsWith('Roles & Permissions'))).toBe(true);
|
||||
expect(chunks[0].metadata.section).toBe('concepts');
|
||||
expect(chunks[0].metadata.tags).toEqual(['roles']);
|
||||
});
|
||||
|
||||
it('produces the SAME chunks for CRLF input as for LF', async () => {
|
||||
const body = ['# Guide', '', 'Intro paragraph long enough to be kept as its own indexed chunk here.', '', '## Step one', '', 'Do the first thing, which needs a sentence long enough to pass the minimum.', '', '## Step two', '', 'Then do the second thing, also written out at a reasonable length for this.'].join('\n');
|
||||
const src = page('type: Guide\ntags: [setup]', body);
|
||||
|
||||
const lf = await withBundle({ 'getting-started/g.md': src }, (r) => loadDocsCorpus(r));
|
||||
const crlf = await withBundle({ 'getting-started/g.md': src.replace(/\n/g, '\r\n') }, (r) => loadDocsCorpus(r));
|
||||
|
||||
expect(crlf).toEqual(lf);
|
||||
expect(lf.map((c) => c.metadata.heading)).toEqual([null, 'Step one', 'Step two']);
|
||||
expect(lf.some((c) => c.text.includes('\r'))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats <details>/<summary> blocks as sections', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'troubleshooting/faq.md': page(
|
||||
'type: FAQ\ntags: [faq]',
|
||||
['# FAQ', '', '## General', '', '<details>', '', '<summary>Is Autopilot Monitor free?</summary>', '', 'Yes, the Community plan is free and stays free for everyone who wants to use it.', '', '</details>'].join('\n'),
|
||||
),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
expect(chunks.map((c) => c.metadata.heading)).toContain('General › Is Autopilot Monitor free?');
|
||||
});
|
||||
|
||||
it('never emits a heading from inside a fenced code block', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'reference/cli.md': page(
|
||||
'type: Reference\ntags: [cli]',
|
||||
['# CLI', '', '## Usage', '', '```powershell', '# This is a shell comment, not a heading', '## Neither is this', '```', '', 'Explanatory prose after the block so the section is comfortably long enough.'].join('\n'),
|
||||
),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
expect(chunks.map((c) => c.metadata.heading)).toEqual(['Usage']);
|
||||
});
|
||||
|
||||
it('skips SUMMARY.md and log.md but keeps index.md', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'SUMMARY.md': '# Table of contents\n\n* [A](a.md)\n',
|
||||
'log.md': '# Log\n\nSome change record entry that is long enough to be a chunk on its own.\n',
|
||||
'index.md': page('okf_version: "0.1"', '# Bundle\n\nThe documentation map listing every page available in this bundle here.'),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
const paths = [...new Set(chunks.map((c) => c.metadata.path))];
|
||||
expect(paths).toEqual(['index.md']);
|
||||
// index.md is indexed for its content but is not a published page.
|
||||
expect(chunks[0].metadata.url).toBeNull();
|
||||
});
|
||||
|
||||
it('splits an oversized unbroken list so its tail is not truncated away', async () => {
|
||||
const items = Array.from({ length: 60 }, (_, i) => `* Release note number ${i} describing a change that shipped in this release.`);
|
||||
const chunks = await withBundle(
|
||||
{ 'changelog/platform.md': page('type: Changelog\ntags: [changelog]', ['# Platform Changelog', '', '## July 2026', '', ...items].join('\n')) },
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((c) => c.text.length <= 1700)).toBe(true);
|
||||
// The final item must still be reachable — that is the whole point of splitting.
|
||||
expect(chunks.some((c) => c.text.includes('number 59'))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a markdown table intact rather than splitting mid-table', async () => {
|
||||
const rows = Array.from({ length: 40 }, (_, i) => `| Setting ${i} | A reasonably wordy description of what this particular setting controls. |`);
|
||||
const chunks = await withBundle(
|
||||
{ 'reference/settings.md': page('type: Reference\ntags: [settings]', ['# Settings', '', '## All settings', '', '| Name | Description |', '| --- | --- |', ...rows].join('\n')) },
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
const withTable = chunks.filter((c) => c.text.includes('| Setting '));
|
||||
expect(withTable).toHaveLength(1);
|
||||
expect(withTable[0].text).toContain('| Setting 39 |');
|
||||
});
|
||||
|
||||
it('assigns unique, stable ids across repeated loads', async () => {
|
||||
const files = {
|
||||
'a/one.md': page('type: Guide\ntags: [x]', '# One\n\n## Same\n\nFirst body text that is long enough to be indexed as a chunk.\n\n## Same\n\nSecond body text that is also long enough to be indexed here.'),
|
||||
};
|
||||
const first = await withBundle(files, (r) => loadDocsCorpus(r));
|
||||
const second = await withBundle(files, (r) => loadDocsCorpus(r));
|
||||
expect(new Set(first.map((c) => c.id)).size).toBe(first.length);
|
||||
expect(first.map((c) => c.id)).toEqual(second.map((c) => c.id));
|
||||
});
|
||||
|
||||
it('returns an empty corpus for a missing directory instead of throwing', async () => {
|
||||
await expect(loadDocsCorpus(join(tmpdir(), 'definitely-not-here-9d2f'))).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('docSections', () => {
|
||||
it('lists distinct top-level areas, sorted', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'trust/a.md': '# A\n\nSome prose that is definitely long enough to become an indexed chunk.',
|
||||
'concepts/b.md': '# B\n\nMore prose that is definitely long enough to become an indexed chunk.',
|
||||
'root.md': '# R\n\nRoot level prose that is long enough to become an indexed chunk too.',
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
expect(docSections(chunks)).toEqual(['concepts', 'general', 'trust']);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,19 @@ const MODEL = 'Xenova/all-MiniLM-L6-v2';
|
||||
const doc = (id: string, text: string, metadata: Record<string, unknown> = {}): SearchDocument =>
|
||||
({ id, text, metadata });
|
||||
|
||||
/**
|
||||
* Fake embeddings still have to satisfy the real contract: indexPrecomputed()
|
||||
* asserts L2-unit norm, because cosineSimilarity is a bare dot product that would
|
||||
* otherwise return silently wrong scores. Normalize here rather than hand-writing
|
||||
* unit vectors, so a test can pass any direction it finds readable.
|
||||
*/
|
||||
const unit = (v: number[]): number[] => {
|
||||
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
|
||||
return v.map((x) => x / norm);
|
||||
};
|
||||
|
||||
const withEmbedding = (d: SearchDocument, embedding: number[] = [0.1, 0.2, 0.3]): PrecomputedDocument =>
|
||||
({ ...d, embedding });
|
||||
({ ...d, embedding: unit(embedding) });
|
||||
|
||||
const KB_DOCS = [doc('rule-1', 'TPM not ready', { type: 'analyze-rule' }), doc('rule-2', 'ESP timeout', { type: 'analyze-rule' })];
|
||||
const ET_DOCS = [doc('et-1', 'bitlocker status', { eventType: 'bitlocker_status' })];
|
||||
@@ -106,7 +117,66 @@ describe('validatePrecomputedIndex', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('docs section (optional third corpus)', () => {
|
||||
const DOC_CHUNKS = [doc('docs:trust/security-faq.md#intro', 'Where is my data stored', { type: 'doc', section: 'trust' })];
|
||||
|
||||
const withDocs = (): PrecomputedIndexFile => ({
|
||||
...validFile(),
|
||||
docs: { docsHash: hashDocs(DOC_CHUNKS), entries: DOC_CHUNKS.map((d) => withEmbedding(d)) },
|
||||
});
|
||||
|
||||
it('returns the docs entries when the corpus matches', () => {
|
||||
const result = validatePrecomputedIndex(withDocs(), MODEL, KB_DOCS, ET_DOCS, DOC_CHUNKS);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(result.ok && result.docs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects the whole file when the docs corpus changed since the build', () => {
|
||||
const stale = [doc('docs:trust/security-faq.md#intro', 'DIFFERENT TEXT', { type: 'doc', section: 'trust' })];
|
||||
expect(validatePrecomputedIndex(withDocs(), MODEL, KB_DOCS, ET_DOCS, stale)).toMatchObject({
|
||||
ok: false, reason: expect.stringContaining('docs'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the server has docs but the image shipped none — that is staleness, not absence', () => {
|
||||
expect(validatePrecomputedIndex(validFile(), MODEL, KB_DOCS, ET_DOCS, DOC_CHUNKS)).toMatchObject({
|
||||
ok: false, reason: expect.stringContaining('docs'),
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a file WITHOUT a docs section when the server has no docs corpus', () => {
|
||||
// A local checkout has no docs repo. That must not throw away the rules and
|
||||
// event-type vectors too — recomputing those is the expensive half.
|
||||
const result = validatePrecomputedIndex(validFile(), MODEL, KB_DOCS, ET_DOCS, []);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(result.ok && result.docs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VectorSearchProvider serialize/hydrate roundtrip', () => {
|
||||
it('rounds embeddings on serialize but stays well inside the unit-norm tolerance', () => {
|
||||
const p = new VectorSearchProvider();
|
||||
const raw = unit([0.123456789012345, -0.987654321098765, 0.5555555555555]);
|
||||
p.indexPrecomputed([{ ...doc('d1', 'text'), embedding: raw }]);
|
||||
|
||||
const [out] = p.serialize();
|
||||
// Rounded to 6 decimals — the point of the exercise (halves the index JSON).
|
||||
expect(out.embedding.every((v) => v === Number(v.toFixed(6)))).toBe(true);
|
||||
expect(out.embedding).not.toEqual(raw);
|
||||
|
||||
const norm = Math.sqrt(out.embedding.reduce((s, x) => s + x * x, 0));
|
||||
expect(Math.abs(norm - 1)).toBeLessThan(1e-5);
|
||||
// Re-hydrating the rounded vectors must not trip the assertion.
|
||||
expect(() => new VectorSearchProvider().indexPrecomputed(p.serialize())).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a non-unit embedding on hydrate rather than skewing every score silently', () => {
|
||||
const p = new VectorSearchProvider();
|
||||
expect(() => p.indexPrecomputed([{ ...doc('d1', 'text'), embedding: [0.1, 0.2, 0.3] }]))
|
||||
.toThrow(/not L2-unit-normalized/);
|
||||
});
|
||||
|
||||
|
||||
it('indexPrecomputed restores exactly what serialize exported, without the model', () => {
|
||||
const source = new VectorSearchProvider();
|
||||
source.indexPrecomputed(KB_DOCS.map((d, i) => withEmbedding(d, [i + 0.5, i + 1.5])));
|
||||
|
||||
@@ -20,16 +20,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { registerTools } from '../tools.js';
|
||||
import type { SearchProvider } from '../search-provider.js';
|
||||
|
||||
function registeredToolNames(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
// knowledgeBase / eventTypeIndex are optional — pass undefined so this stays a
|
||||
// pure unit test with no search-provider or backend dependency.
|
||||
registerTools(server, undefined, undefined, ga, strictGa, delegated);
|
||||
registerTools(server, undefined, undefined, undefined, [], ga, strictGa, delegated);
|
||||
const internal = server as unknown as { _registeredTools: Record<string, unknown> };
|
||||
return Object.keys(internal._registeredTools);
|
||||
}
|
||||
|
||||
/** Minimal stand-in for an indexed docs corpus — registration only checks `size`. */
|
||||
function stubDocsIndex(size = 3): SearchProvider {
|
||||
return {
|
||||
name: 'stub',
|
||||
semanticCapable: true,
|
||||
size,
|
||||
index: async () => {},
|
||||
search: async () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function namesWithDocs(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, stubDocsIndex(), ['concepts', 'trust'], ga, strictGa, delegated);
|
||||
const internal = server as unknown as { _registeredTools: Record<string, unknown> };
|
||||
return Object.keys(internal._registeredTools);
|
||||
}
|
||||
|
||||
describe('search_docs registration', () => {
|
||||
it('is NOT registered when no documentation corpus was baked into the image', () => {
|
||||
expect(registeredToolNames(true)).not.toContain('search_docs');
|
||||
});
|
||||
|
||||
it('is registered when a corpus is present — for EVERY role, including a plain tenant user', () => {
|
||||
// Published product documentation carries no tenant data, so unlike the
|
||||
// platform tools it is deliberately ungated. A tenant user asking "how do I
|
||||
// deploy the agent" has exactly as much business here as a Global Admin.
|
||||
expect(namesWithDocs(true, true)).toContain('search_docs');
|
||||
expect(namesWithDocs(false, false)).toContain('search_docs');
|
||||
expect(namesWithDocs(false, false, true)).toContain('search_docs');
|
||||
});
|
||||
|
||||
it('is skipped for an empty corpus, so a doc-less build advertises no broken tool', () => {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, stubDocsIndex(0), [], true, true, false);
|
||||
const internal = server as unknown as { _registeredTools: Record<string, unknown> };
|
||||
expect(Object.keys(internal._registeredTools)).not.toContain('search_docs');
|
||||
});
|
||||
|
||||
it('keeps the catalog alphabetically sorted once it joins', () => {
|
||||
const names = namesWithDocs(true);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool catalog ordering', () => {
|
||||
it('lists tools in alphabetical order for a Global Admin', () => {
|
||||
const names = registeredToolNames(true);
|
||||
|
||||
@@ -27,7 +27,7 @@ const MANAGED = 'aaaa-1111-managed';
|
||||
/** Build a server for the given caller shape and return a tool's raw handler by name. */
|
||||
function handlerFor(name: string, opts: { ga?: boolean; strictGa?: boolean; delegated?: boolean }): ToolHandler {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, opts.ga ?? false, opts.strictGa ?? (opts.ga ?? false), opts.delegated ?? false);
|
||||
registerTools(server, undefined, undefined, undefined, [], opts.ga ?? false, opts.strictGa ?? (opts.ga ?? false), opts.delegated ?? false);
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
|
||||
const tool = registry[name];
|
||||
if (!tool) throw new Error(`tool ${name} not registered for this caller shape`);
|
||||
|
||||
@@ -29,7 +29,7 @@ type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{
|
||||
/** Build a strict-GA server and return a tool's raw handler by name. */
|
||||
function gaHandler(name: string): ToolHandler {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, /*ga*/ true, /*strictGa*/ true, /*delegated*/ false);
|
||||
registerTools(server, undefined, undefined, undefined, [], /*ga*/ true, /*strictGa*/ true, /*delegated*/ false);
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
|
||||
const tool = registry[name];
|
||||
if (!tool) throw new Error(`tool ${name} not registered for strict-GA caller`);
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Loads the published customer documentation bundle (autopilotmonitor-docs) and
|
||||
* chunks it into SearchDocuments for semantic search via the `search_docs` tool.
|
||||
*
|
||||
* This is the product-knowledge corpus — "how do I deploy the agent", "where is
|
||||
* my data stored", "which roles exist". It is deliberately SEPARATE from the
|
||||
* rules corpus in knowledge-base.ts: rules answer "why did this session fail",
|
||||
* docs answer "how does the product work". Indexed together they would compete
|
||||
* for the same topK and dilute each other.
|
||||
*
|
||||
* Like the rules corpus, docs are baked into the image and embedded at Docker
|
||||
* build time (see precompute-embeddings.ts) — the 0.25-0.5 vCPU container never
|
||||
* embeds a corpus at boot.
|
||||
*
|
||||
* Chunking matters more here than for rules: a rule is already a short, self-
|
||||
* contained record, whereas a doc page runs to 36 KB (trust/security-faq.md).
|
||||
* MiniLM-L6-v2 truncates at ~256 tokens, so a whole-page embedding would silently
|
||||
* represent only the first paragraph. We therefore split on heading boundaries,
|
||||
* which in this bundle are also the semantic boundaries (one h3 = one question).
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import type { SearchDocument } from './search-provider.js';
|
||||
|
||||
/** Published base URL of the bundle — used to build a citable link per chunk. */
|
||||
const DOCS_BASE_URL = 'https://docs.autopilotmonitor.com';
|
||||
|
||||
/**
|
||||
* Upper bound on chunk text. MiniLM-L6-v2's 256-token window is roughly 1000-1200
|
||||
* characters of English prose; going much beyond that means the tail of the chunk
|
||||
* contributes nothing to the embedding. We allow a little slack over that so a
|
||||
* coherent section (or an indivisible table) is not split for the sake of a few
|
||||
* characters — the overflow is only ever the part that gets truncated.
|
||||
*/
|
||||
const MAX_CHUNK_CHARS = 1500;
|
||||
|
||||
/**
|
||||
* A trailing fragment shorter than this is folded back into the preceding chunk
|
||||
* when splitting an oversized section, so a split never leaves a stranded line.
|
||||
*/
|
||||
const MIN_CHUNK_CHARS = 120;
|
||||
|
||||
/**
|
||||
* Minimum body length for a chunk to be indexed at all, EXCLUDING the breadcrumb
|
||||
* header. Set low on purpose: a short section is very often the most valuable
|
||||
* kind of hit ("Is Autopilot Monitor free?" → "Yes — the Community plan is free
|
||||
* and stays free"), and the breadcrumb already supplies the context that a short
|
||||
* body lacks. This filter only exists to drop structural residue — a stray rule
|
||||
* line, an empty section left behind by stripped GitBook markers.
|
||||
*/
|
||||
const MIN_BODY_CHARS = 25;
|
||||
|
||||
/**
|
||||
* Structural/meta files that are not documentation content.
|
||||
* `SUMMARY.md` is the GitBook table of contents and `log.md` is the OKF change
|
||||
* log — both are navigation scaffolding whose text would match queries about
|
||||
* every topic at once. `index.md` (the OKF bundle catalog) is deliberately KEPT:
|
||||
* it is the documentation map and answers "what documentation exists".
|
||||
*/
|
||||
const SKIP_FILES = new Set(['SUMMARY.md', 'log.md']);
|
||||
|
||||
const SKIP_DIRS = new Set(['.git', '.github', '.gitbook', 'node_modules']);
|
||||
|
||||
// ── Frontmatter ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize CRLF/CR to LF before ANY parsing.
|
||||
*
|
||||
* Not cosmetic — a correctness precondition. The docs repo is authored on
|
||||
* Windows and ships CRLF, and in JavaScript `.` does not match `\r`. A pattern
|
||||
* like /^(#{2,3})\s+(.*)$/ therefore fails on every single heading of a CRLF
|
||||
* file: `(.*)` stops before the `\r` and `$` never reaches end-of-string. The
|
||||
* observable symptom is silent, not a crash — headings and frontmatter keys
|
||||
* simply stop being recognized and the page degrades to unlabelled paragraph
|
||||
* splits. Normalizing once here is the root fix; the alternative is remembering
|
||||
* `\r?` in every pattern in this file forever.
|
||||
*/
|
||||
function normalizeNewlines(text: string): string {
|
||||
return text.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
export interface DocFrontmatter {
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter parser covering exactly the forms this bundle uses:
|
||||
* plain scalars, folded blocks (`description: >-`), inline sequences
|
||||
* (`tags: [a, b]`) and block sequences (`- a`). A full YAML parser would be a new
|
||||
* runtime dependency in a hardened, dependency-minimal container for the sake of
|
||||
* four flat keys.
|
||||
*
|
||||
* Anything it cannot parse is skipped rather than thrown — a malformed header
|
||||
* must not cost us the page body, which is the part being searched.
|
||||
*/
|
||||
export function parseFrontmatter(input: string): { frontmatter: DocFrontmatter; body: string } {
|
||||
const raw = normalizeNewlines(input);
|
||||
if (!raw.startsWith('---')) return { frontmatter: {}, body: raw };
|
||||
const end = raw.indexOf('\n---', 3);
|
||||
if (end === -1) return { frontmatter: {}, body: raw };
|
||||
|
||||
const header = raw.slice(raw.indexOf('\n') + 1, end);
|
||||
const body = raw.slice(raw.indexOf('\n', end + 1) + 1);
|
||||
const fm: Record<string, string | string[]> = {};
|
||||
|
||||
const lines = header.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]);
|
||||
if (!match) continue;
|
||||
const key = match[1];
|
||||
let value = match[2].trim();
|
||||
|
||||
// Folded / literal block scalar: consume the following indented lines.
|
||||
if (value === '>-' || value === '>' || value === '|' || value === '|-') {
|
||||
const parts: string[] = [];
|
||||
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
|
||||
parts.push(lines[++i].trim());
|
||||
}
|
||||
fm[key] = parts.join(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block sequence: `tags:` followed by `- item` lines.
|
||||
if (value === '') {
|
||||
const items: string[] = [];
|
||||
while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) {
|
||||
items.push(lines[++i].replace(/^\s*-\s+/, '').trim().replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
if (items.length > 0) fm[key] = items;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline sequence: `[a, b, c]`.
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
fm[key] = value
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
||||
.filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
|
||||
fm[key] = value.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
|
||||
const asString = (v: string | string[] | undefined): string | undefined =>
|
||||
typeof v === 'string' ? v : undefined;
|
||||
const asArray = (v: string | string[] | undefined): string[] | undefined =>
|
||||
Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : undefined;
|
||||
|
||||
return {
|
||||
frontmatter: {
|
||||
type: asString(fm.type),
|
||||
title: asString(fm.title),
|
||||
description: asString(fm.description),
|
||||
tags: asArray(fm.tags),
|
||||
},
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Markdown normalization ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip GitBook-specific block syntax while keeping the prose inside it.
|
||||
* `{% hint style="info" %}` wrappers carry no meaning for retrieval but do add
|
||||
* tokens that dilute the embedding; the sentence inside them frequently IS the
|
||||
* answer, so the content is preserved and only the markers removed.
|
||||
*/
|
||||
function stripGitbookSyntax(text: string): string {
|
||||
return text
|
||||
.replace(/^\s*\{%\s*(end)?(hint|tabs|tab|content-ref|embed|code)[^%]*%\}\s*$/gim, '')
|
||||
.replace(/^\s*\{%\s*end\w+\s*%\}\s*$/gim, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** URL/anchor-safe slug from heading text. */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[`*_[\]()]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public GitBook URL for a bundle-relative path, or undefined when the file is
|
||||
* not published as a page.
|
||||
*
|
||||
* `index.md` is an OKF reserved file that the bundle deliberately keeps out of
|
||||
* SUMMARY.md, so GitBook never renders it — emitting a link would hand callers a
|
||||
* 404. Its content is still indexed (it is the documentation map); it just has no
|
||||
* citable URL.
|
||||
*/
|
||||
export function docUrl(relPath: string): string | undefined {
|
||||
const posix = relPath.split(sep).join('/');
|
||||
if (posix === 'index.md') return undefined;
|
||||
const withoutExt = posix.replace(/\.md$/, '');
|
||||
const path = withoutExt === 'README' ? '' : withoutExt.replace(/\/README$/, '');
|
||||
return path ? `${DOCS_BASE_URL}/${path}` : DOCS_BASE_URL;
|
||||
}
|
||||
|
||||
// ── Sectioning ───────────────────────────────────────────────
|
||||
|
||||
interface Section {
|
||||
/** Heading trail below the page title, e.g. ['Data Processing', 'What data…']. */
|
||||
breadcrumb: string[];
|
||||
/** Anchor of the deepest markdown heading, if this section came from one. */
|
||||
anchor?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a page body into heading-delimited sections.
|
||||
*
|
||||
* Handles both structures the bundle uses: markdown h2/h3 headings (the common
|
||||
* case — trust/security-faq.md carries 37 h3s, one per question) and
|
||||
* `<details><summary>Q</summary>…</details>` blocks (troubleshooting/faq.md),
|
||||
* where the summary is the question and therefore the natural section title.
|
||||
*/
|
||||
function splitIntoSections(body: string): Section[] {
|
||||
const sections: Section[] = [];
|
||||
const lines = body.split('\n');
|
||||
|
||||
let h2: string | null = null;
|
||||
let h3: string | null = null;
|
||||
let detailsTitle: string | null = null;
|
||||
let buffer: string[] = [];
|
||||
let inFence = false;
|
||||
|
||||
const flush = (anchorSource: string | null) => {
|
||||
const content = buffer.join('\n').trim();
|
||||
buffer = [];
|
||||
if (!content) return;
|
||||
const breadcrumb = [h2, detailsTitle ?? h3].filter((s): s is string => Boolean(s));
|
||||
sections.push({
|
||||
breadcrumb,
|
||||
anchor: anchorSource ? slugify(anchorSource) : undefined,
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// Never interpret markdown inside a fenced code block as structure.
|
||||
if (/^\s*```/.test(line)) {
|
||||
inFence = !inFence;
|
||||
buffer.push(line);
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
buffer.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const summary = /^\s*<summary>(.*?)<\/summary>\s*$/i.exec(line);
|
||||
if (summary) {
|
||||
flush(detailsTitle ?? h3 ?? h2);
|
||||
detailsTitle = summary[1].replace(/<[^>]+>/g, '').trim();
|
||||
continue;
|
||||
}
|
||||
if (/^\s*<\/details>\s*$/i.test(line)) {
|
||||
flush(null);
|
||||
detailsTitle = null;
|
||||
continue;
|
||||
}
|
||||
if (/^\s*<details>\s*$/i.test(line)) continue;
|
||||
|
||||
const heading = /^(#{2,3})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
flush(detailsTitle ?? h3 ?? h2);
|
||||
detailsTitle = null;
|
||||
const text = heading[2].replace(/[*_`]/g, '').trim();
|
||||
if (heading[1] === '##') {
|
||||
h2 = text;
|
||||
h3 = null;
|
||||
} else {
|
||||
h3 = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.push(line);
|
||||
}
|
||||
flush(detailsTitle ?? h3 ?? h2);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an oversized section at paragraph boundaries.
|
||||
*
|
||||
* Markdown tables are kept intact: a table split across chunks leaves a body of
|
||||
* `| --- |` rows with no header, which is both unreadable in a result and
|
||||
* meaningless to embed. Tables carry real answers in this bundle (the role matrix
|
||||
* in concepts/roles-and-permissions.md is one), so an over-long table is allowed
|
||||
* to exceed MAX_CHUNK_CHARS rather than be broken.
|
||||
*/
|
||||
function splitOversized(content: string): string[] {
|
||||
if (content.length <= MAX_CHUNK_CHARS) return [content];
|
||||
|
||||
const blocks = content.split(/\n\s*\n/);
|
||||
const chunks: string[] = [];
|
||||
let current: string[] = [];
|
||||
let currentLen = 0;
|
||||
|
||||
for (const block of blocks) {
|
||||
const isTable = /^\s*\|/.test(block);
|
||||
if (currentLen > 0 && currentLen + block.length > MAX_CHUNK_CHARS && !isTable) {
|
||||
chunks.push(current.join('\n\n'));
|
||||
current = [];
|
||||
currentLen = 0;
|
||||
}
|
||||
current.push(block);
|
||||
currentLen += block.length + 2;
|
||||
}
|
||||
if (current.length > 0) chunks.push(current.join('\n\n'));
|
||||
|
||||
// A single block can still exceed the budget on its own — the changelog pages
|
||||
// are one unbroken bullet list per release with no blank lines, so paragraph
|
||||
// splitting cannot touch them. Left whole, everything past the model's ~256
|
||||
// tokens would be truncated away and become unsearchable, so fall back to
|
||||
// packing whole LINES. Contiguous table rows stay together (see above).
|
||||
const final: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.length <= MAX_CHUNK_CHARS) {
|
||||
final.push(chunk);
|
||||
continue;
|
||||
}
|
||||
let buf: string[] = [];
|
||||
let len = 0;
|
||||
for (const line of chunk.split('\n')) {
|
||||
const isTableRow = /^\s*\|/.test(line);
|
||||
if (len > 0 && len + line.length > MAX_CHUNK_CHARS && !isTableRow) {
|
||||
final.push(buf.join('\n'));
|
||||
buf = [];
|
||||
len = 0;
|
||||
}
|
||||
buf.push(line);
|
||||
len += line.length + 1;
|
||||
}
|
||||
if (buf.length > 0) final.push(buf.join('\n'));
|
||||
}
|
||||
|
||||
// Fold a runt tail back into its predecessor.
|
||||
if (final.length > 1 && final[final.length - 1].length < MIN_CHUNK_CHARS) {
|
||||
const tail = final.pop()!;
|
||||
final[final.length - 1] += `\n\n${tail}`;
|
||||
}
|
||||
return final;
|
||||
}
|
||||
|
||||
// ── File walking ─────────────────────────────────────────────
|
||||
|
||||
async function walkMarkdown(root: string, dir = root): Promise<string[]> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true, encoding: 'utf-8' });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
files.push(...(await walkMarkdown(root, join(dir, entry.name))));
|
||||
} else if (entry.name.endsWith('.md') && !SKIP_FILES.has(entry.name)) {
|
||||
files.push(relative(root, join(dir, entry.name)));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and chunk the customer documentation bundle at `docsRoot`.
|
||||
*
|
||||
* Returns an empty array when the directory is absent or holds no markdown —
|
||||
* a local checkout without the docs repo is a supported state, and the caller
|
||||
* then simply does not register `search_docs`. A missing corpus must never be
|
||||
* a boot failure.
|
||||
*/
|
||||
export async function loadDocsCorpus(docsRoot: string): Promise<SearchDocument[]> {
|
||||
const files = (await walkMarkdown(docsRoot)).sort();
|
||||
const docs: SearchDocument[] = [];
|
||||
const usedIds = new Set<string>();
|
||||
|
||||
for (const relPath of files) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(join(docsRoot, relPath), 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
const posixPath = relPath.split(sep).join('/');
|
||||
|
||||
// Title: frontmatter wins (the technical bundle carries it), then the page's
|
||||
// own h1 (the customer bundle's only source), then the filename.
|
||||
const h1 = /^#\s+(.+)$/m.exec(body);
|
||||
const title =
|
||||
frontmatter.title ??
|
||||
h1?.[1].replace(/[*_`]/g, '').trim() ??
|
||||
posixPath.replace(/\.md$/, '').split('/').pop()!;
|
||||
|
||||
// Top-level directory doubles as the bundle's section taxonomy
|
||||
// (getting-started, concepts, portal-guide, …). Root files are 'general'.
|
||||
const section = posixPath.includes('/') ? posixPath.split('/')[0] : 'general';
|
||||
const url = docUrl(relPath);
|
||||
|
||||
// Drop the h1 line itself — it is carried in every chunk's header instead.
|
||||
const withoutH1 = body.replace(/^#\s+.+$/m, '');
|
||||
const sections = splitIntoSections(stripGitbookSyntax(withoutH1));
|
||||
|
||||
for (const sec of sections) {
|
||||
for (const piece of splitOversized(sec.content)) {
|
||||
const trail = [title, ...sec.breadcrumb].join(' › ');
|
||||
// The breadcrumb is prepended to the embedded text so a chunk lifted out
|
||||
// of the middle of a page still states which page and section it is from —
|
||||
// both for the embedding and for a human reading the tool result.
|
||||
if (piece.replace(/[^A-Za-z0-9]/g, '').length < MIN_BODY_CHARS) continue;
|
||||
const text = `${trail}\n\n${piece}`.trim();
|
||||
|
||||
const base = `docs:${posixPath}#${slugify(sec.breadcrumb.join('-')) || 'intro'}`;
|
||||
let id = base;
|
||||
for (let n = 2; usedIds.has(id); n++) id = `${base}-${n}`;
|
||||
usedIds.add(id);
|
||||
|
||||
docs.push({
|
||||
id,
|
||||
text,
|
||||
metadata: {
|
||||
type: 'doc',
|
||||
title,
|
||||
section,
|
||||
path: posixPath,
|
||||
heading: sec.breadcrumb.join(' › ') || null,
|
||||
docType: frontmatter.type ?? null,
|
||||
tags: frontmatter.tags ?? [],
|
||||
url: url ? (sec.anchor ? `${url}#${sec.anchor}` : url) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
/** Distinct top-level sections present in a loaded corpus — drives the tool's filter enum. */
|
||||
export function docSections(docs: SearchDocument[]): string[] {
|
||||
return [...new Set(docs.map((d) => String(d.metadata.section)))].sort();
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { registerTools } from './tools.js';
|
||||
import { registerResources } from './resources.js';
|
||||
import { registerPrompts } from './prompts.js';
|
||||
import { loadKnowledgeDocs } from './knowledge-base.js';
|
||||
import { loadDocsCorpus, docSections } from './docs-corpus.js';
|
||||
import { createSearchProvider, resolveBackend } from './search-factory.js';
|
||||
import type { SearchBackend, SearchProvider } from './search-provider.js';
|
||||
import { MODEL_NAME, VectorSearchProvider, embed } from './vector-search-provider.js';
|
||||
@@ -27,6 +28,18 @@ const SERVER_VERSION: string = pkg.version;
|
||||
const PORT = parseInt(process.env.PORT ?? '8080', 10);
|
||||
const RULES_DIR = process.env.RULES_DIR ?? resolve(__dirname, '..', '..', '..', '..', 'rules');
|
||||
|
||||
// Published customer documentation bundle (autopilotmonitor-docs), baked into the
|
||||
// image by the deploy workflow. Unset in a plain local checkout — the docs live in
|
||||
// a separate repo — in which case the corpus is empty and search_docs is simply not
|
||||
// registered. Point DOCS_DIR at a local clone to develop against it.
|
||||
const DOCS_DIR = process.env.DOCS_DIR ?? '';
|
||||
|
||||
// Commit of the docs bundle this image was built from. The docs repo changes
|
||||
// independently of this one, so a deployed image can silently serve stale
|
||||
// documentation; surfacing the SHA on /health makes that checkable instead of a
|
||||
// thing to remember.
|
||||
const DOCS_COMMIT = process.env.DOCS_COMMIT ?? 'unknown';
|
||||
|
||||
// Surface the MCP_PUBLIC_URL state at boot. In production a missing pin is a
|
||||
// hard boot failure (config.ts throws — host-spoofing defense); this dev-only
|
||||
// notice flags the forwarded-header fallback. Two-stage deploy expectation:
|
||||
@@ -49,10 +62,15 @@ console.error(`[startup] Backend API base URL: ${API_BASE_URL}`);
|
||||
console.error('Loading knowledge base documents…');
|
||||
const docs = await loadKnowledgeDocs(RULES_DIR);
|
||||
const eventTypeDocs = buildEventTypeSearchDocs();
|
||||
const docsDocs = DOCS_DIR ? await loadDocsCorpus(DOCS_DIR) : [];
|
||||
if (DOCS_DIR && docsDocs.length === 0) {
|
||||
console.error(`[startup] DOCS_DIR=${DOCS_DIR} yielded no documents — search_docs will NOT be registered.`);
|
||||
}
|
||||
|
||||
async function buildSearchIndexes(backend?: SearchBackend): Promise<{
|
||||
knowledgeBase: SearchProvider;
|
||||
eventTypeIndex: SearchProvider;
|
||||
docsIndex: SearchProvider | undefined;
|
||||
}> {
|
||||
const kb = await createSearchProvider(backend);
|
||||
await kb.index(docs);
|
||||
@@ -61,7 +79,15 @@ async function buildSearchIndexes(backend?: SearchBackend): Promise<{
|
||||
// the embedder singleton with the knowledge base, so this adds ~no memory.
|
||||
const et = await createSearchProvider(backend);
|
||||
await et.index(eventTypeDocs);
|
||||
return { knowledgeBase: kb, eventTypeIndex: et };
|
||||
// Product documentation — a third corpus, deliberately not merged into the
|
||||
// knowledge base (rules answer "why did this fail", docs answer "how does this
|
||||
// work"; sharing one topK would dilute both).
|
||||
let dx: SearchProvider | undefined;
|
||||
if (docsDocs.length > 0) {
|
||||
dx = await createSearchProvider(backend);
|
||||
await dx.index(docsDocs);
|
||||
}
|
||||
return { knowledgeBase: kb, eventTypeIndex: et, docsIndex: dx };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +98,11 @@ async function buildSearchIndexes(backend?: SearchBackend): Promise<{
|
||||
*/
|
||||
const SEARCH_INDEX_PATH = process.env.SEARCH_INDEX_PATH ?? resolve(__dirname, '..', 'search-index.json');
|
||||
|
||||
function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventTypeIndex: SearchProvider } | null {
|
||||
function tryLoadPrecomputedIndexes(): {
|
||||
knowledgeBase: SearchProvider;
|
||||
eventTypeIndex: SearchProvider;
|
||||
docsIndex: SearchProvider | undefined;
|
||||
} | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(SEARCH_INDEX_PATH, 'utf-8');
|
||||
@@ -81,7 +111,7 @@ function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventType
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const validated = validatePrecomputedIndex(JSON.parse(raw), MODEL_NAME, docs, eventTypeDocs);
|
||||
const validated = validatePrecomputedIndex(JSON.parse(raw), MODEL_NAME, docs, eventTypeDocs, docsDocs);
|
||||
if (!validated.ok) {
|
||||
console.error(`[startup] Precomputed search index rejected (${validated.reason}) — computing embeddings at boot.`);
|
||||
return null;
|
||||
@@ -90,7 +120,13 @@ function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventType
|
||||
kb.indexPrecomputed(validated.knowledgeBase);
|
||||
const et = new VectorSearchProvider();
|
||||
et.indexPrecomputed(validated.eventTypes);
|
||||
return { knowledgeBase: kb, eventTypeIndex: et };
|
||||
let dx: SearchProvider | undefined;
|
||||
if (validated.docs.length > 0) {
|
||||
const provider = new VectorSearchProvider();
|
||||
provider.indexPrecomputed(validated.docs);
|
||||
dx = provider;
|
||||
}
|
||||
return { knowledgeBase: kb, eventTypeIndex: et, docsIndex: dx };
|
||||
} catch (err) {
|
||||
console.error('[startup] Failed to read precomputed search index — computing embeddings at boot:', err);
|
||||
return null;
|
||||
@@ -106,9 +142,10 @@ function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventType
|
||||
console.error(`Initializing search provider (${docs.length} documents)…`);
|
||||
let knowledgeBase: SearchProvider;
|
||||
let eventTypeIndex: SearchProvider;
|
||||
let docsIndex: SearchProvider | undefined;
|
||||
const precomputed = (await resolveBackend()) === 'vector' ? tryLoadPrecomputedIndexes() : null;
|
||||
if (precomputed) {
|
||||
({ knowledgeBase, eventTypeIndex } = precomputed);
|
||||
({ knowledgeBase, eventTypeIndex, docsIndex } = precomputed);
|
||||
// Serving precomputed vectors does not need the embedder — only incoming
|
||||
// queries do. Warm it in the background so neither boot nor the readiness
|
||||
// probe waits on the model load; a search arriving first awaits the same
|
||||
@@ -119,14 +156,26 @@ if (precomputed) {
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
({ knowledgeBase, eventTypeIndex } = await buildSearchIndexes());
|
||||
({ knowledgeBase, eventTypeIndex, docsIndex } = await buildSearchIndexes());
|
||||
} catch (err) {
|
||||
console.error('[startup] Search index initialization failed (embedding model unavailable?) — falling back to the keyword (fuse) backend:', err);
|
||||
({ knowledgeBase, eventTypeIndex } = await buildSearchIndexes('fuse'));
|
||||
({ knowledgeBase, eventTypeIndex, docsIndex } = await buildSearchIndexes('fuse'));
|
||||
}
|
||||
}
|
||||
console.error(`Search provider ready: ${knowledgeBase.name} — ${knowledgeBase.size} documents indexed${precomputed ? ' (precomputed)' : ''}.`);
|
||||
console.error(`Event-type index ready: ${eventTypeIndex.name} — ${eventTypeIndex.size} types indexed${precomputed ? ' (precomputed)' : ''}.`);
|
||||
if (docsIndex) {
|
||||
console.error(
|
||||
`Docs index ready: ${docsIndex.name} — ${docsIndex.size} chunks indexed${precomputed ? ' (precomputed)' : ''} ` +
|
||||
`(docs commit ${DOCS_COMMIT}).`,
|
||||
);
|
||||
} else {
|
||||
console.error('Docs index: not available — search_docs is not registered.');
|
||||
}
|
||||
|
||||
// Computed once: the tool catalog is rebuilt per request, and this is a pure
|
||||
// function of the (immutable) corpus.
|
||||
const DOCS_SECTIONS = docSections(docsDocs);
|
||||
|
||||
// Server-level guidance. The host surfaces this once per connection, so it is
|
||||
// the right home for cross-cutting strategy that would otherwise be duplicated
|
||||
@@ -154,6 +203,11 @@ function buildInstructions(ga: boolean, delegated: boolean, managedTenants: stri
|
||||
'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.',
|
||||
'',
|
||||
'Investigating one session: call get_session_summary FIRST (status, filtered timeline, stats, rule analysis in one call), then drill in.',
|
||||
...(docsIndex
|
||||
? ['Product questions ("how do I…", "what does X mean", "where is my data stored"): use search_docs — the ' +
|
||||
'published customer documentation. search_knowledge is a DIFFERENT corpus (analysis rules and IME log ' +
|
||||
'patterns) and answers why an enrollment failed, not how the product works.']
|
||||
: []),
|
||||
'Searching events: use search_events (hybrid keyword+semantic ranking; depth="fast" then "deep" for exhaustive recall) for ranked hits, or get_session_events / query_raw_events for the raw unranked stream.',
|
||||
'Counting / aggregating: pass a lean `fields=` projection and use `agentVersionPrefix=`/`imeAgentVersionPrefix=` sweeps to stay under the per-response size cap.',
|
||||
'Pagination: when a response carries `nextLink`, pass that whole string back as `continuation`; stop when it is absent. Results are never silently truncated.',
|
||||
@@ -173,7 +227,7 @@ function createMcpServer(ga: boolean, strictGa: boolean, delegated: boolean, man
|
||||
{ name: 'Autopilot-Monitor', version: SERVER_VERSION },
|
||||
{ instructions: buildInstructions(ga, delegated, managedTenants, homeTenantId) },
|
||||
);
|
||||
registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa, delegated);
|
||||
registerTools(s, knowledgeBase, eventTypeIndex, docsIndex, DOCS_SECTIONS, ga, strictGa, delegated);
|
||||
registerResources(s);
|
||||
// A delegated caller has no platform scope, so prompts get the tenant-user surface (ga=false) —
|
||||
// the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user.
|
||||
@@ -227,8 +281,19 @@ app.use(createOAuthRouter());
|
||||
// The version is the same value already advertised in the MCP handshake
|
||||
// (createMcpServer → { name, version }), so surfacing it here leaks nothing
|
||||
// new; it lets the backend health dashboard show the deployed MCP build.
|
||||
//
|
||||
// The docs block exists for one reason: the documentation bundle lives in its own
|
||||
// repo and is baked in at image build time, so editing the docs does NOT update a
|
||||
// running server — only a redeploy does. Reporting the bundle's commit and chunk
|
||||
// count turns "did we remember to redeploy?" into a diff against
|
||||
// `git rev-parse HEAD` in autopilotmonitor-docs. Both values are public
|
||||
// (the bundle is a public repo published at docs.autopilotmonitor.com).
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'healthy', version: SERVER_VERSION });
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
version: SERVER_VERSION,
|
||||
docs: { commit: DOCS_COMMIT, chunks: docsIndex?.size ?? 0, sections: docSections(docsDocs) },
|
||||
});
|
||||
});
|
||||
|
||||
// Access guard for /mcp — validates JWT, checks backend whitelist, enforces rate limits
|
||||
|
||||
@@ -7,18 +7,19 @@
|
||||
* 0.25 vCPU container. Running the real pipeline here also doubles as the
|
||||
* model prefetch into HF_CACHE_DIR (no HuggingFace CDN dependency at runtime).
|
||||
*
|
||||
* Usage: node dist/precompute-embeddings.js <rulesDir> <outFile>
|
||||
* Usage: node dist/precompute-embeddings.js <rulesDir> <outFile> [docsDir]
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { loadKnowledgeDocs } from './knowledge-base.js';
|
||||
import { loadDocsCorpus } from './docs-corpus.js';
|
||||
import { buildEventTypeSearchDocs } from './resource-catalog.js';
|
||||
import { MODEL_NAME, VectorSearchProvider } from './vector-search-provider.js';
|
||||
import { hashDocs, type PrecomputedIndexFile } from './precomputed-index.js';
|
||||
|
||||
const [rulesDir, outFile] = process.argv.slice(2);
|
||||
const [rulesDir, outFile, docsDir] = process.argv.slice(2);
|
||||
if (!rulesDir || !outFile) {
|
||||
console.error('usage: node dist/precompute-embeddings.js <rulesDir> <outFile>');
|
||||
console.error('usage: node dist/precompute-embeddings.js <rulesDir> <outFile> [docsDir]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -31,6 +32,17 @@ if (knowledgeDocs.length === 0) {
|
||||
}
|
||||
const eventTypeDocs = buildEventTypeSearchDocs();
|
||||
|
||||
// Docs are optional: the production image always passes docsDir (the workflow
|
||||
// checks the docs repo out into the build context), but a local build without it
|
||||
// must still produce a usable index rather than fail. Warn loudly instead —
|
||||
// a silently doc-less production image would leave search_docs unregistered.
|
||||
const docsDocs = docsDir ? await loadDocsCorpus(docsDir) : [];
|
||||
if (docsDir && docsDocs.length === 0) {
|
||||
console.error(`WARNING: docs dir ${docsDir} yielded no documents — search_docs will not be registered`);
|
||||
} else if (!docsDir) {
|
||||
console.error('no docsDir argument — building an index without the documentation corpus');
|
||||
}
|
||||
|
||||
const knowledgeBase = new VectorSearchProvider();
|
||||
await knowledgeBase.index(knowledgeDocs);
|
||||
const eventTypeIndex = new VectorSearchProvider();
|
||||
@@ -41,7 +53,15 @@ const file: PrecomputedIndexFile = {
|
||||
knowledgeBase: { docsHash: hashDocs(knowledgeDocs), entries: knowledgeBase.serialize() },
|
||||
eventTypes: { docsHash: hashDocs(eventTypeDocs), entries: eventTypeIndex.serialize() },
|
||||
};
|
||||
|
||||
if (docsDocs.length > 0) {
|
||||
const docsIndex = new VectorSearchProvider();
|
||||
await docsIndex.index(docsDocs);
|
||||
file.docs = { docsHash: hashDocs(docsDocs), entries: docsIndex.serialize() };
|
||||
}
|
||||
|
||||
writeFileSync(outFile, JSON.stringify(file));
|
||||
console.error(
|
||||
`precomputed embeddings: ${knowledgeDocs.length} knowledge docs + ${eventTypeDocs.length} event types (${MODEL_NAME}) → ${outFile}`,
|
||||
`precomputed embeddings: ${knowledgeDocs.length} knowledge docs + ${eventTypeDocs.length} event types + ` +
|
||||
`${docsDocs.length} doc chunks (${MODEL_NAME}) → ${outFile}`,
|
||||
);
|
||||
|
||||
@@ -29,6 +29,12 @@ export interface PrecomputedIndexFile {
|
||||
model: string;
|
||||
knowledgeBase: PrecomputedSection;
|
||||
eventTypes: PrecomputedSection;
|
||||
/**
|
||||
* Chunked customer documentation (see docs-corpus.ts). Optional in the file
|
||||
* format: a build without the docs repo checked out ships no docs section, and
|
||||
* that must not invalidate the other two.
|
||||
*/
|
||||
docs?: PrecomputedSection;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +57,12 @@ export function hashDocs(docs: SearchDocument[]): string {
|
||||
}
|
||||
|
||||
export type PrecomputedValidation =
|
||||
| { ok: true; knowledgeBase: PrecomputedDocument[]; eventTypes: PrecomputedDocument[] }
|
||||
| {
|
||||
ok: true;
|
||||
knowledgeBase: PrecomputedDocument[];
|
||||
eventTypes: PrecomputedDocument[];
|
||||
docs: PrecomputedDocument[];
|
||||
}
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function validateSection(
|
||||
@@ -88,6 +99,7 @@ export function validatePrecomputedIndex(
|
||||
expectedModel: string,
|
||||
knowledgeDocs: SearchDocument[],
|
||||
eventTypeDocs: SearchDocument[],
|
||||
docsDocs: SearchDocument[] = [],
|
||||
): PrecomputedValidation {
|
||||
if (parsed === null || typeof parsed !== 'object') return { ok: false, reason: 'not an object' };
|
||||
const file = parsed as Partial<PrecomputedIndexFile>;
|
||||
@@ -98,5 +110,24 @@ export function validatePrecomputedIndex(
|
||||
if (kbError) return { ok: false, reason: kbError };
|
||||
const etError = validateSection(file.eventTypes, 'eventTypes', eventTypeDocs);
|
||||
if (etError) return { ok: false, reason: etError };
|
||||
return { ok: true, knowledgeBase: file.knowledgeBase!.entries, eventTypes: file.eventTypes!.entries };
|
||||
|
||||
// Docs are an OPTIONAL corpus: a checkout without the docs repo loads zero doc
|
||||
// documents, and the matching image ships no docs section. Only validate when
|
||||
// the booting server actually has a docs corpus to check against — otherwise a
|
||||
// dev build would throw away the rules and event-type vectors too, which is the
|
||||
// expensive half. When the server HAS docs but the file does not, that is a
|
||||
// genuine staleness signal and must be rejected like any other mismatch.
|
||||
let docs: PrecomputedDocument[] = [];
|
||||
if (docsDocs.length > 0) {
|
||||
const docsError = validateSection(file.docs, 'docs', docsDocs);
|
||||
if (docsError) return { ok: false, reason: docsError };
|
||||
docs = file.docs!.entries;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
knowledgeBase: file.knowledgeBase!.entries,
|
||||
eventTypes: file.eventTypes!.entries,
|
||||
docs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,12 +26,14 @@ export function registerTools(
|
||||
server: McpServer,
|
||||
knowledgeBase: SearchProvider | undefined,
|
||||
eventTypeIndex: SearchProvider | undefined,
|
||||
docsIndex: SearchProvider | undefined,
|
||||
docsSections: string[],
|
||||
ga: boolean,
|
||||
strictGa: boolean = ga,
|
||||
delegated: boolean = false,
|
||||
): void {
|
||||
registerSessionTools(server, ga, delegated);
|
||||
registerSearchTools(server, knowledgeBase, eventTypeIndex, ga, delegated);
|
||||
registerSearchTools(server, knowledgeBase, eventTypeIndex, docsIndex, docsSections, ga, delegated);
|
||||
registerAdminTools(server, ga, strictGa, delegated);
|
||||
sortToolCatalog(server);
|
||||
}
|
||||
|
||||
@@ -965,6 +965,9 @@ export function registerSearchTools(
|
||||
server: McpServer,
|
||||
knowledgeBase: SearchProvider | undefined,
|
||||
eventTypeIndex: SearchProvider | undefined,
|
||||
docsIndex: SearchProvider | undefined,
|
||||
/** Top-level documentation areas present in the corpus — surfaced as the `section` filter hint. */
|
||||
docsSections: string[],
|
||||
ga: boolean,
|
||||
delegated: boolean = false,
|
||||
): void {
|
||||
@@ -1176,4 +1179,95 @@ export function registerSearchTools(
|
||||
})
|
||||
);
|
||||
|
||||
// Tool 11: search_docs — semantic search over the published customer documentation.
|
||||
//
|
||||
// Registered for EVERY role, unlike the platform tools: this is public product
|
||||
// documentation (docs.autopilotmonitor.com), carries no tenant data, and a
|
||||
// normal tenant user has exactly as much business asking "how do I deploy the
|
||||
// agent" as a Global Admin. It is skipped entirely when no corpus was baked
|
||||
// into the image, so a doc-less build advertises no broken tool.
|
||||
if (docsIndex && docsIndex.size > 0) {
|
||||
server.registerTool(
|
||||
'search_docs',
|
||||
{
|
||||
title: 'Search Product Documentation',
|
||||
description:
|
||||
'PRODUCT DOCUMENTATION SEARCH — the published Autopilot Monitor customer documentation ' +
|
||||
'(docs.autopilotmonitor.com), chunked by section and ranked semantically. ' +
|
||||
'Use this for questions about how the PRODUCT works rather than what a specific enrollment did: ' +
|
||||
'setup and onboarding, agent deployment, portal features, roles and permissions, settings, ' +
|
||||
'notifications, network endpoints, security/privacy/data-residency, plans, and troubleshooting guidance. ' +
|
||||
'Each result carries the page `title`, its `heading` breadcrumb and a citable `url` — quote the url when ' +
|
||||
'answering so the user can verify. ' +
|
||||
'NOT the same corpus as search_knowledge: that one holds analysis/gather rules and IME log patterns and ' +
|
||||
'explains why an enrollment failed. Rule of thumb — "how does X work?" → search_docs; ' +
|
||||
'"why did this session fail?" → search_knowledge or get_session_summary.',
|
||||
inputSchema: {
|
||||
query: z.string().describe('Natural language question (e.g. "how do I deploy the agent with Intune", "where is my data stored", "which roles exist")'),
|
||||
topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'),
|
||||
section: z.string().optional()
|
||||
.describe('Restrict to one documentation area, matching the bundle\'s top-level folder ' +
|
||||
`(one of: ${docsSections.join(', ')}). Omit to search everything.`),
|
||||
minScore: z.coerce.number().min(0).max(1).optional().default(0.25)
|
||||
.describe('Minimum similarity score (0-1, default 0.25). Short keyword queries score low on ' +
|
||||
'all-MiniLM embeddings, so the default is tuned to keep the relevant-but-marginal band.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('search_docs', async () => {
|
||||
try {
|
||||
const { query, topK, section, minScore } = args;
|
||||
|
||||
// Over-fetch before filtering by section, exactly as search_knowledge does
|
||||
// for `type` — filtering a topK-sized result set would otherwise return
|
||||
// fewer than topK hits whenever the top matches sit in other sections.
|
||||
const fetchK = section ? topK * 4 : topK;
|
||||
let results = await docsIndex.search(query, { topK: fetchK, minScore });
|
||||
|
||||
// Same rationale as search_knowledge: opaque HRESULT/Win32 tokens embed
|
||||
// poorly, and the troubleshooting pages name them verbatim.
|
||||
const needles = extractErrorCodeNeedles(query);
|
||||
const errorCodeHitIds = new Set<string>();
|
||||
if (needles.length > 0 && docsIndex.lexicalMatch) {
|
||||
const byId = new Map(results.map((r) => [r.id, r] as const));
|
||||
for (const hit of docsIndex.lexicalMatch(needles)) {
|
||||
errorCodeHitIds.add(hit.id);
|
||||
const existing = byId.get(hit.id);
|
||||
if (!existing || hit.score > existing.score) byId.set(hit.id, hit);
|
||||
}
|
||||
results = [...byId.values()].sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
if (section) {
|
||||
results = results.filter((r) => r.metadata.section === section);
|
||||
}
|
||||
results = results.slice(0, topK);
|
||||
|
||||
const formatted = results.map((r) => ({
|
||||
id: r.id,
|
||||
score: Math.round(r.score * 1000) / 1000,
|
||||
title: r.metadata.title,
|
||||
heading: r.metadata.heading,
|
||||
section: r.metadata.section,
|
||||
path: r.metadata.path,
|
||||
url: r.metadata.url,
|
||||
tags: r.metadata.tags,
|
||||
content: r.text,
|
||||
matchType: errorCodeHitIds.has(r.id) ? ('error-code' as const) : undefined,
|
||||
}));
|
||||
|
||||
return toolResultText({
|
||||
query,
|
||||
searchBackend: docsIndex.name,
|
||||
...(section ? { section } : {}),
|
||||
resultCount: formatted.length,
|
||||
...(needles.length > 0 ? { errorCodeFallback: { codes: needles, matchedCount: errorCodeHitIds.size } } : {}),
|
||||
results: formatted,
|
||||
}, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
return toolError('search_docs', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ function getEmbedder(): Promise<FeatureExtractionPipeline> {
|
||||
*/
|
||||
const UNIT_NORM_EPSILON = 1e-3;
|
||||
|
||||
/**
|
||||
* Decimal places kept when serializing embeddings to the precomputed index.
|
||||
* Six is comfortably below the tolerance above while roughly halving the file
|
||||
* size — see VectorSearchProvider.serialize().
|
||||
*/
|
||||
const SERIALIZED_PRECISION = 6;
|
||||
|
||||
export function assertUnitNorm(vec: number[], context: string): void {
|
||||
let sumSq = 0;
|
||||
for (let i = 0; i < vec.length; i++) sumSq += vec[i] * vec[i];
|
||||
@@ -119,12 +126,31 @@ export class VectorSearchProvider implements SearchProvider {
|
||||
* from disk is milliseconds. The embedder is still loaded lazily for queries.
|
||||
*/
|
||||
indexPrecomputed(docs: PrecomputedDocument[]): void {
|
||||
// The serialized vectors are rounded (see serialize()) and arrive from a file
|
||||
// this process did not produce. Spot-check the first one: cosineSimilarity is
|
||||
// a bare dot product, so a non-unit vector here would silently skew every
|
||||
// score rather than fail. One check is enough — the whole section is written
|
||||
// by a single serialize() call, so they stand or fall together.
|
||||
if (docs.length > 0) assertUnitNorm(docs[0].embedding, 'indexPrecomputed');
|
||||
this.documents.push(...docs);
|
||||
}
|
||||
|
||||
/** Export the indexed documents with embeddings for build-time serialization. */
|
||||
/**
|
||||
* Export the indexed documents with embeddings for build-time serialization.
|
||||
*
|
||||
* Embeddings are rounded to SERIALIZED_PRECISION decimals. A float64 component
|
||||
* serializes to ~20 characters of JSON ("0.05234159901738167"), so 384 dims
|
||||
* cost ~7.8 KB per document; rounding roughly halves that, and the index file
|
||||
* is JSON.parse'd on every scale-to-zero cold start. The precision loss is
|
||||
* irrelevant to ranking: the induced L2 norm error is ~1e-5 across 384 dims,
|
||||
* two orders of magnitude inside UNIT_NORM_EPSILON, and far below the score
|
||||
* gaps that separate ranked results.
|
||||
*/
|
||||
serialize(): PrecomputedDocument[] {
|
||||
return this.documents;
|
||||
return this.documents.map((doc) => ({
|
||||
...doc,
|
||||
embedding: doc.embedding.map((v) => Number(v.toFixed(SERIALIZED_PRECISION))),
|
||||
}));
|
||||
}
|
||||
|
||||
async index(docs: SearchDocument[]): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user