feat: enhance local knowledge retrieval
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
>
|
||||
@@ -0,0 +1,3 @@
|
||||
export function stripKnowledgeHighlightTags(value: string): string {
|
||||
return value.replace(/<\/?mark\b[^>]*>/giu, '')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
>
|
||||
Reference in New Issue
Block a user