Skip to content

Commit d6628bb

Browse files
committed
feat(search): provide suggestions for unknown libraries
Introduces fuzzy search using `fuse.js` to suggest similar library names when a requested library is not found during a search operation. This improves the user experience by guiding users towards potentially correct library names instead of just returning an empty result or a generic error. The `DocumentManagementService` now includes a `validateLibraryExists` method that performs this check and suggestion generation. The `SearchTool` utilizes this method before attempting to find versions or perform the actual search. Fixes #12
1 parent 759b915 commit d6628bb

File tree

7 files changed

+248
-13
lines changed

7 files changed

+248
-13
lines changed

package-lock.json

Lines changed: 21 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"dompurify": "^3.2.4",
4545
"dotenv": "^16.4.7",
4646
"drizzle-orm": "^0.41.0",
47+
"fuse.js": "^7.1.0",
4748
"jsdom": "^26.0.0",
4849
"langchain": "0.3.19",
4950
"pg": "^8.14.0",
@@ -65,6 +66,7 @@
6566
"@semantic-release/npm": "^12.0.1",
6667
"@types/better-sqlite3": "^7.6.12",
6768
"@types/jsdom": "~21.1.7",
69+
"@types/lint-staged": "~13.3.0",
6870
"@types/node": "^20.17.23",
6971
"@types/pg": "~8.11.11",
7072
"@types/semver": "^7.5.8",

src/store/DocumentManagementService.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Document } from "@langchain/core/documents";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3-
import { VersionNotFoundError } from "../tools/errors";
3+
import { LibraryNotFoundError, VersionNotFoundError } from "../tools/errors";
44
import { DocumentManagementService } from "./DocumentManagementService";
55
import { StoreError } from "./errors";
66

@@ -413,4 +413,98 @@ describe("DocumentManagementService", () => {
413413
expect(mockRetriever.search).toHaveBeenCalledWith(library, "", query, 10);
414414
});
415415
});
416+
417+
describe("validateLibraryExists", () => {
418+
const library = "test-lib";
419+
const existingLibraries = [
420+
{ library: "test-lib", versions: [{ version: "1.0.0", indexed: true }] },
421+
{ library: "another-lib", versions: [{ version: "2.0.0", indexed: true }] },
422+
{ library: "react", versions: [] },
423+
];
424+
425+
it("should resolve successfully if versioned documents exist", async () => {
426+
mockStore.queryUniqueVersions.mockResolvedValue(["1.0.0"]); // Has versioned docs
427+
mockStore.checkDocumentExists.mockResolvedValue(false); // No unversioned docs
428+
429+
await expect(docService.validateLibraryExists(library)).resolves.toBeUndefined();
430+
expect(mockStore.queryUniqueVersions).toHaveBeenCalledWith(library.toLowerCase());
431+
expect(mockStore.checkDocumentExists).toHaveBeenCalledWith(
432+
library.toLowerCase(),
433+
"",
434+
);
435+
});
436+
437+
it("should resolve successfully if only unversioned documents exist", async () => {
438+
mockStore.queryUniqueVersions.mockResolvedValue([]); // No versioned docs
439+
mockStore.checkDocumentExists.mockResolvedValue(true); // Has unversioned docs
440+
441+
await expect(docService.validateLibraryExists(library)).resolves.toBeUndefined();
442+
expect(mockStore.queryUniqueVersions).toHaveBeenCalledWith(library.toLowerCase());
443+
expect(mockStore.checkDocumentExists).toHaveBeenCalledWith(
444+
library.toLowerCase(),
445+
"",
446+
);
447+
});
448+
449+
it("should throw LibraryNotFoundError if library does not exist (no suggestions)", async () => {
450+
const nonExistentLibrary = "non-existent-lib";
451+
mockStore.queryUniqueVersions.mockResolvedValue([]);
452+
mockStore.checkDocumentExists.mockResolvedValue(false);
453+
mockStore.queryLibraryVersions.mockResolvedValue(new Map()); // No libraries exist at all
454+
455+
await expect(docService.validateLibraryExists(nonExistentLibrary)).rejects.toThrow(
456+
LibraryNotFoundError,
457+
);
458+
459+
const error = await docService
460+
.validateLibraryExists(nonExistentLibrary)
461+
.catch((e) => e);
462+
expect(error).toBeInstanceOf(LibraryNotFoundError);
463+
expect(error.requestedLibrary).toBe(nonExistentLibrary);
464+
expect(error.suggestions).toEqual([]);
465+
expect(mockStore.queryLibraryVersions).toHaveBeenCalled(); // Ensure it tried to get suggestions
466+
});
467+
468+
it("should throw LibraryNotFoundError with suggestions if library does not exist", async () => {
469+
const misspelledLibrary = "reac"; // Misspelled 'react'
470+
mockStore.queryUniqueVersions.mockResolvedValue([]);
471+
mockStore.checkDocumentExists.mockResolvedValue(false);
472+
// Mock listLibraries to return existing libraries
473+
const mockLibraryMap = new Map(
474+
existingLibraries.map((l) => [
475+
l.library,
476+
new Set(l.versions.map((v) => v.version)),
477+
]),
478+
);
479+
mockStore.queryLibraryVersions.mockResolvedValue(mockLibraryMap);
480+
481+
await expect(docService.validateLibraryExists(misspelledLibrary)).rejects.toThrow(
482+
LibraryNotFoundError,
483+
);
484+
485+
const error = await docService
486+
.validateLibraryExists(misspelledLibrary)
487+
.catch((e) => e);
488+
expect(error).toBeInstanceOf(LibraryNotFoundError);
489+
expect(error.requestedLibrary).toBe(misspelledLibrary);
490+
expect(error.suggestions).toEqual(["react"]); // Expect 'react' as suggestion
491+
expect(mockStore.queryLibraryVersions).toHaveBeenCalled();
492+
});
493+
494+
it("should handle case insensitivity", async () => {
495+
const libraryUpper = "TEST-LIB";
496+
mockStore.queryUniqueVersions.mockResolvedValue(["1.0.0"]); // Mock store uses lowercase
497+
mockStore.checkDocumentExists.mockResolvedValue(false);
498+
499+
// Should still resolve because the service normalizes the input
500+
await expect(
501+
docService.validateLibraryExists(libraryUpper),
502+
).resolves.toBeUndefined();
503+
expect(mockStore.queryUniqueVersions).toHaveBeenCalledWith(library.toLowerCase());
504+
expect(mockStore.checkDocumentExists).toHaveBeenCalledWith(
505+
library.toLowerCase(),
506+
"",
507+
);
508+
});
509+
});
416510
});

