@apollo-music/dsh-sidecar-terms 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -9,9 +9,9 @@
9
9
  5. request 尚未固定时 inject,step 已开始或已有候选回答时 steer;
10
10
  6. steer 修订后不再审计同一 turn 的后续 assistant 输出,避免规则回执自我触发;下一 turn 自动恢复;
11
11
  7. 最终答案前只 steer 尚未交付的 canonical term / Rule delta。
12
- 8. Catalog 或 Resolver 暂不可用时启用内置 P0 快照;高风险核心词仍受控,其余无法确认的候选词保留原文并标记复核。
12
+ 8. Catalog、Resolver、鉴权或网络不可用时失败关闭,不生成离线替代规则。
13
13
 
14
- 内置快照只承担故障降级,不替代线上全量 102 术语目录。降级消息带 `degraded` 语义并写入 Harness warning 日志;远程目录恢复后按 `catalogRefreshMs` 自动重试。
14
+ 插件固定只读 `https://apollomusic-poc1.fde0.com/kb/terminology`。`apiUrl` 不再是运行时配置项,线上服务返回 `degraded` 也会按错误处理;插件不包含本地术语表或 P0 fallback。
15
15
 
16
16
  ## 安装
17
17
 
@@ -29,7 +29,7 @@ npm install @apollo-music/dsh-sidecar-terms
29
29
  export APOLLO_TERMINOLOGY_READ_API_KEY='<READ_ONLY_API_KEY>'
30
30
  ```
31
31
 
32
- 本地调试与未来公网部署使用同一个 HTTP 契约,只需切换 `apiUrl`。本地服务默认可运行在 `http://localhost:3092`;公网部署后改为 HTTPS 域名,插件代码和 Agent 配置无需变化。
32
+ 只需把线上服务的只读凭据注入 Harness 进程。服务地址由插件版本锁定,不能通过环境变量或 Composition 改写。
33
33
 
34
34
  在 DeepSeek Harness 的 `cordis.yml` 插件列表中加入。Cordis 的 `name` 支持 npm 包名:
35
35
 
@@ -37,7 +37,6 @@ export APOLLO_TERMINOLOGY_READ_API_KEY='<READ_ONLY_API_KEY>'
37
37
  - id: apollo-terminology-rules
38
38
  name: '@apollo-music/dsh-sidecar-terms'
39
39
  config:
40
- apiUrl: 'http://localhost:3092'
41
40
  apiKeyEnv: 'APOLLO_TERMINOLOGY_READ_API_KEY'
42
41
  queryIntervalMs: 0
43
42
  catalogRefreshMs: 60000
@@ -55,17 +54,13 @@ npm ci
55
54
  npm run build:harness-plugin
56
55
  ```
57
56
 
58
- 本地与公网示例:
57
+ 凭据示例:
59
58
 
60
59
  ```bash
61
- # 本地
62
- export APOLLO_TERMINOLOGY_API_URL='http://localhost:3092'
63
-
64
- # 未来公网
65
- export APOLLO_TERMINOLOGY_API_URL='https://terminology.example.com'
60
+ export APOLLO_TERMINOLOGY_READ_API_KEY='<READ_ONLY_API_KEY>'
66
61
  ```
67
62
 
68
- 插件不会注册模型可见 Tool,也不拦截或改写最终答案。命中后注入的内容以 `[阿波罗术语库:原文增量]` 开头,仅包含 API 返回的新增 term/rule 数据;知识库版本、revision 或降级状态没有变化时也不重复。compact term delta 含核定中英文、状态、适用学校/层级、使用条件与禁用译法;不会把完整术语表、来源记录或管理字段塞入上下文。`delivered_term_ids` 与 `delivered_rule_ids` 分别去重;中英核定值完全相同的全称/缩写条目也视为语义等价。
63
+ 插件不会注册模型可见 Tool,也不拦截或改写最终答案。命中后注入的内容以 `[阿波罗术语库:原文增量]` 开头,仅包含 API 返回的新增 term/rule 数据;知识库版本或 revision 没有变化时也不重复。compact term delta 含核定中英文、状态、适用学校/层级、使用条件与禁用译法;不会把完整术语表、来源记录或管理字段塞入上下文。`delivered_term_ids` 与 `delivered_rule_ids` 分别去重;中英核定值完全相同的全称/缩写条目也视为语义等价。
69
64
 
70
65
  由于 Harness 的运行时 context 与 steer 消息使用 user role,宿主 persona 必须在 **system prompt** 中预先声明执行协议;不要把协议文字重复放进每批 Delta。使用 `complete: true` 的 persona 时,协议必须直接写入该完整 persona,例如:
71
66
 
@@ -79,9 +74,9 @@ export APOLLO_TERMINOLOGY_API_URL='https://terminology.example.com'
79
74
 
80
75
  `auditReasoning: true` 只匹配每个 step 已提交的完整 reasoning block;流式 `reasoning-delta` 不参与匹配。thinking 原文只发送给 Resolver 做瞬时匹配,不会回注给模型。若部署边界不允许 thinking 离开 Harness 进程,可设为 `false`。
81
76
 
82
- 该插件声明依赖 `agents`、`sessions` 与 `tools`;Cordis 会等相关服务存在后再加载。官方 Harness 目前仍处于 Developer Preview,本项目锁定并测试 `0.1.0-rc.7` 的公开 `Agent`、`session/event`、`tools/post-execute`、`agent/pre-step`、`agent/turn-stopping` 接口。
77
+ 该插件声明依赖 `agents`、`sessions` 与 `tools`;Cordis 会等相关服务存在后再加载。官方 Harness 目前仍处于 Developer Preview,本项目锁定并测试 `0.1.0-rc.8` 的公开 `Agent`、`session/event`、`tools/post-execute`、`agent/pre-step`、`agent/turn-stopping` 接口。
83
78
 
84
- 在线验证(本地 API 已启动时):
79
+ 配置线上只读凭据后验证:
85
80
 
86
81
  ```bash
