feat: enhance knowledge workflows and refresh interface

This commit is contained in:
lofyer
2026-08-10 16:27:40 +08:00
parent 2cb712e4ba
commit 0fab985f28
26 changed files with 4088 additions and 1183 deletions
+24
View File
@@ -520,6 +520,20 @@ function getKnowledgeSnapshot(
documentsById.get(item.documentId)?.title ?? '未知文档',
excerpt: item.quote ?? '',
location: item.location
})),
tasks: snapshot.tasks.map((task) => ({
id: task.id,
libraryId: task.libraryId,
sourceId: task.sourceId,
documentId: task.documentId,
documentName: task.documentName,
kind: task.kind,
status: task.status,
progress: task.progress,
message: task.message,
createdAt: task.createdAt,
startedAt: task.startedAt,
completedAt: task.completedAt
}))
}
}
@@ -3515,12 +3529,22 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const value = knowledgeUpdateLibrarySchema.parse(input)
knowledgeService.database.updateKnowledgeBase(value.libraryId, {
name: value.name,
description: value.description,
graphEnabled: value.graphEnabled,
graphStrategy: value.graphStrategy
})
}
)
ipcMain.handle(
ipcChannels.knowledgeReextractGraph,
async (event, input: unknown) => {
assertTrustedSender(event, window)
return knowledgeService.reextractGraph(knowledgeIdSchema.parse(input))
}
)
ipcMain.handle(
ipcChannels.knowledgeSelectFiles,
async (event, input: unknown) => {
+25 -5
View File
@@ -348,6 +348,28 @@ describe('extraction strategies', () => {
)
})
it('propagates model extraction failures for hybrid and model strategies', async () => {
const chunks = [{ id: 'fallback', content: '# Local Entity' }]
for (const strategy of ['hybrid', 'model'] as const) {
await expect(
extractKnowledgeGraph(chunks, {
strategy,
extractStructured: async () => {
throw new Error('模型未返回图谱内容')
}
})
).rejects.toThrow('模型未返回图谱内容')
}
await expect(
extractKnowledgeGraph(chunks, {
strategy: 'hybrid',
extractStructured: async () => {
return { invalid: true }
}
})
).rejects.toThrow()
})
it('supports rules, model, and ask behavior without an implicit model call', async () => {
const chunks = [{ id: 'strategy', content: '# Local Entity' }]
const callback = vi.fn()
@@ -359,14 +381,12 @@ describe('extraction strategies', () => {
strategy: 'ask',
extractStructured: callback
})
const unavailable = await extractKnowledgeGraph(chunks, {
strategy: 'model'
})
expect(callback).not.toHaveBeenCalled()
expect(rules.requiresModelApproval).toBe(false)
expect(ask.requiresModelApproval).toBe(true)
expect(unavailable.warnings).toEqual(['Model extraction is unavailable'])
await expect(
extractKnowledgeGraph(chunks, { strategy: 'model' })
).rejects.toThrow('Model extraction is unavailable')
})
it('honors cancellation before and after the injected model callback', async () => {
+6 -7
View File
@@ -679,12 +679,7 @@ export async function extractKnowledgeGraph(
}
}
if (!options.extractStructured) {
return {
...rules,
strategy,
requiresModelApproval: false,
warnings: ['Model extraction is unavailable']
}
throw new Error('Model extraction is unavailable')
}
const output = await options.extractStructured(
@@ -692,7 +687,11 @@ export async function extractKnowledgeGraph(
options.signal
)
throwIfAborted(options.signal)
const model = validateModelGraph(output, prepared)
const parsedOutput = parseModelOutput(output)
if (!modelEnvelopeSchema.safeParse(parsedOutput).success) {
throw new Error('模型返回的图谱结构无效')
}
const model = validateModelGraph(parsedOutput, prepared)
const graph =
strategy === 'hybrid' ? mergeKnowledgeGraphs(rules, model) : model
return {
+48
View File
@@ -905,6 +905,54 @@ export class KnowledgeDatabase {
)
}
pruneUnreferencedGeneratedGraph(knowledgeBaseId: string): {
entities: number
relations: number
} {
const normalizedId = requiredString(
knowledgeBaseId,
'knowledgeBaseId',
MAX_ID_LENGTH
)
const database = this.requireDatabase()
let entities = 0
let relations = 0
this.transaction(database, () => {
relations = Number(
database
.prepare(
`DELETE FROM graph_relations
WHERE knowledge_base_id = ?
AND locked = 0
AND NOT EXISTS (
SELECT 1 FROM graph_evidence
WHERE relation_id = graph_relations.id
)`
)
.run(normalizedId).changes
)
entities = Number(
database
.prepare(
`DELETE FROM graph_entities
WHERE knowledge_base_id = ?
AND locked = 0
AND NOT EXISTS (
SELECT 1 FROM graph_evidence
WHERE entity_id = graph_entities.id
)
AND NOT EXISTS (
SELECT 1 FROM graph_relations
WHERE source_entity_id = graph_entities.id
OR target_entity_id = graph_entities.id
)`
)
.run(normalizedId).changes
)
})
return { entities, relations }
}
listChunks(documentId: string, limit = MAX_LIST_LIMIT): Chunk[] {
const normalizedId = requiredString(
documentId,
+97 -2
View File
@@ -8,6 +8,7 @@ import {
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'
@@ -17,7 +18,8 @@ const services: KnowledgeService[] = []
async function createService(
urlImporter?: UrlImporter,
embeddingProvider?: EmbeddingProvider
embeddingProvider?: EmbeddingProvider,
extractStructured?: ExtractStructured
): Promise<{ directory: string; service: KnowledgeService }> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-service-'))
temporaryDirectories.push(directory)
@@ -25,7 +27,8 @@ async function createService(
databasePath: join(directory, 'knowledge.sqlite'),
managedRoot: join(directory, 'managed'),
urlImporter,
embeddingProvider
embeddingProvider,
extractStructured
})
await service.initialize()
services.push(service)
@@ -145,9 +148,101 @@ describe('KnowledgeService', () => {
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',
+339 -6
View File
@@ -23,7 +23,8 @@ import { classifyEmbeddingError } from './embedding-errors'
import {
extractKnowledgeGraph,
normalizeEntityAlias,
type ExtractStructured
type ExtractStructured,
type GraphExtractionResult
} from './graph-extractor'
import { KnowledgeDatabase } from './knowledge-database'
import type {
@@ -66,6 +67,21 @@ export type KnowledgeDocumentSnapshot = Document & {
error?: string
}
export type KnowledgeTaskSnapshot = {
id: string
libraryId: string
sourceId?: string
documentId?: string
documentName: string
kind: 'parsing' | 'embedding' | 'graph'
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'skipped'
progress: number
message?: string
createdAt: string
startedAt?: string
completedAt?: string
}
export type KnowledgeSnapshot = {
libraries: KnowledgeLibrarySnapshot[]
sources: KnowledgeSourceSnapshot[]
@@ -73,6 +89,7 @@ export type KnowledgeSnapshot = {
entities: GraphEntity[]
relations: GraphRelation[]
evidence: ReturnType<KnowledgeDatabase['listEvidence']>
tasks: KnowledgeTaskSnapshot[]
}
export type KnowledgeServiceOptions = {
@@ -89,6 +106,7 @@ const maximumFileBytes = 20 * 1024 * 1024
const maximumSourceBytes = 500 * 1024 * 1024
const maximumFilesPerSource = 2_000
const maximumEmbeddingChunksPerBatch = 32
const maximumKnowledgeTasks = 500
function isInside(root: string, candidate: string): boolean {
const path = relative(resolve(root), resolve(candidate))
@@ -105,6 +123,7 @@ export class KnowledgeService {
private readonly watchers = new Map<string, FSWatcher>()
private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>()
private readonly activeSyncs = new Map<string, Promise<void>>()
private readonly tasks = new Map<string, KnowledgeTaskSnapshot>()
private readonly lifecycleController = new AbortController()
constructor(options: KnowledgeServiceOptions) {
@@ -163,6 +182,109 @@ export class KnowledgeService {
return Promise.resolve()
}
private createKnowledgeTask(input: {
libraryId: string
sourceId?: string
documentId?: string
documentName: string
kind: KnowledgeTaskSnapshot['kind']
status?: KnowledgeTaskSnapshot['status']
message?: string
}): KnowledgeTaskSnapshot {
while (this.tasks.size >= maximumKnowledgeTasks) {
const oldestTaskId = this.tasks.keys().next().value as
| string
| undefined
if (!oldestTaskId) {
break
}
this.tasks.delete(oldestTaskId)
}
const now = new Date().toISOString()
const status = input.status ?? 'queued'
const task: KnowledgeTaskSnapshot = {
id: randomUUID(),
libraryId: input.libraryId,
sourceId: input.sourceId,
documentId: input.documentId,
documentName: input.documentName.slice(0, 512),
kind: input.kind,
status,
progress: status === 'succeeded' || status === 'skipped' ? 100 : 0,
message: input.message?.slice(0, 1_000),
createdAt: now,
startedAt: status === 'running' ? now : undefined,
completedAt:
status === 'succeeded' ||
status === 'failed' ||
status === 'skipped'
? now
: undefined
}
this.tasks.set(task.id, task)
return task
}
private updateKnowledgeTask(
taskId: string,
update: {
status?: KnowledgeTaskSnapshot['status']
progress?: number
message?: string
documentId?: string
documentName?: string
}
): void {
const current = this.tasks.get(taskId)
if (!current) {
return
}
const status = update.status ?? current.status
const terminal =
status === 'succeeded' ||
status === 'failed' ||
status === 'skipped'
this.tasks.set(taskId, {
...current,
status,
documentId: update.documentId ?? current.documentId,
documentName:
update.documentName?.slice(0, 512) ?? current.documentName,
progress:
status === 'succeeded' || status === 'skipped'
? 100
: update.progress === undefined
? current.progress
: Math.max(0, Math.min(100, Math.round(update.progress))),
message:
update.message === undefined
? current.message
: update.message.slice(0, 1_000),
startedAt:
status === 'running' && !current.startedAt
? new Date().toISOString()
: current.startedAt,
completedAt:
terminal && !current.completedAt
? new Date().toISOString()
: current.completedAt
})
}
private failKnowledgeTask(taskId: string, error: unknown): void {
const current = this.tasks.get(taskId)
if (
current?.status === 'succeeded' ||
current?.status === 'skipped'
) {
return
}
this.updateKnowledgeTask(taskId, {
status: 'failed',
message: error instanceof Error ? error.message : '任务失败'
})
}
createLibrary(input: CreateKnowledgeBaseInput): KnowledgeBase {
return this.database.createKnowledgeBase(input)
}
@@ -175,6 +297,11 @@ export class KnowledgeService {
for (const source of this.database.listSources(id)) {
this.stopWatcher(source.id)
}
for (const task of this.tasks.values()) {
if (task.libraryId === id) {
this.tasks.delete(task.id)
}
}
const deleted = this.database.deleteKnowledgeBase(id)
if (deleted && library.storageMode === 'managed') {
const path = join(this.managedRoot, id)
@@ -206,7 +333,8 @@ export class KnowledgeService {
documents: [],
entities: [],
relations: [],
evidence: []
evidence: [],
tasks: []
}
}
const sources = this.database.listSources(libraryId).map((source) => ({
@@ -253,7 +381,12 @@ export class KnowledgeService {
documents,
entities: this.database.listEntities(libraryId),
relations: this.database.listRelations(libraryId),
evidence: this.database.listEvidence(libraryId)
evidence: this.database.listEvidence(libraryId),
tasks: [...this.tasks.values()]
.filter((task) => task.libraryId === libraryId)
.sort((left, right) =>
right.createdAt.localeCompare(left.createdAt)
)
}
}
@@ -427,7 +560,28 @@ export class KnowledgeService {
this.lifecycleController.signal,
AbortSignal.timeout(60_000)
])
const result = await this.urlImporter.import(input, effectiveSignal)
const parsingTask = this.createKnowledgeTask({
libraryId: library.id,
sourceId,
documentName: new URL(input).hostname,
kind: 'parsing'
})
let result: Awaited<ReturnType<UrlImporter['import']>>
try {
this.updateKnowledgeTask(parsingTask.id, {
status: 'running',
progress: 10,
message: '正在抓取并解析网页'
})
result = await this.urlImporter.import(input, effectiveSignal)
this.updateKnowledgeTask(parsingTask.id, {
progress: 70,
message: '正在保存网页内容'
})
} catch (error) {
this.failKnowledgeTask(parsingTask.id, error)
throw error
}
let source = this.database.upsertSource({
id: sourceId,
knowledgeBaseId,
@@ -465,6 +619,12 @@ export class KnowledgeService {
location: chunk.locator
}))
)
this.updateKnowledgeTask(parsingTask.id, {
status: 'succeeded',
documentId: document.id,
documentName: document.title,
message: '网页解析完成'
})
await this.indexDocumentEmbeddings(document)
await this.extractGraph(effectiveLibrary, document)
source = this.database.upsertSource({
@@ -477,6 +637,7 @@ export class KnowledgeService {
}
})
} catch (error) {
this.failKnowledgeTask(parsingTask.id, error)
this.database.upsertSource({
...source,
status: 'error',
@@ -511,6 +672,62 @@ export class KnowledgeService {
return this.syncSource(sourceId)
}
async reextractGraph(knowledgeBaseId: string): Promise<void> {
const library = this.requireLibrary(knowledgeBaseId)
if (!library.graphEnabled) {
throw new Error('请先启用知识图谱')
}
if (library.graphStrategy === 'ask') {
throw new Error('按需询问策略不会自动抽取,请在设置中选择其他策略')
}
const documents = this.database.listDocuments(library.id)
const tasks = documents.map((document) =>
this.createKnowledgeTask({
libraryId: library.id,
sourceId: document.sourceId,
documentId: document.id,
documentName: document.title,
kind: 'graph',
message: '等待重新抽取'
})
)
for (let index = 0; index < documents.length; index += 1) {
const document = documents[index]
const task = tasks[index]
if (!document || !task) {
continue
}
try {
this.updateKnowledgeTask(task.id, {
status: 'running',
progress: 10,
message: '正在重新抽取知识图谱'
})
const result = await this.extractGraphResult(library, document)
this.updateKnowledgeTask(task.id, {
progress: 85,
message: '正在保存实体和关系'
})
this.database.removeEvidenceForDocument(document.id)
this.storeExtractedGraph(library, document, result)
this.updateKnowledgeTask(task.id, {
status: 'succeeded',
message: `已抽取 ${result.entities.length} 个实体、${result.relations.length} 条关系`
})
} catch (error) {
this.failKnowledgeTask(task.id, error)
for (const pendingTask of tasks.slice(index + 1)) {
this.updateKnowledgeTask(pendingTask.id, {
status: 'skipped',
message: '因前序图谱任务失败而未执行'
})
}
throw error
}
}
this.database.pruneUnreferencedGeneratedGraph(library.id)
}
async removeSource(sourceId: string): Promise<boolean> {
const source = this.requireSource(sourceId)
const library = this.requireLibrary(source.knowledgeBaseId)
@@ -594,19 +811,42 @@ export class KnowledgeService {
if (!file) {
continue
}
const parsingTask = this.createKnowledgeTask({
libraryId: library.id,
sourceId: source.id,
documentName: file.relativePath,
kind: 'parsing'
})
try {
this.updateKnowledgeTask(parsingTask.id, {
status: 'running',
progress: 10,
message: '正在读取文档'
})
const buffer = await this.readBoundedFile(file.absolutePath)
this.updateKnowledgeTask(parsingTask.id, {
progress: 35,
message: '正在解析文档内容'
})
const checksum = createHash('sha256').update(buffer).digest('hex')
const previous = existing.find(
(document) => document.externalId === file.relativePath
)
if (previous?.checksum === checksum) {
this.updateKnowledgeTask(parsingTask.id, {
status: 'skipped',
message: '文档内容未发生变化'
})
continue
}
const parsed = await parseDocument(
basename(file.absolutePath),
buffer
)
this.updateKnowledgeTask(parsingTask.id, {
progress: 75,
message: '正在保存解析结果'
})
const document = this.database.upsertDocument(
{
knowledgeBaseId: library.id,
@@ -630,10 +870,17 @@ export class KnowledgeService {
location: chunk.locator
}))
)
this.updateKnowledgeTask(parsingTask.id, {
status: 'succeeded',
documentId: document.id,
documentName: document.title,
message: '文档解析完成'
})
this.database.removeEvidenceForDocument(document.id)
await this.indexDocumentEmbeddings(document)
await this.extractGraph(library, document)
} catch (error) {
this.failKnowledgeTask(parsingTask.id, error)
failures.push(
`${file.relativePath}: ${
error instanceof Error ? error.message : '解析失败'
@@ -661,10 +908,26 @@ export class KnowledgeService {
requestedProvider?: EmbeddingProvider
): Promise<void> {
const provider = requestedProvider ?? this.embeddingProvider
const task = this.createKnowledgeTask({
libraryId: document.knowledgeBaseId,
sourceId: document.sourceId,
documentId: document.id,
documentName: document.title,
kind: 'embedding'
})
if (!provider) {
this.updateKnowledgeTask(task.id, {
status: 'skipped',
message: '未启用向量化'
})
return
}
try {
this.updateKnowledgeTask(task.id, {
status: 'running',
progress: 5,
message: '正在准备文档分块'
})
const chunks = this.database.listChunks(document.id, 10_000)
const embeddings: Array<{
chunkId: string
@@ -704,8 +967,21 @@ export class KnowledgeService {
vector
})
}
this.updateKnowledgeTask(task.id, {
progress:
5 +
((offset + batch.length) / Math.max(chunks.length, 1)) * 85,
message: `正在向量化 ${Math.min(
offset + batch.length,
chunks.length
)}/${chunks.length} 个分块`
})
}
if (this.embeddingProvider !== provider) {
this.updateKnowledgeTask(task.id, {
status: 'skipped',
message: '向量模型配置已变化'
})
return
}
this.database.replaceDocumentEmbeddings(
@@ -714,11 +990,17 @@ export class KnowledgeService {
provider.model,
embeddings
)
this.updateKnowledgeTask(task.id, {
status: 'succeeded',
message: `已向量化 ${chunks.length} 个分块`
})
} catch (error) {
if (this.lifecycleController.signal.aborted) {
this.failKnowledgeTask(task.id, new Error('向量化已取消'))
return
}
const safeError = classifyEmbeddingError(error)
this.failKnowledgeTask(task.id, safeError)
try {
this.database.recordEmbeddingIndexError(
document.id,
@@ -736,11 +1018,55 @@ export class KnowledgeService {
library: KnowledgeBase,
document: Document
): Promise<void> {
if (!library.graphEnabled || library.graphStrategy === 'ask') {
const task = this.createKnowledgeTask({
libraryId: library.id,
sourceId: document.sourceId,
documentId: document.id,
documentName: document.title,
kind: 'graph'
})
if (!library.graphEnabled) {
this.updateKnowledgeTask(task.id, {
status: 'skipped',
message: '知识图谱未启用'
})
return
}
if (library.graphStrategy === 'ask') {
this.updateKnowledgeTask(task.id, {
status: 'skipped',
message: '按需询问策略不自动抽取'
})
return
}
try {
this.updateKnowledgeTask(task.id, {
status: 'running',
progress: 10,
message: '正在准备图谱抽取'
})
const result = await this.extractGraphResult(library, document)
this.updateKnowledgeTask(task.id, {
progress: 85,
message: '正在保存实体和关系'
})
this.storeExtractedGraph(library, document, result)
this.updateKnowledgeTask(task.id, {
status: 'succeeded',
message: `已抽取 ${result.entities.length} 个实体、${result.relations.length} 条关系`
})
} catch (error) {
this.failKnowledgeTask(task.id, error)
throw error
}
}
private async extractGraphResult(
library: KnowledgeBase,
document: Document
): Promise<GraphExtractionResult> {
const chunks = this.database.listChunks(document.id)
const result = await extractKnowledgeGraph(
return extractKnowledgeGraph(
chunks.map((chunk) => ({
id: chunk.id,
content: chunk.content
@@ -750,6 +1076,13 @@ export class KnowledgeService {
extractStructured: this.extractStructured
}
)
}
private storeExtractedGraph(
library: KnowledgeBase,
document: Document,
result: GraphExtractionResult
): void {
const existingEntities = this.database.listEntities(library.id)
const entityIds = new Map<string, string>()
for (const entity of result.entities) {
+22 -1
View File
@@ -65,7 +65,12 @@ describe('createModelGraphExtractor', () => {
choices: [
{
message: {
content: '```json\n{"relations":[]}\n```'
content: [
{
type: 'text',
text: '```json\n{"relations":[]}\n```'
}
]
}
}
]
@@ -136,6 +141,22 @@ describe('createModelGraphExtractor', () => {
)
})
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({
+23 -5
View File
@@ -95,12 +95,28 @@ function openAIChatText(payload: unknown): string {
if (!Array.isArray(choices)) {
return ''
}
const message = record(record(choices[0])?.message)
return typeof message?.content === 'string' ? message.content : ''
const choice = record(choices[0])
const message = record(choice?.message)
if (typeof message?.content === 'string') {
return message.content
}
if (Array.isArray(message?.content)) {
return message.content
.flatMap((part) => {
const value = record(part)
return typeof value?.text === 'string' ? [value.text] : []
})
.join('')
}
return typeof choice?.text === 'string' ? choice.text : ''
}
function openAIResponsesText(payload: unknown): string {
const output = record(payload)?.output
const response = record(payload)
if (typeof response?.output_text === 'string') {
return response.output_text
}
const output = response?.output
if (!Array.isArray(output)) {
return ''
}
@@ -111,7 +127,7 @@ function openAIResponsesText(payload: unknown): string {
})
.flatMap((part) => {
const value = record(part)
return value?.type === 'output_text' &&
return (value?.type === 'output_text' || value?.type === 'text') &&
typeof value.text === 'string'
? [value.text]
: []
@@ -211,7 +227,9 @@ export function createModelGraphExtractor(
? openAIResponsesText(payload)
: openAIChatText(payload)
if (!text) {
throw new Error('模型未返回图谱内容')
throw new Error(
'模型未返回图谱内容,请重试或在知识库设置中切换到规则抽取'
)
}
return extractJsonText(text)
}