src/store/DocumentManagementService.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { mkdirSync } from "node:fs";
22
import path from "node:path";
33
import type { Document } from "@langchain/core/documents";
4+
import Fuse from "fuse.js";
45
import semver from "semver";
56
import { GreedySplitter, SemanticMarkdownSplitter } from "../splitter";
67
import type { ContentChunk, DocumentSplitter } from "../splitter/types";
7-
import { VersionNotFoundError } from "../tools";
8+
import { LibraryNotFoundError, VersionNotFoundError } from "../tools";
89
import { logger } from "../utils/logger";
910
import { DocumentRetrieverService } from "./DocumentRetrieverService";
1011
import { DocumentStore } from "./DocumentStore";
@@ -46,18 +47,65 @@ export class DocumentManagementService {
4647
this.splitter = greedySplitter;
4748
}
4849

50+
/**
51+
* Initializes the underlying document store.
52+
*/
4953
async initialize(): Promise<void> {
5054
await this.store.initialize();
5155
}
5256

57+
/**
58+
* Shuts down the underlying document store.
59+
*/
60+
5361
async shutdown(): Promise<void> {
5462
logger.info("🔌 Shutting down store manager");
5563
await this.store.shutdown();
5664
}
5765

5866
/**
59-
* Returns a list of all available versions for a library.
60-
* Only returns versions that follow semver format.
67+
* Validates if a library exists in the store (either versioned or unversioned).
68+
* Throws LibraryNotFoundError with suggestions if the library is not found.
69+
* @param library The name of the library to validate.
70+
* @throws {LibraryNotFoundError} If the library does not exist.
71+
*/
72+
async validateLibraryExists(library: string): Promise<void> {
73+
logger.info(`🔎 Validating existence of library: ${library}`);
74+
const normalizedLibrary = library.toLowerCase(); // Ensure consistent casing
75+
76+
// Check for both versioned and unversioned documents
77+
const versions = await this.listVersions(normalizedLibrary);
78+
const hasUnversioned = await this.exists(normalizedLibrary, ""); // Check explicitly for unversioned
79+
80+
if (versions.length === 0 && !hasUnversioned) {
81+
logger.warn(`⚠️ Library '${library}' not found.`);
82+
83+
// Library doesn't exist, fetch all libraries to provide suggestions
84+
const allLibraries = await this.listLibraries();
85+
const libraryNames = allLibraries.map((lib) => lib.library);
86+
87+
let suggestions: string[] = [];
88+
if (libraryNames.length > 0) {
89+
const fuse = new Fuse(libraryNames, {
90+
// Configure fuse.js options if needed (e.g., threshold)
91+
// isCaseSensitive: false, // Handled by normalizing library names
92+
// includeScore: true,
93+
threshold: 0.4, // Adjust threshold for desired fuzziness (0=exact, 1=match anything)
94+
});
95+
const results = fuse.search(normalizedLibrary);
96+
// Take top 3 suggestions
97+
suggestions = results.slice(0, 3).map((result) => result.item);
98+
logger.info(`🔍 Found suggestions: ${suggestions.join(", ")}`);
99+
}
100+
101+
throw new LibraryNotFoundError(library, suggestions);
102+
}
103+
104+
logger.info(`✅ Library '${library}' confirmed to exist.`);
105+
}
106+
107+
/**
108+
* Returns a list of all available semantic versions for a library.
61109
*/
62110
async listVersions(library: string): Promise<LibraryVersion[]> {
63111
const versions = await this.store.queryUniqueVersions(library);

src/tools/SearchTool.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { DocumentManagementService } from "../store";
33
import type { StoreSearchResult } from "../store/types";
44
import { logger } from "../utils/logger";
55
import { SearchTool, type SearchToolOptions } from "./SearchTool";
6-
import { VersionNotFoundError } from "./errors";
6+
import { LibraryNotFoundError, VersionNotFoundError } from "./errors";
77

88
// Mock dependencies
99
vi.mock("../store");
@@ -17,6 +17,7 @@ describe("SearchTool", () => {
1717
vi.resetAllMocks();
1818

1919
mockDocService = {
20+
validateLibraryExists: vi.fn(),
2021
findBestVersion: vi.fn(),
2122
searchStore: vi.fn(),
2223
};
@@ -169,6 +170,41 @@ describe("SearchTool", () => {
169170
);
170171
});
171172

173+
it("should return error structure with suggestions when LibraryNotFoundError occurs", async () => {
174+
const options: SearchToolOptions = { ...baseOptions };
175+
const suggestions = ["test-lib-correct", "another-test-lib"];
176+
const error = new LibraryNotFoundError("test-lib", suggestions);
177+
(mockDocService.validateLibraryExists as Mock).mockRejectedValue(error);
178+
179+
const result = await searchTool.execute(options);
180+
181+
expect(mockDocService.validateLibraryExists).toHaveBeenCalledWith("test-lib");
182+
expect(mockDocService.findBestVersion).not.toHaveBeenCalled();
183+
expect(mockDocService.searchStore).not.toHaveBeenCalled();
184+
expect(result.results).toEqual([]);
185+
expect(result.error).toBeDefined();
186+
expect(result.error?.message).toContain("Library 'test-lib' not found.");
187+
expect(result.error?.suggestions).toEqual(suggestions);
188+
expect(result.error?.availableVersions).toBeUndefined(); // Ensure version info isn't present
189+
expect(logger.info).toHaveBeenCalledWith(
190+
// Changed from warn to info to match implementation
191+
expect.stringContaining("Library not found"),
192+
);
193+
});
194+
195+
it("should re-throw unexpected errors from validateLibraryExists", async () => {
196+
const options: SearchToolOptions = { ...baseOptions };
197+
const unexpectedError = new Error("Validation DB connection failed");
198+
(mockDocService.validateLibraryExists as Mock).mockRejectedValue(unexpectedError);
199+
200+
await expect(searchTool.execute(options)).rejects.toThrow(
201+
"Validation DB connection failed",
202+
);
203+
expect(logger.error).toHaveBeenCalledWith(
204+
expect.stringContaining("Search failed: Validation DB connection failed"),
205+
);
206+
});
207+
172208
it("should re-throw unexpected errors from searchStore", async () => {
173209
const options: SearchToolOptions = {
174210
...baseOptions,

src/tools/SearchTool.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { DocumentManagementService } from "../store";
22
import type { StoreSearchResult } from "../store/types";
33
import { logger } from "../utils/logger";
4-
import { VersionNotFoundError } from "./errors";
4+
import { LibraryNotFoundError, VersionNotFoundError } from "./errors";
55

66
export interface SearchToolOptions {
77
library: string;
@@ -11,12 +11,15 @@ export interface SearchToolOptions {
1111
exactMatch?: boolean;
1212
}
1313

14+
export interface SearchToolResultError {
15+
message: string;
16+
availableVersions?: Array<{ version: string; indexed: boolean }>; // Specific to VersionNotFoundError
17+
suggestions?: string[]; // Specific to LibraryNotFoundError
18+
}
19+
1420
export interface SearchToolResult {
1521
results: StoreSearchResult[];
16-
error?: {
17-
message: string;
18-
availableVersions: Array<{ version: string; indexed: boolean }>;
19-
};
22+
error?: SearchToolResultError;
2023
}
2124

2225
/**
@@ -39,6 +42,10 @@ export class SearchTool {
3942
);
4043

4144
try {
45+
// 1. Validate library exists first
46+
await this.docService.validateLibraryExists(library);
47+
48+
// 2. Proceed with version finding and searching
4249
let versionToSearch: string | null | undefined = version;
4350

4451
if (!exactMatch) {
@@ -66,6 +73,16 @@ export class SearchTool {
6673

6774
return { results };
6875
} catch (error) {
76+
if (error instanceof LibraryNotFoundError) {
77+
logger.info(`ℹ️ Library not found: ${error.message}`);
78+
return {
79+
results: [],
80+
error: {
81+
message: error.message,
82+
suggestions: error.suggestions,
83+
},
84+
};
85+
}
6986
if (error instanceof VersionNotFoundError) {
7087
logger.info(`ℹ️ Version not found: ${error.message}`);
7188
return {

0 commit comments

Comments
 (0)