dsh-prime-memory 0.24.2 → 0.26.0
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.
- package/CHANGELOG.en.md +28 -0
- package/CHANGELOG.ja.md +29 -0
- package/CHANGELOG.ko.md +29 -0
- package/CHANGELOG.md +30 -0
- package/dist/client.js +907 -654
- package/dist/config.d.ts +47 -2
- package/dist/config.js +30 -2
- package/dist/contract.d.ts +107 -1
- package/dist/keyword-backfill.d.ts +75 -0
- package/dist/keyword-backfill.js +218 -0
- package/dist/metadata-validators.d.ts +9 -0
- package/dist/metadata-validators.js +31 -0
- package/dist/pipeline/relabel.d.ts +11 -12
- package/dist/pipeline/relabel.js +54 -66
- package/dist/pipeline/runner.js +24 -1
- package/dist/projection/recall.d.ts +3 -2
- package/dist/projection/recall.js +26 -5
- package/dist/projection/slots.d.ts +11 -0
- package/dist/projection/slots.js +13 -3
- package/dist/redistill-backfill.d.ts +133 -0
- package/dist/redistill-backfill.js +352 -0
- package/dist/room-review.d.ts +12 -13
- package/dist/room-review.js +37 -29
- package/dist/settings.js +13 -0
- package/dist/stats.js +126 -14
- package/dist/store/l1.d.ts +18 -0
- package/dist/store/l1.js +17 -0
- package/dist/store/memory-backend-worker.d.ts +1 -0
- package/dist/store/memory-backend-worker.js +3 -0
- package/dist/store/memory-backend.d.ts +3 -0
- package/dist/store/memory-backend.js +3 -0
- package/dist/store/memory-md.d.ts +8 -0
- package/dist/store/memory-md.js +13 -2
- package/dist/store/memory-worker.js +3 -0
- package/dist/store/sqlite.d.ts +28 -0
- package/dist/store/sqlite.js +66 -0
- package/dist/wing-backfill.d.ts +16 -14
- package/dist/wing-backfill.js +76 -96
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -123,12 +123,31 @@ export interface MemoryConfig {
|
|
|
123
123
|
/** active 技能闲置多少天后自动归档(0 = 不做闲置归档)。 */
|
|
124
124
|
idleArchiveDays: number;
|
|
125
125
|
};
|
|
126
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* 存量回填护栏(wing / keyword / redistill 共用,默认 300 条 / 30min 总预算 / 串行 / 单条请求 240s)。
|
|
128
|
+
*
|
|
129
|
+
* 形状约定(用户定):**按单元、串行、不合并**——一个请求只装一条记忆,跑完一条再发下一条。
|
|
130
|
+
* 实测单条 35B 只要 ~2.6s,而 20 条合并成一批要 200s+ 且一次空输出/截断就带走整批,
|
|
131
|
+
* 所以“批量”在这里不省时间只堆风险。`concurrency` 默认 1;拖高概览页滑块可临时多路在飞。
|
|
132
|
+
*
|
|
133
|
+
* 三个旋钮分工(别混淆):
|
|
134
|
+
* - `maxRecords` = **一轮最多处理多少条**(防一次点击拖过久;真实队列没跑完的计入 deferred,
|
|
135
|
+
* 再点一次继续)。注意它**不是**请求批大小:逐条形状下每个请求只装 1 条。
|
|
136
|
+
* - `timeBudgetMs` = **本轮总墙钟预算**(防跑飞)。与 maxRecords 谁先到谁生效:
|
|
137
|
+
* 默认 300 条 × ~2.6s ≈ 13min < 30min,正常由 maxRecords 先结算。
|
|
138
|
+
* - `callTimeoutMs` = **一次 `callLLM`(含整条回退链)**的超时。不能沿用 `llm.timeoutMs`(120s):
|
|
139
|
+
* 它是**整次调用**的预算而非每条路由各享一份——真机踩到主路由(9B)空输出吃 95s,
|
|
140
|
+
* 降到 35B 后只剩余量在线上被掐死(看似“35B 也跑不完”)。默认 240s 按「路由数 × 单路由耗时」留余量。
|
|
141
|
+
*/
|
|
127
142
|
backfill: {
|
|
128
143
|
/** 单次回填处理条数上限。 */
|
|
129
144
|
maxRecords: number;
|
|
130
|
-
/**
|
|
145
|
+
/** 单次回填总墙钟预算(ms),超时剩留待再次触发。 */
|
|
131
146
|
timeBudgetMs: number;
|
|
147
|
+
/** LLM 批调用的并行度(1 = 串行)。 */
|
|
148
|
+
concurrency: number;
|
|
149
|
+
/** 一次 LLM 调用(含整条回退链)的超时(ms),比对话蒸馏的 llm.timeoutMs 宽;按路由数留余量。 */
|
|
150
|
+
callTimeoutMs: number;
|
|
132
151
|
};
|
|
133
152
|
/**
|
|
134
153
|
* 内容路由(workspace 归属):按「这条记忆**谈到**哪个项目」给记录补 `workspace_id`。
|
|
@@ -193,6 +212,32 @@ export interface MemoryConfig {
|
|
|
193
212
|
usageScenario: {
|
|
194
213
|
enabled: boolean;
|
|
195
214
|
};
|
|
215
|
+
/**
|
|
216
|
+
* 关键词蒸馏回填(keyword 检索轴的产出方):**独立回填批扫**,为缺 `metadata.dsh_keywords` 的
|
|
217
|
+
* L1 记录批量补关键词,喂 `l1_fts` 的关键词轴(`ftsKeywordText` 读该键)。由
|
|
218
|
+
* `dsh-memory/keyword-backfill` 端点触发,镜像 wing 回填(单飞/后台/有界)。
|
|
219
|
+
*
|
|
220
|
+
* **默认关**:关时不回填、不产该键、`l1_fts.keywords` 列逐字不变(零漂移)。护栏复用 `backfill`。
|
|
221
|
+
* 注:写回走 `patchMetadata` 只改主表,回填会**额外重索引该记录的 fts 行**(见 keyword-backfill),
|
|
222
|
+
* 否则关键词轴对存量记录不生效。
|
|
223
|
+
*/
|
|
224
|
+
keyword: {
|
|
225
|
+
enabled: boolean;
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* 存量记忆重蒸馏整形(redistill):**独立回填批扫**,把活跃且 `scoreSkeleton` 判「骨架不齐备」
|
|
229
|
+
* 的 L1 正文按 G2 分类型骨架重排,并顺带产 `dsh_keywords` / `dsh_usage_scenario`。由
|
|
230
|
+
* `dsh-memory/redistill-backfill` 端点触发,镜像 keyword/wing 回填(单飞/后台/有界)。
|
|
231
|
+
*
|
|
232
|
+
* **默认关**:关时不整形、content/FTS/投射逐字不变(零漂移)。护栏复用 `backfill`。
|
|
233
|
+
* 与 keyword 回填的关键差异:本项**改 content**,写回走 `L1Store.appendNew`(追加 JSONL 事实源
|
|
234
|
+
* + 重嵌 + FTS + 投射点火),且入口要求 `vectorsReady()`(embed 未就绪时改正文会静默丢向量)。
|
|
235
|
+
* 只有整形后骨架分严格更优才写回,并盖整形版本戳(`dsh_reshape_ver`);未过闸的留原文**不打标记**,
|
|
236
|
+
* 下轮仍可再试——即只有「成功替换的」不再进入。
|
|
237
|
+
*/
|
|
238
|
+
redistill: {
|
|
239
|
+
enabled: boolean;
|
|
240
|
+
};
|
|
196
241
|
/**
|
|
197
242
|
* 类型叙述模板(G2):抽取时按 7 类型各自的“前因后果”骨架写 content。
|
|
198
243
|
*
|
package/dist/config.js
CHANGED
|
@@ -80,6 +80,22 @@ export function liveSettingsSchema() {
|
|
|
80
80
|
conflictFreeze: Schema.boolean().default(false),
|
|
81
81
|
// 轨2 技能结晶总开关(看板可拨):默认 false。开=注册 memory_skill_* 工具 + 注入 active 技能 + skills-* 端点可用。
|
|
82
82
|
skillsEnabled: Schema.boolean().default(false),
|
|
83
|
+
// 存量回填/进阶功能的启用开关(概览「存量回填与整形」区可拨):默认 false,
|
|
84
|
+
// 与部署 cfg.<x>.enabled 取或(经 effectiveCfg)——部署可强制开,live 只能追加开。
|
|
85
|
+
// 存在的理由:这些面都有对应按钮/端点,若开关只住 YAML,用户点了只会报“未开启”却没处翻。
|
|
86
|
+
keywordEnabled: Schema.boolean().default(false),
|
|
87
|
+
redistillEnabled: Schema.boolean().default(false),
|
|
88
|
+
mdSyncEnabled: Schema.boolean().default(false),
|
|
89
|
+
memportEnabled: Schema.boolean().default(false),
|
|
90
|
+
/**
|
|
91
|
+
* 存量回填(keyword / redistill)LLM 批调用的**运行时并行度**覆盖:
|
|
92
|
+
* `0` = 跟随部署 `cfg.backfill.concurrency`;1–8 = 直接覆盖。
|
|
93
|
+
*
|
|
94
|
+
* 为什么要是 live 而不是只住 YAML:真机出过“3 并发把每请求拖过超时线、13 批全 abort”,
|
|
95
|
+
* 排查时需要的恰好是“立刻降到 1 串行再看一遍”——为此改 YAML + 重启不现实。
|
|
96
|
+
* 概览页 0–8 滑块写的就是它(0 = 跟随部署,默认部署值是 1 串行)。
|
|
97
|
+
*/
|
|
98
|
+
backfillConcurrency: Schema.number().min(0).max(8).default(0),
|
|
83
99
|
// 工具分组封印(v0.21.0):false = 该组工具不注册,从模型工具列表消失。
|
|
84
100
|
// 核心读(memory_search/read_scene/receipts/conversation_search)与槽位三件不受控。
|
|
85
101
|
toolRoom: Schema.boolean().default(true),
|
|
@@ -164,10 +180,14 @@ export const memorySchema = Schema.object({
|
|
|
164
180
|
excerptChars: Schema.number().min(0).max(4000).default(240),
|
|
165
181
|
idleArchiveDays: Schema.number().min(0).max(3650).default(90),
|
|
166
182
|
}),
|
|
167
|
-
// wing
|
|
183
|
+
// wing/keyword/redistill 回填护栏。maxRecords 是“一轮处理多少条”,timeBudgetMs 是“本轮不跑飞”,
|
|
184
|
+
// callTimeoutMs 是“单次请求(含回退链)不撞线”;逐条形状下每请求只装 1 条(不是 20 条一批)。
|
|
185
|
+
// maxRecords 不得低于真实队列量级:真机活跃集里待整形真目标 251 条,砍到 40 就等于“点一次只动 40”。
|
|
168
186
|
backfill: Schema.object({
|
|
169
187
|
maxRecords: Schema.number().min(1).max(10_000).default(300),
|
|
170
|
-
timeBudgetMs: Schema.number().min(1000).max(1_800_000).default(
|
|
188
|
+
timeBudgetMs: Schema.number().min(1000).max(1_800_000).default(1_800_000),
|
|
189
|
+
concurrency: Schema.number().min(1).max(8).default(1),
|
|
190
|
+
callTimeoutMs: Schema.number().min(1000).max(1_800_000).default(240_000),
|
|
171
191
|
}),
|
|
172
192
|
/**
|
|
173
193
|
* 内容路由:默认**开**(用户口径:谈到本仓/dsh 生态的记忆要归到本仓 workspace,
|
|
@@ -226,6 +246,14 @@ export const memorySchema = Schema.object({
|
|
|
226
246
|
usageScenario: Schema.object({
|
|
227
247
|
enabled: Schema.boolean().default(false),
|
|
228
248
|
}),
|
|
249
|
+
// 关键词蒸馏回填:默认关(关时不回填、不产 dsh_keywords、FTS 逐字不变);护栏复用 backfill。
|
|
250
|
+
keyword: Schema.object({
|
|
251
|
+
enabled: Schema.boolean().default(false),
|
|
252
|
+
}),
|
|
253
|
+
// 存量记忆重蒸馏整形:默认关(关时不整形、content/FTS/投射逐字不变);护栏复用 backfill。
|
|
254
|
+
redistill: Schema.object({
|
|
255
|
+
enabled: Schema.boolean().default(false),
|
|
256
|
+
}),
|
|
229
257
|
// 类型叙述模板(G2):默认**开**;开时抽取 prompt 追加分类型 content 骨架(关时仍逐字不变)。
|
|
230
258
|
// 默认值从关改开的理由写在上方接口注释:只给定义实测骨架齐备 0/23,
|
|
231
259
|
// 而"没前因后果就不算可用"是用户定的判据,所以默认值要按可用性定而不是按保守定。
|
package/dist/contract.d.ts
CHANGED
|
@@ -106,9 +106,20 @@ export interface MemoryLiveSettings {
|
|
|
106
106
|
/** 轨2 技能结晶总开关(看板可拨):true = 注册 memory_skill_* 工具、注入 active 技能、skills-* 端点可用。
|
|
107
107
|
* 默认 false(新功能默认关);与部署 cfg.skills.enabled 取或(任一开则生效)。 */
|
|
108
108
|
skillsEnabled: boolean;
|
|
109
|
+
/** 关键词回填启用开关(概览区可拨):true = 放行 dsh-memory/keyword-backfill。与部署 cfg.keyword.enabled 取或。 */
|
|
110
|
+
keywordEnabled: boolean;
|
|
111
|
+
/** 重蒸馏整形启用开关(概览区可拨):true = 放行 dsh-memory/redistill-backfill。与部署 cfg.redistill.enabled 取或。 */
|
|
112
|
+
redistillEnabled: boolean;
|
|
113
|
+
/** md 反向同步启用开关(概览区可拨):true = 放行 md-sync 回灌。与部署 cfg.mdSync.enabled 取或。 */
|
|
114
|
+
mdSyncEnabled: boolean;
|
|
115
|
+
/** 外部记忆来源 Connect 启用开关(概览区可拨):true = 放行 memport-* 端点。与部署 cfg.memport.enabled 取或。 */
|
|
116
|
+
memportEnabled: boolean;
|
|
109
117
|
/** md 投射/后缀收敛的单次动作护栏运行时覆盖:0 = 跟随部署 `cfg.memExport.maxRecords`(默认 2000)。
|
|
110
118
|
* 存在的理由:一次点完多少条属于“回填历史时才改一下”的旋钮,不该要求改 YAML + 重启。 */
|
|
111
119
|
mdExportMaxRecords: number;
|
|
120
|
+
/** 存量回填(keyword/redistill)LLM 批调用的运行时并行度覆盖:0 = 跟随部署
|
|
121
|
+
* `cfg.backfill.concurrency`(默认 2);1–8 = 直接覆盖。概览页 1–3 滑块写的就是它。 */
|
|
122
|
+
backfillConcurrency: number;
|
|
112
123
|
}
|
|
113
124
|
/** 召回停用原因(session-stats recall.enabled=false 时带出;短路序第一个为假的因子)。 */
|
|
114
125
|
export type RecallDisabledReason = 'deploy' | 'global' | 'session' | 'mode';
|
|
@@ -641,12 +652,69 @@ export interface RoomAdminResponse {
|
|
|
641
652
|
/** retire withRecords 实际退场的记录条数(软删,可恢复)。 */
|
|
642
653
|
retiredRecords?: number;
|
|
643
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* 回填端点(wing/keyword/redistill)的请求体。三者都同时是「触发器 + 状态查询」:
|
|
657
|
+
* - `probe` 缺省/false:空闲时**启动**一轮;已在跑则只回状态(单飞)。
|
|
658
|
+
* - `probe: true`:**只读快照**,绝不启动——供 UI 离开区域再回来时恢复在途任务的进度
|
|
659
|
+
* (否则计时器随组件卸载而清,回到页面只能看到卡住的最后一帧)。
|
|
660
|
+
*/
|
|
661
|
+
export interface BackfillTriggerRequest {
|
|
662
|
+
probe?: boolean;
|
|
663
|
+
}
|
|
644
664
|
/** dsh-memory/wing-backfill(一键回填,后台任务;端点立即返回,进度以 wing-overview 轮询)。 */
|
|
645
665
|
export interface WingBackfillResponse {
|
|
646
666
|
/** true = 本次触发启动了新任务;false = 已有任务在跑(单飞,不叠加)。 */
|
|
647
667
|
started: boolean;
|
|
648
668
|
running: boolean;
|
|
649
669
|
}
|
|
670
|
+
/** 回填批扫的逐条进度(keyword/redistill 共用):后台任务一轮可达几分钟,没有它 UI 只能转圈圈。 */
|
|
671
|
+
export interface BackfillProgress {
|
|
672
|
+
/** 每个请求处理的条数(两个回填都已改成逐条 = 1;保留字段供未来批量形状复用)。 */
|
|
673
|
+
chunk: number;
|
|
674
|
+
/** 扫描阶段确定的**真目标**总数(扫描未完成时为已发现数)。 */
|
|
675
|
+
candidates: number;
|
|
676
|
+
/** 本次已完成(送过 LLM 且已提交结果)条数。 */
|
|
677
|
+
processed: number;
|
|
678
|
+
/** 仍在扫描阶段(此阶段报「扫描中」而不是「第 N 条」)。 */
|
|
679
|
+
scanning: boolean;
|
|
680
|
+
/** 已完成条数 / 本次要处理的总条数。 */
|
|
681
|
+
batchesDone: number;
|
|
682
|
+
batchTotal: number;
|
|
683
|
+
/** LLM 批调用并行度(cfg.backfill.concurrency)。 */
|
|
684
|
+
concurrency: number;
|
|
685
|
+
/** 预算/上限用尽未处理、留下次的条数(跟“跑完了”区分开的关键一个数)。 */
|
|
686
|
+
deferred: number;
|
|
687
|
+
}
|
|
688
|
+
/** dsh-memory/keyword-backfill(关键词蒸馏回填,后台任务;门控关时 enabled=false 不启动)。 */
|
|
689
|
+
export interface KeywordBackfillResponse {
|
|
690
|
+
/** true = 本次触发启动了新任务;false = 已有任务在跑或门控关。 */
|
|
691
|
+
started: boolean;
|
|
692
|
+
running: boolean;
|
|
693
|
+
/** 门控 `cfg.keyword.enabled`;关时不启动,面板据此提示先开开关。 */
|
|
694
|
+
enabled: boolean;
|
|
695
|
+
/** 本进程累计成功回填条数(重启归零;观测用)。 */
|
|
696
|
+
updated: number;
|
|
697
|
+
/** 失败/跳过数。 */
|
|
698
|
+
failed: number;
|
|
699
|
+
/** 分批进度(运行期轮询显示用)。 */
|
|
700
|
+
progress: BackfillProgress;
|
|
701
|
+
}
|
|
702
|
+
/** dsh-memory/redistill-backfill(存量记忆重蒸馏整形,后台任务;门控关时 enabled=false 不启动)。 */
|
|
703
|
+
export interface RedistillBackfillResponse {
|
|
704
|
+
/** true = 本次触发启动了新任务;false = 已有任务在跑或门控关。 */
|
|
705
|
+
started: boolean;
|
|
706
|
+
running: boolean;
|
|
707
|
+
/** 门控 `cfg.redistill.enabled`;关时不启动。 */
|
|
708
|
+
enabled: boolean;
|
|
709
|
+
/** 本进程累计整形写回条数(重启归零;观测用)。 */
|
|
710
|
+
updated: number;
|
|
711
|
+
/** 质量闸判不写(留原文)条数。 */
|
|
712
|
+
skipped: number;
|
|
713
|
+
/** 失败/跳过数(LLM 失败、id 配不上、embed 掉线)。 */
|
|
714
|
+
failed: number;
|
|
715
|
+
/** 分批进度(运行期轮询显示用)。 */
|
|
716
|
+
progress: BackfillProgress;
|
|
717
|
+
}
|
|
650
718
|
/** dsh-memory/session-stats(悬浮卡信息区;热路径端点)。 */
|
|
651
719
|
export interface SessionStatsRequest {
|
|
652
720
|
sessionId: string;
|
|
@@ -880,6 +948,38 @@ export interface UiRecord {
|
|
|
880
948
|
*/
|
|
881
949
|
userRelevance?: number;
|
|
882
950
|
}
|
|
951
|
+
/** dsh-memory/records-meta(按 ID 批量查元数据,供 .memory.md 查看器拼接 Qoder 风格条目)。 */
|
|
952
|
+
export interface RecordsMetaRequest {
|
|
953
|
+
/** L1 record_id 列表(上限 50)。 */
|
|
954
|
+
ids: string[];
|
|
955
|
+
}
|
|
956
|
+
/** 单条轻量元数据(含正文,供 .memory.md 查看器拼接头部条)。 */
|
|
957
|
+
export interface RecordMeta {
|
|
958
|
+
id: string;
|
|
959
|
+
/** 名称蒸馏产出的短标题;无 name 时为 null。 */
|
|
960
|
+
name: string | null;
|
|
961
|
+
type: string;
|
|
962
|
+
priority: number;
|
|
963
|
+
/** 正文全文(markdown)。 */
|
|
964
|
+
content: string;
|
|
965
|
+
maturity?: 'low' | 'mid' | 'high';
|
|
966
|
+
createdAt: string | null;
|
|
967
|
+
updatedAt: string | null;
|
|
968
|
+
scene: string;
|
|
969
|
+
scope: 'global' | 'workspace';
|
|
970
|
+
workspaceId: string | null;
|
|
971
|
+
/** 关键词(metadata.dsh_keywords)。 */
|
|
972
|
+
keywords?: string[];
|
|
973
|
+
/** 使用场景(metadata.dsh_usage_scenario)。 */
|
|
974
|
+
usageScenario?: string[];
|
|
975
|
+
/** 投射文件相对路径 `<type>/<stem>.memory.md`。 */
|
|
976
|
+
filePath: string;
|
|
977
|
+
/** 是否已退场(软删)。 */
|
|
978
|
+
retired: boolean;
|
|
979
|
+
}
|
|
980
|
+
export interface RecordsMetaResponse {
|
|
981
|
+
items: RecordMeta[];
|
|
982
|
+
}
|
|
883
983
|
export interface ListRecordsResponse {
|
|
884
984
|
items: UiRecord[];
|
|
885
985
|
hasMore: boolean;
|
|
@@ -1649,11 +1749,14 @@ export interface DshMemoryRequestMap {
|
|
|
1649
1749
|
'dsh-memory/rooms-export': RoomsExportRequest;
|
|
1650
1750
|
'dsh-memory/room-register': RoomRegisterRequest;
|
|
1651
1751
|
'dsh-memory/room-admin': RoomAdminRequest;
|
|
1652
|
-
'dsh-memory/wing-backfill':
|
|
1752
|
+
'dsh-memory/wing-backfill': BackfillTriggerRequest;
|
|
1753
|
+
'dsh-memory/keyword-backfill': BackfillTriggerRequest;
|
|
1754
|
+
'dsh-memory/redistill-backfill': BackfillTriggerRequest;
|
|
1653
1755
|
'dsh-memory/session-stats': SessionStatsRequest;
|
|
1654
1756
|
'dsh-memory/settings-get': Record<string, never>;
|
|
1655
1757
|
'dsh-memory/settings-set': SettingsSetRequest;
|
|
1656
1758
|
'dsh-memory/list-records': ListRecordsRequest;
|
|
1759
|
+
'dsh-memory/records-meta': RecordsMetaRequest;
|
|
1657
1760
|
'dsh-memory/records-delete': RecordsDeleteRequest;
|
|
1658
1761
|
'dsh-memory/receipts': ReceiptsRequest;
|
|
1659
1762
|
'dsh-memory/conflicts': ConflictsRequest;
|
|
@@ -1712,10 +1815,13 @@ export interface DshMemoryResponseMap {
|
|
|
1712
1815
|
'dsh-memory/room-register': RoomRegisterResponse;
|
|
1713
1816
|
'dsh-memory/room-admin': RoomAdminResponse;
|
|
1714
1817
|
'dsh-memory/wing-backfill': WingBackfillResponse;
|
|
1818
|
+
'dsh-memory/keyword-backfill': KeywordBackfillResponse;
|
|
1819
|
+
'dsh-memory/redistill-backfill': RedistillBackfillResponse;
|
|
1715
1820
|
'dsh-memory/session-stats': SessionStatsResponse;
|
|
1716
1821
|
'dsh-memory/settings-get': SettingsGetResponse;
|
|
1717
1822
|
'dsh-memory/settings-set': SettingsSetResponse;
|
|
1718
1823
|
'dsh-memory/list-records': ListRecordsResponse;
|
|
1824
|
+
'dsh-memory/records-meta': RecordsMetaResponse;
|
|
1719
1825
|
'dsh-memory/records-delete': RecordsDeleteResponse;
|
|
1720
1826
|
'dsh-memory/receipts': ReceiptsResponse;
|
|
1721
1827
|
'dsh-memory/conflicts': ConflictsResponse;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 关键词蒸馏回填(keyword axis 产出方)。
|
|
3
|
+
*
|
|
4
|
+
* 职责:从 L1 记录中找「缺 dsh_keywords」的存量记录,**一条一个请求**送 LLM 提炼关键词
|
|
5
|
+
* 与使用场景,归一后写 `metadata.dsh_keywords` + `metadata.dsh_usage_scenario`,
|
|
6
|
+
* 并用 `patchMetadataAndReindex` 保证新关键词**立即进 FTS BM25 轴**。
|
|
7
|
+
*
|
|
8
|
+
* 安全边界(继承 wing-backfill):
|
|
9
|
+
* - 单飞:同时最多一个回填任务,重复触发直接返回 running(不排队不叠加)
|
|
10
|
+
* - 后台执行:端点立即返回,**逐条推进、跑完一条再发下一条**,不阻塞 RPC/管线
|
|
11
|
+
* - 失败可退:LLM 失败/输出不合法只跳过当前块,原记录零改动(标签只写成功的)
|
|
12
|
+
* - 有界:单次任务最多处理 cfg.backfill.maxRecords 条,防止一次跑飞;其余可再次触发
|
|
13
|
+
* - 门控关(`cfg.keyword.enabled = false`):run() 入口立即跳过——无回填、FTS 逐字不变
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
16
|
+
import type { MemoryLogger, MemoryRecord } from './types.js';
|
|
17
|
+
import type { MemoryBackend } from './store/memory-backend.js';
|
|
18
|
+
import type { MemoryConfig } from './config.js';
|
|
19
|
+
export interface KeywordBackfillDeps {
|
|
20
|
+
ctx: Context;
|
|
21
|
+
cfg: MemoryConfig;
|
|
22
|
+
backend: MemoryBackend;
|
|
23
|
+
logger: MemoryLogger;
|
|
24
|
+
/**
|
|
25
|
+
* 运行时**生效并发**读取器(可选):给正在跑的任务即时改并发用——池子每完成一条重读一次。
|
|
26
|
+
* 缺省 = 启动时读一次 `cfg.backfill.concurrency`(旧行为)。
|
|
27
|
+
*/
|
|
28
|
+
concurrency?: () => number | undefined;
|
|
29
|
+
}
|
|
30
|
+
export interface KeywordBackfillState {
|
|
31
|
+
running: boolean;
|
|
32
|
+
updated: number;
|
|
33
|
+
failed: number;
|
|
34
|
+
/** 分批进度(UI 轮询据此显示「扫描中 / 第几条」,否则运行期只有一个「回填中…」): */
|
|
35
|
+
/** 每个请求处理的条数——已改逐条,永远为 1(与 redistill 同形状)。 */
|
|
36
|
+
chunk: number;
|
|
37
|
+
/** 扫描阶段确定的真目标总数(缺关键词;扫描未完成时为已发现数)。 */
|
|
38
|
+
candidates: number;
|
|
39
|
+
/** 本次已完成(送过 LLM 且已写回)**在请求完成时才自增**,否则“全失败”跟“全成功”看起来一样。 */
|
|
40
|
+
processed: number;
|
|
41
|
+
/** 仍在扫描阶段。 */
|
|
42
|
+
scanning: boolean;
|
|
43
|
+
/** 已完成条数(与 processed 同值)。 */
|
|
44
|
+
batchesDone: number;
|
|
45
|
+
/** 本次要处理的总条数。 */
|
|
46
|
+
batchTotal: number;
|
|
47
|
+
/** LLM 批调用并行度。 */
|
|
48
|
+
concurrency: number;
|
|
49
|
+
/** 预算/上限用尽未处理、留下次的条数。 */
|
|
50
|
+
deferred: number;
|
|
51
|
+
}
|
|
52
|
+
export declare function keywordBackfillState(): KeywordBackfillState;
|
|
53
|
+
/** 启动后台关键词回填;已在运行返回 false(单飞)。 */
|
|
54
|
+
export declare function startKeywordBackfill(deps: KeywordBackfillDeps): {
|
|
55
|
+
started: boolean;
|
|
56
|
+
running: boolean;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* 回填主体(导出供单测 await 到完成;生产走 `startKeywordBackfill` 的单飞后台包装)。
|
|
60
|
+
* 门控关(`cfg.keyword.enabled = false`)时入口立即返回,不碰任何记录与 fts。
|
|
61
|
+
*/
|
|
62
|
+
export declare function runKeywordBackfillOnce(deps: KeywordBackfillDeps): Promise<void>;
|
|
63
|
+
/** 单条标注结果。 */
|
|
64
|
+
export interface KeywordOneResult {
|
|
65
|
+
keywords: string[];
|
|
66
|
+
usageScenario: string[];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 标注**一条**记忆的关键词与情景(一个请求只装一条)。失败/无合法关键词返回 null。
|
|
70
|
+
*
|
|
71
|
+
* 旧名 `labelKeywordChunk`(多条一次)已废:合并请求下一次截断/空输出就整批丢弃,
|
|
72
|
+
* 而逐条只花秒级。记账(state.failed 等)全部归调用方——标注器不再动全局状态,
|
|
73
|
+
* 否则同一条会被两处各计一次。
|
|
74
|
+
*/
|
|
75
|
+
export declare function labelKeywordOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, callTimeoutMs?: number): Promise<KeywordOneResult | null>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { callLLM, parseJsonLogged } from './llm.js';
|
|
2
|
+
import { normKeywords, normUsageScenario, KEYWORDS_METADATA_KEY } from './metadata-validators.js';
|
|
3
|
+
import { yieldLoop } from './util/yield.js';
|
|
4
|
+
// 一个请求只装**一条**记忆(旧形状是 20 条/次,已改)。与 redistill 同口径:
|
|
5
|
+
// 合并请求把爆炸半径从 1 条放大到 20 条(一次截断/空输出就整批丢弃),而实测单条
|
|
6
|
+
// 在本地端点上只是秒级,合并并不省时间。
|
|
7
|
+
/** 扫描页大小(轻量投影分页;与 wing-backfill 一致)。 */
|
|
8
|
+
const PAGE = 200;
|
|
9
|
+
/** 单次任务默认处理上限(cfg.backfill.maxRecords 优先)。 */
|
|
10
|
+
const DEFAULT_MAX_RECORDS = 40;
|
|
11
|
+
/** 总墙钟默认预算(cfg.backfill.timeBudgetMs 优先)。 */
|
|
12
|
+
const DEFAULT_TIME_BUDGET_MS = 1_800_000;
|
|
13
|
+
/** 默认并行度(cfg.backfill.concurrency 优先):1 = 串行逐条(用户定的形状)。 */
|
|
14
|
+
const DEFAULT_CONCURRENCY = 1;
|
|
15
|
+
/**
|
|
16
|
+
* 一次调用(含整条回退链)的默认超时(cfg.backfill.callTimeoutMs 优先)。
|
|
17
|
+
* 不能沿用 `llm.timeoutMs`(120s):它是整次调用的预算而不是每条路由各享一份,
|
|
18
|
+
* 主路由空转一轮再降级就会撞线。
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_CALL_TIMEOUT_MS = 240_000;
|
|
21
|
+
/** 单条关键词标注的输出预算(5 关键词 + 3 情景,实测远低于此)。 */
|
|
22
|
+
const LABEL_MAX_TOKENS = 512;
|
|
23
|
+
/** 并发度钳位(与 schema 的 min1/max8 同口径)。 */
|
|
24
|
+
function clampConcurrency(raw) {
|
|
25
|
+
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : DEFAULT_CONCURRENCY;
|
|
26
|
+
return Math.min(8, Math.max(1, n));
|
|
27
|
+
}
|
|
28
|
+
const state = {
|
|
29
|
+
running: false, updated: 0, failed: 0,
|
|
30
|
+
chunk: 1, candidates: 0, processed: 0,
|
|
31
|
+
scanning: false, batchesDone: 0, batchTotal: 0, concurrency: DEFAULT_CONCURRENCY, deferred: 0,
|
|
32
|
+
};
|
|
33
|
+
export function keywordBackfillState() {
|
|
34
|
+
return { ...state };
|
|
35
|
+
}
|
|
36
|
+
// ── SYSTEM 提示词(定稿版,由 tools/keyword-distill.prompt.md 的 ```text 块迁移) ──
|
|
37
|
+
const SYSTEM_PROMPT = [
|
|
38
|
+
'你是记忆库的关键词与情景标注器。给定**一条**记忆(含标题与正文),为它提炼:',
|
|
39
|
+
'1) keywords:最能代表该记忆的检索词,用于精确命中;2) usage_scenario:未来「何时应当浮现这条记忆」的场景短语。',
|
|
40
|
+
'',
|
|
41
|
+
'严格规则:',
|
|
42
|
+
'- keywords 的唯一判据:**用户未来会输入什么词把这条记忆捞出来**。优先具体名词性标识——项目/模块/文件/函数/配置项/命令/错误码/版本号/数字阈值/工具名/专有实体名。',
|
|
43
|
+
'- **禁止**把正文里的动宾短语压缩成抽象黑话当关键词(反例:把"判定批量流量"压成"批量流量判定"、把"做窗口滑动归因"压成"窗口滑动归因"、把"保持只读约束"压成"只读约束")——这类词没人会拿去搜,一律换成它指向的具体实体/标识,或直接删掉。',
|
|
44
|
+
'- keywords:2–5 个(宁少勿滥,够区分即可);每个 2–6 词或 1 个复合术语;不得与标题字面重复、不得互相近义堆叠、不收停用词/整句。',
|
|
45
|
+
'- usage_scenario:1–3 条;每条 5–10 词的短场景描述(不是完整命令句);彼此必须可区分,相似的合并成更泛化的一条。',
|
|
46
|
+
'- 语言跟随该记忆正文:中文记忆产出中文(技术标识符、API 名、错误码原样保留),英文记忆产出英文。',
|
|
47
|
+
'',
|
|
48
|
+
'输出:只输出**一个 JSON 对象**(不是数组),不要多余文字、不要解释。形如:',
|
|
49
|
+
'{"keywords":["…","…"],"usage_scenario":["…","…"]}',
|
|
50
|
+
].join('\n');
|
|
51
|
+
/** 启动后台关键词回填;已在运行返回 false(单飞)。 */
|
|
52
|
+
export function startKeywordBackfill(deps) {
|
|
53
|
+
if (state.running)
|
|
54
|
+
return { started: false, running: true };
|
|
55
|
+
state.running = true;
|
|
56
|
+
state.updated = 0;
|
|
57
|
+
state.failed = 0;
|
|
58
|
+
state.candidates = 0;
|
|
59
|
+
state.processed = 0;
|
|
60
|
+
state.scanning = true;
|
|
61
|
+
state.batchesDone = 0;
|
|
62
|
+
state.batchTotal = 0;
|
|
63
|
+
state.deferred = 0;
|
|
64
|
+
state.concurrency = clampConcurrency(deps.cfg.backfill?.concurrency);
|
|
65
|
+
void runKeywordBackfillOnce(deps).finally(() => {
|
|
66
|
+
state.running = false;
|
|
67
|
+
state.scanning = false;
|
|
68
|
+
deps.logger.info(`[memory] keyword 回填结束:成功 ${state.updated} 条,失败 ${state.failed} 条`
|
|
69
|
+
+ (state.deferred > 0 ? `,留待下次 ${state.deferred} 条` : ''));
|
|
70
|
+
});
|
|
71
|
+
return { started: true, running: true };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 回填主体(导出供单测 await 到完成;生产走 `startKeywordBackfill` 的单飞后台包装)。
|
|
75
|
+
* 门控关(`cfg.keyword.enabled = false`)时入口立即返回,不碰任何记录与 fts。
|
|
76
|
+
*/
|
|
77
|
+
export async function runKeywordBackfillOnce(deps) {
|
|
78
|
+
const { cfg, backend, logger } = deps;
|
|
79
|
+
if (!cfg.keyword?.enabled) {
|
|
80
|
+
logger.info('[memory] keyword 回填跳过:keyword.enabled = false(功能关,不回填)');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const MAX_RECORDS = cfg.backfill?.maxRecords ?? DEFAULT_MAX_RECORDS;
|
|
84
|
+
const TIME_BUDGET_MS = cfg.backfill?.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
|
|
85
|
+
const LIMIT = clampConcurrency(cfg.backfill?.concurrency);
|
|
86
|
+
state.concurrency = LIMIT;
|
|
87
|
+
const startedAt = Date.now();
|
|
88
|
+
// ── 扫描阶段(轻量投影,不反序列化正文;分页 + 每页让位,不设墙钟)──
|
|
89
|
+
const total = await backend.size();
|
|
90
|
+
const pendingIds = [];
|
|
91
|
+
let scanned = 0;
|
|
92
|
+
state.scanning = true;
|
|
93
|
+
for (let offset = 0;; offset += PAGE) {
|
|
94
|
+
const page = await backend.allLite(PAGE, offset);
|
|
95
|
+
if (page.length === 0)
|
|
96
|
+
break;
|
|
97
|
+
for (const r of page) {
|
|
98
|
+
const kw = r.metadata?.[KEYWORDS_METADATA_KEY];
|
|
99
|
+
if (!(Array.isArray(kw) && kw.length > 0))
|
|
100
|
+
pendingIds.push(r.id);
|
|
101
|
+
}
|
|
102
|
+
scanned += page.length;
|
|
103
|
+
state.candidates = pendingIds.length;
|
|
104
|
+
await yieldLoop();
|
|
105
|
+
}
|
|
106
|
+
state.scanning = false;
|
|
107
|
+
const batch = pendingIds.slice(0, MAX_RECORDS);
|
|
108
|
+
state.candidates = batch.length;
|
|
109
|
+
state.batchTotal = batch.length;
|
|
110
|
+
state.deferred = Math.max(0, pendingIds.length - batch.length);
|
|
111
|
+
const callTimeoutMs = cfg.backfill?.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
112
|
+
logger.info(`[memory] keyword 回填启动:已扫 ${scanned}/${total} 行,缺关键词 ${pendingIds.length} 条,本次处理 ${batch.length} 条,并行度 ${LIMIT}`);
|
|
113
|
+
/** 一条一个请求:取一条 → 标注 → **即时写回**(含 fts 重索引)。 */
|
|
114
|
+
const labelAndCommit = async (id) => {
|
|
115
|
+
const [rec] = await backend.getByIds([id]);
|
|
116
|
+
if (!rec || !rec.content || rec.content.trim() === '') {
|
|
117
|
+
state.processed++;
|
|
118
|
+
state.batchesDone++;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const labeled = await labelKeywordOne(deps.ctx, cfg, logger, rec, callTimeoutMs);
|
|
122
|
+
if (!labeled) {
|
|
123
|
+
state.failed++;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const meta = { ...(rec.metadata ?? {}) };
|
|
127
|
+
meta[KEYWORDS_METADATA_KEY] = labeled.keywords;
|
|
128
|
+
if (labeled.usageScenario.length > 0)
|
|
129
|
+
meta.dsh_usage_scenario = labeled.usageScenario;
|
|
130
|
+
if (await backend.patchMetadataAndReindex(rec.id, meta))
|
|
131
|
+
state.updated++;
|
|
132
|
+
else
|
|
133
|
+
state.failed++;
|
|
134
|
+
}
|
|
135
|
+
// 完成才计数(同 redistill):否则“全失败”在面板上跟“全成功”长得一样。
|
|
136
|
+
state.processed++;
|
|
137
|
+
state.batchesDone++;
|
|
138
|
+
await yieldLoop();
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* 弹性工作池(与 redistill 同构):每完成一条重读一次**生效并发**,拖概览页滑块即时生效。
|
|
142
|
+
* 拖高立刻补 worker;拖低则各自干完手头那条后自然收工。缺省 getter = 启动值。
|
|
143
|
+
* 预算只在**条前**检查,中途不挖已烧 token 的那条。
|
|
144
|
+
*/
|
|
145
|
+
const readLimit = () => {
|
|
146
|
+
const raw = deps.concurrency ? deps.concurrency() : undefined;
|
|
147
|
+
return clampConcurrency(raw ?? cfg.backfill?.concurrency);
|
|
148
|
+
};
|
|
149
|
+
let cursor = 0;
|
|
150
|
+
let budgetHit = false;
|
|
151
|
+
const work = async () => {
|
|
152
|
+
for (;;) {
|
|
153
|
+
if (budgetHit || Date.now() - startedAt > TIME_BUDGET_MS) {
|
|
154
|
+
budgetHit = true;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const i = cursor++;
|
|
158
|
+
if (i >= batch.length)
|
|
159
|
+
return;
|
|
160
|
+
await labelAndCommit(batch[i]);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const inFlight = new Set();
|
|
164
|
+
const spawn = () => {
|
|
165
|
+
const p = work().finally(() => inFlight.delete(p));
|
|
166
|
+
inFlight.add(p);
|
|
167
|
+
};
|
|
168
|
+
for (;;) {
|
|
169
|
+
const target = Math.max(1, Math.min(readLimit(), batch.length));
|
|
170
|
+
state.concurrency = target; // 面板「并行 N」跟实时值走
|
|
171
|
+
while (!budgetHit && inFlight.size < target && cursor < batch.length)
|
|
172
|
+
spawn();
|
|
173
|
+
if (inFlight.size === 0)
|
|
174
|
+
break;
|
|
175
|
+
await Promise.race(inFlight);
|
|
176
|
+
}
|
|
177
|
+
if (budgetHit) {
|
|
178
|
+
state.deferred += Math.max(0, batch.length - state.processed);
|
|
179
|
+
logger.info(`[memory] keyword 回填:墙钟预算(${Math.round(TIME_BUDGET_MS / 1000)}s)用尽,累计留待再触发 ${state.deferred} 条`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* 标注**一条**记忆的关键词与情景(一个请求只装一条)。失败/无合法关键词返回 null。
|
|
184
|
+
*
|
|
185
|
+
* 旧名 `labelKeywordChunk`(多条一次)已废:合并请求下一次截断/空输出就整批丢弃,
|
|
186
|
+
* 而逐条只花秒级。记账(state.failed 等)全部归调用方——标注器不再动全局状态,
|
|
187
|
+
* 否则同一条会被两处各计一次。
|
|
188
|
+
*/
|
|
189
|
+
export async function labelKeywordOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS) {
|
|
190
|
+
const user = `记忆:\n标题:${rec.name ?? ''}\n正文:${rec.content.slice(0, 900)}`;
|
|
191
|
+
const t0 = Date.now();
|
|
192
|
+
const secs = () => Math.round((Date.now() - t0) / 10);
|
|
193
|
+
try {
|
|
194
|
+
const raw = await callLLM(ctx, cfg, {
|
|
195
|
+
system: SYSTEM_PROMPT,
|
|
196
|
+
user,
|
|
197
|
+
maxTokens: LABEL_MAX_TOKENS,
|
|
198
|
+
layer: 'l1-extract',
|
|
199
|
+
logger,
|
|
200
|
+
signal: AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS),
|
|
201
|
+
});
|
|
202
|
+
logger.debug?.(`[memory] keyword 单条耗时 ${(Date.now() - t0) / 1000}s(id=${rec.id})`);
|
|
203
|
+
const parsed = parseJsonLogged(raw, 'keyword 回填', logger);
|
|
204
|
+
const obj = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
205
|
+
if (!obj || typeof obj !== 'object')
|
|
206
|
+
return null;
|
|
207
|
+
const keywords = normKeywords(obj.keywords, 5);
|
|
208
|
+
if (keywords.length === 0) {
|
|
209
|
+
logger.warn(`[memory] keyword 回填:normKeywords 结果为空,已跳过(id=${rec.id})`);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return { keywords, usageScenario: normUsageScenario(obj.usage_scenario, 3) };
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
logger.warn(`[memory] keyword 单条失败(id=${rec.id},已等 ${secs() / 10}s,原记录未改): ${err instanceof Error ? err.message : String(err)}`);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -32,6 +32,15 @@ export { isCognitiveHall, COGNITIVE_HALLS, type CognitiveHall } from './cognitiv
|
|
|
32
32
|
* 非数组或空→ `[]`(缺字段中性,不抛——防单条抽取失败波及整切片重试)。
|
|
33
33
|
*/
|
|
34
34
|
export declare function normUsageScenario(v: unknown, max?: number): string[];
|
|
35
|
+
/** 关键词检索轴的磁盘保留键(喂 `l1_fts.keywords` 列,读取侧见 `sqlite.ftsKeywordText`)。 */
|
|
36
|
+
export declare const KEYWORDS_METADATA_KEY = "dsh_keywords";
|
|
37
|
+
/**
|
|
38
|
+
* 关键词归一 + 去重(忽略大小写)+ 长度钳 + 上限。**所有 `dsh_keywords` 写回点必须过这里**。
|
|
39
|
+
*
|
|
40
|
+
* 与 `normUsageScenario` 同范式(关键词是自然语言术语/标识符,非 slug,不做字符集校验),
|
|
41
|
+
* 只是上限更紧(≤5)、单条更短。非数组/空→`[]`(缺字段中性,不抛——防单条产出失败波及整批)。
|
|
42
|
+
*/
|
|
43
|
+
export declare function normKeywords(v: unknown, max?: number): string[];
|
|
35
44
|
/**
|
|
36
45
|
* 读 **Wing(生活域)**。⚠️ 磁盘键是 `metadata.hall`——历史别名,存的其实是 **wing 值**。
|
|
37
46
|
* 只认词表内的合法 wing(8 角 + general),否则 undefined。
|
|
@@ -92,6 +92,37 @@ export function normUsageScenario(v, max = 3) {
|
|
|
92
92
|
}
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
|
+
/** 关键词检索轴的磁盘保留键(喂 `l1_fts.keywords` 列,读取侧见 `sqlite.ftsKeywordText`)。 */
|
|
96
|
+
export const KEYWORDS_METADATA_KEY = 'dsh_keywords';
|
|
97
|
+
/** 单个关键词最大字符数:按“词”太主观,用字符上限兜住整句泄漏(CJK 复合术语通常 ≤12)。 */
|
|
98
|
+
const KEYWORD_MAX_CHARS = 24;
|
|
99
|
+
/**
|
|
100
|
+
* 关键词归一 + 去重(忽略大小写)+ 长度钳 + 上限。**所有 `dsh_keywords` 写回点必须过这里**。
|
|
101
|
+
*
|
|
102
|
+
* 与 `normUsageScenario` 同范式(关键词是自然语言术语/标识符,非 slug,不做字符集校验),
|
|
103
|
+
* 只是上限更紧(≤5)、单条更短。非数组/空→`[]`(缺字段中性,不抛——防单条产出失败波及整批)。
|
|
104
|
+
*/
|
|
105
|
+
export function normKeywords(v, max = 5) {
|
|
106
|
+
if (!Array.isArray(v) || max <= 0)
|
|
107
|
+
return [];
|
|
108
|
+
const out = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (const raw of v) {
|
|
111
|
+
if (typeof raw !== 'string')
|
|
112
|
+
continue;
|
|
113
|
+
const t = raw.trim();
|
|
114
|
+
if (!t)
|
|
115
|
+
continue;
|
|
116
|
+
const key = t.toLowerCase();
|
|
117
|
+
if (seen.has(key))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
out.push(t.slice(0, KEYWORD_MAX_CHARS));
|
|
121
|
+
if (out.length >= max)
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
95
126
|
/**
|
|
96
127
|
* 读 **Wing(生活域)**。⚠️ 磁盘键是 `metadata.hall`——历史别名,存的其实是 **wing 值**。
|
|
97
128
|
* 只认词表内的合法 wing(8 角 + general),否则 undefined。
|