dsh-data-domain-knowledge 0.1.0-alpha.11 → 0.1.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +64 -13
  2. package/eval/README.md +19 -8
  3. package/eval/evaluate-retrieval.mjs +17 -8
  4. package/eval/queries.example.jsonl +1 -0
  5. package/lib/client.js +2294 -2096
  6. package/lib/doc-page.js +1 -1
  7. package/lib/pages.js +2 -2
  8. package/lib/types/axis-registry.d.ts +9 -4
  9. package/lib/types/axis-registry.d.ts.map +1 -1
  10. package/lib/types/axis-registry.js +62 -23
  11. package/lib/types/axis-registry.js.map +1 -1
  12. package/lib/types/card-organize.d.ts +5 -0
  13. package/lib/types/card-organize.d.ts.map +1 -1
  14. package/lib/types/card-organize.js +15 -0
  15. package/lib/types/card-organize.js.map +1 -1
  16. package/lib/types/compiler/embedding-identity.d.ts +3 -0
  17. package/lib/types/compiler/embedding-identity.d.ts.map +1 -0
  18. package/lib/types/compiler/embedding-identity.js +24 -0
  19. package/lib/types/compiler/embedding-identity.js.map +1 -0
  20. package/lib/types/compiler/snapshot.d.ts +16 -0
  21. package/lib/types/compiler/snapshot.d.ts.map +1 -0
  22. package/lib/types/compiler/snapshot.js +2 -0
  23. package/lib/types/compiler/snapshot.js.map +1 -0
  24. package/lib/types/diagnosis/store.d.ts.map +1 -1
  25. package/lib/types/diagnosis/store.js +2 -0
  26. package/lib/types/diagnosis/store.js.map +1 -1
  27. package/lib/types/environment/install.d.ts +7 -0
  28. package/lib/types/environment/install.d.ts.map +1 -0
  29. package/lib/types/environment/install.js +172 -0
  30. package/lib/types/environment/install.js.map +1 -0
  31. package/lib/types/environment/manifest.d.ts +208 -0
  32. package/lib/types/environment/manifest.d.ts.map +1 -0
  33. package/lib/types/environment/manifest.js +26 -0
  34. package/lib/types/environment/manifest.js.map +1 -0
  35. package/lib/types/environment/model-catalog.d.ts +191 -0
  36. package/lib/types/environment/model-catalog.d.ts.map +1 -0
  37. package/lib/types/environment/model-catalog.js +91 -0
  38. package/lib/types/environment/model-catalog.js.map +1 -0
  39. package/lib/types/environment/process.d.ts +10 -0
  40. package/lib/types/environment/process.d.ts.map +1 -0
  41. package/lib/types/environment/process.js +54 -0
  42. package/lib/types/environment/process.js.map +1 -0
  43. package/lib/types/environment/selection.d.ts +9 -0
  44. package/lib/types/environment/selection.d.ts.map +1 -0
  45. package/lib/types/environment/selection.js +29 -0
  46. package/lib/types/environment/selection.js.map +1 -0
  47. package/lib/types/environment/service.d.ts +47 -0
  48. package/lib/types/environment/service.d.ts.map +1 -0
  49. package/lib/types/environment/service.js +253 -0
  50. package/lib/types/environment/service.js.map +1 -0
  51. package/lib/types/environment/types.d.ts +37 -0
  52. package/lib/types/environment/types.d.ts.map +1 -0
  53. package/lib/types/environment/types.js +2 -0
  54. package/lib/types/environment/types.js.map +1 -0
  55. package/lib/types/events.d.ts +10 -0
  56. package/lib/types/events.d.ts.map +1 -1
  57. package/lib/types/events.js +38 -1
  58. package/lib/types/events.js.map +1 -1
  59. package/lib/types/images.d.ts +26 -0
  60. package/lib/types/images.d.ts.map +1 -0
  61. package/lib/types/images.js +196 -0
  62. package/lib/types/images.js.map +1 -0
  63. package/lib/types/index.d.ts.map +1 -1
  64. package/lib/types/index.js +59 -3
  65. package/lib/types/index.js.map +1 -1
  66. package/lib/types/knowledge-bases.d.ts +3 -0
  67. package/lib/types/knowledge-bases.d.ts.map +1 -1
  68. package/lib/types/knowledge-bases.js +9 -0
  69. package/lib/types/knowledge-bases.js.map +1 -1
  70. package/lib/types/knowledge.d.ts +14 -6
  71. package/lib/types/knowledge.d.ts.map +1 -1
  72. package/lib/types/knowledge.js +56 -39
  73. package/lib/types/knowledge.js.map +1 -1
  74. package/lib/types/layout.d.ts +2 -0
  75. package/lib/types/layout.d.ts.map +1 -1
  76. package/lib/types/layout.js +2 -0
  77. package/lib/types/layout.js.map +1 -1
  78. package/lib/types/retrieval/vector-index.d.ts +31 -0
  79. package/lib/types/retrieval/vector-index.d.ts.map +1 -0
  80. package/lib/types/retrieval/vector-index.js +15 -0
  81. package/lib/types/retrieval/vector-index.js.map +1 -0
  82. package/lib/types/retrieval/visibility.d.ts +6 -0
  83. package/lib/types/retrieval/visibility.d.ts.map +1 -0
  84. package/lib/types/retrieval/visibility.js +11 -0
  85. package/lib/types/retrieval/visibility.js.map +1 -0
  86. package/lib/types/runtime.d.ts +36 -6
  87. package/lib/types/runtime.d.ts.map +1 -1
  88. package/lib/types/runtime.js +520 -190
  89. package/lib/types/runtime.js.map +1 -1
  90. package/lib/types/source-evidence.d.ts +6 -0
  91. package/lib/types/source-evidence.d.ts.map +1 -0
  92. package/lib/types/source-evidence.js +28 -0
  93. package/lib/types/source-evidence.js.map +1 -0
  94. package/lib/types/store/axis-references.d.ts +5 -0
  95. package/lib/types/store/axis-references.d.ts.map +1 -0
  96. package/lib/types/store/axis-references.js +106 -0
  97. package/lib/types/store/axis-references.js.map +1 -0
  98. package/lib/types/store/derived.d.ts +11 -0
  99. package/lib/types/store/derived.d.ts.map +1 -1
  100. package/lib/types/store/derived.js +31 -6
  101. package/lib/types/store/derived.js.map +1 -1
  102. package/lib/types/store/knowledge-db.d.ts +4 -1
  103. package/lib/types/store/knowledge-db.d.ts.map +1 -1
  104. package/lib/types/store/knowledge-db.js +15 -9
  105. package/lib/types/store/knowledge-db.js.map +1 -1
  106. package/lib/types/store/migrate.d.ts.map +1 -1
  107. package/lib/types/store/migrate.js +16 -5
  108. package/lib/types/store/migrate.js.map +1 -1
  109. package/lib/types/types.d.ts +7 -1
  110. package/lib/types/types.d.ts.map +1 -1
  111. package/lib/types/types.js +3 -4
  112. package/lib/types/types.js.map +1 -1
  113. package/package.json +3 -1
  114. package/python/prepare-models.py +19 -0
  115. package/python/requirements.txt +8 -6
  116. package/scripts/doctor.mjs +14 -2
  117. package/scripts/environment.mjs +29 -0
  118. package/scripts/setup-embedding.sh +11 -2
  119. package/src/axis-registry.ts +59 -23
  120. package/src/card-organize.ts +15 -0
  121. package/src/client/EnvironmentSetup.tsx +145 -0
  122. package/src/client/KnowledgePanel.tsx +9 -4
  123. package/src/compiler/embedding-identity.ts +21 -0
  124. package/src/compiler/snapshot.ts +17 -0
  125. package/src/diagnosis/store.ts +1 -0
  126. package/src/environment/install.ts +129 -0
  127. package/src/environment/manifest.ts +28 -0
  128. package/src/environment/model-catalog.ts +105 -0
  129. package/src/environment/process.ts +54 -0
  130. package/src/environment/selection.ts +29 -0
  131. package/src/environment/service.ts +211 -0
  132. package/src/environment/types.ts +34 -0
  133. package/src/events.ts +46 -0
  134. package/src/images.ts +203 -0
  135. package/src/index.ts +54 -3
  136. package/src/knowledge-bases.ts +9 -0
  137. package/src/knowledge.ts +56 -40
  138. package/src/layout.ts +2 -0
  139. package/src/retrieval/vector-index.ts +44 -0
  140. package/src/retrieval/visibility.ts +14 -0
  141. package/src/runtime.ts +473 -233
  142. package/src/source-evidence.ts +30 -0
  143. package/src/store/axis-references.ts +96 -0
  144. package/src/store/derived.ts +31 -6
  145. package/src/store/knowledge-db.ts +16 -9
  146. package/src/store/migrate.ts +15 -5
  147. package/src/types.ts +10 -5
