feat: enhance local knowledge retrieval

This commit is contained in:
lofyer
2026-08-12 21:45:47 +08:00
parent e0e5a8c1b3
commit 111f487e20
65 changed files with 13517 additions and 1155 deletions
+39 -5
View File
@@ -2,8 +2,13 @@ import { z } from 'zod'
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
export const assistantIdSchema = z.string().uuid()
export const workModeSchema = z.enum(['ask', 'plan', 'execute'])
export const interactiveWorkModes = ['ask', 'execute'] as const
export const workModeSchema = z.enum(interactiveWorkModes)
export const legacyWorkModeSchema = z.enum([
'ask',
'plan',
'execute'
])
export const projectKindSchema = z.enum(['user', 'channel'])
export const projectChannels = [
'weixin',
@@ -18,12 +23,13 @@ export const projectChannelLabels: Record<ProjectChannel, string> = {
}
export type WorkMode = z.infer<typeof workModeSchema>
export type LegacyWorkMode = z.infer<typeof legacyWorkModeSchema>
export type InteractiveWorkMode = (typeof interactiveWorkModes)[number]
export type ProjectKind = z.infer<typeof projectKindSchema>
export type ProjectChannel = z.infer<typeof projectChannelSchema>
export function normalizeInteractiveWorkMode(
workMode: WorkMode | undefined
workMode: LegacyWorkMode | undefined
): InteractiveWorkMode {
return workMode === 'execute' ? 'execute' : 'ask'
}
@@ -136,6 +142,7 @@ export const conversationSnapshotSchema = z
id: assistantIdSchema,
projectId: assistantIdSchema.optional(),
runtimeSelection: agentRuntimeSelectionSchema.optional(),
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
remote: z
.object({
channel: projectChannelSchema,
@@ -167,15 +174,21 @@ export const conversationSnapshotSchema = z
libraryId: assistantIdSchema,
libraryName: z.string().max(200),
documentId: assistantIdSchema,
chunkId: assistantIdSchema.optional(),
documentName: z.string().max(500),
sourceName: z.string().max(500),
sourceLocation: z.string().max(4_096).optional(),
locator: z.string().max(1_000).optional(),
snippet: z.string().max(16_000),
rank: z.number().finite(),
score: z.number().finite().optional(),
lexicalRank: z.number().int().positive().optional(),
vectorRank: z.number().int().positive().optional(),
graphRank: z.number().int().positive().optional(),
similarity: z.number().min(-1).max(1).optional(),
retrievalChannels: z
.array(z.enum(['fts', 'vector', 'graph']))
.max(3)
.array(z.enum(['fts', 'cjk', 'vector', 'graph']))
.max(4)
.optional(),
evidenceIds: z
.array(assistantIdSchema)
@@ -186,6 +199,27 @@ export const conversationSnapshotSchema = z
)
.max(20)
.optional(),
knowledgeRetrieval: z
.object({
mode: z.literal('always'),
state: z.enum([
'searching',
'succeeded',
'zero',
'degraded',
'failed',
'cancelled'
]),
libraryCount: z.number().int().min(1).max(20),
resultCount: z.number().int().nonnegative().max(20),
durationMs: z.number().int().nonnegative().optional(),
usedChannels: z
.array(z.enum(['fts', 'cjk', 'vector', 'graph']))
.max(4),
warnings: z.array(z.string().max(500)).max(20)
})
.strict()
.optional(),
artifactIds: z.array(assistantIdSchema).max(8).optional(),
attachments: z
.array(conversationAttachmentSchema)
@@ -357,7 +391,7 @@ export const scheduleCreateSchema = z
projectId: z.string().uuid().optional(),
title: z.string().trim().min(1).max(120),
prompt: z.string().trim().min(1).max(100_000),
workMode: z.enum(['ask', 'plan']),
workMode: z.literal('ask'),
recurrence: z.enum(['once', 'daily', 'weekly']),
nextRunAt: z.string().datetime({ offset: true })
})
+135 -25
View File
@@ -13,7 +13,7 @@ import type {
} from './capability-contracts'
import {
assistantIdSchema,
workModeSchema,
legacyWorkModeSchema,
type AssistantProject,
type AssistantArtifact,
type AssistantMemory,
@@ -73,8 +73,8 @@ import type {
} from './speech-model-contracts'
import type {
EmbeddingDiagnosticResult,
EmbeddingIndexStatus,
EmbeddingSettingsSnapshot
EmbeddingSettingsSnapshot,
KnowledgeEmbeddingIndexSnapshot
} from './embedding-contracts'
import type {
DocumentOcrAssets,
@@ -85,6 +85,34 @@ import type {
DocumentParsingSettings,
DocumentParsingSnapshot
} from './document-parsing-contracts'
import type {
KnowledgeChunkDeleteInput,
KnowledgeChunkPage,
KnowledgeChunkUpdateInput,
KnowledgeChunksListInput,
KnowledgeDocumentRebuildInput,
KnowledgeLibraryRebuildInput,
KnowledgeReferenceContext,
KnowledgeReferenceContextInput,
KnowledgeReferenceOpenInput,
KnowledgeRetrievalResponse,
KnowledgeRetrievalSettings,
KnowledgeRetrieveInput,
KnowledgeSettingsUpdateInput,
KnowledgeChunkingSettings
} from './knowledge-contracts'
import type { KnowledgeOntologySettings } from './knowledge-ontology'
import type {
KnowledgeTaskItem
} from './knowledge-task-contracts'
export type {
KnowledgeTaskError,
KnowledgeTaskItem,
KnowledgeTaskKind,
KnowledgeTaskScope,
KnowledgeTaskStage,
KnowledgeTaskStatus
} from './knowledge-task-contracts'
import type { WeixinBindingSnapshot } from './weixin-channel-contracts'
import type { RemoteChannelActivity } from './remote-channel-contracts'
import {
@@ -148,6 +176,11 @@ export type AgentQuestionAnswer = z.infer<
export const conversationIdSchema = z.string().min(1).max(128)
export const knowledgeRetrievalModeSchema = z.enum(['auto', 'always'])
export type KnowledgeRetrievalMode = z.infer<
typeof knowledgeRetrievalModeSchema
>
export const agentRequestSchema = z
.object({
requestId: z.string().uuid(),
@@ -157,12 +190,13 @@ export const agentRequestSchema = z
teamMode: z.boolean().optional(),
smartRouting: z.boolean().optional(),
runtimeSelection: agentRuntimeSelectionSchema.optional(),
workMode: workModeSchema.optional(),
workMode: legacyWorkModeSchema.optional(),
prompt: z.string().trim().min(1).max(100_000),
knowledgeLibraryIds: z
.array(z.string().uuid())
.max(20)
.default([]),
knowledgeRetrievalMode: knowledgeRetrievalModeSchema.default('auto'),
contextIds: z.array(z.string().uuid()).max(8).optional(),
history: z
.array(
@@ -260,6 +294,9 @@ export const defaultRuntimeSettings = {
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingModel: 'nomic-embed-text',
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
knowledgeRerankModel: 'rerank-v3.5',
workspacePath: '',
toolApproval: 'always'
} as const
@@ -386,6 +423,15 @@ export const runtimeSettingsInputSchema = z
.max(256)
.regex(/^[\w./:-]+$/, '向量模型名称包含不支持的字符'),
knowledgeEmbeddingApiKey: modelApiKeyUpdateSchema.optional(),
knowledgeRerankEnabled: z.boolean(),
knowledgeRerankEndpoint: z.string().url().max(2_048),
knowledgeRerankModel: z
.string()
.trim()
.min(1)
.max(256)
.regex(/^[\w./:-]+$/, '重排模型名称包含不支持的字符'),
knowledgeRerankApiKey: modelApiKeyUpdateSchema.optional(),
workspacePath: z.string().trim().min(1).max(4_096),
apiKey: modelApiKeyUpdateSchema,
modelProfiles: z.array(modelProfileInputSchema).min(1).max(20).optional(),
@@ -527,6 +573,17 @@ export const runtimeSettingsInputSchema = z
message: '向量接口 URL 必须使用 HTTP 或 HTTPS'
})
}
if (
!['http:', 'https:'].includes(
new URL(settings.knowledgeRerankEndpoint).protocol
)
) {
context.addIssue({
code: 'custom',
path: ['knowledgeRerankEndpoint'],
message: '重排接口 URL 必须使用 HTTP 或 HTTPS'
})
}
})
export type RuntimeSettingsInput = z.infer<typeof runtimeSettingsInputSchema>
@@ -568,6 +625,11 @@ export type RuntimeSettings = {
knowledgeEmbeddingModel: string
knowledgeEmbeddingApiKeyConfigured: boolean
knowledgeEmbeddingCredentialSource: 'none' | 'encrypted' | 'environment'
knowledgeRerankEnabled?: boolean
knowledgeRerankEndpoint?: string
knowledgeRerankModel?: string
knowledgeRerankApiKeyConfigured?: boolean
knowledgeRerankCredentialSource?: 'none' | 'encrypted' | 'environment'
workspacePath: string
apiKeyConfigured: boolean
credentialSource: 'none' | 'encrypted' | 'environment'
@@ -758,6 +820,23 @@ export type AgentEvent =
type: 'source-references'
references: KnowledgeSearchReference[]
}
| {
requestId: string
type: 'knowledge-retrieval'
mode: 'always'
state:
| 'searching'
| 'succeeded'
| 'zero'
| 'degraded'
| 'failed'
| 'cancelled'
libraryCount: number
resultCount: number
durationMs?: number
usedChannels: Array<'fts' | 'cjk' | 'vector' | 'graph'>
warnings: string[]
}
| {
requestId: string
type: 'done'
@@ -870,6 +949,11 @@ export type KnowledgeLibrary = z.infer<typeof knowledgeCreateSchema> & {
sourceCount: number
documentCount: number
indexedDocumentCount: number
retrievalSettings?: KnowledgeRetrievalSettings
chunkingSettings?: KnowledgeChunkingSettings
chunkingRebuildRequired?: boolean
ontologySettings?: KnowledgeOntologySettings
ontologyRebuildRequired?: boolean
updatedAt?: string
}
@@ -900,21 +984,6 @@ export type KnowledgeDocumentItem = {
error?: string
}
export type KnowledgeTaskItem = {
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 KnowledgeGraphNode = {
id: string
label: string
@@ -958,13 +1027,19 @@ export type KnowledgeSearchReference = {
libraryId: string
libraryName: string
documentId: string
chunkId?: string
documentName: string
sourceName: string
sourceLocation?: string
locator?: string
snippet: string
rank: number
retrievalChannels?: Array<'fts' | 'vector' | 'graph'>
score?: number
lexicalRank?: number
vectorRank?: number
graphRank?: number
similarity?: number
retrievalChannels?: Array<'fts' | 'cjk' | 'vector' | 'graph'>
evidenceIds?: string[]
}
@@ -1078,11 +1153,6 @@ export type DesktopApi = {
embeddings?: {
getSnapshot: () => Promise<EmbeddingSettingsSnapshot>
diagnose: () => Promise<EmbeddingDiagnosticResult>
rebuild: () => Promise<EmbeddingIndexStatus>
cancel: (jobId: string) => Promise<boolean>
onStatus: (
listener: (status: EmbeddingIndexStatus) => void
) => () => void
}
documentParsing?: {
getSnapshot: () => Promise<DocumentParsingSnapshot>
@@ -1341,6 +1411,46 @@ export type DesktopApi = {
libraryIds: string[],
query: string
) => Promise<KnowledgeSearchReference[]>
retrieve: (
input: KnowledgeRetrieveInput
) => Promise<KnowledgeRetrievalResponse>
updateSettings: (
input: KnowledgeSettingsUpdateInput
) => Promise<KnowledgeLibrary>
listChunks: (
input: KnowledgeChunksListInput
) => Promise<KnowledgeChunkPage>
updateChunk: (
input: KnowledgeChunkUpdateInput
) => Promise<void>
deleteChunk: (
input: KnowledgeChunkDeleteInput
) => Promise<void>
rebuildDocument: (
input: KnowledgeDocumentRebuildInput
) => Promise<KnowledgeSnapshot>
rebuildLibrary: (
input: KnowledgeLibraryRebuildInput
) => Promise<{ rebuilt: number; failed: number }>
cancelRebuild: (knowledgeBaseId: string) => Promise<boolean>
getEmbeddingIndex: (
knowledgeBaseId: string
) => Promise<KnowledgeEmbeddingIndexSnapshot>
rebuildEmbeddingIndex: (
knowledgeBaseId: string
) => Promise<KnowledgeEmbeddingIndexSnapshot>
cancelEmbeddingIndex: (
knowledgeBaseId: string,
jobId: string
) => Promise<boolean>
cancelTask: (taskId: string) => Promise<boolean>
retryTask: (taskId: string) => Promise<void>
getReferenceContext: (
input: KnowledgeReferenceContextInput
) => Promise<KnowledgeReferenceContext>
openReferenceSource: (
input: KnowledgeReferenceOpenInput
) => Promise<void>
createEntity: (
libraryId: string,
input: z.infer<typeof knowledgeEntityUpdateSchema>
+19
View File
@@ -3,6 +3,7 @@ import {
embeddingDiagnosticResultSchema,
embeddingIndexJobSchema,
embeddingIndexStatusSchema,
knowledgeEmbeddingIndexSnapshotSchema,
embeddingSafeErrorSchema,
isEmbeddingIndexJobActive
} from './embedding-contracts'
@@ -130,4 +131,22 @@ describe('embedding contracts', () => {
}).success
).toBe(false)
})
it('validates library-scoped embedding coverage totals', () => {
const base = {
knowledgeBaseId: '11111111-1111-4111-8111-111111111111',
enabled: true,
coverage: { total: 4, indexed: 2, missing: 1, error: 1 },
indexStatus: { job: null }
}
expect(
knowledgeEmbeddingIndexSnapshotSchema.safeParse(base).success
).toBe(true)
expect(
knowledgeEmbeddingIndexSnapshotSchema.safeParse({
...base,
coverage: { total: 4, indexed: 2, missing: 2, error: 1 }
}).success
).toBe(false)
})
})
+45 -5
View File
@@ -3,7 +3,7 @@ import { z } from 'zod'
const boundedLabelSchema = z.string().trim().min(1).max(256)
const timestampSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
const countSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
const safeEndpointSchema = z
export const safeProviderEndpointSchema = z
.url()
.trim()
.max(2_048)
@@ -40,7 +40,7 @@ export const embeddingConfigurationSummarySchema = z
.object({
provider: boundedLabelSchema,
model: boundedLabelSchema,
endpoint: safeEndpointSchema.optional(),
endpoint: safeProviderEndpointSchema.optional(),
credentialConfigured: z.boolean()
})
.strict()
@@ -180,20 +180,60 @@ export type EmbeddingIndexStatus = z.infer<
export const embeddingSettingsSnapshotSchema = z
.object({
configuration: embeddingConfigurationSummarySchema,
indexStatus: embeddingIndexStatusSchema
configuration: embeddingConfigurationSummarySchema
})
.strict()
export type EmbeddingSettingsSnapshot = z.infer<
typeof embeddingSettingsSnapshotSchema
>
export const embeddingIndexJobRequestSchema = z
export const knowledgeEmbeddingIndexRequestSchema = z
.object({
knowledgeBaseId: z.string().uuid()
})
.strict()
export const knowledgeEmbeddingIndexCancelRequestSchema = z
.object({
knowledgeBaseId: z.string().uuid(),
jobId: z.string().uuid()
})
.strict()
export const knowledgeEmbeddingIndexCoverageSchema = z
.object({
total: countSchema,
indexed: countSchema,
missing: countSchema,
error: countSchema
})
.strict()
.superRefine((value, context) => {
if (value.indexed + value.missing + value.error !== value.total) {
context.addIssue({
code: 'custom',
message: 'coverage counts must equal total',
path: ['total']
})
}
})
export type KnowledgeEmbeddingIndexCoverage = z.infer<
typeof knowledgeEmbeddingIndexCoverageSchema
>
export const knowledgeEmbeddingIndexSnapshotSchema = z
.object({
knowledgeBaseId: z.string().uuid(),
enabled: z.boolean(),
configuration: embeddingConfigurationSummarySchema.optional(),
coverage: knowledgeEmbeddingIndexCoverageSchema,
indexStatus: embeddingIndexStatusSchema
})
.strict()
export type KnowledgeEmbeddingIndexSnapshot = z.infer<
typeof knowledgeEmbeddingIndexSnapshotSchema
>
export const isEmbeddingIndexJobActive = (
job: EmbeddingIndexJob | null | undefined
): boolean => job?.status === 'queued' || job?.status === 'running'
+15 -3
View File
@@ -58,9 +58,6 @@ export const ipcChannels = {
speechTranscriptionCancel: 'speech:transcription:cancel',
embeddingSettingsGet: 'settings:embedding:get',
embeddingDiagnose: 'settings:embedding:diagnose',
embeddingIndexRebuild: 'settings:embedding:index:rebuild',
embeddingIndexCancel: 'settings:embedding:index:cancel',
embeddingIndexStatusChanged: 'settings:embedding:index:status-changed',
documentParsingGet: 'settings:document-parsing:get',
documentParsingUpdate: 'settings:document-parsing:update',
documentParsingTest: 'settings:document-parsing:test',
@@ -170,6 +167,21 @@ export const ipcChannels = {
knowledgeRetrySource: 'knowledge:source:retry',
knowledgeRemoveSource: 'knowledge:source:remove',
knowledgeSearch: 'knowledge:search',
knowledgeRetrieve: 'knowledge:retrieve',
knowledgeUpdateSettings: 'knowledge:settings:update',
knowledgeListChunks: 'knowledge:chunks:list',
knowledgeUpdateChunk: 'knowledge:chunk:update',
knowledgeDeleteChunk: 'knowledge:chunk:delete',
knowledgeRebuildDocument: 'knowledge:document:rebuild',
knowledgeRebuildLibrary: 'knowledge:library:rebuild',
knowledgeCancelRebuild: 'knowledge:library:rebuild:cancel',
knowledgeEmbeddingIndexGet: 'knowledge:embedding-index:get',
knowledgeEmbeddingIndexRebuild: 'knowledge:embedding-index:rebuild',
knowledgeEmbeddingIndexCancel: 'knowledge:embedding-index:cancel',
knowledgeTaskCancel: 'knowledge:task:cancel',
knowledgeTaskRetry: 'knowledge:task:retry',
knowledgeReferenceContext: 'knowledge:reference:context',
knowledgeOpenReferenceSource: 'knowledge:reference:open-source',
knowledgeCreateEntity: 'knowledge:entity:create',
knowledgeUpdateEntity: 'knowledge:entity:update',
knowledgeMoveEntity: 'knowledge:entity:move',
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
defaultKnowledgeChunkingSettings,
defaultKnowledgeRetrievalSettings,
knowledgeChunkingSettingsSchema,
knowledgeChunkUpdateInputSchema,
knowledgeRetrievalSettingsSchema,
knowledgeRetrieveInputSchema
} from './knowledge-contracts'
describe('knowledge contracts', () => {
it('provides the approved strict retrieval defaults', () => {
expect(defaultKnowledgeRetrievalSettings).toEqual({
version: 1,
topK: 6,
minimumVectorSimilarity: 0,
ftsWeight: 1,
vectorWeight: 1,
graphWeight: 0.8,
candidateMultiplier: 4,
contextMaxCharacters: 16_000,
adjacentChunkCount: 0,
localRerankEnabled: false,
rerankMode: 'none'
})
expect(
knowledgeRetrievalSettingsSchema.safeParse({
...defaultKnowledgeRetrievalSettings,
ftsWeight: 0,
vectorWeight: 0,
graphWeight: 0
}).success
).toBe(false)
expect(
knowledgeRetrievalSettingsSchema.safeParse({
...defaultKnowledgeRetrievalSettings,
extra: true
}).success
).toBe(false)
expect(
knowledgeRetrievalSettingsSchema.parse({
...defaultKnowledgeRetrievalSettings,
minimumVectorSimilarity: -1
}).minimumVectorSimilarity
).toBe(0)
})
it('bounds chunking settings and validates dependent values', () => {
expect(defaultKnowledgeChunkingSettings).toEqual({
version: 1,
mode: 'structure',
targetCharacters: 1_600,
overlapCharacters: 160,
parentCharacters: 4_800,
childCharacters: 900,
contextualIndexingEnabled: false
})
expect(
knowledgeChunkingSettingsSchema.safeParse({
...defaultKnowledgeChunkingSettings,
targetCharacters: 400,
overlapCharacters: 161
}).success
).toBe(false)
expect(
knowledgeChunkingSettingsSchema.safeParse({
...defaultKnowledgeChunkingSettings,
parentCharacters: 1_600,
childCharacters: 1_601
}).success
).toBe(false)
})
it('bounds retrieval and chunk mutation inputs', () => {
expect(
knowledgeRetrieveInputSchema.safeParse({
knowledgeBaseId: 'library',
query: 'x'.repeat(4_001)
}).success
).toBe(false)
expect(
knowledgeChunkUpdateInputSchema.safeParse({
knowledgeBaseId: 'library',
documentId: 'document',
chunkId: 'chunk'
}).success
).toBe(false)
})
})
+389
View File
@@ -0,0 +1,389 @@
import { z } from 'zod'
import {
defaultKnowledgeOntologySettings,
knowledgeOntologySettingsSchema
} from './knowledge-ontology'
import {
rerankExecutionDiagnosticsSchema,
rerankModeSchema
} from './rerank-contracts'
const idSchema = z.string().trim().min(1).max(128)
const boundedTextSchema = (maximum: number) =>
z.string().min(1).max(maximum)
export const knowledgeRetrievalChannelSchema = z.enum([
'fts',
'cjk',
'vector',
'graph'
])
export type KnowledgeRetrievalChannel = z.infer<
typeof knowledgeRetrievalChannelSchema
>
export const knowledgeRetrievalSettingsSchema = z
.object({
version: z.literal(1).default(1),
topK: z.number().int().min(1).max(20).default(6),
minimumVectorSimilarity: z
.number()
.finite()
.min(-1)
.max(1)
.transform((value) => Math.max(0, value))
.default(0),
ftsWeight: z.number().finite().min(0).max(2).default(1),
vectorWeight: z.number().finite().min(0).max(2).default(1),
graphWeight: z.number().finite().min(0).max(2).default(0.8),
candidateMultiplier: z.number().int().min(2).max(10).default(4),
contextMaxCharacters: z.number().int().min(2_000).max(48_000).default(16_000),
adjacentChunkCount: z.number().int().min(0).max(2).default(0),
localRerankEnabled: z.boolean().default(false),
rerankMode: rerankModeSchema.default('none')
})
.strict()
.superRefine((value, context) => {
if (
value.ftsWeight === 0 &&
value.vectorWeight === 0 &&
value.graphWeight === 0
) {
context.addIssue({
code: 'custom',
message: 'at least one retrieval channel weight must be greater than zero'
})
}
})
.transform((value) => {
const rerankMode =
value.rerankMode === 'none' && value.localRerankEnabled
? 'local'
: value.rerankMode
return {
...value,
rerankMode,
localRerankEnabled: rerankMode !== 'none'
}
})
export type KnowledgeRetrievalSettings = z.infer<
typeof knowledgeRetrievalSettingsSchema
>
export const defaultKnowledgeRetrievalSettings =
knowledgeRetrievalSettingsSchema.parse({})
export const knowledgeChunkingModeSchema = z.enum([
'fixed',
'structure',
'parent-child'
])
export const knowledgeChunkingSettingsSchema = z
.object({
version: z.literal(1).default(1),
mode: knowledgeChunkingModeSchema.default('structure'),
targetCharacters: z.number().int().min(400).max(8_000).default(1_600),
overlapCharacters: z.number().int().min(0).max(3_200).default(160),
parentCharacters: z.number().int().min(1_600).max(16_000).default(4_800),
childCharacters: z.number().int().min(300).max(4_000).default(900),
contextualIndexingEnabled: z.boolean().default(false)
})
.strict()
.superRefine((value, context) => {
if (value.overlapCharacters > value.targetCharacters * 0.4) {
context.addIssue({
code: 'custom',
path: ['overlapCharacters'],
message: 'overlapCharacters must not exceed 40% of targetCharacters'
})
}
if (value.childCharacters > value.parentCharacters) {
context.addIssue({
code: 'custom',
path: ['childCharacters'],
message: 'childCharacters must not exceed parentCharacters'
})
}
})
export type KnowledgeChunkingSettings = z.infer<
typeof knowledgeChunkingSettingsSchema
>
export const defaultKnowledgeChunkingSettings =
knowledgeChunkingSettingsSchema.parse({})
export const knowledgeChunkRoleSchema = z.enum([
'standalone',
'parent',
'child'
])
export type KnowledgeChunkRole = z.infer<typeof knowledgeChunkRoleSchema>
export const knowledgeRetrieveInputSchema = z
.object({
knowledgeBaseId: idSchema,
query: boundedTextSchema(4_000),
settings: knowledgeRetrievalSettingsSchema.optional()
})
.strict()
export type KnowledgeRetrieveInput = z.infer<
typeof knowledgeRetrieveInputSchema
>
const optionalRankSchema = z.number().int().positive().max(1_000_000).optional()
const channelScoresSchema = z
.object({
ftsRank: optionalRankSchema,
cjkRank: optionalRankSchema,
vectorRank: optionalRankSchema,
graphRank: optionalRankSchema,
vectorSimilarity: z.number().finite().min(-1).max(1).optional(),
fusedScore: z.number().finite().nonnegative(),
phraseMatch: z.boolean().optional(),
tokenCoverage: z.number().finite().min(0).max(1).optional(),
duplicatePenalty: z.number().finite().min(0).max(1).optional(),
rerankScore: z.number().finite().min(0).max(1).optional()
})
.strict()
export const knowledgeRetrievalResultSchema = z
.object({
knowledgeBaseId: idSchema,
documentId: idSchema,
sourceId: idSchema,
chunkId: idSchema,
parentChunkId: idSchema.optional(),
documentTitle: z.string().max(512),
sourceDisplayName: z.string().max(512),
sourceType: z.enum(['file', 'directory', 'url']),
heading: z.string().max(512).optional(),
location: z.string().max(8_192).optional(),
snippet: z.string().max(8_000),
relevance: z.number().finite().min(0).max(1),
rank: z.number().int().positive().max(20),
preRerankRank: optionalRankSchema,
channels: z.array(knowledgeRetrievalChannelSchema).min(1).max(4),
scores: channelScoresSchema
})
.strict()
export type KnowledgeRetrievalResult = z.infer<
typeof knowledgeRetrievalResultSchema
>
export const knowledgeContextGroupSchema = z
.object({
resultChunkId: idSchema,
chunkIds: z.array(idSchema).min(1).max(20),
documentId: idSchema,
content: z.string().max(48_000),
characterCount: z.number().int().nonnegative().max(48_000),
truncated: z.boolean()
})
.strict()
export type KnowledgeContextGroup = z.infer<typeof knowledgeContextGroupSchema>
const channelCountSchema = z
.object({
fts: z.number().int().nonnegative().optional(),
cjk: z.number().int().nonnegative().optional(),
vector: z.number().int().nonnegative().optional(),
graph: z.number().int().nonnegative().optional()
})
.strict()
const channelTimingSchema = z
.object({
fts: z.number().int().nonnegative().optional(),
cjk: z.number().int().nonnegative().optional(),
vector: z.number().int().nonnegative().optional(),
graph: z.number().int().nonnegative().optional()
})
.strict()
export const knowledgeRetrievalDiagnosticsSchema = z
.object({
requestedChannels: z.array(knowledgeRetrievalChannelSchema).max(4),
usedChannels: z.array(knowledgeRetrievalChannelSchema).max(4),
degradedChannels: z
.array(
z
.object({
channel: knowledgeRetrievalChannelSchema,
reason: z.string().trim().min(1).max(500)
})
.strict()
)
.max(8),
candidateCounts: channelCountSchema,
channelDurationMs: channelTimingSchema,
vectorScannedCount: z.number().int().nonnegative(),
filteredByThresholdCount: z.number().int().nonnegative(),
filteredByBudgetCount: z.number().int().nonnegative(),
rerank: rerankExecutionDiagnosticsSchema.default({
requested: 'none',
used: 'none',
status: 'skipped',
candidateCount: 0,
durationMs: 0
})
})
.strict()
export const knowledgeRetrievalResponseSchema = z
.object({
query: boundedTextSchema(4_000),
durationMs: z.number().int().nonnegative(),
settings: knowledgeRetrievalSettingsSchema,
diagnostics: knowledgeRetrievalDiagnosticsSchema,
results: z.array(knowledgeRetrievalResultSchema).max(20),
context: z
.object({
characterCount: z.number().int().nonnegative().max(48_000),
truncated: z.boolean(),
groups: z.array(knowledgeContextGroupSchema).max(20)
})
.strict()
})
.strict()
export type KnowledgeRetrievalResponse = z.infer<
typeof knowledgeRetrievalResponseSchema
>
export const knowledgeSettingsUpdateInputSchema = z
.object({
knowledgeBaseId: idSchema,
retrieval: knowledgeRetrievalSettingsSchema.optional(),
chunking: knowledgeChunkingSettingsSchema.optional(),
ontology: knowledgeOntologySettingsSchema.optional()
})
.strict()
.refine((value) =>
value.retrieval !== undefined ||
value.chunking !== undefined ||
value.ontology !== undefined, {
message: 'at least one settings group is required'
})
export type KnowledgeSettingsUpdateInput = z.infer<
typeof knowledgeSettingsUpdateInputSchema
>
export const knowledgeChunksListInputSchema = z
.object({
knowledgeBaseId: idSchema,
documentId: idSchema,
page: z.number().int().min(1).max(1_000_000).default(1),
pageSize: z.number().int().min(1).max(200).default(50),
search: z.string().trim().max(1_000).optional()
})
.strict()
export type KnowledgeChunksListInput = z.infer<
typeof knowledgeChunksListInputSchema
>
export const knowledgeChunkUpdateInputSchema = z
.object({
knowledgeBaseId: idSchema,
documentId: idSchema,
chunkId: idSchema,
content: boundedTextSchema(2_000_000).optional(),
enabled: z.boolean().optional()
})
.strict()
.refine((value) => value.content !== undefined || value.enabled !== undefined, {
message: 'content or enabled is required'
})
export type KnowledgeChunkUpdateInput = z.infer<
typeof knowledgeChunkUpdateInputSchema
>
export const knowledgeChunkDeleteInputSchema = z
.object({
knowledgeBaseId: idSchema,
documentId: idSchema,
chunkId: idSchema
})
.strict()
export type KnowledgeChunkDeleteInput = z.infer<
typeof knowledgeChunkDeleteInputSchema
>
export const knowledgeDocumentRebuildInputSchema = z
.object({
knowledgeBaseId: idSchema,
documentId: idSchema
})
.strict()
export type KnowledgeDocumentRebuildInput = z.infer<
typeof knowledgeDocumentRebuildInputSchema
>
export const knowledgeLibraryRebuildInputSchema = z
.object({
knowledgeBaseId: idSchema
})
.strict()
export type KnowledgeLibraryRebuildInput = z.infer<
typeof knowledgeLibraryRebuildInputSchema
>
export const knowledgeReferenceContextInputSchema =
knowledgeChunkDeleteInputSchema
export type KnowledgeReferenceContextInput = z.infer<
typeof knowledgeReferenceContextInputSchema
>
export const knowledgeReferenceOpenInputSchema =
knowledgeChunkDeleteInputSchema
export type KnowledgeReferenceOpenInput = z.infer<
typeof knowledgeReferenceOpenInputSchema
>
export const knowledgeManagedChunkSchema = z
.object({
id: idSchema,
ordinal: z.number().int().nonnegative(),
role: knowledgeChunkRoleSchema,
parentChunkId: idSchema.optional(),
heading: z.string().max(512).optional(),
locator: z.string().max(8_192).optional(),
characterCount: z.number().int().nonnegative().max(2_000_000),
enabled: z.boolean(),
content: z.string().max(2_000_000),
manuallyEdited: z.boolean(),
updatedAt: z.string().datetime().optional()
})
.strict()
export type KnowledgeManagedChunk = z.infer<
typeof knowledgeManagedChunkSchema
>
export const knowledgeChunkPageSchema = z
.object({
items: z.array(knowledgeManagedChunkSchema).max(200),
page: z.number().int().min(1).max(1_000_000),
pageSize: z.number().int().min(1).max(200),
totalItems: z.number().int().nonnegative()
})
.strict()
export type KnowledgeChunkPage = z.infer<typeof knowledgeChunkPageSchema>
export const knowledgeReferenceContextSchema = z
.object({
knowledgeBaseId: idSchema,
documentId: idSchema,
chunkId: idSchema,
documentTitle: z.string().max(512),
sourceDisplayName: z.string().max(512),
locator: z.string().max(8_192).optional(),
matchedContent: z.string().max(48_000),
contextContent: z.string().max(48_000),
contextChunkIds: z.array(idSchema).max(5),
truncated: z.boolean()
})
.strict()
export type KnowledgeReferenceContext = z.infer<
typeof knowledgeReferenceContextSchema
>
export {
defaultKnowledgeOntologySettings,
knowledgeOntologySettingsSchema
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import {
defaultKnowledgeOntologySettings,
getKnowledgeOntologyDisplayDefinitions,
isRelationEndpointAllowed,
knowledgeOntologySettingsSchema,
normalizeEntityTypeAlias,
normalizeRelationTypeAlias
} from './knowledge-ontology'
describe('knowledge ontology contract', () => {
it('provides the version 1 controlled defaults and useful aliases', () => {
expect(defaultKnowledgeOntologySettings.version).toBe(1)
expect(
defaultKnowledgeOntologySettings.entityTypes.map(({ id }) => id)
).toEqual([
'PERSON',
'ORGANIZATION',
'EVENT',
'LOCATION',
'DOCUMENT',
'CONCEPT'
])
expect(normalizeEntityTypeAlias('people')).toBe('PERSON')
expect(normalizeEntityTypeAlias('人员')).toBe('PERSON')
expect(normalizeEntityTypeAlias('公司')).toBe('ORGANIZATION')
expect(normalizeEntityTypeAlias('uncontrolled legacy type')).toBe('CONCEPT')
expect(normalizeRelationTypeAlias('depends on')).toBe('DEPENDS_ON')
expect(normalizeRelationTypeAlias('依赖于')).toBe('DEPENDS_ON')
})
it('rejects noncanonical ids, collisions, missing fallback, and bad endpoints', () => {
const concept = {
id: 'CONCEPT',
name: { zh: '概念', en: 'Concept' },
aliases: ['topic']
}
expect(() =>
knowledgeOntologySettingsSchema.parse({
entityTypes: [concept, { ...concept, id: 'person' }],
relationTypes: []
})
).toThrow()
expect(() =>
knowledgeOntologySettingsSchema.parse({
entityTypes: [
concept,
{
id: 'PERSON',
name: { zh: '人物', en: 'Person' },
aliases: ['shared']
},
{
id: 'ORGANIZATION',
name: { zh: '组织', en: 'Organization' },
aliases: ['shared']
}
],
relationTypes: []
})
).toThrow()
expect(() =>
knowledgeOntologySettingsSchema.parse({
entityTypes: [
{
id: 'PERSON',
name: { zh: '人物', en: 'Person' },
aliases: []
}
],
relationTypes: []
})
).toThrow()
expect(() =>
knowledgeOntologySettingsSchema.parse({
entityTypes: [concept],
relationTypes: [
{
id: 'KNOWS',
name: { zh: '认识', en: 'Knows' },
aliases: [],
sourceTypes: ['MISSING']
}
]
})
).toThrow()
})
it('enforces optional relation endpoint constraints', () => {
const settings = knowledgeOntologySettingsSchema.parse({
entityTypes: [
{
id: 'CONCEPT',
name: { zh: '概念', en: 'Concept' },
aliases: []
},
{
id: 'PERSON',
name: { zh: '人物', en: 'Person' },
aliases: ['people']
},
{
id: 'ORGANIZATION',
name: { zh: '组织', en: 'Organization' },
aliases: ['company']
}
],
relationTypes: [
{
id: 'WORKS_FOR',
name: { zh: '任职于', en: 'Works for' },
aliases: ['works for'],
sourceTypes: ['PERSON'],
targetTypes: ['ORGANIZATION']
}
]
})
expect(isRelationEndpointAllowed('works for', 'people', 'company', settings)).toBe(
true
)
expect(
isRelationEndpointAllowed('WORKS_FOR', 'ORGANIZATION', 'PERSON', settings)
).toBe(false)
expect(isRelationEndpointAllowed('UNKNOWN', 'PERSON', 'ORGANIZATION', settings)).toBe(
false
)
})
it('returns localized, detached display data', () => {
const display = getKnowledgeOntologyDisplayDefinitions(
defaultKnowledgeOntologySettings,
'en'
)
expect(display.fallbackEntityType).toBe('CONCEPT')
expect(display.entityTypes.find(({ id }) => id === 'PERSON')?.label).toBe(
'Person'
)
display.entityTypes[0]?.aliases.push('local mutation')
expect(defaultKnowledgeOntologySettings.entityTypes[0]?.aliases).not.toContain(
'local mutation'
)
})
})
+438
View File
@@ -0,0 +1,438 @@
import { z } from 'zod'
export const KNOWLEDGE_ONTOLOGY_LIMITS = {
maximumEntityTypes: 64,
maximumRelationTypes: 128,
maximumAliases: 32,
maximumEndpointTypes: 64,
maximumIdLength: 64,
maximumLabelLength: 80,
maximumDescriptionLength: 500,
maximumAliasLength: 80
} as const
const canonicalIdSchema = z
.string()
.min(1)
.max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumIdLength)
.regex(/^[A-Z][A-Z0-9_]*$/, 'must be a canonical uppercase identifier')
const localizedTextSchema = z
.object({
zh: z.string().trim().min(1).max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumLabelLength),
en: z.string().trim().min(1).max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumLabelLength)
})
.strict()
const localizedDescriptionSchema = z
.object({
zh: z
.string()
.trim()
.min(1)
.max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumDescriptionLength),
en: z
.string()
.trim()
.min(1)
.max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumDescriptionLength)
})
.strict()
const aliasesSchema = z
.array(
z.string().trim().min(1).max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumAliasLength)
)
.max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumAliases)
.default([])
export const knowledgeOntologyEntityTypeSchema = z
.object({
id: canonicalIdSchema,
name: localizedTextSchema,
description: localizedDescriptionSchema.optional(),
aliases: aliasesSchema
})
.strict()
export type KnowledgeOntologyEntityType = z.infer<
typeof knowledgeOntologyEntityTypeSchema
>
export const knowledgeOntologyRelationTypeSchema = z
.object({
id: canonicalIdSchema,
name: localizedTextSchema,
description: localizedDescriptionSchema.optional(),
aliases: aliasesSchema,
sourceTypes: z
.array(canonicalIdSchema)
.min(1)
.max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumEndpointTypes)
.optional(),
targetTypes: z
.array(canonicalIdSchema)
.min(1)
.max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumEndpointTypes)
.optional()
})
.strict()
export type KnowledgeOntologyRelationType = z.infer<
typeof knowledgeOntologyRelationTypeSchema
>
/**
* Normalizes user/model spelling for lookup only. Canonical ids in persisted
* settings remain strict uppercase ids.
*/
export function normalizeOntologyAlias(value: string): string {
return value
.normalize('NFKC')
.trim()
.toLocaleLowerCase('en-US')
.replace(/[\s-]+/g, '_')
}
function addUniquenessIssues(
definitions: readonly {
id: string
aliases: readonly string[]
}[],
path: 'entityTypes' | 'relationTypes',
context: z.RefinementCtx
): void {
const owners = new Map<string, { id: string; index: number }>()
for (const [index, definition] of definitions.entries()) {
const key = normalizeOntologyAlias(definition.id)
const owner = owners.get(key)
if (owner) {
context.addIssue({
code: 'custom',
path: [path, index, 'id'],
message: `duplicate id ${definition.id}`
})
} else {
owners.set(key, { id: definition.id, index })
}
}
for (const [index, definition] of definitions.entries()) {
const localAliases = new Set<string>()
for (const value of definition.aliases) {
const key = normalizeOntologyAlias(value)
if (localAliases.has(key)) {
context.addIssue({
code: 'custom',
path: [path, index, 'aliases'],
message: `duplicate alias "${value}"`
})
continue
}
localAliases.add(key)
const owner = owners.get(key)
if (owner && owner.id !== definition.id) {
context.addIssue({
code: 'custom',
path: [path, index, 'aliases'],
message: `alias "${value}" already maps to ${owner.id}`
})
} else {
owners.set(key, { id: definition.id, index })
}
}
}
}
const defaultEntityTypesInput = [
{
id: 'PERSON',
name: { zh: '人物', en: 'Person' },
description: { zh: '个人或人物', en: 'An individual or person' },
aliases: ['person', 'persons', 'people', 'human', 'individual', '人员', '人物', '个人']
},
{
id: 'ORGANIZATION',
name: { zh: '组织', en: 'Organization' },
description: { zh: '公司、团队或机构', en: 'A company, team, or institution' },
aliases: [
'organization',
'organisation',
'org',
'company',
'business',
'team',
'institution',
'组织',
'公司',
'企业',
'机构',
'团队'
]
},
{
id: 'EVENT',
name: { zh: '事件', en: 'Event' },
description: { zh: '发生的活动或事件', en: 'An activity or occurrence' },
aliases: ['event', 'occurrence', 'activity', '事件', '活动']
},
{
id: 'LOCATION',
name: { zh: '地点', en: 'Location' },
description: { zh: '地理或虚拟位置', en: 'A geographic or virtual place' },
aliases: ['location', 'place', 'site', 'address', '地点', '位置', '地址']
},
{
id: 'DOCUMENT',
name: { zh: '文档', en: 'Document' },
description: { zh: '文档、文件或出版物', en: 'A document, file, or publication' },
aliases: ['document', 'doc', 'file', 'publication', '文档', '文件', '资料']
},
{
id: 'CONCEPT',
name: { zh: '概念', en: 'Concept' },
description: { zh: '其他概念或主题', en: 'Any other concept or topic' },
aliases: ['concept', 'topic', 'subject', 'thing', '概念', '主题', '事物']
}
]
const defaultRelationTypesInput = [
{
id: 'DEPENDS_ON',
name: { zh: '依赖于', en: 'Depends on' },
aliases: ['depends on', 'depends upon', 'requires', '依赖', '依赖于', '需要']
},
{
id: 'USES',
name: { zh: '使用', en: 'Uses' },
aliases: ['uses', 'use', '使用']
},
{
id: 'CALLS',
name: { zh: '调用', en: 'Calls' },
aliases: ['calls', 'call', '调用']
},
{
id: 'IMPORTS',
name: { zh: '导入', en: 'Imports' },
aliases: ['imports', 'import', '导入']
},
{
id: 'EXTENDS',
name: { zh: '继承', en: 'Extends' },
aliases: ['extends', 'inherits from', '继承', '继承自']
},
{
id: 'IMPLEMENTS',
name: { zh: '实现', en: 'Implements' },
aliases: ['implements', 'implement', '实现']
},
{
id: 'CONTAINS',
name: { zh: '包含', en: 'Contains' },
aliases: ['contains', 'includes', '包含', '包括']
},
{
id: 'BELONGS_TO',
name: { zh: '属于', en: 'Belongs to' },
aliases: ['belongs to', 'is part of', '属于']
},
{
id: 'CONNECTS_TO',
name: { zh: '连接到', en: 'Connects to' },
aliases: ['connects to', 'connects', '连接到', '连接']
},
{
id: 'RELATED_TO',
name: { zh: '相关', en: 'Related to' },
aliases: ['related to', 'relates to', '相关', '相关于']
}
]
export const knowledgeOntologySettingsSchema = z
.object({
version: z.literal(1).default(1),
entityTypes: z
.array(knowledgeOntologyEntityTypeSchema)
.min(1)
.max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumEntityTypes)
.default(
defaultEntityTypesInput.map((definition) => ({
...definition,
name: { ...definition.name },
description: { ...definition.description },
aliases: [...definition.aliases]
}))
),
relationTypes: z
.array(knowledgeOntologyRelationTypeSchema)
.max(KNOWLEDGE_ONTOLOGY_LIMITS.maximumRelationTypes)
.default(
defaultRelationTypesInput.map((definition) => ({
...definition,
name: { ...definition.name },
aliases: [...definition.aliases]
}))
)
})
.strict()
.superRefine((value, context) => {
addUniquenessIssues(value.entityTypes, 'entityTypes', context)
addUniquenessIssues(value.relationTypes, 'relationTypes', context)
const entityIds = new Set(value.entityTypes.map((definition) => definition.id))
if (!entityIds.has('CONCEPT')) {
context.addIssue({
code: 'custom',
path: ['entityTypes'],
message: 'CONCEPT is required as the fallback entity type'
})
}
for (const [index, relation] of value.relationTypes.entries()) {
for (const field of ['sourceTypes', 'targetTypes'] as const) {
const seen = new Set<string>()
for (const endpointType of relation[field] ?? []) {
if (seen.has(endpointType)) {
context.addIssue({
code: 'custom',
path: ['relationTypes', index, field],
message: `duplicate endpoint type ${endpointType}`
})
}
seen.add(endpointType)
if (!entityIds.has(endpointType)) {
context.addIssue({
code: 'custom',
path: ['relationTypes', index, field],
message: `unknown endpoint type ${endpointType}`
})
}
}
}
}
})
export type KnowledgeOntologySettings = z.infer<
typeof knowledgeOntologySettingsSchema
>
export const defaultKnowledgeOntologySettings =
knowledgeOntologySettingsSchema.parse({})
export function resolveKnowledgeOntologySettings(
settings?: KnowledgeOntologySettings
): KnowledgeOntologySettings {
return settings
? knowledgeOntologySettingsSchema.parse(settings)
: defaultKnowledgeOntologySettings
}
function aliasMap(
definitions: readonly { id: string; aliases: readonly string[] }[]
): ReadonlyMap<string, string> {
return new Map(
definitions.flatMap((definition) =>
[definition.id, ...definition.aliases].map(
(alias) => [normalizeOntologyAlias(alias), definition.id] as const
)
)
)
}
export function normalizeEntityTypeAlias(
value: string | undefined,
settings: KnowledgeOntologySettings = defaultKnowledgeOntologySettings
): string {
if (!value) {
return 'CONCEPT'
}
return (
aliasMap(settings.entityTypes).get(normalizeOntologyAlias(value)) ??
'CONCEPT'
)
}
export function normalizeRelationTypeAlias(
value: string | undefined,
settings: KnowledgeOntologySettings = defaultKnowledgeOntologySettings
): string | undefined {
if (!value) {
return undefined
}
return aliasMap(settings.relationTypes).get(normalizeOntologyAlias(value))
}
export function isRelationEndpointAllowed(
relationType: string,
sourceType: string,
targetType: string,
settings: KnowledgeOntologySettings = defaultKnowledgeOntologySettings
): boolean {
const canonicalRelation = normalizeRelationTypeAlias(relationType, settings)
if (!canonicalRelation) {
return false
}
const definition = settings.relationTypes.find(
(candidate) => candidate.id === canonicalRelation
)
if (!definition) {
return false
}
const canonicalSource = normalizeEntityTypeAlias(sourceType, settings)
const canonicalTarget = normalizeEntityTypeAlias(targetType, settings)
return (
(!definition.sourceTypes ||
definition.sourceTypes.includes(canonicalSource)) &&
(!definition.targetTypes ||
definition.targetTypes.includes(canonicalTarget))
)
}
export type KnowledgeOntologyDisplayLocale = 'zh' | 'en'
export interface KnowledgeOntologyDisplayDefinition {
id: string
label: string
description?: string
aliases: string[]
}
export interface KnowledgeOntologyRelationDisplayDefinition
extends KnowledgeOntologyDisplayDefinition {
sourceTypes?: string[]
targetTypes?: string[]
}
export interface KnowledgeOntologyDisplayDefinitions {
version: 1
fallbackEntityType: 'CONCEPT'
entityTypes: KnowledgeOntologyDisplayDefinition[]
relationTypes: KnowledgeOntologyRelationDisplayDefinition[]
}
export function getKnowledgeOntologyDisplayDefinitions(
settings: KnowledgeOntologySettings = defaultKnowledgeOntologySettings,
locale: KnowledgeOntologyDisplayLocale = 'zh'
): KnowledgeOntologyDisplayDefinitions {
return {
version: 1,
fallbackEntityType: 'CONCEPT',
entityTypes: settings.entityTypes.map((definition) => ({
id: definition.id,
label: definition.name[locale],
...(definition.description
? { description: definition.description[locale] }
: {}),
aliases: [...definition.aliases]
})),
relationTypes: settings.relationTypes.map((definition) => ({
id: definition.id,
label: definition.name[locale],
...(definition.description
? { description: definition.description[locale] }
: {}),
aliases: [...definition.aliases],
...(definition.sourceTypes
? { sourceTypes: [...definition.sourceTypes] }
: {}),
...(definition.targetTypes
? { targetTypes: [...definition.targetTypes] }
: {})
}))
}
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
knowledgeTaskActionInputSchema,
knowledgeTaskItemSchema
} from './knowledge-task-contracts'
const task = {
id: '11111111-1111-4111-8111-111111111111',
libraryId: 'library-1',
documentName: '产品手册',
scope: 'source',
kind: 'source-sync',
stage: 'parsing',
status: 'running',
progress: 40,
completedItems: 2,
totalItems: 5,
attempt: 1,
canCancel: true,
canRetry: false,
createdAt: '2026-08-12T08:00:00.000Z',
startedAt: '2026-08-12T08:00:01.000Z',
updatedAt: '2026-08-12T08:00:02.000Z'
} as const
describe('knowledge task contracts', () => {
it('accepts an active scoped processing task', () => {
expect(knowledgeTaskItemSchema.parse(task)).toEqual(task)
})
it('requires terminal and failed task details', () => {
expect(() =>
knowledgeTaskItemSchema.parse({
...task,
status: 'failed',
canCancel: false,
canRetry: true
})
).toThrow()
expect(
knowledgeTaskItemSchema.parse({
...task,
status: 'failed',
progress: 75,
error: {
message: '文档解析失败',
remedy: '检查文件后重试'
},
canCancel: false,
canRetry: true,
completedAt: '2026-08-12T08:01:00.000Z',
updatedAt: '2026-08-12T08:01:00.000Z'
}).error
).toEqual({
message: '文档解析失败',
remedy: '检查文件后重试'
})
})
it('validates bounded task action identifiers', () => {
expect(
knowledgeTaskActionInputSchema.parse({
taskId: '22222222-2222-4222-8222-222222222222'
})
).toEqual({
taskId: '22222222-2222-4222-8222-222222222222'
})
expect(() =>
knowledgeTaskActionInputSchema.parse({ taskId: 'not-a-uuid' })
).toThrow()
})
})
+137
View File
@@ -0,0 +1,137 @@
import { z } from 'zod'
const taskIdSchema = z.string().uuid()
const boundedTaskTextSchema = z.string().trim().min(1).max(1_000)
const optionalTimestampSchema = z.string().datetime().optional()
export const knowledgeTaskScopeSchema = z.enum([
'library',
'source',
'document'
])
export type KnowledgeTaskScope = z.infer<typeof knowledgeTaskScopeSchema>
export const knowledgeTaskKindSchema = z.enum([
'source-sync',
'document-process',
'document-rebuild',
'library-rebuild',
'embedding-rebuild',
'graph-rebuild',
'parsing',
'embedding',
'graph'
])
export type KnowledgeTaskKind = z.infer<typeof knowledgeTaskKindSchema>
export const knowledgeTaskStageSchema = z.enum([
'queued',
'syncing',
'reading',
'parsing',
'chunking',
'indexing',
'embedding',
'graph',
'finalizing'
])
export type KnowledgeTaskStage = z.infer<typeof knowledgeTaskStageSchema>
export const knowledgeTaskStatusSchema = z.enum([
'queued',
'running',
'succeeded',
'failed',
'cancelled',
'skipped',
'interrupted'
])
export type KnowledgeTaskStatus = z.infer<typeof knowledgeTaskStatusSchema>
export const knowledgeTaskErrorSchema = z
.object({
message: boundedTaskTextSchema,
remedy: boundedTaskTextSchema.optional()
})
.strict()
export type KnowledgeTaskError = z.infer<typeof knowledgeTaskErrorSchema>
export const knowledgeTaskItemSchema = z
.object({
id: taskIdSchema,
libraryId: z.string().trim().min(1).max(128),
parentTaskId: taskIdSchema.optional(),
retryOfTaskId: taskIdSchema.optional(),
sourceId: z.string().trim().min(1).max(128).optional(),
documentId: z.string().trim().min(1).max(128).optional(),
documentName: z.string().trim().min(1).max(512),
scope: knowledgeTaskScopeSchema,
kind: knowledgeTaskKindSchema,
stage: knowledgeTaskStageSchema,
status: knowledgeTaskStatusSchema,
progress: z.number().int().min(0).max(100),
completedItems: z.number().int().nonnegative().optional(),
totalItems: z.number().int().nonnegative().optional(),
message: z.string().trim().max(1_000).optional(),
error: knowledgeTaskErrorSchema.optional(),
attempt: z.number().int().positive(),
canCancel: z.boolean(),
canRetry: z.boolean(),
embeddingJobId: z.string().trim().min(1).max(256).optional(),
createdAt: z.string().datetime(),
startedAt: optionalTimestampSchema,
completedAt: optionalTimestampSchema,
updatedAt: z.string().datetime()
})
.strict()
.superRefine((value, context) => {
if (
value.completedItems !== undefined &&
value.totalItems !== undefined &&
value.completedItems > value.totalItems
) {
context.addIssue({
code: 'custom',
message: 'completedItems must not exceed totalItems',
path: ['completedItems']
})
}
const terminal = [
'succeeded',
'failed',
'cancelled',
'skipped',
'interrupted'
].includes(value.status)
if (terminal && value.completedAt === undefined) {
context.addIssue({
code: 'custom',
message: 'terminal tasks must include completedAt',
path: ['completedAt']
})
}
if (value.status === 'failed' && value.error === undefined) {
context.addIssue({
code: 'custom',
message: 'failed tasks must include an error',
path: ['error']
})
}
if (value.status === 'succeeded' && value.progress !== 100) {
context.addIssue({
code: 'custom',
message: 'succeeded tasks must report 100 percent',
path: ['progress']
})
}
})
export type KnowledgeTaskItem = z.infer<typeof knowledgeTaskItemSchema>
export const knowledgeTaskActionInputSchema = z
.object({
taskId: taskIdSchema
})
.strict()
export type KnowledgeTaskActionInput = z.infer<
typeof knowledgeTaskActionInputSchema
>
+3
View File
@@ -0,0 +1,3 @@
export function stripKnowledgeHighlightTags(value: string): string {
return value.replace(/<\/?mark\b[^>]*>/giu, '')
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import {
rerankConfigurationSummarySchema,
rerankDiagnosticResultSchema,
rerankExecutionDiagnosticsSchema,
rerankSafeErrorSchema
} from './rerank-contracts'
describe('rerank contracts', () => {
it('publishes configuration without coupling it to credentials', () => {
expect(
rerankConfigurationSummarySchema.parse({
provider: 'cohere-compatible',
model: 'rerank-v3.5',
endpoint: 'https://api.example/v1/rerank',
credentialConfigured: true
})
).toEqual({
provider: 'cohere-compatible',
model: 'rerank-v3.5',
endpoint: 'https://api.example/v1/rerank',
credentialConfigured: true
})
expect(
rerankConfigurationSummarySchema.safeParse({
provider: 'cohere-compatible',
model: 'rerank-v3.5',
credentialConfigured: true,
apiKey: 'secret'
}).success
).toBe(false)
})
it('accepts safe available and unavailable diagnostics', () => {
expect(
rerankDiagnosticResultSchema.safeParse({
status: 'available',
provider: 'cohere-compatible',
model: 'rerank-v3.5',
checkedAt: 1_700_000_000_000,
latencyMs: 82
}).success
).toBe(true)
expect(
rerankDiagnosticResultSchema.safeParse({
status: 'unavailable',
provider: 'cohere-compatible',
model: 'rerank-v3.5',
checkedAt: 1_700_000_000_000,
latencyMs: 82,
error: {
code: 'authentication',
message: '重排服务身份验证失败。',
retryable: false
}
}).success
).toBe(true)
})
it('bounds safe errors and rejects raw provider details', () => {
expect(
rerankSafeErrorSchema.safeParse({
code: 'unknown',
message: 'x'.repeat(501),
retryable: false
}).success
).toBe(false)
expect(
rerankSafeErrorSchema.safeParse({
code: 'authentication',
message: '重排服务身份验证失败。',
retryable: false,
rawResponse: '{"token":"secret"}'
}).success
).toBe(false)
})
it('describes requested, used and fallback rerank modes safely', () => {
expect(
rerankExecutionDiagnosticsSchema.parse({
requested: 'learned',
used: 'learned',
status: 'applied',
candidateCount: 24,
durationMs: 91,
model: 'rerank-v3.5'
})
).toMatchObject({ status: 'applied', used: 'learned' })
expect(
rerankExecutionDiagnosticsSchema.safeParse({
requested: 'learned',
used: 'local',
status: 'fallback',
candidateCount: 24,
durationMs: 91,
reason: '服务暂时不可用。'
}).success
).toBe(true)
expect(
rerankExecutionDiagnosticsSchema.safeParse({
requested: 'learned',
used: 'learned',
status: 'fallback',
candidateCount: 24,
durationMs: 91,
model: 'rerank-v3.5'
}).success
).toBe(false)
expect(
rerankExecutionDiagnosticsSchema.safeParse({
requested: 'none',
used: 'none',
status: 'skipped',
candidateCount: 101,
durationMs: 0
}).success
).toBe(false)
})
})
+132
View File
@@ -0,0 +1,132 @@
import { z } from 'zod'
import { safeProviderEndpointSchema } from './embedding-contracts'
const boundedLabelSchema = z.string().trim().min(1).max(256)
const boundedReasonSchema = z.string().trim().min(1).max(500)
const timestampSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
const countSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
export const rerankModeSchema = z.enum(['none', 'local', 'learned'])
export type RerankMode = z.infer<typeof rerankModeSchema>
export const rerankErrorCodeSchema = z.enum([
'model_not_found',
'authentication',
'rate_limited',
'timeout',
'network',
'provider_unavailable',
'invalid_configuration',
'invalid_response',
'cancelled',
'unknown'
])
export type RerankErrorCode = z.infer<typeof rerankErrorCodeSchema>
export const rerankSafeErrorSchema = z
.object({
code: rerankErrorCodeSchema,
message: boundedReasonSchema,
retryable: z.boolean(),
remedy: boundedReasonSchema.optional()
})
.strict()
export type RerankSafeError = z.infer<typeof rerankSafeErrorSchema>
export const rerankConfigurationSummarySchema = z
.object({
provider: boundedLabelSchema,
model: boundedLabelSchema,
endpoint: safeProviderEndpointSchema.optional(),
credentialConfigured: z.boolean()
})
.strict()
export type RerankConfigurationSummary = z.infer<
typeof rerankConfigurationSummarySchema
>
const rerankDiagnosticBase = {
provider: boundedLabelSchema,
model: boundedLabelSchema,
checkedAt: timestampSchema,
latencyMs: countSchema
}
export const rerankDiagnosticResultSchema = z.discriminatedUnion('status', [
z
.object({
...rerankDiagnosticBase,
status: z.literal('available')
})
.strict(),
z
.object({
...rerankDiagnosticBase,
status: z.literal('unavailable'),
error: rerankSafeErrorSchema
})
.strict()
])
export type RerankDiagnosticResult = z.infer<
typeof rerankDiagnosticResultSchema
>
export const rerankExecutionStatusSchema = z.enum([
'skipped',
'applied',
'fallback',
'failed'
])
export type RerankExecutionStatus = z.infer<
typeof rerankExecutionStatusSchema
>
/**
* Safe, bounded telemetry for one retrieval execution. `requested` records
* user intent while `used` records the algorithm that actually produced the
* final ordering.
*/
export const rerankExecutionDiagnosticsSchema = z
.object({
requested: rerankModeSchema,
used: rerankModeSchema,
status: rerankExecutionStatusSchema,
candidateCount: countSchema.max(100),
durationMs: countSchema,
model: boundedLabelSchema.optional(),
reason: boundedReasonSchema.optional()
})
.strict()
.superRefine((value, context) => {
if (value.status === 'skipped' && value.used !== 'none') {
context.addIssue({
code: 'custom',
message: 'a skipped rerank must not report an algorithm used',
path: ['used']
})
}
if (value.status === 'applied' && value.used === 'none') {
context.addIssue({
code: 'custom',
message: 'an applied rerank must report the algorithm used',
path: ['used']
})
}
if (value.status === 'fallback' && value.requested === value.used) {
context.addIssue({
code: 'custom',
message: 'a fallback must differ from the requested mode',
path: ['used']
})
}
if (value.used === 'learned' && value.model === undefined) {
context.addIssue({
code: 'custom',
message: 'a learned rerank must identify its model',
path: ['model']
})
}
})
export type RerankExecutionDiagnostics = z.infer<
typeof rerankExecutionDiagnosticsSchema
>