@afterxleep/doc-bot 1.10.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -321
- package/bin/doc-bot.js +2 -8
- package/package.json +7 -11
- package/src/__tests__/docset-integration.test.js +4 -4
- package/src/__tests__/temp-docs-1752689978225/test.md +5 -0
- package/src/__tests__/temp-docs-1752689978235/test.md +5 -0
- package/src/__tests__/temp-docs-1752689978241/test.md +5 -0
- package/src/__tests__/temp-docs-1752689978243/test.md +5 -0
- package/src/__tests__/temp-docs-1752689978244/test.md +5 -0
- package/src/__tests__/temp-docsets-1752689978244/7e2cbc65/Mock.docset/Contents/Info.plist +10 -0
- package/src/__tests__/temp-docsets-1752689978244/7e2cbc65/Mock.docset/Contents/Resources/docSet.dsidx +0 -0
- package/src/__tests__/temp-docsets-1752689978244/Mock.docset/Contents/Info.plist +10 -0
- package/src/__tests__/temp-docsets-1752689978244/Mock.docset/Contents/Resources/docSet.dsidx +0 -0
- package/src/__tests__/temp-docsets-1752689978244/docsets.json +10 -0
- package/src/index.js +411 -210
- package/src/services/DocumentationService.js +131 -2
- package/src/services/UnifiedSearchService.js +214 -0
- package/src/services/__tests__/DocumentationService.test.js +318 -0
- package/src/services/__tests__/UnifiedSearchService.test.js +302 -0
- package/src/services/docset/ParallelSearchManager.js +158 -0
- package/src/services/docset/__tests__/EnhancedDocsetDatabase.test.js +324 -0
- package/src/services/docset/database.js +271 -0
|
@@ -99,10 +99,17 @@ class DocumentationService {
|
|
|
99
99
|
|
|
100
100
|
for (const doc of this.documents.values()) {
|
|
101
101
|
const score = this.calculateAdvancedRelevanceScore(doc, searchTerms, query);
|
|
102
|
-
|
|
102
|
+
// Only include documents with meaningful relevance (5% or higher)
|
|
103
|
+
// This filters out documents that only have weak partial matches
|
|
104
|
+
if (score >= 5.0) {
|
|
105
|
+
// Extract a relevant snippet from the content
|
|
106
|
+
const snippet = this.extractRelevantSnippet(doc.content, searchTerms, query);
|
|
107
|
+
|
|
103
108
|
results.push({
|
|
104
109
|
...doc,
|
|
105
|
-
relevanceScore: score
|
|
110
|
+
relevanceScore: score,
|
|
111
|
+
snippet: snippet,
|
|
112
|
+
matchedTerms: this.getMatchedTerms(doc, searchTerms)
|
|
106
113
|
});
|
|
107
114
|
}
|
|
108
115
|
}
|
|
@@ -245,6 +252,128 @@ class DocumentationService {
|
|
|
245
252
|
// Legacy method - keep for backward compatibility
|
|
246
253
|
return this.calculateAdvancedRelevanceScore(doc, [searchTerm], searchTerm);
|
|
247
254
|
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Extract a relevant snippet from content that shows context around matched terms
|
|
258
|
+
* @param {string} content - The document content
|
|
259
|
+
* @param {string[]} searchTerms - The search terms
|
|
260
|
+
* @param {string} originalQuery - The original query
|
|
261
|
+
* @returns {string} A relevant snippet
|
|
262
|
+
*/
|
|
263
|
+
extractRelevantSnippet(content, searchTerms, originalQuery) {
|
|
264
|
+
const contentLower = content.toLowerCase();
|
|
265
|
+
const snippetLength = 200;
|
|
266
|
+
let bestSnippet = '';
|
|
267
|
+
let bestScore = 0;
|
|
268
|
+
|
|
269
|
+
// First, try to find exact phrase match
|
|
270
|
+
if (originalQuery.length > 3) {
|
|
271
|
+
const phraseIndex = contentLower.indexOf(originalQuery.toLowerCase());
|
|
272
|
+
if (phraseIndex !== -1) {
|
|
273
|
+
const start = Math.max(0, phraseIndex - 50);
|
|
274
|
+
const end = Math.min(content.length, phraseIndex + originalQuery.length + 150);
|
|
275
|
+
return this.cleanSnippet(content.substring(start, end), start > 0, end < content.length);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Find the best snippet containing the most search terms
|
|
280
|
+
const lines = content.split('\n');
|
|
281
|
+
for (let i = 0; i < lines.length; i++) {
|
|
282
|
+
const line = lines[i];
|
|
283
|
+
const lineLower = line.toLowerCase();
|
|
284
|
+
|
|
285
|
+
// Skip empty lines and frontmatter
|
|
286
|
+
if (!line.trim() || line.startsWith('---')) continue;
|
|
287
|
+
|
|
288
|
+
// Count matching terms in this line and surrounding context
|
|
289
|
+
let score = 0;
|
|
290
|
+
let matchCount = 0;
|
|
291
|
+
|
|
292
|
+
for (const term of searchTerms) {
|
|
293
|
+
if (lineLower.includes(term.toLowerCase())) {
|
|
294
|
+
matchCount++;
|
|
295
|
+
// Higher score for terms in headers
|
|
296
|
+
if (line.startsWith('#')) {
|
|
297
|
+
score += 10;
|
|
298
|
+
} else {
|
|
299
|
+
score += 5;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (matchCount > 0) {
|
|
305
|
+
// Get context around this line
|
|
306
|
+
const contextStart = Math.max(0, i - 1);
|
|
307
|
+
const contextEnd = Math.min(lines.length, i + 3);
|
|
308
|
+
const contextLines = lines.slice(contextStart, contextEnd);
|
|
309
|
+
const snippet = contextLines.join(' ').trim();
|
|
310
|
+
|
|
311
|
+
if (score > bestScore && snippet.length > 20) {
|
|
312
|
+
bestScore = score;
|
|
313
|
+
bestSnippet = snippet;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// If no good snippet found, return the description or first meaningful paragraph
|
|
319
|
+
if (!bestSnippet) {
|
|
320
|
+
const metadata = this.extractMetadata(content);
|
|
321
|
+
if (metadata.description) {
|
|
322
|
+
return metadata.description;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Find first non-empty paragraph after frontmatter
|
|
326
|
+
const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n*/m, '');
|
|
327
|
+
const paragraphs = contentWithoutFrontmatter.split(/\n\n+/);
|
|
328
|
+
for (const para of paragraphs) {
|
|
329
|
+
const cleaned = para.trim();
|
|
330
|
+
if (cleaned && !cleaned.startsWith('#') && cleaned.length > 30) {
|
|
331
|
+
return this.cleanSnippet(cleaned.substring(0, snippetLength), false, cleaned.length > snippetLength);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return this.cleanSnippet(bestSnippet.substring(0, snippetLength), false, bestSnippet.length > snippetLength);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Clean and format a snippet for display
|
|
341
|
+
*/
|
|
342
|
+
cleanSnippet(snippet, hasStart, hasEnd) {
|
|
343
|
+
// Remove multiple spaces and clean up
|
|
344
|
+
let cleaned = snippet.replace(/\s+/g, ' ').trim();
|
|
345
|
+
|
|
346
|
+
// Remove markdown formatting for readability
|
|
347
|
+
cleaned = cleaned.replace(/\*\*/g, '');
|
|
348
|
+
cleaned = cleaned.replace(/`/g, '');
|
|
349
|
+
|
|
350
|
+
// Add ellipsis if truncated
|
|
351
|
+
if (hasStart) cleaned = '...' + cleaned;
|
|
352
|
+
if (hasEnd) cleaned = cleaned + '...';
|
|
353
|
+
|
|
354
|
+
return cleaned;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Get the terms that matched in this document
|
|
359
|
+
*/
|
|
360
|
+
getMatchedTerms(doc, searchTerms) {
|
|
361
|
+
const matched = [];
|
|
362
|
+
const contentLower = doc.content.toLowerCase();
|
|
363
|
+
const titleLower = (doc.metadata?.title || doc.fileName).toLowerCase();
|
|
364
|
+
const descriptionLower = (doc.metadata?.description || '').toLowerCase();
|
|
365
|
+
|
|
366
|
+
for (const term of searchTerms) {
|
|
367
|
+
const termLower = term.toLowerCase();
|
|
368
|
+
if (titleLower.includes(termLower) ||
|
|
369
|
+
descriptionLower.includes(termLower) ||
|
|
370
|
+
contentLower.includes(termLower)) {
|
|
371
|
+
matched.push(term);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return matched;
|
|
376
|
+
}
|
|
248
377
|
|
|
249
378
|
async getGlobalRules() {
|
|
250
379
|
const globalRules = [];
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { DocumentationService } from './DocumentationService.js';
|
|
2
|
+
import { MultiDocsetDatabase } from './docset/database.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UnifiedSearchService provides a single search interface that searches
|
|
6
|
+
* both local project documentation and official API documentation (docsets)
|
|
7
|
+
* with intelligent query parsing and relevance scoring.
|
|
8
|
+
*/
|
|
9
|
+
export class UnifiedSearchService {
|
|
10
|
+
constructor(documentationService, multiDocsetDatabase) {
|
|
11
|
+
this.documentationService = documentationService;
|
|
12
|
+
this.multiDocsetDatabase = multiDocsetDatabase;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse query into individual search terms, removing stop words
|
|
17
|
+
* @param {string} query - The search query
|
|
18
|
+
* @returns {string[]} Array of search terms
|
|
19
|
+
*/
|
|
20
|
+
parseQuery(query) {
|
|
21
|
+
const stopWords = new Set([
|
|
22
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to',
|
|
23
|
+
'for', 'of', 'with', 'by', 'how', 'what', 'where', 'when', 'is',
|
|
24
|
+
'are', 'was', 'were', 'been', 'being', 'have', 'has', 'had',
|
|
25
|
+
'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may',
|
|
26
|
+
'might', 'can', 'this', 'that', 'these', 'those'
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
return query.toLowerCase()
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.map(term => term.replace(/[^a-z0-9-_.]/g, '')) // Keep alphanumeric, dash, underscore, dot
|
|
32
|
+
.filter(term => term.length > 1 && !stopWords.has(term));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Search both local documentation and docsets with a unified query
|
|
37
|
+
* @param {string} query - The search query
|
|
38
|
+
* @param {Object} options - Search options
|
|
39
|
+
* @param {number} options.limit - Maximum number of results (default: 20)
|
|
40
|
+
* @param {string} options.docsetId - Limit to specific docset
|
|
41
|
+
* @param {string} options.type - Filter docset results by type
|
|
42
|
+
* @returns {Promise<Array>} Combined search results sorted by relevance
|
|
43
|
+
*/
|
|
44
|
+
async search(query, options = {}) {
|
|
45
|
+
const { limit = 20, docsetId, type } = options;
|
|
46
|
+
|
|
47
|
+
if (!query || query.trim() === '') {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Parse query into search terms
|
|
52
|
+
const searchTerms = this.parseQuery(query);
|
|
53
|
+
if (searchTerms.length === 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Perform searches in parallel
|
|
58
|
+
const [localResults, docsetResults] = await Promise.all([
|
|
59
|
+
// Search local documentation (unless searching specific docset)
|
|
60
|
+
docsetId ? [] : this.searchLocalDocs(query, searchTerms, Math.ceil(limit / 2)),
|
|
61
|
+
// Search docsets
|
|
62
|
+
this.searchDocsets(searchTerms, { type, docsetId, limit: Math.ceil(limit / 2) })
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// Combine and normalize results
|
|
66
|
+
const combinedResults = [
|
|
67
|
+
...this.normalizeLocalResults(localResults),
|
|
68
|
+
...this.normalizeDocsetResults(docsetResults)
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// Apply source-based score boosting
|
|
72
|
+
const boostedResults = combinedResults.map(result => {
|
|
73
|
+
// Boost project documentation scores to prioritize them
|
|
74
|
+
if (result.type === 'local') {
|
|
75
|
+
// Multiply project doc scores by 5 to ensure they rank higher
|
|
76
|
+
// This ensures even moderately relevant project docs appear before API docs
|
|
77
|
+
result.relevanceScore = result.relevanceScore * 5;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Sort by relevance score
|
|
83
|
+
const sortedResults = boostedResults
|
|
84
|
+
.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
|
85
|
+
|
|
86
|
+
// Apply quality filtering
|
|
87
|
+
// If we have high-quality results (score > 50), filter out low-quality ones
|
|
88
|
+
const highQualityResults = sortedResults.filter(r => r.relevanceScore >= 50);
|
|
89
|
+
|
|
90
|
+
if (highQualityResults.length >= 5) {
|
|
91
|
+
// We have enough high-quality results, use only those
|
|
92
|
+
return highQualityResults.slice(0, limit);
|
|
93
|
+
} else if (sortedResults.length > 0) {
|
|
94
|
+
// Include medium quality results, but filter out very low relevance
|
|
95
|
+
const minScore = Math.max(sortedResults[0].relevanceScore * 0.1, 10);
|
|
96
|
+
const qualityResults = sortedResults.filter(r => r.relevanceScore >= minScore);
|
|
97
|
+
return qualityResults.slice(0, limit);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Search local project documentation
|
|
105
|
+
*/
|
|
106
|
+
async searchLocalDocs(query, searchTerms, limit) {
|
|
107
|
+
try {
|
|
108
|
+
// Use existing DocumentationService search which already has good relevance scoring
|
|
109
|
+
const results = await this.documentationService.searchDocuments(query);
|
|
110
|
+
return results.slice(0, limit);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error('Error searching local docs:', error);
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Search docsets using term-based search
|
|
119
|
+
*/
|
|
120
|
+
searchDocsets(searchTerms, options) {
|
|
121
|
+
try {
|
|
122
|
+
// Use the new term-based search method
|
|
123
|
+
return this.multiDocsetDatabase.searchWithTerms(searchTerms, options);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('Error searching docsets:', error);
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Normalize local documentation results to unified format
|
|
132
|
+
*/
|
|
133
|
+
normalizeLocalResults(results) {
|
|
134
|
+
return results.map(doc => ({
|
|
135
|
+
id: doc.fileName,
|
|
136
|
+
title: doc.metadata?.title || doc.fileName,
|
|
137
|
+
description: doc.metadata?.description || doc.snippet || '',
|
|
138
|
+
type: 'local',
|
|
139
|
+
source: 'project',
|
|
140
|
+
path: doc.fileName,
|
|
141
|
+
url: doc.fileName,
|
|
142
|
+
relevanceScore: doc.relevanceScore || 0,
|
|
143
|
+
metadata: doc.metadata,
|
|
144
|
+
content: doc.content,
|
|
145
|
+
snippet: doc.snippet,
|
|
146
|
+
matchedTerms: doc.matchedTerms || []
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Normalize docset results to unified format
|
|
152
|
+
*/
|
|
153
|
+
normalizeDocsetResults(results) {
|
|
154
|
+
// First normalize all results
|
|
155
|
+
const normalized = results.map(doc => ({
|
|
156
|
+
id: `${doc.docsetId}:${doc.name}`,
|
|
157
|
+
title: doc.name,
|
|
158
|
+
description: `${doc.type} in ${doc.docsetName}`,
|
|
159
|
+
type: 'docset',
|
|
160
|
+
source: doc.docsetName,
|
|
161
|
+
path: doc.path || doc.url,
|
|
162
|
+
url: doc.url,
|
|
163
|
+
relevanceScore: doc.relevanceScore || 0,
|
|
164
|
+
docsetId: doc.docsetId,
|
|
165
|
+
docsetName: doc.docsetName,
|
|
166
|
+
entryType: doc.type
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
// Deduplicate by name + type, preferring Swift entries
|
|
170
|
+
const dedupMap = new Map();
|
|
171
|
+
|
|
172
|
+
for (const doc of normalized) {
|
|
173
|
+
const key = `${doc.title}:${doc.entryType}`;
|
|
174
|
+
const existing = dedupMap.get(key);
|
|
175
|
+
|
|
176
|
+
if (!existing) {
|
|
177
|
+
dedupMap.set(key, doc);
|
|
178
|
+
} else {
|
|
179
|
+
// Prefer Swift entries (they have 'language=swift' in the URL)
|
|
180
|
+
const isSwift = doc.url && doc.url.includes('language=swift');
|
|
181
|
+
const existingIsSwift = existing.url && existing.url.includes('language=swift');
|
|
182
|
+
|
|
183
|
+
if (isSwift && !existingIsSwift) {
|
|
184
|
+
dedupMap.set(key, doc);
|
|
185
|
+
} else if (!isSwift && !existingIsSwift && doc.relevanceScore > existing.relevanceScore) {
|
|
186
|
+
// If neither is Swift, keep the one with higher score
|
|
187
|
+
dedupMap.set(key, doc);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return Array.from(dedupMap.values());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get a summary of available documentation sources
|
|
197
|
+
*/
|
|
198
|
+
async getSources() {
|
|
199
|
+
const localDocs = this.documentationService.documents.size;
|
|
200
|
+
const docsets = this.multiDocsetDatabase.databases.size;
|
|
201
|
+
const docsetStats = this.multiDocsetDatabase.getStats();
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
local: {
|
|
205
|
+
documentCount: localDocs,
|
|
206
|
+
indexed: localDocs > 0
|
|
207
|
+
},
|
|
208
|
+
docsets: {
|
|
209
|
+
count: docsets,
|
|
210
|
+
details: docsetStats
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { DocumentationService } from '../DocumentationService.js';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname } from 'path';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
10
|
+
describe('DocumentationService', () => {
|
|
11
|
+
let docService;
|
|
12
|
+
let tempDocsPath;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tempDocsPath = path.join(__dirname, 'temp-docs-' + Date.now());
|
|
16
|
+
await fs.ensureDir(tempDocsPath);
|
|
17
|
+
docService = new DocumentationService(tempDocsPath);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fs.remove(tempDocsPath);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('extractRelevantSnippet', () => {
|
|
25
|
+
it('should extract snippet around exact phrase match', () => {
|
|
26
|
+
const content = `This is some content before the match.
|
|
27
|
+
Here we discuss how to use AlarmKit Framework effectively.
|
|
28
|
+
And this is content after the match.`;
|
|
29
|
+
|
|
30
|
+
const snippet = docService.extractRelevantSnippet(
|
|
31
|
+
content,
|
|
32
|
+
['use', 'alarmkit', 'framework'],
|
|
33
|
+
'use AlarmKit Framework'
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(snippet).toContain('use AlarmKit Framework');
|
|
37
|
+
expect(snippet.length).toBeLessThanOrEqual(250);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should find best snippet with most matching terms', () => {
|
|
41
|
+
const content = `# Introduction
|
|
42
|
+
This document covers various topics.
|
|
43
|
+
|
|
44
|
+
# URLSession Configuration
|
|
45
|
+
Learn how to configure URLSession properly.
|
|
46
|
+
|
|
47
|
+
# Advanced Usage
|
|
48
|
+
Here we discuss URLSession and configuration together.
|
|
49
|
+
URLSession provides many configuration options.`;
|
|
50
|
+
|
|
51
|
+
const snippet = docService.extractRelevantSnippet(
|
|
52
|
+
content,
|
|
53
|
+
['urlsession', 'configuration'],
|
|
54
|
+
'URLSession configuration'
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(snippet).toContain('URLSession');
|
|
58
|
+
expect(snippet).toContain('configuration');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should prioritize headers containing search terms', () => {
|
|
62
|
+
const content = `# Random Header
|
|
63
|
+
Some content here.
|
|
64
|
+
|
|
65
|
+
# AlarmKit Integration
|
|
66
|
+
This is the section about integration.
|
|
67
|
+
|
|
68
|
+
Some other content mentioning AlarmKit.`;
|
|
69
|
+
|
|
70
|
+
const snippet = docService.extractRelevantSnippet(
|
|
71
|
+
content,
|
|
72
|
+
['alarmkit', 'integration'],
|
|
73
|
+
'AlarmKit integration'
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(snippet).toContain('# AlarmKit Integration');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should return description from metadata if no good snippet found', () => {
|
|
80
|
+
const content = `---
|
|
81
|
+
title: Test Document
|
|
82
|
+
description: This is a comprehensive guide to using the API
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
Some unrelated content here.`;
|
|
86
|
+
|
|
87
|
+
const snippet = docService.extractRelevantSnippet(
|
|
88
|
+
content,
|
|
89
|
+
['nonexistent', 'terms'],
|
|
90
|
+
'nonexistent terms'
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(snippet).toBe('This is a comprehensive guide to using the API');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should clean and format snippets properly', () => {
|
|
97
|
+
const content = `This is **bold** text with \`code\` and multiple spaces.`;
|
|
98
|
+
|
|
99
|
+
const snippet = docService.extractRelevantSnippet(
|
|
100
|
+
content,
|
|
101
|
+
['bold', 'text'],
|
|
102
|
+
'bold text'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(snippet).not.toContain('**');
|
|
106
|
+
expect(snippet).not.toContain('`');
|
|
107
|
+
expect(snippet).not.toMatch(/\s{2,}/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('getMatchedTerms', () => {
|
|
112
|
+
it('should return terms that match in document', () => {
|
|
113
|
+
const doc = {
|
|
114
|
+
content: 'Learn about URLSession and networking in Swift',
|
|
115
|
+
metadata: {
|
|
116
|
+
title: 'Swift Networking Guide',
|
|
117
|
+
description: 'A guide to URLSession'
|
|
118
|
+
},
|
|
119
|
+
fileName: 'networking.md'
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const matched = docService.getMatchedTerms(doc, ['urlsession', 'swift', 'api']);
|
|
123
|
+
|
|
124
|
+
expect(matched).toContain('urlsession');
|
|
125
|
+
expect(matched).toContain('swift');
|
|
126
|
+
expect(matched).not.toContain('api');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should match terms in title and description', () => {
|
|
130
|
+
const doc = {
|
|
131
|
+
content: 'Some content',
|
|
132
|
+
metadata: {
|
|
133
|
+
title: 'AlarmKit Framework',
|
|
134
|
+
description: 'Learn to use AlarmKit'
|
|
135
|
+
},
|
|
136
|
+
fileName: 'guide.md'
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const matched = docService.getMatchedTerms(doc, ['alarmkit', 'framework', 'use']);
|
|
140
|
+
|
|
141
|
+
expect(matched).toEqual(['alarmkit', 'framework', 'use']);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('searchDocuments with enhanced features', () => {
|
|
146
|
+
beforeEach(async () => {
|
|
147
|
+
// Create test documents
|
|
148
|
+
await fs.writeFile(
|
|
149
|
+
path.join(tempDocsPath, 'high-relevance.md'),
|
|
150
|
+
`---
|
|
151
|
+
title: AlarmKit Framework Guide
|
|
152
|
+
description: Complete guide to using AlarmKit Framework
|
|
153
|
+
keywords: [alarmkit, framework, ios]
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
# AlarmKit Framework
|
|
157
|
+
|
|
158
|
+
Learn how to use AlarmKit Framework effectively.
|
|
159
|
+
AlarmKit provides powerful alarm functionality.`
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
await fs.writeFile(
|
|
163
|
+
path.join(tempDocsPath, 'medium-relevance.md'),
|
|
164
|
+
`---
|
|
165
|
+
title: iOS Development
|
|
166
|
+
description: General iOS development guide
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
# iOS Development
|
|
170
|
+
|
|
171
|
+
This guide covers various frameworks including AlarmKit.`
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await fs.writeFile(
|
|
175
|
+
path.join(tempDocsPath, 'low-relevance.md'),
|
|
176
|
+
`---
|
|
177
|
+
title: Random Document
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
# Random Content
|
|
181
|
+
|
|
182
|
+
This has nothing to do with alarms or kits.`
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
await docService.initialize();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should return results with snippets and matched terms', async () => {
|
|
189
|
+
const results = await docService.searchDocuments('AlarmKit Framework');
|
|
190
|
+
|
|
191
|
+
expect(results.length).toBeGreaterThan(0);
|
|
192
|
+
expect(results[0].snippet).toBeDefined();
|
|
193
|
+
expect(results[0].matchedTerms).toBeDefined();
|
|
194
|
+
expect(results[0].matchedTerms).toContain('alarmkit');
|
|
195
|
+
expect(results[0].matchedTerms).toContain('framework');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should filter out low relevance results', async () => {
|
|
199
|
+
const results = await docService.searchDocuments('AlarmKit Framework');
|
|
200
|
+
|
|
201
|
+
// Should not include the "Random Document" with no relevant content
|
|
202
|
+
const hasLowRelevance = results.some(r => r.metadata?.title === 'Random Document');
|
|
203
|
+
expect(hasLowRelevance).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should prioritize exact phrase matches', async () => {
|
|
207
|
+
const results = await docService.searchDocuments('AlarmKit Framework');
|
|
208
|
+
|
|
209
|
+
expect(results[0].metadata?.title).toBe('AlarmKit Framework Guide');
|
|
210
|
+
expect(results[0].relevanceScore).toBeGreaterThan(50);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should boost keyword matches', async () => {
|
|
214
|
+
const results = await docService.searchDocuments('alarmkit');
|
|
215
|
+
|
|
216
|
+
// Document with alarmkit in keywords should score higher
|
|
217
|
+
expect(results[0].metadata?.keywords).toContain('alarmkit');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('calculateAdvancedRelevanceScore', () => {
|
|
222
|
+
it('should give high score for exact phrase match', () => {
|
|
223
|
+
const doc = {
|
|
224
|
+
content: 'Learn how to use AlarmKit Framework in your iOS app',
|
|
225
|
+
metadata: { title: 'iOS Guide' },
|
|
226
|
+
fileName: 'guide.md'
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const score = docService.calculateAdvancedRelevanceScore(
|
|
230
|
+
doc,
|
|
231
|
+
['alarmkit', 'framework'],
|
|
232
|
+
'AlarmKit Framework'
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(score).toBeGreaterThan(20); // Exact phrase bonus
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should boost matches in title', () => {
|
|
239
|
+
const doc1 = {
|
|
240
|
+
content: 'Some content about URLSession',
|
|
241
|
+
metadata: { title: 'URLSession Guide' },
|
|
242
|
+
fileName: 'guide1.md'
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const doc2 = {
|
|
246
|
+
content: 'URLSession is mentioned here',
|
|
247
|
+
metadata: { title: 'Random Guide' },
|
|
248
|
+
fileName: 'guide2.md'
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const score1 = docService.calculateAdvancedRelevanceScore(
|
|
252
|
+
doc1,
|
|
253
|
+
['urlsession'],
|
|
254
|
+
'URLSession'
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const score2 = docService.calculateAdvancedRelevanceScore(
|
|
258
|
+
doc2,
|
|
259
|
+
['urlsession'],
|
|
260
|
+
'URLSession'
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
expect(score1).toBeGreaterThan(score2);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should apply term coverage bonus', () => {
|
|
267
|
+
const doc = {
|
|
268
|
+
content: 'URLSession configuration and usage',
|
|
269
|
+
metadata: { title: 'Networking' },
|
|
270
|
+
fileName: 'net.md'
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const score1 = docService.calculateAdvancedRelevanceScore(
|
|
274
|
+
doc,
|
|
275
|
+
['urlsession'],
|
|
276
|
+
'URLSession'
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const score2 = docService.calculateAdvancedRelevanceScore(
|
|
280
|
+
doc,
|
|
281
|
+
['urlsession', 'configuration'],
|
|
282
|
+
'URLSession configuration'
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Matching both terms should score higher
|
|
286
|
+
expect(score2).toBeGreaterThan(score1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should cap content match frequency to prevent spam', () => {
|
|
290
|
+
const spamDoc = {
|
|
291
|
+
content: 'test '.repeat(100),
|
|
292
|
+
metadata: { title: 'Spam' },
|
|
293
|
+
fileName: 'spam.md'
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const normalDoc = {
|
|
297
|
+
content: 'This is a test document with normal content',
|
|
298
|
+
metadata: { title: 'Normal' },
|
|
299
|
+
fileName: 'normal.md'
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const spamScore = docService.calculateAdvancedRelevanceScore(
|
|
303
|
+
spamDoc,
|
|
304
|
+
['test'],
|
|
305
|
+
'test'
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const normalScore = docService.calculateAdvancedRelevanceScore(
|
|
309
|
+
normalDoc,
|
|
310
|
+
['test'],
|
|
311
|
+
'test'
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// Spam score should be capped, not drastically higher
|
|
315
|
+
expect(spamScore / normalScore).toBeLessThan(5);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|