@@ -0,0 +1,129 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { chmod, copyFile, mkdir, open, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { dirname, join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { modelsForSelection, UV_VERSION } from './manifest.js'
8
+ import { DEFAULT_MODEL_SELECTION, type ModelSelection } from './model-catalog.js'
9
+ import { writeModelSelection } from './selection.js'
10
+ import { runProcess, type ProcessOptions } from './process.js'
11
+
12
+ export const requirementsFile = fileURLToPath(new URL('../../../python/requirements.txt', import.meta.url))
13
+ export const setupScript = fileURLToPath(new URL('../../../python/prepare-models.py', import.meta.url))
14
+ export function managedPython(root: string): string { return process.platform === 'win32' ? join(root, 'python', 'Scripts', 'python.exe') : join(root, 'python', 'bin', 'python') }
15
+
16
+ async function download(url: string, signal: AbortSignal): Promise<Buffer> {
17
+ const response = await fetch(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(120_000)]) })
18
+ if (!response.ok) throw new Error(`下载失败(HTTP ${response.status}):${url}`)
19
+ return Buffer.from(await response.arrayBuffer())
20
+ }
21
+
22
+ async function findExecutable(root: string, name: string): Promise<string | undefined> {
23
+ for (const entry of await readdir(root, { withFileTypes: true })) {
24
+ const path = join(root, entry.name)
25
+ if (entry.isFile() && entry.name === name) return path
26
+ if (entry.isDirectory()) { const found = await findExecutable(path, name); if (found) return found }
27
+ }
28
+ return undefined
29
+ }
30
+
31
+ /** Prefer existing uv; otherwise install a checksummed release in this resource root. */
32
+ async function ensureUv(root: string, signal: AbortSignal, log: (line: string) => void, run: typeof runProcess): Promise<string> {
33
+ const name = process.platform === 'win32' ? 'uv.exe' : 'uv'
34
+ for (const candidate of [join(root, 'tools', name), 'uv', join(homedir(), '.local', 'bin', name), join(homedir(), '.cargo', 'bin', name)]) {
35
+ try { await run(candidate, ['--version'], { signal }); return candidate } catch { if (signal.aborted) throw new Error('ENVIRONMENT_CANCELLED') }
36
+ }
37
+ const arch = process.arch === 'arm64' ? 'aarch64' : process.arch === 'x64' ? 'x86_64' : undefined
38
+ const target = process.platform === 'darwin' ? 'apple-darwin' : process.platform === 'linux' ? 'unknown-linux-gnu' : process.platform === 'win32' ? 'pc-windows-msvc' : undefined
39
+ if (!arch || !target) throw new Error(`暂不支持自动安装:${process.platform}/${process.arch},请手动安装 uv 后重试`)
40
+ const extension = process.platform === 'win32' ? 'zip' : 'tar.gz'
41
+ const asset = `uv-${arch}-${target}.${extension}`
42
+ const url = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${asset}`
43
+ log('正在下载 Python 安装工具 uv')
44
+ const checksum = (await download(`${url}.sha256`, signal)).toString().match(/[a-f0-9]{64}/i)?.[0]
45
+ const archive = await download(url, signal)
46
+ if (!checksum || createHash('sha256').update(archive).digest('hex') !== checksum.toLowerCase()) throw new Error('uv 下载文件校验失败,请重试')
47
+ const scratch = join(root, `.setup-uv-${randomUUID()}`)
48
+ await mkdir(scratch, { recursive: true })
49
+ try {
50
+ const path = join(scratch, asset)
51
+ await writeFile(path, archive)
52
+ if (process.platform === 'win32') {
53
+ const script = join(scratch, 'expand.ps1')
54
+ await writeFile(script, 'param([string]$Archive,[string]$Destination)\nExpand-Archive -LiteralPath $Archive -DestinationPath $Destination\n')
55
+ await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, path, join(scratch, 'expanded')], { signal })
56
+ } else await run('tar', ['-xzf', path, '-C', scratch], { signal })
57
+ const binary = await findExecutable(scratch, name)
58
+ if (!binary) throw new Error('uv 安装包缺少可执行文件')
59
+ const destination = join(root, 'tools', name)
60
+ await mkdir(dirname(destination), { recursive: true })
61
+ await copyFile(binary, destination)
62
+ if (process.platform !== 'win32') await chmod(destination, 0o755)
63
+ return destination
64
+ } finally { await rm(scratch, { recursive: true, force: true }) }
65
+ }
66
+
67
+ async function installLock(root: string): Promise<() => Promise<void>> {
68
+ const path = join(root, 'environment-install.lock')
69
+ for (let attempt = 0; attempt < 2; attempt++) {
70
+ try {
71
+ const file = await open(path, 'wx')
72
+ try { await file.writeFile(JSON.stringify({ pid: process.pid, at: new Date().toISOString() })) }
73
+ catch (cause) { await rm(path, { force: true }); throw cause }
74
+ finally { await file.close() }
75
+ return () => rm(path, { force: true })
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
78
+ let pid = 0
79
+ try { pid = Number(JSON.parse(await readFile(path, 'utf8')).pid) } catch { throw new Error('安装锁异常,请检查 resource/environment-install.lock') }
80
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error('安装锁异常,请检查 resource/environment-install.lock')
81
+ try { process.kill(pid, 0); throw new Error('另一个进程正在准备环境,请等待完成后重试') }
82
+ catch (cause) {
83
+ if ((cause as NodeJS.ErrnoException).code !== 'ESRCH') throw cause
84
+ await rm(path, { force: true })
85
+ }
86
+ }
87
+ }
88
+ throw new Error('无法获取环境安装锁')
89
+ }
90
+
91
+ export async function installRequiredEnvironment(root: string, signal: AbortSignal, stage: (label: string) => void, log: (line: string) => void, runner: typeof runProcess = runProcess, selection: ModelSelection = DEFAULT_MODEL_SELECTION, basePython = process.env.DSH_PYTHON || 'python3'): Promise<void> {
92
+ await mkdir(root, { recursive: true })
93
+ const unlock = await installLock(root)
94
+ const env = { ...process.env, UV_CACHE_DIR: join(root, 'cache', 'uv'), HF_HOME: join(root, 'cache', 'huggingface'), PIP_DISABLE_PIP_VERSION_CHECK: '1' }
95
+ const run = (command: string, args: string[], extra: ProcessOptions = {}): Promise<string> => runner(command, args, { env, signal, timeoutMs: 30 * 60_000, onLine: log, ...extra })
96
+ try {
97
+ stage('准备模型运行时')
98
+ const uv = await ensureUv(root, signal, log, runner)
99
+ const versionOutput = await run(basePython, ['-c', 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")'], { timeoutMs: 15_000 })
100
+ const baseVersion = versionOutput.trim().split(/\r?\n/).at(-1)
101
+ if (!baseVersion || !/^3\.(10|11|12|13)$/.test(baseVersion)) {
102
+ throw new Error(`DSH Python 版本不受支持:${baseVersion || '无法读取'};需要 Python 3.10–3.13`)
103
+ }
104
+ const python = managedPython(root)
105
+ let compatible = false
106
+ if (existsSync(python)) {
107
+ try {
108
+ const output = await run(python, ['-c', 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")'], { timeoutMs: 15_000 })
109
+ compatible = output.trim().split(/\r?\n/).at(-1) === baseVersion
110
+ } catch { if (signal.aborted) throw new Error('ENVIRONMENT_CANCELLED') }
111
+ }
112
+ if (!compatible) {
113
+ if (existsSync(join(root, 'python'))) {
114
+ const backup = join(root, `python-backup-${randomUUID()}`)
115
+ await rename(join(root, 'python'), backup)
116
+ log(`原模型运行环境不可用,已保留备份:${backup}`)
117
+ }
118
+ await run(uv, ['venv', '--python', basePython, '--python-preference', 'only-system', join(root, 'python')])
119
+ }
120
+ stage('安装推理依赖')
121
+ await run(uv, ['pip', 'install', '--reinstall', '--python', python, '-r', requirementsFile])
122
+ const selectedModels = modelsForSelection(selection)
123
+ for (const model of selectedModels) {
124
+ stage(model.kind === 'embedding' ? '下载向量模型' : '下载重排模型')
125
+ await run(python, [setupScript, '--repo', model.repo, '--revision', model.revision, '--destination', join(root, 'models', model.directory)])
126
+ }
127
+ await writeModelSelection(root, selection)
128
+ } finally { await unlock() }
129
+ }
@@ -0,0 +1,28 @@
1
+ import { DEFAULT_MODEL_SELECTION, embeddingModel, rerankModel } from './model-catalog.js'
2
+
3
+ /** Revisions are immutable Hub commit IDs, not a moving main branch. */
4
+ export const MODEL_REVISIONS = {
5
+ 'bge-small-zh-v1.5': '7999e1d3359715c523056ef9478215996d62a620',
6
+ 'bge-base-zh-v1.5': 'f03589ceff5aac7111bd60cfc7d497ca17ecac65',
7
+ 'bge-large-zh-v1.5': '79e7739b6ab944e86d6171e44d24c997fc1e0116',
8
+ 'wemm-embedding-2b-quantized': '524bbe61e1b8ce4673e4e17dcf6a8694a3c45993',
9
+ 'wemm-embedding-2b': 'bbd6cd4bf52cfc6716f752a2df80b2706720bd95',
10
+ 'bge-reranker-base': '2cfc18c9415c912f9d8155881c133215df768a70',
11
+ 'bge-reranker-large': '55611d7bca2a7133960a6d3b71e083071bbfc312',
12
+ 'qwen3-reranker-0.6b': 'e61197ed45024b0ed8a2d74b80b4d909f1255473',
13
+ 'qwen3-reranker-4b': '22e683669bc0f0bd69640a1354a6d0aebcfeede5',
14
+ 'qwen3-reranker-8b': '77d193c791ed757ca307ee72715aa132723da912',
15
+ } as const
16
+
17
+ export function modelsForSelection(selection = DEFAULT_MODEL_SELECTION) {
18
+ const embedding = embeddingModel(selection.embedding)
19
+ const rerank = rerankModel(selection.rerank)
20
+ return [
21
+ { ...embedding, kind: 'embedding' as const, revision: MODEL_REVISIONS[embedding.id] },
22
+ { ...rerank, kind: 'rerank' as const, revision: MODEL_REVISIONS[rerank.id] },
23
+ ]
24
+ }
25
+
26
+ export const REQUIRED_MODELS = modelsForSelection()
27
+ export const UV_VERSION = '0.13.1'
28
+ export const ENVIRONMENT_PROBE_VERSION = 2
@@ -0,0 +1,105 @@
1
+ /** Models supported by the local embedding and reranking workers. Revisions are pinned in manifest.ts. */
2
+ export const EMBEDDING_MODELS = [
3
+ {
4
+ id: 'bge-small-zh-v1.5', repo: 'BAAI/bge-small-zh-v1.5', directory: 'bge-small-zh-v1.5',
5
+ label: 'BGE Small 中文', dimension: 512, download: '约 96 MB',
6
+ description: '资源占用最低,适合先跑通本地知识检索。',
7
+ },
8
+ {
9
+ id: 'bge-base-zh-v1.5', repo: 'BAAI/bge-base-zh-v1.5', directory: 'bge-base-zh-v1.5',
10
+ label: 'BGE Base 中文', dimension: 768, download: '约 409 MB',
11
+ description: '效果与资源占用较均衡。',
12
+ },
13
+ {
14
+ id: 'bge-large-zh-v1.5', repo: 'BAAI/bge-large-zh-v1.5', directory: 'bge-large-zh-v1.5',
15
+ label: 'BGE Large 中文', dimension: 1024, download: '约 1.3 GB',
16
+ description: '中文向量效果优先,需要更多内存与磁盘。',
17
+ },
18
+ {
19
+ id: 'wemm-embedding-2b-quantized', repo: 'ewin-reg/WeMM-Embedding-2B-Quantized', directory: 'WeMM-Embedding-2B-Quantized',
20
+ label: 'WeMM Embedding 2B 社区量化版', dimension: 2048, download: '约 1.8 GB',
21
+ description: '社区维护的 INT4 / FP8 量化模型,非腾讯官方发布;模型卡标注 license: other,请查看协议。',
22
+ },
23
+ {
24
+ id: 'wemm-embedding-2b', repo: 'tencent/WeMM-Embedding-2B', directory: 'WeMM-Embedding-2B',
25
+ label: '腾讯 WeMM Embedding 2B(BF16)', dimension: 2048, download: '约 5.5 GB',
26
+ description: '多模态向量模型,支持文本和图片;下载与推理资源占用较高。',
27
+ },
28
+ ] as const
29
+
30
+ export const RERANK_MODELS = [
31
+ {
32
+ id: 'bge-reranker-base', repo: 'BAAI/bge-reranker-base', directory: 'bge-reranker-base',
33
+ label: 'BGE Reranker Base', download: '约 1.1 GB',
34
+ description: '中文重排的轻量选项。',
35
+ },
36
+ {
37
+ id: 'qwen3-reranker-0.6b', repo: 'Qwen/Qwen3-Reranker-0.6B', directory: 'Qwen3-Reranker-0.6B',
38
+ label: 'Qwen3 Reranker 0.6B', download: '约 1.2 GB',
39
+ description: '支持 100 多种语言,适合先试用 Qwen 重排。',
40
+ },
41
+ {
42
+ id: 'bge-reranker-large', repo: 'BAAI/bge-reranker-large', directory: 'bge-reranker-large',
43
+ label: 'BGE Reranker Large', download: '约 2.2 GB',
44
+ description: '中文和英文重排效果优先,推理速度较慢。',
45
+ },
46
+ {
47
+ id: 'qwen3-reranker-4b', repo: 'Qwen/Qwen3-Reranker-4B', directory: 'Qwen3-Reranker-4B',
48
+ label: 'Qwen3 Reranker 4B', download: '约 8.1 GB',
49
+ description: 'Qwen 中档重排模型;请先确认磁盘与内存充足。',
50
+ },
51
+ {
52
+ id: 'qwen3-reranker-8b', repo: 'Qwen/Qwen3-Reranker-8B', directory: 'Qwen3-Reranker-8B',
53
+ label: 'Qwen3 Reranker 8B', download: '约 16.4 GB',
54
+ description: '资源占用最高;下载前请确认有足够磁盘与内存。',
55
+ },
56
+ ] as const
57
+
58
+ export type EmbeddingModelId = (typeof EMBEDDING_MODELS)[number]['id']
59
+ export type RerankModelId = (typeof RERANK_MODELS)[number]['id']
60
+ export interface ModelSelection { embedding: EmbeddingModelId; rerank: RerankModelId }
61
+ export interface ModelOptionView {
62
+ id: string
63
+ label: string
64
+ dimension?: number
65
+ download: string
66
+ description: string
67
+ modelCardUrl?: string
68
+ }
69
+ export const DEFAULT_MODEL_SELECTION: ModelSelection = {
70
+ embedding: 'bge-small-zh-v1.5',
71
+ rerank: 'bge-reranker-base',
72
+ }
73
+
74
+ export function isModelSelection(value: unknown): value is ModelSelection {
75
+ if (!value || typeof value !== 'object') return false
76
+ const selection = value as Record<string, unknown>
77
+ return EMBEDDING_MODELS.some(model => model.id === selection.embedding)
78
+ && RERANK_MODELS.some(model => model.id === selection.rerank)
79
+ }
80
+
81
+ export function parseModelSelection(value: unknown, fallback = DEFAULT_MODEL_SELECTION): ModelSelection {
82
+ if (value === undefined || value === null) return { ...fallback }
83
+ if (!isModelSelection(value)) throw new Error('模型选择无效,请重新选择向量模型和重排模型')
84
+ return { embedding: value.embedding, rerank: value.rerank }
85
+ }
86
+
87
+ export function embeddingModel(id: EmbeddingModelId) {
88
+ return EMBEDDING_MODELS.find(model => model.id === id)!
89
+ }
90
+
91
+ export function rerankModel(id: RerankModelId) {
92
+ return RERANK_MODELS.find(model => model.id === id)!
93
+ }
94
+
95
+ export function modelOptionsView() {
96
+ const present = <T extends { id: string; label: string; download: string; description: string; repo: string }>(model: T) => ({
97
+ id: model.id, label: model.label, download: model.download, description: model.description,
98
+ modelCardUrl: `https://huggingface.co/${model.repo}`,
99
+ ...('dimension' in model ? { dimension: model.dimension as number } : {}),
100
+ })
101
+ return {
102
+ embedding: EMBEDDING_MODELS.map(present),
103
+ rerank: RERANK_MODELS.map(present),
104
+ }
105
+ }
@@ -0,0 +1,54 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ export interface ProcessOptions {
4
+ cwd?: string
5
+ env?: NodeJS.ProcessEnv
6
+ signal?: AbortSignal
7
+ timeoutMs?: number
8
+ onLine?: (line: string) => void
9
+ }
10
+
11
+ /** No shell, bounded output, and only this child PID is stopped. */
12
+ export function runProcess(command: string, args: string[], options: ProcessOptions = {}): Promise<string> {
13
+ return new Promise((resolve, reject) => {
14
+ if (options.signal?.aborted) return reject(new Error('ENVIRONMENT_CANCELLED'))
15
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'] })
16
+ let output = '', tail = '', settled = false, stopError: Error | undefined
17
+ let killTimer: ReturnType<typeof setTimeout> | undefined
18
+ const end = (error?: Error): void => {
19
+ if (settled) return
20
+ settled = true
21
+ clearTimeout(timer)
22
+ clearTimeout(killTimer)
23
+ options.signal?.removeEventListener('abort', cancel)
24
+ if (error) reject(error)
25
+ else resolve(output)
26
+ }
27
+ // Keep the install lock until this owned child has actually exited.
28
+ const stop = (error: Error): void => {
29
+ if (settled || stopError) return
30
+ stopError = error
31
+ clearTimeout(timer)
32
+ child.kill('SIGTERM')
33
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 1500)
34
+ }
35
+ const cancel = (): void => stop(new Error('ENVIRONMENT_CANCELLED'))
36
+ const timer = setTimeout(() => stop(new Error(`环境步骤超时:${command}`)), options.timeoutMs ?? 30_000)
37
+ options.signal?.addEventListener('abort', cancel, { once: true })
38
+ const receive = (data: Buffer): void => {
39
+ const text = data.toString()
40
+ output = (output + text).slice(-128_000)
41
+ tail = (tail + text).slice(-16_000)
42
+ const lines = tail.split(/[\r\n]+/)
43
+ tail = lines.pop() ?? ''
44
+ for (const line of lines) if (line.trim()) options.onLine?.(line.trim())
45
+ }
46
+ child.stdout.on('data', receive)
47
+ child.stderr.on('data', receive)
48
+ child.once('error', error => end(error))
49
+ child.once('close', code => {
50
+ if (tail.trim()) options.onLine?.(tail.trim())
51
+ end(stopError ?? (code === 0 ? undefined : new Error(`环境步骤失败(${code ?? '已停止'}):${output.slice(-3000).trim() || command}`)))
52
+ })
53
+ })
54
+ }
@@ -0,0 +1,29 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { DEFAULT_MODEL_SELECTION, embeddingModel, isModelSelection, type ModelSelection } from './model-catalog.js'
5
+
6
+ export const MODEL_SELECTION_FILE = 'environment-model-selection.json'
7
+
8
+ export function readModelSelection(resourceRoot: string): ModelSelection {
9
+ try {
10
+ const value: unknown = JSON.parse(readFileSync(join(resourceRoot, MODEL_SELECTION_FILE), 'utf8'))
11
+ if (isModelSelection(value)) return { embedding: value.embedding, rerank: value.rerank }
12
+ } catch { /* first install or damaged selection: use the shipped defaults */ }
13
+ return { ...DEFAULT_MODEL_SELECTION }
14
+ }
15
+
16
+ export function selectedEmbedding(resourceRoot: string | undefined): { path?: string; dimension?: number } {
17
+ if (!resourceRoot) return {}
18
+ const model = embeddingModel(readModelSelection(resourceRoot).embedding)
19
+ const path = join(resourceRoot, 'models', model.directory)
20
+ return existsSync(path) ? { path, dimension: model.dimension } : {}
21
+ }
22
+
23
+ export async function writeModelSelection(resourceRoot: string, selection: ModelSelection): Promise<void> {
24
+ await mkdir(resourceRoot, { recursive: true })
25
+ const destination = join(resourceRoot, MODEL_SELECTION_FILE)
26
+ const temporary = `${destination}.${process.pid}.tmp`
27
+ await writeFile(temporary, `${JSON.stringify(selection, null, 2)}\n`, 'utf8')
28
+ await rename(temporary, destination)
29
+ }
@@ -0,0 +1,211 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { readdirSync, statSync } from 'node:fs'
3
+ import { mkdir, writeFile } from 'node:fs/promises'
4
+ import { dirname, isAbsolute, join } from 'node:path'
5
+ import { embeddingFingerprint } from '../compiler/embedding-identity.js'
6
+ import type { RuntimeKnowledgeConfig } from '../types.js'
7
+ import { installRequiredEnvironment, requirementsFile } from './install.js'
8
+ import { modelOptionsView, parseModelSelection, type ModelSelection } from './model-catalog.js'
9
+ import { ENVIRONMENT_PROBE_VERSION } from './manifest.js'
10
+ import { runProcess } from './process.js'
11
+ import { readModelSelection, writeModelSelection } from './selection.js'
12
+ import type { EnvironmentItem, EnvironmentItemId, EnvironmentReport, EnvironmentView } from './types.js'
13
+
14
+ export interface EnvironmentOptions {
15
+ resourceRoot: string
16
+ snapshot: () => { config: RuntimeKnowledgeConfig; paths: { python: string; embeddingModel: string; rerankModel: string } }
17
+ probe: (kind: 'embedding' | 'rerank') => Promise<{ ok: boolean; message: string }>
18
+ reload: () => void
19
+ run?: typeof runProcess
20
+ install?: typeof installRequiredEnvironment
21
+ }
22
+
23
+ const labels = { embedding: '向量模型', rerank: '重排模型' }
24
+ function blankReport(): EnvironmentReport {
25
+ return { state: 'unchecked', required: true, items: (['embedding', 'rerank'] as const).map(id => ({ id, label: labels[id], state: 'unchecked', message: '首次使用前必须完成模型推理自检' })) }
26
+ }
27
+ function stamp(path: string): unknown { try { const value = statSync(path); return [value.size, value.mtimeMs] } catch { return null } }
28
+ function sitePackagesPath(pythonPath: string): string {
29
+ const environmentRoot = dirname(dirname(pythonPath))
30
+ const windows = join(environmentRoot, 'Lib', 'site-packages')
31
+ try { if (statSync(windows).isDirectory()) return windows } catch { /* use the platform-neutral lib layout */ }
32
+ const libraryRoot = join(environmentRoot, 'lib')
33
+ try {
34
+ const versionDirectory = readdirSync(libraryRoot).find(name => /^python\d+\.\d+$/.test(name))
35
+ if (versionDirectory) return join(libraryRoot, versionDirectory, 'site-packages')
36
+ } catch { /* no venv/site-packages directory */ }
37
+ return join(libraryRoot, 'site-packages')
38
+ }
39
+
40
+ export function modelFilesProblem(path: string): string | undefined {
41
+ if (!isAbsolute(path)) return '尚未准备本地模型,请安装默认资源或填写已下载模型的完整路径'
42
+ try {
43
+ const files = readdirSync(path)
44
+ if (!files.includes('config.json')) return '模型缺少 config.json'
45
+ const weights = files.filter(name => /\.(safetensors|bin)$/.test(name))
46
+ if (!weights.length || weights.some(name => statSync(join(path, name)).size < 1024)) return '模型权重缺失或下载不完整'
47
+ const tokenizer = ['tokenizer.json', 'sentencepiece.bpe.model', 'vocab.txt'].some(name => files.includes(name) && statSync(join(path, name)).size >= 1024)
48
+ if (!tokenizer) return '模型缺少完整分词器词表'
49
+ } catch { return '模型目录不存在或无法读取' }
50
+ return undefined
51
+ }
52
+
53
+ /** Preparation and readiness are shared by all bases in one deployment. */
54
+ export class KnowledgeEnvironment {
55
+ private report = blankReport()
56
+ private job?: EnvironmentView['job']
57
+ private checkPending?: Promise<EnvironmentReport>
58
+ private work?: Promise<void>
59
+ private controller?: AbortController
60
+ private checkController?: AbortController
61
+ private disposed = false
62
+ constructor(private readonly options: EnvironmentOptions) {}
63
+
64
+ private fingerprint(): string {
65
+ const { config, paths } = this.options.snapshot()
66
+ const env = dirname(dirname(paths.python))
67
+ return createHash('sha256').update(JSON.stringify({
68
+ probe: ENVIRONMENT_PROBE_VERSION, paths,
69
+ provider: config.embeddingProvider, rerank: config.rerankProvider, device: config.embeddingDevice, dimension: config.embeddingDimension, endpoint: config.embeddingBaseUrl,
70
+ embedding: embeddingFingerprint(paths.embeddingModel, this.options.resourceRoot), rerankModel: embeddingFingerprint(paths.rerankModel, this.options.resourceRoot),
71
+ python: stamp(paths.python), venv: stamp(join(env, 'pyvenv.cfg')), dependencies: stamp(sitePackagesPath(paths.python)), requirements: stamp(requirementsFile),
72
+ })).digest('hex')
73
+ }
74
+
75
+ view(): EnvironmentView {
76
+ if (!this.checkPending && this.report.state === 'ready' && this.report.fingerprint !== this.fingerprint()) this.report = blankReport()
77
+ const selection = readModelSelection(this.options.resourceRoot)
78
+ return structuredClone({ report: this.report, selection, models: modelOptionsView(), ...(this.job ? { job: this.job } : {}) })
79
+ }
80
+ invalidate(): void { if (!this.checkPending) this.report = blankReport() }
81
+
82
+ async requireReady(): Promise<void> {
83
+ if (this.disposed) throw new Error('环境服务已关闭')
84
+ if (this.job?.state === 'running') throw new Error('环境正在准备或自检,请等待完成后再编译和召回')
85
+ const view = this.view()
86
+ const recent = view.report.checkedAt && Date.now() - Date.parse(view.report.checkedAt) < 5 * 60_000
87
+ const report = view.report.state === 'ready' && recent ? view.report : await this.check(true)
88
+ if (report.state !== 'ready') throw new Error(`环境未就绪:${report.items.filter(item => item.state !== 'ready').map(item => `${item.label}:${item.message}`).join(';')}。请在 Tita 设置中完成环境准备`)
89
+ }
90
+
91
+ check(deep = true): Promise<EnvironmentReport> {
92
+ if (this.checkPending) return this.checkPending
93
+ const pending = this.performCheck(deep)
94
+ this.checkPending = pending
95
+ void pending.finally(() => { if (this.checkPending === pending) this.checkPending = undefined }).catch(() => undefined)
96
+ return pending
97
+ }
98
+
99
+ private item(id: EnvironmentItemId, patch: Partial<EnvironmentItem>): void {
100
+ this.report.items = this.report.items.map(item => item.id === id ? { ...item, ...patch } : item)
101
+ }
102
+
103
+ private async performCheck(deep: boolean): Promise<EnvironmentReport> {
104
+ const checkController = new AbortController()
105
+ this.checkController = checkController
106
+ const signal = this.controller ? AbortSignal.any([this.controller.signal, checkController.signal]) : checkController.signal
107
+ const fingerprint = this.fingerprint()
108
+ const { config, paths } = this.options.snapshot()
109
+ this.report = blankReport()
110
+ this.report.state = 'checking'
111
+ for (const id of ['embedding', 'rerank'] as const) {
112
+ if (signal.aborted || this.disposed) break
113
+ const path = id === 'embedding' ? paths.embeddingModel : paths.rerankModel
114
+ this.item(id, { path })
115
+ const disabled = id === 'embedding' ? config.embeddingProvider === 'none' : config.rerankProvider === 'none'
116
+ const problem = disabled ? '此能力是必需项,请启用模型'
117
+ : id === 'embedding' && config.embeddingProvider === 'ollama' ? undefined : modelFilesProblem(path)
118
+ if (problem) { this.item(id, { state: 'missing', message: problem }); continue }
119
+ if (!deep) { this.item(id, { state: 'unchecked', message: '资源已找到,尚未完成实际推理自检' }); continue }
120
+ this.item(id, { state: 'checking', message: '加载模型并运行推理自检,首次加载可能较慢' })
121
+ try {
122
+ const result = await this.options.probe(id)
123
+ this.item(id, { state: result.ok ? 'ready' : 'failed', message: result.message })
124
+ } catch (cause) { this.item(id, { state: 'failed', message: cause instanceof Error ? cause.message : String(cause) }) }
125
+ }
126
+ const changed = fingerprint !== this.fingerprint()
127
+ if (changed) this.report.items = this.report.items.map(item => item.state === 'ready'
128
+ ? { ...item, state: 'unchecked', message: '自检期间配置或资源发生变化,请重新自检' }
129
+ : item)
130
+ if (signal.aborted || this.disposed) this.report.items = this.report.items.map(item => item.state === 'checking'
131
+ ? { ...item, state: 'unchecked', message: '自检已停止,请重新运行' }
132
+ : item)
133
+ this.report.state = this.report.items.every(item => item.state === 'ready') ? 'ready' : 'blocked'
134
+ this.report.checkedAt = new Date().toISOString()
135
+ this.report.fingerprint = fingerprint
136
+ if (this.checkController === checkController) this.checkController = undefined
137
+ return structuredClone(this.report)
138
+ }
139
+
140
+ start(action: 'check' | 'install', requestedSelection?: unknown): EnvironmentView {
141
+ if (this.disposed) throw new Error('环境服务已关闭')
142
+ if (this.job?.state === 'running') return this.view()
143
+ const previousSelection = readModelSelection(this.options.resourceRoot)
144
+ const selection: ModelSelection = action === 'install'
145
+ ? parseModelSelection(requestedSelection, previousSelection)
146
+ : previousSelection
147
+ this.checkController?.abort()
148
+ this.controller = new AbortController()
149
+ const controller = this.controller
150
+ this.job = { id: randomUUID(), state: 'running', stage: action === 'install' ? '准备安装' : '环境自检', startedAt: new Date().toISOString(), logs: [] }
151
+ const job = this.job
152
+ const log = (line: string): void => { job.logs = [...job.logs, line.slice(-1500)].slice(-12) }
153
+ const work = async (): Promise<void> => {
154
+ try {
155
+ // A first-use guard can already be checking. Do not install into that probe.
156
+ if (this.checkPending) await this.checkPending
157
+ if (controller.signal.aborted) throw new Error('ENVIRONMENT_CANCELLED')
158
+ if (action === 'install') {
159
+ this.report = blankReport()
160
+ this.report.state = 'checking'
161
+ await (this.options.install ?? installRequiredEnvironment)(this.options.resourceRoot, controller.signal, label => {
162
+ job.stage = label
163
+ for (const item of this.report.items) if (item.state === 'checking') this.item(item.id, { state: 'unchecked', message: '准备步骤已完成,等待实际自检' })
164
+ const id = label.includes('向量') ? 'embedding' : label.includes('重排') ? 'rerank' : undefined
165
+ if (id) this.item(id, { state: 'checking', message: label })
166
+ }, log, this.options.run ?? runProcess, selection)
167
+ this.options.reload()
168
+ } else this.options.reload()
169
+ job.stage = '运行实际推理自检'
170
+ const report = await this.check(true)
171
+ if (controller.signal.aborted) throw new Error('ENVIRONMENT_CANCELLED')
172
+ if (report.state !== 'ready') throw new Error(report.items.filter(item => item.state !== 'ready').map(item => `${item.label}:${item.message}`).join(';'))
173
+ await mkdir(this.options.resourceRoot, { recursive: true })
174
+ await writeFile(join(this.options.resourceRoot, 'environment-last-check.json'), JSON.stringify(report, null, 2))
175
+ job.state = 'succeeded'; job.stage = '环境已就绪'
176
+ } catch (cause) {
177
+ if (action === 'install') {
178
+ const activeSelection = readModelSelection(this.options.resourceRoot)
179
+ if (activeSelection.embedding !== previousSelection.embedding || activeSelection.rerank !== previousSelection.rerank) {
180
+ try {
181
+ await writeModelSelection(this.options.resourceRoot, previousSelection)
182
+ this.options.reload()
183
+ this.report = blankReport()
184
+ log('所选模型未通过自检,已恢复此前启用的模型')
185
+ } catch (rollbackCause) {
186
+ log(`恢复此前模型选择失败:${rollbackCause instanceof Error ? rollbackCause.message : String(rollbackCause)}`)
187
+ }
188
+ }
189
+ }
190
+ job.state = controller.signal.aborted ? 'cancelled' : 'failed'
191
+ job.error = controller.signal.aborted ? '已停止,已下载的资源可在下次重试时复用' : cause instanceof Error ? cause.message : String(cause)
192
+ job.stage = controller.signal.aborted ? '已停止' : '准备未完成'
193
+ if (this.report.state !== 'ready') {
194
+ this.report.state = 'blocked'
195
+ for (const item of this.report.items) if (item.state === 'checking') this.item(item.id, { state: controller.signal.aborted ? 'unchecked' : 'failed', message: job.error })
196
+ }
197
+ } finally {
198
+ job.finishedAt = new Date().toISOString()
199
+ if (this.controller === controller) this.controller = undefined
200
+ }
201
+ }
202
+ this.work = Promise.resolve().then(work)
203
+ return this.view()
204
+ }
205
+ async wait(): Promise<void> { await this.work }
206
+ cancel(): void {
207
+ this.controller?.abort(); this.checkController?.abort()
208
+ if (this.job?.state === 'running') this.job.stage = '正在停止,请等待当前步骤结束'
209
+ }
210
+ close(): void { this.disposed = true; this.cancel() }
211
+ }
@@ -0,0 +1,34 @@
1
+ /** Installation state is deployment-wide; it is not knowledge or a build artifact. */
2
+ import type { ModelOptionView, ModelSelection } from './model-catalog.js'
3
+
4
+ export type EnvironmentItemId = 'embedding' | 'rerank'
5
+ export type EnvironmentItemState = 'unchecked' | 'checking' | 'ready' | 'missing' | 'failed'
6
+ export interface EnvironmentItem {
7
+ id: EnvironmentItemId
8
+ label: string
9
+ state: EnvironmentItemState
10
+ message: string
11
+ path?: string
12
+ }
13
+ export interface EnvironmentReport {
14
+ state: 'unchecked' | 'checking' | 'blocked' | 'ready'
15
+ required: true
16
+ checkedAt?: string
17
+ fingerprint?: string
18
+ items: EnvironmentItem[]
19
+ }
20
+ export interface EnvironmentJob {
21
+ id: string
22
+ state: 'running' | 'succeeded' | 'failed' | 'cancelled'
23
+ stage: string
24
+ startedAt: string
25
+ finishedAt?: string
26
+ error?: string
27
+ logs: string[]
28
+ }
29
+ export interface EnvironmentView {
30
+ report: EnvironmentReport
31
+ job?: EnvironmentJob
32
+ selection: ModelSelection
33
+ models: { embedding: ModelOptionView[]; rerank: ModelOptionView[] }
34
+ }
package/src/events.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { normalizeKnowledge } from './knowledge.js'
1
3
  import type { CardCoordinate } from './types.js'
