sandoichi 0.5.0 → 0.6.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/index.mjs +13 -1
- package/package.json +1 -1
- package/src/context-transform.mjs +40 -0
- package/src/proxy.mjs +71 -2
- package/src/semantic-gate.mjs +69 -0
- package/src/semantic-judge.mjs +261 -0
package/index.mjs
CHANGED
|
@@ -7,7 +7,13 @@ export {
|
|
|
7
7
|
} from './src/core.mjs';
|
|
8
8
|
export { createRedactionProfile } from './src/redaction-profile.mjs';
|
|
9
9
|
export { loadProjectRedactionProfile } from './src/redaction-config.mjs';
|
|
10
|
-
export {
|
|
10
|
+
export {
|
|
11
|
+
detectProviderBody,
|
|
12
|
+
listSemanticCandidates,
|
|
13
|
+
listSemanticJudgmentCandidates,
|
|
14
|
+
restoreSemanticJudgmentCandidates,
|
|
15
|
+
transformProviderRequest,
|
|
16
|
+
} from './src/context-transform.mjs';
|
|
11
17
|
export {
|
|
12
18
|
DEFAULT_ACCOUNTING_WEIGHTS,
|
|
13
19
|
PAIRED_ARMS,
|
|
@@ -24,6 +30,12 @@ export {
|
|
|
24
30
|
SEMANTIC_SUMMARY_SCHEMA,
|
|
25
31
|
validateSemanticSummary,
|
|
26
32
|
} from './src/semantic-compactor.mjs';
|
|
33
|
+
export {
|
|
34
|
+
buildSemanticJudgeRequest,
|
|
35
|
+
createSemanticJudge,
|
|
36
|
+
SEMANTIC_JUDGMENT_SCHEMA,
|
|
37
|
+
} from './src/semantic-judge.mjs';
|
|
38
|
+
export { createSemanticGate } from './src/semantic-gate.mjs';
|
|
27
39
|
export { createProviderProxy } from './src/proxy.mjs';
|
|
28
40
|
export { shakeHistoricalResult } from './src/history-shake.mjs';
|
|
29
41
|
export {
|
package/package.json
CHANGED
|
@@ -320,6 +320,46 @@ export function listSemanticCandidates({ provider, body, model } = {}) {
|
|
|
320
320
|
}));
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
export function listSemanticJudgmentCandidates({ provider, originalBody, transformedBody, model } = {}) {
|
|
324
|
+
const originalRecords = collectHistoryRecords(provider, originalBody);
|
|
325
|
+
const originalById = new Map(originalRecords.map((record) => [record.id, record]));
|
|
326
|
+
return collectHistoryRecords(provider, transformedBody)
|
|
327
|
+
.filter((record) => record.safe && record.historical)
|
|
328
|
+
.flatMap((preview) => {
|
|
329
|
+
const original = originalById.get(preview.id);
|
|
330
|
+
const originalText = resultText(original?.output);
|
|
331
|
+
const previewText = resultText(preview.output);
|
|
332
|
+
if (!original || originalText === null || previewText === null || originalText === previewText) return [];
|
|
333
|
+
return [{
|
|
334
|
+
id: preview.id,
|
|
335
|
+
provider,
|
|
336
|
+
model: model ?? null,
|
|
337
|
+
toolName: preview.toolName,
|
|
338
|
+
originalText,
|
|
339
|
+
previewText,
|
|
340
|
+
historical: preview.historical,
|
|
341
|
+
isError: preview.isError,
|
|
342
|
+
recoverable: historyArchiveMarker(previewText),
|
|
343
|
+
estimatedTokens: original.estimatedTokens,
|
|
344
|
+
previewTokens: preview.estimatedTokens,
|
|
345
|
+
}];
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function restoreSemanticJudgmentCandidates({ provider, originalBody, transformedBody, ids = [] } = {}) {
|
|
350
|
+
const restoreIds = new Set(ids);
|
|
351
|
+
const body = structuredClone(transformedBody);
|
|
352
|
+
if (restoreIds.size === 0) return body;
|
|
353
|
+
const originalById = new Map(collectHistoryRecords(provider, originalBody).map((record) => [record.id, record]));
|
|
354
|
+
for (const preview of collectHistoryRecords(provider, body)) {
|
|
355
|
+
if (!restoreIds.has(preview.id) || !preview.historical || !preview.safe) continue;
|
|
356
|
+
const original = originalById.get(preview.id);
|
|
357
|
+
if (!original?.safe || resultText(original.output) === null) continue;
|
|
358
|
+
replaceResult(preview.entry.item, preview.entry.key, structuredClone(original.output));
|
|
359
|
+
}
|
|
360
|
+
return body;
|
|
361
|
+
}
|
|
362
|
+
|
|
323
363
|
function carriesBreakpoint(value) {
|
|
324
364
|
if (Array.isArray(value)) return value.some(carriesBreakpoint);
|
|
325
365
|
if (!object(value)) return false;
|
package/src/proxy.mjs
CHANGED
|
@@ -6,7 +6,12 @@ import { brotliDecompressSync, gunzipSync, inflateSync, zstdDecompressSync } fro
|
|
|
6
6
|
|
|
7
7
|
import { estimateTokens } from './core.mjs';
|
|
8
8
|
import { buildContextCaptureRecord, recordContextCapture } from './context-capture.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
detectProviderBody,
|
|
11
|
+
listSemanticCandidates,
|
|
12
|
+
listSemanticJudgmentCandidates,
|
|
13
|
+
transformProviderRequest,
|
|
14
|
+
} from './context-transform.mjs';
|
|
10
15
|
import { publishF1Telemetry } from './f1-telemetry.mjs';
|
|
11
16
|
import { recordProxyRequest } from './proxy-metrics.mjs';
|
|
12
17
|
import {
|
|
@@ -407,9 +412,61 @@ async function observeSemanticCandidates({ provider, candidates, semanticCompact
|
|
|
407
412
|
}
|
|
408
413
|
}
|
|
409
414
|
|
|
415
|
+
function createSemanticJudgeStats(candidates) {
|
|
416
|
+
return {
|
|
417
|
+
candidates: candidates.length,
|
|
418
|
+
attempted: candidates.length,
|
|
419
|
+
judged: 0,
|
|
420
|
+
losses: 0,
|
|
421
|
+
preserved: 0,
|
|
422
|
+
fallbacks: 0,
|
|
423
|
+
skipped: 0,
|
|
424
|
+
pending: candidates.length,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function createPendingSemanticJudgeStats() {
|
|
429
|
+
const stats = createSemanticJudgeStats([]);
|
|
430
|
+
stats.pending = 1;
|
|
431
|
+
return stats;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function scheduleSemanticJudgments({ provider, originalBody, transformedBody, model, semanticJudge, stats }) {
|
|
435
|
+
setImmediate(() => {
|
|
436
|
+
let candidates;
|
|
437
|
+
try {
|
|
438
|
+
candidates = listSemanticJudgmentCandidates({ provider, originalBody, transformedBody, model });
|
|
439
|
+
} catch {
|
|
440
|
+
stats.fallbacks += 1;
|
|
441
|
+
stats.pending = 0;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
Object.assign(stats, createSemanticJudgeStats(candidates));
|
|
445
|
+
void observeSemanticJudgments({ candidates, semanticJudge, stats });
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function observeSemanticJudgments({ candidates, semanticJudge, stats }) {
|
|
450
|
+
for (const candidate of candidates) {
|
|
451
|
+
try {
|
|
452
|
+
const result = await semanticJudge(candidate);
|
|
453
|
+
if (result?.status === 'judged') {
|
|
454
|
+
stats.judged += 1;
|
|
455
|
+
if (result.verdict === 'loss') stats.losses += 1;
|
|
456
|
+
else if (result.verdict === 'preserved') stats.preserved += 1;
|
|
457
|
+
} else if (result?.status === 'skipped') stats.skipped += 1;
|
|
458
|
+
else stats.fallbacks += 1;
|
|
459
|
+
} catch {
|
|
460
|
+
stats.fallbacks += 1;
|
|
461
|
+
} finally {
|
|
462
|
+
stats.pending -= 1;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
410
467
|
export async function createProviderProxy({
|
|
411
468
|
upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
412
|
-
semanticCompactor, metricsPath, contextCapturePath, contextCaptureHost, contextSessionKey,
|
|
469
|
+
semanticCompactor, semanticJudge, metricsPath, contextCapturePath, contextCaptureHost, contextSessionKey,
|
|
413
470
|
f1TelemetryPublisher = publishF1Telemetry, transformProviderRequests = false,
|
|
414
471
|
historyArchiveRoot,
|
|
415
472
|
env = process.env,
|
|
@@ -521,6 +578,18 @@ export async function createProviderProxy({
|
|
|
521
578
|
lastStats.semantic = stats;
|
|
522
579
|
setImmediate(() => observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }));
|
|
523
580
|
}
|
|
581
|
+
if (typeof semanticJudge === 'function' && transformed.changed) {
|
|
582
|
+
const stats = createPendingSemanticJudgeStats();
|
|
583
|
+
lastStats.semanticJudge = stats;
|
|
584
|
+
scheduleSemanticJudgments({
|
|
585
|
+
provider,
|
|
586
|
+
originalBody: parsed,
|
|
587
|
+
transformedBody: transformed.body,
|
|
588
|
+
model: recordModel,
|
|
589
|
+
semanticJudge,
|
|
590
|
+
stats,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
524
593
|
}
|
|
525
594
|
} catch {
|
|
526
595
|
recordProxyFailure({ env, provider, failureStage: 'optimization' });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listSemanticJudgmentCandidates,
|
|
3
|
+
restoreSemanticJudgmentCandidates,
|
|
4
|
+
} from './context-transform.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_LOSS_THRESHOLD = 0.7;
|
|
7
|
+
|
|
8
|
+
function lossThreshold(value) {
|
|
9
|
+
if (value === undefined) return DEFAULT_LOSS_THRESHOLD;
|
|
10
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
11
|
+
throw new TypeError('lossThreshold must be a number between 0 and 1');
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createSemanticGate({ judge, lossThreshold: threshold } = {}) {
|
|
17
|
+
if (typeof judge !== 'function') throw new TypeError('judge must be a function');
|
|
18
|
+
const selectedThreshold = lossThreshold(threshold);
|
|
19
|
+
|
|
20
|
+
return async function gate({ provider, originalBody, transformedBody, model } = {}) {
|
|
21
|
+
const candidates = listSemanticJudgmentCandidates({
|
|
22
|
+
provider, originalBody, transformedBody, model,
|
|
23
|
+
});
|
|
24
|
+
const restoreIds = [];
|
|
25
|
+
const stats = {
|
|
26
|
+
candidates: candidates.length,
|
|
27
|
+
judged: 0,
|
|
28
|
+
losses: 0,
|
|
29
|
+
preserved: 0,
|
|
30
|
+
restored: 0,
|
|
31
|
+
fallbacks: 0,
|
|
32
|
+
skipped: 0,
|
|
33
|
+
lossThreshold: selectedThreshold,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
let result;
|
|
38
|
+
try {
|
|
39
|
+
result = await judge(candidate);
|
|
40
|
+
} catch {
|
|
41
|
+
stats.fallbacks += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (result?.status === 'judged') {
|
|
45
|
+
stats.judged += 1;
|
|
46
|
+
if (result.verdict === 'loss') {
|
|
47
|
+
stats.losses += 1;
|
|
48
|
+
if (Number.isFinite(result.lossProbability) && result.lossProbability >= selectedThreshold) {
|
|
49
|
+
restoreIds.push(candidate.id);
|
|
50
|
+
}
|
|
51
|
+
} else if (result.verdict === 'preserved') {
|
|
52
|
+
stats.preserved += 1;
|
|
53
|
+
} else {
|
|
54
|
+
stats.fallbacks += 1;
|
|
55
|
+
}
|
|
56
|
+
} else if (result?.status === 'skipped') {
|
|
57
|
+
stats.skipped += 1;
|
|
58
|
+
} else {
|
|
59
|
+
stats.fallbacks += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const body = restoreSemanticJudgmentCandidates({
|
|
64
|
+
provider, originalBody, transformedBody, ids: restoreIds,
|
|
65
|
+
});
|
|
66
|
+
stats.restored = restoreIds.length;
|
|
67
|
+
return { body, stats };
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { estimateTokens } from './core.mjs';
|
|
4
|
+
import { redact } from './secret-redaction.mjs';
|
|
5
|
+
|
|
6
|
+
export const SEMANTIC_JUDGMENT_SCHEMA = 'sando-semantic-judgment/v1';
|
|
7
|
+
const QUESTION_ID = 'preview_loses_diagnostic_evidence';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_POLICY = Object.freeze({
|
|
10
|
+
minInputTokens: 8000,
|
|
11
|
+
maxTextChars: 6000,
|
|
12
|
+
timeoutMs: 1500,
|
|
13
|
+
maxRequests: 20,
|
|
14
|
+
lossThreshold: 0.7,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function object(value) {
|
|
18
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sha256(value) {
|
|
22
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validatePolicy(policy) {
|
|
26
|
+
const result = { ...DEFAULT_POLICY, ...(policy ?? {}) };
|
|
27
|
+
if (!Number.isInteger(result.minInputTokens) || result.minInputTokens < 1
|
|
28
|
+
|| !Number.isInteger(result.maxTextChars) || result.maxTextChars < 64
|
|
29
|
+
|| !Number.isInteger(result.timeoutMs) || result.timeoutMs < 1
|
|
30
|
+
|| !Number.isInteger(result.maxRequests) || result.maxRequests < 1
|
|
31
|
+
|| !Number.isFinite(result.lossThreshold) || result.lossThreshold < 0 || result.lossThreshold > 1) {
|
|
32
|
+
throw new TypeError('invalid semantic judge policy');
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function boundedText(text, maxChars) {
|
|
38
|
+
if (text.length <= maxChars) return { text, sampled: false };
|
|
39
|
+
const marker = '\n[sando semantic judge middle elided]\n';
|
|
40
|
+
if (maxChars <= marker.length) return { text: text.slice(0, maxChars), sampled: true };
|
|
41
|
+
const available = maxChars - marker.length;
|
|
42
|
+
const headChars = Math.ceil(available / 2);
|
|
43
|
+
return {
|
|
44
|
+
text: `${text.slice(0, headChars)}${marker}${text.slice(-available + headChars)}`,
|
|
45
|
+
sampled: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function redactedBounded(text, maxChars, redactionProfile) {
|
|
50
|
+
const safe = redactionProfile ? redactionProfile.redact(text) : redact(text);
|
|
51
|
+
const bounded = boundedText(safe.text, maxChars);
|
|
52
|
+
return { ...bounded, count: safe.count };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function prepareSemanticJudgeRequest(options) {
|
|
56
|
+
const {
|
|
57
|
+
originalText, previewText, maxTextChars = DEFAULT_POLICY.maxTextChars, redactionProfile,
|
|
58
|
+
} = options;
|
|
59
|
+
requireText(originalText, 'originalText');
|
|
60
|
+
requireText(previewText, 'previewText');
|
|
61
|
+
if (!Number.isInteger(maxTextChars) || maxTextChars < 64) throw new TypeError('maxTextChars is invalid');
|
|
62
|
+
if (typeof options.recoverable !== 'boolean') throw new TypeError('recoverable must be a boolean');
|
|
63
|
+
|
|
64
|
+
const original = redactedBounded(originalText, maxTextChars, redactionProfile);
|
|
65
|
+
const preview = redactedBounded(previewText, maxTextChars, redactionProfile);
|
|
66
|
+
return {
|
|
67
|
+
request: {
|
|
68
|
+
state: {
|
|
69
|
+
provider: typeof options.provider === 'string' ? options.provider : 'unknown',
|
|
70
|
+
model: typeof options.model === 'string' ? options.model : 'unknown',
|
|
71
|
+
tool: typeof options.toolName === 'string' ? options.toolName : 'unknown',
|
|
72
|
+
original: original.text,
|
|
73
|
+
preview: preview.text,
|
|
74
|
+
originalSampled: original.sampled,
|
|
75
|
+
previewSampled: preview.sampled,
|
|
76
|
+
recoverable: options.recoverable,
|
|
77
|
+
},
|
|
78
|
+
questions: {
|
|
79
|
+
[QUESTION_ID]: {
|
|
80
|
+
type: 'noul',
|
|
81
|
+
instructions: 'Does `preview` omit diagnostic information from `original` that a coding agent would need to diagnose the result? If `recoverable` is true, treat intentionally omitted information as available through the Sando recovery artifact.',
|
|
82
|
+
criteria: 'Answer yes only for task-relevant diagnostic evidence that is absent from the preview and not recoverable; answer no when the preview preserves the evidence or the omission is recoverable.',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
redactions: original.count + preview.count,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireText(value, name) {
|
|
91
|
+
if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildSemanticJudgeRequest({
|
|
95
|
+
provider, model, toolName, originalText, previewText, recoverable = false,
|
|
96
|
+
maxTextChars = DEFAULT_POLICY.maxTextChars, redactionProfile,
|
|
97
|
+
} = {}) {
|
|
98
|
+
return prepareSemanticJudgeRequest({
|
|
99
|
+
provider, model, toolName, originalText, previewText, recoverable, maxTextChars, redactionProfile,
|
|
100
|
+
}).request;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cacheKey(candidate, request) {
|
|
104
|
+
return sha256(JSON.stringify({
|
|
105
|
+
schema: SEMANTIC_JUDGMENT_SCHEMA,
|
|
106
|
+
id: candidate.id ?? null,
|
|
107
|
+
provider: candidate.provider ?? null,
|
|
108
|
+
model: candidate.model ?? null,
|
|
109
|
+
toolName: candidate.toolName ?? null,
|
|
110
|
+
recoverable: candidate.recoverable === true,
|
|
111
|
+
state: request.state,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function probability(value) {
|
|
116
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function usageSummary(value) {
|
|
120
|
+
if (!object(value)) return null;
|
|
121
|
+
const result = {};
|
|
122
|
+
const fields = {
|
|
123
|
+
inputTokens: ['inputTokens', 'input_tokens'],
|
|
124
|
+
outputTokens: ['outputTokens', 'output_tokens'],
|
|
125
|
+
};
|
|
126
|
+
for (const [target, keys] of Object.entries(fields)) {
|
|
127
|
+
const tokenValue = keys.map((key) => value[key]).find(
|
|
128
|
+
(item) => Number.isSafeInteger(item) && item >= 0,
|
|
129
|
+
);
|
|
130
|
+
if (tokenValue !== undefined) result[target] = tokenValue;
|
|
131
|
+
}
|
|
132
|
+
return Object.keys(result).length ? result : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resultBase(candidate, inputTokens, previewTokens) {
|
|
136
|
+
return {
|
|
137
|
+
schema: SEMANTIC_JUDGMENT_SCHEMA,
|
|
138
|
+
mode: 'shadow',
|
|
139
|
+
status: 'fallback',
|
|
140
|
+
id: typeof candidate.id === 'string' ? candidate.id : null,
|
|
141
|
+
inputTokens,
|
|
142
|
+
previewTokens,
|
|
143
|
+
omittedTokens: Math.max(0, inputTokens - previewTokens),
|
|
144
|
+
lossProbability: null,
|
|
145
|
+
cacheHit: false,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validCandidate(candidate) {
|
|
150
|
+
return candidate && typeof candidate === 'object' && !Array.isArray(candidate);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function evaluateWithTimeout(evaluate, request, timeoutMs) {
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
let timer;
|
|
156
|
+
const timeout = new Promise((_, reject) => {
|
|
157
|
+
timer = setTimeout(() => {
|
|
158
|
+
controller.abort();
|
|
159
|
+
const error = new Error('semantic judge timeout');
|
|
160
|
+
error.code = 'SEMANTIC_JUDGE_TIMEOUT';
|
|
161
|
+
reject(error);
|
|
162
|
+
}, timeoutMs);
|
|
163
|
+
});
|
|
164
|
+
const operation = Promise.resolve().then(() => evaluate(request, { signal: controller.signal }));
|
|
165
|
+
try {
|
|
166
|
+
return await Promise.race([operation, timeout]);
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cachedResult(value, lossThreshold) {
|
|
173
|
+
const lossProbability = probability(value?.lossProbability);
|
|
174
|
+
if (lossProbability === null) return null;
|
|
175
|
+
const result = {
|
|
176
|
+
status: 'judged',
|
|
177
|
+
lossProbability,
|
|
178
|
+
verdict: lossProbability >= lossThreshold ? 'loss' : 'preserved',
|
|
179
|
+
cacheHit: true,
|
|
180
|
+
};
|
|
181
|
+
if (Number.isSafeInteger(value?.latencyMs) && value.latencyMs >= 0) result.latencyMs = value.latencyMs;
|
|
182
|
+
if (Number.isSafeInteger(value?.redactions) && value.redactions >= 0) result.redactions = value.redactions;
|
|
183
|
+
const usage = usageSummary(value?.usage);
|
|
184
|
+
if (usage) result.usage = usage;
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function createSemanticJudge({ evaluate, cache = new Map(), policy, redactionProfile } = {}) {
|
|
189
|
+
if (typeof evaluate !== 'function') throw new TypeError('semantic judge evaluate callback is required');
|
|
190
|
+
if (!cache || typeof cache.get !== 'function' || typeof cache.set !== 'function') {
|
|
191
|
+
throw new TypeError('semantic judge cache must implement get and set');
|
|
192
|
+
}
|
|
193
|
+
if (redactionProfile && typeof redactionProfile.redact !== 'function') {
|
|
194
|
+
throw new TypeError('redactionProfile is invalid');
|
|
195
|
+
}
|
|
196
|
+
const options = validatePolicy(policy);
|
|
197
|
+
const pending = new Map();
|
|
198
|
+
let requests = 0;
|
|
199
|
+
|
|
200
|
+
return async function judge(candidate = {}) {
|
|
201
|
+
if (!validCandidate(candidate)) throw new TypeError('semantic judge candidate must be an object');
|
|
202
|
+
requireText(candidate.originalText, 'originalText');
|
|
203
|
+
requireText(candidate.previewText, 'previewText');
|
|
204
|
+
const inputTokens = estimateTokens(candidate.originalText);
|
|
205
|
+
const previewTokens = estimateTokens(candidate.previewText);
|
|
206
|
+
const base = resultBase(candidate, inputTokens, previewTokens);
|
|
207
|
+
if (candidate.historical === false) return { ...base, status: 'skipped', reason: 'current-result' };
|
|
208
|
+
if (candidate.isError === true) return { ...base, status: 'skipped', reason: 'error-result' };
|
|
209
|
+
if (inputTokens < options.minInputTokens) return { ...base, status: 'skipped', reason: 'below-threshold' };
|
|
210
|
+
|
|
211
|
+
const prepared = prepareSemanticJudgeRequest({
|
|
212
|
+
provider: candidate.provider,
|
|
213
|
+
model: candidate.model,
|
|
214
|
+
toolName: candidate.toolName,
|
|
215
|
+
originalText: candidate.originalText,
|
|
216
|
+
previewText: candidate.previewText,
|
|
217
|
+
recoverable: candidate.recoverable === true,
|
|
218
|
+
maxTextChars: options.maxTextChars,
|
|
219
|
+
redactionProfile,
|
|
220
|
+
});
|
|
221
|
+
const request = prepared.request;
|
|
222
|
+
const key = cacheKey(candidate, request);
|
|
223
|
+
let cached;
|
|
224
|
+
try { cached = cachedResult(cache.get(key), options.lossThreshold); } catch { cached = null; }
|
|
225
|
+
if (cached) return { ...base, ...cached };
|
|
226
|
+
if (pending.has(key)) return { ...base, ...(await pending.get(key)), cacheHit: true };
|
|
227
|
+
if (requests >= options.maxRequests) return { ...base, reason: 'budget' };
|
|
228
|
+
|
|
229
|
+
requests += 1;
|
|
230
|
+
const started = Date.now();
|
|
231
|
+
const operation = (async () => {
|
|
232
|
+
let raw;
|
|
233
|
+
try {
|
|
234
|
+
raw = await evaluateWithTimeout(evaluate, request, options.timeoutMs);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return { ...base, reason: error?.code === 'SEMANTIC_JUDGE_TIMEOUT' ? 'timeout' : 'judge-error', latencyMs: Date.now() - started };
|
|
237
|
+
}
|
|
238
|
+
const lossProbability = probability(raw?.answers?.[QUESTION_ID]?.noul);
|
|
239
|
+
if (lossProbability === null) {
|
|
240
|
+
return { ...base, reason: 'invalid-response', latencyMs: Date.now() - started };
|
|
241
|
+
}
|
|
242
|
+
const judged = {
|
|
243
|
+
status: 'judged',
|
|
244
|
+
lossProbability,
|
|
245
|
+
verdict: lossProbability >= options.lossThreshold ? 'loss' : 'preserved',
|
|
246
|
+
cacheHit: false,
|
|
247
|
+
latencyMs: Number.isSafeInteger(raw?.elapsedMs) ? raw.elapsedMs : Date.now() - started,
|
|
248
|
+
usage: usageSummary(raw?.usage),
|
|
249
|
+
redactions: prepared.redactions,
|
|
250
|
+
};
|
|
251
|
+
try { cache.set(key, judged); } catch { /* cache is best-effort */ }
|
|
252
|
+
return { ...base, ...judged };
|
|
253
|
+
})();
|
|
254
|
+
pending.set(key, operation);
|
|
255
|
+
try {
|
|
256
|
+
return await operation;
|
|
257
|
+
} finally {
|
|
258
|
+
pending.delete(key);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|