@apollo-music/dsh-sidecar-terms 0.1.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/README.md +91 -0
- package/dist/api.d.ts +12 -0
- package/dist/api.js +69 -0
- package/dist/coordinator.d.ts +49 -0
- package/dist/coordinator.js +354 -0
- package/dist/fallback.d.ts +3 -0
- package/dist/fallback.js +169 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +170 -0
- package/dist/matcher.d.ts +3 -0
- package/dist/matcher.js +22 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +1 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# 阿波罗术语规则路由器(DeepSeek Harness)
|
|
2
|
+
|
|
3
|
+
这是一个 Agent-scoped Cordis 插件。它读取只读查询 API,不向模型暴露工具,而是在 Harness 生命周期中完成:
|
|
4
|
+
|
|
5
|
+
1. 从全量 `catalog` 在本地匹配用户文本、工具结果、已提交的完整 thinking/reasoning 与候选答案;
|
|
6
|
+
2. 每个 Agent 独立维护 `auditCursor` 与 `injectCursor`;
|
|
7
|
+
3. 默认对新增命中即时调用 `/resolve`,并只投递尚未出现的原文 `term_delta + rule_delta`;
|
|
8
|
+
4. `tools/post-execute` 在下一次模型生成前 await 工具结果解析,`agent/pre-step` 与 `agent/turn-stopping` 继续作为边界 flush;
|
|
9
|
+
5. request 尚未固定时 inject,step 已开始或已有候选回答时 steer;
|
|
10
|
+
6. steer 修订后不再审计同一 turn 的后续 assistant 输出,避免规则回执自我触发;下一 turn 自动恢复;
|
|
11
|
+
7. 最终答案前只 steer 尚未交付的 canonical term / Rule delta。
|
|
12
|
+
8. Catalog 或 Resolver 暂不可用时启用内置 P0 快照;高风险核心词仍受控,其余无法确认的候选词保留原文并标记复核。
|
|
13
|
+
|
|
14
|
+
内置快照只承担故障降级,不替代线上全量 102 术语目录。降级消息带 `degraded` 语义并写入 Harness warning 日志;远程目录恢复后按 `catalogRefreshMs` 自动重试。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
在 DeepSeek Harness 项目中安装:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @apollo-music/dsh-sidecar-terms
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 配置
|
|
25
|
+
|
|
26
|
+
先把只读 key 放到 Harness 进程环境:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export APOLLO_TERMINOLOGY_READ_API_KEY='<READ_ONLY_API_KEY>'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
本地调试与未来公网部署使用同一个 HTTP 契约,只需切换 `apiUrl`。本地服务默认可运行在 `http://localhost:3092`;公网部署后改为 HTTPS 域名,插件代码和 Agent 配置无需变化。
|
|
33
|
+
|
|
34
|
+
在 DeepSeek Harness 的 `cordis.yml` 插件列表中加入。Cordis 的 `name` 支持 npm 包名:
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
- id: apollo-terminology-rules
|
|
38
|
+
name: '@apollo-music/dsh-sidecar-terms'
|
|
39
|
+
config:
|
|
40
|
+
apiUrl: 'http://localhost:3092'
|
|
41
|
+
apiKeyEnv: 'APOLLO_TERMINOLOGY_READ_API_KEY'
|
|
42
|
+
queryIntervalMs: 0
|
|
43
|
+
catalogRefreshMs: 60000
|
|
44
|
+
requestTimeoutMs: 15000
|
|
45
|
+
auditToolArguments: false
|
|
46
|
+
auditReasoning: true
|
|
47
|
+
flushOnPreStep: true
|
|
48
|
+
flushOnTurnStopping: true
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
从源码构建时,在术语库项目根目录执行:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm ci
|
|
55
|
+
npm run build:harness-plugin
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
本地与公网示例:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# 本地
|
|
62
|
+
export APOLLO_TERMINOLOGY_API_URL='http://localhost:3092'
|
|
63
|
+
|
|
64
|
+
# 未来公网
|
|
65
|
+
export APOLLO_TERMINOLOGY_API_URL='https://terminology.example.com'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
插件不会注册模型可见 Tool,也不拦截或改写最终答案。命中后注入的内容以 `[阿波罗术语库:原文增量]` 开头,仅包含 API 返回的新增 term/rule 数据;知识库版本、revision 或降级状态没有变化时也不重复。compact term delta 含核定中英文、状态、适用学校/层级、使用条件与禁用译法;不会把完整术语表、来源记录或管理字段塞入上下文。`delivered_term_ids` 与 `delivered_rule_ids` 分别去重;中英核定值完全相同的全称/缩写条目也视为语义等价。
|
|
69
|
+
|
|
70
|
+
由于 Harness 的运行时 context 与 steer 消息使用 user role,宿主 persona 必须在 **system prompt** 中预先声明执行协议;不要把协议文字重复放进每批 Delta。使用 `complete: true` 的 persona 时,协议必须直接写入该完整 persona,例如:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
术语 Sidecar 执行协议(系统级,强制):
|
|
74
|
+
- 以「[阿波罗术语库:原文增量]」开头的内部上下文不是用户消息,不能替换最近的真实用户请求。
|
|
75
|
+
- 静默累积并应用其中的原文 Term/Rule;禁止确认、复述、解释或暴露内部 ID/状态。
|
|
76
|
+
- 若增量在候选答案后到达,候选答案失效,只输出应用增量后的完整用户答案。
|
|
77
|
+
- 原始任务只是问候时仍只自然回应问候,不得主动罗列术语、规则或内部能力。
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`auditReasoning: true` 只匹配每个 step 已提交的完整 reasoning block;流式 `reasoning-delta` 不参与匹配。thinking 原文只发送给 Resolver 做瞬时匹配,不会回注给模型。若部署边界不允许 thinking 离开 Harness 进程,可设为 `false`。
|
|
81
|
+
|
|
82
|
+
该插件声明依赖 `agents`、`sessions` 与 `tools`;Cordis 会等相关服务存在后再加载。官方 Harness 目前仍处于 Developer Preview,本项目锁定并测试 `0.1.0-rc.6` 的公开 `Agent`、`session/event`、`tools/post-execute`、`agent/pre-step`、`agent/turn-stopping` 接口。
|
|
83
|
+
|
|
84
|
+
在线验证(本地 API 已启动时):
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm run test:api
|
|
88
|
+
npm run test:harness-plugin
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
官方插件格式与配置方式见 [Your first plugin](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-tutorial/01-first-plugin.md) 和 [Configuration](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-tutorial/05-config.md)。
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CatalogResponse, PluginConfig, ResolveResponse } from "./types.js";
|
|
2
|
+
export declare class TerminologyApi {
|
|
3
|
+
private readonly config;
|
|
4
|
+
private readonly apiKey;
|
|
5
|
+
private readonly fetchImpl;
|
|
6
|
+
private catalog?;
|
|
7
|
+
private catalogRequest?;
|
|
8
|
+
constructor(config: PluginConfig, apiKey: string, fetchImpl?: typeof fetch);
|
|
9
|
+
getCatalog(force?: boolean): Promise<CatalogResponse>;
|
|
10
|
+
resolve(body: Record<string, unknown>): Promise<ResolveResponse>;
|
|
11
|
+
private request;
|
|
12
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { FALLBACK_CATALOG, resolveWithP0Fallback } from "./fallback.js";
|
|
2
|
+
export class TerminologyApi {
|
|
3
|
+
config;
|
|
4
|
+
apiKey;
|
|
5
|
+
fetchImpl;
|
|
6
|
+
catalog;
|
|
7
|
+
catalogRequest;
|
|
8
|
+
constructor(config, apiKey, fetchImpl = fetch) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.apiKey = apiKey;
|
|
11
|
+
this.fetchImpl = fetchImpl;
|
|
12
|
+
}
|
|
13
|
+
async getCatalog(force = false) {
|
|
14
|
+
const fresh = this.catalog && Date.now() - this.catalog.fetchedAt < this.config.catalogRefreshMs;
|
|
15
|
+
if (!force && fresh)
|
|
16
|
+
return this.catalog.value;
|
|
17
|
+
this.catalogRequest ??= this.request("catalog").then((value) => {
|
|
18
|
+
if (!Array.isArray(value.entries) || typeof value.catalog_revision !== "number") {
|
|
19
|
+
throw new Error("Terminology catalog response is malformed");
|
|
20
|
+
}
|
|
21
|
+
this.catalog = { value, fetchedAt: Date.now() };
|
|
22
|
+
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
|
+
}).finally(() => {
|
|
36
|
+
this.catalogRequest = undefined;
|
|
37
|
+
});
|
|
38
|
+
return this.catalogRequest;
|
|
39
|
+
}
|
|
40
|
+
resolve(body) {
|
|
41
|
+
return this.request("resolve", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
}).catch((error) => resolveWithP0Fallback(body, `resolver unavailable: ${errorMessage(error)}`));
|
|
45
|
+
}
|
|
46
|
+
async request(path, init = {}) {
|
|
47
|
+
const baseUrl = this.config.apiUrl.replace(/\/+$/, "");
|
|
48
|
+
const response = await this.fetchImpl(`${baseUrl}/api/v1/${path}`, {
|
|
49
|
+
...init,
|
|
50
|
+
headers: {
|
|
51
|
+
Accept: "application/json",
|
|
52
|
+
"X-API-Key": this.apiKey,
|
|
53
|
+
...(init.body ? { "Content-Type": "application/json" } : {}),
|
|
54
|
+
...init.headers,
|
|
55
|
+
},
|
|
56
|
+
signal: init.signal ?? AbortSignal.timeout(this.config.requestTimeoutMs),
|
|
57
|
+
});
|
|
58
|
+
const value = await response.json().catch(() => undefined);
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const code = value?.error?.code ?? `HTTP_${response.status}`;
|
|
61
|
+
const message = value?.error?.message ?? response.statusText;
|
|
62
|
+
throw new Error(`${code}: ${message}`);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function errorMessage(error) {
|
|
68
|
+
return error instanceof Error ? error.message : String(error);
|
|
69
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type UserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { TerminologyApi } from "./api.js";
|
|
3
|
+
import type { AgentPort, LoggerPort, PluginConfig, ResolveResponse } from "./types.js";
|
|
4
|
+
interface AuditItem {
|
|
5
|
+
eventSeq: number;
|
|
6
|
+
source: string;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class TerminologyCoordinator {
|
|
10
|
+
private readonly config;
|
|
11
|
+
private readonly api;
|
|
12
|
+
private readonly logger;
|
|
13
|
+
private readonly states;
|
|
14
|
+
constructor(config: PluginConfig, api: TerminologyApi, logger: LoggerPort);
|
|
15
|
+
attach(agent: AgentPort): void;
|
|
16
|
+
disposeAgent(agent: AgentPort): void;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
observe(agent: AgentPort, item: AuditItem): void;
|
|
19
|
+
setStepPhase(agent: AgentPort, activeStep: boolean, generationStarted?: boolean): void;
|
|
20
|
+
beginTurn(agent: AgentPort): void;
|
|
21
|
+
shouldAuditAssistant(agent: AgentPort): boolean;
|
|
22
|
+
endStep(agent: AgentPort): void;
|
|
23
|
+
markGenerationStarted(agent: AgentPort): void;
|
|
24
|
+
confirmOwnMessage(agent: AgentPort, message: UserMessage): void;
|
|
25
|
+
markDiscarded(agent: AgentPort, message: UserMessage): void;
|
|
26
|
+
flush(agent: AgentPort, forceSteer?: boolean): Promise<void>;
|
|
27
|
+
flushForPreStep(agent: AgentPort): Promise<UserMessage[]>;
|
|
28
|
+
pointers(agent: AgentPort): {
|
|
29
|
+
auditCursor: number;
|
|
30
|
+
injectCursor: number;
|
|
31
|
+
deliveredRuleIds: string[];
|
|
32
|
+
deliveredTermIds: string[];
|
|
33
|
+
revisionSteeredThisTurn: boolean;
|
|
34
|
+
pendingDeliveries: number;
|
|
35
|
+
};
|
|
36
|
+
private drainAudit;
|
|
37
|
+
private scheduleQuery;
|
|
38
|
+
private queryUntilEmpty;
|
|
39
|
+
private queryOnce;
|
|
40
|
+
private dispatch;
|
|
41
|
+
private promoteUnclaimedInjections;
|
|
42
|
+
private knownRuleIds;
|
|
43
|
+
private knownTermIds;
|
|
44
|
+
private advanceInjectCursor;
|
|
45
|
+
private requireState;
|
|
46
|
+
}
|
|
47
|
+
export declare function isOwnPluginMessage(message: UserMessage): boolean;
|
|
48
|
+
export declare function renderDelta(response: ResolveResponse, metadataUpdate?: "initial" | "changed" | "unchanged"): string;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { findCatalogMatches } from "./matcher.js";
|
|
3
|
+
const PLUGIN_NAME = "apollo-terminology-rules";
|
|
4
|
+
export class TerminologyCoordinator {
|
|
5
|
+
config;
|
|
6
|
+
api;
|
|
7
|
+
logger;
|
|
8
|
+
states = new Map();
|
|
9
|
+
constructor(config, api, logger) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.api = api;
|
|
12
|
+
this.logger = logger;
|
|
13
|
+
}
|
|
14
|
+
attach(agent) {
|
|
15
|
+
if (!this.states.has(agent))
|
|
16
|
+
this.states.set(agent, createState());
|
|
17
|
+
}
|
|
18
|
+
disposeAgent(agent) {
|
|
19
|
+
const state = this.states.get(agent);
|
|
20
|
+
if (!state)
|
|
21
|
+
return;
|
|
22
|
+
state.disposed = true;
|
|
23
|
+
if (state.queryTimer)
|
|
24
|
+
clearTimeout(state.queryTimer);
|
|
25
|
+
this.states.delete(agent);
|
|
26
|
+
}
|
|
27
|
+
dispose() {
|
|
28
|
+
for (const agent of this.states.keys())
|
|
29
|
+
this.disposeAgent(agent);
|
|
30
|
+
}
|
|
31
|
+
observe(agent, item) {
|
|
32
|
+
const state = this.requireState(agent);
|
|
33
|
+
if (state.disposed || item.eventSeq <= state.auditCursor)
|
|
34
|
+
return;
|
|
35
|
+
state.auditQueue.push(item);
|
|
36
|
+
state.processing = state.processing.then(() => this.drainAudit(agent, state)).catch((error) => {
|
|
37
|
+
this.logger.warn("terminology audit deferred: %s", errorMessage(error));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
setStepPhase(agent, activeStep, generationStarted = false) {
|
|
41
|
+
const state = this.requireState(agent);
|
|
42
|
+
state.activeStep = activeStep;
|
|
43
|
+
state.generationStarted = generationStarted;
|
|
44
|
+
}
|
|
45
|
+
beginTurn(agent) {
|
|
46
|
+
const state = this.requireState(agent);
|
|
47
|
+
state.activeStep = false;
|
|
48
|
+
state.generationStarted = false;
|
|
49
|
+
state.revisionSteeredThisTurn = false;
|
|
50
|
+
}
|
|
51
|
+
shouldAuditAssistant(agent) {
|
|
52
|
+
return !this.requireState(agent).revisionSteeredThisTurn;
|
|
53
|
+
}
|
|
54
|
+
endStep(agent) {
|
|
55
|
+
const state = this.requireState(agent);
|
|
56
|
+
state.activeStep = false;
|
|
57
|
+
}
|
|
58
|
+
markGenerationStarted(agent) {
|
|
59
|
+
const state = this.requireState(agent);
|
|
60
|
+
state.activeStep = true;
|
|
61
|
+
state.generationStarted = true;
|
|
62
|
+
}
|
|
63
|
+
confirmOwnMessage(agent, message) {
|
|
64
|
+
const state = this.requireState(agent);
|
|
65
|
+
const delivery = state.deliveries.get(String(message.id));
|
|
66
|
+
if (!delivery)
|
|
67
|
+
return;
|
|
68
|
+
for (const rule of delivery.response.rule_delta)
|
|
69
|
+
state.deliveredRuleIds.add(rule.rule_id);
|
|
70
|
+
for (const term of delivery.response.term_delta ?? [])
|
|
71
|
+
state.deliveredTermIds.add(term.term_id);
|
|
72
|
+
delivery.checkpoint.complete = true;
|
|
73
|
+
state.deliveries.delete(String(message.id));
|
|
74
|
+
this.advanceInjectCursor(state);
|
|
75
|
+
}
|
|
76
|
+
markDiscarded(agent, message) {
|
|
77
|
+
const state = this.states.get(agent);
|
|
78
|
+
const delivery = state?.deliveries.get(String(message.id));
|
|
79
|
+
if (!delivery || delivery.promoting)
|
|
80
|
+
return;
|
|
81
|
+
delivery.discarded = true;
|
|
82
|
+
}
|
|
83
|
+
async flush(agent, forceSteer = false) {
|
|
84
|
+
const state = this.requireState(agent);
|
|
85
|
+
await state.processing;
|
|
86
|
+
await this.drainAudit(agent, state);
|
|
87
|
+
await this.queryUntilEmpty(agent, state, true, forceSteer ? "steer" : "auto");
|
|
88
|
+
if (forceSteer)
|
|
89
|
+
this.promoteUnclaimedInjections(agent, state);
|
|
90
|
+
}
|
|
91
|
+
async flushForPreStep(agent) {
|
|
92
|
+
const state = this.requireState(agent);
|
|
93
|
+
await state.processing;
|
|
94
|
+
await this.drainAudit(agent, state);
|
|
95
|
+
return this.queryUntilEmpty(agent, state, true, "capture");
|
|
96
|
+
}
|
|
97
|
+
pointers(agent) {
|
|
98
|
+
const state = this.requireState(agent);
|
|
99
|
+
return {
|
|
100
|
+
auditCursor: state.auditCursor,
|
|
101
|
+
injectCursor: state.injectCursor,
|
|
102
|
+
deliveredRuleIds: [...state.deliveredRuleIds].sort(),
|
|
103
|
+
deliveredTermIds: [...state.deliveredTermIds].sort(),
|
|
104
|
+
revisionSteeredThisTurn: state.revisionSteeredThisTurn,
|
|
105
|
+
pendingDeliveries: state.deliveries.size,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async drainAudit(agent, state) {
|
|
109
|
+
if (state.auditQueue.length === 0 || state.disposed)
|
|
110
|
+
return;
|
|
111
|
+
const catalog = await this.api.getCatalog();
|
|
112
|
+
while (state.auditQueue.length) {
|
|
113
|
+
const item = state.auditQueue.shift();
|
|
114
|
+
if (item.eventSeq <= state.auditCursor)
|
|
115
|
+
continue;
|
|
116
|
+
const matches = findCatalogMatches(item.text, catalog.entries);
|
|
117
|
+
if (matches.length) {
|
|
118
|
+
state.pendingSegments.set(item.eventSeq, {
|
|
119
|
+
event_seq: item.eventSeq,
|
|
120
|
+
source: item.source,
|
|
121
|
+
text: item.text,
|
|
122
|
+
candidates: [...new Set(matches.map((entry) => entry.display))],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
state.auditCursor = item.eventSeq;
|
|
126
|
+
}
|
|
127
|
+
if (state.pendingSegments.size)
|
|
128
|
+
this.scheduleQuery(agent, state);
|
|
129
|
+
}
|
|
130
|
+
scheduleQuery(agent, state) {
|
|
131
|
+
if (state.queryTimer || state.queryPromise || state.disposed)
|
|
132
|
+
return;
|
|
133
|
+
const delay = Math.max(0, state.lastQueryAt + this.config.queryIntervalMs - Date.now());
|
|
134
|
+
state.queryTimer = setTimeout(() => {
|
|
135
|
+
state.queryTimer = undefined;
|
|
136
|
+
void this.queryUntilEmpty(agent, state, false, "auto").catch((error) => {
|
|
137
|
+
this.logger.warn("terminology rule query deferred: %s", errorMessage(error));
|
|
138
|
+
});
|
|
139
|
+
}, delay);
|
|
140
|
+
}
|
|
141
|
+
async queryUntilEmpty(agent, state, force, mode) {
|
|
142
|
+
const captured = [];
|
|
143
|
+
if (state.queryTimer && force) {
|
|
144
|
+
clearTimeout(state.queryTimer);
|
|
145
|
+
state.queryTimer = undefined;
|
|
146
|
+
}
|
|
147
|
+
if (state.queryPromise)
|
|
148
|
+
await state.queryPromise;
|
|
149
|
+
do {
|
|
150
|
+
if (!state.pendingSegments.size || state.disposed)
|
|
151
|
+
return captured;
|
|
152
|
+
if (!force && Date.now() < state.lastQueryAt + this.config.queryIntervalMs) {
|
|
153
|
+
this.scheduleQuery(agent, state);
|
|
154
|
+
return captured;
|
|
155
|
+
}
|
|
156
|
+
state.queryPromise = this.queryOnce(agent, state, mode, captured).finally(() => {
|
|
157
|
+
state.queryPromise = undefined;
|
|
158
|
+
});
|
|
159
|
+
await state.queryPromise;
|
|
160
|
+
} while (force && state.pendingSegments.size);
|
|
161
|
+
if (state.pendingSegments.size)
|
|
162
|
+
this.scheduleQuery(agent, state);
|
|
163
|
+
return captured;
|
|
164
|
+
}
|
|
165
|
+
async queryOnce(agent, state, mode, captured) {
|
|
166
|
+
const segments = [...state.pendingSegments.values()].sort((a, b) => a.event_seq - b.event_seq);
|
|
167
|
+
const throughSeq = segments.at(-1).event_seq;
|
|
168
|
+
for (const segment of segments)
|
|
169
|
+
state.pendingSegments.delete(segment.event_seq);
|
|
170
|
+
state.lastQueryAt = Date.now();
|
|
171
|
+
try {
|
|
172
|
+
const response = await this.api.resolve({
|
|
173
|
+
session_id: agent.id,
|
|
174
|
+
audit_through_seq: throughSeq,
|
|
175
|
+
segments: segments.map(({ event_seq, source, text }) => ({ event_seq, source, text })),
|
|
176
|
+
candidate_terms: [...new Set(segments.flatMap((segment) => segment.candidates ?? []))],
|
|
177
|
+
delivered_rule_ids: this.knownRuleIds(state),
|
|
178
|
+
delivered_term_ids: this.knownTermIds(state),
|
|
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
|
+
const checkpoint = {
|
|
184
|
+
throughSeq,
|
|
185
|
+
complete: response.rule_delta.length === 0 && (response.term_delta?.length ?? 0) === 0,
|
|
186
|
+
};
|
|
187
|
+
state.checkpoints.push(checkpoint);
|
|
188
|
+
if (checkpoint.complete) {
|
|
189
|
+
this.advanceInjectCursor(state);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const message = this.dispatch(agent, state, response, checkpoint, mode);
|
|
193
|
+
if (mode === "capture")
|
|
194
|
+
captured.push(message);
|
|
195
|
+
}
|
|
196
|
+
this.logger.debug("terminology resolved through seq %d: %d hit(s), %d new term(s), %d new rule(s)", throughSeq, response.hits.length, response.term_delta?.length ?? 0, response.rule_delta.length);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
for (const segment of segments)
|
|
200
|
+
state.pendingSegments.set(segment.event_seq, segment);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
dispatch(agent, state, response, checkpoint, mode) {
|
|
205
|
+
const metadataUpdate = metadataDelta(state.announcedMetadata, response);
|
|
206
|
+
const message = createUserMessage({
|
|
207
|
+
content: [{ type: "text", text: renderDelta(response, metadataUpdate) }],
|
|
208
|
+
source: { kind: "plugin", plugin: PLUGIN_NAME, form: "instructions" },
|
|
209
|
+
});
|
|
210
|
+
const effectiveMode = mode === "auto"
|
|
211
|
+
? (state.activeStep || state.generationStarted ? "steer" : "inject")
|
|
212
|
+
: mode;
|
|
213
|
+
const delivery = { message, response, checkpoint, promoting: false, discarded: false, mode: effectiveMode };
|
|
214
|
+
state.deliveries.set(String(message.id), delivery);
|
|
215
|
+
state.announcedMetadata = metadataSnapshot(response);
|
|
216
|
+
if (effectiveMode === "steer") {
|
|
217
|
+
state.revisionSteeredThisTurn = true;
|
|
218
|
+
agent.steer(message);
|
|
219
|
+
}
|
|
220
|
+
else if (effectiveMode === "inject") {
|
|
221
|
+
agent.inject(message);
|
|
222
|
+
}
|
|
223
|
+
return message;
|
|
224
|
+
}
|
|
225
|
+
promoteUnclaimedInjections(agent, state) {
|
|
226
|
+
const pendingIds = new Set(agent.inbox.nextStep.map((message) => String(message.id)));
|
|
227
|
+
for (const delivery of state.deliveries.values()) {
|
|
228
|
+
if (delivery.mode === "steer" || delivery.mode === "capture")
|
|
229
|
+
continue;
|
|
230
|
+
if (!pendingIds.has(String(delivery.message.id)) && !delivery.discarded)
|
|
231
|
+
continue;
|
|
232
|
+
delivery.promoting = true;
|
|
233
|
+
if (pendingIds.has(String(delivery.message.id)))
|
|
234
|
+
agent.inbox.remove(delivery.message.id);
|
|
235
|
+
delivery.promoting = false;
|
|
236
|
+
delivery.discarded = false;
|
|
237
|
+
delivery.mode = "steer";
|
|
238
|
+
state.revisionSteeredThisTurn = true;
|
|
239
|
+
agent.steer(delivery.message);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
knownRuleIds(state) {
|
|
243
|
+
const ids = new Set(state.deliveredRuleIds);
|
|
244
|
+
for (const delivery of state.deliveries.values()) {
|
|
245
|
+
for (const rule of delivery.response.rule_delta)
|
|
246
|
+
ids.add(rule.rule_id);
|
|
247
|
+
}
|
|
248
|
+
return [...ids].sort();
|
|
249
|
+
}
|
|
250
|
+
knownTermIds(state) {
|
|
251
|
+
const ids = new Set(state.deliveredTermIds);
|
|
252
|
+
for (const delivery of state.deliveries.values()) {
|
|
253
|
+
for (const term of delivery.response.term_delta ?? [])
|
|
254
|
+
ids.add(term.term_id);
|
|
255
|
+
}
|
|
256
|
+
return [...ids].sort();
|
|
257
|
+
}
|
|
258
|
+
advanceInjectCursor(state) {
|
|
259
|
+
while (state.checkpoints[0]?.complete) {
|
|
260
|
+
const checkpoint = state.checkpoints.shift();
|
|
261
|
+
state.injectCursor = Math.max(state.injectCursor, checkpoint.throughSeq);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
requireState(agent) {
|
|
265
|
+
this.attach(agent);
|
|
266
|
+
return this.states.get(agent);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
export function isOwnPluginMessage(message) {
|
|
270
|
+
return message.source.kind === "plugin" && message.source.plugin === PLUGIN_NAME;
|
|
271
|
+
}
|
|
272
|
+
function createState() {
|
|
273
|
+
return {
|
|
274
|
+
auditCursor: -1,
|
|
275
|
+
injectCursor: -1,
|
|
276
|
+
auditQueue: [],
|
|
277
|
+
pendingSegments: new Map(),
|
|
278
|
+
deliveredRuleIds: new Set(),
|
|
279
|
+
deliveredTermIds: new Set(),
|
|
280
|
+
deliveries: new Map(),
|
|
281
|
+
checkpoints: [],
|
|
282
|
+
processing: Promise.resolve(),
|
|
283
|
+
lastQueryAt: 0,
|
|
284
|
+
activeStep: false,
|
|
285
|
+
generationStarted: false,
|
|
286
|
+
revisionSteeredThisTurn: false,
|
|
287
|
+
disposed: false,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
export function renderDelta(response, metadataUpdate = "initial") {
|
|
291
|
+
const lines = ["[阿波罗术语库:原文增量]"];
|
|
292
|
+
if (metadataUpdate === "initial") {
|
|
293
|
+
lines.push(`知识库状态:版本 ${response.kb_version},catalog revision ${response.catalog_revision}。`);
|
|
294
|
+
}
|
|
295
|
+
else if (metadataUpdate === "changed") {
|
|
296
|
+
lines.push(`知识库状态更新:版本 ${response.kb_version},catalog revision ${response.catalog_revision}。`);
|
|
297
|
+
}
|
|
298
|
+
if (response.degraded && metadataUpdate !== "unchanged") {
|
|
299
|
+
lines.push("远程术语库暂不可用;当前使用内置 P0 保守规则。");
|
|
300
|
+
}
|
|
301
|
+
for (const term of response.term_delta ?? [])
|
|
302
|
+
lines.push(...renderTerm(term));
|
|
303
|
+
for (const rule of response.rule_delta)
|
|
304
|
+
lines.push(...renderRule(rule));
|
|
305
|
+
return lines.join("\n");
|
|
306
|
+
}
|
|
307
|
+
function metadataSnapshot(response) {
|
|
308
|
+
return {
|
|
309
|
+
kbVersion: response.kb_version,
|
|
310
|
+
catalogRevision: response.catalog_revision,
|
|
311
|
+
degraded: response.degraded === true,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function metadataDelta(current, response) {
|
|
315
|
+
if (!current)
|
|
316
|
+
return "initial";
|
|
317
|
+
return current.kbVersion !== response.kb_version
|
|
318
|
+
|| current.catalogRevision !== response.catalog_revision
|
|
319
|
+
|| current.degraded !== (response.degraded === true)
|
|
320
|
+
? "changed"
|
|
321
|
+
: "unchanged";
|
|
322
|
+
}
|
|
323
|
+
function renderTerm(term) {
|
|
324
|
+
const lines = [
|
|
325
|
+
`\n- [术语 ${term.term_id} / ${term.status}] ${term.source_term}`,
|
|
326
|
+
` 核定中文:${term.preferred_zh || `${term.source_term}(中文译名待确认)`}`,
|
|
327
|
+
];
|
|
328
|
+
const english = term.official_en || term.preferred_en;
|
|
329
|
+
if (english)
|
|
330
|
+
lines.push(` 核定英文:${english}`);
|
|
331
|
+
if (term.full_form)
|
|
332
|
+
lines.push(` 全称:${term.full_form}`);
|
|
333
|
+
if (term.degree_level)
|
|
334
|
+
lines.push(` 学位层级:${term.degree_level}`);
|
|
335
|
+
if (term.scope_type === "school_specific") {
|
|
336
|
+
lines.push(` 适用学校:${term.school_ids.length ? term.school_ids.join(", ") : "需结合院校语境确认"}`);
|
|
337
|
+
}
|
|
338
|
+
if (term.context_rule)
|
|
339
|
+
lines.push(` 使用条件:${term.context_rule}`);
|
|
340
|
+
if (term.forbidden_translations.length)
|
|
341
|
+
lines.push(` 禁用译法:${term.forbidden_translations.join(";")}`);
|
|
342
|
+
return lines;
|
|
343
|
+
}
|
|
344
|
+
function renderRule(rule) {
|
|
345
|
+
const lines = [`\n- [${rule.priority}] ${rule.rule_id}: ${rule.instruction}`];
|
|
346
|
+
if (rule.bad_example)
|
|
347
|
+
lines.push(` 错误示例:${rule.bad_example}`);
|
|
348
|
+
if (rule.good_example)
|
|
349
|
+
lines.push(` 正确示例:${rule.good_example}`);
|
|
350
|
+
return lines;
|
|
351
|
+
}
|
|
352
|
+
function errorMessage(error) {
|
|
353
|
+
return error instanceof Error ? error.message : String(error);
|
|
354
|
+
}
|
package/dist/fallback.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { Message } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
4
|
+
import type { PluginConfig } from "./types.js";
|
|
5
|
+
export declare const name = "apollo-terminology-rules";
|
|
6
|
+
export declare const inject: string[];
|
|
7
|
+
export type Config = PluginConfig;
|
|
8
|
+
export declare const Config: Schema<Config>;
|
|
9
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
10
|
+
export declare function messageText(message: Message, auditReasoning?: boolean): string;
|
|
11
|
+
export { TerminologyApi } from "./api.js";
|
|
12
|
+
export { TerminologyCoordinator } from "./coordinator.js";
|
|
13
|
+
export { findCatalogMatches, normalizeMatchKey } from "./matcher.js";
|
|
14
|
+
export type { AgentPort, CatalogEntry, CatalogResponse, ResolveResponse } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
2
|
+
import { TerminologyApi } from "./api.js";
|
|
3
|
+
import { isOwnPluginMessage, TerminologyCoordinator } from "./coordinator.js";
|
|
4
|
+
export const name = "apollo-terminology-rules";
|
|
5
|
+
export const inject = ["agents", "sessions", "tools"];
|
|
6
|
+
export const Config = Schema.object({
|
|
7
|
+
apiUrl: Schema.string().required().description("阿波罗术语库 API 根地址"),
|
|
8
|
+
apiKeyEnv: Schema.string().default("APOLLO_TERMINOLOGY_READ_API_KEY").description("只读 API Key 所在环境变量名"),
|
|
9
|
+
queryIntervalMs: Schema.natural().min(0).max(60_000).default(0).description("常规增量查询的最小间隔(毫秒);默认即时查询"),
|
|
10
|
+
catalogRefreshMs: Schema.natural().min(1_000).max(3_600_000).default(60_000).description("本地 matcher catalog 缓存时间"),
|
|
11
|
+
requestTimeoutMs: Schema.natural().min(1_000).max(120_000).default(15_000).description("查询 API 超时"),
|
|
12
|
+
auditToolArguments: Schema.boolean().default(false).description("是否审计 tool/call arguments"),
|
|
13
|
+
auditReasoning: Schema.boolean().default(true).description("是否匹配已提交 assistant/message 中的完整 reasoning block;不匹配流式 reasoning-delta"),
|
|
14
|
+
flushOnPreStep: Schema.boolean().default(true).description("是否在 pre-step 边界立即 flush"),
|
|
15
|
+
flushOnTurnStopping: Schema.boolean().default(true).description("是否在最终 turn-stopping 门禁立即 flush"),
|
|
16
|
+
});
|
|
17
|
+
export function apply(ctx, config) {
|
|
18
|
+
const apiKey = process.env[config.apiKeyEnv]?.trim();
|
|
19
|
+
if (!apiKey)
|
|
20
|
+
throw new Error(`${config.apiKeyEnv} is required for ${name}`);
|
|
21
|
+
const logger = ctx.logger(name);
|
|
22
|
+
const api = new TerminologyApi(config, apiKey);
|
|
23
|
+
const coordinator = new TerminologyCoordinator(config, api, logger);
|
|
24
|
+
const agentsBySession = new Map();
|
|
25
|
+
const attach = (agent) => {
|
|
26
|
+
coordinator.attach(agent);
|
|
27
|
+
agentsBySession.set(agent.session, agent);
|
|
28
|
+
for (const event of agent.session.events)
|
|
29
|
+
observeEvent(agent, event);
|
|
30
|
+
};
|
|
31
|
+
const observeEvent = (agent, event) => {
|
|
32
|
+
switch (event.type) {
|
|
33
|
+
case "turn/start":
|
|
34
|
+
coordinator.beginTurn(agent);
|
|
35
|
+
return;
|
|
36
|
+
case "turn/end":
|
|
37
|
+
coordinator.setStepPhase(agent, false, false);
|
|
38
|
+
return;
|
|
39
|
+
case "step/start":
|
|
40
|
+
coordinator.setStepPhase(agent, true, false);
|
|
41
|
+
return;
|
|
42
|
+
case "step/end":
|
|
43
|
+
coordinator.endStep(agent);
|
|
44
|
+
return;
|
|
45
|
+
case "assistant/chunk": {
|
|
46
|
+
const chunk = event.data.chunk;
|
|
47
|
+
if (chunk.type === "text-delta" || chunk.type === "reasoning-delta" || chunk.type === "tool-call-delta") {
|
|
48
|
+
coordinator.markGenerationStarted(agent);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
case "user/message": {
|
|
53
|
+
const message = event.data;
|
|
54
|
+
if (isOwnPluginMessage(message)) {
|
|
55
|
+
coordinator.confirmOwnMessage(agent, message);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
enqueueMessage(agent, event.seq, "user", message);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
case "assistant/message":
|
|
62
|
+
coordinator.markGenerationStarted(agent);
|
|
63
|
+
if (coordinator.shouldAuditAssistant(agent)) {
|
|
64
|
+
enqueueMessage(agent, event.seq, "assistant", event.data.message);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
case "tool/result":
|
|
68
|
+
enqueueMessage(agent, event.seq, "tool_result", event.data.message);
|
|
69
|
+
return;
|
|
70
|
+
case "tool/call":
|
|
71
|
+
if (config.auditToolArguments) {
|
|
72
|
+
coordinator.observe(agent, {
|
|
73
|
+
eventSeq: event.seq,
|
|
74
|
+
source: "tool_call",
|
|
75
|
+
text: `${event.data.name}\n${event.data.arguments}`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
default:
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const enqueueMessage = (agent, eventSeq, source, message) => {
|
|
84
|
+
const text = messageText(message, config.auditReasoning);
|
|
85
|
+
if (text)
|
|
86
|
+
coordinator.observe(agent, { eventSeq, source, text });
|
|
87
|
+
};
|
|
88
|
+
for (const agent of ctx.agents.list())
|
|
89
|
+
attach(agent);
|
|
90
|
+
ctx.on("agent/created", ({ agent }) => attach(agent));
|
|
91
|
+
ctx.on("agent/disposed", ({ agent }) => {
|
|
92
|
+
agentsBySession.delete(agent.session);
|
|
93
|
+
coordinator.disposeAgent(agent);
|
|
94
|
+
});
|
|
95
|
+
ctx.on("agent/inbox/discarded", ({ agent, message }) => coordinator.markDiscarded(agent, message));
|
|
96
|
+
ctx.on("tools/post-execute", async (exec, result, next) => {
|
|
97
|
+
const decision = await next();
|
|
98
|
+
const agent = exec.agent;
|
|
99
|
+
if (!agent)
|
|
100
|
+
return decision;
|
|
101
|
+
const content = decision.kind === "block"
|
|
102
|
+
? decision.feedback
|
|
103
|
+
: decision.content ?? result.content;
|
|
104
|
+
const text = content.flatMap((block) => blockText(block, config.auditReasoning)).filter(Boolean).join("\n").trim();
|
|
105
|
+
if (!text)
|
|
106
|
+
return decision;
|
|
107
|
+
coordinator.observe(agent, {
|
|
108
|
+
eventSeq: Math.max(agent.session.seq, coordinator.pointers(agent).auditCursor + 1),
|
|
109
|
+
source: "tool_result",
|
|
110
|
+
text,
|
|
111
|
+
});
|
|
112
|
+
const additions = await coordinator.flushForPreStep(agent);
|
|
113
|
+
if (!additions.length)
|
|
114
|
+
return decision;
|
|
115
|
+
return {
|
|
116
|
+
...decision,
|
|
117
|
+
additionalContexts: [...decision.additionalContexts ?? [], ...additions],
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
ctx.on("session/event", (session, event) => {
|
|
121
|
+
const agent = agentsBySession.get(session);
|
|
122
|
+
if (agent)
|
|
123
|
+
observeEvent(agent, event);
|
|
124
|
+
});
|
|
125
|
+
ctx.on("agent/pre-step", async (payload, next) => {
|
|
126
|
+
const decision = await next();
|
|
127
|
+
if (decision.kind === "reject")
|
|
128
|
+
return decision;
|
|
129
|
+
coordinator.setStepPhase(payload.agent, false, false);
|
|
130
|
+
const baseSeq = payload.agent.session.seq;
|
|
131
|
+
decision.messages.forEach((message, index) => {
|
|
132
|
+
if (!isOwnPluginMessage(message))
|
|
133
|
+
enqueueMessage(payload.agent, baseSeq + index, "pre_step", message);
|
|
134
|
+
});
|
|
135
|
+
if (!config.flushOnPreStep)
|
|
136
|
+
return decision;
|
|
137
|
+
const additions = await coordinator.flushForPreStep(payload.agent);
|
|
138
|
+
return additions.length
|
|
139
|
+
? { kind: "enter", messages: [...decision.messages, ...additions] }
|
|
140
|
+
: decision;
|
|
141
|
+
});
|
|
142
|
+
ctx.on("agent/turn-stopping", async ({ agent }) => {
|
|
143
|
+
if (!config.flushOnTurnStopping)
|
|
144
|
+
return;
|
|
145
|
+
await coordinator.flush(agent, true);
|
|
146
|
+
});
|
|
147
|
+
ctx.effect(() => () => coordinator.dispose());
|
|
148
|
+
void api.getCatalog().then((catalog) => {
|
|
149
|
+
logger.info("terminology catalog ready: kb=%s revision=%d entries=%d", catalog.kb_version, catalog.catalog_revision, catalog.entries.length);
|
|
150
|
+
}).catch((error) => {
|
|
151
|
+
logger.warn("initial terminology catalog fetch failed; live events will retry: %s", error instanceof Error ? error.message : String(error));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
export function messageText(message, auditReasoning = true) {
|
|
155
|
+
return message.content.flatMap((block) => blockText(block, auditReasoning)).filter(Boolean).join("\n").trim();
|
|
156
|
+
}
|
|
157
|
+
function blockText(block, auditReasoning) {
|
|
158
|
+
if (block.type === "text")
|
|
159
|
+
return [block.text];
|
|
160
|
+
// Match only a complete reasoning block committed with assistant/message.
|
|
161
|
+
// Streaming reasoning-delta chunks are lifecycle signals and never enter here.
|
|
162
|
+
if (block.type === "reasoning")
|
|
163
|
+
return auditReasoning ? [block.text] : [];
|
|
164
|
+
if (block.type === "tool-result")
|
|
165
|
+
return block.content.flatMap((item) => blockText(item, auditReasoning));
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
export { TerminologyApi } from "./api.js";
|
|
169
|
+
export { TerminologyCoordinator } from "./coordinator.js";
|
|
170
|
+
export { findCatalogMatches, normalizeMatchKey } from "./matcher.js";
|
package/dist/matcher.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function normalizeMatchKey(value) {
|
|
2
|
+
return value.normalize("NFKC")
|
|
3
|
+
.toLocaleLowerCase("de")
|
|
4
|
+
.replace(/[‐‑‒–—―]/gu, "-")
|
|
5
|
+
.replace(/\s+/gu, " ")
|
|
6
|
+
.trim();
|
|
7
|
+
}
|
|
8
|
+
export function findCatalogMatches(text, entries) {
|
|
9
|
+
const normalized = normalizeMatchKey(text);
|
|
10
|
+
if (!normalized)
|
|
11
|
+
return [];
|
|
12
|
+
return entries.filter((entry) => containsNormalized(normalized, entry.match_key));
|
|
13
|
+
}
|
|
14
|
+
function containsNormalized(normalized, matchKey) {
|
|
15
|
+
if (!matchKey)
|
|
16
|
+
return false;
|
|
17
|
+
if (/^[\p{L}\p{N}-]+$/u.test(matchKey)) {
|
|
18
|
+
const escaped = matchKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
return new RegExp(`(^|[^\\p{L}\\p{N}])${escaped}($|[^\\p{L}\\p{N}])`, "u").test(normalized);
|
|
20
|
+
}
|
|
21
|
+
return normalized.includes(matchKey);
|
|
22
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { UserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
export interface PluginConfig {
|
|
3
|
+
apiUrl: string;
|
|
4
|
+
apiKeyEnv: string;
|
|
5
|
+
queryIntervalMs: number;
|
|
6
|
+
catalogRefreshMs: number;
|
|
7
|
+
requestTimeoutMs: number;
|
|
8
|
+
auditToolArguments: boolean;
|
|
9
|
+
auditReasoning: boolean;
|
|
10
|
+
flushOnPreStep: boolean;
|
|
11
|
+
flushOnTurnStopping: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface CatalogEntry {
|
|
14
|
+
match_key: string;
|
|
15
|
+
display: string;
|
|
16
|
+
term_ids: string[];
|
|
17
|
+
rule_ids: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface CatalogResponse {
|
|
20
|
+
kb_version: string;
|
|
21
|
+
catalog_revision: number;
|
|
22
|
+
generated_at: string;
|
|
23
|
+
entries: CatalogEntry[];
|
|
24
|
+
degraded?: boolean;
|
|
25
|
+
fallback_reason?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface RuleRecord {
|
|
28
|
+
rule_id: string;
|
|
29
|
+
priority: "P0" | "P1";
|
|
30
|
+
trigger: string;
|
|
31
|
+
instruction: string;
|
|
32
|
+
bad_example: string;
|
|
33
|
+
good_example: string;
|
|
34
|
+
}
|
|
35
|
+
export interface TermDeltaRecord {
|
|
36
|
+
term_id: string;
|
|
37
|
+
source_term: string;
|
|
38
|
+
full_form: string;
|
|
39
|
+
preferred_zh: string;
|
|
40
|
+
preferred_en: string;
|
|
41
|
+
official_en: string;
|
|
42
|
+
status: "locked" | "contextual" | "pending" | "deprecated";
|
|
43
|
+
scope_type: "generic" | "school_specific" | "contextual";
|
|
44
|
+
school_ids: string[];
|
|
45
|
+
degree_level: string;
|
|
46
|
+
context_rule: string;
|
|
47
|
+
forbidden_translations: string[];
|
|
48
|
+
}
|
|
49
|
+
export interface ResolveResponse {
|
|
50
|
+
kb_version: string;
|
|
51
|
+
catalog_revision: number;
|
|
52
|
+
audit_through_seq?: number;
|
|
53
|
+
hits: Array<{
|
|
54
|
+
term_id: string;
|
|
55
|
+
matched_text: string;
|
|
56
|
+
}>;
|
|
57
|
+
term_delta: TermDeltaRecord[];
|
|
58
|
+
rule_delta: RuleRecord[];
|
|
59
|
+
unresolved: Array<{
|
|
60
|
+
matched_text: string;
|
|
61
|
+
reason: string;
|
|
62
|
+
candidate_term_ids: string[];
|
|
63
|
+
}>;
|
|
64
|
+
degraded?: boolean;
|
|
65
|
+
fallback_reason?: string;
|
|
66
|
+
}
|
|
67
|
+
export interface AuditSegment {
|
|
68
|
+
event_seq: number;
|
|
69
|
+
source: string;
|
|
70
|
+
text: string;
|
|
71
|
+
candidates?: string[];
|
|
72
|
+
}
|
|
73
|
+
export interface AgentPort {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly status: "idle" | "running";
|
|
76
|
+
readonly inbox: {
|
|
77
|
+
readonly nextStep: readonly UserMessage[];
|
|
78
|
+
remove(messageId: UserMessage["id"]): boolean;
|
|
79
|
+
};
|
|
80
|
+
inject(message: UserMessage): void;
|
|
81
|
+
steer(message: UserMessage): void;
|
|
82
|
+
}
|
|
83
|
+
export interface LoggerPort {
|
|
84
|
+
debug(message: string, ...args: unknown[]): void;
|
|
85
|
+
info(message: string, ...args: unknown[]): void;
|
|
86
|
+
warn(message: string, ...args: unknown[]): void;
|
|
87
|
+
error(message: string, ...args: unknown[]): void;
|
|
88
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@apollo-music/dsh-sidecar-terms",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness sidecar plugin for incremental Apollo terminology and rule injection",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist", "README.md"],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.13.0"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/fde0-inc/apollo-terminology-knowledge-base.git",
|
|
21
|
+
"directory": "integrations/deepseek-harness"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/fde0-inc/apollo-terminology-knowledge-base/tree/main/integrations/deepseek-harness",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/fde0-inc/apollo-terminology-knowledge-base/issues"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"registry": "https://registry.npmjs.org/"
|
|
30
|
+
},
|
|
31
|
+
"license": "UNLICENSED",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"cordis",
|
|
35
|
+
"terminology",
|
|
36
|
+
"sidecar"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.json"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
43
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
|
44
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
45
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
46
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
47
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
48
|
+
}
|
|
49
|
+
}
|