principles-disciple 1.27.0 → 1.28.1
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 +4 -4
- package/package.json +4 -4
- package/scripts/diagnose-nocturnal.mjs +139 -2
- package/scripts/seed-nocturnal-scenarios.mjs +377 -0
- package/scripts/validate-live-path.ts +18 -18
- package/src/commands/nocturnal-train.ts +4 -6
- package/src/commands/pain.ts +8 -11
- package/src/commands/pd-reflect.ts +1 -1
- package/src/core/bootstrap-rules.ts +3 -3
- package/src/core/merge-gate-audit.ts +1 -1
- package/src/core/nocturnal-candidate-scoring.ts +131 -0
- package/src/core/nocturnal-reasoning-deriver.ts +337 -0
- package/src/core/nocturnal-trinity.ts +462 -25
- package/src/core/pain-context-extractor.ts +1 -3
- package/src/core/principle-tree-migration.ts +2 -4
- package/src/core/thinking-os-parser.ts +3 -3
- package/src/hooks/bash-risk.ts +1 -1
- package/src/hooks/gfi-gate.ts +1 -1
- package/src/hooks/pain.ts +1 -1
- package/src/hooks/prompt.ts +36 -2
- package/src/hooks/subagent.ts +1 -1
- package/src/index.ts +3 -1
- package/src/service/evolution-worker.ts +138 -44
- package/src/service/health-query-service.ts +15 -6
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +0 -1
- package/src/tools/write-pain-flag.ts +191 -0
- package/templates/langs/en/skills/pd-pain-signal/SKILL.md +34 -20
- package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +34 -20
- package/tests/core/nocturnal-candidate-scoring.test.ts +132 -0
- package/tests/core/nocturnal-e2e.test.ts +224 -0
- package/tests/core/nocturnal-reasoning-deriver.test.ts +372 -0
- package/tests/core/nocturnal-trinity.test.ts +791 -0
- package/tests/tools/write-pain-flag.test.ts +240 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from '../openclaw-sdk.js';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { buildPainFlag, writePainFlag } from '../core/pain.js';
|
|
4
|
+
import { resolveWorkspaceDirFromApi } from '../core/path-resolver.js';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
|
|
8
|
+
// Pain flag contract required fields
|
|
9
|
+
const PAIN_FLAG_REQUIRED_FIELDS = ['source', 'score', 'time', 'reason'] as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Atomic file write: write to temp file then rename.
|
|
13
|
+
* Prevents corruption if process crashes mid-write.
|
|
14
|
+
*/
|
|
15
|
+
function writePainFlagAtomic(filePath: string, content: string): void {
|
|
16
|
+
const dir = path.dirname(filePath);
|
|
17
|
+
if (!fs.existsSync(dir)) {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
const tmpPath = `${filePath}.tmp.${Date.now()}.${process.pid}`;
|
|
21
|
+
fs.writeFileSync(tmpPath, content, 'utf-8');
|
|
22
|
+
fs.renameSync(tmpPath, filePath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates the `write_pain_flag` tool.
|
|
27
|
+
*
|
|
28
|
+
* This tool allows the agent to record a pain signal when it recognizes
|
|
29
|
+
* that it made a mistake, violated a principle, or needs to flag an issue
|
|
30
|
+
* for later reflection.
|
|
31
|
+
*
|
|
32
|
+
* The tool wraps `buildPainFlag` + atomic `writePainFlag` to ensure:
|
|
33
|
+
* - Correct KV format serialization (never [object Object] corruption)
|
|
34
|
+
* - Atomic writes (temp file + rename, crash-safe)
|
|
35
|
+
* - Full contract compliance (source, score, time, reason)
|
|
36
|
+
*
|
|
37
|
+
* The agent should NEVER write to .pain_flag directly.
|
|
38
|
+
*/
|
|
39
|
+
export function createWritePainFlagTool(api: OpenClawPluginApi) {
|
|
40
|
+
return {
|
|
41
|
+
name: 'write_pain_flag',
|
|
42
|
+
description:
|
|
43
|
+
'Record a pain signal to flag mistakes, principle violations, or issues for later reflection. ' +
|
|
44
|
+
'Use this tool INSTEAD of writing .pain_flag directly. ' +
|
|
45
|
+
'Pain signals are processed by the evolution system on the next heartbeat cycle.',
|
|
46
|
+
parameters: Type.Object({
|
|
47
|
+
reason: Type.String({
|
|
48
|
+
description:
|
|
49
|
+
'Describe specifically what went wrong. ' +
|
|
50
|
+
'Include the error, the violated principle, or the issue. ' +
|
|
51
|
+
'Be concrete: "I edited config.ts without reading it first, breaking the export" ' +
|
|
52
|
+
'is better than "I made a mistake".',
|
|
53
|
+
}),
|
|
54
|
+
score: Type.Optional(Type.Number({
|
|
55
|
+
description:
|
|
56
|
+
'Pain severity score (0-100). Default: 80. ' +
|
|
57
|
+
'Guidelines: 30-50 (minor issue), 50-70 (moderate error), ' +
|
|
58
|
+
'70-100 (severe principle violation or data loss risk).',
|
|
59
|
+
minimum: 0,
|
|
60
|
+
maximum: 100,
|
|
61
|
+
})),
|
|
62
|
+
source: Type.Optional(Type.String({
|
|
63
|
+
description:
|
|
64
|
+
'Source of the pain signal. ' +
|
|
65
|
+
'Values: manual (user flagged), tool_failure (tool error), ' +
|
|
66
|
+
'user_empathy (user frustration), principle_violation (principle broken), ' +
|
|
67
|
+
'human_intervention (user manually intervened). ' +
|
|
68
|
+
'Default: manual.',
|
|
69
|
+
})),
|
|
70
|
+
session_id: Type.Optional(Type.String({
|
|
71
|
+
description:
|
|
72
|
+
'Session ID where the pain occurred. ' +
|
|
73
|
+
'If not provided, the system will use the current session.',
|
|
74
|
+
})),
|
|
75
|
+
is_risky: Type.Optional(Type.Boolean({
|
|
76
|
+
description:
|
|
77
|
+
'Whether this involves a high-risk operation (e.g., writing to sensitive files). ' +
|
|
78
|
+
'Default: false.',
|
|
79
|
+
})),
|
|
80
|
+
}),
|
|
81
|
+
|
|
82
|
+
async execute(
|
|
83
|
+
_toolCallId: string,
|
|
84
|
+
rawParams: Record<string, unknown>
|
|
85
|
+
): Promise<{ content: { type: string; text: string }[] }> {
|
|
86
|
+
const reason = typeof rawParams.reason === 'string' ? rawParams.reason.trim() : '';
|
|
87
|
+
const score = typeof rawParams.score === 'number' ? Math.max(0, Math.min(100, Math.round(rawParams.score))) : 80;
|
|
88
|
+
const source = typeof rawParams.source === 'string' && rawParams.source.trim() ? rawParams.source.trim() : 'manual';
|
|
89
|
+
const sessionId = typeof rawParams.session_id === 'string' ? rawParams.session_id.trim() : '';
|
|
90
|
+
const isRisky = rawParams.is_risky === true;
|
|
91
|
+
|
|
92
|
+
// ── Validate required fields ──
|
|
93
|
+
if (!reason) {
|
|
94
|
+
api.logger?.warn?.('[PD:write_pain_flag] Missing required field: reason');
|
|
95
|
+
return {
|
|
96
|
+
content: [{
|
|
97
|
+
type: 'text',
|
|
98
|
+
text: '❌ Error: The `reason` parameter is required.\n' +
|
|
99
|
+
'Describe specifically what went wrong. Example:\n' +
|
|
100
|
+
'"I edited config.ts without reading it first, breaking the export"',
|
|
101
|
+
}],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Resolve workspace ──
|
|
106
|
+
const workspaceDir = resolveWorkspaceDirFromApi(api);
|
|
107
|
+
if (!workspaceDir) {
|
|
108
|
+
api.logger?.error?.('[PD:write_pain_flag] Cannot resolve workspace directory');
|
|
109
|
+
return {
|
|
110
|
+
content: [{
|
|
111
|
+
type: 'text',
|
|
112
|
+
text: '❌ Error: Cannot determine the workspace directory. ' +
|
|
113
|
+
'Please ensure you are in an active workspace.',
|
|
114
|
+
}],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// ── Build pain flag data (KV format) ──
|
|
120
|
+
const painData = buildPainFlag({
|
|
121
|
+
source,
|
|
122
|
+
score: String(score),
|
|
123
|
+
reason,
|
|
124
|
+
session_id: sessionId,
|
|
125
|
+
is_risky: isRisky,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── Validate contract compliance ──
|
|
129
|
+
const missingFields: string[] = [];
|
|
130
|
+
for (const field of PAIN_FLAG_REQUIRED_FIELDS) {
|
|
131
|
+
if (!painData[field] || painData[field].trim() === '') {
|
|
132
|
+
missingFields.push(field);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (missingFields.length > 0) {
|
|
136
|
+
api.logger?.error?.(`[PD:write_pain_flag] Pain flag missing required fields: ${missingFields.join(', ')}`);
|
|
137
|
+
return {
|
|
138
|
+
content: [{
|
|
139
|
+
type: 'text',
|
|
140
|
+
text: `❌ Error: Pain flag is missing required fields: ${missingFields.join(', ')}. ` +
|
|
141
|
+
'This is an internal error — please report it.',
|
|
142
|
+
}],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Atomic write (temp file + rename) ──
|
|
147
|
+
const painFlagPath = path.join(workspaceDir, '.state', '.pain_flag');
|
|
148
|
+
const { serializeKvLines } = await import('../utils/io.js');
|
|
149
|
+
const content = serializeKvLines(painData);
|
|
150
|
+
writePainFlagAtomic(painFlagPath, content);
|
|
151
|
+
|
|
152
|
+
// ── Log success ──
|
|
153
|
+
api.logger?.info?.(
|
|
154
|
+
`[PD:write_pain_flag] Pain signal recorded: source=${source}, score=${score}, ` +
|
|
155
|
+
`reason="${reason.slice(0, 80)}"${reason.length > 80 ? '...' : ''}"`
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// ── Agent feedback ──
|
|
159
|
+
return {
|
|
160
|
+
content: [{
|
|
161
|
+
type: 'text',
|
|
162
|
+
text: `✅ Pain signal recorded successfully.\n\n` +
|
|
163
|
+
`- **Reason**: ${reason}\n` +
|
|
164
|
+
`- **Score**: ${score}/100\n` +
|
|
165
|
+
`- **Source**: ${source}\n` +
|
|
166
|
+
`- **Risk**: ${isRisky ? 'Yes' : 'No'}\n` +
|
|
167
|
+
`- **Session**: ${sessionId || '(current)'}\n\n` +
|
|
168
|
+
`The evolution system will process this signal on the next heartbeat cycle ` +
|
|
169
|
+
`(typically within 60 seconds).`,
|
|
170
|
+
}],
|
|
171
|
+
};
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// ── Log failure with stack trace ──
|
|
174
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
175
|
+
const stack = err instanceof Error ? err.stack?.split('\n').slice(0, 3).join(' → ') : '';
|
|
176
|
+
api.logger?.error?.(
|
|
177
|
+
`[PD:write_pain_flag] Failed to write pain flag: ${errorMsg}` +
|
|
178
|
+
(stack ? `\n Stack: ${stack}` : '')
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
content: [{
|
|
183
|
+
type: 'text',
|
|
184
|
+
text: `❌ Failed to record pain signal: ${errorMsg}\n\n` +
|
|
185
|
+
'The error has been logged. Please try again or report this issue.',
|
|
186
|
+
}],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pd-pain-signal
|
|
3
|
-
description: Manually inject a pain signal into the evolution system
|
|
3
|
+
description: Manually inject a pain signal into the evolution system. TRIGGER CONDITIONS: (1) User reports agent stuck/looping/unresponsive (2) User says "record this issue", "force reflection", "trigger pain" (3) Tool failure with no follow-up action (4) User provides human intervention feedback.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -9,30 +9,44 @@ disable-model-invocation: true
|
|
|
9
9
|
You are now the "Manual Intervention Pain" component.
|
|
10
10
|
|
|
11
11
|
**Task**:
|
|
12
|
-
1.
|
|
12
|
+
1. Record the user's feedback `$ARGUMENTS` as a **high-priority** pain signal.
|
|
13
13
|
2. Inform the user that the signal has been injected, and suggest waiting for the next Hook trigger (e.g., Stop or PreCompact) or manually running `/reflection-log`.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
**⚠️ Write Rules (MUST follow)**
|
|
16
|
+
|
|
17
|
+
**The ONLY correct way**: Use the `write_pain_flag` tool.
|
|
16
18
|
|
|
17
19
|
```
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
time: <ISO 8601 timestamp>
|
|
20
|
+
write_pain_flag({
|
|
21
|
+
reason: "User feedback or error description",
|
|
22
|
+
score: 80,
|
|
23
|
+
source: "human_intervention",
|
|
24
|
+
is_risky: false
|
|
25
|
+
})
|
|
25
26
|
```
|
|
26
27
|
|
|
27
|
-
**
|
|
28
|
-
- `
|
|
29
|
-
-
|
|
30
|
-
- `
|
|
31
|
-
- `
|
|
28
|
+
**Absolutely forbidden**:
|
|
29
|
+
- ❌ Writing to `.state/.pain_flag` directly (any method)
|
|
30
|
+
- ❌ Using bash heredoc (`cat <<EOF > .pain_flag`)
|
|
31
|
+
- ❌ Using `echo "..." > .pain_flag`
|
|
32
|
+
- ❌ Using `node -e` to call `writePainFlag` or `buildPainFlag`
|
|
33
|
+
- ❌ Any method that `toString()` a JavaScript object to the file
|
|
34
|
+
|
|
35
|
+
**Why use the tool?**
|
|
36
|
+
The `write_pain_flag` tool encapsulates correct KV-format serialization, ensuring `.pain_flag` is never corrupted. Historically, direct file writes caused `[object Object]` corruption multiple times.
|
|
32
37
|
|
|
33
|
-
**
|
|
34
|
-
- `
|
|
35
|
-
- `
|
|
36
|
-
- `
|
|
38
|
+
**Parameters**:
|
|
39
|
+
- `reason` (required): The reason for the pain signal — describe what went wrong
|
|
40
|
+
- `score` (optional): Pain score 0-100, default 80 (manual intervention)
|
|
41
|
+
- `source` (optional): Source, default `human_intervention`
|
|
42
|
+
- `is_risky` (optional): Whether this is a high-risk action, default false
|
|
37
43
|
|
|
38
|
-
**
|
|
44
|
+
**Example**:
|
|
45
|
+
```
|
|
46
|
+
write_pain_flag({
|
|
47
|
+
reason: "Agent edited a file without reading it first, breaking existing logic",
|
|
48
|
+
score: 85,
|
|
49
|
+
source: "human_intervention",
|
|
50
|
+
is_risky: false
|
|
51
|
+
})
|
|
52
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pd-pain-signal
|
|
3
|
-
description:
|
|
3
|
+
description: 手动注入痛苦信号到进化系统。TRIGGER CONDITIONS: (1) 用户报告 agent 卡住/循环/无响应 (2) 用户说"记录这个问题"、"强制反思"、"触发痛觉" (3) 工具失败后 agent 没有后续动作 (4) 用户提供人工干预反馈。
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -9,30 +9,44 @@ disable-model-invocation: true
|
|
|
9
9
|
你现在是"人工干预痛觉"组件。
|
|
10
10
|
|
|
11
11
|
**任务**:
|
|
12
|
-
1. 将用户的反馈 `$ARGUMENTS`
|
|
12
|
+
1. 将用户的反馈 `$ARGUMENTS` 作为一条**高优先级**的痛苦信号记录下来。
|
|
13
13
|
2. 告知用户信号已注入,并建议其等待下一个 Hook 触发(如 Stop 或 PreCompact)或手动运行 `/reflection-log`。
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
**⚠️ 写入规则(必须遵守)**
|
|
16
|
+
|
|
17
|
+
**唯一正确的方式**: 使用 `write_pain_flag` 工具。
|
|
16
18
|
|
|
17
19
|
```
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
time: <ISO 8601 时间>
|
|
20
|
+
write_pain_flag({
|
|
21
|
+
reason: "用户反馈原文或错误描述",
|
|
22
|
+
score: 80,
|
|
23
|
+
source: "human_intervention",
|
|
24
|
+
is_risky: false
|
|
25
|
+
})
|
|
25
26
|
```
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
- `
|
|
31
|
-
- `
|
|
28
|
+
**绝对禁止**:
|
|
29
|
+
- ❌ 直接写 `.state/.pain_flag` 文件(任何方式都不行)
|
|
30
|
+
- ❌ 使用 bash heredoc(`cat <<EOF > .pain_flag`)
|
|
31
|
+
- ❌ 使用 `echo "..." > .pain_flag`
|
|
32
|
+
- ❌ 使用 `node -e` 调用 `writePainFlag` 或 `buildPainFlag`
|
|
33
|
+
- ❌ 任何将 JavaScript 对象 `toString()` 写入文件的方式
|
|
34
|
+
|
|
35
|
+
**为什么必须用工具?**
|
|
36
|
+
`write_pain_flag` 工具封装了正确的序列化逻辑(KV 格式),确保 `.pain_flag` 文件不会被写坏。历史上多次因为直接写文件导致 `[object Object]` 损坏。
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
- `
|
|
35
|
-
- `
|
|
36
|
-
- `
|
|
38
|
+
**参数说明**:
|
|
39
|
+
- `reason` (必填): 痛苦的原因,描述具体发生了什么
|
|
40
|
+
- `score` (可选): 痛苦分数 0-100,默认 80(人工干预)
|
|
41
|
+
- `source` (可选): 来源,默认 `human_intervention`
|
|
42
|
+
- `is_risky` (可选): 是否高风险,默认 false
|
|
37
43
|
|
|
38
|
-
|
|
44
|
+
**示例**:
|
|
45
|
+
```
|
|
46
|
+
write_pain_flag({
|
|
47
|
+
reason: "Agent 没有读取文件就直接编辑,导致现有逻辑被破坏",
|
|
48
|
+
score: 85,
|
|
49
|
+
source: "human_intervention",
|
|
50
|
+
is_risky: false
|
|
51
|
+
})
|
|
52
|
+
```
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
rankCandidates,
|
|
6
6
|
runTournament,
|
|
7
7
|
DEFAULT_SCORING_WEIGHTS,
|
|
8
|
+
validateCandidateDiversity,
|
|
8
9
|
} from '../../src/core/nocturnal-candidate-scoring.js';
|
|
9
10
|
import type { DreamerCandidate, PhilosopherJudgment } from '../../src/core/nocturnal-trinity.js';
|
|
10
11
|
import type { ThresholdValues } from '../../src/core/adaptive-thresholds.js';
|
|
@@ -398,3 +399,134 @@ describe('DEFAULT_SCORING_WEIGHTS', () => {
|
|
|
398
399
|
}
|
|
399
400
|
});
|
|
400
401
|
});
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Tests: validateCandidateDiversity
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
describe('validateCandidateDiversity', () => {
|
|
408
|
+
it('passes when candidates have 2+ distinct risk levels and low keyword overlap', () => {
|
|
409
|
+
const candidates: DreamerCandidate[] = [
|
|
410
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Read config.json to verify settings' }),
|
|
411
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'Refactor the entire authentication module from scratch' }),
|
|
412
|
+
];
|
|
413
|
+
const result = validateCandidateDiversity(candidates);
|
|
414
|
+
expect(result.diversityCheckPassed).toBe(true);
|
|
415
|
+
expect(result.riskLevelDiversity).toBe(true);
|
|
416
|
+
expect(result.keywordOverlapPassed).toBe(true);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('fails when all candidates have the same risk level', () => {
|
|
420
|
+
const candidates: DreamerCandidate[] = [
|
|
421
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Read file A to check settings' }),
|
|
422
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'low', betterDecision: 'Review file completely different approach' }),
|
|
423
|
+
makeCandidate({ candidateIndex: 2, riskLevel: 'low', betterDecision: 'Inspect another unique diagnostic method' }),
|
|
424
|
+
];
|
|
425
|
+
const result = validateCandidateDiversity(candidates);
|
|
426
|
+
expect(result.diversityCheckPassed).toBe(false);
|
|
427
|
+
expect(result.riskLevelDiversity).toBe(false);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('fails when candidate pair has keyword overlap > 0.8', () => {
|
|
431
|
+
const candidates: DreamerCandidate[] = [
|
|
432
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Review the authentication configuration file before making any changes to the system' }),
|
|
433
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'Review the authentication configuration file before making any changes to the system' }),
|
|
434
|
+
];
|
|
435
|
+
const result = validateCandidateDiversity(candidates);
|
|
436
|
+
expect(result.diversityCheckPassed).toBe(false);
|
|
437
|
+
expect(result.keywordOverlapPassed).toBe(false);
|
|
438
|
+
expect(result.maxOverlapScore).toBeGreaterThan(0.8);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('passes for single candidate', () => {
|
|
442
|
+
const candidates: DreamerCandidate[] = [
|
|
443
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low' }),
|
|
444
|
+
];
|
|
445
|
+
const result = validateCandidateDiversity(candidates);
|
|
446
|
+
expect(result.diversityCheckPassed).toBe(true);
|
|
447
|
+
expect(result.details).toContain('Single candidate');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('passes for empty array', () => {
|
|
451
|
+
const result = validateCandidateDiversity([]);
|
|
452
|
+
expect(result.diversityCheckPassed).toBe(true);
|
|
453
|
+
expect(result.details).toContain('No candidates');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('passes when candidates lack riskLevel (graceful degradation)', () => {
|
|
457
|
+
const candidates: DreamerCandidate[] = [
|
|
458
|
+
makeCandidate({ candidateIndex: 0, betterDecision: 'Read config.json to verify settings' }),
|
|
459
|
+
makeCandidate({ candidateIndex: 1, betterDecision: 'Refactor the entire authentication module from scratch' }),
|
|
460
|
+
];
|
|
461
|
+
// No riskLevel on any candidate - should pass (no risk levels to check)
|
|
462
|
+
const result = validateCandidateDiversity(candidates);
|
|
463
|
+
expect(result.diversityCheckPassed).toBe(true);
|
|
464
|
+
expect(result.riskLevelDiversity).toBe(true);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('fails when some candidates have riskLevel but fewer than 2 distinct values', () => {
|
|
468
|
+
const candidates: DreamerCandidate[] = [
|
|
469
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'medium', betterDecision: 'Read config.json to verify settings' }),
|
|
470
|
+
makeCandidate({ candidateIndex: 1, betterDecision: 'Refactor the entire authentication module from scratch' }),
|
|
471
|
+
];
|
|
472
|
+
// Only 1 candidate has riskLevel, so only 1 distinct value → fail
|
|
473
|
+
const result = validateCandidateDiversity(candidates);
|
|
474
|
+
expect(result.diversityCheckPassed).toBe(false);
|
|
475
|
+
expect(result.riskLevelDiversity).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('uses max(|A|, |B|) as denominator for keyword overlap', () => {
|
|
479
|
+
// Short text A, long text B - overlap should use max as denominator
|
|
480
|
+
const candidates: DreamerCandidate[] = [
|
|
481
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'review authentication configuration' }),
|
|
482
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'review authentication configuration before proceeding with changes to the deployment pipeline infrastructure' }),
|
|
483
|
+
];
|
|
484
|
+
const result = validateCandidateDiversity(candidates);
|
|
485
|
+
// "review", "authentication", "configuration" overlap in both
|
|
486
|
+
// Set A = {review, authentication, configuration} = 3
|
|
487
|
+
// Set B = {review, authentication, configuration, before, proceeding, with, changes, deployment, pipeline, infrastructure} = 10
|
|
488
|
+
// intersection = 3, max(3, 10) = 10, overlap = 3/10 = 0.3
|
|
489
|
+
expect(result.maxOverlapScore).toBeLessThanOrEqual(0.4);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('ignores words <= 3 characters in keyword overlap', () => {
|
|
493
|
+
const candidates: DreamerCandidate[] = [
|
|
494
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'the and but for' }),
|
|
495
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'the and but for' }),
|
|
496
|
+
];
|
|
497
|
+
// All words are <= 3 chars, so no keywords extracted → overlap = 0
|
|
498
|
+
const result = validateCandidateDiversity(candidates);
|
|
499
|
+
expect(result.keywordOverlapPassed).toBe(true);
|
|
500
|
+
expect(result.maxOverlapScore).toBe(0);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('never throws on malformed input', () => {
|
|
504
|
+
// Undefined candidates
|
|
505
|
+
expect(() => validateCandidateDiversity(undefined as unknown as DreamerCandidate[])).not.toThrow();
|
|
506
|
+
// Null candidates
|
|
507
|
+
expect(() => validateCandidateDiversity(null as unknown as DreamerCandidate[])).not.toThrow();
|
|
508
|
+
// Candidates with undefined fields
|
|
509
|
+
expect(() => validateCandidateDiversity([
|
|
510
|
+
{ candidateIndex: 0 } as DreamerCandidate,
|
|
511
|
+
])).not.toThrow();
|
|
512
|
+
// Mixed valid and malformed
|
|
513
|
+
expect(() => validateCandidateDiversity([
|
|
514
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low' }),
|
|
515
|
+
{ candidateIndex: 1 } as DreamerCandidate,
|
|
516
|
+
])).not.toThrow();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('returns correct maxOverlapScore rounded to 2 decimal places', () => {
|
|
520
|
+
const candidates: DreamerCandidate[] = [
|
|
521
|
+
makeCandidate({ candidateIndex: 0, riskLevel: 'low', betterDecision: 'Review configuration settings before deployment' }),
|
|
522
|
+
makeCandidate({ candidateIndex: 1, riskLevel: 'high', betterDecision: 'Review configuration settings before deployment testing' }),
|
|
523
|
+
];
|
|
524
|
+
const result = validateCandidateDiversity(candidates);
|
|
525
|
+
// Verify the maxOverlapScore is a number with at most 2 decimal places
|
|
526
|
+
const decimalPart = result.maxOverlapScore.toString().split('.')[1];
|
|
527
|
+
if (decimalPart) {
|
|
528
|
+
expect(decimalPart.length).toBeLessThanOrEqual(2);
|
|
529
|
+
}
|
|
530
|
+
expect(typeof result.maxOverlapScore).toBe('number');
|
|
531
|
+
});
|
|
532
|
+
});
|