Files
goodbuddy/src/main/knowledge/model-extractor.test.ts
T
mesalogo 88658aceaa fix: stabilize knowledge graph extraction for long documents
Long document imports could send oversized graph prompts, surface empty model
responses without useful diagnostics, and fail the entire new document even
when its full-text index was usable. Graph extraction now uses bounded token
batches with retry and timeout handling, preserves successful partial results,
and reports graph failures independently from new-document indexing.

Chinese graph retrieval now recognizes bounded CJK query terms, while graph
search keeps one-hop relationship context visible around matched entities.

Release note: 修复长文档知识图谱抽取易因空响应或超时导致整篇文档导入失败的问题;现在图谱按 Token 分批重试,失败时仍保留新文档全文检索,并改进中文图谱问答和关系上下文展示。
2026-08-25 08:03:15 +08:00

232 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from 'vitest'
import type {
ResolvedRuntimeSettings,
RuntimeSettingsStore
} from '../runtime-settings-store'
import { RetryableGraphExtractionError } from './graph-extractor'
import { createModelGraphExtractor } from './model-extractor'
function store(
overrides: Partial<ResolvedRuntimeSettings>
): RuntimeSettingsStore {
const settings = {
modelBaseUrl: 'http://10.0.0.25:8000/gateway',
modelName: 'intranet-model',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'none',
...overrides
} as ResolvedRuntimeSettings
return {
getResolvedSettings: vi.fn(async () => settings)
} as unknown as RuntimeSettingsStore
}
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' }
})
}
describe('createModelGraphExtractor', () => {
it('uses an unauthenticated Anthropic endpoint with its path and query', async () => {
const fetcher = vi.fn(async () =>
jsonResponse({
content: [{ type: 'text', text: '{"entities":[]}' }]
})
)
const extract = createModelGraphExtractor(
store({
modelBaseUrl:
'http://10.0.0.25:8000/gateway?api-version=2024-02-01'
}),
fetcher
)
await expect(
extract('extract this', new AbortController().signal)
).resolves.toEqual({ entities: [] })
expect(fetcher).toHaveBeenCalledWith(
expect.objectContaining({
href:
'http://10.0.0.25:8000/gateway/v1/messages?api-version=2024-02-01'
}),
expect.objectContaining({
headers: {
'anthropic-version': '2023-06-01',
'content-type': 'application/json'
}
})
)
})
it('supports an unauthenticated OpenAI chat-completions endpoint', async () => {
const fetcher = vi.fn(async () =>
jsonResponse({
choices: [
{
message: {
content: [
{
type: 'text',
text: '```json\n{"relations":[]}\n```'
}
]
}
}
]
})
)
const extract = createModelGraphExtractor(
store({
modelProtocol: 'openai-chat-completions',
modelBaseUrl: 'http://192.168.1.50:11434/v1'
}),
fetcher
)
await expect(extract('extract this')).resolves.toEqual({
relations: []
})
expect(fetcher).toHaveBeenCalledWith(
expect.objectContaining({
href:
'http://192.168.1.50:11434/v1/chat/completions'
}),
expect.objectContaining({
headers: {
'content-type': 'application/json'
}
})
)
})
it('supports OpenAI Responses and sends a configured bearer token', async () => {
const fetcher = vi.fn(async () =>
jsonResponse({
output: [
{
type: 'message',
content: [
{
type: 'output_text',
text: '{"entities":[{"id":"one"}]}'
}
]
}
]
})
)
const extract = createModelGraphExtractor(
store({
modelProtocol: 'openai-responses',
modelAuthentication: 'api-key',
apiKey: 'test-key'
}),
fetcher
)
await expect(extract('extract this')).resolves.toEqual({
entities: [{ id: 'one' }]
})
expect(fetcher).toHaveBeenCalledWith(
expect.objectContaining({
pathname: '/gateway/responses'
}),
expect.objectContaining({
headers: {
authorization: 'Bearer test-key',
'content-type': 'application/json'
}
})
)
})
it('accepts top-level output text from compatible Responses providers', async () => {
const extract = createModelGraphExtractor(
store({ modelProtocol: 'openai-responses' }),
vi.fn(async () =>
jsonResponse({
output_text: '{"entities":[],"relations":[]}'
})
)
)
await expect(extract('extract this')).resolves.toEqual({
entities: [],
relations: []
})
})
it('requires a key only for API-key authentication', async () => {
const extract = createModelGraphExtractor(
store({
modelAuthentication: 'api-key',
apiKey: undefined
}),
vi.fn()
)
await expect(extract('extract this')).rejects.toThrow('API Key')
})
it('classifies empty successful responses as retryable with diagnostics', async () => {
const extract = createModelGraphExtractor(
store({ modelProtocol: 'openai-chat-completions' }),
vi.fn(async () =>
jsonResponse({
choices: [{
finish_reason: 'length',
message: { content: '' }
}]
})
)
)
await expect(extract('extract this')).rejects.toMatchObject({
name: 'RetryableGraphExtractionError',
message: '模型未返回图谱内容(结束原因:length)'
})
})
it('classifies request timeouts as retryable', async () => {
const fetcher = vi.fn(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
})
)
const extract = createModelGraphExtractor(
store({ modelProtocol: 'openai-chat-completions' }),
fetcher,
5
)
const result = extract('extract this')
await expect(result).rejects.toBeInstanceOf(
RetryableGraphExtractionError
)
await expect(result).rejects.toThrow(
'模型图谱抽取响应超时'
)
})
it('preserves non-retryable HTTP status with a malformed error body', async () => {
const extract = createModelGraphExtractor(
store({ modelProtocol: 'openai-chat-completions' }),
vi.fn(async () =>
new Response('<html>unauthorized</html>', { status: 401 })
)
)
await expect(extract('extract this')).rejects.toMatchObject({
name: 'Error',
message: '模型图谱抽取失败(HTTP 401)'
})
})
})