@operor/knowledge 0.1.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 +457 -0
- package/dist/index.d.ts +437 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1442 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/EmbeddingService.ts +92 -0
- package/src/IngestionPipeline.ts +357 -0
- package/src/QueryNormalizer.ts +59 -0
- package/src/QueryRewriter.ts +73 -0
- package/src/RankFusion.ts +72 -0
- package/src/RetrievalPipeline.ts +388 -0
- package/src/SQLiteKnowledgeStore.ts +379 -0
- package/src/TextChunker.ts +34 -0
- package/src/__tests__/cli-integration.test.ts +134 -0
- package/src/__tests__/content-fetcher.test.ts +156 -0
- package/src/__tests__/knowledge.test.ts +493 -0
- package/src/__tests__/retrieval-layers.test.ts +672 -0
- package/src/index.ts +41 -0
- package/src/ingestors/FileIngestor.ts +85 -0
- package/src/ingestors/SiteCrawler.ts +153 -0
- package/src/ingestors/UrlIngestor.ts +106 -0
- package/src/ingestors/WatiFaqSync.ts +75 -0
- package/src/ingestors/content-fetcher.ts +142 -0
- package/src/types.ts +62 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +10 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { fetchContent, extractFromHtml, extractLinks, resetCrawl4aiHealthCache } from '../ingestors/content-fetcher.js';
|
|
3
|
+
|
|
4
|
+
// Mock global fetch
|
|
5
|
+
const mockFetch = vi.fn();
|
|
6
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockFetch.mockReset();
|
|
10
|
+
resetCrawl4aiHealthCache();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('content-fetcher', () => {
|
|
14
|
+
describe('fetchContent without Crawl4AI', () => {
|
|
15
|
+
it('uses readability extraction from static HTML', async () => {
|
|
16
|
+
const html = `
|
|
17
|
+
<html><head><title>Test Page</title></head>
|
|
18
|
+
<body><article><h1>Hello World</h1><p>Some content here.</p></article></body>
|
|
19
|
+
</html>
|
|
20
|
+
`;
|
|
21
|
+
mockFetch.mockResolvedValueOnce({
|
|
22
|
+
ok: true,
|
|
23
|
+
text: async () => html,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const result = await fetchContent('https://example.com/page');
|
|
27
|
+
|
|
28
|
+
expect(result.isMarkdown).toBe(false);
|
|
29
|
+
expect(result.content).toContain('Some content here');
|
|
30
|
+
expect(mockFetch).toHaveBeenCalledWith('https://example.com/page', expect.objectContaining({
|
|
31
|
+
headers: { 'User-Agent': 'Operor-KB/1.0' },
|
|
32
|
+
}));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('fetchContent with Crawl4AI', () => {
|
|
37
|
+
it('uses Crawl4AI when health check passes and returns markdown', async () => {
|
|
38
|
+
// Health check
|
|
39
|
+
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
40
|
+
// Crawl4AI POST response
|
|
41
|
+
mockFetch.mockResolvedValueOnce({
|
|
42
|
+
ok: true,
|
|
43
|
+
json: async () => ({
|
|
44
|
+
results: [{
|
|
45
|
+
markdown: {
|
|
46
|
+
fit_markdown: '# Dynamic Content\n\nRendered by JavaScript.',
|
|
47
|
+
raw_markdown: '# Dynamic Content\n\nRendered by JavaScript.',
|
|
48
|
+
},
|
|
49
|
+
}],
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const result = await fetchContent('https://example.com/spa', { crawl4aiUrl: 'http://localhost:11235' });
|
|
54
|
+
|
|
55
|
+
expect(result.isMarkdown).toBe(true);
|
|
56
|
+
expect(result.content).toContain('Rendered by JavaScript');
|
|
57
|
+
expect(result.title).toBe('Dynamic Content');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('falls back to Readability when Crawl4AI health check fails', async () => {
|
|
61
|
+
// Health check fails
|
|
62
|
+
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
|
|
63
|
+
// Readability fallback fetch
|
|
64
|
+
const html = `
|
|
65
|
+
<html><head><title>Fallback Page</title></head>
|
|
66
|
+
<body><article><p>Fallback content.</p></article></body>
|
|
67
|
+
</html>
|
|
68
|
+
`;
|
|
69
|
+
mockFetch.mockResolvedValueOnce({
|
|
70
|
+
ok: true,
|
|
71
|
+
text: async () => html,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = await fetchContent('https://example.com/page', { crawl4aiUrl: 'http://localhost:11235' });
|
|
75
|
+
|
|
76
|
+
expect(result.isMarkdown).toBe(false);
|
|
77
|
+
expect(result.content).toContain('Fallback content');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('falls back to Readability when Crawl4AI returns empty results', async () => {
|
|
81
|
+
// Health check passes
|
|
82
|
+
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
83
|
+
// Crawl4AI returns empty
|
|
84
|
+
mockFetch.mockResolvedValueOnce({
|
|
85
|
+
ok: true,
|
|
86
|
+
json: async () => ({ results: [{ markdown: { fit_markdown: '', raw_markdown: '' } }] }),
|
|
87
|
+
});
|
|
88
|
+
// Readability fallback
|
|
89
|
+
const html = `
|
|
90
|
+
<html><head><title>Fallback</title></head>
|
|
91
|
+
<body><article><p>Recovery content.</p></article></body>
|
|
92
|
+
</html>
|
|
93
|
+
`;
|
|
94
|
+
mockFetch.mockResolvedValueOnce({
|
|
95
|
+
ok: true,
|
|
96
|
+
text: async () => html,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = await fetchContent('https://example.com/empty', { crawl4aiUrl: 'http://localhost:11235' });
|
|
100
|
+
|
|
101
|
+
expect(result).toHaveProperty('content');
|
|
102
|
+
expect(result.content).toContain('Recovery content');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('extractFromHtml', () => {
|
|
107
|
+
it('extracts title and content from HTML', () => {
|
|
108
|
+
const html = `
|
|
109
|
+
<html><head><title>My Page</title></head>
|
|
110
|
+
<body><article><p>Article content goes here.</p></article></body>
|
|
111
|
+
</html>
|
|
112
|
+
`;
|
|
113
|
+
const result = extractFromHtml(html, 'https://example.com');
|
|
114
|
+
expect(result.content).toContain('Article content goes here');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('extractLinks', () => {
|
|
119
|
+
it('extracts same-domain links', () => {
|
|
120
|
+
const html = `
|
|
121
|
+
<html><body>
|
|
122
|
+
<a href="/about">About</a>
|
|
123
|
+
<a href="https://example.com/contact">Contact</a>
|
|
124
|
+
<a href="https://other.com/external">External</a>
|
|
125
|
+
</body></html>
|
|
126
|
+
`;
|
|
127
|
+
const links = extractLinks(html, 'https://example.com');
|
|
128
|
+
expect(links).toContain('https://example.com/about');
|
|
129
|
+
expect(links).toContain('https://example.com/contact');
|
|
130
|
+
expect(links).not.toContain('https://other.com/external');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('deduplicates links', () => {
|
|
134
|
+
const html = `
|
|
135
|
+
<html><body>
|
|
136
|
+
<a href="/page">Link 1</a>
|
|
137
|
+
<a href="/page">Link 2</a>
|
|
138
|
+
</body></html>
|
|
139
|
+
`;
|
|
140
|
+
const links = extractLinks(html, 'https://example.com');
|
|
141
|
+
expect(links.filter(l => l === 'https://example.com/page')).toHaveLength(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('strips fragment identifiers', () => {
|
|
145
|
+
const html = `
|
|
146
|
+
<html><body>
|
|
147
|
+
<a href="/page#section1">Section 1</a>
|
|
148
|
+
<a href="/page#section2">Section 2</a>
|
|
149
|
+
</body></html>
|
|
150
|
+
`;
|
|
151
|
+
const links = extractLinks(html, 'https://example.com');
|
|
152
|
+
expect(links).toContain('https://example.com/page');
|
|
153
|
+
expect(links).toHaveLength(1);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { EmbeddingService } from '../EmbeddingService.js';
|
|
3
|
+
import { SQLiteKnowledgeStore } from '../SQLiteKnowledgeStore.js';
|
|
4
|
+
import { TextChunker } from '../TextChunker.js';
|
|
5
|
+
import { IngestionPipeline } from '../IngestionPipeline.js';
|
|
6
|
+
import { RetrievalPipeline } from '../RetrievalPipeline.js';
|
|
7
|
+
import { unlinkSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
// Mock AI SDK
|
|
10
|
+
vi.mock('ai', () => ({
|
|
11
|
+
embed: vi.fn(async ({ value }: { value: string }) => ({
|
|
12
|
+
embedding: mockEmbed(value),
|
|
13
|
+
})),
|
|
14
|
+
embedMany: vi.fn(async ({ values }: { values: string[] }) => ({
|
|
15
|
+
embeddings: values.map(mockEmbed),
|
|
16
|
+
})),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('@ai-sdk/openai', () => ({
|
|
20
|
+
createOpenAI: vi.fn(() => ({
|
|
21
|
+
embedding: vi.fn(() => ({})),
|
|
22
|
+
})),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('@ai-sdk/google', () => ({
|
|
26
|
+
createGoogleGenerativeAI: vi.fn(() => ({
|
|
27
|
+
textEmbeddingModel: vi.fn(() => ({})),
|
|
28
|
+
})),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('@ai-sdk/mistral', () => ({
|
|
32
|
+
mistral: {
|
|
33
|
+
embedding: vi.fn(() => ({})),
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock('@ai-sdk/cohere', () => ({
|
|
38
|
+
cohere: {
|
|
39
|
+
embedding: vi.fn(() => ({})),
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
function mockEmbed(text: string): number[] {
|
|
44
|
+
const hash = text.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
|
45
|
+
return Array.from({ length: 1536 }, (_, i) => Math.sin(hash + i) * 0.1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('EmbeddingService', () => {
|
|
49
|
+
it('should embed single text', async () => {
|
|
50
|
+
const service = new EmbeddingService({ provider: 'openai', apiKey: 'test' });
|
|
51
|
+
const embedding = await service.embed('hello world');
|
|
52
|
+
expect(embedding).toHaveLength(1536);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should embed multiple texts', async () => {
|
|
56
|
+
const service = new EmbeddingService({ provider: 'openai', apiKey: 'test' });
|
|
57
|
+
const embeddings = await service.embedMany(['hello', 'world']);
|
|
58
|
+
expect(embeddings).toHaveLength(2);
|
|
59
|
+
expect(embeddings[0]).toHaveLength(1536);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should return dimensions', () => {
|
|
63
|
+
const service = new EmbeddingService({ provider: 'openai', apiKey: 'test', dimensions: 512 });
|
|
64
|
+
expect(service.dimensions).toBe(512);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should return default dimensions per provider', () => {
|
|
68
|
+
expect(new EmbeddingService({ provider: 'openai', apiKey: 'test' }).dimensions).toBe(1536);
|
|
69
|
+
expect(new EmbeddingService({ provider: 'google', apiKey: 'test' }).dimensions).toBe(768);
|
|
70
|
+
expect(new EmbeddingService({ provider: 'mistral', apiKey: 'test' }).dimensions).toBe(1024);
|
|
71
|
+
expect(new EmbeddingService({ provider: 'cohere', apiKey: 'test' }).dimensions).toBe(1024);
|
|
72
|
+
expect(new EmbeddingService({ provider: 'ollama' }).dimensions).toBe(768);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should expose provider name', () => {
|
|
76
|
+
const service = new EmbeddingService({ provider: 'google', apiKey: 'test' });
|
|
77
|
+
expect(service.provider).toBe('google');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('SQLiteKnowledgeStore', () => {
|
|
82
|
+
let store: SQLiteKnowledgeStore;
|
|
83
|
+
const dbPath = './test-kb.db';
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
store = new SQLiteKnowledgeStore(dbPath);
|
|
87
|
+
await store.initialize();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(async () => {
|
|
91
|
+
await store.close();
|
|
92
|
+
try {
|
|
93
|
+
unlinkSync(dbPath);
|
|
94
|
+
unlinkSync(`${dbPath}-shm`);
|
|
95
|
+
unlinkSync(`${dbPath}-wal`);
|
|
96
|
+
} catch {}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should add and retrieve document', async () => {
|
|
100
|
+
const doc = {
|
|
101
|
+
id: 'doc1',
|
|
102
|
+
sourceType: 'url' as const,
|
|
103
|
+
sourceUrl: 'https://example.com',
|
|
104
|
+
title: 'Test Doc',
|
|
105
|
+
content: 'Test content',
|
|
106
|
+
createdAt: Date.now(),
|
|
107
|
+
updatedAt: Date.now(),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await store.addDocument(doc);
|
|
111
|
+
const retrieved = await store.getDocument('doc1');
|
|
112
|
+
expect(retrieved).toMatchObject(doc);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should list documents', async () => {
|
|
116
|
+
const doc1 = {
|
|
117
|
+
id: 'doc1',
|
|
118
|
+
sourceType: 'url' as const,
|
|
119
|
+
content: 'Content 1',
|
|
120
|
+
createdAt: Date.now(),
|
|
121
|
+
updatedAt: Date.now(),
|
|
122
|
+
};
|
|
123
|
+
const doc2 = {
|
|
124
|
+
id: 'doc2',
|
|
125
|
+
sourceType: 'file' as const,
|
|
126
|
+
content: 'Content 2',
|
|
127
|
+
createdAt: Date.now(),
|
|
128
|
+
updatedAt: Date.now(),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
await store.addDocument(doc1);
|
|
132
|
+
await store.addDocument(doc2);
|
|
133
|
+
|
|
134
|
+
const docs = await store.listDocuments();
|
|
135
|
+
expect(docs).toHaveLength(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should delete document and chunks', async () => {
|
|
139
|
+
const doc = {
|
|
140
|
+
id: 'doc1',
|
|
141
|
+
sourceType: 'url' as const,
|
|
142
|
+
content: 'Test',
|
|
143
|
+
createdAt: Date.now(),
|
|
144
|
+
updatedAt: Date.now(),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
await store.addDocument(doc);
|
|
148
|
+
await store.addChunks([
|
|
149
|
+
{
|
|
150
|
+
id: 'chunk1',
|
|
151
|
+
documentId: 'doc1',
|
|
152
|
+
content: 'Test chunk',
|
|
153
|
+
chunkIndex: 0,
|
|
154
|
+
embedding: mockEmbed('test'),
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
await store.deleteDocument('doc1');
|
|
159
|
+
const retrieved = await store.getDocument('doc1');
|
|
160
|
+
expect(retrieved).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should search by embedding', async () => {
|
|
164
|
+
const doc = {
|
|
165
|
+
id: 'doc1',
|
|
166
|
+
sourceType: 'url' as const,
|
|
167
|
+
content: 'Test content',
|
|
168
|
+
createdAt: Date.now(),
|
|
169
|
+
updatedAt: Date.now(),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
await store.addDocument(doc);
|
|
173
|
+
await store.addChunks([
|
|
174
|
+
{
|
|
175
|
+
id: 'chunk1',
|
|
176
|
+
documentId: 'doc1',
|
|
177
|
+
content: 'hello world',
|
|
178
|
+
chunkIndex: 0,
|
|
179
|
+
embedding: mockEmbed('hello world'),
|
|
180
|
+
},
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
const results = await store.searchByEmbedding(mockEmbed('hello world'), { limit: 5 });
|
|
184
|
+
expect(results.length).toBeGreaterThan(0);
|
|
185
|
+
expect(results[0].chunk.content).toBe('hello world');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('TextChunker', () => {
|
|
190
|
+
it('should chunk plain text', async () => {
|
|
191
|
+
const chunker = new TextChunker({ chunkSize: 50, chunkOverlap: 10 });
|
|
192
|
+
const text = 'a'.repeat(200);
|
|
193
|
+
const chunks = await chunker.chunk(text);
|
|
194
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should chunk markdown', async () => {
|
|
198
|
+
const chunker = new TextChunker({ chunkSize: 100, chunkOverlap: 20 });
|
|
199
|
+
const markdown = '# Title\n\n' + 'Content. '.repeat(50);
|
|
200
|
+
const chunks = await chunker.chunkMarkdown(markdown);
|
|
201
|
+
expect(chunks.length).toBeGreaterThan(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('IngestionPipeline', () => {
|
|
206
|
+
let store: SQLiteKnowledgeStore;
|
|
207
|
+
let embedder: EmbeddingService;
|
|
208
|
+
let chunker: TextChunker;
|
|
209
|
+
let pipeline: IngestionPipeline;
|
|
210
|
+
const dbPath = './test-ingest.db';
|
|
211
|
+
|
|
212
|
+
beforeEach(async () => {
|
|
213
|
+
store = new SQLiteKnowledgeStore(dbPath);
|
|
214
|
+
await store.initialize();
|
|
215
|
+
embedder = new EmbeddingService({ provider: 'openai', apiKey: 'test' });
|
|
216
|
+
chunker = new TextChunker({ chunkSize: 100, chunkOverlap: 20 });
|
|
217
|
+
pipeline = new IngestionPipeline(store, embedder, chunker);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
afterEach(async () => {
|
|
221
|
+
await store.close();
|
|
222
|
+
try {
|
|
223
|
+
unlinkSync(dbPath);
|
|
224
|
+
unlinkSync(`${dbPath}-shm`);
|
|
225
|
+
unlinkSync(`${dbPath}-wal`);
|
|
226
|
+
} catch {}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should ingest document with chunks', async () => {
|
|
230
|
+
const doc = await pipeline.ingest({
|
|
231
|
+
sourceType: 'url',
|
|
232
|
+
content: 'This is a test document. '.repeat(20),
|
|
233
|
+
title: 'Test',
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(doc.id).toBeDefined();
|
|
237
|
+
const retrieved = await store.getDocument(doc.id);
|
|
238
|
+
expect(retrieved).toBeDefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should ingest FAQ', async () => {
|
|
242
|
+
const doc = await pipeline.ingestFaq('What is Operor?', 'Operor is a framework.');
|
|
243
|
+
expect(doc.sourceType).toBe('faq');
|
|
244
|
+
expect(doc.title).toBe('What is Operor?');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('RetrievalPipeline', () => {
|
|
249
|
+
let store: SQLiteKnowledgeStore;
|
|
250
|
+
let embedder: EmbeddingService;
|
|
251
|
+
let chunker: TextChunker;
|
|
252
|
+
let ingestion: IngestionPipeline;
|
|
253
|
+
let retrieval: RetrievalPipeline;
|
|
254
|
+
const dbPath = './test-retrieval.db';
|
|
255
|
+
|
|
256
|
+
beforeEach(async () => {
|
|
257
|
+
store = new SQLiteKnowledgeStore(dbPath);
|
|
258
|
+
await store.initialize();
|
|
259
|
+
embedder = new EmbeddingService({ provider: 'openai', apiKey: 'test' });
|
|
260
|
+
chunker = new TextChunker({ chunkSize: 100, chunkOverlap: 20 });
|
|
261
|
+
ingestion = new IngestionPipeline(store, embedder, chunker);
|
|
262
|
+
retrieval = new RetrievalPipeline(store, embedder, 0.85);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
afterEach(async () => {
|
|
266
|
+
await store.close();
|
|
267
|
+
try {
|
|
268
|
+
unlinkSync(dbPath);
|
|
269
|
+
unlinkSync(`${dbPath}-shm`);
|
|
270
|
+
unlinkSync(`${dbPath}-wal`);
|
|
271
|
+
} catch {}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should retrieve FAQ with fast-path', async () => {
|
|
275
|
+
await ingestion.ingestFaq('What is the return policy?', 'You can return within 30 days.');
|
|
276
|
+
|
|
277
|
+
const result = await retrieval.retrieve('What is the return policy?');
|
|
278
|
+
expect(result.isFaqMatch).toBe(true);
|
|
279
|
+
expect(result.context).toContain('return policy');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should retrieve general KB content', async () => {
|
|
283
|
+
await ingestion.ingest({
|
|
284
|
+
sourceType: 'url',
|
|
285
|
+
content: 'Operor is a framework for building AI agents.',
|
|
286
|
+
title: 'About Operor',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const result = await retrieval.retrieve('What is Operor?');
|
|
290
|
+
expect(result.results.length).toBeGreaterThan(0);
|
|
291
|
+
expect(result.context).toContain('Knowledge Base Context');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should format context correctly', async () => {
|
|
295
|
+
await ingestion.ingest({
|
|
296
|
+
sourceType: 'url',
|
|
297
|
+
sourceUrl: 'https://example.com',
|
|
298
|
+
content: 'Test content',
|
|
299
|
+
title: 'Test',
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const result = await retrieval.retrieve('test');
|
|
303
|
+
expect(result.context).toContain('## Knowledge Base Context');
|
|
304
|
+
expect(result.context).toContain('### Source');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('Content Deduplication', () => {
|
|
309
|
+
let store: SQLiteKnowledgeStore;
|
|
310
|
+
let embedder: EmbeddingService;
|
|
311
|
+
let chunker: TextChunker;
|
|
312
|
+
let pipeline: IngestionPipeline;
|
|
313
|
+
const dbPath = './test-dedup.db';
|
|
314
|
+
|
|
315
|
+
beforeEach(async () => {
|
|
316
|
+
store = new SQLiteKnowledgeStore(dbPath);
|
|
317
|
+
await store.initialize();
|
|
318
|
+
embedder = new EmbeddingService({ provider: 'openai', apiKey: 'test' });
|
|
319
|
+
chunker = new TextChunker({ chunkSize: 100, chunkOverlap: 20 });
|
|
320
|
+
pipeline = new IngestionPipeline(store, embedder, chunker);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
afterEach(async () => {
|
|
324
|
+
await store.close();
|
|
325
|
+
try {
|
|
326
|
+
unlinkSync(dbPath);
|
|
327
|
+
unlinkSync(`${dbPath}-shm`);
|
|
328
|
+
unlinkSync(`${dbPath}-wal`);
|
|
329
|
+
} catch {}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should update existing doc when same URL is ingested twice', async () => {
|
|
333
|
+
await pipeline.ingest({
|
|
334
|
+
sourceType: 'url',
|
|
335
|
+
sourceUrl: 'https://example.com/page',
|
|
336
|
+
content: 'Original content',
|
|
337
|
+
title: 'Page',
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await pipeline.ingest({
|
|
341
|
+
sourceType: 'url',
|
|
342
|
+
sourceUrl: 'https://example.com/page',
|
|
343
|
+
content: 'Updated content',
|
|
344
|
+
title: 'Page v2',
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const docs = await store.listDocuments();
|
|
348
|
+
expect(docs).toHaveLength(1);
|
|
349
|
+
const doc = await store.getDocument(docs[0].id);
|
|
350
|
+
expect(doc!.content).toBe('Updated content');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should skip when content hash matches existing doc', async () => {
|
|
354
|
+
await pipeline.ingest({
|
|
355
|
+
sourceType: 'url',
|
|
356
|
+
content: 'Identical content here',
|
|
357
|
+
title: 'First',
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await pipeline.ingest({
|
|
361
|
+
sourceType: 'file',
|
|
362
|
+
content: 'Identical content here',
|
|
363
|
+
title: 'Second',
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const docs = await store.listDocuments();
|
|
367
|
+
expect(docs).toHaveLength(1);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should store contentHash on ingested documents', async () => {
|
|
371
|
+
await pipeline.ingest({
|
|
372
|
+
sourceType: 'url',
|
|
373
|
+
content: 'Some content',
|
|
374
|
+
title: 'Hash Test',
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const docs = await store.listDocuments();
|
|
378
|
+
const doc = await store.getDocument(docs[0].id);
|
|
379
|
+
expect(doc!.contentHash).toBeDefined();
|
|
380
|
+
expect(doc!.contentHash!.length).toBe(64); // SHA-256 hex
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe('FAQ Deduplication', () => {
|
|
385
|
+
let store: SQLiteKnowledgeStore;
|
|
386
|
+
let embedder: EmbeddingService;
|
|
387
|
+
let chunker: TextChunker;
|
|
388
|
+
let pipeline: IngestionPipeline;
|
|
389
|
+
const dbPath = './test-faq-dedup.db';
|
|
390
|
+
|
|
391
|
+
beforeEach(async () => {
|
|
392
|
+
store = new SQLiteKnowledgeStore(dbPath);
|
|
393
|
+
await store.initialize();
|
|
394
|
+
embedder = new EmbeddingService({ provider: 'openai', apiKey: 'test' });
|
|
395
|
+
chunker = new TextChunker({ chunkSize: 100, chunkOverlap: 20 });
|
|
396
|
+
pipeline = new IngestionPipeline(store, embedder, chunker);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
afterEach(async () => {
|
|
400
|
+
await store.close();
|
|
401
|
+
try {
|
|
402
|
+
unlinkSync(dbPath);
|
|
403
|
+
unlinkSync(`${dbPath}-shm`);
|
|
404
|
+
unlinkSync(`${dbPath}-wal`);
|
|
405
|
+
} catch {}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('should return existingMatch when similar FAQ exists', async () => {
|
|
409
|
+
await pipeline.ingestFaq('What is the return policy?', 'You can return within 30 days.');
|
|
410
|
+
|
|
411
|
+
// Exact same question should trigger dedup
|
|
412
|
+
const result = await pipeline.ingestFaq('What is the return policy?', 'New answer.');
|
|
413
|
+
expect(result.existingMatch).toBeDefined();
|
|
414
|
+
expect(result.existingMatch!.answer).toBe('You can return within 30 days.');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should replace existing FAQ with forceReplace', async () => {
|
|
418
|
+
const original = await pipeline.ingestFaq('What are your hours?', 'We are open 9-5.');
|
|
419
|
+
|
|
420
|
+
await pipeline.ingestFaq('What are your hours?', 'We are open 24/7.', {
|
|
421
|
+
forceReplace: true,
|
|
422
|
+
replaceId: original.id,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const docs = await store.listDocuments();
|
|
426
|
+
const faqs = docs.filter(d => d.sourceType === 'faq');
|
|
427
|
+
expect(faqs).toHaveLength(1);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe('Priority Auto-Assignment', () => {
|
|
432
|
+
let store: SQLiteKnowledgeStore;
|
|
433
|
+
let embedder: EmbeddingService;
|
|
434
|
+
let chunker: TextChunker;
|
|
435
|
+
let pipeline: IngestionPipeline;
|
|
436
|
+
const dbPath = './test-priority.db';
|
|
437
|
+
|
|
438
|
+
beforeEach(async () => {
|
|
439
|
+
store = new SQLiteKnowledgeStore(dbPath);
|
|
440
|
+
await store.initialize();
|
|
441
|
+
embedder = new EmbeddingService({ provider: 'openai', apiKey: 'test' });
|
|
442
|
+
chunker = new TextChunker({ chunkSize: 100, chunkOverlap: 20 });
|
|
443
|
+
pipeline = new IngestionPipeline(store, embedder, chunker);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
afterEach(async () => {
|
|
447
|
+
await store.close();
|
|
448
|
+
try {
|
|
449
|
+
unlinkSync(dbPath);
|
|
450
|
+
unlinkSync(`${dbPath}-shm`);
|
|
451
|
+
unlinkSync(`${dbPath}-wal`);
|
|
452
|
+
} catch {}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should assign priority 1 to FAQ, priority 2 to URL and file', async () => {
|
|
456
|
+
const faq = await pipeline.ingestFaq('Q?', 'A.');
|
|
457
|
+
const url = await pipeline.ingest({ sourceType: 'url', content: 'URL content', title: 'URL' });
|
|
458
|
+
const file = await pipeline.ingest({ sourceType: 'file', content: 'File content', title: 'File' });
|
|
459
|
+
|
|
460
|
+
const faqDoc = await store.getDocument(faq.id);
|
|
461
|
+
const urlDoc = await store.getDocument(url.id);
|
|
462
|
+
const fileDoc = await store.getDocument(file.id);
|
|
463
|
+
|
|
464
|
+
expect(faqDoc!.priority).toBe(1);
|
|
465
|
+
expect(urlDoc!.priority).toBe(2);
|
|
466
|
+
expect(fileDoc!.priority).toBe(2);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should allow manual priority override', async () => {
|
|
470
|
+
const doc = await pipeline.ingest({
|
|
471
|
+
sourceType: 'url',
|
|
472
|
+
content: 'Important content',
|
|
473
|
+
title: 'Important',
|
|
474
|
+
priority: 1,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const retrieved = await store.getDocument(doc.id);
|
|
478
|
+
expect(retrieved!.priority).toBe(1);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe('TextChunker Defaults', () => {
|
|
483
|
+
it('should default to chunkSize 3200 and chunkOverlap 200', async () => {
|
|
484
|
+
const chunker = new TextChunker();
|
|
485
|
+
// Text shorter than 3200 should be a single chunk
|
|
486
|
+
const shortChunks = await chunker.chunk('a'.repeat(3000));
|
|
487
|
+
expect(shortChunks).toHaveLength(1);
|
|
488
|
+
|
|
489
|
+
// Text longer than 3200 should be split
|
|
490
|
+
const longChunks = await chunker.chunk('a'.repeat(6000));
|
|
491
|
+
expect(longChunks.length).toBeGreaterThan(1);
|
|
492
|
+
});
|
|
493
|
+
});
|