pi-jev-guard 0.3.1 → 0.3.2
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 +16 -0
- package/config/jev-config.example.json +12 -0
- package/extensions/index.ts +192 -0
- package/package.json +1 -1
- package/src/automatic/guardian.ts +124 -26
- package/src/automatic/overlay.ts +13 -1
- package/src/commands.ts +41 -0
- package/src/config.ts +36 -0
- package/src/grounding.ts +388 -0
package/README.md
CHANGED
|
@@ -159,6 +159,19 @@ avvisa e dice di riferirsi al valore per nome; se è un errore, allega il
|
|
|
159
159
|
consiglio per la classe (`transient` → riprova, `code_bug` → fixa il codice…).
|
|
160
160
|
Non blocca mai; `/jev output` mostra l'ultimo verdetto.
|
|
161
161
|
|
|
162
|
+
Sempre in `automatic`, il **grounding** (opt-in, default spento) confronta le
|
|
163
|
+
affermazioni sulla sessione — "ho letto il file", "i test passano", "ho
|
|
164
|
+
eseguito X" — con un registro evidenza costruito dai `tool_result`
|
|
165
|
+
(comandi, test, letture, modifiche; redatto, max 30 voci, solo sessione
|
|
166
|
+
corrente). Due domande Jev strette: contraddizione con l'evidenza e
|
|
167
|
+
affermazione non supportata. Senza evidenza non si giudica: "senza
|
|
168
|
+
evidenza" non significa "falso". Una modifica riuscita invalida i test
|
|
169
|
+
precedenti (stale). Tre modalità (`/jev grounding off|warn|repair|hold`):
|
|
170
|
+
`warn` pubblica con nota, `repair` rigenera una volta, `hold` trattiene solo
|
|
171
|
+
le contraddizioni ad alta confidenza. Fail-open: se il verificatore non
|
|
172
|
+
risponde, il testo passa senza nota. `/jev last` mostra anche l'esito
|
|
173
|
+
grounding (`warned`/`repaired`/`held`).
|
|
174
|
+
|
|
162
175
|
## Configurazione
|
|
163
176
|
|
|
164
177
|
`~/.pi/agent/jev-config.json` (o `$JEV_CONFIG`). Env: `JEV_MODE`, `JEV_BACKEND`
|
|
@@ -210,3 +223,6 @@ echo '{"requirements":"...","candidate":"..."}' | npm run jev-check
|
|
|
210
223
|
- Soglie calibrate su 20 casi (`docs/calibration-2026-09-19.md`): buone per
|
|
211
224
|
iniziare, da rivalutare con corpus più ampio.
|
|
212
225
|
- Jev è un giudice probabilistico, non un verificatore formale.
|
|
226
|
+
- Il grounding controlla solo affermazioni verificabili sulla sessione
|
|
227
|
+
(comandi, file, test, modifiche): niente fact-check di fatti esterni,
|
|
228
|
+
e con contesti lunghi l'evidenza oltre budget viene elisa.
|
|
@@ -80,5 +80,17 @@
|
|
|
80
80
|
"minConfidence": 0.5,
|
|
81
81
|
"cacheSeconds": 120,
|
|
82
82
|
"blockWithoutUI": false
|
|
83
|
+
},
|
|
84
|
+
"grounding": {
|
|
85
|
+
"enabled": false,
|
|
86
|
+
"mode": "warn",
|
|
87
|
+
"contradictionThreshold": 0.85,
|
|
88
|
+
"unsupportedThreshold": 0.7,
|
|
89
|
+
"maxRepairs": 1,
|
|
90
|
+
"maxEntries": 30,
|
|
91
|
+
"evidenceChars": 2000,
|
|
92
|
+
"excerptChars": 300,
|
|
93
|
+
"cacheSeconds": 120,
|
|
94
|
+
"invalidateTestsOnEdit": true
|
|
83
95
|
}
|
|
84
96
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -51,6 +51,20 @@ import {
|
|
|
51
51
|
lastUserText,
|
|
52
52
|
type ExfilVerdict,
|
|
53
53
|
} from "../src/exfil.ts";
|
|
54
|
+
import {
|
|
55
|
+
buildEvidenceDigest,
|
|
56
|
+
buildGroundingHoldText,
|
|
57
|
+
buildGroundingState,
|
|
58
|
+
decideGroundingAction,
|
|
59
|
+
evaluateGrounding,
|
|
60
|
+
groundingKey,
|
|
61
|
+
recordEvidence,
|
|
62
|
+
recordToolResult,
|
|
63
|
+
GROUNDING_QUESTIONS,
|
|
64
|
+
type EvidenceEntry,
|
|
65
|
+
type GroundingEvaluation,
|
|
66
|
+
} from "../src/grounding.ts";
|
|
67
|
+
import type { PostPublishCheckFn } from "../src/automatic/guardian.ts";
|
|
54
68
|
|
|
55
69
|
export interface GateStatus {
|
|
56
70
|
active: boolean;
|
|
@@ -83,6 +97,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
83
97
|
let typedHandle = createTypedAsk(configSnapshot(config));
|
|
84
98
|
let outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
|
|
85
99
|
let exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
|
|
100
|
+
let groundingCache = createVerdictCache<GroundingEvaluation>({ ttlSeconds: config.grounding.cacheSeconds });
|
|
101
|
+
// Ledger evidenza per sessione: effetti osservati (tool_result), redatti.
|
|
102
|
+
// Resettato a ogni session_start; solo automatic lo alimenta e lo usa.
|
|
103
|
+
let evidenceLedger: EvidenceEntry[] = [];
|
|
104
|
+
let evidenceSeq = 0;
|
|
86
105
|
let lastOutput: { tool: string; verdict: OutputVerdict; at: number } | undefined;
|
|
87
106
|
let lastGate: { report: GateVerdictReport; at: number } | undefined;
|
|
88
107
|
|
|
@@ -91,6 +110,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
91
110
|
}
|
|
92
111
|
let lastOutputErrorAt = 0;
|
|
93
112
|
let lastExfilErrorAt = 0;
|
|
113
|
+
let lastGroundingErrorAt = 0;
|
|
94
114
|
const metrics = createMetrics();
|
|
95
115
|
|
|
96
116
|
// Twin attivi, uno o più per provider. Nessun overlay finché la config non
|
|
@@ -170,6 +190,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
190
|
typedHandle = createTypedAsk(configSnapshot(config));
|
|
171
191
|
outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
|
|
172
192
|
exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
|
|
193
|
+
groundingCache = createVerdictCache<GroundingEvaluation>({ ttlSeconds: config.grounding.cacheSeconds });
|
|
173
194
|
return getConfig();
|
|
174
195
|
}
|
|
175
196
|
|
|
@@ -211,6 +232,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
211
232
|
getConfig,
|
|
212
233
|
getReview: getGateReview,
|
|
213
234
|
onVerdict: recordGateVerdict,
|
|
235
|
+
createPostCheck: createGroundingCheck,
|
|
214
236
|
});
|
|
215
237
|
pi.registerProvider(overlay);
|
|
216
238
|
overlays.set(providerId, {
|
|
@@ -440,6 +462,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
440
462
|
removeTwin,
|
|
441
463
|
switchToGuarded,
|
|
442
464
|
getLastOutput: () => lastOutput,
|
|
465
|
+
groundingStatus: () => ({
|
|
466
|
+
enabled: config.grounding.enabled,
|
|
467
|
+
mode: config.grounding.mode,
|
|
468
|
+
entries: evidenceLedger.length,
|
|
469
|
+
hasStaleTests: evidenceLedger.some((e) => e.kind === "test" && e.stale),
|
|
470
|
+
}),
|
|
471
|
+
setGrounding: (mode) => {
|
|
472
|
+
const next = configSnapshot(config);
|
|
473
|
+
if (mode === "off") {
|
|
474
|
+
next.grounding.enabled = false;
|
|
475
|
+
} else {
|
|
476
|
+
next.grounding.enabled = true;
|
|
477
|
+
next.grounding.mode = mode;
|
|
478
|
+
}
|
|
479
|
+
saveConfig(next);
|
|
480
|
+
return refresh();
|
|
481
|
+
},
|
|
443
482
|
getLastGate: () => lastGate,
|
|
444
483
|
});
|
|
445
484
|
|
|
@@ -494,6 +533,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
494
533
|
|
|
495
534
|
async function startSession(ctx: ExtensionContext): Promise<void> {
|
|
496
535
|
restoreMode(ctx);
|
|
536
|
+
// Nuova sessione = nuova evidenza: il ledger non attraversa le sessioni.
|
|
537
|
+
evidenceLedger = [];
|
|
538
|
+
evidenceSeq = 0;
|
|
497
539
|
const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
|
|
498
540
|
if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
|
|
499
541
|
try {
|
|
@@ -797,11 +839,161 @@ export default function (pi: ExtensionAPI) {
|
|
|
797
839
|
return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
|
|
798
840
|
}
|
|
799
841
|
|
|
842
|
+
/** Registra un tool_result nel ledger evidenza (solo automatic, mai bloccare). */
|
|
843
|
+
function recordGroundingResult(event: {
|
|
844
|
+
toolName: string;
|
|
845
|
+
input: unknown;
|
|
846
|
+
content: unknown;
|
|
847
|
+
isError: boolean;
|
|
848
|
+
}): void {
|
|
849
|
+
try {
|
|
850
|
+
if (sessionMode !== "automatic") return;
|
|
851
|
+
if (!config.grounding.enabled) return;
|
|
852
|
+
const entry = recordToolResult({
|
|
853
|
+
toolName: event.toolName,
|
|
854
|
+
input: event.input,
|
|
855
|
+
content: event.content,
|
|
856
|
+
isError: event.isError,
|
|
857
|
+
seq: ++evidenceSeq,
|
|
858
|
+
at: Date.now(),
|
|
859
|
+
excerptChars: config.grounding.excerptChars,
|
|
860
|
+
});
|
|
861
|
+
if (!entry) return;
|
|
862
|
+
recordEvidence(evidenceLedger, entry, {
|
|
863
|
+
maxEntries: config.grounding.maxEntries,
|
|
864
|
+
invalidateTestsOnEdit: config.grounding.invalidateTestsOnEdit,
|
|
865
|
+
});
|
|
866
|
+
} catch {
|
|
867
|
+
// Mai rompere i risultati tool per il ledger.
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function throttledGroundingError(error: unknown): void {
|
|
872
|
+
const now = Date.now();
|
|
873
|
+
if (now - lastGroundingErrorAt < 60_000) return;
|
|
874
|
+
lastGroundingErrorAt = now;
|
|
875
|
+
try {
|
|
876
|
+
pi.appendEntry("jev-grounding-error", {
|
|
877
|
+
error: error instanceof Error ? error.message : String(error),
|
|
878
|
+
});
|
|
879
|
+
} catch {
|
|
880
|
+
// solo diagnostica
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Factory per-richiesta del controllo grounding: il budget repair vive
|
|
886
|
+
* nella closure (una per runGuardedStream). Ritorna undefined quando il
|
|
887
|
+
* controllo non si applica: il gate pubblica senza nota (fail-open).
|
|
888
|
+
*/
|
|
889
|
+
function createGroundingCheck(): PostPublishCheckFn | undefined {
|
|
890
|
+
try {
|
|
891
|
+
if (sessionMode !== "automatic") return undefined;
|
|
892
|
+
if (!config.grounding.enabled) return undefined;
|
|
893
|
+
if (!resolveBackend(config).apiKeyPresent) return undefined;
|
|
894
|
+
const g = config.grounding;
|
|
895
|
+
const mode = g.mode;
|
|
896
|
+
const maxRepairs = g.maxRepairs;
|
|
897
|
+
let repairsUsed = 0;
|
|
898
|
+
return async ({ text, attempts }) => {
|
|
899
|
+
const digest = buildEvidenceDigest(evidenceLedger, {
|
|
900
|
+
maxEntries: g.maxEntries,
|
|
901
|
+
evidenceChars: g.evidenceChars,
|
|
902
|
+
});
|
|
903
|
+
// Senza evidenza non si giudica: "senza evidenza" ≠ "falso".
|
|
904
|
+
if (digest.entries.length === 0) return { action: "publish" };
|
|
905
|
+
const state = buildGroundingState(text, digest, {
|
|
906
|
+
maxCandidateChars: config.limits.maxCandidateChars,
|
|
907
|
+
});
|
|
908
|
+
let evaluation: GroundingEvaluation | undefined;
|
|
909
|
+
try {
|
|
910
|
+
evaluation = await groundingCache.getOr(
|
|
911
|
+
groundingKey(text, state),
|
|
912
|
+
async () => {
|
|
913
|
+
const asked = await typedHandle.ask(state, GROUNDING_QUESTIONS, undefined);
|
|
914
|
+
return evaluateGrounding(asked, {
|
|
915
|
+
contradictionThreshold: g.contradictionThreshold,
|
|
916
|
+
unsupportedThreshold: g.unsupportedThreshold,
|
|
917
|
+
});
|
|
918
|
+
},
|
|
919
|
+
);
|
|
920
|
+
} catch (error) {
|
|
921
|
+
throttledGroundingError(error);
|
|
922
|
+
return { action: "publish" };
|
|
923
|
+
}
|
|
924
|
+
if (!evaluation) return { action: "publish" };
|
|
925
|
+
const decision = decideGroundingAction({
|
|
926
|
+
evaluation,
|
|
927
|
+
mode,
|
|
928
|
+
repairsUsed,
|
|
929
|
+
maxRepairs,
|
|
930
|
+
});
|
|
931
|
+
if (decision.action === "regenerate") {
|
|
932
|
+
repairsUsed++;
|
|
933
|
+
try {
|
|
934
|
+
pi.appendEntry("jev-grounding-repair", { reasons: evaluation.reasons });
|
|
935
|
+
} catch {
|
|
936
|
+
// solo diagnostica
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
action: "regenerate",
|
|
940
|
+
feedback: decision.feedback,
|
|
941
|
+
grounding: { action: "repaired", reasons: evaluation.reasons },
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
if (decision.action === "hold") {
|
|
945
|
+
try {
|
|
946
|
+
pi.appendEntry("jev-grounding-hold", {
|
|
947
|
+
reasons: evaluation.reasons,
|
|
948
|
+
mode,
|
|
949
|
+
});
|
|
950
|
+
} catch {
|
|
951
|
+
// solo diagnostica
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
action: "hold",
|
|
955
|
+
holdText: buildGroundingHoldText({ reasons: evaluation.reasons, attempts }),
|
|
956
|
+
grounding: { action: "held", reasons: evaluation.reasons },
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
if (decision.notice) {
|
|
960
|
+
try {
|
|
961
|
+
pi.appendEntry("jev-grounding-warn", {
|
|
962
|
+
reasons: evaluation.reasons,
|
|
963
|
+
mode,
|
|
964
|
+
});
|
|
965
|
+
} catch {
|
|
966
|
+
// solo diagnostica
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
action: "publish",
|
|
970
|
+
notice: decision.notice,
|
|
971
|
+
grounding:
|
|
972
|
+
repairsUsed > 0
|
|
973
|
+
? { action: "repaired", reasons: evaluation.reasons }
|
|
974
|
+
: { action: "warned", reasons: evaluation.reasons },
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
return {
|
|
978
|
+
action: "publish",
|
|
979
|
+
grounding:
|
|
980
|
+
repairsUsed > 0
|
|
981
|
+
? { action: "repaired", reasons: evaluation.reasons }
|
|
982
|
+
: undefined,
|
|
983
|
+
};
|
|
984
|
+
};
|
|
985
|
+
} catch {
|
|
986
|
+
return undefined;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
800
990
|
// Giudice output (solo automatic, fail-open): dopo l'esecuzione cerca
|
|
801
991
|
// secret nel testo e classifica i fallimenti. Non blocca mai: allega
|
|
802
992
|
// una riga al risultato che il modello legge.
|
|
803
993
|
pi.on("tool_result", async (event, ctx) => {
|
|
804
994
|
try {
|
|
995
|
+
// Ledger prima di tutto: vale anche con outputJudge disattivato.
|
|
996
|
+
recordGroundingResult(event);
|
|
805
997
|
if (sessionMode !== "automatic") return;
|
|
806
998
|
if (!config.outputJudge.enabled) return;
|
|
807
999
|
if (!config.outputJudge.tools.includes(event.toolName)) return;
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
|
9
9
|
import type { JevConfig } from "../config.ts";
|
|
10
10
|
import type { ReviewFn, ReviewResult } from "../reviewer.ts";
|
|
11
11
|
import { reviewWithCoverage } from "../reviewer.ts";
|
|
12
|
+
import { buildGroundingRepairContext } from "../grounding.ts";
|
|
12
13
|
import {
|
|
13
14
|
aggregateUsage,
|
|
14
15
|
buildRepairContext,
|
|
@@ -34,6 +35,33 @@ export interface GuardianDeps {
|
|
|
34
35
|
publishedModel: string;
|
|
35
36
|
/** Chiamato una volta per esito finale con verdetto Jev (pass e hold). */
|
|
36
37
|
onVerdict?: (report: GateVerdictReport) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Controllo post-safety sul candidato approvato (es. grounding evidenza).
|
|
40
|
+
* Opzionale; se assente o se solleva, il candidato viene pubblicato
|
|
41
|
+
* (fail-open: il safety ha già deciso). Rigenerazioni bounded dal loop.
|
|
42
|
+
*/
|
|
43
|
+
postPublishCheck?: PostPublishCheckFn;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Controllo post-safety: una chiamata per ogni candidato approvato. */
|
|
47
|
+
export type PostPublishCheckFn = (args: {
|
|
48
|
+
message: AssistantMessage;
|
|
49
|
+
text: string;
|
|
50
|
+
/** Tentativo upstream corrente (1-based). */
|
|
51
|
+
attempts: number;
|
|
52
|
+
}) => Promise<PostPublishCheckResult>;
|
|
53
|
+
|
|
54
|
+
/** Esito di un controllo post-safety sul candidato approvato. */
|
|
55
|
+
export interface PostPublishCheckResult {
|
|
56
|
+
action: "publish" | "regenerate" | "hold";
|
|
57
|
+
/** Nota di trasparenza accodata (publish). */
|
|
58
|
+
notice?: string;
|
|
59
|
+
/** Feedback per la rigenerazione (regenerate). */
|
|
60
|
+
feedback?: string;
|
|
61
|
+
/** Testo sicuro da pubblicare (hold): mai il candidato. */
|
|
62
|
+
holdText?: string;
|
|
63
|
+
/** Dettaglio per il report (publish con nota, hold). */
|
|
64
|
+
grounding?: GateVerdictReport["grounding"];
|
|
37
65
|
}
|
|
38
66
|
|
|
39
67
|
/** Esito finale del gate con verdetto, per `/jev last` e diagnostica. */
|
|
@@ -43,6 +71,11 @@ export interface GateVerdictReport {
|
|
|
43
71
|
attempts: number;
|
|
44
72
|
/** Come si è chiuso: pubblicato dopo ok, o trattenuto. */
|
|
45
73
|
outcome: "published" | "held";
|
|
74
|
+
/** Dettaglio grounding quando un post-check è intervenuto. */
|
|
75
|
+
grounding?: {
|
|
76
|
+
action: "warned" | "repaired" | "held";
|
|
77
|
+
reasons: string[];
|
|
78
|
+
};
|
|
46
79
|
}
|
|
47
80
|
|
|
48
81
|
export class GuardianLimitError extends Error {
|
|
@@ -248,8 +281,16 @@ export function runGuardedStream(
|
|
|
248
281
|
}
|
|
249
282
|
signal?.throwIfAborted();
|
|
250
283
|
|
|
251
|
-
const publishCandidate = async (
|
|
252
|
-
|
|
284
|
+
const publishCandidate = async (
|
|
285
|
+
notice?: string,
|
|
286
|
+
grounding?: GateVerdictReport["grounding"],
|
|
287
|
+
): Promise<void> => {
|
|
288
|
+
deps.onVerdict?.({
|
|
289
|
+
verdict,
|
|
290
|
+
attempts: attempt + 1,
|
|
291
|
+
outcome: "published",
|
|
292
|
+
...(grounding ? { grounding } : {}),
|
|
293
|
+
});
|
|
253
294
|
const approved = notice ? withNotice(candidate, notice) : candidate;
|
|
254
295
|
const aggregated = aggregateUsage(usages);
|
|
255
296
|
const replayed = replayApproved(approved, {
|
|
@@ -266,9 +307,84 @@ export function runGuardedStream(
|
|
|
266
307
|
out.end(final);
|
|
267
308
|
};
|
|
268
309
|
|
|
310
|
+
/** Pubblica un testo sicuro (hold): mai il candidato bocciato. */
|
|
311
|
+
const publishHoldText = async (safeText: string): Promise<void> => {
|
|
312
|
+
const aggregated = aggregateUsage(usages);
|
|
313
|
+
const safeMessage: AssistantMessage = {
|
|
314
|
+
role: "assistant",
|
|
315
|
+
content: [{ type: "text", text: "" }],
|
|
316
|
+
api: candidate.api,
|
|
317
|
+
provider: deps.publishedProvider as AssistantMessage["provider"],
|
|
318
|
+
model: deps.publishedModel,
|
|
319
|
+
responseModel: candidate.model,
|
|
320
|
+
usage: aggregated,
|
|
321
|
+
stopReason: "stop",
|
|
322
|
+
timestamp: Date.now(),
|
|
323
|
+
};
|
|
324
|
+
out.push({ type: "start", partial: safeMessage });
|
|
325
|
+
out.push({ type: "text_start", contentIndex: 0, partial: safeMessage });
|
|
326
|
+
const block = safeMessage.content[0];
|
|
327
|
+
if (block && block.type === "text") block.text = safeText;
|
|
328
|
+
out.push({ type: "text_delta", contentIndex: 0, delta: safeText, partial: safeMessage });
|
|
329
|
+
out.push({ type: "text_end", contentIndex: 0, content: safeText, partial: safeMessage });
|
|
330
|
+
out.push({ type: "done", reason: "stop", message: safeMessage });
|
|
331
|
+
out.end(safeMessage);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Controllo post-safety sul candidato approvato. Ritorna "published"
|
|
336
|
+
* o "held" (stream già chiuso) oppure undefined per rigenerare
|
|
337
|
+
* (attemptContext aggiornato, il loop continua). Fail-open.
|
|
338
|
+
*/
|
|
339
|
+
const runPostCheck = async (
|
|
340
|
+
baseNotice?: string,
|
|
341
|
+
): Promise<"published" | "held" | undefined> => {
|
|
342
|
+
if (!deps.postPublishCheck) {
|
|
343
|
+
await publishCandidate(baseNotice);
|
|
344
|
+
return "published";
|
|
345
|
+
}
|
|
346
|
+
let check: PostPublishCheckResult;
|
|
347
|
+
try {
|
|
348
|
+
check = await deps.postPublishCheck({
|
|
349
|
+
message: candidate,
|
|
350
|
+
text: rawCandidate,
|
|
351
|
+
attempts: attempt + 1,
|
|
352
|
+
});
|
|
353
|
+
} catch {
|
|
354
|
+
await publishCandidate(baseNotice);
|
|
355
|
+
return "published";
|
|
356
|
+
}
|
|
357
|
+
signal?.throwIfAborted();
|
|
358
|
+
if (check.action === "publish") {
|
|
359
|
+
const notice = [baseNotice, check.notice].filter(Boolean).join("") || undefined;
|
|
360
|
+
await publishCandidate(notice, check.grounding);
|
|
361
|
+
return "published";
|
|
362
|
+
}
|
|
363
|
+
if (check.action === "hold") {
|
|
364
|
+
const attempts = attempt + 1;
|
|
365
|
+
deps.onVerdict?.({
|
|
366
|
+
verdict,
|
|
367
|
+
attempts,
|
|
368
|
+
outcome: "held",
|
|
369
|
+
...(check.grounding ? { grounding: check.grounding } : {}),
|
|
370
|
+
});
|
|
371
|
+
await publishHoldText(check.holdText ?? "[JEV — OUTPUT NON PUBBLICATO]");
|
|
372
|
+
return "held";
|
|
373
|
+
}
|
|
374
|
+
attemptContext = buildGroundingRepairContext({
|
|
375
|
+
attemptContext,
|
|
376
|
+
rejectedText: rawCandidate,
|
|
377
|
+
feedback:
|
|
378
|
+
check.feedback ??
|
|
379
|
+
"Grounding check failed; only assert session activities supported by evidence.",
|
|
380
|
+
});
|
|
381
|
+
return undefined;
|
|
382
|
+
};
|
|
383
|
+
|
|
269
384
|
if (verdict.status === "pass") {
|
|
270
|
-
await
|
|
271
|
-
return;
|
|
385
|
+
const outcome = await runPostCheck();
|
|
386
|
+
if (outcome !== undefined) return;
|
|
387
|
+
continue;
|
|
272
388
|
}
|
|
273
389
|
|
|
274
390
|
const canRepair =
|
|
@@ -291,8 +407,9 @@ export function runGuardedStream(
|
|
|
291
407
|
const unavailablePass =
|
|
292
408
|
verdict.status === "unavailable" && deps.config.policy.onUnavailable === "pass";
|
|
293
409
|
if (uncertainPass || unavailablePass) {
|
|
294
|
-
await
|
|
295
|
-
return;
|
|
410
|
+
const outcome = await runPostCheck(uncertainNotice(verdict));
|
|
411
|
+
if (outcome !== undefined) return;
|
|
412
|
+
continue;
|
|
296
413
|
}
|
|
297
414
|
|
|
298
415
|
// Hold: pubblica failure sicura, mai il candidato bocciato.
|
|
@@ -305,26 +422,7 @@ export function runGuardedStream(
|
|
|
305
422
|
? "Input troncato per la verifica: Jev ha visto solo una parte (vedi truncated)."
|
|
306
423
|
: undefined,
|
|
307
424
|
});
|
|
308
|
-
|
|
309
|
-
const safeMessage: AssistantMessage = {
|
|
310
|
-
role: "assistant",
|
|
311
|
-
content: [{ type: "text", text: "" }],
|
|
312
|
-
api: candidate.api,
|
|
313
|
-
provider: deps.publishedProvider as AssistantMessage["provider"],
|
|
314
|
-
model: deps.publishedModel,
|
|
315
|
-
responseModel: candidate.model,
|
|
316
|
-
usage: aggregated,
|
|
317
|
-
stopReason: "stop",
|
|
318
|
-
timestamp: Date.now(),
|
|
319
|
-
};
|
|
320
|
-
out.push({ type: "start", partial: safeMessage });
|
|
321
|
-
out.push({ type: "text_start", contentIndex: 0, partial: safeMessage });
|
|
322
|
-
const block = safeMessage.content[0];
|
|
323
|
-
if (block && block.type === "text") block.text = safeText;
|
|
324
|
-
out.push({ type: "text_delta", contentIndex: 0, delta: safeText, partial: safeMessage });
|
|
325
|
-
out.push({ type: "text_end", contentIndex: 0, content: safeText, partial: safeMessage });
|
|
326
|
-
out.push({ type: "done", reason: "stop", message: safeMessage });
|
|
327
|
-
out.end(safeMessage);
|
|
425
|
+
await publishHoldText(safeText);
|
|
328
426
|
return;
|
|
329
427
|
}
|
|
330
428
|
} catch (error) {
|
package/src/automatic/overlay.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* per i soli modelli scelti. Gli stream dei gemelli passano dal gate.
|
|
11
11
|
*/
|
|
12
12
|
import type {
|
|
13
|
+
AssistantMessage,
|
|
13
14
|
AssistantMessageEventStream,
|
|
14
15
|
Context,
|
|
15
16
|
Credential,
|
|
@@ -25,7 +26,7 @@ import { emptyUsage } from "./serialize.ts";
|
|
|
25
26
|
import type { JevConfig } from "../config.ts";
|
|
26
27
|
import { configSnapshot } from "../config.ts";
|
|
27
28
|
import type { ReviewFn } from "../reviewer.ts";
|
|
28
|
-
import { runGuardedStream, type GateVerdictReport } from "./guardian.ts";
|
|
29
|
+
import { runGuardedStream, type GateVerdictReport, type PostPublishCheckFn } from "./guardian.ts";
|
|
29
30
|
|
|
30
31
|
export const TWIN_SUFFIX = "__jev";
|
|
31
32
|
|
|
@@ -80,6 +81,11 @@ export interface OverlayDeps {
|
|
|
80
81
|
getReview: () => ReviewFn;
|
|
81
82
|
/** Riceve l'esito finale di ogni turno sul twin (per `/jev last`). */
|
|
82
83
|
onVerdict?: (report: GateVerdictReport) => void;
|
|
84
|
+
/**
|
|
85
|
+
* Factory del controllo post-safety, una per richiesta: il budget repair
|
|
86
|
+
* vive nella closure restituita, non nel loop del guardian.
|
|
87
|
+
*/
|
|
88
|
+
createPostCheck?: () => PostPublishCheckFn | undefined;
|
|
83
89
|
/** Override streams upstream per test (altrimenti delega al provider). */
|
|
84
90
|
upstreamStreams?: {
|
|
85
91
|
streamSimple: (
|
|
@@ -206,6 +212,12 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
|
|
|
206
212
|
publishedProvider: model.provider,
|
|
207
213
|
publishedModel: model.id,
|
|
208
214
|
...(deps.onVerdict ? { onVerdict: deps.onVerdict } : {}),
|
|
215
|
+
...(deps.createPostCheck
|
|
216
|
+
? (() => {
|
|
217
|
+
const check = deps.createPostCheck();
|
|
218
|
+
return check ? { postPublishCheck: check } : {};
|
|
219
|
+
})()
|
|
220
|
+
: {}),
|
|
209
221
|
});
|
|
210
222
|
}
|
|
211
223
|
|
package/src/commands.ts
CHANGED
|
@@ -72,6 +72,13 @@ export interface JevCommandDeps {
|
|
|
72
72
|
}>;
|
|
73
73
|
getLastOutput: () => { tool: string; verdict: OutputVerdict; at: number } | undefined;
|
|
74
74
|
getLastGate: () => { report: GateVerdictReport; at: number } | undefined;
|
|
75
|
+
groundingStatus: () => {
|
|
76
|
+
enabled: boolean;
|
|
77
|
+
mode: "warn" | "repair" | "hold";
|
|
78
|
+
entries: number;
|
|
79
|
+
hasStaleTests: boolean;
|
|
80
|
+
};
|
|
81
|
+
setGrounding: (mode: "off" | "warn" | "repair" | "hold") => JevConfig;
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
export const JEV_SUBCOMMANDS = [
|
|
@@ -88,6 +95,7 @@ export const JEV_SUBCOMMANDS = [
|
|
|
88
95
|
"reload",
|
|
89
96
|
"output",
|
|
90
97
|
"last",
|
|
98
|
+
"grounding",
|
|
91
99
|
"backend",
|
|
92
100
|
"save-key",
|
|
93
101
|
"help",
|
|
@@ -111,6 +119,8 @@ export const JEV_HELP_TEXT = [
|
|
|
111
119
|
" /jev stats Conteggi pass/block/review/unavailable + latenze",
|
|
112
120
|
" /jev output Ultimo output giudicato: leak + classe errore",
|
|
113
121
|
" /jev last Ultimo verdetto del gate (twin): esito e controlli",
|
|
122
|
+
" /jev grounding off|warn|repair|hold",
|
|
123
|
+
" Grounding affermazioni sessione (solo automatic, opt-in)",
|
|
114
124
|
" /jev backend Mostra backend attivo e chiavi rilevate",
|
|
115
125
|
" /jev backend auto|openrouter|typesafe",
|
|
116
126
|
" Cambia backend (salvato in jev-config.json)",
|
|
@@ -239,6 +249,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
239
249
|
`Tool policy: ${cfg.toolsPolicy.enabled ? "attiva in automatic (bash/powershell/write/edit)" : "disattiva (config)"}`,
|
|
240
250
|
`Exfil check: ${cfg.exfilCheck.enabled ? "attivo in automatic (confirm in TUI, fail-open)" : "disattivo (config)"}`,
|
|
241
251
|
`Output judge: ${cfg.outputJudge.enabled ? `attivo in automatic (tool: ${cfg.outputJudge.tools.join(",") || "—"}, fail-open)` : "disattivo (config)"}`,
|
|
252
|
+
`Grounding: ${cfg.grounding.enabled ? `attivo in automatic (mode=${cfg.grounding.mode}, evidenza=${deps.groundingStatus().entries}${deps.groundingStatus().hasStaleTests ? ", test stale" : ""})` : "disattivo (opt-in: /jev grounding warn)"}`,
|
|
242
253
|
cfg.mode === "automatic"
|
|
243
254
|
? "ATTENZIONE: in automatic testo/codice vengono inviati al verificatore. Crea il twin con /jev upstream e seleziona il modello __jev per enforcement."
|
|
244
255
|
: "on-demand: nessuna verifica automatica.",
|
|
@@ -451,6 +462,9 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
451
462
|
[
|
|
452
463
|
`Gate: ${last.report.outcome === "published" ? "pubblicato" : "trattenuto"} dopo ${last.report.attempts} tentativ${last.report.attempts === 1 ? "o" : "i"}`,
|
|
453
464
|
`stato: ${v.status}${top ? ` — ${top.ruleId} p=${top.pFlaw.toFixed(2)}` : ""}`,
|
|
465
|
+
last.report.grounding
|
|
466
|
+
? `grounding: ${last.report.grounding.action} (${last.report.grounding.reasons.join(", ") || "n/a"})`
|
|
467
|
+
: "grounding: non intervenuto",
|
|
454
468
|
v.errorCode ? `errore: ${v.errorCode}` : `backend: ${v.backend ?? "?"} · modello: ${v.model ?? "?"}`,
|
|
455
469
|
v.truncated ? `troncato: ${JSON.stringify(v.truncated)}` : "copertura intera",
|
|
456
470
|
`tempo verifica: ${Math.round(v.elapsedMs)}ms`,
|
|
@@ -480,6 +494,33 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
480
494
|
return;
|
|
481
495
|
}
|
|
482
496
|
|
|
497
|
+
if (sub === "grounding") {
|
|
498
|
+
const value = parts[1];
|
|
499
|
+
if (value === undefined) {
|
|
500
|
+
const st = deps.groundingStatus();
|
|
501
|
+
ctx.ui.notify(
|
|
502
|
+
st.enabled
|
|
503
|
+
? `Grounding attivo in automatic (mode=${st.mode}, voci evidenza=${st.entries}${st.hasStaleTests ? ", test stale presenti" : ""}). Cambia con /jev grounding off|warn|repair|hold.`
|
|
504
|
+
: "Grounding disattivo (opt-in). Attiva con /jev grounding warn|repair|hold (solo automatic).",
|
|
505
|
+
"info",
|
|
506
|
+
);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (value !== "off" && value !== "warn" && value !== "repair" && value !== "hold") {
|
|
510
|
+
ctx.ui.notify("Uso: /jev grounding off|warn|repair|hold", "warning");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const cfg = deps.setGrounding(value);
|
|
514
|
+
ctx.ui.notify(
|
|
515
|
+
value === "off"
|
|
516
|
+
? "Grounding disattivato."
|
|
517
|
+
: `Grounding attivo in automatic (mode=${value}). Le affermazioni sulla sessione vengono confrontate col registro evidenza; senza evidenza non si giudica.`,
|
|
518
|
+
"info",
|
|
519
|
+
);
|
|
520
|
+
void cfg;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
483
524
|
if (sub === "reload") {
|
|
484
525
|
const cfg = deps.reload();
|
|
485
526
|
ctx.ui.notify(`Config ricaricata. mode=${cfg.mode} backend=${cfg.jev.backend}`, "info");
|
package/src/config.ts
CHANGED
|
@@ -75,6 +75,28 @@ export interface JevConfig {
|
|
|
75
75
|
/** Riusa verdetti per output identici entro la finestra. */
|
|
76
76
|
cacheSeconds: number;
|
|
77
77
|
};
|
|
78
|
+
grounding: {
|
|
79
|
+
/** Grounding affermazioni sulla sessione (solo automatic, opt-in). */
|
|
80
|
+
enabled: boolean;
|
|
81
|
+
/** warn = nota, repair = rigenera, hold = trattiene le contraddizioni. */
|
|
82
|
+
mode: "warn" | "repair" | "hold";
|
|
83
|
+
/** Soglia p(contraddizione evidenza) per scattare. */
|
|
84
|
+
contradictionThreshold: number;
|
|
85
|
+
/** Soglia p(affermazione non supportata) per scattare. */
|
|
86
|
+
unsupportedThreshold: number;
|
|
87
|
+
/** Rigenerazioni grounding per risposta (separate dal safety). */
|
|
88
|
+
maxRepairs: number;
|
|
89
|
+
/** Voci evidenza mantenute per sessione. */
|
|
90
|
+
maxEntries: number;
|
|
91
|
+
/** Budget caratteri del digest evidenza inviato a Jev. */
|
|
92
|
+
evidenceChars: number;
|
|
93
|
+
/** Estratto output per voce evidenza. */
|
|
94
|
+
excerptChars: number;
|
|
95
|
+
/** Riusa verdetti per candidato+evidenza identici entro la finestra. */
|
|
96
|
+
cacheSeconds: number;
|
|
97
|
+
/** Una modifica riuscita invalida i test precedenti (stale). */
|
|
98
|
+
invalidateTestsOnEdit: boolean;
|
|
99
|
+
};
|
|
78
100
|
exfilCheck: {
|
|
79
101
|
/** Check semantico sui comandi con tool di rete (solo automatic). */
|
|
80
102
|
enabled: boolean;
|
|
@@ -158,6 +180,20 @@ export const DEFAULT_CONFIG: JevConfig = {
|
|
|
158
180
|
minConfidence: 0.6,
|
|
159
181
|
cacheSeconds: 120,
|
|
160
182
|
},
|
|
183
|
+
grounding: {
|
|
184
|
+
enabled: false,
|
|
185
|
+
mode: "warn",
|
|
186
|
+
// Soglie alte: "senza evidenza" non significa "falso"; solo le
|
|
187
|
+
// contraddizioni quasi certe trattengono (vedi decideGroundingAction).
|
|
188
|
+
contradictionThreshold: 0.85,
|
|
189
|
+
unsupportedThreshold: 0.7,
|
|
190
|
+
maxRepairs: 1,
|
|
191
|
+
maxEntries: 30,
|
|
192
|
+
evidenceChars: 2000,
|
|
193
|
+
excerptChars: 300,
|
|
194
|
+
cacheSeconds: 120,
|
|
195
|
+
invalidateTestsOnEdit: true,
|
|
196
|
+
},
|
|
161
197
|
exfilCheck: {
|
|
162
198
|
enabled: true,
|
|
163
199
|
tools: ["bash", "powershell"],
|
package/src/grounding.ts
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grounding delle affermazioni sulla sessione (anti "ho fatto X" inventato).
|
|
3
|
+
*
|
|
4
|
+
* Il gate safety vede solo {requirements, candidate}: non sa se il modello ha
|
|
5
|
+
* davvero letto un file, eseguito un comando o superato i test. Questo modulo
|
|
6
|
+
* mantiene un registro temporaneo e redatto degli effetti osservati nella
|
|
7
|
+
* sessione (tool_result) e lo confronta con il candidato prima della
|
|
8
|
+
* pubblicazione. Solo affermazioni verificabili sulla sessione: comandi, file,
|
|
9
|
+
* test, modifiche. Niente fact-check esterno.
|
|
10
|
+
*
|
|
11
|
+
* Solo funzioni pure + tipi: nessun accesso a pi qui.
|
|
12
|
+
*/
|
|
13
|
+
import type { Context } from "@earendil-works/pi-ai";
|
|
14
|
+
import { fnvKey } from "./cache.ts";
|
|
15
|
+
import { elideText, toolResultText } from "./output-judge.ts";
|
|
16
|
+
import type {
|
|
17
|
+
TypedAnswer,
|
|
18
|
+
TypedAskResult,
|
|
19
|
+
TypedQuestion,
|
|
20
|
+
} from "./reviewer.ts";
|
|
21
|
+
|
|
22
|
+
export type GroundingMode = "warn" | "repair" | "hold";
|
|
23
|
+
|
|
24
|
+
export type EvidenceKind = "command" | "test" | "read" | "modify";
|
|
25
|
+
|
|
26
|
+
export interface EvidenceEntry {
|
|
27
|
+
seq: number;
|
|
28
|
+
at: number;
|
|
29
|
+
tool: string;
|
|
30
|
+
kind: EvidenceKind;
|
|
31
|
+
/** "bash: npm run test:unit" / "read: src/config.ts" — mai output interi. */
|
|
32
|
+
summary: string;
|
|
33
|
+
ok: boolean;
|
|
34
|
+
exitCode?: number;
|
|
35
|
+
/** Test superato da una modifica successiva (non più probante). */
|
|
36
|
+
stale?: boolean;
|
|
37
|
+
/** Breve estratto redatto dell'output. */
|
|
38
|
+
excerpt?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Tool osservati per il ledger (risultati di comandi, file, test, modifiche). */
|
|
42
|
+
export const GROUNDING_TOOLS = ["bash", "powershell", "read", "edit", "write"] as const;
|
|
43
|
+
|
|
44
|
+
const TEST_COMMAND =
|
|
45
|
+
/(^|[\s;&|])(npm\s+(test|run\s+test|run\s+test:unit)|npx\s+(vitest|jest)|vitest(\s+run)?|jest|pytest|go\s+test|cargo\s+test|phpunit|rspec|make\s+test)\b/i;
|
|
46
|
+
|
|
47
|
+
/** Riconosce un comando che esegue test (evidenza "test", non generica). */
|
|
48
|
+
export function isTestCommand(command: string): boolean {
|
|
49
|
+
return TEST_COMMAND.test(command);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EXIT_CODE = /Command exited with code (\d+)/;
|
|
53
|
+
|
|
54
|
+
/** Exit code dal testo d'errore pi ("Command exited with code N"). */
|
|
55
|
+
export function parseExitCode(text: string): number | undefined {
|
|
56
|
+
const match = EXIT_CODE.exec(text);
|
|
57
|
+
if (!match) return undefined;
|
|
58
|
+
const code = Number(match[1]);
|
|
59
|
+
return Number.isFinite(code) ? code : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function firstLine(text: string, max: number): string {
|
|
63
|
+
const line = text.split(/\r?\n/)[0]?.trim() ?? "";
|
|
64
|
+
return line.length > max ? `${line.slice(0, max)}…` : line;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Costruisce una voce di evidenza da un tool_result. Ritorna undefined per i
|
|
69
|
+
* tool non osservati. Redatto: solo riepilogo + breve estratto, mai segreti
|
|
70
|
+
* (l'output judge resta il filtro dedicato per i leak).
|
|
71
|
+
*/
|
|
72
|
+
export function recordToolResult(args: {
|
|
73
|
+
toolName: string;
|
|
74
|
+
input: unknown;
|
|
75
|
+
content: unknown;
|
|
76
|
+
isError: boolean;
|
|
77
|
+
seq: number;
|
|
78
|
+
at: number;
|
|
79
|
+
excerptChars: number;
|
|
80
|
+
}): EvidenceEntry | undefined {
|
|
81
|
+
const { toolName } = args;
|
|
82
|
+
if (!(GROUNDING_TOOLS as readonly string[]).includes(toolName)) return undefined;
|
|
83
|
+
const text = toolResultText(args.content);
|
|
84
|
+
const ok = !args.isError;
|
|
85
|
+
const input = (args.input ?? {}) as Record<string, unknown>;
|
|
86
|
+
const excerptSource = text.trim();
|
|
87
|
+
const excerpt = excerptSource
|
|
88
|
+
? elideText(excerptSource, Math.max(0, args.excerptChars))
|
|
89
|
+
: undefined;
|
|
90
|
+
|
|
91
|
+
if (toolName === "bash" || toolName === "powershell") {
|
|
92
|
+
const command = typeof input.command === "string" ? input.command.trim() : "";
|
|
93
|
+
if (!command) return undefined;
|
|
94
|
+
const kind: EvidenceKind = isTestCommand(command) ? "test" : "command";
|
|
95
|
+
const entry: EvidenceEntry = {
|
|
96
|
+
seq: args.seq,
|
|
97
|
+
at: args.at,
|
|
98
|
+
tool: toolName,
|
|
99
|
+
kind,
|
|
100
|
+
summary: `${toolName}: ${firstLine(command, 160)}`,
|
|
101
|
+
ok,
|
|
102
|
+
};
|
|
103
|
+
const exitCode = parseExitCode(text);
|
|
104
|
+
if (exitCode !== undefined) entry.exitCode = exitCode;
|
|
105
|
+
else if (!ok) entry.exitCode = -1;
|
|
106
|
+
if (excerpt) entry.excerpt = excerpt;
|
|
107
|
+
return entry;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (toolName === "read") {
|
|
111
|
+
const path = typeof input.path === "string" ? input.path.trim() : "";
|
|
112
|
+
if (!path) return undefined;
|
|
113
|
+
const entry: EvidenceEntry = {
|
|
114
|
+
seq: args.seq,
|
|
115
|
+
at: args.at,
|
|
116
|
+
tool: toolName,
|
|
117
|
+
kind: "read",
|
|
118
|
+
summary: `read: ${firstLine(path, 200)}`,
|
|
119
|
+
ok,
|
|
120
|
+
};
|
|
121
|
+
if (excerpt) entry.excerpt = excerpt;
|
|
122
|
+
return entry;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// edit / write: modifica osservata (anche fallita: ok=false, non invalida).
|
|
126
|
+
const path = typeof input.path === "string" ? input.path.trim() : "";
|
|
127
|
+
if (!path) return undefined;
|
|
128
|
+
const entry: EvidenceEntry = {
|
|
129
|
+
seq: args.seq,
|
|
130
|
+
at: args.at,
|
|
131
|
+
tool: toolName,
|
|
132
|
+
kind: "modify",
|
|
133
|
+
summary: `${toolName}: ${firstLine(path, 200)}`,
|
|
134
|
+
ok,
|
|
135
|
+
};
|
|
136
|
+
if (excerpt) entry.excerpt = excerpt;
|
|
137
|
+
return entry;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Accoda una voce al ledger con cap: oltre maxEntries scarta le più vecchie.
|
|
142
|
+
* Dopo una modifica riuscita, i test precedenti diventano stale (il loro esito
|
|
143
|
+
* non prova più lo stato attuale). Ritorna quanti test sono stati invalidati.
|
|
144
|
+
*/
|
|
145
|
+
export function recordEvidence(
|
|
146
|
+
ledger: EvidenceEntry[],
|
|
147
|
+
entry: EvidenceEntry,
|
|
148
|
+
opts: { maxEntries: number; invalidateTestsOnEdit: boolean },
|
|
149
|
+
): { invalidated: number } {
|
|
150
|
+
ledger.push(entry);
|
|
151
|
+
while (ledger.length > Math.max(1, opts.maxEntries)) ledger.shift();
|
|
152
|
+
let invalidated = 0;
|
|
153
|
+
if (opts.invalidateTestsOnEdit && entry.kind === "modify" && entry.ok) {
|
|
154
|
+
for (const prior of ledger) {
|
|
155
|
+
if (prior.kind === "test" && !prior.stale && prior.seq < entry.seq) {
|
|
156
|
+
prior.stale = true;
|
|
157
|
+
invalidated++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { invalidated };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface GroundingDigest {
|
|
165
|
+
entries: EvidenceEntry[];
|
|
166
|
+
/** True se almeno un test è stale (Jev lo vede: non è più probante). */
|
|
167
|
+
hasStaleTests: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Digest pronto per Jev: voci recenti entro il budget caratteri. */
|
|
171
|
+
export function buildEvidenceDigest(
|
|
172
|
+
ledger: readonly EvidenceEntry[],
|
|
173
|
+
opts: { maxEntries: number; evidenceChars: number },
|
|
174
|
+
): GroundingDigest {
|
|
175
|
+
const recent = ledger.slice(-Math.max(1, opts.maxEntries));
|
|
176
|
+
const entries: EvidenceEntry[] = [];
|
|
177
|
+
let budget = Math.max(0, opts.evidenceChars);
|
|
178
|
+
// Dalle più recenti: l'evidenza fresca conta di più.
|
|
179
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
180
|
+
const entry = recent[i];
|
|
181
|
+
if (!entry) continue;
|
|
182
|
+
const cost =
|
|
183
|
+
entry.summary.length + (entry.excerpt ? entry.excerpt.length : 0) + 48;
|
|
184
|
+
if (cost > budget) {
|
|
185
|
+
const trimmed: EvidenceEntry = {
|
|
186
|
+
...entry,
|
|
187
|
+
excerpt: undefined,
|
|
188
|
+
};
|
|
189
|
+
if (trimmed.summary.length + 48 <= budget) {
|
|
190
|
+
entries.unshift(trimmed);
|
|
191
|
+
budget -= trimmed.summary.length + 48;
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
entries.unshift(entry);
|
|
196
|
+
budget -= cost;
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
entries,
|
|
200
|
+
hasStaleTests: recent.some((e) => e.kind === "test" && e.stale),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const GROUNDING_QUESTIONS: TypedQuestion[] = [
|
|
205
|
+
{
|
|
206
|
+
kind: "noul",
|
|
207
|
+
id: "contradicts_evidence",
|
|
208
|
+
instructions:
|
|
209
|
+
"Does `candidate` assert a session activity whose outcome is directly contradicted by `evidence`?",
|
|
210
|
+
criteria: {
|
|
211
|
+
true: "The candidate claims a file was read, modified, created, or deleted, a command was run, or tests passed, and `evidence` shows the opposite outcome (failure, error, exit code non-zero, or a stale test result presented as current).",
|
|
212
|
+
false: "No direct contradiction; the candidate only gives generic advice, explains concepts, or its session claims match the recorded evidence.",
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
kind: "noul",
|
|
217
|
+
id: "unsupported_session_claim",
|
|
218
|
+
instructions:
|
|
219
|
+
"Does `candidate` claim a session activity (read, modified, executed, or verified something) with no supporting entry in `evidence`?",
|
|
220
|
+
criteria: {
|
|
221
|
+
true: "The candidate states as fact that it read a specific file, ran a specific command, or verified/tested something, and `evidence` contains no entry for that activity. Judge only verifiable session-activity claims.",
|
|
222
|
+
false: "Every session-activity claim has a matching evidence entry, or the candidate makes no verifiable session-activity claims (generic explanations, advice, opinions, or explicitly marked unverified statements).",
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
export function buildGroundingState(
|
|
228
|
+
candidate: string,
|
|
229
|
+
digest: GroundingDigest,
|
|
230
|
+
opts: { maxCandidateChars: number },
|
|
231
|
+
): string {
|
|
232
|
+
return JSON.stringify({
|
|
233
|
+
candidate: elideText(candidate, Math.max(1, opts.maxCandidateChars)),
|
|
234
|
+
evidence: digest.entries.map((e) => ({
|
|
235
|
+
tool: e.tool,
|
|
236
|
+
kind: e.kind,
|
|
237
|
+
summary: e.summary,
|
|
238
|
+
ok: e.ok,
|
|
239
|
+
...(e.exitCode !== undefined ? { exitCode: e.exitCode } : {}),
|
|
240
|
+
...(e.stale ? { stale: true } : {}),
|
|
241
|
+
...(e.excerpt ? { excerpt: e.excerpt } : {}),
|
|
242
|
+
})),
|
|
243
|
+
stale_tests_present: digest.hasStaleTests,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Chiave cache: hash di candidato + digest (niente contenuto in chiaro). */
|
|
248
|
+
export function groundingKey(candidate: string, state: string): string {
|
|
249
|
+
return `${candidate.length}:${state.length}:${fnvKey(`${candidate}\0${state}`)}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface GroundingThresholds {
|
|
253
|
+
contradictionThreshold: number;
|
|
254
|
+
unsupportedThreshold: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface GroundingEvaluation {
|
|
258
|
+
contradiction: number;
|
|
259
|
+
unsupported: number;
|
|
260
|
+
triggered: boolean;
|
|
261
|
+
reasons: string[];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function noulById(answers: TypedAnswer[], id: string): number {
|
|
265
|
+
const found = answers.find((a) => a.id === id);
|
|
266
|
+
return found?.type === "noul" ? found.p : 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function evaluateGrounding(
|
|
270
|
+
result: TypedAskResult,
|
|
271
|
+
thresholds: GroundingThresholds,
|
|
272
|
+
): GroundingEvaluation {
|
|
273
|
+
const contradiction = noulById(result.answers, "contradicts_evidence");
|
|
274
|
+
const unsupported = noulById(result.answers, "unsupported_session_claim");
|
|
275
|
+
const reasons: string[] = [];
|
|
276
|
+
if (contradiction >= thresholds.contradictionThreshold) {
|
|
277
|
+
reasons.push(`contradicts_evidence ${contradiction.toFixed(2)}`);
|
|
278
|
+
}
|
|
279
|
+
if (unsupported >= thresholds.unsupportedThreshold) {
|
|
280
|
+
reasons.push(`unsupported_session_claim ${unsupported.toFixed(2)}`);
|
|
281
|
+
}
|
|
282
|
+
return { contradiction, unsupported, triggered: reasons.length > 0, reasons };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export type GroundingAction = "publish" | "regenerate" | "hold";
|
|
286
|
+
|
|
287
|
+
export interface GroundingDecision {
|
|
288
|
+
action: GroundingAction;
|
|
289
|
+
/** Nota di trasparenza (publish dopo trigger). */
|
|
290
|
+
notice?: string;
|
|
291
|
+
/** Feedback per la rigenerazione. */
|
|
292
|
+
feedback?: string;
|
|
293
|
+
/** Motivo leggibile (hold). */
|
|
294
|
+
reason?: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Nota accodata al testo pubblicato quando il grounding scatta senza hold. */
|
|
298
|
+
export function groundingNotice(reasons: string[]): string {
|
|
299
|
+
return `\n\n> [jev] grounding: affermazioni sulla sessione non supportate dall'evidenza (${reasons.join(", ")}) — verifica col modello i file/comandi citati.`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Feedback per la rigenerazione: correggere, non accusare. */
|
|
303
|
+
export function buildGroundingRepairFeedback(reasons: string[], attempt: number): string {
|
|
304
|
+
return [
|
|
305
|
+
`Grounding check failed (${reasons.join(", ")}), attempt ${attempt}.`,
|
|
306
|
+
"The draft asserts session activities (file reads, commands, tests) that the",
|
|
307
|
+
"recorded evidence does not support or directly contradicts.",
|
|
308
|
+
"Produce a replacement that only asserts session activities present in the",
|
|
309
|
+
"evidence, or explicitly marks the rest as NOT verified in this session.",
|
|
310
|
+
"Do not repeat unsupported claims as facts. 'Not verified' is acceptable.",
|
|
311
|
+
].join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Testo sicuro per l'hold: mai il candidato, solo il motivo. */
|
|
315
|
+
export function buildGroundingHoldText(args: {
|
|
316
|
+
reasons: string[];
|
|
317
|
+
attempts: number;
|
|
318
|
+
}): string {
|
|
319
|
+
return [
|
|
320
|
+
"[JEV — OUTPUT NON PUBBLICATO]",
|
|
321
|
+
"",
|
|
322
|
+
"Risultato: grounding-hold",
|
|
323
|
+
`Controllo: ${args.reasons.join(", ") || "n/a"}`,
|
|
324
|
+
`Tentativi: ${args.attempts}`,
|
|
325
|
+
"",
|
|
326
|
+
"La risposta contraddice l'evidenza della sessione con alta confidenza.",
|
|
327
|
+
].join("\n");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Decide l'azione in base alla modalità. "Senza evidenza" non significa
|
|
332
|
+
* "falso": solo le contraddizioni ad alta confidenza trattengono (hold);
|
|
333
|
+
* le affermazioni non supportate avvisano o rigenerano.
|
|
334
|
+
*/
|
|
335
|
+
export function decideGroundingAction(args: {
|
|
336
|
+
evaluation: GroundingEvaluation;
|
|
337
|
+
mode: GroundingMode;
|
|
338
|
+
repairsUsed: number;
|
|
339
|
+
maxRepairs: number;
|
|
340
|
+
}): GroundingDecision {
|
|
341
|
+
const { evaluation, mode, repairsUsed, maxRepairs } = args;
|
|
342
|
+
if (!evaluation.triggered) return { action: "publish" };
|
|
343
|
+
const notice = groundingNotice(evaluation.reasons);
|
|
344
|
+
if (mode === "warn") return { action: "publish", notice };
|
|
345
|
+
const canRepair = repairsUsed < Math.max(0, maxRepairs);
|
|
346
|
+
const contradictionHit =
|
|
347
|
+
evaluation.reasons.some((r) => r.startsWith("contradicts_evidence")) &&
|
|
348
|
+
mode === "hold";
|
|
349
|
+
if (contradictionHit) {
|
|
350
|
+
return {
|
|
351
|
+
action: "hold",
|
|
352
|
+
reason: `grounding: ${evaluation.reasons.join(", ")}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (canRepair) {
|
|
356
|
+
return {
|
|
357
|
+
action: "regenerate",
|
|
358
|
+
feedback: buildGroundingRepairFeedback(evaluation.reasons, repairsUsed + 1),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return { action: "publish", notice };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Contesto di repair per il grounding: feedback + bozza scartata. */
|
|
365
|
+
export function buildGroundingRepairContext(args: {
|
|
366
|
+
attemptContext: Context;
|
|
367
|
+
rejectedText: string;
|
|
368
|
+
feedback: string;
|
|
369
|
+
maxChars?: number;
|
|
370
|
+
}): Context {
|
|
371
|
+
const maxChars = args.maxChars ?? 4000;
|
|
372
|
+
return {
|
|
373
|
+
...args.attemptContext,
|
|
374
|
+
messages: [
|
|
375
|
+
...args.attemptContext.messages,
|
|
376
|
+
{
|
|
377
|
+
role: "user",
|
|
378
|
+
content: [
|
|
379
|
+
args.feedback,
|
|
380
|
+
"",
|
|
381
|
+
"Rejected draft (untrusted reference, truncated):",
|
|
382
|
+
args.rejectedText.slice(0, maxChars),
|
|
383
|
+
].join("\n"),
|
|
384
|
+
timestamp: Date.now(),
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
};
|
|
388
|
+
}
|