principles-disciple 1.35.0 → 1.37.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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/nocturnal-train.ts +1 -0
- package/src/core/correction-cue-learner.ts +23 -8
- package/src/core/event-log.ts +3 -0
- package/src/core/evolution-engine.ts +1 -0
- package/src/core/init.ts +2 -2
- package/src/core/nocturnal-trinity-types.ts +124 -0
- package/src/core/session-tracker.ts +1 -0
- package/src/core/training-program.ts +1 -0
- package/src/hooks/gate-block-helper.ts +1 -1
- package/src/hooks/prompt.ts +3 -3
- package/src/index.ts +2 -1
- package/src/service/central-sync-service.ts +2 -0
- package/src/service/evolution-dedup.ts +74 -0
- package/src/service/evolution-pain-context.ts +79 -0
- package/src/service/evolution-queue-lock.ts +47 -0
- package/src/service/evolution-queue-migration.ts +173 -0
- package/src/service/evolution-worker.ts +43 -34
- package/src/service/keyword-optimization-service.ts +2 -2
- package/src/service/subagent-workflow/correction-observer-types.ts +69 -0
- package/src/service/subagent-workflow/correction-observer-workflow-manager.ts +246 -0
- package/src/service/subagent-workflow/index.ts +13 -0
- package/src/service/subagent-workflow/workflow-manager-base.ts +1 -0
- package/tests/core/correction-cue-learner.test.ts +345 -0
- package/tests/core/pain-score.property.test.ts +205 -0
- package/tests/integration/chaos-resilience.test.ts +348 -0
- package/tests/integration/gate-real-io.e2e.test.ts +251 -0
- package/tests/integration/pain-diagnostician-loop.e2e.test.ts +380 -0
- package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +8 -2
- package/tests/integration/trajectory-lifecycle.e2e.test.ts +523 -0
- package/vitest.config.ts +23 -4
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CorrectionObserverWorkflowManager
|
|
3
|
+
*
|
|
4
|
+
* Workflow manager that dispatches an LLM subagent to optimize correction
|
|
5
|
+
* keywords based on recent match performance data and user feedback.
|
|
6
|
+
*
|
|
7
|
+
* Follows the established WorkflowManagerBase pattern from EmpathyObserverWorkflowManager.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PluginLogger } from '../../openclaw-sdk.js';
|
|
11
|
+
import type {
|
|
12
|
+
SubagentWorkflowSpec,
|
|
13
|
+
WorkflowMetadata,
|
|
14
|
+
WorkflowResultContext,
|
|
15
|
+
WorkflowPersistContext,
|
|
16
|
+
WorkflowHandle,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
import type { RuntimeDirectDriver } from './runtime-direct-driver.js';
|
|
19
|
+
import { WorkflowManagerBase } from './workflow-manager-base.js';
|
|
20
|
+
import { isSubagentRuntimeAvailable } from '../../utils/subagent-probe.js';
|
|
21
|
+
import type {
|
|
22
|
+
CorrectionObserverPayload,
|
|
23
|
+
CorrectionObserverResult,
|
|
24
|
+
} from './correction-observer-types.js';
|
|
25
|
+
|
|
26
|
+
const WORKFLOW_SESSION_PREFIX = 'agent:main:subagent:workflow-correction-';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
29
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
// Prompt formatting constants
|
|
32
|
+
const MAX_TRAJECTORY_MESSAGE_LENGTH = 80;
|
|
33
|
+
|
|
34
|
+
// ── Options ─────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface CorrectionObserverWorkflowOptions {
|
|
37
|
+
workspaceDir: string;
|
|
38
|
+
logger: PluginLogger;
|
|
39
|
+
subagent: RuntimeDirectDriver['subagent'];
|
|
40
|
+
/** Pass api.runtime.agent.session to enable heartbeat-safe cleanup (#188) */
|
|
41
|
+
agentSession?: RuntimeDirectDriver['agentSession'];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Helper Functions ─────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract raw assistant text from messages or assistantTexts array.
|
|
48
|
+
*/
|
|
49
|
+
function extractAssistantTextForSpec(messages: unknown[], assistantTexts?: string[]): string {
|
|
50
|
+
if (assistantTexts && assistantTexts.length > 0) {
|
|
51
|
+
return assistantTexts[assistantTexts.length - 1] || '';
|
|
52
|
+
}
|
|
53
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
54
|
+
const msg = messages[i] as { role?: string; content?: unknown };
|
|
55
|
+
if (msg?.role !== 'assistant') continue;
|
|
56
|
+
if (typeof msg.content === 'string') return msg.content;
|
|
57
|
+
if (Array.isArray(msg.content)) {
|
|
58
|
+
const txt = msg.content
|
|
59
|
+
.filter((part: unknown) => part && typeof part === 'object' && (part as { type?: string }).type === 'text' && typeof (part as { text?: unknown }).text === 'string')
|
|
60
|
+
.map((part: unknown) => (part as { text: string }).text)
|
|
61
|
+
.join('\n');
|
|
62
|
+
if (txt) return txt;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse correction observer JSON payload from raw text.
|
|
70
|
+
*/
|
|
71
|
+
function parseCorrectionObserverPayload(rawText: string): CorrectionObserverResult | null {
|
|
72
|
+
if (!rawText?.trim()) return null;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(rawText.trim()) as CorrectionObserverResult;
|
|
75
|
+
} catch {
|
|
76
|
+
const match = /\{[\s\S]*\}/.exec(rawText);
|
|
77
|
+
if (!match) return null;
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(match[0]) as CorrectionObserverResult;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Workflow Spec ─────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export const correctionObserverWorkflowSpec: SubagentWorkflowSpec<CorrectionObserverResult> = {
|
|
89
|
+
workflowType: 'correction_observer',
|
|
90
|
+
transport: 'runtime_direct',
|
|
91
|
+
timeoutMs: 30_000,
|
|
92
|
+
ttlMs: 300_000,
|
|
93
|
+
shouldDeleteSessionAfterFinalize: true,
|
|
94
|
+
|
|
95
|
+
buildPrompt(taskInput: unknown, _metadata: WorkflowMetadata): string {
|
|
96
|
+
const payload = taskInput as CorrectionObserverPayload;
|
|
97
|
+
const { keywordStoreSummary, recentMessages, trajectoryHistory } = payload;
|
|
98
|
+
|
|
99
|
+
const termsList = keywordStoreSummary.terms
|
|
100
|
+
.map(t => ` - term="${t.term}", weight=${t.weight}, hits=${t.hitCount}, TP=${t.truePositiveCount}, FP=${t.falsePositiveCount}`)
|
|
101
|
+
.join('\n');
|
|
102
|
+
|
|
103
|
+
const messages = recentMessages.length > 0
|
|
104
|
+
? recentMessages.map(m => ` - ${JSON.stringify(m)}`).join('\n')
|
|
105
|
+
: ' (none)';
|
|
106
|
+
|
|
107
|
+
const trajectory = trajectoryHistory.length > 0
|
|
108
|
+
? trajectoryHistory.map(t => ` - [${t.sessionId}] ${t.term} (${t.timestamp}): ${t.userMessage.substring(0, MAX_TRAJECTORY_MESSAGE_LENGTH)}`)
|
|
109
|
+
.join('\n')
|
|
110
|
+
: ' (none)';
|
|
111
|
+
|
|
112
|
+
return [
|
|
113
|
+
'You are a correction keyword optimizer.',
|
|
114
|
+
'',
|
|
115
|
+
'## TASK',
|
|
116
|
+
'Analyze the current correction keyword store and recent user messages.',
|
|
117
|
+
'Recommend ADD/UPDATE/REMOVE actions to improve correction cue accuracy.',
|
|
118
|
+
'',
|
|
119
|
+
'## Current Keyword Store (' + keywordStoreSummary.totalKeywords + ' terms):',
|
|
120
|
+
termsList,
|
|
121
|
+
'',
|
|
122
|
+
'## Recent User Messages (' + recentMessages.length + ' messages):',
|
|
123
|
+
messages,
|
|
124
|
+
'',
|
|
125
|
+
'## Correction Trajectory (recent confirmed corrections, D-40-08):',
|
|
126
|
+
trajectory,
|
|
127
|
+
'',
|
|
128
|
+
'## Rules:',
|
|
129
|
+
'- ADD: If a correction pattern is detected in messages but not in store',
|
|
130
|
+
'- UPDATE: If a term\'s weight should change based on TP/FP ratio',
|
|
131
|
+
'- REMOVE: If a term has 0 hits after many uses AND high false positive rate (>0.3)',
|
|
132
|
+
'- Keep reasoning concise (max 100 chars)',
|
|
133
|
+
'- Weight range: 0.1-0.9',
|
|
134
|
+
'',
|
|
135
|
+
'Return strict JSON (no markdown):',
|
|
136
|
+
'{"updated": boolean, "updates": {...}, "summary": string}',
|
|
137
|
+
].join('\n');
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async parseResult(ctx: WorkflowResultContext): Promise<CorrectionObserverResult | null> {
|
|
141
|
+
const rawText = extractAssistantTextForSpec(ctx.messages, ctx.assistantTexts);
|
|
142
|
+
return parseCorrectionObserverPayload(rawText);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async persistResult(_ctx: WorkflowPersistContext<CorrectionObserverResult>): Promise<void> {
|
|
146
|
+
// Result persistence is handled by the caller (evolution-worker.ts)
|
|
147
|
+
// which reads the result and applies keyword store updates.
|
|
148
|
+
// This spec handles only the LLM dispatch and result parsing.
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
shouldFinalizeOnWaitStatus(status: 'ok' | 'error' | 'timeout'): boolean {
|
|
152
|
+
return status === 'ok';
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ── Manager Class ─────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
export class CorrectionObserverWorkflowManager extends WorkflowManagerBase {
|
|
159
|
+
constructor(opts: CorrectionObserverWorkflowOptions) {
|
|
160
|
+
super({
|
|
161
|
+
workspaceDir: opts.workspaceDir,
|
|
162
|
+
logger: opts.logger,
|
|
163
|
+
subagent: opts.subagent,
|
|
164
|
+
agentSession: opts.agentSession,
|
|
165
|
+
workflowType: 'correction_observer',
|
|
166
|
+
sessionPrefix: WORKFLOW_SESSION_PREFIX,
|
|
167
|
+
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
|
168
|
+
defaultTtlMs: DEFAULT_TTL_MS,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async startWorkflow<TResult>(
|
|
173
|
+
spec: SubagentWorkflowSpec<TResult>,
|
|
174
|
+
options: {
|
|
175
|
+
parentSessionId: string;
|
|
176
|
+
workspaceDir?: string;
|
|
177
|
+
taskInput: unknown;
|
|
178
|
+
metadata?: Record<string, unknown>;
|
|
179
|
+
}
|
|
180
|
+
): Promise<WorkflowHandle> {
|
|
181
|
+
// Surface degrade: skip boot sessions
|
|
182
|
+
if (options.parentSessionId.startsWith('boot-')) {
|
|
183
|
+
this.logger.info(`[PD:CorrectionObserver] Skipping workflow: boot session`);
|
|
184
|
+
throw new Error(`CorrectionObserverWorkflowManager: cannot start workflow for boot session`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Surface degrade: check subagent runtime availability
|
|
188
|
+
if (!isSubagentRuntimeAvailable(this.driver.getSubagent())) {
|
|
189
|
+
this.logger.info(`[PD:CorrectionObserver] Skipping workflow: subagent runtime unavailable`);
|
|
190
|
+
throw new Error(`CorrectionObserverWorkflowManager: subagent runtime unavailable`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (spec.transport !== 'runtime_direct') {
|
|
194
|
+
throw new Error(`CorrectionObserverWorkflowManager only supports runtime_direct transport`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return super.startWorkflow(spec, options);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get the parsed workflow result for a completed workflow.
|
|
202
|
+
* Used by callers (evolution-worker.ts) to retrieve LLM optimization results
|
|
203
|
+
* after the workflow completes, so mutations can be applied to the keyword store.
|
|
204
|
+
*/
|
|
205
|
+
async getWorkflowResult(workflowId: string): Promise<CorrectionObserverResult | null> {
|
|
206
|
+
const workflow = this.store.getWorkflow(workflowId);
|
|
207
|
+
if (!workflow) return null;
|
|
208
|
+
|
|
209
|
+
const result = await this.driver.getResult({ sessionKey: workflow.child_session_key, limit: 20 });
|
|
210
|
+
return correctionObserverWorkflowSpec.parseResult({
|
|
211
|
+
messages: result.messages,
|
|
212
|
+
assistantTexts: result.assistantTexts,
|
|
213
|
+
metadata: JSON.parse(workflow.metadata_json) as WorkflowMetadata,
|
|
214
|
+
waitStatus: 'ok',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
219
|
+
protected override createWorkflowMetadata<TResult>(
|
|
220
|
+
spec: SubagentWorkflowSpec<TResult>,
|
|
221
|
+
options: {
|
|
222
|
+
parentSessionId: string;
|
|
223
|
+
workspaceDir?: string;
|
|
224
|
+
taskInput: unknown;
|
|
225
|
+
metadata?: Record<string, unknown>;
|
|
226
|
+
},
|
|
227
|
+
now: number
|
|
228
|
+
): WorkflowMetadata {
|
|
229
|
+
return {
|
|
230
|
+
parentSessionId: options.parentSessionId,
|
|
231
|
+
workspaceDir: options.workspaceDir,
|
|
232
|
+
taskInput: options.taskInput,
|
|
233
|
+
startedAt: now,
|
|
234
|
+
workflowType: spec.workflowType,
|
|
235
|
+
...options.metadata,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Factory ─────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
export function createCorrectionObserverWorkflowManager(
|
|
243
|
+
opts: CorrectionObserverWorkflowOptions
|
|
244
|
+
): CorrectionObserverWorkflowManager {
|
|
245
|
+
return new CorrectionObserverWorkflowManager(opts);
|
|
246
|
+
}
|
|
@@ -65,3 +65,16 @@ export type {
|
|
|
65
65
|
WorkflowEventRow,
|
|
66
66
|
WorkflowDebugSummary,
|
|
67
67
|
} from './types.js';
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
CorrectionObserverWorkflowManager,
|
|
71
|
+
createCorrectionObserverWorkflowManager,
|
|
72
|
+
correctionObserverWorkflowSpec,
|
|
73
|
+
type CorrectionObserverWorkflowOptions,
|
|
74
|
+
} from './correction-observer-workflow-manager.js';
|
|
75
|
+
|
|
76
|
+
export type {
|
|
77
|
+
CorrectionObserverPayload,
|
|
78
|
+
CorrectionObserverResult,
|
|
79
|
+
CorrectionObserverWorkflowSpec,
|
|
80
|
+
} from './correction-observer-types.js';
|
|
@@ -303,6 +303,7 @@ export abstract class WorkflowManagerBase implements WorkflowManager {
|
|
|
303
303
|
await this.notifyWaitResult(workflowId, 'error', errMsg);
|
|
304
304
|
}
|
|
305
305
|
}, 100);
|
|
306
|
+
timeout.unref(); // Don't keep process alive for wait poll
|
|
306
307
|
|
|
307
308
|
this.activeWorkflows.set(workflowId, timeout);
|
|
308
309
|
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import {
|
|
5
|
+
CorrectionCueLearner,
|
|
6
|
+
loadCorrectionKeywordStore,
|
|
7
|
+
saveCorrectionKeywordStore,
|
|
8
|
+
_resetCorrectionCueCache,
|
|
9
|
+
_resetCorrectionCueLearnerInstance,
|
|
10
|
+
} from '../../src/core/correction-cue-learner.js';
|
|
11
|
+
import {
|
|
12
|
+
CORRECTION_SEED_KEYWORDS,
|
|
13
|
+
MAX_CORRECTION_KEYWORDS,
|
|
14
|
+
} from '../../src/core/correction-types.js';
|
|
15
|
+
|
|
16
|
+
// ── Mock fs (hoisted — vi.mock runs before imports) ──────────────────────────
|
|
17
|
+
|
|
18
|
+
vi.mock('fs', () => ({
|
|
19
|
+
existsSync: vi.fn(() => false),
|
|
20
|
+
readFileSync: vi.fn(() => ''),
|
|
21
|
+
writeFileSync: vi.fn(),
|
|
22
|
+
renameSync: vi.fn(),
|
|
23
|
+
mkdirSync: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import * as fs from 'fs';
|
|
27
|
+
|
|
28
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function tempDir(): string {
|
|
31
|
+
return path.join(os.tmpdir(), `correction-cue-test-${Date.now()}-${Math.random()}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Test setup: reset module-level cache and singleton between tests ─────────
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
_resetCorrectionCueCache();
|
|
39
|
+
_resetCorrectionCueLearnerInstance();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
43
|
+
// CORR-01: Seed keywords
|
|
44
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
45
|
+
|
|
46
|
+
describe('CORR-01: Seed keywords', () => {
|
|
47
|
+
it('should create store with 16 seed keywords on first load', () => {
|
|
48
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
49
|
+
const dir = tempDir();
|
|
50
|
+
const store = loadCorrectionKeywordStore(dir);
|
|
51
|
+
expect(store.keywords).toHaveLength(16);
|
|
52
|
+
expect(store.version).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should set source=seed and non-empty addedAt for all seed keywords', () => {
|
|
56
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
57
|
+
const dir = tempDir();
|
|
58
|
+
const store = loadCorrectionKeywordStore(dir);
|
|
59
|
+
for (const kw of store.keywords) {
|
|
60
|
+
expect(kw.source).toBe('seed');
|
|
61
|
+
expect(kw.addedAt).not.toBe('');
|
|
62
|
+
expect(kw.addedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should have all 16 exact terms from CORRECTION_SEED_KEYWORDS', () => {
|
|
67
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
68
|
+
const dir = tempDir();
|
|
69
|
+
const store = loadCorrectionKeywordStore(dir);
|
|
70
|
+
const terms = store.keywords.map((k) => k.term);
|
|
71
|
+
for (const seed of CORRECTION_SEED_KEYWORDS) {
|
|
72
|
+
expect(terms).toContain(seed.term);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
78
|
+
// CORR-03: Atomic write
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
80
|
+
|
|
81
|
+
describe('CORR-03: Atomic write', () => {
|
|
82
|
+
it('should write to .tmp file before rename', () => {
|
|
83
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
84
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
85
|
+
JSON.stringify({ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })), version: 1 })
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const dir = tempDir();
|
|
89
|
+
const store = {
|
|
90
|
+
keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })),
|
|
91
|
+
version: 1,
|
|
92
|
+
lastOptimizedAt: '2026-01-01T00:00:00Z',
|
|
93
|
+
};
|
|
94
|
+
saveCorrectionKeywordStore(dir, store);
|
|
95
|
+
|
|
96
|
+
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
|
|
97
|
+
const tmpPath = writeCall[0] as string;
|
|
98
|
+
expect(tmpPath).toMatch(/\.tmp$/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should rename from tmp path to final path after write', () => {
|
|
102
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
103
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
104
|
+
JSON.stringify({ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })), version: 1 })
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const dir = tempDir();
|
|
108
|
+
const store = {
|
|
109
|
+
keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })),
|
|
110
|
+
version: 1,
|
|
111
|
+
lastOptimizedAt: '2026-01-01T00:00:00Z',
|
|
112
|
+
};
|
|
113
|
+
saveCorrectionKeywordStore(dir, store);
|
|
114
|
+
|
|
115
|
+
const renameCalls = vi.mocked(fs.renameSync).mock.calls;
|
|
116
|
+
expect(renameCalls).toHaveLength(1);
|
|
117
|
+
const [from, to] = renameCalls[0];
|
|
118
|
+
expect(from).toMatch(/\.tmp$/);
|
|
119
|
+
expect(to).not.toMatch(/\.tmp$/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should call mkdirSync with recursive:true before writing', () => {
|
|
123
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
124
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
125
|
+
JSON.stringify({ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })), version: 1 })
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const dir = tempDir();
|
|
129
|
+
const store = {
|
|
130
|
+
keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })),
|
|
131
|
+
version: 1,
|
|
132
|
+
lastOptimizedAt: '2026-01-01T00:00:00Z',
|
|
133
|
+
};
|
|
134
|
+
saveCorrectionKeywordStore(dir, store);
|
|
135
|
+
|
|
136
|
+
expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledWith(dir, { recursive: true });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
141
|
+
// CORR-04: Cache invalidation
|
|
142
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
143
|
+
|
|
144
|
+
describe('CORR-04: Cache invalidation', () => {
|
|
145
|
+
it('should invalidate cache after save so next load re-reads from disk', () => {
|
|
146
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
147
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
148
|
+
JSON.stringify({
|
|
149
|
+
keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })),
|
|
150
|
+
version: 1,
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const dir = tempDir();
|
|
155
|
+
loadCorrectionKeywordStore(dir);
|
|
156
|
+
expect(vi.mocked(fs.readFileSync)).toHaveBeenCalled();
|
|
157
|
+
|
|
158
|
+
const store = loadCorrectionKeywordStore(dir);
|
|
159
|
+
saveCorrectionKeywordStore(dir, store);
|
|
160
|
+
|
|
161
|
+
// After save, cache is null — next load must re-read. Verify by changing
|
|
162
|
+
// the mock return and confirming the new data is picked up.
|
|
163
|
+
vi.mocked(fs.readFileSync).mockClear();
|
|
164
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ keywords: [], version: 1 }));
|
|
165
|
+
|
|
166
|
+
const store2 = loadCorrectionKeywordStore(dir);
|
|
167
|
+
expect(vi.mocked(fs.readFileSync)).toHaveBeenCalled();
|
|
168
|
+
expect(store2.keywords).toHaveLength(0); // proves re-read happened
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
173
|
+
// CORR-05: 200-term limit
|
|
174
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
175
|
+
|
|
176
|
+
describe('CORR-05: 200-term limit', () => {
|
|
177
|
+
it('should throw when adding keyword beyond 200 terms', () => {
|
|
178
|
+
const keywords = Array.from({ length: 200 }, (_, i) => ({
|
|
179
|
+
term: `keyword-${i}`,
|
|
180
|
+
weight: 0.5,
|
|
181
|
+
source: 'seed' as const,
|
|
182
|
+
addedAt: '2026-01-01T00:00:00Z',
|
|
183
|
+
}));
|
|
184
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
185
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ keywords, version: 1 }));
|
|
186
|
+
|
|
187
|
+
const dir = tempDir();
|
|
188
|
+
const learner = new CorrectionCueLearner(dir);
|
|
189
|
+
expect(learner.getStore().keywords).toHaveLength(200);
|
|
190
|
+
|
|
191
|
+
expect(() => learner.add({ term: 'new-keyword', weight: 0.5, source: 'user' })).toThrow(
|
|
192
|
+
'Correction keyword store limit reached (200 terms)'
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should allow add when at 199 terms', () => {
|
|
197
|
+
const keywords = Array.from({ length: 199 }, (_, i) => ({
|
|
198
|
+
term: `keyword-${i}`,
|
|
199
|
+
weight: 0.5,
|
|
200
|
+
source: 'seed' as const,
|
|
201
|
+
addedAt: '2026-01-01T00:00:00Z',
|
|
202
|
+
}));
|
|
203
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
204
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ keywords, version: 1 }));
|
|
205
|
+
|
|
206
|
+
const dir = tempDir();
|
|
207
|
+
const learner = new CorrectionCueLearner(dir);
|
|
208
|
+
expect(learner.getStore().keywords).toHaveLength(199);
|
|
209
|
+
|
|
210
|
+
expect(() => learner.add({ term: 'new-keyword', weight: 0.5, source: 'user' })).not.toThrow();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should not modify store when add fails due to limit', () => {
|
|
214
|
+
const keywords = Array.from({ length: 200 }, (_, i) => ({
|
|
215
|
+
term: `keyword-${i}`,
|
|
216
|
+
weight: 0.5,
|
|
217
|
+
source: 'seed' as const,
|
|
218
|
+
addedAt: '2026-01-01T00:00:00Z',
|
|
219
|
+
}));
|
|
220
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
221
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ keywords, version: 1 }));
|
|
222
|
+
|
|
223
|
+
const dir = tempDir();
|
|
224
|
+
const learner = new CorrectionCueLearner(dir);
|
|
225
|
+
try {
|
|
226
|
+
learner.add({ term: 'new-keyword', weight: 0.5, source: 'user' });
|
|
227
|
+
} catch {
|
|
228
|
+
// expected
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
expect(learner.getStore().keywords).toHaveLength(200);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
236
|
+
// CORR-11: Equivalence to detectCorrectionCue
|
|
237
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
238
|
+
|
|
239
|
+
describe('CORR-11: Equivalence to detectCorrectionCue', () => {
|
|
240
|
+
beforeEach(() => {
|
|
241
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Reference implementation using find() — first match wins (same as detectCorrectionCue).
|
|
246
|
+
*/
|
|
247
|
+
function detectCorrectionCueLegacy(text: string): string | null {
|
|
248
|
+
const normalized = text.trim().toLowerCase().replace(/[.,!?;:,。!?;:]/g, '');
|
|
249
|
+
const cues = CORRECTION_SEED_KEYWORDS.map((k) => k.term);
|
|
250
|
+
return cues.find((cue) => normalized.includes(cue)) ?? null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Tests using first-match semantics: find() returns the FIRST keyword in the
|
|
255
|
+
* array whose term appears in the normalized text, not the longest match.
|
|
256
|
+
*
|
|
257
|
+
* Order of CORRECTION_SEED_KEYWORDS array (first 8 Chinese):
|
|
258
|
+
* '不是这个', '不对', '错了', '搞错了', '理解错了', '你理解错了', '重新来', '再试一次'
|
|
259
|
+
*
|
|
260
|
+
* So "我搞错了" → "错了" is found first (index 2) before "搞错了" (index 3).
|
|
261
|
+
* "你理解错了" → "错了" is found first (index 2) before "理解错了" (index 4) and "你理解错了" (index 5).
|
|
262
|
+
*/
|
|
263
|
+
it.each([
|
|
264
|
+
// Chinese cases — note: first match wins
|
|
265
|
+
['不是这个', '不是这个'], // exact match
|
|
266
|
+
['你不对啊', '不对'], // first match is '不对' (index 1)
|
|
267
|
+
['错了!', '错了'], // exact match (index 2)
|
|
268
|
+
['我搞错了', '错了'], // '错了' appears first in array (index 2 < index 3)
|
|
269
|
+
['你理解错了', '错了'], // '错了' appears first in array (index 2 < index 4)
|
|
270
|
+
['重新来一遍', '重新来'], // exact match
|
|
271
|
+
['再试一次行不行', '再试一次'], // exact match
|
|
272
|
+
// English cases
|
|
273
|
+
['you are wrong', 'you are wrong'], // exact match
|
|
274
|
+
['wrong file', 'wrong file'], // exact match
|
|
275
|
+
['not this one', 'not this'], // exact match
|
|
276
|
+
['redo it', 'redo'], // exact match (index 11)
|
|
277
|
+
['try again', 'try again'], // exact match (index 12)
|
|
278
|
+
['do it again', 'again'], // 'again' is index 13
|
|
279
|
+
['please redo', 'redo'], // 'redo' found first (index 11 < index 14)
|
|
280
|
+
['please try again', 'try again'], // 'try again' found first (index 12 < index 15)
|
|
281
|
+
])('should match "%s" → "%s"', (text, expected) => {
|
|
282
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
283
|
+
const dir = tempDir();
|
|
284
|
+
const learner = new CorrectionCueLearner(dir);
|
|
285
|
+
const result = learner.match(text);
|
|
286
|
+
expect(result.matched).toBe(true);
|
|
287
|
+
expect(result.matchedTerms).toContain(expected);
|
|
288
|
+
expect(result.score).toBeGreaterThan(0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should produce same result as legacy detectCorrectionCue for varied inputs', () => {
|
|
292
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
293
|
+
const dir = tempDir();
|
|
294
|
+
const learner = new CorrectionCueLearner(dir);
|
|
295
|
+
|
|
296
|
+
const cases = [
|
|
297
|
+
'这个可以,没问题',
|
|
298
|
+
'不对,应该是这样',
|
|
299
|
+
'你再试试这个方法',
|
|
300
|
+
'nothing wrong here',
|
|
301
|
+
'please be careful',
|
|
302
|
+
'can you try again?',
|
|
303
|
+
'I think you are wrong about this',
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
for (const text of cases) {
|
|
307
|
+
const legacyResult = detectCorrectionCueLegacy(text);
|
|
308
|
+
const learnerResult = learner.match(text);
|
|
309
|
+
|
|
310
|
+
if (legacyResult !== null) {
|
|
311
|
+
expect(learnerResult.matched).toBe(true);
|
|
312
|
+
expect(learnerResult.matchedTerms).toContain(legacyResult);
|
|
313
|
+
expect(learnerResult.score).toBeGreaterThan(0);
|
|
314
|
+
} else {
|
|
315
|
+
expect(learnerResult.matched).toBe(false);
|
|
316
|
+
expect(learnerResult.matchedTerms).toEqual([]);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should match regardless of surrounding punctuation', () => {
|
|
322
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
323
|
+
const dir = tempDir();
|
|
324
|
+
const learner = new CorrectionCueLearner(dir);
|
|
325
|
+
|
|
326
|
+
const variations = ['不对', '不对!', '不对?', '。不对', '不对。', ' 不对 ', '不对啊'];
|
|
327
|
+
for (const text of variations) {
|
|
328
|
+
const result = learner.match(text);
|
|
329
|
+
expect(result.matched).toBe(true);
|
|
330
|
+
expect(result.matchedTerms).toContain('不对');
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should return positive score when matched, 0 when not matched', () => {
|
|
335
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
336
|
+
const dir = tempDir();
|
|
337
|
+
const learner = new CorrectionCueLearner(dir);
|
|
338
|
+
expect(learner.match('不是这个').score).toBeGreaterThan(0);
|
|
339
|
+
expect(learner.match('这个可以').score).toBe(0);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should export MAX_CORRECTION_KEYWORDS = 200', () => {
|
|
343
|
+
expect(MAX_CORRECTION_KEYWORDS).toBe(200);
|
|
344
|
+
});
|
|
345
|
+
});
|