diff --git a/packages/igniteui-mcp/docs-backend/docs-backend/Controllers/DocsController.cs b/packages/igniteui-mcp/docs-backend/docs-backend/Controllers/DocsController.cs index 0e0521892..1d70de8a7 100644 --- a/packages/igniteui-mcp/docs-backend/docs-backend/Controllers/DocsController.cs +++ b/packages/igniteui-mcp/docs-backend/docs-backend/Controllers/DocsController.cs @@ -90,31 +90,75 @@ public IActionResult Search([FromQuery] string framework, [FromQuery] string que if (string.IsNullOrWhiteSpace(query)) return Content("Empty query.", "text/plain"); + // Field-weighted re-ranking: FTS4 BM25 treats all columns equally and ranks + // by raw term frequency across the corpus, causing dedicated feature docs to + // rank below generic docs that mention the same terms in passing. + // We fix this by computing a field-boost score: a term match in the doc + // title (toc_name) is worth far more than a match buried in body text. + // This generalizes to any query — no per-feature heuristics needed. + var terms = query + .Replace("*", " ").Replace("\"", " ") + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(t => t.Length > 2) + .Select(t => t.ToLowerInvariant()) + .ToList(); + var cmd = db.CreateCommand(); cmd.CommandText = @" - SELECT d.framework, d.filename, d.component, d.toc_name, d.premium, + SELECT d.filename, d.component, d.toc_name, d.keywords, d.summary, snippet(docs_fts, '>>>', '<<<', '...', -1, 32) AS excerpt FROM docs_fts JOIN docs d ON d.rowid = docs_fts.rowid WHERE docs_fts MATCH @q AND d.framework = @fw - LIMIT 20"; + LIMIT 200"; cmd.Parameters.AddWithValue("@q", query); cmd.Parameters.AddWithValue("@fw", framework); - var sb = new StringBuilder(); + var results = new List<(int score, int idx, string filename, string? component, string? tocName, string? summary, string excerpt)>(); + var rowIdx = 0; using var reader = cmd.ExecuteReader(); while (reader.Read()) { var filename = reader.GetString(reader.GetOrdinal("filename")); - var docName = filename.EndsWith(".md") ? filename[..^3] : filename; var component = reader.IsDBNull(reader.GetOrdinal("component")) ? null : reader.GetString(reader.GetOrdinal("component")); - var comp = !string.IsNullOrEmpty(component) ? $" [{component}]" : ""; - var excerpt = reader.GetString(reader.GetOrdinal("excerpt")); + var tocName = reader.IsDBNull(reader.GetOrdinal("toc_name")) ? null : reader.GetString(reader.GetOrdinal("toc_name")); + var keywords = reader.IsDBNull(reader.GetOrdinal("keywords")) ? null : reader.GetString(reader.GetOrdinal("keywords")); + var summary = reader.IsDBNull(reader.GetOrdinal("summary")) ? null : reader.GetString(reader.GetOrdinal("summary")); + var excerpt = reader.GetString(reader.GetOrdinal("excerpt")); + + var toc = (tocName ?? "").ToLowerInvariant(); + var fn = filename.ToLowerInvariant(); + var kw = (keywords ?? "").ToLowerInvariant(); + var sm = (summary ?? "").ToLowerInvariant(); + + var score = 0; + foreach (var t in terms) + { + if (toc.Contains(t)) score += 10; // title match — strongest signal + if (fn.Contains(t)) score += 4; // filename contains the concept + if (kw.Contains(t)) score += 5; // curated keyword metadata + if (sm.Contains(t)) score += 3; // summary mentions the concept + // content matches are already captured by FTS; no extra boost needed + } + + results.Add((score, rowIdx++, filename, component, tocName, summary, excerpt)); + } + var ranked = results + .OrderByDescending(r => r.score) + .ThenBy(r => r.idx) + .Take(20); + + var sb = new StringBuilder(); + foreach (var r in ranked) + { + var docName = r.filename.EndsWith(".md") ? r.filename[..^3] : r.filename; + var comp = !string.IsNullOrEmpty(r.component) ? $" [{r.component}]" : ""; if (sb.Length > 0) sb.AppendLine().AppendLine(); - sb.Append($"**{docName}**{comp}\n{excerpt}"); + sb.Append($"**{docName}**{comp}\n{r.excerpt}"); } - var text = sb.Length > 0 ? sb.ToString() : $"No results found for \"{query}\"."; + var displayQuery = System.Text.RegularExpressions.Regex.Replace(query.Replace('"', ' ').Replace('*', ' '), @"\s+", " ").Trim(); + var text = sb.Length > 0 ? sb.ToString() : $"No results found for \"{displayQuery}\"."; return Content(text, "text/plain"); } } diff --git a/packages/igniteui-mcp/docs-backend/tests-docs-backend/DocsControllerTests.cs b/packages/igniteui-mcp/docs-backend/tests-docs-backend/DocsControllerTests.cs index 96872ba7b..8d39224e7 100644 --- a/packages/igniteui-mcp/docs-backend/tests-docs-backend/DocsControllerTests.cs +++ b/packages/igniteui-mcp/docs-backend/tests-docs-backend/DocsControllerTests.cs @@ -231,4 +231,56 @@ public void Search_OnlyReturnsRequestedFramework() Assert.That(result.Content, Does.Not.Contain("grid-editing")); Assert.That(result.Content, Does.Not.Contain("grid-filtering")); } + + [Test] + public void Search_TitleAndKeywordMatchesOutrankBodyOnlyMatch() + { + // Isolated DB: one doc has the query term in toc_name + keywords (dedicated feature doc), + // the other only mentions it once in the body (generic doc that references it in passing). + // Verifies that field-weighted re-ranking surfaces the dedicated doc first. + using var rankDb = new SqliteConnection("Data Source=:memory:"); + rankDb.Open(); + + void Exec(string sql) { var c = rankDb.CreateCommand(); c.CommandText = sql; c.ExecuteNonQuery(); } + + Exec(@"CREATE TABLE docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + framework TEXT NOT NULL, filename TEXT NOT NULL, component TEXT NOT NULL, + toc_name TEXT, premium INTEGER DEFAULT 0, + keywords TEXT, summary TEXT, content TEXT NOT NULL, + UNIQUE(framework, filename))"); + + Exec(@"CREATE VIRTUAL TABLE docs_fts USING fts4( + component, toc_name, keywords, summary, content, + content='docs', tokenize=porter)"); + + Exec(@"INSERT INTO docs (framework, filename, component, toc_name, premium, keywords, summary, content) + VALUES ('angular', 'grid-virtualization.md', 'IgxGridComponent', + 'Virtualization and Performance', 0, + 'virtualization, performance, scrolling', + 'How grid virtualization works.', + 'Virtualization improves rendering performance.')"); + + Exec(@"INSERT INTO docs (framework, filename, component, toc_name, premium, keywords, summary, content) + VALUES ('angular', 'grid-editing.md', 'IgxGridComponent', + 'Grid Editing', 0, + 'editing, cell', + 'How to edit grid cells.', + 'The grid uses virtualization internally for performance.')"); + + Exec("INSERT INTO docs_fts (rowid, component, toc_name, keywords, summary, content) SELECT id, component, toc_name, keywords, summary, content FROM docs"); + + var controller = new DocsController(rankDb); + var result = controller.Search("angular", "\"virtualization\"") as ContentResult; + + Assert.That(result, Is.Not.Null); + var content = result!.Content!; + var dedicatedPos = content.IndexOf("**grid-virtualization**", StringComparison.Ordinal); + var genericPos = content.IndexOf("**grid-editing**", StringComparison.Ordinal); + + Assert.That(dedicatedPos, Is.GreaterThanOrEqualTo(0), "grid-virtualization should appear in results"); + Assert.That(genericPos, Is.GreaterThanOrEqualTo(0), "grid-editing should appear in results"); + Assert.That(dedicatedPos, Is.LessThan(genericPos), + "grid-virtualization (title + keyword match) should rank before grid-editing (body-only match)"); + } } diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db b/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db index 81553cfa8..3b822c05b 100644 Binary files a/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db and b/packages/igniteui-mcp/igniteui-doc-mcp/db/igniteui-docs.db differ diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md b/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md index 0655f9c49..0c8d189b9 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md +++ b/packages/igniteui-mcp/igniteui-doc-mcp/docs/knowledgebase.md @@ -1,6 +1,6 @@ # Documentation Processing Knowledgebase -Lessons learned and issues encountered while building the documentation processing pipelines. Entries 1-16 are from the Angular pipeline; entries 17-21 from React; entries 22-26 from WebComponents and cross-platform improvements; entries 27-32 from Blazor, cross-platform architecture, and prompt improvements. +Lessons learned and issues encountered while building the documentation processing pipelines. Entries 1-16 are from the Angular pipeline; entries 17-22 from React and MCP server; entries 23-28 from WebComponents and cross-platform improvements; entries 29-33 from Blazor, cross-platform architecture, and prompt improvements. ## 1. LLM Compression: Wrong Component Prefix (Hallucination) @@ -209,7 +209,36 @@ Uses `&&` chaining so any step failure stops the pipeline. **Rule:** All platform compress prompts need these rules. LLMs have a strong tendency to "improve" code they compress. The prompt must explicitly forbid it. -## 20. LLM Compression: Dropping Complex Examples Entirely +## 20. FTS4 Ranking Buries Dedicated Feature Docs + +**Problem:** The MCP `search_docs` tool returns wrong results — generic grid feature docs (editing, filtering, pinning, etc.) rank higher than the dedicated doc for the feature being queried. + +**Root cause:** FTS4's BM25 scoring is distorted by corpus-wide term frequency. In a grid component library, most feature docs share a boilerplate sentence like *"The grid uses virtual scrolling / column pinning / keyboard navigation for..."*. This means almost any feature term appears across a large fraction of the corpus, collapsing its IDF weight. The dedicated feature doc (short, focused) is outscored by long generic docs that mention the term once in passing. Additionally, `search_docs` was using OR between query terms, which made multi-word queries match nearly half the corpus. + +Observed example: `grid-virtualization.md` ranked 34th of 89 for query "virtual scroll" — completely outside the 20-result cutoff — while `grid-cell-editing.md` ranked in the top 10. + +**Fix applied — two changes:** + +1. **Multi-word queries use AND, not OR** (`sanitizeSearchDocsQuery` in `src/tools/doc-tools.ts`): + `"virtual scroll"` → `"virtual" OR "scroll"` (old, 151 matches) → `"virtual" "scroll"` (new, 89 matches). Reduces noise for any multi-word query. + +2. **Field-weighted re-ranking** (`LocalDocsProvider.ts` + `DocsController.cs`): + FTS returns up to 200 candidates; each is scored in application code using per-field boosts: + - `toc_name` (title) → **+10** per matched term — strongest signal + - `keywords` → **+5** — curated metadata written for discoverability + - `filename` → **+4** — structural + - `summary` → **+3** + - content body → 0 (already used by FTS for candidate selection) + + Results are sorted descending by score, then trimmed to 20. This generalizes to any query: a doc titled "Remote Data Operations" immediately scores +10+10 for query "remote data", while unrelated docs score 0. + +**Rule — keywords are the primary search signal:** The `keywords` frontmatter field in compressed docs is now the highest-weight signal after the title. The LLM compressor must include the exact natural-language phrases users would search for (e.g. "virtual scroll, virtual scrolling" on the virtualization doc; "infinite scroll, remote data" on the remote data operations doc). Vague or missing keywords directly hurt search quality. + +**Rule — `get_doc` aliases for non-mechanical names:** When testing reveals that LLMs consistently guess the wrong doc name (e.g. `"virtualization"` instead of `"grid-virtualization"`, `"remote-data-operations"` instead of `"grid-remote-data-operations"`), add an alias to `DOC_ALIASES` in `src/tools/doc-tools.ts`. For bare grid feature names that simply lack the `grid-` prefix, a generic fallback in `index.ts` automatically tries `grid-{name}` on a miss — no alias needed per feature. + +**Rule — the fix generalises:** Do not add per-query or per-feature heuristics. The field-boost scoring handles any future query automatically as long as the compressed docs have accurate `toc_name`, `keywords`, and `summary` frontmatter. + +## 21. LLM Compression: Dropping Complex Examples Entirely **Problem:** When a section has a large, complex code example (e.g. accordion customization showing IgrCheckbox, IgrRangeSlider, IgrDateTimeInput, IgrRating, IgrRadioGroup, and `registerIconFromText` in 260 lines), the LLM sometimes deletes the entire example and replaces it with a text pointer like "see the product examples". This loses the most instructive content in the doc. @@ -220,7 +249,7 @@ Uses `&&` chaining so any step failure stops the pipeline. **Rule:** All platform compress prompts should include this. The LLM defaults to dropping content it can't easily shrink. The prompt must make clear that a trimmed 30-line version of a 260-line example is far more valuable than no example at all. -## 21. LLM Compression: Code Block Self-Consistency +## 22. LLM Compression: Code Block Self-Consistency **Problem:** The LLM removes variable definitions, data model fields, or component props from code blocks while keeping code that references them. Examples: removing `const mods = [...]` while keeping `mods.forEach(...)`, removing `highLA`/`lowLA` fields from a data model while keeping `lowMemberPath="lowLA"` in JSX, removing `name` prop from ``. @@ -230,7 +259,7 @@ Uses `&&` chaining so any step failure stops the pipeline. **Rule:** All platform compress prompts need this. The LLM optimizes for size reduction without checking referential integrity. Broken code examples are worse than verbose ones. -## 22. Token Counting with js-tiktoken +## 23. Token Counting with js-tiktoken **Context:** Compressed docs are served to LLMs via the MCP server. Knowing the token count per file is essential for understanding context window consumption and budgeting. @@ -244,7 +273,7 @@ Tokens are counted for all files including skipped (below min-size threshold) an **Rule:** Any new platform compress script must include token counting. Use the same `encodingForModel("gpt-4o")` call — the encoding is model-agnostic enough for estimation purposes even if the docs are ultimately consumed by a different model. -## 23. Under-Compression on Code-Heavy Files +## 24. Under-Compression on Code-Heavy Files **Problem:** Documents that are primarily code with little prose (e.g. `cell-template.md` with a large `GridLiteDataService` class, `bullet-graph.md` with many HTML attribute examples) consistently under-compress. Observed reductions: `cell-template.md` ~15%, `bullet-graph.md` ~35%, `card.md` ~31% — all well below the ~50% target. @@ -254,7 +283,7 @@ Tokens are counted for all files including skipped (below min-size threshold) an **Rule:** Accept under-compression on code-heavy files as expected behavior. Do not tune the prompt to force deeper cuts — the ~50% target is an average across the full corpus, not a per-file requirement. Data service/utility class method bodies are the one area where deeper trimming is safe (keep signatures + one representative method, `// ...` the rest), but this is better handled in the inject step's `trimDataArrays` than by prompt tuning. -## 24. LLM Compression: Synthesized Code Snippets for Prose-Only Sections +## 25. LLM Compression: Synthesized Code Snippets for Prose-Only Sections **Problem:** When a documentation section describes a component feature in prose only (no code example in the original), the LLM occasionally synthesizes a small illustrative code snippet. Example: the WebComponents `card.md` "Outlined cards" section only had prose describing the `outlined` attribute, but the compressed version added ``. @@ -262,7 +291,7 @@ Tokens are counted for all files including skipped (below min-size threshold) an **Rule:** The existing anti-hallucination prompt rule ("Do NOT invent or generate new content") should catch this, but LLMs still do it occasionally for very short/obvious snippets. During validation, flag these as minor issues but don't consider them blockers. The rule "every section that had a code example must keep one" should not be misread as "every section must have a code example" — sections that were prose-only in the original should stay prose-only. -## 25. LLM Compression: Markdown Links Dropped in API References +## 26. LLM Compression: Markdown Links Dropped in API References **Problem:** The LLM consistently strips markdown hyperlink syntax from API References and Additional Resources sections. `[Bar Chart](bar-chart.md)` becomes `- Bar Chart` or `Bar Chart (bar-chart.md)`. This was observed across all WebComponents compressed files and likely affects all platforms. @@ -270,7 +299,7 @@ Tokens are counted for all files including skipped (below min-size threshold) an **Rule:** This is a known LLM behavior — models treat links as verbose boilerplate and strip them for compression. The current prompt doesn't explicitly address link preservation. If link preservation becomes important, add a rule: "Preserve markdown hyperlinks in API References and Additional Resources sections exactly as they appear in the original." For now, accept this as a minor quality trade-off. -## 26. LLM Compression: Header Hierarchy Changes +## 27. LLM Compression: Header Hierarchy Changes **Problem:** The LLM occasionally changes heading levels during compression — demoting `##` headers to `###` (e.g. in `area-chart.md` where chart variant sections were demoted), promoting `###` to `##` (e.g. Summary section in `card.md`), or merging similar sections under a combined header (e.g. "Stacked 100% Area / Spline Area"). Observed across WebComponents files. @@ -278,7 +307,7 @@ Tokens are counted for all files including skipped (below min-size threshold) an **Rule:** The compress prompt says "preserve all markdown headers" but doesn't explicitly forbid changing their levels or merging them. If header fidelity is important, add: "Do not change heading levels (##, ###, ####) — keep them exactly as in the original. Do not merge separate sections into combined headers." For now this is a minor issue since the MCP server doesn't use header hierarchy for indexing. -## 27. Blazor Sample File Types and Skip List +## 28. Blazor Sample File Types and Skip List **Context:** Blazor samples use a different file structure than React (`.tsx`) or WebComponents (`.html`/`.ts`). The inject script must handle Blazor-specific extensions and skip boilerplate files. @@ -291,7 +320,7 @@ Tokens are counted for all files including skipped (below min-size threshold) an **Rule:** Each platform's inject script needs a curated skip list. Blazor has more boilerplate files than React/WC. The skip list prevents project configuration from being inlined into documentation. -## 28. Cross-Platform Architecture: Gulp Build First (Option A) +## 29. Cross-Platform Architecture: Gulp Build First (Option A) **Context:** React, WebComponents, and Blazor docs all live in the shared `igniteui-xplat-docs` repo. Rather than reimplementing the complex `MarkdownTransformer` logic (variable replacement from `docConfig.json`, platform conditional sections, code fence filtering, grid template expansion, API link resolution via `apiMap/`), all three pipelines use Option A: run the gulp build first (`buildReact`, `buildWC`, `buildBlazor`), then process the fully-resolved output. @@ -301,7 +330,7 @@ Tokens are counted for all files including skipped (below min-size threshold) an See `docs/xplat-docs-architecture.md` for detailed analysis of the xplat build system. -## 29. Blazor Naming Inconsistency in docConfig.json +## 30. Blazor Naming Inconsistency in docConfig.json **Problem:** In `docConfig.json`, the `{GridName}` replacement values for Blazor are inconsistent — only the base `GridName` has the `Igb` prefix (`IgbGrid`), while the others use unprefixed names (`TreeGrid`, `PivotGrid`, `HierarchicalGrid`). However, in actual Blazor code, component **tags** always use the `Igb` prefix: ``, ``, ``. @@ -309,7 +338,7 @@ See `docs/xplat-docs-architecture.md` for detailed analysis of the xplat build s **Rule:** Be aware of this inconsistency when validating Blazor compressed docs. The compress prompt correctly specifies `Igb` prefix with no suffix, but validation shouldn't flag prose mentions of unprefixed grid names inherited from `docConfig.json` replacements. -## 30. Xplat toc.json vs Angular toc.yml +## 31. Xplat toc.json vs Angular toc.yml **Context:** Angular uses `toc.yml` (YAML, flat list with `name`, `href`, `premium` fields). React, WebComponents, and Blazor share `toc.json` (JSON, hierarchical with `exclude` arrays for platform filtering). @@ -321,7 +350,7 @@ This is the opposite logic from what you might expect — `exclude` means "hide **Rule:** When adding a new platform, filter toc.json entries by checking `!entry.exclude?.includes("PlatformName")`. The platform names are case-sensitive: `"Angular"`, `"React"`, `"WebComponents"`, `"Blazor"`. -## 31. Backtick Sample Syntax vs code-view Tags +## 32. Backtick Sample Syntax vs code-view Tags **Problem:** The xplat source docs use a backtick syntax for sample references (`` `sample="/gauges/bullet-graph/animation", height="155"` ``), not `` tags like Angular. However, the xplat gulp build converts these backtick references into `` HTML tags with `iframe-src` and `github-src` attributes during the build step. @@ -329,7 +358,7 @@ This is the opposite logic from what you might expect — `exclude` means "hide **Rule:** Always process post-build output (not raw source) for xplat platforms. The backtick-to-code-view conversion is handled by the gulp build, and the `github-src` attribute is the primary mechanism for locating sample files across all xplat platforms. -## 32. LLM Compression: Variant Sections Merged and Examples Dropped +## 33. LLM Compression: Variant Sections Merged and Examples Dropped **Problem:** In docs that cover multiple sub-types of the same component family (e.g., `area-chart.md` with Stacked Area, Stacked 100% Area, Stacked Spline Area, Stacked 100% Spline Area), the LLM treats these as "minor variations of the same pattern" and merges their sections under a combined heading (e.g., "### Stacked Area / Stacked Spline / Stacked 100% Variants"). This causes two issues simultaneously: header hierarchy changes (KB #26) and dropped examples (KB #20), since the merged section keeps only one example for all variants. diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/index.get-doc.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/index.get-doc.test.ts new file mode 100644 index 000000000..e765c1b18 --- /dev/null +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/index.get-doc.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => ({ + getDoc: vi.fn(), + registeredTools: new Map Promise>(), +})); + +vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({ + McpServer: class { + registerTool(name: string, _config: unknown, handler: (...args: any[]) => Promise) { + mockState.registeredTools.set(name, handler); + } + registerPrompt() {} + async connect() {} + }, +})); + +vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ + StdioServerTransport: class {}, +})); + +vi.mock("dotenv", () => ({ + default: { config: vi.fn() }, +})); + +vi.mock("../providers/LocalDocsProvider.js", () => ({ + LocalDocsProvider: class { + async init() {} + async listComponents() { + return ""; + } + async getDoc(framework: string, name: string) { + return mockState.getDoc(framework, name); + } + async searchDocs() { + return ""; + } + }, +})); + +vi.mock("../providers/RemoteDocsProvider.js", () => ({ + RemoteDocsProvider: class {}, +})); + +vi.mock("../lib/api-doc-loader.js", () => ({ + ApiDocLoader: class { + load() {} + }, +})); + +vi.mock("../config/platforms.js", () => ({ + PLATFORMS: ["angular", "react", "blazor", "webcomponents"], + getPlatforms: () => [], +})); + +describe("get_doc fallback resolution", () => { + beforeEach(async () => { + vi.resetModules(); + mockState.registeredTools.clear(); + mockState.getDoc.mockReset(); + process.argv = ["node", "index.js"]; + await import("../index.js"); + }); + + it("retries with grid- prefix when a non-prefixed name is not found", async () => { + const getDocHandler = mockState.registeredTools.get("get_doc")!; + mockState.getDoc + .mockResolvedValueOnce({ text: "missing", found: false }) + .mockResolvedValueOnce({ text: "grid-sorting doc", found: true }); + + const result = await getDocHandler({ framework: "angular", name: "sorting" }); + + expect(mockState.getDoc).toHaveBeenNthCalledWith(1, "angular", "sorting"); + expect(mockState.getDoc).toHaveBeenNthCalledWith(2, "angular", "grid-sorting"); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe("grid-sorting doc"); + }); + + it("does not double-prefix names that are already prefixed", async () => { + const getDocHandler = mockState.registeredTools.get("get_doc")!; + mockState.getDoc.mockResolvedValueOnce({ text: "missing", found: false }); + + const result = await getDocHandler({ framework: "angular", name: "grid-sorting" }); + + expect(mockState.getDoc).toHaveBeenCalledTimes(1); + expect(mockState.getDoc).toHaveBeenCalledWith("angular", "grid-sorting"); + expect(result.isError).toBe(true); + }); +}); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts index 84774e447..a9f75fe75 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/__tests__/tools/doc-tools.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'; import { applyDocAlias, normalizeDocName, sanitizeSearchDocsQuery } from '../../tools/doc-tools.js'; describe('sanitizeSearchDocsQuery', () => { - it('quotes plain terms with OR', () => { - expect(sanitizeSearchDocsQuery('grid selection')).toBe('"grid" OR "selection"'); + it('quotes plain terms with AND (implicit in FTS4)', () => { + expect(sanitizeSearchDocsQuery('grid selection')).toBe('"grid" "selection"'); }); it('quotes a single term', () => { @@ -15,7 +15,7 @@ describe('sanitizeSearchDocsQuery', () => { }); it('handles mix of prefix and plain terms', () => { - expect(sanitizeSearchDocsQuery('grid* selection')).toBe('grid* OR "selection"'); + expect(sanitizeSearchDocsQuery('grid* selection')).toBe('grid* "selection"'); }); it('strips double quotes (FTS4 phrase delimiter)', () => { @@ -23,11 +23,11 @@ describe('sanitizeSearchDocsQuery', () => { }); it('strips parentheses (FTS4 grouping operators)', () => { - expect(sanitizeSearchDocsQuery('(grid OR combo)')).toBe('"grid" OR "OR" OR "combo"'); + expect(sanitizeSearchDocsQuery('(grid OR combo)')).toBe('"grid" "OR" "combo"'); }); it('strips colons (FTS4 column filters)', () => { - expect(sanitizeSearchDocsQuery('title:grid')).toBe('"title" OR "grid"'); + expect(sanitizeSearchDocsQuery('title:grid')).toBe('"title" "grid"'); }); it('strips @ characters (FTS4 internal)', () => { @@ -35,7 +35,7 @@ describe('sanitizeSearchDocsQuery', () => { }); it('strips curly braces and brackets', () => { - expect(sanitizeSearchDocsQuery('{grid} [combo]')).toBe('"grid" OR "combo"'); + expect(sanitizeSearchDocsQuery('{grid} [combo]')).toBe('"grid" "combo"'); }); it('preserves hyphens (porter tokenizer handles them)', () => { @@ -67,11 +67,11 @@ describe('sanitizeSearchDocsQuery', () => { }); it('collapses multiple spaces', () => { - expect(sanitizeSearchDocsQuery('grid selection')).toBe('"grid" OR "selection"'); + expect(sanitizeSearchDocsQuery('grid selection')).toBe('"grid" "selection"'); }); it('handles realistic user query: column pinning', () => { - expect(sanitizeSearchDocsQuery('column pinning')).toBe('"column" OR "pinning"'); + expect(sanitizeSearchDocsQuery('column pinning')).toBe('"column" "pinning"'); }); it('handles realistic user query: tree* prefix search', () => { @@ -79,7 +79,7 @@ describe('sanitizeSearchDocsQuery', () => { }); it('handles realistic user query with special chars injected', () => { - expect(sanitizeSearchDocsQuery('grid" OR "1=1')).toBe('"grid" OR "OR" OR "1=1"'); + expect(sanitizeSearchDocsQuery('grid" OR "1=1')).toBe('"grid" "OR" "1=1"'); }); }); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts index 0ccf79e03..ebb689c79 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/index.ts @@ -145,8 +145,22 @@ function registerDocTools(server: McpServer, docsProvider: DocsProvider) { async ({ framework, name }) => { const start = performance.now(); const resolvedName = applyDocAlias(framework, normalizeDocName(name.trim())); - const { text, found } = await docsProvider.getDoc(framework, resolvedName); - log("get_doc", { framework, name: resolvedName }, text, Math.round(performance.now() - start)); + let { text, found } = await docsProvider.getDoc(framework, resolvedName); + + // Generic grid-prefix fallback: if the doc isn't found and the name doesn't + // already start with a component-type prefix, try "grid-{name}". + // This handles bare feature names like "sorting", "remote-data-operations", + // "row-editing" etc. without needing an explicit alias for every grid sub-doc. + let servedName = resolvedName; + if (!found && !/^(grid|hierarchical|tree|pivot|hierarchicalgrid|treegrid|pivotgrid|combo|drop-down|select|for-of)[-]/.test(resolvedName)) { + const withGridPrefix = await docsProvider.getDoc(framework, `grid-${resolvedName}`); + if (withGridPrefix.found) { + ({ text, found } = withGridPrefix); + servedName = `grid-${resolvedName}`; + } + } + + log("get_doc", { framework, name: servedName }, text, Math.round(performance.now() - start)); return { content: [{ type: "text" as const, text }], ...(found ? {} : { isError: true }) }; } ); diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/LocalDocsProvider.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/LocalDocsProvider.ts index b5a99809f..3b6e24d43 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/LocalDocsProvider.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/providers/LocalDocsProvider.ts @@ -123,27 +123,62 @@ export class LocalDocsProvider implements DocsProvider { const db = this.ensureDb(); const stmt = db.prepare( - `SELECT d.filename, d.toc_name, d.component, d.summary, + `SELECT d.filename, d.toc_name, d.component, d.keywords, d.summary, snippet(docs_fts, '>>>', '<<<', '...', -1, 32) AS excerpt FROM docs_fts JOIN docs d ON d.rowid = docs_fts.rowid WHERE docs_fts MATCH $query AND d.framework = $framework - LIMIT 20` + LIMIT 200` ); stmt.bind({ $query: query, $framework: framework }); - const rows: Record[] = []; + const rawRows: Record[] = []; while (stmt.step()) { - rows.push(stmt.getAsObject()); + rawRows.push(stmt.getAsObject()); } stmt.free(); - if (rows.length === 0) { - return `No results found for "${query}" in ${framework} docs.`; + const displayQuery = query.replace(/["*]/g, '').replace(/\s+/g, ' ').trim(); + + if (rawRows.length === 0) { + return `No results found for "${displayQuery}" in ${framework} docs.`; } - const lines = rows.map((r) => { + // Field-weighted re-ranking: FTS4 BM25 treats all columns equally and ranks + // by raw term frequency across the corpus. This causes dedicated feature docs + // to rank below generic docs that mention the same terms in passing. + // We fix this by computing a field-boost score: a query term match in the + // doc title (toc_name) is worth far more than a match buried in body text. + // This generalizes to any query — no per-feature heuristics needed. + const terms = query + .replace(/[*"]/g, ' ') + .split(/\s+/) + .filter((t) => t.length > 2) + .map((t) => t.toLowerCase()); + + const scored = rawRows.map((r, idx) => { + const toc = ((r.toc_name as string) || '').toLowerCase(); + const fn = ((r.filename as string) || '').toLowerCase(); + const kw = ((r.keywords as string) || '').toLowerCase(); + const sm = ((r.summary as string) || '').toLowerCase(); + let score = 0; + for (const t of terms) { + if (toc.includes(t)) score += 10; // title match — strongest signal + if (fn.includes(t)) score += 4; // filename contains the concept + if (kw.includes(t)) score += 5; // curated keyword metadata + if (sm.includes(t)) score += 3; // summary mentions the concept + // content matches are already captured by FTS; no extra boost needed + } + return { row: r, score, idx }; + }); + + const top20 = scored + .sort((a, b) => (b.score - a.score) || (a.idx - b.idx)) + .slice(0, 20) + .map((x) => x.row); + + const lines = top20.map((r) => { const name = (r.filename as string).replace(/\.md$/, ""); return [ `- **${r.toc_name || name}** (\`${name}\`)`, @@ -154,6 +189,6 @@ export class LocalDocsProvider implements DocsProvider { .join("\n"); }); - return `Found ${rows.length} results for "${query}" in **${framework}**:\n\n${lines.join("\n")}`; + return `Found ${top20.length} results for "${displayQuery}" in **${framework}**:\n\n${lines.join("\n")}`; } } diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts index 4a0e86fff..3a3e094bf 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/constants.ts @@ -32,20 +32,22 @@ No pagination — the full result set is returned in one call. get_doc: `Retrieve the full markdown content of one specific Ignite UI component doc by its exact name. The name is kebab-case without the .md extension (e.g. "grid-editing", "combo-overview", "accordion"). -Use this after discovering doc names from list_components or search_docs. Do NOT guess doc names — call one of those discovery tools first if the name is unknown. +For grid feature docs, the bare feature name works without the "grid-" prefix — e.g. "sorting" resolves to "grid-sorting", "remote-data-operations" resolves to "grid-remote-data-operations". Common aliases are also handled: "virtualization" → "grid-virtualization", "virtual-scroll" → "grid-virtualization". For completely unknown names, use list_components or search_docs to discover the exact name first. Returns YAML frontmatter (component, keywords, summary) followed by the complete markdown body with code samples, tables, and links. Returns isError if the doc name is not found, with a suggestion to use list_components. `, - search_docs: `Full-text search across all Ignite UI documentation for a specific framework. Supports prefix matching with trailing * (e.g. "grid*" matches grid, grids, grid-editing) and hyphenated terms (e.g. "grid-editing" matched as a phrase). + search_docs: `Full-text search across all Ignite UI documentation for a specific framework. -Use this when the user asks "how do I..." or describes a feature need — e.g. "column pinning", "data validation", "tree*". For browsing by component name instead, use list_components. +Use this when the user asks "how do I..." or describes a feature need — e.g. "column pinning", "data validation", "virtual scroll", "remote data", "tree*". For browsing by component name instead, use list_components. For a known doc name, use get_doc directly. -Returns up to 20 results ranked by relevance. Each result includes: doc name (pass to get_doc for full content), display name, summary, and a text excerpt with matching terms highlighted between >>> and <<<. +Returns up to 20 results ranked by relevance — results are title- and keyword-weighted, so the first result is typically the dedicated feature doc for the query. Each result includes: doc name (pass to get_doc for full content), display name, summary, and a text excerpt with matching terms highlighted between >>> and <<<. -Query must be non-empty. Special characters are sanitized automatically — only * for prefix matching needs to be passed explicitly. +Query syntax: multi-word queries require ALL terms to appear (implicit AND) — "virtual scroll" matches docs containing both words. Prefix matching with trailing * (e.g. "grid*"). Hyphenated terms matched as a phrase (e.g. "grid-editing"). Special characters are sanitized automatically — only * needs to be passed explicitly. + +Query must be non-empty. `, get_api_reference: `Look up the full API reference for a specific Ignite UI component or class by exact name. Case-insensitive matching. Covers all four frameworks: angular, react, webcomponents, and blazor. diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts index 61092a70b..fedd48f66 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/doc-tools.ts @@ -12,6 +12,11 @@ export const MISSING_FRAMEWORK_MESSAGE = // at both index and query time (e.g. "grid-editing" stays as one phrase). // Preserve trailing * — FTS4 prefix queries (e.g. grid*) rely on it, // and the DB is built with prefix="2,3" indexes to support this. +// +// Multi-word queries use implicit AND (space-separated in FTS4), meaning all +// terms must appear in the document. This is far more precise than OR: +// "virtual scroll" → `"virtual" "scroll"` (both required) +// Single-word and prefix queries are unaffected by this change. export function sanitizeSearchDocsQuery(queryText: string): string | null { const sanitized = queryText .replace(/["(){}[\]:@]/g, ' ') @@ -30,7 +35,7 @@ export function sanitizeSearchDocsQuery(queryText: string): string | null { return `"${term}"`; }) .filter((term): term is string => Boolean(term)) - .join(' OR '); + .join(' '); return sanitized || null; } @@ -86,6 +91,11 @@ const DOC_ALIASES: Record> = { spreadsheet: 'spreadsheet-overview', 'zoom-slider': 'zoomslider-overview', zoomslider: 'zoomslider-overview', + // Virtualization + virtualization: 'grid-virtualization', + 'virtual-scroll': 'grid-virtualization', + 'virtual-scrolling': 'grid-virtualization', + 'grid-performance': 'grid-virtualization', // Non-obvious renames treemap: 'treemap-chart', 'radio-group': 'radio', @@ -110,6 +120,20 @@ const DOC_ALIASES: Record> = { spreadsheet: 'spreadsheet-overview', 'zoom-slider': 'zoomslider-overview', zoomslider: 'zoomslider-overview', + // Virtualization — Angular uses different naming convention (hierarchicalgrid-, treegrid-) + virtualization: 'grid-virtualization', + 'virtual-scroll': 'grid-virtualization', + 'virtual-scrolling': 'grid-virtualization', + 'grid-performance': 'grid-virtualization', + 'hierarchical-grid-virtualization': 'hierarchicalgrid-virtualization', + 'tree-grid-virtualization': 'treegrid-virtualization', + // igxFor / virtual-for directive + 'for-of': 'for-of', + igxfor: 'for-of', + igxforof: 'for-of', + forof: 'for-of', + 'virtual-for': 'for-of', + 'igx-for-of': 'for-of', // Non-obvious renames treemap: 'types-treemap-chart', 'radio-group': 'radio-button', @@ -131,6 +155,11 @@ const DOC_ALIASES: Record> = { spreadsheet: 'spreadsheet-overview', 'zoom-slider': 'zoomslider-overview', zoomslider: 'zoomslider-overview', + // Virtualization + virtualization: 'grid-virtualization', + 'virtual-scroll': 'grid-virtualization', + 'virtual-scrolling': 'grid-virtualization', + 'grid-performance': 'grid-virtualization', // Non-obvious renames treemap: 'treemap-chart', 'radio-group': 'radio', @@ -150,6 +179,11 @@ const DOC_ALIASES: Record> = { 'pivot-grid': 'pivot-grid-overview', 'zoom-slider': 'zoomslider-overview', zoomslider: 'zoomslider-overview', + // Virtualization + virtualization: 'grid-virtualization', + 'virtual-scroll': 'grid-virtualization', + 'virtual-scrolling': 'grid-virtualization', + 'grid-performance': 'grid-virtualization', // Non-obvious renames treemap: 'treemap-chart', 'radio-group': 'radio', diff --git a/spec/unit/mcp-runtime-spec.ts b/spec/unit/mcp-runtime-spec.ts index cc092870b..3f924e50c 100644 --- a/spec/unit/mcp-runtime-spec.ts +++ b/spec/unit/mcp-runtime-spec.ts @@ -458,7 +458,7 @@ describe("Unit - MCP runtime", () => { it("sanitizes search_docs queries while preserving hyphens and valid prefix operators", () => { const result = sanitizeSearchDocsQuery('grid-editing @"row" tree*'); - expect(result).toBe('"grid-editing" OR "row" OR tree*'); + expect(result).toBe('"grid-editing" "row" tree*'); }); it("drops invalid all-asterisk terms and returns null when no search terms remain", () => { @@ -471,12 +471,12 @@ describe("Unit - MCP runtime", () => { expect(sanitizeSearchDocsQuery("")).toBeNull(); }); - it("wraps plain space-separated terms in quotes joined by OR", () => { + it("wraps plain space-separated terms in quotes joined by AND (implicit in FTS4)", () => { const result = sanitizeSearchDocsQuery("column pinning"); expect(result).toContain('"column"'); expect(result).toContain('"pinning"'); - expect(result).toContain("OR"); + expect(result).toBe('"column" "pinning"'); }); it("returns the framework prompt when no project setup framework is provided", async () => {