pi-jev-guard 0.3.0 → 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 +23 -1
- package/config/jev-config.example.json +12 -0
- package/extensions/index.ts +298 -3
- 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
|
@@ -116,10 +116,16 @@ il guarded nelle sessioni senza uno stato Jev salvato.
|
|
|
116
116
|
Un hold `[JEV — OUTPUT NON PUBBLICATO]` interrompe intenzionalmente il turno:
|
|
117
117
|
controlla `/jev last`. Non viene aggirato con un proseguimento automatico.
|
|
118
118
|
|
|
119
|
-
Verdetto **incerto** (`review`): il gate tenta prima una
|
|
119
|
+
Verdetto **incerto** (`review`): il gate tenta prima una riparazione privata
|
|
120
120
|
(`automatic.repairOnReview`, default `true`), poi applica la policy configurata:
|
|
121
121
|
`policy.onUncertain` e `policy.onUnavailable` (`"hold"` default, oppure `"pass"`).
|
|
122
122
|
Con `"pass"` il testo viene pubblicato con una nota di trasparenza sul verdetto.
|
|
123
|
+
|
|
124
|
+
Se un'altra estensione ri-registra lo stesso provider (caso reale: `pi-accounts`
|
|
125
|
+
al cambio account), l'overlay viene ricostruito sopra il provider live prima di
|
|
126
|
+
ogni turno; in più il gate toglie il suffisso `__jev` da qualunque payload,
|
|
127
|
+
registrando `jev-gate-bypass` e avvisando in TUI, perché quel turno non è
|
|
128
|
+
passato dalla verifica.
|
|
123
129
|
Le conferme per comandi di rete sospetti attendono invece una decisione esplicita;
|
|
124
130
|
la normale sequenza tool → risposta successiva continua senza un nuovo prompt.
|
|
125
131
|
|
|
@@ -153,6 +159,19 @@ avvisa e dice di riferirsi al valore per nome; se è un errore, allega il
|
|
|
153
159
|
consiglio per la classe (`transient` → riprova, `code_bug` → fixa il codice…).
|
|
154
160
|
Non blocca mai; `/jev output` mostra l'ultimo verdetto.
|
|
155
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
|
+
|
|
156
175
|
## Configurazione
|
|
157
176
|
|
|
158
177
|
`~/.pi/agent/jev-config.json` (o `$JEV_CONFIG`). Env: `JEV_MODE`, `JEV_BACKEND`
|
|
@@ -204,3 +223,6 @@ echo '{"requirements":"...","candidate":"..."}' | npm run jev-check
|
|
|
204
223
|
- Soglie calibrate su 20 casi (`docs/calibration-2026-09-19.md`): buone per
|
|
205
224
|
iniziare, da rivalutare con corpus più ampio.
|
|
206
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,13 +110,19 @@ 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
|
|
97
117
|
// li attiva (automatic) o l'utente non usa /jev upstream (opt-in).
|
|
98
118
|
interface OverlayState {
|
|
119
|
+
/** Provider live catturato prima dell'override (auth inclusa). */
|
|
99
120
|
upstream: Provider;
|
|
100
121
|
twinIds: string[];
|
|
122
|
+
/** Il nostro overlay registrato, per accorgersi se viene scalzato. */
|
|
123
|
+
provider: Provider;
|
|
124
|
+
/** Ripiego dopo un tentativo di ri-registrazione non riuscito. */
|
|
125
|
+
reassertFailed?: boolean;
|
|
101
126
|
}
|
|
102
127
|
const overlays = new Map<string, OverlayState>();
|
|
103
128
|
|
|
@@ -165,6 +190,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
165
190
|
typedHandle = createTypedAsk(configSnapshot(config));
|
|
166
191
|
outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
|
|
167
192
|
exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
|
|
193
|
+
groundingCache = createVerdictCache<GroundingEvaluation>({ ttlSeconds: config.grounding.cacheSeconds });
|
|
168
194
|
return getConfig();
|
|
169
195
|
}
|
|
170
196
|
|
|
@@ -206,9 +232,49 @@ export default function (pi: ExtensionAPI) {
|
|
|
206
232
|
getConfig,
|
|
207
233
|
getReview: getGateReview,
|
|
208
234
|
onVerdict: recordGateVerdict,
|
|
235
|
+
createPostCheck: createGroundingCheck,
|
|
209
236
|
});
|
|
210
237
|
pi.registerProvider(overlay);
|
|
211
|
-
overlays.set(providerId, {
|
|
238
|
+
overlays.set(providerId, {
|
|
239
|
+
upstream,
|
|
240
|
+
twinIds: [...twinIds],
|
|
241
|
+
provider: overlay,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Un'altra estensione (es. pi-accounts) può ri-registrare lo stesso provider
|
|
247
|
+
* id, sostituendo il nostro overlay con un provider che conserva i modelli
|
|
248
|
+
* (twin compresi) ma non il gate: l'id `__jev` finirebbe all'API.
|
|
249
|
+
* Qui riapplichiamo l'overlay sopra il provider live, preservandone auth e
|
|
250
|
+
* config. Se la ri-registrazione non regge, si smette di ritentare.
|
|
251
|
+
*/
|
|
252
|
+
function reassertOverlays(registry: ModelRegistry): string[] {
|
|
253
|
+
const fixed: string[] = [];
|
|
254
|
+
for (const [providerId, state] of [...overlays]) {
|
|
255
|
+
if (state.reassertFailed) continue;
|
|
256
|
+
let live: Provider | undefined;
|
|
257
|
+
try {
|
|
258
|
+
live = registry.getProvider(providerId);
|
|
259
|
+
} catch {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (!live || live === state.provider) continue;
|
|
263
|
+
try {
|
|
264
|
+
registerOverlay(providerId, live, state.twinIds);
|
|
265
|
+
const applied = overlays.get(providerId);
|
|
266
|
+
if (applied && registry.getProvider(providerId) === applied.provider) {
|
|
267
|
+
fixed.push(providerId);
|
|
268
|
+
} else if (applied) {
|
|
269
|
+
// Il registry restituisce un altro oggetto: non insistere.
|
|
270
|
+
applied.reassertFailed = true;
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
const current = overlays.get(providerId);
|
|
274
|
+
if (current) current.reassertFailed = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return fixed;
|
|
212
278
|
}
|
|
213
279
|
|
|
214
280
|
/**
|
|
@@ -396,6 +462,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
396
462
|
removeTwin,
|
|
397
463
|
switchToGuarded,
|
|
398
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
|
+
},
|
|
399
482
|
getLastGate: () => lastGate,
|
|
400
483
|
});
|
|
401
484
|
|
|
@@ -450,6 +533,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
450
533
|
|
|
451
534
|
async function startSession(ctx: ExtensionContext): Promise<void> {
|
|
452
535
|
restoreMode(ctx);
|
|
536
|
+
// Nuova sessione = nuova evidenza: il ledger non attraversa le sessioni.
|
|
537
|
+
evidenceLedger = [];
|
|
538
|
+
evidenceSeq = 0;
|
|
453
539
|
const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
|
|
454
540
|
if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
|
|
455
541
|
try {
|
|
@@ -513,8 +599,62 @@ export default function (pi: ExtensionAPI) {
|
|
|
513
599
|
});
|
|
514
600
|
pi.on("session_tree", async (_event, ctx) => { await startSession(ctx); });
|
|
515
601
|
|
|
516
|
-
//
|
|
517
|
-
//
|
|
602
|
+
// Prima di ogni turno: se un'altra estensione ci ha scalzati dal provider,
|
|
603
|
+
// rimettiamo l'overlay (altrimenti l'id __jev arriverebbe all'API).
|
|
604
|
+
function noteReassert(fixed: string[]): void {
|
|
605
|
+
if (fixed.length === 0) return;
|
|
606
|
+
try {
|
|
607
|
+
pi.appendEntry("jev-overlay-reassert", { providers: fixed });
|
|
608
|
+
} catch {
|
|
609
|
+
// solo diagnostica
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
614
|
+
try {
|
|
615
|
+
noteReassert(reassertOverlays(ctx.modelRegistry));
|
|
616
|
+
} catch {
|
|
617
|
+
// mai bloccare il turno
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// Ultima rete: se per qualunque motivo il payload esce con l'id sintetico,
|
|
622
|
+
// togliamo il suffisso (il modello originale è quello giusto) e registriamo
|
|
623
|
+
// che quel turno NON è passato dal gate.
|
|
624
|
+
let lastBypassNoticeAt = 0;
|
|
625
|
+
pi.on("before_provider_request", async (event, ctx) => {
|
|
626
|
+
try {
|
|
627
|
+
const payload = event.payload as { model?: unknown } | undefined;
|
|
628
|
+
if (!payload || typeof payload.model !== "string" || !isTwinId(payload.model)) {
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
const requested = payload.model;
|
|
632
|
+
payload.model = untwinId(requested);
|
|
633
|
+
try {
|
|
634
|
+
pi.appendEntry("jev-gate-bypass", {
|
|
635
|
+
requested,
|
|
636
|
+
sent: payload.model,
|
|
637
|
+
provider: ctx.model?.provider,
|
|
638
|
+
});
|
|
639
|
+
} catch {
|
|
640
|
+
// solo diagnostica
|
|
641
|
+
}
|
|
642
|
+
const now = Date.now();
|
|
643
|
+
if (ctx.hasUI && now - lastBypassNoticeAt >= 60_000) {
|
|
644
|
+
lastBypassNoticeAt = now;
|
|
645
|
+
ctx.ui.notify(
|
|
646
|
+
`Jev: richiesta non passata dal gate (${requested} → ${payload.model}). Verifica disattivata per questo turno; riprova per riattivarla.`,
|
|
647
|
+
"warning",
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
return payload;
|
|
651
|
+
} catch {
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// Normalizza gli alias persistiti anche dopo /jev off (overlay rimossi).
|
|
657
|
+
// L'evento context è una copia: la cronologia salvata resta invariata.
|
|
518
658
|
pi.on("context", async (event) => ({
|
|
519
659
|
messages: event.messages.map((message) =>
|
|
520
660
|
message.role === "assistant" && isTwinId(message.model)
|
|
@@ -526,6 +666,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
526
666
|
// su un modello non guarded (revert immediato al twin).
|
|
527
667
|
pi.on("model_select", async (event, ctx) => {
|
|
528
668
|
try {
|
|
669
|
+
// Una selezione account/provider può aver rimpiazzato il nostro overlay.
|
|
670
|
+
noteReassert(reassertOverlays(ctx.modelRegistry));
|
|
529
671
|
if (sessionMode !== "automatic" || !config.automatic.requireGuardedModel) return;
|
|
530
672
|
if (overlays.size === 0) return;
|
|
531
673
|
if (isGuardedModel(event.model.provider, event.model.id)) return;
|
|
@@ -697,11 +839,161 @@ export default function (pi: ExtensionAPI) {
|
|
|
697
839
|
return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
|
|
698
840
|
}
|
|
699
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
|
+
|
|
700
990
|
// Giudice output (solo automatic, fail-open): dopo l'esecuzione cerca
|
|
701
991
|
// secret nel testo e classifica i fallimenti. Non blocca mai: allega
|
|
702
992
|
// una riga al risultato che il modello legge.
|
|
703
993
|
pi.on("tool_result", async (event, ctx) => {
|
|
704
994
|
try {
|
|
995
|
+
// Ledger prima di tutto: vale anche con outputJudge disattivato.
|
|
996
|
+
recordGroundingResult(event);
|
|
705
997
|
if (sessionMode !== "automatic") return;
|
|
706
998
|
if (!config.outputJudge.enabled) return;
|
|
707
999
|
if (!config.outputJudge.tools.includes(event.toolName)) return;
|
|
@@ -768,6 +1060,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
768
1060
|
pi.on("message_end", async (event, ctx) => {
|
|
769
1061
|
try {
|
|
770
1062
|
if (ctx.hasUI) ctx.ui.setStatus("jev", undefined);
|
|
1063
|
+
// Dopo la richiesta: se qualcuno ci ha scalzati, rimetti l'overlay
|
|
1064
|
+
// per il turno successivo (il payload resta già normalizzato).
|
|
1065
|
+
noteReassert(reassertOverlays(ctx.modelRegistry));
|
|
771
1066
|
if (sessionMode !== "automatic") return;
|
|
772
1067
|
if (event.message.role !== "assistant") return;
|
|
773
1068
|
|
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
|
+
}
|