87
82
  npm run test:api
package/dist/api.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CatalogResponse, PluginConfig, ResolveResponse } from "./types.js";
2
+ export declare const APOLLO_TERMINOLOGY_API_URL = "https://apollomusic-poc1.fde0.com/kb/terminology";
2
3
  export declare class TerminologyApi {
3
4
  private readonly config;
4
5
  private readonly apiKey;
package/dist/api.js CHANGED
@@ -1,4 +1,4 @@
1
- import { FALLBACK_CATALOG, resolveWithP0Fallback } from "./fallback.js";
1
+ export const APOLLO_TERMINOLOGY_API_URL = "https://apollomusic-poc1.fde0.com/kb/terminology";
2
2
  export class TerminologyApi {
3
3
  config;
4
4
  apiKey;
@@ -18,35 +18,30 @@ export class TerminologyApi {
18
18
  if (!Array.isArray(value.entries) || typeof value.catalog_revision !== "number") {
19
19
  throw new Error("Terminology catalog response is malformed");
20
20
  }
21
+ if (value.degraded) {
22
+ throw new Error(`Terminology catalog returned a degraded response: ${value.fallback_reason ?? "reason unavailable"}`);
23
+ }
21
24
  this.catalog = { value, fetchedAt: Date.now() };
22
25
  return value;
23
- }).catch((error) => {
24
- const fallback = this.catalog?.value ?? {
25
- ...FALLBACK_CATALOG,
26
- fallback_reason: errorMessage(error),
27
- };
28
- const degraded = {
29
- ...fallback,
30
- degraded: true,
31
- fallback_reason: `live catalog unavailable: ${errorMessage(error)}`,
32
- };
33
- this.catalog = { value: degraded, fetchedAt: Date.now() };
34
- return degraded;
35
26
  }).finally(() => {
36
27
  this.catalogRequest = undefined;
37
28
  });
38
29
  return this.catalogRequest;
39
30
  }
