@wyattjoh/demur 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -25
- package/extensions/demur/index.ts +99 -15
- package/extensions/demur/training-store.ts +213 -35
- package/package.json +4 -2
- package/src/adapters/pi-worker.ts +21 -7
- package/src/cli.ts +554 -135
- package/src/guard.internal.ts +83 -33
- package/src/guard.ts +42 -2
- package/src/judge.ts +53 -7
- package/src/policy.ts +10 -1
- package/src/questions.ts +9 -1
- package/src/settings-model.ts +60 -0
- package/src/state.ts +14 -16
- package/src/training-evaluation.ts +408 -0
- package/src/training-review-model.ts +18 -2
- package/src/training-review-tui.tsx +408 -44
- package/src/types.ts +56 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TrainingCorrectionReason,
|
|
3
|
+
TrainingRecord,
|
|
4
|
+
} from "../extensions/demur/training-store.ts";
|
|
5
|
+
import type { JudgeResult } from "./judge.ts";
|
|
6
|
+
import { applyStaticGate, decide, THRESHOLDS, type Thresholds } from "./policy.ts";
|
|
7
|
+
import {
|
|
8
|
+
getLatestTrainingReview,
|
|
9
|
+
getTrainingCorrectionReason,
|
|
10
|
+
type TrainingReviewEntry,
|
|
11
|
+
} from "./training-review-model.ts";
|
|
12
|
+
import type {
|
|
13
|
+
Decision,
|
|
14
|
+
Judgments,
|
|
15
|
+
RenderedCommandState,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decision-by-decision counts for one threshold evaluation.
|
|
20
|
+
*/
|
|
21
|
+
export type TrainingDecisionMatrix = Readonly<
|
|
22
|
+
Record<Decision, Readonly<Record<Decision, number>>>
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Aggregate quality and safety metrics for one threshold set.
|
|
27
|
+
*/
|
|
28
|
+
export type TrainingThresholdMetrics = {
|
|
29
|
+
evaluated: number;
|
|
30
|
+
matches: number;
|
|
31
|
+
corrections: number;
|
|
32
|
+
weightedLoss: number;
|
|
33
|
+
matrix: TrainingDecisionMatrix;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One exploratory single-threshold change that improves observed weighted loss.
|
|
38
|
+
*/
|
|
39
|
+
export type TrainingThresholdCandidate = {
|
|
40
|
+
field: keyof Thresholds;
|
|
41
|
+
value: number;
|
|
42
|
+
metrics: TrainingThresholdMetrics;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Offline report derived only from locally persisted judgments and reviews.
|
|
47
|
+
*/
|
|
48
|
+
export type TrainingFeedbackReport = {
|
|
49
|
+
reviewed: number;
|
|
50
|
+
evaluable: number;
|
|
51
|
+
unavailable: number;
|
|
52
|
+
completeReplayRecords: number;
|
|
53
|
+
policyOnlyRecords: number;
|
|
54
|
+
correctionsByReason: Readonly<
|
|
55
|
+
Partial<Record<TrainingCorrectionReason, number>>
|
|
56
|
+
>;
|
|
57
|
+
current: TrainingThresholdMetrics;
|
|
58
|
+
candidates: ReadonlyArray<TrainingThresholdCandidate>;
|
|
59
|
+
warning: string | undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Adapter used to run the current TypeSafe questions against captured state.
|
|
64
|
+
*/
|
|
65
|
+
export type TrainingReplayJudge = (
|
|
66
|
+
state: RenderedCommandState,
|
|
67
|
+
) => Promise<JudgeResult>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One reviewed record's live question-replay result.
|
|
71
|
+
*/
|
|
72
|
+
export type TrainingQuestionReplaySample = {
|
|
73
|
+
recordId: string;
|
|
74
|
+
expectedDecision: Decision;
|
|
75
|
+
originalDecision: Decision;
|
|
76
|
+
replayedDecision: Decision | undefined;
|
|
77
|
+
judgments: Judgments | undefined;
|
|
78
|
+
failure: string | undefined;
|
|
79
|
+
inputTokens: number | undefined;
|
|
80
|
+
outputTokens: number | undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Aggregate comparison of the current question set with reviewed outcomes.
|
|
85
|
+
*/
|
|
86
|
+
export type TrainingQuestionReplayReport = {
|
|
87
|
+
replayable: number;
|
|
88
|
+
unavailable: number;
|
|
89
|
+
skipped: number;
|
|
90
|
+
improved: number;
|
|
91
|
+
regressed: number;
|
|
92
|
+
metrics: TrainingThresholdMetrics;
|
|
93
|
+
inputTokens: number;
|
|
94
|
+
outputTokens: number;
|
|
95
|
+
samples: ReadonlyArray<TrainingQuestionReplaySample>;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const MIN_RECOMMENDATION_SAMPLE = 20;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Evaluate reviewed training records and search safe, one-field threshold alternatives.
|
|
102
|
+
*
|
|
103
|
+
* Candidate values are advisory only. They are selected against the same records
|
|
104
|
+
* used to score them and must be validated on an independent holdout before any
|
|
105
|
+
* policy change is promoted.
|
|
106
|
+
*
|
|
107
|
+
* @param entries - Training records paired with complete append-only review history
|
|
108
|
+
* @param thresholds - Current policy thresholds used as the comparison baseline
|
|
109
|
+
* @returns Current metrics, correction clusters, and exploratory candidates
|
|
110
|
+
*/
|
|
111
|
+
export function analyzeTrainingFeedback(
|
|
112
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
113
|
+
thresholds: Thresholds = THRESHOLDS,
|
|
114
|
+
): TrainingFeedbackReport {
|
|
115
|
+
const reviewed = entries.filter((entry) =>
|
|
116
|
+
getLatestTrainingReview(entry) !== undefined
|
|
117
|
+
);
|
|
118
|
+
const evaluable = reviewed.filter((entry) =>
|
|
119
|
+
entry.record.verdict.judgments !== undefined
|
|
120
|
+
);
|
|
121
|
+
const completeReplayRecords = evaluable.filter(hasCompleteReplayEvidence)
|
|
122
|
+
.length;
|
|
123
|
+
const current = evaluateThresholds(evaluable, thresholds);
|
|
124
|
+
const correctionsByReason = countCorrectionReasons(reviewed);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
reviewed: reviewed.length,
|
|
128
|
+
evaluable: evaluable.length,
|
|
129
|
+
unavailable: reviewed.length - evaluable.length,
|
|
130
|
+
completeReplayRecords,
|
|
131
|
+
policyOnlyRecords: evaluable.length - completeReplayRecords,
|
|
132
|
+
correctionsByReason,
|
|
133
|
+
current,
|
|
134
|
+
candidates: findImprovingCandidates(evaluable, thresholds, current),
|
|
135
|
+
warning: evaluable.length < MIN_RECOMMENDATION_SAMPLE
|
|
136
|
+
? `Only ${evaluable.length} reviewed records have judgments; collect at least ${MIN_RECOMMENDATION_SAMPLE} before treating threshold candidates as meaningful.`
|
|
137
|
+
: undefined,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Score one threshold set against the latest human answer for each record.
|
|
143
|
+
*
|
|
144
|
+
* @param entries - Reviewed entries with usable raw judgments
|
|
145
|
+
* @param thresholds - Candidate policy thresholds
|
|
146
|
+
* @returns Decision matrix and asymmetrically weighted correction loss
|
|
147
|
+
*/
|
|
148
|
+
export function evaluateThresholds(
|
|
149
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
150
|
+
thresholds: Thresholds,
|
|
151
|
+
): TrainingThresholdMetrics {
|
|
152
|
+
const matrix = emptyDecisionMatrix();
|
|
153
|
+
let evaluated = 0;
|
|
154
|
+
let matches = 0;
|
|
155
|
+
let weightedLoss = 0;
|
|
156
|
+
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
const review = getLatestTrainingReview(entry);
|
|
159
|
+
const judgments = entry.record.verdict.judgments;
|
|
160
|
+
if (review === undefined || judgments === undefined) continue;
|
|
161
|
+
|
|
162
|
+
const actual = replayDecision(entry.record, thresholds);
|
|
163
|
+
const expected = review.expectedDecision;
|
|
164
|
+
matrix[expected][actual] += 1;
|
|
165
|
+
evaluated += 1;
|
|
166
|
+
if (actual === expected) matches += 1;
|
|
167
|
+
weightedLoss += decisionLoss(expected, actual);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
evaluated,
|
|
172
|
+
matches,
|
|
173
|
+
corrections: evaluated - matches,
|
|
174
|
+
weightedLoss,
|
|
175
|
+
matrix,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Re-run the current TypeSafe question set against exact captured state.
|
|
181
|
+
*
|
|
182
|
+
* Candidate commands remain JSON state and are never executed. The caller is
|
|
183
|
+
* responsible for making the cost-bearing TypeSafe request explicit to users.
|
|
184
|
+
*
|
|
185
|
+
* @param entries - Reviewed training history
|
|
186
|
+
* @param judge - Live judgment adapter for exact rendered state
|
|
187
|
+
* @param limit - Maximum cost-bearing TypeSafe requests in this replay
|
|
188
|
+
* @param thresholds - Policy thresholds applied to replayed raw judgments
|
|
189
|
+
* @returns Per-record evidence and aggregate improvement/regression metrics
|
|
190
|
+
*/
|
|
191
|
+
export async function replayTrainingQuestions(
|
|
192
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
193
|
+
judge: TrainingReplayJudge,
|
|
194
|
+
limit = 20,
|
|
195
|
+
thresholds: Thresholds = THRESHOLDS,
|
|
196
|
+
): Promise<TrainingQuestionReplayReport> {
|
|
197
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
198
|
+
throw new RangeError("training replay limit must be a positive integer");
|
|
199
|
+
}
|
|
200
|
+
const samples: Array<TrainingQuestionReplaySample> = [];
|
|
201
|
+
const matrix = emptyDecisionMatrix();
|
|
202
|
+
let unavailable = 0;
|
|
203
|
+
let skipped = 0;
|
|
204
|
+
let attempted = 0;
|
|
205
|
+
let improved = 0;
|
|
206
|
+
let regressed = 0;
|
|
207
|
+
let matches = 0;
|
|
208
|
+
let weightedLoss = 0;
|
|
209
|
+
let inputTokens = 0;
|
|
210
|
+
let outputTokens = 0;
|
|
211
|
+
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const review = getLatestTrainingReview(entry);
|
|
214
|
+
if (review === undefined) continue;
|
|
215
|
+
if (entry.record.version !== 2 || entry.record.evidence === undefined) {
|
|
216
|
+
unavailable += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (attempted >= limit) {
|
|
220
|
+
skipped += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
attempted += 1;
|
|
225
|
+
const result = await judge(entry.record.evidence.modelState);
|
|
226
|
+
if (!result.ok) {
|
|
227
|
+
unavailable += 1;
|
|
228
|
+
samples.push({
|
|
229
|
+
recordId: entry.record.id,
|
|
230
|
+
expectedDecision: review.expectedDecision,
|
|
231
|
+
originalDecision: entry.record.verdict.decision,
|
|
232
|
+
replayedDecision: undefined,
|
|
233
|
+
judgments: undefined,
|
|
234
|
+
failure: `${result.failure}: ${result.detail}`,
|
|
235
|
+
inputTokens: undefined,
|
|
236
|
+
outputTokens: undefined,
|
|
237
|
+
});
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const base = decide(result.judgments, thresholds);
|
|
242
|
+
const replayedDecision = applyStaticGate(
|
|
243
|
+
base,
|
|
244
|
+
entry.record.evidence.analysis,
|
|
245
|
+
result.judgments,
|
|
246
|
+
thresholds,
|
|
247
|
+
).decision;
|
|
248
|
+
const expected = review.expectedDecision;
|
|
249
|
+
const originalMatched = entry.record.verdict.decision === expected;
|
|
250
|
+
const replayMatched = replayedDecision === expected;
|
|
251
|
+
if (!originalMatched && replayMatched) improved += 1;
|
|
252
|
+
if (originalMatched && !replayMatched) regressed += 1;
|
|
253
|
+
if (replayMatched) matches += 1;
|
|
254
|
+
weightedLoss += decisionLoss(expected, replayedDecision);
|
|
255
|
+
matrix[expected][replayedDecision] += 1;
|
|
256
|
+
inputTokens += result.usage.inputTokens;
|
|
257
|
+
outputTokens += result.usage.outputTokens;
|
|
258
|
+
samples.push({
|
|
259
|
+
recordId: entry.record.id,
|
|
260
|
+
expectedDecision: expected,
|
|
261
|
+
originalDecision: entry.record.verdict.decision,
|
|
262
|
+
replayedDecision,
|
|
263
|
+
judgments: result.judgments,
|
|
264
|
+
failure: undefined,
|
|
265
|
+
inputTokens: result.usage.inputTokens,
|
|
266
|
+
outputTokens: result.usage.outputTokens,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const evaluated = samples.filter((sample) =>
|
|
271
|
+
sample.replayedDecision !== undefined
|
|
272
|
+
).length;
|
|
273
|
+
return {
|
|
274
|
+
replayable: evaluated,
|
|
275
|
+
unavailable,
|
|
276
|
+
skipped,
|
|
277
|
+
improved,
|
|
278
|
+
regressed,
|
|
279
|
+
metrics: {
|
|
280
|
+
evaluated,
|
|
281
|
+
matches,
|
|
282
|
+
corrections: evaluated - matches,
|
|
283
|
+
weightedLoss,
|
|
284
|
+
matrix,
|
|
285
|
+
},
|
|
286
|
+
inputTokens,
|
|
287
|
+
outputTokens,
|
|
288
|
+
samples,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function replayDecision(
|
|
293
|
+
record: TrainingRecord,
|
|
294
|
+
thresholds: Thresholds,
|
|
295
|
+
): Decision {
|
|
296
|
+
const judgments = record.verdict.judgments;
|
|
297
|
+
if (judgments === undefined) return record.verdict.decision;
|
|
298
|
+
|
|
299
|
+
const outcome = decide(judgments, thresholds);
|
|
300
|
+
if (record.version === 1 || record.evidence?.analysis === undefined) {
|
|
301
|
+
return outcome.decision;
|
|
302
|
+
}
|
|
303
|
+
return applyStaticGate(
|
|
304
|
+
outcome,
|
|
305
|
+
record.evidence.analysis,
|
|
306
|
+
judgments,
|
|
307
|
+
thresholds,
|
|
308
|
+
).decision;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function hasCompleteReplayEvidence(entry: TrainingReviewEntry): boolean {
|
|
312
|
+
return entry.record.version === 2 &&
|
|
313
|
+
entry.record.evidence?.analysis !== undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function countCorrectionReasons(
|
|
317
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
318
|
+
): Partial<Record<TrainingCorrectionReason, number>> {
|
|
319
|
+
const counts: Partial<Record<TrainingCorrectionReason, number>> = {};
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
const review = getLatestTrainingReview(entry);
|
|
322
|
+
if (
|
|
323
|
+
review === undefined ||
|
|
324
|
+
review.expectedDecision === entry.record.verdict.decision
|
|
325
|
+
) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const reason = getTrainingCorrectionReason(review);
|
|
329
|
+
if (reason !== undefined) counts[reason] = (counts[reason] ?? 0) + 1;
|
|
330
|
+
}
|
|
331
|
+
return counts;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function findImprovingCandidates(
|
|
335
|
+
entries: ReadonlyArray<TrainingReviewEntry>,
|
|
336
|
+
thresholds: Thresholds,
|
|
337
|
+
current: TrainingThresholdMetrics,
|
|
338
|
+
): ReadonlyArray<TrainingThresholdCandidate> {
|
|
339
|
+
if (entries.length === 0) return [];
|
|
340
|
+
|
|
341
|
+
const candidates: Array<TrainingThresholdCandidate> = [];
|
|
342
|
+
for (const [field, values] of thresholdCandidateValues()) {
|
|
343
|
+
for (const value of values) {
|
|
344
|
+
if (value === thresholds[field]) continue;
|
|
345
|
+
const candidateThresholds = { ...thresholds, [field]: value };
|
|
346
|
+
const metrics = evaluateThresholds(entries, candidateThresholds);
|
|
347
|
+
if (metrics.weightedLoss >= current.weightedLoss) continue;
|
|
348
|
+
candidates.push({ field, value, metrics });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return candidates
|
|
353
|
+
.sort((left, right) =>
|
|
354
|
+
left.metrics.weightedLoss - right.metrics.weightedLoss ||
|
|
355
|
+
left.metrics.corrections - right.metrics.corrections ||
|
|
356
|
+
left.field.localeCompare(right.field) ||
|
|
357
|
+
left.value - right.value
|
|
358
|
+
)
|
|
359
|
+
.slice(0, 5);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function thresholdCandidateValues(): ReadonlyArray<
|
|
363
|
+
readonly [keyof Thresholds, ReadonlyArray<number>]
|
|
364
|
+
> {
|
|
365
|
+
const probabilities = numericRange(0.05, 0.95, 0.05);
|
|
366
|
+
return [
|
|
367
|
+
["executesDestruction", probabilities],
|
|
368
|
+
["denySensitiveDataExposure", probabilities],
|
|
369
|
+
["denySecurityBoundaryWeakening", probabilities],
|
|
370
|
+
["denyUnrecoverable", probabilities],
|
|
371
|
+
["denySharedInfrastructure", probabilities],
|
|
372
|
+
["denyBlastRadius", numericRange(0.5, 3, 0.1)],
|
|
373
|
+
["minBlastRadiusConfidence", probabilities],
|
|
374
|
+
["uncertaintyBand", numericRange(0, 0.15, 0.01)],
|
|
375
|
+
["uncertaintyBandScore", numericRange(0, 0.5, 0.05)],
|
|
376
|
+
["staticGateMinExecution", probabilities],
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function numericRange(
|
|
381
|
+
start: number,
|
|
382
|
+
end: number,
|
|
383
|
+
step: number,
|
|
384
|
+
): ReadonlyArray<number> {
|
|
385
|
+
const values: Array<number> = [];
|
|
386
|
+
for (let value = start; value <= end + step / 2; value += step) {
|
|
387
|
+
values.push(Number(value.toFixed(4)));
|
|
388
|
+
}
|
|
389
|
+
return values;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function emptyDecisionMatrix(): Record<
|
|
393
|
+
Decision,
|
|
394
|
+
Record<Decision, number>
|
|
395
|
+
> {
|
|
396
|
+
return {
|
|
397
|
+
allow: { allow: 0, ask: 0, deny: 0 },
|
|
398
|
+
ask: { allow: 0, ask: 0, deny: 0 },
|
|
399
|
+
deny: { allow: 0, ask: 0, deny: 0 },
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function decisionLoss(expected: Decision, actual: Decision): number {
|
|
404
|
+
if (expected === actual) return 0;
|
|
405
|
+
if (expected === "deny") return actual === "allow" ? 20 : 4;
|
|
406
|
+
if (expected === "ask") return actual === "allow" ? 5 : 2;
|
|
407
|
+
return actual === "deny" ? 3 : 1;
|
|
408
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
TrainingCorrectionReason,
|
|
2
3
|
TrainingRecord,
|
|
3
4
|
TrainingReview,
|
|
4
5
|
TrainingReviewInput,
|
|
@@ -114,17 +115,31 @@ export function filterTrainingReviewEntries(
|
|
|
114
115
|
.map((candidate) => candidate.entry);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Return the structured reason attached to a versioned review.
|
|
120
|
+
*
|
|
121
|
+
* @param review - Append-only human review revision
|
|
122
|
+
* @returns Structured reason, or undefined for accepted and legacy reviews
|
|
123
|
+
*/
|
|
124
|
+
export function getTrainingCorrectionReason(
|
|
125
|
+
review: TrainingReview,
|
|
126
|
+
): TrainingCorrectionReason | undefined {
|
|
127
|
+
return review.version === 2 ? review.correctionReason : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
117
130
|
/**
|
|
118
131
|
* Build an append-only review revision from a human decision.
|
|
119
132
|
*
|
|
120
133
|
* @param record - Training evidence being reviewed
|
|
121
134
|
* @param expectedDecision - Human-selected expected outcome
|
|
122
|
-
* @param
|
|
135
|
+
* @param correctionReason - Structured reason required for corrected decisions
|
|
136
|
+
* @param note - Optional explanation for the human decision
|
|
123
137
|
* @returns Normalized review input for persistence
|
|
124
138
|
*/
|
|
125
139
|
export function createTrainingReviewInput(
|
|
126
140
|
record: TrainingRecord,
|
|
127
141
|
expectedDecision: Decision,
|
|
142
|
+
correctionReason: TrainingCorrectionReason | undefined,
|
|
128
143
|
note: string | undefined,
|
|
129
144
|
): TrainingReviewInput {
|
|
130
145
|
const corrected = expectedDecision !== record.verdict.decision;
|
|
@@ -132,7 +147,8 @@ export function createTrainingReviewInput(
|
|
|
132
147
|
recordId: record.id,
|
|
133
148
|
originalDecision: record.verdict.decision,
|
|
134
149
|
expectedDecision,
|
|
135
|
-
|
|
150
|
+
correctionReason: corrected ? correctionReason : undefined,
|
|
151
|
+
note: note?.trim() || undefined,
|
|
136
152
|
};
|
|
137
153
|
}
|
|
138
154
|
|