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
+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) {