principles-disciple 1.35.0 → 1.36.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/core/correction-cue-learner.ts +23 -8
- package/src/core/init.ts +2 -2
- package/src/hooks/prompt.ts +3 -3
- package/src/service/evolution-worker.ts +39 -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/tests/core/correction-cue-learner.test.ts +345 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -21,10 +21,17 @@ import {
|
|
|
21
21
|
CORRECTION_SEED_KEYWORDS,
|
|
22
22
|
MAX_CORRECTION_KEYWORDS,
|
|
23
23
|
} from './correction-types.js';
|
|
24
|
-
import { checkCooldown
|
|
24
|
+
import { checkCooldown } from '../service/nocturnal-runtime.js';
|
|
25
25
|
|
|
26
26
|
const KEYWORD_STORE_FILE = 'correction_keywords.json';
|
|
27
27
|
|
|
28
|
+
// CORR-08: Daily optimization throttle (uses checkCooldown in nocturnal-runtime.ts)
|
|
29
|
+
// Note: throttle state is stored in nocturnal-runtime.json, not a separate file.
|
|
30
|
+
|
|
31
|
+
// Weight bounds for correction keywords (D-39-03, D-39-15)
|
|
32
|
+
const MIN_KEYWORD_WEIGHT = 0.1;
|
|
33
|
+
const MAX_KEYWORD_WEIGHT = 0.9;
|
|
34
|
+
|
|
28
35
|
// =========================================================================
|
|
29
36
|
// Module-level cache (D-04, D-05)
|
|
30
37
|
// =========================================================================
|
|
@@ -112,6 +119,8 @@ export function saveCorrectionKeywordStore(
|
|
|
112
119
|
_correctionCueCache = null;
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
// =========================================================================
|
|
123
|
+
// Throttle helpers (CORR-08)
|
|
115
124
|
// =========================================================================
|
|
116
125
|
// Singleton state
|
|
117
126
|
// =========================================================================
|
|
@@ -217,7 +226,7 @@ export class CorrectionCueLearner {
|
|
|
217
226
|
keyword.hitCount = (keyword.hitCount ?? 0) + 1;
|
|
218
227
|
|
|
219
228
|
// D-39-15: Multiplicative weight decay x0.8 on confirmed FP
|
|
220
|
-
keyword.weight = Math.max(
|
|
229
|
+
keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, keyword.weight * 0.8);
|
|
221
230
|
keyword.lastHitAt = new Date().toISOString();
|
|
222
231
|
|
|
223
232
|
this.flush();
|
|
@@ -238,10 +247,10 @@ export class CorrectionCueLearner {
|
|
|
238
247
|
|
|
239
248
|
/**
|
|
240
249
|
* Records that an optimization was performed.
|
|
241
|
-
*
|
|
250
|
+
* Updates lastOptimizedAt for the store. Throttle state is managed
|
|
251
|
+
* by checkCooldown() — no separate throttle file needed (CORR-08).
|
|
242
252
|
*/
|
|
243
|
-
|
|
244
|
-
await recordCooldown(this.stateDir, 24 * 60 * 60 * 1000);
|
|
253
|
+
recordOptimizationPerformed(): void {
|
|
245
254
|
this.store.lastOptimizedAt = new Date().toISOString();
|
|
246
255
|
this.flush();
|
|
247
256
|
}
|
|
@@ -270,14 +279,20 @@ export class CorrectionCueLearner {
|
|
|
270
279
|
* Throws if keyword not found.
|
|
271
280
|
*/
|
|
272
281
|
updateWeight(term: string, weight: number): void {
|
|
273
|
-
const
|
|
282
|
+
const keyword = this.store.keywords.find(
|
|
274
283
|
k => k.term.toLowerCase() === term.toLowerCase()
|
|
275
284
|
);
|
|
276
|
-
if (
|
|
285
|
+
if (!keyword) {
|
|
277
286
|
throw new Error(`Keyword not found: ${term}`);
|
|
278
287
|
}
|
|
279
288
|
|
|
280
|
-
|
|
289
|
+
keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, Math.min(MAX_KEYWORD_WEIGHT, weight)); // Clamp to MIN-MAX_KEYWORD_WEIGHT
|
|
290
|
+
const idx = this.store.keywords.findIndex(
|
|
291
|
+
k => k.term.toLowerCase() === term.toLowerCase()
|
|
292
|
+
);
|
|
293
|
+
if (idx >= 0) {
|
|
294
|
+
this.store.keywords[idx] = { ...keyword };
|
|
295
|
+
}
|
|
281
296
|
this.flush();
|
|
282
297
|
}
|
|
283
298
|
|
package/src/core/init.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function ensureWorkspaceTemplates(api: OpenClawPluginApi, workspaceDir: s
|
|
|
46
46
|
if (fs.existsSync(commonTemplatesDir)) {
|
|
47
47
|
api.logger.info(`[PD] Syncing workspace templates: ${workspaceDir}...`);
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
|
|
50
50
|
copyRecursiveSync(commonTemplatesDir, workspaceDir, api);
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -89,7 +89,7 @@ export function ensureWorkspaceTemplates(api: OpenClawPluginApi, workspaceDir: s
|
|
|
89
89
|
fs.mkdirSync(painDestDir, { recursive: true });
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
|
|
93
93
|
copyRecursiveSync(painTemplatesDir, painDestDir, api);
|
|
94
94
|
}
|
|
95
95
|
|
package/src/hooks/prompt.ts
CHANGED
|
@@ -368,7 +368,7 @@ export async function handleBeforePromptBuild(
|
|
|
368
368
|
// prependContext: Only short dynamic directives: evolutionDirective + heartbeat
|
|
369
369
|
|
|
370
370
|
|
|
371
|
-
|
|
371
|
+
|
|
372
372
|
let prependSystemContext: string;
|
|
373
373
|
let prependContext = '';
|
|
374
374
|
let appendSystemContext = '';
|
|
@@ -684,7 +684,7 @@ ${taskBlocks}${processingNote}
|
|
|
684
684
|
|
|
685
685
|
// ──── 6. Dynamic Attitude Matrix (based on GFI) ────
|
|
686
686
|
|
|
687
|
-
|
|
687
|
+
|
|
688
688
|
let attitudeDirective: string;
|
|
689
689
|
const currentGfi = session?.currentGfi || 0;
|
|
690
690
|
|
|
@@ -910,7 +910,7 @@ ${taskBlocks}${processingNote}
|
|
|
910
910
|
const toolMatches = toolPatterns.flatMap(({ pattern, tool }) => {
|
|
911
911
|
const matches: string[] = [];
|
|
912
912
|
|
|
913
|
-
|
|
913
|
+
|
|
914
914
|
let _m;
|
|
915
915
|
const r = new RegExp(pattern.source, pattern.flags);
|
|
916
916
|
|
|
@@ -16,11 +16,10 @@ import { getEvolutionLogger } from '../core/evolution-logger.js';
|
|
|
16
16
|
import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
17
17
|
export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
18
18
|
import { LockUnavailableError } from '../config/index.js';
|
|
19
|
-
import { PAIN_QUEUE_DEDUP_WINDOW_MS } from '../config/defaults/runtime.js';
|
|
20
19
|
import { checkWorkspaceIdle, checkCooldown } from './nocturnal-runtime.js';
|
|
21
20
|
import { loadNocturnalConfig } from './nocturnal-config.js';
|
|
22
21
|
import { WorkflowStore } from './subagent-workflow/workflow-store.js';
|
|
23
|
-
import type { WorkflowRow
|
|
22
|
+
import type { WorkflowRow } from './subagent-workflow/types.js';
|
|
24
23
|
import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
|
|
25
24
|
import { DeepReflectWorkflowManager } from './subagent-workflow/deep-reflect-workflow-manager.js';
|
|
26
25
|
import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
|
|
@@ -32,22 +31,14 @@ import {
|
|
|
32
31
|
import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-contract.js';
|
|
33
32
|
import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
|
|
34
33
|
import { readPainFlagContract } from '../core/pain.js';
|
|
35
|
-
import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './correction-observer-workflow-manager.js';
|
|
36
|
-
import type { CorrectionObserverPayload } from './correction-observer-types.js';
|
|
34
|
+
import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './subagent-workflow/correction-observer-workflow-manager.js';
|
|
35
|
+
import type { CorrectionObserverPayload } from './subagent-workflow/correction-observer-types.js';
|
|
37
36
|
import { KeywordOptimizationService } from './keyword-optimization-service.js';
|
|
38
37
|
import { TrajectoryRegistry } from '../core/trajectory.js';
|
|
39
38
|
import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
40
|
-
import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
|
|
41
|
-
import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*/
|
|
46
|
-
function atomicWriteFileSync(filePath: string, data: string): void {
|
|
47
|
-
const tmpPath = filePath + '.tmp';
|
|
48
|
-
fs.writeFileSync(tmpPath, data, 'utf8');
|
|
49
|
-
fs.renameSync(tmpPath, filePath);
|
|
50
|
-
}
|
|
40
|
+
const WORKFLOW_TTL_MS = 5 * 60 * 1000; // 5 minutes default TTL for helper workflows
|
|
41
|
+
import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
|
|
51
42
|
|
|
52
43
|
// ── Workflow Watchdog ────────────────────────────────────────────────────────
|
|
53
44
|
// Detects stale/orphaned workflows, invalid results, and cleanup failures.
|
|
@@ -209,6 +200,27 @@ let timeoutId: NodeJS.Timeout | null = null;
|
|
|
209
200
|
export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
|
|
210
201
|
export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation';
|
|
211
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Recent pain context attached to sleep_reflection tasks.
|
|
205
|
+
* Carries explicit recent pain signal metadata without being a separate task kind.
|
|
206
|
+
* Used by NocturnalTargetSelector for ranking bias and context enrichment.
|
|
207
|
+
*/
|
|
208
|
+
export interface RecentPainContext {
|
|
209
|
+
/** Most recent unresolved pain event */
|
|
210
|
+
mostRecent: {
|
|
211
|
+
score: number;
|
|
212
|
+
source: string;
|
|
213
|
+
reason: string;
|
|
214
|
+
timestamp: string;
|
|
215
|
+
/** Session ID where the pain occurred */
|
|
216
|
+
sessionId: string;
|
|
217
|
+
} | null;
|
|
218
|
+
/** Count of pain events in the recent window (for signal strength) */
|
|
219
|
+
recentPainCount: number;
|
|
220
|
+
/** Highest pain score in the recent window */
|
|
221
|
+
recentMaxPainScore: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
212
224
|
export interface EvolutionQueueItem {
|
|
213
225
|
// Core identity
|
|
214
226
|
id: string;
|
|
@@ -414,6 +426,7 @@ function buildFallbackNocturnalSnapshot(
|
|
|
414
426
|
};
|
|
415
427
|
}
|
|
416
428
|
|
|
429
|
+
const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
|
|
417
430
|
|
|
418
431
|
// P0 fix: File lock constants and helper for queue operations (prevents TOCTOU race)
|
|
419
432
|
export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
|
|
@@ -711,7 +724,7 @@ function enqueueNewSleepReflectionTask(
|
|
|
711
724
|
recentPainContext,
|
|
712
725
|
});
|
|
713
726
|
|
|
714
|
-
|
|
727
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
715
728
|
logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
|
|
716
729
|
}
|
|
717
730
|
|
|
@@ -856,7 +869,7 @@ async function doEnqueuePainTask(
|
|
|
856
869
|
retryCount: 0, maxRetries: 3,
|
|
857
870
|
});
|
|
858
871
|
|
|
859
|
-
|
|
872
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
860
873
|
fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
|
|
861
874
|
result.enqueued = true;
|
|
862
875
|
|
|
@@ -1645,7 +1658,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1645
1658
|
|
|
1646
1659
|
// Write claimed state (includes any pain changes from above) and release lock
|
|
1647
1660
|
if (queueChanged) {
|
|
1648
|
-
|
|
1661
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
1649
1662
|
}
|
|
1650
1663
|
releaseLock();
|
|
1651
1664
|
for (const sleepTask of sleepReflectionTasks) {
|
|
@@ -1899,7 +1912,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1899
1912
|
freshQueue[idx] = sleepTask;
|
|
1900
1913
|
}
|
|
1901
1914
|
}
|
|
1902
|
-
|
|
1915
|
+
fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2), 'utf8');
|
|
1903
1916
|
|
|
1904
1917
|
// Log completions to EvolutionLogger
|
|
1905
1918
|
for (const sleepTask of sleepReflectionTasks) {
|
|
@@ -1992,14 +2005,10 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1992
2005
|
};
|
|
1993
2006
|
|
|
1994
2007
|
// Dispatch LLM subagent via CorrectionObserverWorkflowManager
|
|
1995
|
-
const subagent = api?.runtime?.subagent;
|
|
1996
|
-
if (!subagent) {
|
|
1997
|
-
throw new Error('[PD:EvolutionWorker] subagent runtime not available for keyword_optimization');
|
|
1998
|
-
}
|
|
1999
2008
|
const manager = new CorrectionObserverWorkflowManager({
|
|
2000
2009
|
workspaceDir: wctx.workspaceDir,
|
|
2001
2010
|
logger,
|
|
2002
|
-
subagent
|
|
2011
|
+
subagent: api?.runtime?.subagent!,
|
|
2003
2012
|
agentSession: api?.runtime?.agent?.session,
|
|
2004
2013
|
});
|
|
2005
2014
|
|
|
@@ -2013,11 +2022,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
2013
2022
|
workflowId = handle.workflowId;
|
|
2014
2023
|
koTask.resultRef = workflowId;
|
|
2015
2024
|
} else {
|
|
2016
|
-
|
|
2017
|
-
workflowId = koTask.resultRef;
|
|
2018
|
-
if (!workflowId) {
|
|
2019
|
-
throw new Error(`[PD:EvolutionWorker] keyword_optimization task ${koTask.id} has no resultRef in polling mode`);
|
|
2020
|
-
}
|
|
2025
|
+
workflowId = koTask.resultRef!;
|
|
2021
2026
|
}
|
|
2022
2027
|
|
|
2023
2028
|
// Poll workflow state
|
|
@@ -2029,7 +2034,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
2029
2034
|
|
|
2030
2035
|
if (parsedResult?.updated) {
|
|
2031
2036
|
koService.applyResult(parsedResult);
|
|
2032
|
-
|
|
2037
|
+
learner.recordOptimizationPerformed();
|
|
2033
2038
|
logger?.info?.(`[PD:EvolutionWorker] keyword_optimization applied mutations: ${parsedResult.summary}`);
|
|
2034
2039
|
} else {
|
|
2035
2040
|
logger?.info?.(`[PD:EvolutionWorker] keyword_optimization completed with no updates`);
|
|
@@ -2082,7 +2087,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
2082
2087
|
freshQueue.push(koTask);
|
|
2083
2088
|
}
|
|
2084
2089
|
}
|
|
2085
|
-
fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2));
|
|
2090
|
+
fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2), 'utf8');
|
|
2086
2091
|
} catch (koResultErr) {
|
|
2087
2092
|
logger?.warn?.(`[PD:EvolutionWorker] Failed to write keyword_optimization results: ${String(koResultErr)}`);
|
|
2088
2093
|
} finally {
|
|
@@ -2092,7 +2097,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
2092
2097
|
}
|
|
2093
2098
|
|
|
2094
2099
|
if (queueChanged) {
|
|
2095
|
-
|
|
2100
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
2096
2101
|
}
|
|
2097
2102
|
|
|
2098
2103
|
// Pipeline observability: log stage-level summary at end of cycle
|
|
@@ -2210,7 +2215,7 @@ export async function registerEvolutionTaskSession(
|
|
|
2210
2215
|
if (!task.started_at) {
|
|
2211
2216
|
task.started_at = new Date().toISOString();
|
|
2212
2217
|
}
|
|
2213
|
-
|
|
2218
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
2214
2219
|
return true;
|
|
2215
2220
|
} finally {
|
|
2216
2221
|
releaseLock();
|
|
@@ -2250,7 +2255,7 @@ interface WorkerStatusReport {
|
|
|
2250
2255
|
function writeWorkerStatus(stateDir: string, report: WorkerStatusReport): void {
|
|
2251
2256
|
try {
|
|
2252
2257
|
const statusPath = path.join(stateDir, 'worker-status.json');
|
|
2253
|
-
|
|
2258
|
+
fs.writeFileSync(statusPath, JSON.stringify(report, null, 2), 'utf8');
|
|
2254
2259
|
} catch (statusErr) {
|
|
2255
2260
|
// Non-critical: worker-status.json is for monitoring, failure is acceptable
|
|
2256
2261
|
// (no logger available in this standalone helper)
|
|
@@ -2281,7 +2286,7 @@ async function processEvolutionQueueWithResult(
|
|
|
2281
2286
|
const purgeResult = purgeStaleFailedTasks(queue, logger);
|
|
2282
2287
|
if (purgeResult.purged > 0) {
|
|
2283
2288
|
// Write back the cleaned queue
|
|
2284
|
-
|
|
2289
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
2285
2290
|
}
|
|
2286
2291
|
|
|
2287
2292
|
queueResult.total = queue.length;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
10
|
-
import type { CorrectionObserverResult } from './correction-observer-types.js';
|
|
10
|
+
import type { CorrectionObserverResult } from './subagent-workflow/correction-observer-types.js';
|
|
11
11
|
import type { PluginLogger } from '../openclaw-sdk.js';
|
|
12
12
|
import { TrajectoryRegistry } from '../core/trajectory.js';
|
|
13
13
|
|
|
@@ -137,4 +137,4 @@ export type TrajectoryHistoryEntry = {
|
|
|
137
137
|
};
|
|
138
138
|
|
|
139
139
|
/** Re-export CorrectionObserverPayload for convenience */
|
|
140
|
-
export type { CorrectionObserverPayload } from './correction-observer-types.js';
|
|
140
|
+
export type { CorrectionObserverPayload } from './subagent-workflow/correction-observer-types.js';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Correction Observer Workflow - Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* Types for the correction observer LLM optimization workflow.
|
|
5
|
+
* This workflow dispatches an LLM subagent to analyze keyword performance
|
|
6
|
+
* and recommend ADD/UPDATE/REMOVE actions for the correction keyword store.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SubagentWorkflowSpec } from './types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Input passed to the correction observer subagent.
|
|
13
|
+
*/
|
|
14
|
+
export interface CorrectionObserverPayload {
|
|
15
|
+
/** Parent session that triggered the optimization */
|
|
16
|
+
parentSessionId: string;
|
|
17
|
+
/** Workspace directory */
|
|
18
|
+
workspaceDir: string;
|
|
19
|
+
/** Current keyword store summary for context */
|
|
20
|
+
keywordStoreSummary: {
|
|
21
|
+
totalKeywords: number;
|
|
22
|
+
terms: Array<{
|
|
23
|
+
term: string;
|
|
24
|
+
weight: number;
|
|
25
|
+
hitCount: number;
|
|
26
|
+
truePositiveCount: number;
|
|
27
|
+
falsePositiveCount: number;
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
30
|
+
/** Recent user messages for pattern analysis */
|
|
31
|
+
recentMessages: string[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Trajectory history: user turns where correctionDetected=true (D-40-08).
|
|
35
|
+
* Includes term matched, timestamp, sessionId for FPR trend analysis.
|
|
36
|
+
*/
|
|
37
|
+
trajectoryHistory: Array<{
|
|
38
|
+
sessionId: string;
|
|
39
|
+
timestamp: string;
|
|
40
|
+
term: string;
|
|
41
|
+
userMessage: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Result from the correction observer subagent.
|
|
47
|
+
*/
|
|
48
|
+
export interface CorrectionObserverResult {
|
|
49
|
+
/** Whether any changes were made */
|
|
50
|
+
updated: boolean;
|
|
51
|
+
/** The optimization decisions returned by the LLM */
|
|
52
|
+
updates: Record<string, {
|
|
53
|
+
action: 'add' | 'update' | 'remove';
|
|
54
|
+
weight?: number;
|
|
55
|
+
falsePositiveRate?: number;
|
|
56
|
+
reasoning: string;
|
|
57
|
+
}>;
|
|
58
|
+
/** Human-readable summary */
|
|
59
|
+
summary: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Workflow spec for the correction observer optimization workflow.
|
|
64
|
+
*/
|
|
65
|
+
export interface CorrectionObserverWorkflowSpec extends SubagentWorkflowSpec<CorrectionObserverResult> {
|
|
66
|
+
workflowType: 'correction_observer';
|
|
67
|
+
payload: CorrectionObserverPayload;
|
|
68
|
+
result?: CorrectionObserverResult;
|
|
69
|
+
}
|
|
@@ -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';
|
|
@@ -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
|
+
});
|