2
4
  import type { TrialEvent, TrialSource } from './wiki-trial-types.js'
3
5
 
@@ -43,3 +45,47 @@ export interface EventEntity {
43
45
  createdAt: string
44
46
  updatedAt: string
45
47
  }
48
+
49
+ export function compositeEventIdentity(occurredAt: string | null, coordinate: CardCoordinate, symptom: string | null): string {
50
+ const payload = [occurredAt, coordinate.objectId, coordinate.actionId, coordinate.sceneId, normalizeKnowledge(symptom ?? '')]
51
+ return 'composite:' + createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 24)
52
+ }
53
+
54
+ /** Recompute the aggregate after records are appended, pruned or redirected. */
55
+ export function eventEntityFromRecords(input: {
56
+ id: string
57
+ identityKey: string
58
+ identityType: EventEntity['identityType']
59
+ records: EventSourceRecord[]
60
+ createdAt: string
61
+ updatedAt?: string
62
+ }): EventEntity {
63
+ const { id, identityKey, identityType, records } = input
64
+ const first = records[0]
65
+ const firstCoordinate = records.find(record => record.coordinateIds.objectId || record.coordinateIds.actionId || record.coordinateIds.sceneId)
66
+ const sources = new Map<string, TrialSource>()
67
+ for (const record of records) {
68
+ for (const source of record.sources) sources.set(`${source.documentId}\u0001${source.quote}`, source)
69
+ }
70
+ return {
71
+ id, identityKey, identityType,
72
+ externalId: records.find(record => record.externalId)?.externalId ?? null,
73
+ title: first?.title ?? id,
74
+ summary: records.find(record => record.summary.trim())?.summary ?? first?.title ?? id,
75
+ coordinate: {
76
+ object: records.find(record => record.coordinate.object)?.coordinate.object ?? null,
77
+ action: records.find(record => record.coordinate.action)?.coordinate.action ?? null,
78
+ scene: records.find(record => record.coordinate.scene)?.coordinate.scene ?? null,
79
+ },
80
+ coordinateIds: firstCoordinate?.coordinateIds ?? { objectId: '', actionId: '', sceneId: '' },
81
+ occurredAt: records.find(record => record.occurredAt)?.occurredAt ?? null,
82
+ symptom: records.find(record => record.symptom)?.symptom ?? null,
83
+ timeline: records.flatMap(record => record.timeline.map(step => ({ ...step, sourceId: record.sourceId }))),
84
+ rootCauses: records.flatMap(record => record.rootCause ? [{ text: record.rootCause, status: record.rootCauseStatus, sourceId: record.sourceId }] : []),
85
+ outcomes: records.flatMap(record => record.outcome ? [{ text: record.outcome, sourceId: record.sourceId }] : []),
86
+ missing: [...new Set(records.flatMap(record => record.missing))],
87
+ sources: [...sources.values()], records,
88
+ createdAt: input.createdAt,
89
+ updatedAt: input.updatedAt ?? new Date().toISOString(),
90
+ }
91
+ }