import {
access,
mkdtemp,
mkdir,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ExtractStructured } from './graph-extractor'
import { KnowledgeService } from './knowledge-service'
import type { EmbeddingProvider } from './types'
import { UrlImporter } from './url-importer'
const temporaryDirectories: string[] = []
const services: KnowledgeService[] = []
async function createService(
urlImporter?: UrlImporter,
embeddingProvider?: EmbeddingProvider,
extractStructured?: ExtractStructured
): Promise<{ directory: string; service: KnowledgeService }> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-service-'))
temporaryDirectories.push(directory)
const service = new KnowledgeService({
databasePath: join(directory, 'knowledge.sqlite'),
managedRoot: join(directory, 'managed'),
urlImporter,
embeddingProvider,
extractStructured
})
await service.initialize()
services.push(service)
return { directory, service }
}
afterEach(async () => {
await Promise.all(services.splice(0).map((service) => service.dispose()))
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('KnowledgeService', () => {
it('indexes referenced files and returns cited search results', async () => {
const { directory, service } = await createService()
const sourcePath = join(directory, '产品说明.md')
await writeFile(sourcePath, '# GoodBuddy\n跨平台桌面智能助手', 'utf8')
const library = service.createLibrary({
name: '产品知识',
storageMode: 'reference',
graphEnabled: false,
graphStrategy: 'rules'
})
await service.importPaths(library.id, [sourcePath])
const snapshot = service.snapshot(library.id)
const results = service.search(library.id, '跨平台桌面')
expect(snapshot.sources).toHaveLength(1)
expect(snapshot.documents).toHaveLength(1)
expect(snapshot.documents[0]?.status).toBe('ready')
expect(results[0]?.document.title).toBe('产品说明')
expect(results[0]?.source.location).toBe(sourcePath)
await service.dispose()
})
it('copies managed directories and never deletes the original source', async () => {
const { directory, service } = await createService()
const original = join(directory, 'original')
await mkdir(original)
await writeFile(join(original, 'notes.txt'), '托管目录知识', 'utf8')
const library = service.createLibrary({
name: '托管知识',
storageMode: 'managed',
graphEnabled: false,
graphStrategy: 'rules'
})
await service.importPaths(library.id, [original])
const [source] = service.snapshot(library.id).sources
expect(source?.location).not.toBe(original)
if (!source) {
throw new Error('Managed source was not created')
}
await access(join(source.location, 'notes.txt'))
await service.removeSource(source.id)
await access(join(original, 'notes.txt'))
await expect(access(source.location)).rejects.toThrow()
await service.dispose()
})
it('imports safe URLs through the validated importer', async () => {
const importer = new UrlImporter({
lookup: async () => [{ address: '93.184.216.34', family: 4 }],
transport: async () => ({
status: 200,
headers: { 'content-type': 'text/html' },
body: Buffer.from(
'
帮助中心安装与配置说明'
)
})
})
const { service } = await createService(importer)
const library = service.createLibrary({
name: '网页知识',
storageMode: 'managed',
graphEnabled: false,
graphStrategy: 'rules'
})
await service.importUrl(
library.id,
'https://example.com/help',
new AbortController().signal
)
expect(service.snapshot(library.id).sources[0]).toMatchObject({
type: 'url',
status: 'ready',
displayName: '帮助中心'
})
expect(service.search(library.id, '安装配置')).not.toHaveLength(0)
await service.dispose()
})
it('extracts an optional local rule graph with evidence', async () => {
const { directory, service } = await createService()
const sourcePath = join(directory, 'architecture.md')
await writeFile(
sourcePath,
'GoodBuddy(产品)依赖 Electron(框架)。',
'utf8'
)
const library = service.createLibrary({
name: '架构图谱',
storageMode: 'reference',
graphEnabled: true,
graphStrategy: 'rules'
})
await service.importPaths(library.id, [sourcePath])
const snapshot = service.snapshot(library.id)
expect(snapshot.entities.length).toBeGreaterThan(0)
expect(snapshot.evidence.length).toBeGreaterThan(0)
expect(snapshot.tasks).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'parsing',
status: 'succeeded',
progress: 100
}),
expect.objectContaining({
kind: 'embedding',
status: 'skipped',
progress: 100
}),
expect.objectContaining({
kind: 'graph',
status: 'succeeded',
progress: 100
})
])
)
await service.dispose()
})
it('reextracts graph evidence and removes only stale generated entities', async () => {
const { directory, service } = await createService()
const sourcePath = join(directory, 'reextract.md')
await writeFile(
sourcePath,
'GoodBuddy(产品)依赖 Electron(框架)。',
'utf8'
)
const library = service.createLibrary({
name: '重新抽取',
storageMode: 'reference',
graphEnabled: true,
graphStrategy: 'rules'
})
await service.importPaths(library.id, [sourcePath])
const stale = service.database.createEntity({
knowledgeBaseId: library.id,
name: '过期实体',
type: '概念',
locked: false
})
const manual = service.database.createEntity({
knowledgeBaseId: library.id,
name: '人工实体',
type: '概念',
locked: true
})
await service.reextractGraph(library.id)
const snapshot = service.snapshot(library.id)
expect(snapshot.evidence.length).toBeGreaterThan(0)
expect(service.database.getEntity(stale.id)).toBeUndefined()
expect(service.database.getEntity(manual.id)).toBeDefined()
})
it('fails hybrid reextraction when model extraction fails', async () => {
const extractStructured = vi.fn(async () => {
throw new Error('模型未返回图谱内容')
})
const { directory, service } = await createService(
undefined,
undefined,
extractStructured
)
const sourcePath = join(directory, 'hybrid-fallback.md')
await writeFile(sourcePath, '# 本地实体', 'utf8')
const library = service.createLibrary({
name: '混合抽取',
storageMode: 'reference',
graphEnabled: false,
graphStrategy: 'hybrid'
})
await service.importPaths(library.id, [sourcePath])
service.database.updateKnowledgeBase(library.id, {
graphEnabled: true
})
await expect(service.reextractGraph(library.id)).rejects.toThrow(
'模型未返回图谱内容'
)
expect(service.snapshot(library.id).entities).toHaveLength(0)
expect(service.snapshot(library.id).tasks).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'graph',
status: 'failed',
message: '模型未返回图谱内容'
})
])
)
})
it('indexes optional embeddings and performs vector-backed hybrid search', async () => {
const provider: EmbeddingProvider = {
provider: 'test-provider',
model: 'test-model',
embed: async (input) =>
input.map((text) =>
text.includes('orbital') || text === 'related meaning'
? [1, 0]
: [0, 1]
)
}
const { directory, service } = await createService(undefined, provider)
const sourcePath = join(directory, 'vectors.txt')
await writeFile(sourcePath, 'orbital telescope notes', 'utf8')
const library = service.createLibrary({
name: 'Vector knowledge',
storageMode: 'reference',
graphEnabled: false
})
await service.importPaths(library.id, [sourcePath])
const document = service.snapshot(library.id).documents[0]
if (!document) {
throw new Error('Indexed document missing')
}
expect(
service.database.getEmbeddingIndexState(
document.id,
provider.provider,
provider.model
)
).toMatchObject({ status: 'ready', dimensions: 2 })
const results = await service.searchHybrid(
library.id,
'related meaning'
)
expect(results[0]?.document.id).toBe(document.id)
expect(results[0]?.retrieval.channels).toContain('vector')
})
it('keeps FTS available and records diagnostics when embeddings fail', async () => {
const provider: EmbeddingProvider = {
provider: 'failing-provider',
model: 'failing-model',
embed: async () => {
throw Object.assign(
new Error('Bearer sk-private failed with private payload'),
{ status: 503 }
)
}
}
const { directory, service } = await createService(undefined, provider)
const sourcePath = join(directory, 'fallback.txt')
await writeFile(sourcePath, 'lexical fallback remains searchable', 'utf8')
const library = service.createLibrary({
name: 'Fallback knowledge',
storageMode: 'reference',
graphEnabled: false
})
await service.importPaths(library.id, [sourcePath])
const document = service.snapshot(library.id).documents[0]
if (!document) {
throw new Error('Indexed document missing')
}
expect(document.status).toBe('ready')
expect(service.snapshot(library.id).sources[0]?.status).toBe('ready')
expect(service.search(library.id, 'fallback')).toHaveLength(1)
expect(
service.database.getEmbeddingIndexState(
document.id,
provider.provider,
provider.model
)
).toMatchObject({
status: 'error',
lastError: '向量服务暂时不可用。'
})
expect(
JSON.stringify(
service.database.getEmbeddingIndexState(
document.id,
provider.provider,
provider.model
)
)
).not.toContain('sk-private')
const results = await service.searchHybrid(library.id, 'fallback')
expect(results[0]?.retrieval.channels).toContain('fts')
})
it('defers existing-document rebuilds when an embedding provider is enabled', async () => {
const { directory, service } = await createService()
const sourcePath = join(directory, 'existing.txt')
await writeFile(sourcePath, 'existing semantic content', 'utf8')
const library = service.createLibrary({
name: 'Existing knowledge',
storageMode: 'reference',
graphEnabled: false
})
await service.importPaths(library.id, [sourcePath])
const document = service.snapshot(library.id).documents[0]!
const provider: EmbeddingProvider = {
provider: 'late-provider',
model: 'late-model',
embed: async (input) => input.map(() => [0.5, 0.5])
}
await service.setEmbeddingProvider(provider)
expect(
service.database.getEmbeddingIndexState(
document.id,
provider.provider,
provider.model
)
).toBeUndefined()
})
it('embeds a hybrid query once across multiple libraries', async () => {
const embed = vi.fn(
async (input) => input.map(() => [1, 0])
)
const provider: EmbeddingProvider = {
provider: 'shared-query-provider',
model: 'shared-query-model',
embed
}
const { directory, service } = await createService(undefined, provider)
const libraryIds: string[] = []
for (const index of [1, 2]) {
const sourcePath = join(directory, `library-${index}.txt`)
await writeFile(sourcePath, `shared topic ${index}`, 'utf8')
const library = service.createLibrary({
name: `Library ${index}`,
storageMode: 'reference',
graphEnabled: false
})
libraryIds.push(library.id)
await service.importPaths(library.id, [sourcePath])
}
embed.mockClear()
const results = await service.searchHybridMany(
libraryIds,
'shared topic'
)
expect(embed).toHaveBeenCalledOnce()
expect(results).toHaveLength(2)
})
})