veriquote 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CITATION.cff +2 -2
- package/README.md +63 -32
- package/dist/cli/main.js +12 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/gate.d.ts +1 -1
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +14 -0
- package/dist/gate.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/match/fuzzy.d.ts +10 -2
- package/dist/match/fuzzy.d.ts.map +1 -1
- package/dist/match/fuzzy.js +87 -11
- package/dist/match/fuzzy.js.map +1 -1
- package/dist/protocol/evi1.d.ts +21 -1
- package/dist/protocol/evi1.d.ts.map +1 -1
- package/dist/protocol/evi1.js +169 -28
- package/dist/protocol/evi1.js.map +1 -1
- package/dist/protocol/prompt.d.ts +6 -0
- package/dist/protocol/prompt.d.ts.map +1 -1
- package/dist/protocol/prompt.js +35 -10
- package/dist/protocol/prompt.js.map +1 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +52 -15
- package/dist/report.js.map +1 -1
- package/dist/types.d.ts +23 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/cli/main.ts +13 -2
- package/src/gate.ts +16 -1
- package/src/index.ts +1 -0
- package/src/match/fuzzy.ts +98 -11
- package/src/protocol/evi1.ts +174 -29
- package/src/protocol/prompt.ts +42 -9
- package/src/report.ts +53 -21
- package/src/types.ts +20 -2
package/dist/types.d.ts
CHANGED
|
@@ -52,6 +52,13 @@ export type MatchMethod =
|
|
|
52
52
|
'exact'
|
|
53
53
|
/** Substring after Unicode/typography normalization and case folding. */
|
|
54
54
|
| 'normalized'
|
|
55
|
+
/**
|
|
56
|
+
* The quote omits text, marked with an ellipsis ("…", "...", "[…]"): every
|
|
57
|
+
* fragment occurs literally, in order and close together. `start`/`end`
|
|
58
|
+
* cover the whole passage, omitted text included, and that passage is what
|
|
59
|
+
* the judge sees.
|
|
60
|
+
*/
|
|
61
|
+
| 'elided'
|
|
55
62
|
/** Best fuzzy window by character-trigram Dice similarity. */
|
|
56
63
|
| 'fuzzy'
|
|
57
64
|
/** No window reached the fuzzy score threshold. */
|
|
@@ -60,8 +67,9 @@ export type MatchMethod =
|
|
|
60
67
|
export interface QuoteMatch {
|
|
61
68
|
method: MatchMethod;
|
|
62
69
|
/**
|
|
63
|
-
* Similarity in [0, 1]. `1` only for exact/normalized hits;
|
|
64
|
-
* are capped at 0.99 so a perfect score always implies a
|
|
70
|
+
* Similarity in [0, 1]. `1` only for exact/normalized hits; elided and
|
|
71
|
+
* fuzzy scores are capped at 0.99 so a perfect score always implies a
|
|
72
|
+
* literal hit.
|
|
65
73
|
*/
|
|
66
74
|
score: number;
|
|
67
75
|
/** Start offset of the matched region in the original source field, if known. */
|
|
@@ -70,6 +78,12 @@ export interface QuoteMatch {
|
|
|
70
78
|
end?: number;
|
|
71
79
|
/** Which source field matched: `"text"` or `"extraTexts[i]"`. */
|
|
72
80
|
field: string;
|
|
81
|
+
/**
|
|
82
|
+
* `elided` only: words in the omitted text that can reverse or limit a
|
|
83
|
+
* statement ("not", "except", "only", "however", …). A hint, not a verdict:
|
|
84
|
+
* the judge sees the full passage and decides.
|
|
85
|
+
*/
|
|
86
|
+
omittedCues?: string[];
|
|
73
87
|
}
|
|
74
88
|
/** Qualitative entailment classes produced by the LLM judge. */
|
|
75
89
|
export type EntailmentClass = 'entailed' | 'partially_entailed' | 'overstated' | 'insufficient' | 'contradicted';
|
|
@@ -108,8 +122,15 @@ export interface CitationVerification {
|
|
|
108
122
|
claimId: string;
|
|
109
123
|
sourceIndex: number;
|
|
110
124
|
claimText: string;
|
|
125
|
+
/** The quote; several quotes for the same (claim, source) pair are joined with " […] ". */
|
|
111
126
|
quote: string;
|
|
127
|
+
/** The match of the quote; with several quotes, the weakest one (cues of all of them). */
|
|
112
128
|
textMatch: QuoteMatch;
|
|
129
|
+
/** One entry per quote, present only when the pair has more than one. */
|
|
130
|
+
parts?: {
|
|
131
|
+
quote: string;
|
|
132
|
+
textMatch: QuoteMatch;
|
|
133
|
+
}[];
|
|
113
134
|
/** Absent when verification ran without a judge. */
|
|
114
135
|
entailment?: EntailmentResult;
|
|
115
136
|
/**
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,iFAAiF;IACjF,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,qDAAqD;AACrD,MAAM,WAAW,YAAY;IAC3B,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,WAAW,EAAE,MAAM,CAAC;IACpB,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,uEAAuE;AACvE,MAAM,WAAW,KAAK;IACpB,qCAAqC;IACrC,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,uCAAuC;IACvC,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,iDAAiD;IACjD,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,+CAA+C;AAC/C,MAAM,MAAM,WAAW;AACrB,sDAAsD;AACpD,OAAO;AACT,yEAAyE;GACvE,YAAY;AACd,8DAA8D;GAC5D,OAAO;AACT,mDAAmD;GACjD,WAAW,CAAC;AAEhB,oEAAoE;AACpE,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,WAAW,CAAC;IACpB
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,iFAAiF;IACjF,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,qDAAqD;AACrD,MAAM,WAAW,YAAY;IAC3B,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,WAAW,EAAE,MAAM,CAAC;IACpB,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,uEAAuE;AACvE,MAAM,WAAW,KAAK;IACpB,qCAAqC;IACrC,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,uCAAuC;IACvC,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,iDAAiD;IACjD,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,+CAA+C;AAC/C,MAAM,MAAM,WAAW;AACrB,sDAAsD;AACpD,OAAO;AACT,yEAAyE;GACvE,YAAY;AACd;;;;;GAKG;GACD,QAAQ;AACV,8DAA8D;GAC5D,OAAO;AACT,mDAAmD;GACjD,WAAW,CAAC;AAEhB,oEAAoE;AACpE,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,WAAW,CAAC;IACpB;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,gEAAgE;AAChE,MAAM,MAAM,eAAe,GACvB,UAAU,GACV,oBAAoB,GACpB,YAAY,GACZ,cAAc,GACd,cAAc,CAAC;AAEnB,gDAAgD;AAChD,MAAM,WAAW,gBAAgB;IAC/B,0EAA0E;IAC1E,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC;IACjC,4DAA4D;IAC5D,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,qEAAqE;IACrE,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,0CAA0C;AAC1C,MAAM,WAAW,eAAe;IAC9B,0DAA0D;IAC1D,EAAE,EAAE,MAAM,CAAC;IACX,yCAAyC;IACzC,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,KAAK,EAAE,eAAe,EAAE,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;CAClG;AAED,qEAAqE;AACrE,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,2FAA2F;IAC3F,KAAK,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,SAAS,EAAE,UAAU,CAAC;IACtB,yEAAyE;IACzE,KAAK,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,UAAU,CAAA;KAAE,EAAE,CAAC;IACnD,oDAAoD;IACpD,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B;;;OAGG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,6DAA6D;AAC7D,MAAM,WAAW,mBAAmB;IAClC,aAAa,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,+EAA+E;IAC/E,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,wDAAwD;IACxD,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,SAAS,EAAE,oBAAoB,EAAE,CAAC;IAClC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,EAAE,mBAAmB,CAAC;CAC9B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "veriquote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Deterministic + semantic verification of quote-grounded LLM citations (EVI1 protocol): fuzzy verbatim-quote matching and an LLM entailment judge for transparent, per-claim hallucination detection.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"author": "NavigNine (https://navignine.com)",
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
23
|
-
"url": "https://github.com/rickintoplace/veriquote.git"
|
|
23
|
+
"url": "git+https://github.com/rickintoplace/veriquote.git"
|
|
24
24
|
},
|
|
25
25
|
"homepage": "https://github.com/rickintoplace/veriquote#readme",
|
|
26
26
|
"bugs": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"main": "./dist/index.js",
|
|
31
31
|
"types": "./dist/index.d.ts",
|
|
32
32
|
"bin": {
|
|
33
|
-
"veriquote": "
|
|
33
|
+
"veriquote": "bin/veriquote.mjs"
|
|
34
34
|
},
|
|
35
35
|
"exports": {
|
|
36
36
|
".": {
|
package/src/cli/main.ts
CHANGED
|
@@ -262,6 +262,13 @@ function judgeFromEnv(args: Args, io: CliIo): { judge?: EntailmentJudge; model?:
|
|
|
262
262
|
|
|
263
263
|
// ------------------------------------------------------------------ output
|
|
264
264
|
|
|
265
|
+
const MATCH_LABEL: Record<string, string> = {
|
|
266
|
+
exact: 'verbatim',
|
|
267
|
+
normalized: 'verbatim',
|
|
268
|
+
elided: 'verbatim with omission',
|
|
269
|
+
fuzzy: 'fuzzy',
|
|
270
|
+
};
|
|
271
|
+
|
|
265
272
|
function renderHuman(
|
|
266
273
|
report: VerificationReport,
|
|
267
274
|
gate: GateResult,
|
|
@@ -289,7 +296,7 @@ function renderHuman(
|
|
|
289
296
|
const match =
|
|
290
297
|
cit.textMatch.method === 'not_found'
|
|
291
298
|
? red(`quote not in source (best ${cit.textMatch.score.toFixed(2)})`)
|
|
292
|
-
: `${cit.textMatch.method
|
|
299
|
+
: `${MATCH_LABEL[cit.textMatch.method]} ${cit.textMatch.score.toFixed(2)}`;
|
|
293
300
|
const judged = cit.entailment
|
|
294
301
|
? cit.entailment.class === 'error'
|
|
295
302
|
? yellow('judge error')
|
|
@@ -297,8 +304,12 @@ function renderHuman(
|
|
|
297
304
|
: '';
|
|
298
305
|
out.push(`${mark} ${cit.claimId} [${cit.sourceIndex}] ${[match, judged].filter(Boolean).join(' · ')}`);
|
|
299
306
|
out.push(` ${truncate(cit.claimText || '(no claim text)', 160)}`);
|
|
307
|
+
const hides = gate.problems.find((p) => p.claimId === cit.claimId && p.sourceIndex === cit.sourceIndex)?.type === 'ellipsis_hides_qualifier';
|
|
300
308
|
if (bad && cit.textMatch.method === 'not_found') out.push(dim(' the quoted text does not occur in the source'));
|
|
301
|
-
else if (
|
|
309
|
+
else if (hides) {
|
|
310
|
+
const cues = (cit.textMatch.omittedCues ?? []).map((c) => `"${c}"`).join(', ');
|
|
311
|
+
out.push(dim(` the ellipsis leaves out ${cues}; run with a judge to check the full passage`));
|
|
312
|
+
} else if (bad && cit.entailment?.reasons.length) out.push(dim(` ${cit.entailment.reasons.map((r) => r.trim().replace(/[.;]+$/, '')).join('; ')}`));
|
|
302
313
|
else if (bad && cit.score !== null && cit.textMatch.method !== 'not_found') {
|
|
303
314
|
out.push(dim(` combined score ${cit.score.toFixed(2)} is below the minimum ${minScore.toFixed(2)}`));
|
|
304
315
|
}
|
package/src/gate.ts
CHANGED
|
@@ -19,7 +19,8 @@ export type ProblemType =
|
|
|
19
19
|
| 'quote_not_in_source'
|
|
20
20
|
| 'contradicted_by_source'
|
|
21
21
|
| 'overstated'
|
|
22
|
-
| 'weakly_supported'
|
|
22
|
+
| 'weakly_supported'
|
|
23
|
+
| 'ellipsis_hides_qualifier';
|
|
23
24
|
|
|
24
25
|
export interface GateProblem {
|
|
25
26
|
claimId: string;
|
|
@@ -90,14 +91,26 @@ export function gateReport(
|
|
|
90
91
|
|
|
91
92
|
function isProblem(c: CitationVerification, minScore: number): boolean {
|
|
92
93
|
if (c.textMatch.method === 'not_found') return true;
|
|
94
|
+
if (hidesQualifier(c)) return true;
|
|
93
95
|
const cls = c.entailment?.class;
|
|
94
96
|
if (cls === 'contradicted' || cls === 'overstated') return true;
|
|
95
97
|
// A null score means the judge errored; an outage is not evidence against the claim.
|
|
96
98
|
return c.score !== null && c.score < minScore;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The ellipsis leaves out a word such as "not" or "except", and no judge has
|
|
103
|
+
* read the full passage. A judge verdict settles it either way ("not only …
|
|
104
|
+
* but also" is harmless), so this only fires without one.
|
|
105
|
+
*/
|
|
106
|
+
function hidesQualifier(c: CitationVerification): boolean {
|
|
107
|
+
const judged = c.entailment && c.entailment.class !== 'error';
|
|
108
|
+
return !judged && (c.textMatch.omittedCues?.length ?? 0) > 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
function problemType(c: CitationVerification): ProblemType {
|
|
100
112
|
if (c.textMatch.method === 'not_found') return 'quote_not_in_source';
|
|
113
|
+
if (hidesQualifier(c)) return 'ellipsis_hides_qualifier';
|
|
101
114
|
if (c.entailment?.class === 'contradicted') return 'contradicted_by_source';
|
|
102
115
|
if (c.entailment?.class === 'overstated') return 'overstated';
|
|
103
116
|
return 'weakly_supported';
|
|
@@ -141,6 +154,8 @@ const FIX: Record<ProblemType, string> = {
|
|
|
141
154
|
contradicted_by_source: 'the cited source contradicts this claim — remove the claim or correct it to match the source',
|
|
142
155
|
overstated: 'this claim is stronger or more general than the evidence — weaken it to exactly what the quote supports',
|
|
143
156
|
weakly_supported: 'this claim is only weakly supported — cite a better passage or soften the wording',
|
|
157
|
+
ellipsis_hides_qualifier:
|
|
158
|
+
'the ellipsis in the quote leaves out a word that may reverse or limit the statement — quote the passage without the omission, or split it into separate quotes',
|
|
144
159
|
};
|
|
145
160
|
|
|
146
161
|
function correctionInstructions(problems: GateProblem[], uncited: string[]): string {
|
package/src/index.ts
CHANGED
package/src/match/fuzzy.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Strategy, in order of preference:
|
|
5
5
|
* 1. exact raw substring,
|
|
6
|
-
* 2. exact substring after normalization (quotes, dashes, case, whitespace
|
|
7
|
-
*
|
|
6
|
+
* 2. exact substring after normalization (quotes, dashes, case, whitespace,
|
|
7
|
+
* an ellipsis at either end),
|
|
8
|
+
* 3. elided: the fragments around inner ellipses occur literally, in order,
|
|
9
|
+
* with at most `maxElisionGap` characters left out between them,
|
|
10
|
+
* 4. best sliding window by character-trigram Dice similarity, with a
|
|
8
11
|
* coarse scan followed by a fine refinement around the best offset.
|
|
9
12
|
*
|
|
10
13
|
* Everything here is pure and deterministic: same inputs, same result.
|
|
@@ -25,6 +28,11 @@ export interface MatchOptions {
|
|
|
25
28
|
fuzzyThreshold?: number;
|
|
26
29
|
/** Window sizes to try, as multiples of the quote length. Default [0.85, 1, 1.15]. */
|
|
27
30
|
windowScales?: number[];
|
|
31
|
+
/**
|
|
32
|
+
* Most (normalized) characters one ellipsis may stand for. Fragments further
|
|
33
|
+
* apart are separate passages, not one elided quote. Default 300.
|
|
34
|
+
*/
|
|
35
|
+
maxElisionGap?: number;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
const DEFAULTS: Required<MatchOptions> = {
|
|
@@ -32,8 +40,43 @@ const DEFAULTS: Required<MatchOptions> = {
|
|
|
32
40
|
shortQuoteLength: 90,
|
|
33
41
|
fuzzyThreshold: 0.4,
|
|
34
42
|
windowScales: [0.85, 1, 1.15],
|
|
43
|
+
maxElisionGap: 300,
|
|
35
44
|
};
|
|
36
45
|
|
|
46
|
+
/** "...", "…" (NFKC turns it into "..."), "[...]", "(...)", ". . ." — in normalized text. */
|
|
47
|
+
const ELLIPSIS = /\s*(?:\[\s*\.(?:\s?\.){2,}\s*\]|\(\s*\.(?:\s?\.){2,}\s*\)|\.(?:\s?\.){2,})\s*/g;
|
|
48
|
+
/** An elided quote needs one fragment at least this long to anchor it. */
|
|
49
|
+
const MIN_ELISION_ANCHOR = 20;
|
|
50
|
+
/** Elided matches rank just below literal ones. */
|
|
51
|
+
const ELIDED_SCORE = 0.99;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Words that reverse, limit or qualify a statement, in English and German.
|
|
55
|
+
* Found in the text an ellipsis leaves out, they make the elision worth a
|
|
56
|
+
* second look: "the landlord is … permitted to terminate" may have dropped
|
|
57
|
+
* a "not".
|
|
58
|
+
*/
|
|
59
|
+
const CUE_WORDS = [
|
|
60
|
+
// negation
|
|
61
|
+
'not', 'no', 'never', 'none', 'neither', 'nor', 'without', 'cannot', 'nothing', 'nobody',
|
|
62
|
+
'nicht', 'kein', 'keine', 'keinen', 'keinem', 'keiner', 'keines', 'nie', 'niemals', 'weder', 'ohne',
|
|
63
|
+
// exception, condition, limitation
|
|
64
|
+
'except', 'unless', 'excluding', 'only', 'außer', 'ausser', 'ausgenommen', 'sofern', 'nur',
|
|
65
|
+
// contrast
|
|
66
|
+
'but', 'however', 'although', 'though', 'whereas', 'despite',
|
|
67
|
+
'aber', 'jedoch', 'obwohl', 'allerdings', 'trotz',
|
|
68
|
+
];
|
|
69
|
+
const CUE = new RegExp(`(?<![\\p{L}\\p{N}])(?:${CUE_WORDS.join('|')})(?![\\p{L}\\p{N}])|(?<=\\p{L})n't(?!\\p{L})`, 'gu');
|
|
70
|
+
|
|
71
|
+
/** Cue words in the given ranges of normalized text, deduplicated, in order of appearance. */
|
|
72
|
+
function cuesIn(text: string, ranges: Array<[number, number]>): string[] {
|
|
73
|
+
const found = new Set<string>();
|
|
74
|
+
for (const [from, to] of ranges) {
|
|
75
|
+
for (const m of text.slice(from, to).matchAll(CUE)) found.add(m[0]);
|
|
76
|
+
}
|
|
77
|
+
return [...found];
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
/** Multiset of character trigrams of `s` as gram -> count. */
|
|
38
81
|
export function trigramCounts(s: string): Map<string, number> {
|
|
39
82
|
const out = new Map<string, number>();
|
|
@@ -192,15 +235,31 @@ export function matchQuoteAgainstText(
|
|
|
192
235
|
const t = normalizeForMatch(text);
|
|
193
236
|
if (!q.text || !t.text) return empty;
|
|
194
237
|
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
238
|
+
const fragments = q.text.split(ELLIPSIS).filter(Boolean);
|
|
239
|
+
if (fragments.length === 1) {
|
|
240
|
+
const normIndex = t.text.indexOf(fragments[0]);
|
|
241
|
+
if (normIndex !== -1) {
|
|
242
|
+
return {
|
|
243
|
+
method: 'normalized',
|
|
244
|
+
score: 1,
|
|
245
|
+
start: t.map[normIndex],
|
|
246
|
+
end: mapEndOffset(t.map, normIndex + fragments[0].length - 1, text),
|
|
247
|
+
field: 'text',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
} else if (fragments.some((f) => f.length >= MIN_ELISION_ANCHOR)) {
|
|
251
|
+
const span = findElided(fragments, t.text, opts.maxElisionGap);
|
|
252
|
+
if (span) {
|
|
253
|
+
const cues = cuesIn(t.text, span.gaps);
|
|
254
|
+
return {
|
|
255
|
+
method: 'elided',
|
|
256
|
+
score: ELIDED_SCORE,
|
|
257
|
+
start: t.map[span.start],
|
|
258
|
+
end: mapEndOffset(t.map, span.end - 1, text),
|
|
259
|
+
field: 'text',
|
|
260
|
+
...(cues.length ? { omittedCues: cues } : {}),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
204
263
|
}
|
|
205
264
|
|
|
206
265
|
const hit = bestFuzzyWindow(q.text, t.text, opts.windowScales);
|
|
@@ -219,6 +278,34 @@ export function matchQuoteAgainstText(
|
|
|
219
278
|
};
|
|
220
279
|
}
|
|
221
280
|
|
|
281
|
+
interface ElidedSpan {
|
|
282
|
+
start: number;
|
|
283
|
+
end: number;
|
|
284
|
+
/** The omitted stretches between fragments, as [from, to) in `text`. */
|
|
285
|
+
gaps: Array<[number, number]>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* First span of `text` holding every fragment literally, in order, with gaps of
|
|
290
|
+
* at most `maxGap`; offsets refer to `text`. Null if there is none.
|
|
291
|
+
*/
|
|
292
|
+
function findElided(fragments: string[], text: string, maxGap: number): ElidedSpan | null {
|
|
293
|
+
for (let first = text.indexOf(fragments[0]); first !== -1; first = text.indexOf(fragments[0], first + 1)) {
|
|
294
|
+
let end = first + fragments[0].length;
|
|
295
|
+
const gaps: Array<[number, number]> = [];
|
|
296
|
+
for (const fragment of fragments.slice(1)) {
|
|
297
|
+
// The earliest occurrence leaves the most room for the fragments after it.
|
|
298
|
+
const at = text.indexOf(fragment, end);
|
|
299
|
+
if (at === -1) return null;
|
|
300
|
+
if (at - end > maxGap) break;
|
|
301
|
+
gaps.push([end, at]);
|
|
302
|
+
end = at + fragment.length;
|
|
303
|
+
}
|
|
304
|
+
if (gaps.length === fragments.length - 1) return { start: first, end, gaps };
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
222
309
|
/** Exclusive end offset in the original text for the normalized char at `lastIndex`. */
|
|
223
310
|
function mapEndOffset(map: Int32Array, lastIndex: number, original: string): number {
|
|
224
311
|
const srcIndex = map[Math.max(0, Math.min(lastIndex, map.length - 1))];
|
package/src/protocol/evi1.ts
CHANGED
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
* c1|5|"verbatim quote from source 5"
|
|
12
12
|
* END_EVI1
|
|
13
13
|
*
|
|
14
|
+
* A (claim, source) pair may have several lines when one passage does not
|
|
15
|
+
* cover the whole claim. With `evidenceFirst` prompting the block comes
|
|
16
|
+
* before the answer instead; the parser accepts either position.
|
|
17
|
+
*
|
|
14
18
|
* Quotes are single-line, with `\n`, `\"` and `\\` escapes.
|
|
19
|
+
*
|
|
20
|
+
* The parser is lenient about how the appendix is framed (see
|
|
21
|
+
* `parseEvi1Appendix`), strict about the evidence lines themselves.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
import type { Claim, EvidenceItem, ParsedAnswer } from '../types.js';
|
|
@@ -20,10 +27,25 @@ import { collapseWhitespace } from '../match/normalize.js';
|
|
|
20
27
|
export const EVI1_START = 'EVI1';
|
|
21
28
|
export const EVI1_END = 'END_EVI1';
|
|
22
29
|
|
|
23
|
-
const EVIDENCE_LINE = /^(c\d+)
|
|
30
|
+
const EVIDENCE_LINE = /^(c\d+)\s*\|\s*(\d+)\s*\|\s*"([\s\S]*)"$/;
|
|
24
31
|
const CLAIM_MARKER = /\{c(\d+)\}/g;
|
|
25
32
|
const CITATION_GROUP_BEFORE_CLAIM = /((?:\[\d+\])+)$/;
|
|
26
33
|
|
|
34
|
+
// Models and gateways garble the appendix slightly: "**EVI1**", "EVI1:",
|
|
35
|
+
// "EVI1 c1|2|...", a code fence around it, END_EVI11 or no END_EVI1 at all.
|
|
36
|
+
// The lookahead keeps a sentence about the EVI1 gene from opening an appendix.
|
|
37
|
+
const START_LINE = /^[\s*_`>#]*EVI1[\s*_`:]*(?=$|c\d+\s*\|)/;
|
|
38
|
+
const END_LINE = /^[\s*_`]*END_EVI1/;
|
|
39
|
+
const EVIDENCE_PREFIX = /^\s*c\d+\s*\|\s*\d+\s*\|/;
|
|
40
|
+
// Without END_EVI1, the appendix runs as long as lines look like evidence,
|
|
41
|
+
// malformed ones included (they get a warning, not the answer body).
|
|
42
|
+
const EVIDENCE_LIKE = /^\s*c\d+\s*\|/;
|
|
43
|
+
const FENCE_LINE = /^\s*```\s*\w*\s*$/;
|
|
44
|
+
// While streaming, the last line may be the beginning of an EVI1, evidence or END_EVI1 line.
|
|
45
|
+
const PARTIAL_START =
|
|
46
|
+
/^[\s*_`>#]*(?:E(?:V(?:I(?:1[\s*_`:]*(?:c\d*)?)?)?)?)?$|^[\s*_`]*EN(?:D(?:_(?:E(?:V(?:I1?)?)?)?)?)?$|^\s*c\d*(?:\s*\|\s*\d*)?\s*$/;
|
|
47
|
+
const PARTIAL_MARKER = /\{(?:c\d*)?$/;
|
|
48
|
+
|
|
27
49
|
/** Unescape an EVI1 quote payload (`\\`, `\n`, `\"`) in a single pass. */
|
|
28
50
|
function unescapeQuote(s: string): string {
|
|
29
51
|
return s.replace(/\\(.)/g, (_, c: string) => (c === 'n' ? '\n' : c));
|
|
@@ -35,41 +57,110 @@ function escapeQuote(s: string): string {
|
|
|
35
57
|
}
|
|
36
58
|
|
|
37
59
|
interface AppendixLocation {
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
|
|
60
|
+
/** First line to cut: the EVI1 line (or first evidence line), or a fence opened just before it. */
|
|
61
|
+
cutStart: number;
|
|
62
|
+
/**
|
|
63
|
+
* First line after the appendix: past END_EVI1 and a closing fence, or,
|
|
64
|
+
* without END_EVI1, the first line that is not evidence (an answer after an
|
|
65
|
+
* evidence-first block), else `lines.length`.
|
|
66
|
+
*/
|
|
67
|
+
cutEnd: number;
|
|
68
|
+
/** Candidate evidence lines, the text after an inline `EVI1` header included. */
|
|
69
|
+
body: string[];
|
|
42
70
|
}
|
|
43
71
|
|
|
44
|
-
|
|
72
|
+
const isBlank = (line: string) => !line.trim();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether the EVI1 line at `start` really opens the appendix: the next
|
|
76
|
+
* non-blank line is evidence, or END_EVI1 follows somewhere.
|
|
77
|
+
* A heading "## EVI1" followed by prose does not.
|
|
78
|
+
*/
|
|
79
|
+
function opensAppendix(lines: string[], start: number): boolean {
|
|
80
|
+
if (lines[start].replace(START_LINE, '').trim()) return true; // "EVI1 c1|…"
|
|
81
|
+
let i = start + 1;
|
|
82
|
+
while (i < lines.length && (isBlank(lines[i]) || FENCE_LINE.test(lines[i]))) i++;
|
|
83
|
+
if (EVIDENCE_PREFIX.test(lines[i] ?? '')) return true;
|
|
84
|
+
return lines.slice(i).some((l) => END_LINE.test(l));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Start of the appendix: the last EVI1 line, or else a run of evidence lines
|
|
89
|
+
* that ends the text. -1 if there is none.
|
|
90
|
+
*/
|
|
91
|
+
function findAppendixStart(lines: string[]): number {
|
|
45
92
|
// Scan from the end so answer text that merely mentions "EVI1" is ignored.
|
|
46
|
-
for (let
|
|
47
|
-
if (lines[
|
|
48
|
-
|
|
49
|
-
|
|
93
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
94
|
+
if (START_LINE.test(lines[i]) && opensAppendix(lines, i)) return i;
|
|
95
|
+
}
|
|
96
|
+
let i = lines.length - 1;
|
|
97
|
+
while (i >= 0 && (isBlank(lines[i]) || END_LINE.test(lines[i]) || FENCE_LINE.test(lines[i]))) i--;
|
|
98
|
+
const last = i;
|
|
99
|
+
while (i >= 0 && EVIDENCE_PREFIX.test(lines[i])) i--;
|
|
100
|
+
return i < last ? i + 1 : -1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function locateAppendix(lines: string[]): AppendixLocation | null {
|
|
104
|
+
const start = findAppendixStart(lines);
|
|
105
|
+
if (start < 0) return null;
|
|
106
|
+
|
|
107
|
+
const headerRest = lines[start].replace(START_LINE, '');
|
|
108
|
+
let endLine = END_LINE.test(headerRest) ? start : -1;
|
|
109
|
+
for (let i = start + 1; endLine < 0 && i < lines.length; i++) {
|
|
110
|
+
if (END_LINE.test(lines[i])) endLine = i;
|
|
111
|
+
}
|
|
112
|
+
let stop = endLine >= 0 ? endLine : start + 1;
|
|
113
|
+
if (endLine < 0) {
|
|
114
|
+
while (stop < lines.length && (isBlank(lines[stop]) || FENCE_LINE.test(lines[stop]) || EVIDENCE_LIKE.test(lines[stop]))) stop++;
|
|
115
|
+
// Blank lines before the answer that follows belong to the answer.
|
|
116
|
+
if (stop < lines.length) while (stop > start + 1 && isBlank(lines[stop - 1])) stop--;
|
|
117
|
+
}
|
|
118
|
+
const body = endLine === start ? [] : [headerRest, ...lines.slice(start + 1, stop)];
|
|
119
|
+
|
|
120
|
+
// A fence the model opened around the appendix goes with it, but not one
|
|
121
|
+
// that closes an earlier code block.
|
|
122
|
+
let cutStart = start;
|
|
123
|
+
let openedFence = /^\s*```/.test(lines[start]);
|
|
124
|
+
let before = start - 1;
|
|
125
|
+
while (before >= 0 && isBlank(lines[before])) before--;
|
|
126
|
+
if (!openedFence && before >= 0 && FENCE_LINE.test(lines[before])) {
|
|
127
|
+
const fences = lines.slice(0, before + 1).filter((l) => FENCE_LINE.test(l)).length;
|
|
128
|
+
if (fences % 2 === 1) {
|
|
129
|
+
cutStart = before;
|
|
130
|
+
openedFence = true;
|
|
50
131
|
}
|
|
51
|
-
return null;
|
|
52
132
|
}
|
|
53
|
-
|
|
133
|
+
|
|
134
|
+
let cutEnd = endLine < 0 ? stop : endLine + 1;
|
|
135
|
+
if (openedFence && endLine >= 0 && !/```/.test(lines[endLine])) {
|
|
136
|
+
let after = cutEnd;
|
|
137
|
+
while (after < lines.length && isBlank(lines[after])) after++;
|
|
138
|
+
if (after < lines.length && FENCE_LINE.test(lines[after])) cutEnd = after + 1;
|
|
139
|
+
}
|
|
140
|
+
return { cutStart, cutEnd, body };
|
|
54
141
|
}
|
|
55
142
|
|
|
56
143
|
/**
|
|
57
144
|
* Parse the EVI1 appendix of a raw model answer.
|
|
58
145
|
* Returns the evidence items plus warnings for malformed lines;
|
|
59
|
-
* `null` when no
|
|
146
|
+
* `null` when no appendix is present.
|
|
147
|
+
*
|
|
148
|
+
* Tolerant of common deviations: a decorated start line (`**EVI1**`,
|
|
149
|
+
* `EVI1:`, `EVI1 c1|…`), a code fence around the appendix, a missing or
|
|
150
|
+
* garbled `END_EVI1`, and a missing `EVI1` line when the answer ends in
|
|
151
|
+
* evidence lines.
|
|
60
152
|
*/
|
|
61
153
|
export function parseEvi1Appendix(
|
|
62
154
|
answer: string,
|
|
63
155
|
): { items: EvidenceItem[]; warnings: string[] } | null {
|
|
64
|
-
const
|
|
65
|
-
const loc = locateAppendix(lines);
|
|
156
|
+
const loc = locateAppendix(String(answer).split('\n'));
|
|
66
157
|
if (!loc) return null;
|
|
67
158
|
|
|
68
159
|
const items: EvidenceItem[] = [];
|
|
69
160
|
const warnings: string[] = [];
|
|
70
|
-
for (
|
|
71
|
-
const line =
|
|
72
|
-
if (!line) continue;
|
|
161
|
+
for (const raw of loc.body) {
|
|
162
|
+
const line = raw.trim();
|
|
163
|
+
if (!line || FENCE_LINE.test(line)) continue;
|
|
73
164
|
const m = line.match(EVIDENCE_LINE);
|
|
74
165
|
if (!m) {
|
|
75
166
|
warnings.push(`EVI1: malformed evidence line ignored: ${truncate(line, 80)}`);
|
|
@@ -85,16 +176,51 @@ export function parseEvi1Appendix(
|
|
|
85
176
|
return { items, warnings };
|
|
86
177
|
}
|
|
87
178
|
|
|
179
|
+
function cutAppendix(lines: string[], loc: AppendixLocation): string {
|
|
180
|
+
const head = lines.slice(0, loc.cutStart);
|
|
181
|
+
const tail = lines.slice(loc.cutEnd);
|
|
182
|
+
// An evidence-first block leaves blank lines before the answer.
|
|
183
|
+
if (head.every(isBlank)) {
|
|
184
|
+
head.length = 0;
|
|
185
|
+
while (tail.length && isBlank(tail[0])) tail.shift();
|
|
186
|
+
}
|
|
187
|
+
return head.concat(tail).join('\n').trimEnd();
|
|
188
|
+
}
|
|
189
|
+
|
|
88
190
|
/** Remove the EVI1 appendix from a raw answer; returns the answer body. */
|
|
89
191
|
export function stripEvi1Appendix(answer: string): string {
|
|
90
192
|
const lines = String(answer).split('\n');
|
|
91
193
|
const loc = locateAppendix(lines);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
194
|
+
return loc ? cutAppendix(lines, loc) : answer;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether the last line may be the beginning of an appendix (but not a fence closing a code block). */
|
|
198
|
+
function isPartialStart(lines: string[]): boolean {
|
|
199
|
+
const last = lines[lines.length - 1];
|
|
200
|
+
if (isBlank(last) || !PARTIAL_START.test(last)) return false;
|
|
201
|
+
return !/```/.test(last) || lines.filter((l) => /^\s*```/.test(l)).length % 2 === 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The answer as a reader should see it, also while it is still streaming:
|
|
206
|
+
* the appendix (complete or not) is cut, as is a last line that may be the
|
|
207
|
+
* beginning of one (`E`, `EV`, `EVI`, `EVI1`, `c1|…`), and all `{cX}`
|
|
208
|
+
* markers are removed, including a half-streamed `{c…` at the end.
|
|
209
|
+
* `[n]` citation markers are kept.
|
|
210
|
+
*/
|
|
211
|
+
export function stripForDisplay(text: string): string {
|
|
212
|
+
let lines = String(text).split('\n');
|
|
213
|
+
const popBlank = () => {
|
|
214
|
+
while (lines.length && isBlank(lines[lines.length - 1])) lines.pop();
|
|
215
|
+
};
|
|
216
|
+
popBlank();
|
|
217
|
+
while (lines.length && isPartialStart(lines)) {
|
|
218
|
+
lines.pop();
|
|
219
|
+
popBlank();
|
|
220
|
+
}
|
|
221
|
+
const loc = locateAppendix(lines);
|
|
222
|
+
if (loc) lines = cutAppendix(lines, loc).split('\n');
|
|
223
|
+
return stripClaimMarkers(lines.join('\n').replace(PARTIAL_MARKER, '')).trimEnd();
|
|
98
224
|
}
|
|
99
225
|
|
|
100
226
|
/** Serialize evidence items into an EVI1 appendix block. */
|
|
@@ -173,24 +299,43 @@ export function parseAnswer(answer: string): ParsedAnswer {
|
|
|
173
299
|
|
|
174
300
|
const body = stripEvi1Appendix(answer);
|
|
175
301
|
const { claims, warnings: claimWarnings } = extractClaims(body);
|
|
176
|
-
|
|
302
|
+
// A claim marker without its [n] group still names its sources in the
|
|
303
|
+
// appendix; take them from there instead of losing the claim's evidence.
|
|
304
|
+
const noGroup = new Set<string>();
|
|
305
|
+
for (const c of claims) {
|
|
306
|
+
if (c.sourceIndexes.length) continue;
|
|
307
|
+
const fromAppendix = [...new Set(evidence.filter((e) => e.claimId === c.id).map((e) => e.sourceIndex))];
|
|
308
|
+
if (!fromAppendix.length) continue;
|
|
309
|
+
c.sourceIndexes = fromAppendix;
|
|
310
|
+
noGroup.add(`Claim ${c.id}: no citation group directly before marker.`);
|
|
311
|
+
}
|
|
312
|
+
warnings.push(
|
|
313
|
+
...claimWarnings.map((w) =>
|
|
314
|
+
noGroup.has(w) ? w.replace(/\.$/, '; sources taken from the EVI1 appendix.') : w,
|
|
315
|
+
),
|
|
316
|
+
);
|
|
177
317
|
|
|
178
318
|
const cited = new Set(
|
|
179
319
|
claims.flatMap((c) => c.sourceIndexes.map((n) => `${c.id}|${n}`)),
|
|
180
320
|
);
|
|
321
|
+
// Several lines for one (claim, source) pair are allowed: a claim may need
|
|
322
|
+
// two passages of the same source. Only repeated quotes are dropped.
|
|
181
323
|
const evidenced = new Set<string>();
|
|
324
|
+
const seenLines = new Set<string>();
|
|
182
325
|
const dedupedEvidence: typeof evidence = [];
|
|
183
326
|
for (const it of evidence) {
|
|
184
327
|
const key = `${it.claimId}|${it.sourceIndex}`;
|
|
185
|
-
|
|
186
|
-
|
|
328
|
+
const line = `${key}|${collapseWhitespace(it.quote)}`;
|
|
329
|
+
if (seenLines.has(line)) {
|
|
330
|
+
warnings.push(`EVI1: repeated quote for ${key}; keeping first occurrence.`);
|
|
187
331
|
continue;
|
|
188
332
|
}
|
|
189
|
-
|
|
333
|
+
seenLines.add(line);
|
|
190
334
|
dedupedEvidence.push(it);
|
|
191
|
-
if (!cited.has(key)) {
|
|
335
|
+
if (!evidenced.has(key) && !cited.has(key)) {
|
|
192
336
|
warnings.push(`EVI1: evidence for ${key} has no matching citation in the answer.`);
|
|
193
337
|
}
|
|
338
|
+
evidenced.add(key);
|
|
194
339
|
}
|
|
195
340
|
for (const key of cited) {
|
|
196
341
|
if (!evidenced.has(key)) {
|