40
- resolve(body) {
41
- return this.request("resolve", {
31
+ async resolve(body) {
32
+ const value = await this.request("resolve", {
42
33
  method: "POST",
43
34
  body: JSON.stringify(body),
44
- }).catch((error) => resolveWithP0Fallback(body, `resolver unavailable: ${errorMessage(error)}`));
35
+ });
36
+ if (value.degraded) {
37
+ throw new Error(`Terminology resolver returned a degraded response: ${value.fallback_reason ?? "reason unavailable"}`);
38
+ }
39
+ return value;
45
40
  }
46
41
  async request(path, init = {}) {
47
- const baseUrl = this.config.apiUrl.replace(/\/+$/, "");
48
- const response = await this.fetchImpl(`${baseUrl}/api/v1/${path}`, {
42
+ const response = await this.fetchImpl(`${APOLLO_TERMINOLOGY_API_URL}/api/v1/${path}`, {
49
43
  ...init,
44
+ redirect: "error",
50
45
  headers: {
51
46
  Accept: "application/json",
52
47
  "X-API-Key": this.apiKey,
@@ -64,6 +59,3 @@ export class TerminologyApi {
64
59
  return value;
65
60
  }
66
61
  }
67
- function errorMessage(error) {
68
- return error instanceof Error ? error.message : String(error);
69
- }
@@ -177,9 +177,6 @@ export class TerminologyCoordinator {
177
177
  delivered_rule_ids: this.knownRuleIds(state),
178
178
  delivered_term_ids: this.knownTermIds(state),
179
179
  });
180
- if (response.degraded) {
181
- this.logger.warn("terminology resolver degraded through seq %d: %s", throughSeq, response.fallback_reason ?? "bundled P0 snapshot");
182
- }
183
180
  const checkpoint = {
184
181
  throughSeq,
185
182
  complete: response.rule_delta.length === 0 && (response.term_delta?.length ?? 0) === 0,
@@ -295,9 +292,6 @@ export function renderDelta(response, metadataUpdate = "initial") {
295
292
  else if (metadataUpdate === "changed") {
296
293
  lines.push(`知识库状态更新:版本 ${response.kb_version},catalog revision ${response.catalog_revision}。`);
297
294
  }
298
- if (response.degraded && metadataUpdate !== "unchanged") {
299
- lines.push("远程术语库暂不可用;当前使用内置 P0 保守规则。");
300
- }
301
295
  for (const term of response.term_delta ?? [])
302
296
  lines.push(...renderTerm(term));
303
297
  for (const rule of response.rule_delta)
@@ -308,7 +302,6 @@ function metadataSnapshot(response) {
308
302
  return {
309
303
  kbVersion: response.kb_version,
310
304
  catalogRevision: response.catalog_revision,
311
- degraded: response.degraded === true,
312
305
  };
313
306
  }
314
307
  function metadataDelta(current, response) {
@@ -316,7 +309,6 @@ function metadataDelta(current, response) {
316
309
  return "initial";
317
310
  return current.kbVersion !== response.kb_version
318
311
  || current.catalogRevision !== response.catalog_revision
319
- || current.degraded !== (response.degraded === true)
320
312
  ? "changed"
321
313
  : "unchanged";
322
314
  }
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export type Config = PluginConfig;
8
8
  export declare const Config: Schema<Config>;
9
9
  export declare function apply(ctx: Context, config: Config): void;
10
10
  export declare function messageText(message: Message, auditReasoning?: boolean): string;
11
- export { TerminologyApi } from "./api.js";
11
+ export { APOLLO_TERMINOLOGY_API_URL, TerminologyApi } from "./api.js";
12
12
  export { TerminologyCoordinator } from "./coordinator.js";
13
13
  export { findCatalogMatches, normalizeMatchKey } from "./matcher.js";
14
14
  export type { AgentPort, CatalogEntry, CatalogResponse, ResolveResponse } from "./types.js";
package/dist/index.js CHANGED
@@ -4,7 +4,6 @@ import { isOwnPluginMessage, TerminologyCoordinator } from "./coordinator.js";
4
4
  export const name = "apollo-terminology-rules";
5
5
  export const inject = ["agents", "sessions", "tools"];
6
6
  export const Config = Schema.object({
7
- apiUrl: Schema.string().required().description("阿波罗术语库 API 根地址"),
8
7
  apiKeyEnv: Schema.string().default("APOLLO_TERMINOLOGY_READ_API_KEY").description("只读 API Key 所在环境变量名"),
9
8
  queryIntervalMs: Schema.natural().min(0).max(60_000).default(0).description("常规增量查询的最小间隔(毫秒);默认即时查询"),
10
9
  catalogRefreshMs: Schema.natural().min(1_000).max(3_600_000).default(60_000).description("本地 matcher catalog 缓存时间"),
@@ -165,6 +164,6 @@ function blockText(block, auditReasoning) {
165
164
  return block.content.flatMap((item) => blockText(item, auditReasoning));
166
165
  return [];
167
166
  }
168
- export { TerminologyApi } from "./api.js";
167
+ export { APOLLO_TERMINOLOGY_API_URL, TerminologyApi } from "./api.js";
169
168
  export { TerminologyCoordinator } from "./coordinator.js";
170
169
  export { findCatalogMatches, normalizeMatchKey } from "./matcher.js";
package/dist/types.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { UserMessage } from "@deepseek-ai/dsh-llm";
2
2
  export interface PluginConfig {
3
- apiUrl: string;
4
3
  apiKeyEnv: string;
5
4
  queryIntervalMs: number;
6
5
  catalogRefreshMs: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollo-music/dsh-sidecar-terms",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "DeepSeek Harness sidecar plugin for incremental Apollo terminology and rule injection",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,14 +36,15 @@
36
36
  "sidecar"
37
37
  ],
38
38
  "scripts": {
39
- "build": "tsc -p tsconfig.json"
39
+ "build": "rm -rf dist && tsc -p tsconfig.json",
40
+ "prepack": "npm run build"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "@deepseek-ai/cordis": "^4.0.1",
43
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.7",
44
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
45
- "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
46
- "@deepseek-ai/dsh-tools": "0.1.0-rc.7",
44
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
45
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
46
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.8",
47
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.8",
47
48
  "@deepseek-ai/schemastery": "^3.18.1"
48
49
  }
49
50
  }
@@ -1,3 +0,0 @@
1
- import type { CatalogResponse, ResolveResponse } from "./types.js";
2
- export declare const FALLBACK_CATALOG: CatalogResponse;
3
- export declare function resolveWithP0Fallback(body: Record<string, unknown>, reason: string): ResolveResponse;
package/dist/fallback.js DELETED
@@ -1,169 +0,0 @@
1
- import { findCatalogMatches, normalizeMatchKey } from "./matcher.js";
2
- const VERSION = "0.1.0-p0-fallback";
3
- const P0_RULES = [
4
- {
5
- rule_id: "RULE-001",
6
- priority: "P0",
7
- trigger: "命中已锁定术语",
8
- instruction: "严格使用知识库核定译法;当前离线快照不含完整译值时保留德文,不得自行改写。",
9
- bad_example: "Musiktheater→音乐剧",
10
- good_example: "Musiktheater→歌剧",
11
- },
12
- {
13
- rule_id: "RULE-002",
14
- priority: "P0",
15
- trigger: "Musiktheater 与 Musical",
16
- instruction: "先判断原词:Musiktheater 在音乐学院专业语境译歌剧;Musical 才译音乐剧。",
17
- bad_example: "Gesang/Musiktheater→声乐/音乐剧",
18
- good_example: "Gesang/Musiktheater→声乐/歌剧",
19
- },
20
- {
21
- rule_id: "RULE-003",
22
- priority: "P0",
23
- trigger: "缩写 CoPeCo/KMM/KPA/EUJAM/EMP",
24
- instruction: "首次出现必须输出中文全称+规范缩写;缩写大小写按 canonical term。",
25
- bad_example: "CopeCo;只写 KMM",
26
- good_example: "当代表演与作曲联合硕士(CoPeCo);文化与媒体管理(KMM)",
27
- },
28
- {
29
- rule_id: "RULE-004",
30
- priority: "P0",
31
- trigger: "语境型术语但缺少学校/专业/学位上下文",
32
- instruction: "保留德文原词,并返回 needs_review=true;不得猜译。",
33
- bad_example: "Diplom 一律译硕士",
34
- good_example: "Diplom(层级待逐校确认)",
35
- },
36
- {
37
- rule_id: "RULE-005",
38
- priority: "P0",
39
- trigger: "离线快照不能确认术语",
40
- instruction: "保留官方德文全称,标记 pending;禁止只保留未知缩写或自动生成中文名。",
41
- bad_example: "未知项目自动生成中文名",
42
- good_example: "保留德文+中文译名待确认",
43
- },
44
- {
45
- rule_id: "RULE-013",
46
- priority: "P0",
47
- trigger: "Solistenexamen/Solistenklasse/Solistenstudium 与 Konzertexamen",
48
- instruction: "按官方原词分别保存,逐校核对层级;不得自动建立等价映射。",
49
- bad_example: "Solistenexamen 一律译演奏家文凭",
50
- good_example: "Solistenexamen(层级逐校确认)",
51
- },
52
- ];
53
- const FALLBACK_TERMS = {
54
- "TERM-001": term("TERM-001", "Musiktheater", "歌剧", "Opera", "locked", "contextual", [], "仅限德国音乐学院专业语境;与 Musical 严格区分。", ["音乐剧", "Musical Theatre", "Musical Theater"]),
55
- "TERM-002": term("TERM-002", "Musical", "音乐剧", "Musical Theatre", "locked", "generic", [], "与 Musiktheater 严格区分。", ["歌剧"]),
56
- "TERM-016": term("TERM-016", "CoPeCo", "当代表演与作曲联合硕士(CoPeCo)", "Master’s in Contemporary Performance and Composition (CoPeCo)", "locked", "school_specific", ["hfmt-hamburg"], "首次出现必须写中文全称与规范缩写。", ["只写CoPeCo", "CopeCo", "不翻译"], "Contemporary Performance & Composition", "Master"),
57
- "TERM-017": term("TERM-017", "Contemporary Performance & Composition", "当代表演与作曲联合硕士(CoPeCo)", "Master’s in Contemporary Performance and Composition (CoPeCo)", "locked", "school_specific", ["hfmt-hamburg"], "首次出现必须写中文全称与规范缩写。", ["不翻译"], "", "Master"),
58
- "TERM-018": term("TERM-018", "KMM", "文化与媒体管理(KMM)", "Culture and Media Management (KMM)", "locked", "school_specific", ["hfmt-hamburg"], "首次出现必须写中文全称与规范缩写。", ["只写KMM", "艺术管理"]),
59
- "TERM-019": term("TERM-019", "Kultur- und Medienmanagement", "文化与媒体管理(KMM)", "Culture and Media Management (KMM)", "locked", "school_specific", ["hfmt-hamburg"], "使用知识库核定中文名。", ["艺术管理"]),
60
- "TERM-020": term("TERM-020", "EUJAM", "欧洲爵士硕士(EUJAM)", "European Jazz Master (EUJAM)", "locked", "school_specific", ["hfmt-hamburg"], "首次出现必须写中文全称与规范缩写。", ["只写EUJAM"]),
61
- "TERM-021": term("TERM-021", "European Jazz Master", "欧洲爵士硕士(EUJAM)", "European Jazz Master (EUJAM)", "locked", "school_specific", ["hfmt-hamburg"], "使用知识库核定中文名。", []),
62
- "TERM-026": term("TERM-026", "Konzertexamen", "演奏家文凭", "Advanced Performance Diploma (Konzertexamen)", "locked", "generic", [], "保留德文可用于院校差异追溯。", ["音乐会考试", "演奏考试"]),
63
- "TERM-027": term("TERM-027", "Solistenexamen", "独奏家考试(Solistenexamen,层级逐校确认)", "Soloist Examination (level to be verified)", "contextual", "contextual", [], "与 Konzertexamen 相关但不等同,必须逐校确认。", ["一律等同Konzertexamen", "一律译演奏家文凭", "硕士", "博士"]),
64
- "TERM-036": term("TERM-036", "Künstlerisch-Pädagogische Ausbildung (KPA)", "音乐教育(KPA)", "Artistic-Pedagogical Music Education (KPA)", "locked", "school_specific", ["udk-berlin"], "UdK 语境固定译音乐教育,学位层级另列。", ["只写KPA", "艺术与教育训练"]),
65
- "TERM-063": term("TERM-063", "Elementare Musikpädagogik (EMP)", "基础音乐教育(EMP)", "Elementary Music Pedagogy (EMP)", "locked", "generic", [], "首次出现保留 EMP。", ["初级音乐教育"]),
66
- };
67
- const rawEntries = [
68
- ["Künstlerisch-Pädagogische Ausbildung (KPA)", ["TERM-036"], ["RULE-001", "RULE-003"]],
69
- ["Contemporary Performance & Composition", ["TERM-016", "TERM-017"], ["RULE-001", "RULE-003"]],
70
- ["Elementare Musikpädagogik (EMP)", ["TERM-063"], ["RULE-001", "RULE-003"]],
71
- ["Kultur- und Medienmanagement", ["TERM-018", "TERM-019"], ["RULE-001", "RULE-003"]],
72
- ["European Jazz Master", ["TERM-020", "TERM-021"], ["RULE-001", "RULE-003"]],
73
- ["Musiktheaterstudium", ["TERM-001"], ["RULE-001", "RULE-002"]],
74
- ["Solistenstudium", [], ["RULE-013"]],
75
- ["Solistenexamen", ["TERM-027"], ["RULE-004", "RULE-013"]],
76
- ["Solistenklasse", [], ["RULE-013"]],
77
- ["Konzertexamen", ["TERM-026"], ["RULE-001", "RULE-013"]],
78
- ["Musiktheater", ["TERM-001"], ["RULE-001", "RULE-002"]],
79
- ["European Jazz Master (EUJAM)", ["TERM-020"], ["RULE-001", "RULE-003"]],
80
- ["Kultur- und Medienmanagement (KMM)", ["TERM-018"], ["RULE-001", "RULE-003"]],
81
- ["Contemporary Performance & Composition (CoPeCo)", ["TERM-016"], ["RULE-001", "RULE-003"]],
82
- ["Musical", ["TERM-002"], ["RULE-001", "RULE-002"]],
83
- ["CoPeCo", ["TERM-016"], ["RULE-001", "RULE-003"]],
84
- ["EUJAM", ["TERM-020"], ["RULE-001", "RULE-003"]],
85
- ["KMM", ["TERM-018"], ["RULE-001", "RULE-003"]],
86
- ["KPA", ["TERM-036"], ["RULE-001", "RULE-003"]],
87
- ["EMP", ["TERM-063"], ["RULE-001", "RULE-003"]],
88
- ];
89
- const entries = rawEntries
90
- .map(([display, term_ids, rule_ids]) => ({ match_key: normalizeMatchKey(display), display, term_ids, rule_ids }))
91
- .sort((a, b) => b.match_key.length - a.match_key.length || a.match_key.localeCompare(b.match_key));
92
- export const FALLBACK_CATALOG = {
93
- kb_version: VERSION,
94
- catalog_revision: 0,
95
- generated_at: "2026-08-14T00:00:00.000Z",
96
- entries,
97
- degraded: true,
98
- fallback_reason: "bundled P0 snapshot",
99
- };
100
- export function resolveWithP0Fallback(body, reason) {
101
- const segments = Array.isArray(body.segments)
102
- ? body.segments.filter(isSegment)
103
- : [];
104
- const delivered = new Set(Array.isArray(body.delivered_rule_ids)
105
- ? body.delivered_rule_ids.filter((value) => typeof value === "string")
106
- : []);
107
- const deliveredTerms = new Set(Array.isArray(body.delivered_term_ids)
108
- ? body.delivered_term_ids.filter((value) => typeof value === "string")
109
- : []);
110
- const ruleIds = new Set();
111
- const hits = [];
112
- for (const segment of segments) {
113
- for (const entry of findCatalogMatches(segment.text, entries)) {
114
- entry.rule_ids.forEach((ruleId) => ruleIds.add(ruleId));
115
- entry.term_ids.forEach((termId) => hits.push({ term_id: termId, matched_text: entry.display }));
116
- }
117
- }
118
- const candidates = Array.isArray(body.candidate_terms)
119
- ? body.candidate_terms.filter((value) => typeof value === "string")
120
- : [];
121
- const unresolved = candidates
122
- .filter((candidate) => !entries.some((entry) => entry.match_key === normalizeMatchKey(candidate)))
123
- .map((matched_text) => ({ matched_text, reason: "unknown", candidate_term_ids: [] }));
124
- if (unresolved.length)
125
- ruleIds.add("RULE-005");
126
- const dedupedHits = dedupeHits(hits);
127
- return {
128
- kb_version: VERSION,
129
- catalog_revision: 0,
130
- ...(typeof body.audit_through_seq === "number" ? { audit_through_seq: body.audit_through_seq } : {}),
131
- hits: dedupedHits,
132
- term_delta: [...new Set(dedupedHits.map((hit) => hit.term_id))]
133
- .filter((termId) => !deliveredTerms.has(termId))
134
- .flatMap((termId) => FALLBACK_TERMS[termId] ? [FALLBACK_TERMS[termId]] : []),
135
- rule_delta: P0_RULES.filter((rule) => ruleIds.has(rule.rule_id) && !delivered.has(rule.rule_id)),
136
- unresolved,
137
- degraded: true,
138
- fallback_reason: reason,
139
- };
140
- }
141
- function term(term_id, source_term, preferred_zh, preferred_en, status, scope_type, school_ids, context_rule, forbidden_translations, full_form = "", degree_level = "") {
142
- return {
143
- term_id,
144
- source_term,
145
- full_form,
146
- preferred_zh,
147
- preferred_en,
148
- official_en: "",
149
- status,
150
- scope_type,
151
- school_ids,
152
- degree_level,
153
- context_rule,
154
- forbidden_translations,
155
- };
156
- }
157
- function isSegment(value) {
158
- return Boolean(value && typeof value === "object" && "text" in value && typeof value.text === "string");
159
- }
160
- function dedupeHits(hits) {
161
- const seen = new Set();
162
- return hits.filter((hit) => {
163
- const key = `${hit.term_id}:${hit.matched_text}`;
164
- if (seen.has(key))
165
- return false;
166
- seen.add(key);
167
- return true;
168
- });
169
- }