stilltrue 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +263 -18
- package/dist/cli.js +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.d.ts +1 -1
- package/dist/init.js +37 -43
- package/dist/support.d.ts +157 -0
- package/dist/support.js +188 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ update it.
|
|
|
23
23
|
|---|---|
|
|
24
24
|
| `npx stilltrue drift` | Checks your saved facts against their live sources. Fails loudly if a fact has gone stale. |
|
|
25
25
|
| `npx stilltrue report` | Turns past check runs into a simple web page — green/red grid, what broke and when. |
|
|
26
|
-
| `verify` | For developers: a checkpoint that inspects AI-generated answers before they're shown to users. |
|
|
26
|
+
| `verify` | For developers: a checkpoint that inspects AI-generated answers before they're shown to users. `supportStage` ships the common one — does this source support this claim? |
|
|
27
27
|
|
|
28
28
|
## Getting started
|
|
29
29
|
|
|
@@ -36,8 +36,8 @@ project folder with your facts saved in a JSON file.
|
|
|
36
36
|
npm install --save-dev stilltrue
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
**2. Scaffold a check** — this creates a ready-to-edit config file
|
|
40
|
-
|
|
39
|
+
**2. Scaffold a check** — this creates a ready-to-edit config file (it
|
|
40
|
+
never overwrites anything you already have):
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
43
|
npx stilltrue init
|
|
@@ -45,34 +45,52 @@ npx stilltrue init
|
|
|
45
45
|
|
|
46
46
|
Open the generated `stilltrue.config.mjs`. Each check answers three
|
|
47
47
|
questions: *where do I fetch the truth from* (`source`), *what do I
|
|
48
|
-
believe* (`expect`), and *how do I compare the two* (`compare`)
|
|
48
|
+
believe* (`expect`), and *how do I compare the two* (`compare`) — and in
|
|
49
|
+
its simplest form, **the only editing needed is replacing strings**:
|
|
49
50
|
|
|
50
51
|
```js
|
|
51
|
-
import { defineStilltrue, corpus
|
|
52
|
+
import { defineStilltrue, corpus } from 'stilltrue';
|
|
52
53
|
|
|
53
54
|
export default defineStilltrue({
|
|
54
55
|
drift: [
|
|
55
56
|
{
|
|
56
|
-
name: '
|
|
57
|
-
// Where the truth lives: the
|
|
58
|
-
source: corpus(['https://
|
|
59
|
-
// What you believe:
|
|
60
|
-
expect:
|
|
61
|
-
|
|
62
|
-
),
|
|
63
|
-
// How to compare: every name must still appear on those pages.
|
|
57
|
+
name: 'my-first-check',
|
|
58
|
+
// Where the truth lives: the official page(s) your facts came from.
|
|
59
|
+
source: corpus(['https://example.org/about']),
|
|
60
|
+
// What you believe: your facts, as short strings ("markers").
|
|
61
|
+
expect: ['Jane Smith', 'Main Street Office', '12 locations'],
|
|
62
|
+
// How to compare: every marker must still appear on those pages.
|
|
64
63
|
compare: 'contains-all',
|
|
65
64
|
},
|
|
66
65
|
],
|
|
67
66
|
});
|
|
68
67
|
```
|
|
69
68
|
|
|
70
|
-
This
|
|
71
|
-
|
|
69
|
+
This says: *fetch that page, and make sure "Jane Smith", "Main Street
|
|
70
|
+
Office", and "12 locations" all still appear on it.* Swap in your own URL
|
|
71
|
+
and your own facts — that's a working check, no programming required.
|
|
72
|
+
|
|
73
|
+
Every other field, compare mode, and helper is in
|
|
74
|
+
[Configuration](#configuration) below. Choosing *what* to assert is the
|
|
75
|
+
judgment call — surnames beat full names, `"143 schools"` beats a bare
|
|
76
|
+
`"143"`. The short guide: [docs/MARKERS.md](docs/MARKERS.md).
|
|
77
|
+
|
|
78
|
+
**Leveling up (optional):** you don't have to keep the facts in the config.
|
|
79
|
+
If `./data/markers.json` is just a list of strings, point at it with
|
|
80
|
+
`expect: json('./data/markers.json')` — still no code. And if your app
|
|
81
|
+
already keeps structured data, derive the markers from it so your data file
|
|
82
|
+
stays the single source of truth:
|
|
72
83
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
```js
|
|
85
|
+
// ./data/school-board.json: { "members": [{ "name": "Dr. Jane Smith" }, ...] }
|
|
86
|
+
expect: json('./data/school-board.json', (data) =>
|
|
87
|
+
data.members.map((m) => surname(m.name)) // keeps just "Smith" — titles
|
|
88
|
+
), // and first names vary on pages
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The function receives *your* file's contents, whatever shape they are, and
|
|
92
|
+
returns the list of markers — stilltrue never needs to understand your data
|
|
93
|
+
structure. Update the data file and the markers update themselves.
|
|
76
94
|
|
|
77
95
|
**3. Run it:**
|
|
78
96
|
|
|
@@ -97,6 +115,172 @@ You'll see one line per check:
|
|
|
97
115
|
This only *warns*. An unreachable site doesn't mean your facts are wrong,
|
|
98
116
|
and an alarm that cries wolf gets ignored — so outages never fail the build.
|
|
99
117
|
|
|
118
|
+
## Configuration
|
|
119
|
+
|
|
120
|
+
stilltrue loads a config file from the current directory. The first of
|
|
121
|
+
these that exists wins:
|
|
122
|
+
|
|
123
|
+
`stilltrue.config.ts` · `.mts` · `.js` · `.mjs` · `.cjs`
|
|
124
|
+
|
|
125
|
+
(`npx stilltrue init` writes `.mjs`.) Point at a file anywhere with
|
|
126
|
+
`stilltrue drift --config path/to/file`.
|
|
127
|
+
|
|
128
|
+
Wrap the export with `defineStilltrue` so TypeScript can check it:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
import { defineStilltrue, corpus, json, surname } from 'stilltrue';
|
|
132
|
+
|
|
133
|
+
export default defineStilltrue({
|
|
134
|
+
drift: [ /* one entry per fact-set */ ],
|
|
135
|
+
verify: { /* optional named pipelines — library use, see below */ },
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Drift checks
|
|
140
|
+
|
|
141
|
+
Each `drift` entry is one curated fact-set vs its live source.
|
|
142
|
+
|
|
143
|
+
| Field | Required | Default | What it is |
|
|
144
|
+
|---|---|---|---|
|
|
145
|
+
| `name` | yes | — | Short id. Shows up in the terminal, `--only`, and the report. |
|
|
146
|
+
| `source` | yes | — | `async (ctx) => actual`. Fetch/parse the live authority. **Throw** if the source is unreachable — that is outcome `error`, never `rot`. |
|
|
147
|
+
| `expect` | yes | — | The curated facts. A value, or `async (ctx) => expected` (e.g. `json(...)`). A throw here is also `error` (broken config, not rot). |
|
|
148
|
+
| `compare` | no | `'deep-equal'` | How to compare `expect` against what `source` returned. |
|
|
149
|
+
|
|
150
|
+
`source` and function-form `expect` receive `ctx`:
|
|
151
|
+
|
|
152
|
+
| | |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `ctx.warn(message)` | Record a non-fatal note (one of several pages down, odd markup). Shows as a warning; does not fail the run. |
|
|
155
|
+
| `ctx.configDir` | Absolute directory of the config file. Data paths resolve from here. |
|
|
156
|
+
|
|
157
|
+
### `compare`
|
|
158
|
+
|
|
159
|
+
**`'contains-all'`** — `expect` is a list of marker strings; `source` must
|
|
160
|
+
return a text corpus (what `corpus()` returns). Every marker must appear
|
|
161
|
+
somewhere in that text, case-insensitively. Extra text on the page is
|
|
162
|
+
ignored. This is the webpage-watch check.
|
|
163
|
+
|
|
164
|
+
**`'deep-equal'`** — structural equality. Extra keys, missing keys, and
|
|
165
|
+
value mismatches all rot, with pathed messages
|
|
166
|
+
(`$.members[2].name: expected "Smith", source has "Jones"`). Use this when
|
|
167
|
+
`source()` already returns structured data that should match `expect`
|
|
168
|
+
exactly.
|
|
169
|
+
|
|
170
|
+
**A function** — `(expected, actual) => string[]`. Return `[]` to pass;
|
|
171
|
+
each string is one mismatch (outcome `rot`). Throw if the shapes are
|
|
172
|
+
unusable (outcome `error`). Use this for numeric tolerance, order-insensitive
|
|
173
|
+
lists, or any comparison that isn't "markers in a page" or "these two
|
|
174
|
+
objects are identical."
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
compare: (expected, actual) =>
|
|
178
|
+
expected.rate === actual.rate
|
|
179
|
+
? []
|
|
180
|
+
: [`tax rate ${expected.rate} → ${actual.rate}`],
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Surnames are a *marker choice*, not a compare mode: derive them from your
|
|
184
|
+
data with `surname()` and use `'contains-all'`.
|
|
185
|
+
|
|
186
|
+
### Helpers
|
|
187
|
+
|
|
188
|
+
These are the built-in `source` / `expect` builders. You can always write
|
|
189
|
+
your own functions instead.
|
|
190
|
+
|
|
191
|
+
**`corpus(urls, options?)`** — fetch pages, strip HTML to text,
|
|
192
|
+
concatenate. Individual unreachable pages call `ctx.warn`; if *every* page
|
|
193
|
+
fails, it throws (`error`). Pair with `'contains-all'`.
|
|
194
|
+
|
|
195
|
+
| Option | Default | What it is |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| `timeoutMs` | `20000` | Per-page fetch timeout, milliseconds. |
|
|
198
|
+
| `userAgent` | `stilltrue drift check (github.com/sg-bfoster/stilltrue)` | Sent on each request so operators can identify the check. |
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
source: corpus(
|
|
202
|
+
['https://example.org/about', 'https://example.org/board'],
|
|
203
|
+
{ timeoutMs: 15_000, userAgent: 'my-app drift check' },
|
|
204
|
+
),
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**`json(path, derive?)`** — read a JSON file relative to the config file.
|
|
208
|
+
With no `derive`, the parsed value *is* the expectation (typical with
|
|
209
|
+
`'deep-equal'`). With `derive`, you map that value into whatever `compare`
|
|
210
|
+
needs — usually a marker list:
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
expect: json('./data/board.json', (data) =>
|
|
214
|
+
data.members.map((m) => surname(m.name)),
|
|
215
|
+
),
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**`surname(fullName)`** — last token of a name, with leading honorifics
|
|
219
|
+
(`Dr.`, `Mr.`, `Mrs.`, `Ms.`) and trailing generational suffixes (`Jr.`,
|
|
220
|
+
`Sr.`, `II`–`V`) stripped. `"Dr. Jane Smith III"` → `"Smith"`.
|
|
221
|
+
|
|
222
|
+
**`htmlToText(html)`** — the same tag-stripping `corpus()` uses. For a
|
|
223
|
+
custom `source` that already has HTML in hand.
|
|
224
|
+
|
|
225
|
+
### Custom `source`
|
|
226
|
+
|
|
227
|
+
A throw is an outage; a return value is data to compare. Warn for partial
|
|
228
|
+
trouble:
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
source: async (ctx) => {
|
|
232
|
+
const res = await fetch('https://example.org/api/rates');
|
|
233
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
234
|
+
const data = await res.json();
|
|
235
|
+
if (!data.updatedAt) ctx.warn('response missing updatedAt');
|
|
236
|
+
return data;
|
|
237
|
+
},
|
|
238
|
+
expect: json('./data/rates.json'),
|
|
239
|
+
// compare defaults to 'deep-equal'
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### `verify` pipelines
|
|
243
|
+
|
|
244
|
+
`verify` is an optional map of named stage lists, for use from your app
|
|
245
|
+
(`runVerify(config.verify.answers, draft)`). There is no `stilltrue verify`
|
|
246
|
+
CLI command — verification runs in the request path, not in CI.
|
|
247
|
+
|
|
248
|
+
Each stage:
|
|
249
|
+
|
|
250
|
+
| Field | Required | Default | What it is |
|
|
251
|
+
|---|---|---|---|
|
|
252
|
+
| `name` | yes | — | Id of this checkpoint. The first rejection reports this name. |
|
|
253
|
+
| `check` | yes | — | `(input, ctx) => { ok: true, value? } \| { ok: false, messages, revision? }`. |
|
|
254
|
+
| `failPolicy` | no | `'open'` | What a **thrown** stage means. `'open'`: warn and continue (a judge outage must not block the app). `'closed'`: treat the throw as a rejection. |
|
|
255
|
+
| `timeoutMs` | no | none | Abort the stage after this many ms. The timeout follows `failPolicy`. |
|
|
256
|
+
| `tier` | no | `'inline'` | `'inline'` always runs; `'ci'` runs only when you call `runVerify(..., { tier: 'ci' })`. |
|
|
257
|
+
|
|
258
|
+
Stages run in order and stop at the first rejection. `zodStage(name, schema)`
|
|
259
|
+
builds a parse stage from a Zod schema.
|
|
260
|
+
`generateVerified({ generate, stages, attempts, tier })` is the generate →
|
|
261
|
+
verify → retry loop (`attempts` defaults to `2`). See
|
|
262
|
+
[For developers: `verify`](#for-developers-verify) below.
|
|
263
|
+
|
|
264
|
+
### CLI flags
|
|
265
|
+
|
|
266
|
+
`stilltrue drift`:
|
|
267
|
+
|
|
268
|
+
| Flag | What it does |
|
|
269
|
+
|---|---|
|
|
270
|
+
| `--config <path>` | Config file. Default: first `stilltrue.config.*` in the current directory. |
|
|
271
|
+
| `--only <name,name>` | Run only these checks. |
|
|
272
|
+
| `--json <path>` | Write the result array as JSON. |
|
|
273
|
+
| `--record` | Append this run to history. |
|
|
274
|
+
| `--history <path>` | History file for `--record`. Default: `.stilltrue/history.jsonl`. |
|
|
275
|
+
|
|
276
|
+
`stilltrue report`:
|
|
277
|
+
|
|
278
|
+
| Flag | What it does |
|
|
279
|
+
|---|---|
|
|
280
|
+
| `--history <path>` | History file to read. Default: `.stilltrue/history.jsonl`. |
|
|
281
|
+
| `--out <path>` | HTML output. Default: `stilltrue-report.html`. |
|
|
282
|
+
| `--title <text>` | Page `<title>` and heading. Default: `stilltrue drift report`. |
|
|
283
|
+
|
|
100
284
|
## Running it automatically every week
|
|
101
285
|
|
|
102
286
|
If your project is on GitHub, add this file as
|
|
@@ -152,6 +336,67 @@ the model for a retry. A broken checker never blocks your app — it fails
|
|
|
152
336
|
open. See the [project docs](docs/BRIEF.md) and the typed API in
|
|
153
337
|
`stilltrue`'s exports (`runVerify`, `zodStage`, `generateVerified`).
|
|
154
338
|
|
|
339
|
+
**Bring your own judge — any provider, or none.** stilltrue ships no AI
|
|
340
|
+
and never touches a model key: a checkpoint is just a function you supply,
|
|
341
|
+
so the judge stage can call Gemini, OpenAI, Anthropic, a local model — or
|
|
342
|
+
be a plain schema/rules check with no AI at all. Swapping providers means
|
|
343
|
+
editing your function, never this package.
|
|
344
|
+
|
|
345
|
+
### The common case, in six lines
|
|
346
|
+
|
|
347
|
+
Most people arrive wanting one thing: *does this source actually support
|
|
348
|
+
this claim?* `supportStage` is that check, ready-made. You bring a function
|
|
349
|
+
that calls your model; it brings the prompt, the response schema, and the
|
|
350
|
+
guard.
|
|
351
|
+
|
|
352
|
+
```js
|
|
353
|
+
import { runVerify, supportStage } from 'stilltrue';
|
|
354
|
+
|
|
355
|
+
const stage = supportStage({
|
|
356
|
+
// The only required option. Return the model's reply — parsed or raw JSON.
|
|
357
|
+
judge: ({ system, user, schema }) => callMyModel({ system, user, schema }),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const result = await runVerify([stage], { claim, source });
|
|
361
|
+
// result.ok → true when the source supports the claim
|
|
362
|
+
// result.value → { verdict, reasoning, evidence, contradiction, guard }
|
|
363
|
+
// result.messages → why it was rejected, ready for a retry
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
`verdict` is one of `supported`, `not_supported` or `cant_tell`. Silence is
|
|
367
|
+
not support: a source that never addresses the claim returns `cant_tell`,
|
|
368
|
+
even when the claim happens to be true.
|
|
369
|
+
|
|
370
|
+
**"Not supported" has to earn itself.** That verdict tells a reader the
|
|
371
|
+
source *says otherwise*, which is the strongest thing a judge can assert
|
|
372
|
+
and the one most worth being wrong about. So the stage requires the judge
|
|
373
|
+
to quote the contradicting sentence, and checks that quote against the
|
|
374
|
+
source in code. If it cannot point at real text, the verdict is downgraded
|
|
375
|
+
to `cant_tell` and a warning explains why. The check only ever downgrades —
|
|
376
|
+
a missing quote is evidence of nothing except a missing quote.
|
|
377
|
+
|
|
378
|
+
That guard is not theoretical. Judged by a small local model, adding two
|
|
379
|
+
lines to a source that never mentioned the claim flipped a verdict from
|
|
380
|
+
`supported` to `not_supported` — three runs each way, so not noise — where
|
|
381
|
+
a frontier model stayed stable. A package that promises engine-agnostic
|
|
382
|
+
verification has to survive the weaker engine.
|
|
383
|
+
|
|
384
|
+
Useful options, all defaulted: `minRun` (how much of the contradiction
|
|
385
|
+
quote must match the source — 24 characters, measured on civic notices and
|
|
386
|
+
fee tables, not universal), `requireContradiction: false` to switch the
|
|
387
|
+
guard off, `treatCantTellAs: 'warn'` to pass unanswerable claims instead of
|
|
388
|
+
rejecting them, and `system` to replace the prompt entirely. `SUPPORT_SYSTEM`
|
|
389
|
+
and `SUPPORT_SCHEMA` are exported too, so a provider with constrained
|
|
390
|
+
decoding can take the schema directly.
|
|
391
|
+
|
|
392
|
+
### Writing your own stages
|
|
393
|
+
|
|
394
|
+
`supportStage` is one stage; a pipeline is a list of them, and any function
|
|
395
|
+
of the right shape will do. **[docs/VERIFY.md](docs/VERIFY.md)** is the full
|
|
396
|
+
walkthrough — the judging prompt explained, response mapping, retries, and
|
|
397
|
+
the generate → verify → retry loop — for when the ready-made stage is not
|
|
398
|
+
the shape you need.
|
|
399
|
+
|
|
155
400
|
## What about testing prompts?
|
|
156
401
|
|
|
157
402
|
That's a different job with a great existing tool:
|
package/dist/cli.js
CHANGED
|
@@ -103,9 +103,9 @@ try {
|
|
|
103
103
|
console.log(`kept existing ${f} (never overwritten)`);
|
|
104
104
|
if (created.length) {
|
|
105
105
|
console.log('\nNext steps:\n' +
|
|
106
|
-
' 1. Edit stilltrue.config.mjs — point source at the real page(s) your facts came from
|
|
107
|
-
'
|
|
108
|
-
'
|
|
106
|
+
' 1. Edit stilltrue.config.mjs — point source at the real page(s) your facts came from,\n' +
|
|
107
|
+
' and replace the example markers with your own facts (plain strings)\n' +
|
|
108
|
+
' 2. Run: npx stilltrue drift\n' +
|
|
109
109
|
'Picking good markers: docs/MARKERS.md in the stilltrue repo');
|
|
110
110
|
}
|
|
111
111
|
process.exit(0);
|
package/dist/index.d.ts
CHANGED
|
@@ -3,3 +3,4 @@ export { corpus, json, surname, htmlToText, type CorpusOptions } from './helpers
|
|
|
3
3
|
export { runDrift, runDriftCheck, type RunDriftOptions } from './runner.ts';
|
|
4
4
|
export { resolveCompare } from './compare.ts';
|
|
5
5
|
export { runVerify, zodStage, formatRejection, generateVerified, type VerifyStage, type VerifyResult, type StageOutcome, type StageContext, type RunVerifyOptions, type GenerateVerifiedOptions, } from './verify.ts';
|
|
6
|
+
export { supportStage, supportPrompt, guardJudgement, longestRun, SUPPORT_SYSTEM, SUPPORT_SCHEMA, type Verdict, type SupportInput, type SupportJudgement, type SupportResult, type SupportStageOptions, type Judge, type JudgeRequest, } from './support.ts';
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,4 @@ export { corpus, json, surname, htmlToText } from "./helpers.js";
|
|
|
3
3
|
export { runDrift, runDriftCheck } from "./runner.js";
|
|
4
4
|
export { resolveCompare } from "./compare.js";
|
|
5
5
|
export { runVerify, zodStage, formatRejection, generateVerified, } from "./verify.js";
|
|
6
|
+
export { supportStage, supportPrompt, guardJudgement, longestRun, SUPPORT_SYSTEM, SUPPORT_SCHEMA, } from "./support.js";
|
package/dist/init.d.ts
CHANGED
|
@@ -2,5 +2,5 @@ export interface InitResult {
|
|
|
2
2
|
created: string[];
|
|
3
3
|
skipped: string[];
|
|
4
4
|
}
|
|
5
|
-
/** Write starter config
|
|
5
|
+
/** Write the starter config. Never overwrites an existing one. */
|
|
6
6
|
export declare function init(dir: string): Promise<InitResult>;
|
package/dist/init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
const CONFIG_TEMPLATE = `// stilltrue configuration — one entry per fact-set you want watched.
|
|
@@ -10,47 +10,53 @@ export default defineStilltrue({
|
|
|
10
10
|
drift: [
|
|
11
11
|
{
|
|
12
12
|
// A short id for this check — shows up in output and reports.
|
|
13
|
-
name: '
|
|
13
|
+
name: 'my-first-check',
|
|
14
14
|
|
|
15
15
|
// WHERE THE TRUTH LIVES: the official page(s) your facts came from.
|
|
16
16
|
// corpus() fetches each page and strips it down to readable text.
|
|
17
|
-
// If one page is unreachable it warns; only if ALL are down does the
|
|
18
|
-
// check report an error (which never fails your build).
|
|
19
17
|
source: corpus([
|
|
20
|
-
'https://example.org/
|
|
21
|
-
// 'https://example.org/
|
|
18
|
+
'https://example.org/about',
|
|
19
|
+
// 'https://example.org/board', // add more pages if facts span several
|
|
22
20
|
]),
|
|
23
21
|
|
|
24
|
-
// WHAT YOU BELIEVE:
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
expect:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
// WHAT YOU BELIEVE: list the facts as short strings ("markers").
|
|
23
|
+
// Every one of these must still appear somewhere on the page(s)
|
|
24
|
+
// above, or the check fails and names the missing one.
|
|
25
|
+
expect: [
|
|
26
|
+
'Jane Smith',
|
|
27
|
+
'Main Street Office',
|
|
28
|
+
'12 locations',
|
|
29
|
+
],
|
|
31
30
|
|
|
32
31
|
// HOW TO COMPARE: 'contains-all' = every marker must appear in the
|
|
33
|
-
// fetched text (case-insensitive).
|
|
34
|
-
// build fails and tells you which fact went stale.
|
|
32
|
+
// fetched page text (case-insensitive).
|
|
35
33
|
compare: 'contains-all',
|
|
36
34
|
},
|
|
35
|
+
|
|
36
|
+
// ── Leveling up (optional) ──────────────────────────────────────────
|
|
37
|
+
//
|
|
38
|
+
// Instead of listing markers inline, you can keep them in a file.
|
|
39
|
+
// If ./data/markers.json contains just a list of strings —
|
|
40
|
+
// ["Jane Smith", "Main Street Office", "12 locations"]
|
|
41
|
+
// — then this works with no code at all:
|
|
42
|
+
//
|
|
43
|
+
// expect: json('./data/markers.json'),
|
|
44
|
+
//
|
|
45
|
+
// And if your app already keeps structured data — say
|
|
46
|
+
// ./data/board.json looks like
|
|
47
|
+
// { "members": [ { "name": "Dr. Jane Smith" }, { "name": "Bob Jones Jr." } ] }
|
|
48
|
+
// — you can derive the markers from it, so your data file stays the
|
|
49
|
+
// single source of truth (surname() keeps just "Smith" / "Jones",
|
|
50
|
+
// because pages change titles and first names more than surnames):
|
|
51
|
+
//
|
|
52
|
+
// expect: json('./data/board.json', (data) =>
|
|
53
|
+
// data.members.map((m) => surname(m.name))
|
|
54
|
+
// ),
|
|
37
55
|
],
|
|
38
56
|
});
|
|
39
57
|
`;
|
|
40
|
-
|
|
41
|
-
"_comment": "Example facts file - replace with your own data, or point the config at a file you already have.",
|
|
42
|
-
"members": [
|
|
43
|
-
{ "name": "Dr. Jane Smith" },
|
|
44
|
-
{ "name": "Robert Jones Jr." }
|
|
45
|
-
],
|
|
46
|
-
"totalSchools": 12
|
|
47
|
-
}
|
|
48
|
-
`;
|
|
49
|
-
/** Write starter config + example data file. Never overwrites. */
|
|
58
|
+
/** Write the starter config. Never overwrites an existing one. */
|
|
50
59
|
export async function init(dir) {
|
|
51
|
-
const created = [];
|
|
52
|
-
const skipped = [];
|
|
53
|
-
const configPath = join(dir, 'stilltrue.config.mjs');
|
|
54
60
|
const existingConfig = [
|
|
55
61
|
'stilltrue.config.mjs',
|
|
56
62
|
'stilltrue.config.ts',
|
|
@@ -59,20 +65,8 @@ export async function init(dir) {
|
|
|
59
65
|
'stilltrue.config.cjs',
|
|
60
66
|
].find((name) => existsSync(join(dir, name)));
|
|
61
67
|
if (existingConfig) {
|
|
62
|
-
skipped
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
65
|
-
await writeFile(configPath, CONFIG_TEMPLATE);
|
|
66
|
-
created.push('stilltrue.config.mjs');
|
|
67
|
-
}
|
|
68
|
-
const dataPath = join(dir, 'data', 'example-facts.json');
|
|
69
|
-
if (existsSync(dataPath)) {
|
|
70
|
-
skipped.push('data/example-facts.json');
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
await mkdir(join(dir, 'data'), { recursive: true });
|
|
74
|
-
await writeFile(dataPath, DATA_TEMPLATE);
|
|
75
|
-
created.push('data/example-facts.json');
|
|
68
|
+
return { created: [], skipped: [existingConfig] };
|
|
76
69
|
}
|
|
77
|
-
|
|
70
|
+
await writeFile(join(dir, 'stilltrue.config.mjs'), CONFIG_TEMPLATE);
|
|
71
|
+
return { created: ['stilltrue.config.mjs'], skipped: [] };
|
|
78
72
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* supportStage — does this SOURCE actually support this CLAIM?
|
|
3
|
+
*
|
|
4
|
+
* The verify half of this package shipped one ready-made stage (`zodStage`, a
|
|
5
|
+
* schema check) and otherwise handed callers an interface: implement
|
|
6
|
+
* VerifyStage, understand StageOutcome, write your own judge — prompt, response
|
|
7
|
+
* schema, parsing, guard. That is a lot of scaffolding in front of the single
|
|
8
|
+
* question people arrive here to ask.
|
|
9
|
+
*
|
|
10
|
+
* This is that scaffolding, shipped. You bring a function that calls a model;
|
|
11
|
+
* everything else has a default. stilltrue still ships no AI, no key and no
|
|
12
|
+
* provider dependency — the judge is a function, and this file never learns
|
|
13
|
+
* what is behind it.
|
|
14
|
+
*
|
|
15
|
+
* See docs/SUPPORT_STAGE_PLAN.md for the measurements that produced the
|
|
16
|
+
* defaults below. They are not taste; each one was paid for.
|
|
17
|
+
*/
|
|
18
|
+
import type { VerifyStage } from './verify.ts';
|
|
19
|
+
export type Verdict = 'supported' | 'not_supported' | 'cant_tell';
|
|
20
|
+
export interface SupportInput {
|
|
21
|
+
claim: string;
|
|
22
|
+
source: string;
|
|
23
|
+
}
|
|
24
|
+
export interface SupportJudgement {
|
|
25
|
+
verdict: Verdict;
|
|
26
|
+
reasoning: string;
|
|
27
|
+
/** The sentence that decided it, quoted from the source. */
|
|
28
|
+
evidence: string | null;
|
|
29
|
+
/** For not_supported only: the sentence that says otherwise, verbatim. */
|
|
30
|
+
contradiction: string | null;
|
|
31
|
+
}
|
|
32
|
+
/** What the stage puts in `result.value`: the input plus what the judge said. */
|
|
33
|
+
export interface SupportResult extends SupportInput, SupportJudgement {
|
|
34
|
+
/**
|
|
35
|
+
* Why the verdict is what it is, when the guard intervened:
|
|
36
|
+
* - `verified` — a contradicting quote was found in the source
|
|
37
|
+
* - `no-contradiction-quoted` — none offered, verdict downgraded
|
|
38
|
+
* - `quote-not-in-source` — one offered but not present, verdict downgraded
|
|
39
|
+
* - `null` — the guard had no opinion (verdict was not not_supported)
|
|
40
|
+
*/
|
|
41
|
+
guard: 'verified' | 'no-contradiction-quoted' | 'quote-not-in-source' | null;
|
|
42
|
+
}
|
|
43
|
+
export interface JudgeRequest {
|
|
44
|
+
system: string;
|
|
45
|
+
user: string;
|
|
46
|
+
/**
|
|
47
|
+
* The response shape, for providers that support constrained decoding. Hand
|
|
48
|
+
* this straight to the provider where you can: a grammar-enforced enum makes
|
|
49
|
+
* an out-of-band verdict unrepresentable rather than merely unlikely, which
|
|
50
|
+
* is a stronger guarantee than asking politely for JSON.
|
|
51
|
+
*/
|
|
52
|
+
schema: typeof SUPPORT_SCHEMA;
|
|
53
|
+
}
|
|
54
|
+
/** Return parsed JSON, or the raw string — both are accepted. */
|
|
55
|
+
export type Judge = (req: JudgeRequest) => unknown | Promise<unknown>;
|
|
56
|
+
export interface SupportStageOptions {
|
|
57
|
+
judge: Judge;
|
|
58
|
+
name?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Minimum contiguous run, in characters, that a contradiction quote must
|
|
61
|
+
* share with the source.
|
|
62
|
+
*
|
|
63
|
+
* 24 is MEASURED, not chosen, and measured on ONE corpus — civic notices,
|
|
64
|
+
* fee schedules and ordinances. Contradictions written in prose ran 76-96
|
|
65
|
+
* characters, but a contradicting line in a fee table ("Seniors 65 and over
|
|
66
|
+
* $1.00.") is only 26, and a first guess of 40 silently discarded those.
|
|
67
|
+
* Treat it as a starting point for your corpus, not a tuned constant.
|
|
68
|
+
*/
|
|
69
|
+
minRun?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Require a not_supported verdict to quote the contradicting sentence, and
|
|
72
|
+
* downgrade to cant_tell when it cannot. Default true.
|
|
73
|
+
*
|
|
74
|
+
* Off restores naive behaviour, which is sometimes what you want — this
|
|
75
|
+
* package should not dictate policy to a caller whose corpus differs.
|
|
76
|
+
*/
|
|
77
|
+
requireContradiction?: boolean;
|
|
78
|
+
/** How to treat cant_tell. 'reject' (default) or 'warn' and pass. */
|
|
79
|
+
treatCantTellAs?: 'reject' | 'warn';
|
|
80
|
+
/** Replace the shipped judge prompt entirely. */
|
|
81
|
+
system?: string;
|
|
82
|
+
/** Default 'open': a judge outage warns rather than rejects. */
|
|
83
|
+
failPolicy?: 'open' | 'closed';
|
|
84
|
+
timeoutMs?: number;
|
|
85
|
+
tier?: 'inline' | 'ci';
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The shipped judge prompt.
|
|
89
|
+
*
|
|
90
|
+
* The line about qualification is load-bearing rather than decorative.
|
|
91
|
+
* Measured 2026-09-04 without it: adding two lines to a source that never
|
|
92
|
+
* mention the claim flipped a verdict from supported to not_supported — three
|
|
93
|
+
* runs each way, so not sampling noise — on a local model where a frontier
|
|
94
|
+
* model stayed stable. A package promising engine-agnostic verification has to
|
|
95
|
+
* survive the weaker engine, so the instruction ships by default.
|
|
96
|
+
*/
|
|
97
|
+
export declare const SUPPORT_SYSTEM = "You are a strict fact-check judge. Decide whether the SOURCE supports the CLAIM using ONLY the source text \u2014 no outside knowledge, no charitable guessing.\nVerdicts:\n- \"supported\": the source affirmatively supports the claim.\n- \"not_supported\": the source contradicts the claim or asserts something incompatible with it.\n- \"cant_tell\": the source does not address the claim, or addresses it only partially. Silence is NOT support.\nA qualification is not a contradiction. If the source substantially supports the claim but adds an exception, a carve-out or a condition, it has NOT said otherwise \u2014 do not rule not_supported. Reserve not_supported for a source that asserts something the claim cannot be true alongside.\nTo rule \"not_supported\" you MUST fill \"contradiction\" with the exact sentence from the source that says otherwise, copied verbatim. If no such sentence exists, you may not use that verdict.\nReturn STRICT JSON only:\n{\"verdict\":\"supported\"|\"not_supported\"|\"cant_tell\",\"reasoning\":\"<one or two plain sentences>\",\"evidence\":\"<short verbatim quote from the source that decided it, or null>\",\"contradiction\":\"<verbatim sentence that contradicts the claim, or null>\"}";
|
|
98
|
+
/** JSON Schema for the judgement, for providers that constrain decoding. */
|
|
99
|
+
export declare const SUPPORT_SCHEMA: {
|
|
100
|
+
readonly type: "object";
|
|
101
|
+
readonly additionalProperties: false;
|
|
102
|
+
readonly properties: {
|
|
103
|
+
readonly verdict: {
|
|
104
|
+
readonly type: "string";
|
|
105
|
+
readonly enum: readonly ["supported", "not_supported", "cant_tell"];
|
|
106
|
+
};
|
|
107
|
+
readonly reasoning: {
|
|
108
|
+
readonly type: "string";
|
|
109
|
+
};
|
|
110
|
+
readonly evidence: {
|
|
111
|
+
readonly type: readonly ["string", "null"];
|
|
112
|
+
};
|
|
113
|
+
readonly contradiction: {
|
|
114
|
+
readonly type: readonly ["string", "null"];
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
readonly required: readonly ["verdict", "reasoning", "evidence", "contradiction"];
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* SOURCE first, CLAIM last — deliberately.
|
|
121
|
+
*
|
|
122
|
+
* Prefix caching is an exact-token match, so putting the long, stable source
|
|
123
|
+
* ahead of the short, variable claim means a second claim checked against the
|
|
124
|
+
* SAME source reuses the cached prefix. That is the obvious next thing a user
|
|
125
|
+
* does. Claim-first diverges at token one and pays full prefill every time.
|
|
126
|
+
*/
|
|
127
|
+
export declare function supportPrompt(input: SupportInput): string;
|
|
128
|
+
/**
|
|
129
|
+
* Longest run of `needle` that appears contiguously in `haystack`.
|
|
130
|
+
*
|
|
131
|
+
* Whole-string matching was tried first and was too strict: a judge quoted
|
|
132
|
+
* "dogs must remain on a leash ..." where the source read "Dogs are permitted
|
|
133
|
+
* in all county parks BUT must remain on a leash ...". Tidying a quote's
|
|
134
|
+
* subject into a grammatical sentence is what careful writers do, and exact
|
|
135
|
+
* matching discarded a correct verdict for it. A long contiguous run still
|
|
136
|
+
* proves the judge was reading real text — it cannot invent two dozen
|
|
137
|
+
* consecutive characters that happen to be in the source.
|
|
138
|
+
*/
|
|
139
|
+
export declare function longestRun(needle: string, haystack: string): number;
|
|
140
|
+
/**
|
|
141
|
+
* Make "not supported" earn itself.
|
|
142
|
+
*
|
|
143
|
+
* That verdict tells a reader the source SAYS OTHERWISE, which is a strong
|
|
144
|
+
* claim and the one most worth being wrong about. This only ever DOWNGRADES:
|
|
145
|
+
* a missing quote is evidence of nothing except a missing quote, so nothing is
|
|
146
|
+
* ever promoted to supported on its strength.
|
|
147
|
+
*/
|
|
148
|
+
export declare function guardJudgement(judgement: SupportJudgement, source: string, minRun: number): SupportResult['guard'];
|
|
149
|
+
/**
|
|
150
|
+
* A ready-made verify stage that asks whether a source supports a claim.
|
|
151
|
+
*
|
|
152
|
+
* ```js
|
|
153
|
+
* const stage = supportStage({ judge: ({ system, user }) => callMyModel(system, user) });
|
|
154
|
+
* const result = await runVerify([stage], { claim, source });
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export declare function supportStage(options: SupportStageOptions): VerifyStage<any>;
|
package/dist/support.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* supportStage — does this SOURCE actually support this CLAIM?
|
|
3
|
+
*
|
|
4
|
+
* The verify half of this package shipped one ready-made stage (`zodStage`, a
|
|
5
|
+
* schema check) and otherwise handed callers an interface: implement
|
|
6
|
+
* VerifyStage, understand StageOutcome, write your own judge — prompt, response
|
|
7
|
+
* schema, parsing, guard. That is a lot of scaffolding in front of the single
|
|
8
|
+
* question people arrive here to ask.
|
|
9
|
+
*
|
|
10
|
+
* This is that scaffolding, shipped. You bring a function that calls a model;
|
|
11
|
+
* everything else has a default. stilltrue still ships no AI, no key and no
|
|
12
|
+
* provider dependency — the judge is a function, and this file never learns
|
|
13
|
+
* what is behind it.
|
|
14
|
+
*
|
|
15
|
+
* See docs/SUPPORT_STAGE_PLAN.md for the measurements that produced the
|
|
16
|
+
* defaults below. They are not taste; each one was paid for.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The shipped judge prompt.
|
|
20
|
+
*
|
|
21
|
+
* The line about qualification is load-bearing rather than decorative.
|
|
22
|
+
* Measured 2026-09-04 without it: adding two lines to a source that never
|
|
23
|
+
* mention the claim flipped a verdict from supported to not_supported — three
|
|
24
|
+
* runs each way, so not sampling noise — on a local model where a frontier
|
|
25
|
+
* model stayed stable. A package promising engine-agnostic verification has to
|
|
26
|
+
* survive the weaker engine, so the instruction ships by default.
|
|
27
|
+
*/
|
|
28
|
+
export const SUPPORT_SYSTEM = `You are a strict fact-check judge. Decide whether the SOURCE supports the CLAIM using ONLY the source text — no outside knowledge, no charitable guessing.
|
|
29
|
+
Verdicts:
|
|
30
|
+
- "supported": the source affirmatively supports the claim.
|
|
31
|
+
- "not_supported": the source contradicts the claim or asserts something incompatible with it.
|
|
32
|
+
- "cant_tell": the source does not address the claim, or addresses it only partially. Silence is NOT support.
|
|
33
|
+
A qualification is not a contradiction. If the source substantially supports the claim but adds an exception, a carve-out or a condition, it has NOT said otherwise — do not rule not_supported. Reserve not_supported for a source that asserts something the claim cannot be true alongside.
|
|
34
|
+
To rule "not_supported" you MUST fill "contradiction" with the exact sentence from the source that says otherwise, copied verbatim. If no such sentence exists, you may not use that verdict.
|
|
35
|
+
Return STRICT JSON only:
|
|
36
|
+
{"verdict":"supported"|"not_supported"|"cant_tell","reasoning":"<one or two plain sentences>","evidence":"<short verbatim quote from the source that decided it, or null>","contradiction":"<verbatim sentence that contradicts the claim, or null>"}`;
|
|
37
|
+
/** JSON Schema for the judgement, for providers that constrain decoding. */
|
|
38
|
+
export const SUPPORT_SCHEMA = {
|
|
39
|
+
type: 'object',
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
properties: {
|
|
42
|
+
verdict: { type: 'string', enum: ['supported', 'not_supported', 'cant_tell'] },
|
|
43
|
+
reasoning: { type: 'string' },
|
|
44
|
+
evidence: { type: ['string', 'null'] },
|
|
45
|
+
contradiction: { type: ['string', 'null'] },
|
|
46
|
+
},
|
|
47
|
+
required: ['verdict', 'reasoning', 'evidence', 'contradiction'],
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* SOURCE first, CLAIM last — deliberately.
|
|
51
|
+
*
|
|
52
|
+
* Prefix caching is an exact-token match, so putting the long, stable source
|
|
53
|
+
* ahead of the short, variable claim means a second claim checked against the
|
|
54
|
+
* SAME source reuses the cached prefix. That is the obvious next thing a user
|
|
55
|
+
* does. Claim-first diverges at token one and pays full prefill every time.
|
|
56
|
+
*/
|
|
57
|
+
export function supportPrompt(input) {
|
|
58
|
+
return `SOURCE:\n${input.source}\n\nCLAIM:\n${input.claim}`;
|
|
59
|
+
}
|
|
60
|
+
const VERDICTS = ['supported', 'not_supported', 'cant_tell'];
|
|
61
|
+
/** Whitespace and quote-style differences must not decide a match. */
|
|
62
|
+
function normalise(s) {
|
|
63
|
+
return String(s ?? '')
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[\s‘’“”"']+/g, ' ')
|
|
66
|
+
.trim();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Longest run of `needle` that appears contiguously in `haystack`.
|
|
70
|
+
*
|
|
71
|
+
* Whole-string matching was tried first and was too strict: a judge quoted
|
|
72
|
+
* "dogs must remain on a leash ..." where the source read "Dogs are permitted
|
|
73
|
+
* in all county parks BUT must remain on a leash ...". Tidying a quote's
|
|
74
|
+
* subject into a grammatical sentence is what careful writers do, and exact
|
|
75
|
+
* matching discarded a correct verdict for it. A long contiguous run still
|
|
76
|
+
* proves the judge was reading real text — it cannot invent two dozen
|
|
77
|
+
* consecutive characters that happen to be in the source.
|
|
78
|
+
*/
|
|
79
|
+
export function longestRun(needle, haystack) {
|
|
80
|
+
let best = 0;
|
|
81
|
+
for (let i = 0; i < needle.length; i += 1) {
|
|
82
|
+
if (needle.length - i <= best)
|
|
83
|
+
break; // no run from here can beat `best`
|
|
84
|
+
let lo = best + 1;
|
|
85
|
+
let hi = needle.length - i;
|
|
86
|
+
while (lo <= hi) {
|
|
87
|
+
const mid = (lo + hi) >> 1;
|
|
88
|
+
if (haystack.includes(needle.slice(i, i + mid))) {
|
|
89
|
+
best = mid;
|
|
90
|
+
lo = mid + 1;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
hi = mid - 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return best;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Make "not supported" earn itself.
|
|
101
|
+
*
|
|
102
|
+
* That verdict tells a reader the source SAYS OTHERWISE, which is a strong
|
|
103
|
+
* claim and the one most worth being wrong about. This only ever DOWNGRADES:
|
|
104
|
+
* a missing quote is evidence of nothing except a missing quote, so nothing is
|
|
105
|
+
* ever promoted to supported on its strength.
|
|
106
|
+
*/
|
|
107
|
+
export function guardJudgement(judgement, source, minRun) {
|
|
108
|
+
if (judgement.verdict !== 'not_supported')
|
|
109
|
+
return null;
|
|
110
|
+
const quote = normalise(judgement.contradiction);
|
|
111
|
+
// Below a floor, a "quote" is not evidence — "not", "free" and "$14" would
|
|
112
|
+
// all match almost any source.
|
|
113
|
+
if (quote.length < Math.min(15, minRun))
|
|
114
|
+
return 'no-contradiction-quoted';
|
|
115
|
+
return longestRun(quote, normalise(source)) >= minRun ? 'verified' : 'quote-not-in-source';
|
|
116
|
+
}
|
|
117
|
+
function parseJudgement(raw) {
|
|
118
|
+
let obj = raw;
|
|
119
|
+
if (typeof raw === 'string') {
|
|
120
|
+
// Providers that cannot constrain decoding often wrap JSON in prose or a
|
|
121
|
+
// fenced block. Recover the object rather than failing the whole stage.
|
|
122
|
+
try {
|
|
123
|
+
obj = JSON.parse(raw);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
127
|
+
if (!match)
|
|
128
|
+
throw new Error('judge returned no JSON');
|
|
129
|
+
obj = JSON.parse(match[0]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!obj || typeof obj !== 'object')
|
|
133
|
+
throw new Error('judge returned no object');
|
|
134
|
+
return {
|
|
135
|
+
// Coerce, do not trust: only a grammar-constrained provider guarantees the
|
|
136
|
+
// enum, and an unrecognised verdict must not become a confident answer.
|
|
137
|
+
verdict: VERDICTS.includes(obj.verdict) ? obj.verdict : 'cant_tell',
|
|
138
|
+
reasoning: typeof obj.reasoning === 'string' ? obj.reasoning : '',
|
|
139
|
+
evidence: typeof obj.evidence === 'string' ? obj.evidence : null,
|
|
140
|
+
contradiction: typeof obj.contradiction === 'string' ? obj.contradiction : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A ready-made verify stage that asks whether a source supports a claim.
|
|
145
|
+
*
|
|
146
|
+
* ```js
|
|
147
|
+
* const stage = supportStage({ judge: ({ system, user }) => callMyModel(system, user) });
|
|
148
|
+
* const result = await runVerify([stage], { claim, source });
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
export function supportStage(options) {
|
|
152
|
+
const { judge, name = 'support', minRun = 24, requireContradiction = true, treatCantTellAs = 'reject', system = SUPPORT_SYSTEM, failPolicy = 'open', timeoutMs, tier, } = options;
|
|
153
|
+
if (typeof judge !== 'function') {
|
|
154
|
+
throw new TypeError('supportStage requires a `judge` function — you bring the model');
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
name,
|
|
158
|
+
failPolicy,
|
|
159
|
+
...(timeoutMs ? { timeoutMs } : {}),
|
|
160
|
+
...(tier ? { tier } : {}),
|
|
161
|
+
check: async (input, ctx) => {
|
|
162
|
+
if (!input || typeof input.claim !== 'string' || typeof input.source !== 'string') {
|
|
163
|
+
return { ok: false, messages: ['supportStage expects { claim, source } as the input value'] };
|
|
164
|
+
}
|
|
165
|
+
// A throw here is an infrastructure failure and follows failPolicy — the
|
|
166
|
+
// stage does not swallow it into a verdict.
|
|
167
|
+
const raw = await judge({ system, user: supportPrompt(input), schema: SUPPORT_SCHEMA });
|
|
168
|
+
const judgement = parseJudgement(raw);
|
|
169
|
+
const guard = requireContradiction ? guardJudgement(judgement, input.source, minRun) : null;
|
|
170
|
+
const verdict = guard && guard !== 'verified' ? 'cant_tell' : judgement.verdict;
|
|
171
|
+
if (guard && guard !== 'verified') {
|
|
172
|
+
ctx.warn(`${name}: "not supported" was downgraded to "can't tell" (${guard})`);
|
|
173
|
+
}
|
|
174
|
+
const value = { ...input, ...judgement, verdict, guard };
|
|
175
|
+
if (verdict === 'supported')
|
|
176
|
+
return { ok: true, value };
|
|
177
|
+
if (verdict === 'cant_tell' && treatCantTellAs === 'warn') {
|
|
178
|
+
ctx.warn(`${name}: the source does not settle this claim`);
|
|
179
|
+
return { ok: true, value };
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
messages: [judgement.reasoning || `the source does not support this claim (${verdict})`],
|
|
184
|
+
revision: value,
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stilltrue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Your tests check your code. stilltrue checks your facts. CI checks for AI-in-the-loop apps: drift (curated facts vs live sources), golden (regression evals), verify (judge answers against sources).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"drift",
|
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
"main": "./dist/index.js",
|
|
18
18
|
"types": "./dist/index.d.ts",
|
|
19
19
|
"bin": {
|
|
20
|
-
"stilltrue": "
|
|
20
|
+
"stilltrue": "dist/cli.js"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"dist"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "tsc",
|
|
27
|
+
"prepare": "tsc",
|
|
27
28
|
"dev": "tsc --watch",
|
|
28
29
|
"test": "node --test test/*.test.ts"
|
|
29
30
|
},
|