bareloop 0.43.0 → 0.45.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/CHANGELOG.md +60 -0
- package/bareloop.context.md +53 -13
- package/package.json +2 -2
- package/src/authorflow.js +27 -12
- package/src/authoring.js +1 -1
- package/src/authorjob.js +52 -16
- package/src/authorreadout.js +12 -6
- package/src/authorrun.js +8 -2
- package/src/declaredclose.js +4 -1
- package/src/draftspend.js +20 -2
- package/src/facts.js +54 -9
- package/src/factwords.js +58 -6
- package/src/fitcheck.js +166 -0
- package/src/inputlook.js +38 -3
- package/src/job.js +1 -1
- package/src/kinds.js +5 -0
- package/src/pagedropwords.js +110 -0
- package/src/pagedry.js +125 -0
- package/src/pagemap.js +269 -0
- package/src/pagerules.js +318 -0
- package/src/panel/authorroutes.js +34 -30
- package/src/panel/authorsession.js +66 -11
- package/src/panel/index.html +469 -65
- package/src/panel/jobplan.js +14 -7
- package/src/panel/runroutes.js +46 -22
- package/src/panel/server.js +792 -28
- package/src/panel/status.js +33 -0
- package/src/panelmoney.js +29 -0
- package/src/planrun.js +53 -4
- package/src/promptregisters.js +8 -0
- package/src/replay.js +118 -7
- package/src/rubricstep.js +157 -15
- package/src/runlist.js +2 -1
- package/src/schedulewords.js +6 -3
- package/src/source.js +18 -0
- package/src/thinking.js +63 -2
- package/src/userrun.js +28 -14
- package/types/authorflow.d.ts +42 -6
- package/types/authorjob.d.ts +20 -4
- package/types/declaredclose.d.ts +2 -0
- package/types/draftspend.d.ts +27 -1
- package/types/facts.d.ts +15 -0
- package/types/factwords.d.ts +26 -3
- package/types/fitcheck.d.ts +113 -0
- package/types/inputlook.d.ts +15 -0
- package/types/kinds.d.ts +5 -0
- package/types/pagedropwords.d.ts +39 -0
- package/types/pagedry.d.ts +37 -0
- package/types/pagemap.d.ts +154 -0
- package/types/pagerules.d.ts +86 -0
- package/types/panel/authorroutes.d.ts +17 -14
- package/types/panel/authorsession.d.ts +4 -2
- package/types/panel/jobplan.d.ts +5 -2
- package/types/panel/runroutes.d.ts +2 -2
- package/types/panel/server.d.ts +156 -2
- package/types/panel/status.d.ts +51 -0
- package/types/panelmoney.d.ts +5 -0
- package/types/replay.d.ts +21 -1
- package/types/rubricstep.d.ts +62 -1
- package/types/runlist.d.ts +4 -1
- package/types/schedulewords.d.ts +4 -2
- package/types/source.d.ts +11 -0
- package/types/thinking.d.ts +39 -0
- package/types/userrun.d.ts +4 -0
package/src/authorreadout.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// not because they are readouts: the runner is a script, and a rule no test can
|
|
25
25
|
// reach is a rule nothing checks.
|
|
26
26
|
import { scrubRaw } from './text.js';
|
|
27
|
+
import { panelMoney2 } from './panelmoney.js';
|
|
27
28
|
import { redactSecrets } from './validate.js';
|
|
28
29
|
import { judgedStages } from './kinds.js';
|
|
29
30
|
import { CALIBRATION_SIZE } from './judged.js';
|
|
@@ -560,11 +561,6 @@ export function plainReasons(reds, { cases = null } = {}) {
|
|
|
560
561
|
});
|
|
561
562
|
}
|
|
562
563
|
|
|
563
|
-
/** @param {number} n @returns {string} the panel's money spelling: 2 decimals, "<$0.01" under a cent */
|
|
564
|
-
function moneyText(n) {
|
|
565
|
-
return n > 0 && n < 0.005 ? '<$0.01' : `$${n.toFixed(2)}`;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
564
|
/**
|
|
569
565
|
* The money clause: the measured figure, "at least" when a call was unpriced,
|
|
570
566
|
* and `null` when there is no honest figure (the caller then says unknown or
|
|
@@ -574,7 +570,7 @@ function moneyText(n) {
|
|
|
574
570
|
*/
|
|
575
571
|
function spendText(spend) {
|
|
576
572
|
if (!spend || typeof spend.knownUsd !== 'number' || !Number.isFinite(spend.knownUsd)) return null;
|
|
577
|
-
return `${spend.spendComplete === false ? 'at least ' : ''}${
|
|
573
|
+
return `${spend.spendComplete === false ? 'at least ' : ''}${panelMoney2(spend.knownUsd)}`;
|
|
578
574
|
}
|
|
579
575
|
|
|
580
576
|
/**
|
|
@@ -662,6 +658,16 @@ const SCOUT_REASON_RE = /^the survey is [^(]*\((.*)\) — an absent facts/s;
|
|
|
662
658
|
*/
|
|
663
659
|
export function draftStopText({ stop, reds = null, refusal = null, spend = null, wholeJob = false }) {
|
|
664
660
|
const spent = spendText(spend);
|
|
661
|
+
// PRD v1.107 Part 5b: too many early asks - the code-owned list (every ask and its reading) is the whole message, quoted in full
|
|
662
|
+
if (stop === 'look-asks-too-many') {
|
|
663
|
+
const detail = (Array.isArray(reds) ? reds : []).find((r) => r?.code === 'look-asks-too-many')?.detail;
|
|
664
|
+
if (detail) return `${redactSecrets(String(detail))}\nSpent so far: ${spent ?? 'unknown'}.`;
|
|
665
|
+
}
|
|
666
|
+
// F243: the fit check - the page does not show everything the job asks for; the not-found list (the code-made red detail) is the whole reason
|
|
667
|
+
if (stop === 'fit-stopped') {
|
|
668
|
+
const detail = (Array.isArray(reds) ? reds : []).find((r) => r?.code === 'fit-stopped')?.detail;
|
|
669
|
+
if (detail) return `Stopped before drafting: the page does not show everything the job asks for: ${redactSecrets(String(detail))}. Change the job or the page and draft again. Spent so far: ${spent ?? 'unknown'}.`;
|
|
670
|
+
}
|
|
665
671
|
/** @type {Record<string, string>} */
|
|
666
672
|
const heads = {
|
|
667
673
|
'provider-red': 'The AI provider failed before a usable plan came back — a connection or service fault, not a problem with your job.',
|
package/src/authorrun.js
CHANGED
|
@@ -128,6 +128,7 @@ import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './provider
|
|
|
128
128
|
import { commandFor } from './invoke.js';
|
|
129
129
|
import { readSourceManifest, missingDependencies, nonRepoSourceMessage, isRubricFilesManifest } from './source.js';
|
|
130
130
|
import { tallyCalls } from './text.js';
|
|
131
|
+
import { splitReadNotes } from './pagedropwords.js';
|
|
131
132
|
import {
|
|
132
133
|
declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
|
|
133
134
|
openQuestionLines, answeredQuestionLines, fellBackLines, PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText,
|
|
@@ -771,9 +772,14 @@ export async function main(argv, deps = {}) {
|
|
|
771
772
|
for (const c of p.checks ?? []) out(` · ${c}`);
|
|
772
773
|
out(' protections (always-on guards, never named in the goal):');
|
|
773
774
|
for (const g of p.protections ?? []) out(` · ${g}`);
|
|
774
|
-
|
|
775
|
+
const { notChecked: unchecked, readNotes } = splitReadNotes(p.notChecked, p.signedFacts);
|
|
776
|
+
if (unchecked.length) {
|
|
775
777
|
out(' you asked for these, but nothing checks them:');
|
|
776
|
-
for (const n of
|
|
778
|
+
for (const n of unchecked) out(` · ${n}`);
|
|
779
|
+
}
|
|
780
|
+
if (readNotes.length) {
|
|
781
|
+
out(` how the page was read · ${readNotes.length}:`);
|
|
782
|
+
for (const n of readNotes) out(` · ${n}`);
|
|
777
783
|
}
|
|
778
784
|
if ((p.questions ?? []).length) {
|
|
779
785
|
out(' open questions:');
|
package/src/declaredclose.js
CHANGED
|
@@ -576,13 +576,14 @@ function translate(r, redact) {
|
|
|
576
576
|
* judgeLoop?: ((o: {system: string}) => any)|null,
|
|
577
577
|
* judgeModel?: string|null,
|
|
578
578
|
* onJudgeCost?: ((c: any) => void)|null,
|
|
579
|
+
* onRubricEvidence?: ((e: any) => void)|null,
|
|
579
580
|
* callBounds?: (() => {timeoutMs?: number, deadlineMs?: number})|null}} [opts]
|
|
580
581
|
* @returns {Promise<any>}
|
|
581
582
|
*/
|
|
582
583
|
export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
|
|
583
584
|
const {
|
|
584
585
|
timeoutMs, cwd, seedRef, seedTrees: shared, gapCap, maxBuffer, baselineMode, humanRuling = null,
|
|
585
|
-
judgeLoop = null, judgeModel = null, onJudgeCost = null, callBounds = null,
|
|
586
|
+
judgeLoop = null, judgeModel = null, onJudgeCost = null, callBounds = null, onRubricEvidence = null,
|
|
586
587
|
} = opts;
|
|
587
588
|
if (!isNonEmptyString(cwd) || !isNonEmptyString(seedRef)) {
|
|
588
589
|
// Refuse rather than guess. A declared close measures against a SEED; a
|
|
@@ -634,6 +635,8 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
|
|
|
634
635
|
// the honest absence (an adopter/test that wires no clock through), never a
|
|
635
636
|
// defaulted number invented here.
|
|
636
637
|
callBounds,
|
|
638
|
+
// REPORT-ONLY seam: what a rubric-step stage's cited checks read (bareguard `full.evidence`); the runner records it, nothing here reads it
|
|
639
|
+
onRubricEvidence,
|
|
637
640
|
seedTrees,
|
|
638
641
|
};
|
|
639
642
|
try {
|
package/src/draftspend.js
CHANGED
|
@@ -25,6 +25,8 @@ export const DRAFT_LOG_FILE = 'draft-log.jsonl';
|
|
|
25
25
|
* - `step-start` {no, id, label, at}
|
|
26
26
|
* - `step-end` {no, status: 'done'|'failed', label, at} (a re-opened step writes `step-reopen` {no, at} first)
|
|
27
27
|
* - `step-label` {no, label, at} (the step reads `label` from now on, status and times untouched: the thinking row's "the model thought anyway" / "can't change it" / "not confirmed")
|
|
28
|
+
* - `human` {no, source: 'confirm'|'try-3'|'look', question, answer, line?, at}: one question the person was asked and what they answered (the answer is secret-masked
|
|
29
|
+
* before it is written; the row is written only when BOTH exist, so a question nobody answered leaves no row)
|
|
28
30
|
* - `call` {no, step, label, model, thinking, thinkingSupport, thinkingStatus?, costUsd (null stays null), unpricedRounds, at}: one METERED model call
|
|
29
31
|
* (`thinking` = the tick's mode: off | on; `thinkingSupport` = bare-agent's lookup for the model: true | false | 'unverified';
|
|
30
32
|
* `thinkingStatus` = bare-agent's own verdict for the call's run: applied | unsupported | ignored | unknown, present only when thinkingMode was sent)
|
|
@@ -126,12 +128,26 @@ export function sessionDirForRow(row, opts = {}) {
|
|
|
126
128
|
return null;
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/**
|
|
132
|
+
* PRD v1.106 Part 5 Q1: one answered question as the two lines the Audit's drafting table shows under its step. ONE place owns the words.
|
|
133
|
+
* @param {{question: string, answer: string, source?: string, line?: number, at?: string}} h
|
|
134
|
+
* @returns {{question: string, answer: string, source: string, line?: number, at?: string, askedText: string, saidText: string}}
|
|
135
|
+
*/
|
|
136
|
+
export function humanRowWords(h) {
|
|
137
|
+
return {
|
|
138
|
+
question: h.question, answer: h.answer, source: typeof h.source === 'string' ? h.source : 'confirm',
|
|
139
|
+
...(Number.isInteger(h.line) ? { line: h.line } : {}), ...(h.at ? { at: h.at } : {}),
|
|
140
|
+
askedText: `you were asked: ${h.question}`, saidText: `you said: ${h.answer}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
129
144
|
/**
|
|
130
145
|
* Fold `<dir>/draft-log.jsonl` into one record per drafting step, each with its calls. A line that cannot be parsed is
|
|
131
146
|
* skipped (a torn last line must not hide the rest).
|
|
132
147
|
* @param {string} dir
|
|
133
148
|
* @returns {{no: number, id: string, label: string, status: 'running'|'done'|'failed', startedAt: string, endedAt: string|null, wallMs: number|null,
|
|
134
|
-
* calls: {label: string, model: string|null, costUsd: number|null, unpricedRounds: number, at: string}[]
|
|
149
|
+
* calls: {label: string, model: string|null, costUsd: number|null, unpricedRounds: number, at: string}[],
|
|
150
|
+
* humans: ReturnType<typeof humanRowWords>[]}[]}
|
|
135
151
|
*/
|
|
136
152
|
export function readDraftSteps(dir) {
|
|
137
153
|
/** @type {string} */
|
|
@@ -145,7 +161,7 @@ export function readDraftSteps(dir) {
|
|
|
145
161
|
try { ev = JSON.parse(line); } catch { continue; }
|
|
146
162
|
if (!ev || typeof ev !== 'object' || !Number.isInteger(ev.no)) continue;
|
|
147
163
|
if (ev.kind === 'step-start') {
|
|
148
|
-
byNo.set(ev.no, { no: ev.no, id: String(ev.id), label: String(ev.label), status: 'running', startedAt: ev.at, endedAt: null, wallMs: null, calls: [] });
|
|
164
|
+
byNo.set(ev.no, { no: ev.no, id: String(ev.id), label: String(ev.label), status: 'running', startedAt: ev.at, endedAt: null, wallMs: null, calls: [], humans: [] });
|
|
149
165
|
} else if (ev.kind === 'step-end' && byNo.has(ev.no)) {
|
|
150
166
|
const x = byNo.get(ev.no);
|
|
151
167
|
x.status = ev.status === 'failed' ? 'failed' : 'done';
|
|
@@ -157,6 +173,8 @@ export function readDraftSteps(dir) {
|
|
|
157
173
|
const x = byNo.get(ev.no);
|
|
158
174
|
x.status = 'running';
|
|
159
175
|
x.endedAt = null;
|
|
176
|
+
} else if (ev.kind === 'human' && byNo.has(ev.no) && typeof ev.question === 'string' && typeof ev.answer === 'string') {
|
|
177
|
+
byNo.get(ev.no).humans.push(humanRowWords({ question: ev.question, answer: ev.answer, source: ev.source, ...(Number.isInteger(ev.line) ? { line: ev.line } : {}), at: ev.at }));
|
|
160
178
|
} else if (ev.kind === 'call') {
|
|
161
179
|
const c = { label: String(ev.label), model: typeof ev.model === 'string' ? ev.model : null, costUsd: typeof ev.costUsd === 'number' ? ev.costUsd : null, unpricedRounds: Number(ev.unpricedRounds) || 0, at: String(ev.at) };
|
|
162
180
|
byNo.get(ev.no)?.calls.push(c);
|
package/src/facts.js
CHANGED
|
@@ -24,6 +24,22 @@ export const FACT_PART_FIELDS = Object.freeze(['source', 'facts', 'count']);
|
|
|
24
24
|
/** a fact's name appears in the claim text bareguard scans for numbers, so it carries letters only (no digit can red the claim) */
|
|
25
25
|
const NAME_RE = /^[A-Za-z][A-Za-z _-]{0,63}$/;
|
|
26
26
|
|
|
27
|
+
/** the limits bareguard 0.24 puts on a money fact's lists (ruling #65), enforced here first so the red is ours and plain */
|
|
28
|
+
export const MAX_CURRENCIES = 3;
|
|
29
|
+
export const MAX_UNITS = 10;
|
|
30
|
+
export const MAX_UNIT_CHARS = 100;
|
|
31
|
+
const CURRENCY_SIGNS = ['$', '€', '£'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A money fact's `unit` / `currency` is ONE string (every spec signed before lists) or a non-empty list of them; a string is read exactly as a
|
|
35
|
+
* one-element list. Nothing is rewritten, so an old spec's bytes and hash stay as they were.
|
|
36
|
+
* @param {unknown} v @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
export const asList = (v) => (Array.isArray(v) ? v.map(String) : (typeof v === 'string' ? [v] : []));
|
|
39
|
+
|
|
40
|
+
/** @param {any} f a money fact @returns {string[]} every form the page writes the amount in: the units first (as R1L reads them), then the currency signs */
|
|
41
|
+
export const moneyForms = (f) => [...asList(f.unit), ...asList(f.currency)];
|
|
42
|
+
|
|
27
43
|
/** @param {unknown} v @returns {v is Record<string, any>} */
|
|
28
44
|
const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
29
45
|
/** @param {unknown} v @returns {v is string} */
|
|
@@ -36,7 +52,7 @@ export function factPartOf(params) {
|
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
/** @param {any} v @returns {boolean} a rule bound has the type its shape needs: a number for number/money, a string for date/time */
|
|
39
|
-
const boundOk = (/** @type {string} */ shape, /** @type {unknown} */ v) => (shape === 'number' || shape === 'money' ? typeof v === 'number' && Number.isFinite(v) : typeof v === 'string' && v !== '');
|
|
55
|
+
export const boundOk = (/** @type {string} */ shape, /** @type {unknown} */ v) => (shape === 'number' || shape === 'money' ? typeof v === 'number' && Number.isFinite(v) : typeof v === 'string' && v !== '');
|
|
40
56
|
|
|
41
57
|
/**
|
|
42
58
|
* The SHAPE of a `fact` part (what it may carry), pushed as reds in the same form `rubricStepShapeReds` uses. Whether the
|
|
@@ -72,10 +88,27 @@ export function factShapeReds(part, w, red) {
|
|
|
72
88
|
if (Object.hasOwn(f, 'aiProposal') && typeof f.aiProposal !== 'boolean') red('invalid-value', `${fw}.aiProposal`, 'true when the AI turned the person\'s words into this number');
|
|
73
89
|
const has = (/** @type {string} */ k) => Object.hasOwn(f, k);
|
|
74
90
|
if (f.shape === 'money') {
|
|
75
|
-
if (has('unit')
|
|
91
|
+
if (!has('unit') && !has('currency')) red('invalid-value', `${fw}.unit`, 'money names at least one of unit (words the input writes after the amount, e.g. "US dollars") or currency ("$", "€" or "£"); each may be one value or a list');
|
|
76
92
|
} else if (has('unit') || has('currency')) red('invalid-value', `${fw}.${has('unit') ? 'unit' : 'currency'}`, 'only money has a unit or a currency');
|
|
77
|
-
if (has('unit')
|
|
78
|
-
|
|
93
|
+
if (has('unit')) {
|
|
94
|
+
const us = f.unit;
|
|
95
|
+
if (Array.isArray(us) ? us.length === 0 || us.some((/** @type {unknown} */ u) => !nonEmpty(u)) : !nonEmpty(us)) red('invalid-value', `${fw}.unit`, 'a non-empty string copied from the input, or a non-empty list of them');
|
|
96
|
+
else {
|
|
97
|
+
const list = asList(us);
|
|
98
|
+
if (list.length > MAX_UNITS) red('invalid-value', `${fw}.unit`, `at most ${MAX_UNITS} units (${list.length} given)`);
|
|
99
|
+
else if (list.some((u) => u.length > MAX_UNIT_CHARS)) red('invalid-value', `${fw}.unit`, `a unit is at most ${MAX_UNIT_CHARS} characters`);
|
|
100
|
+
else if (new Set(list).size !== list.length) red('invalid-value', `${fw}.unit`, 'the units are not repeated');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (has('currency')) {
|
|
104
|
+
const cs = f.currency;
|
|
105
|
+
if (Array.isArray(cs) ? cs.length === 0 || cs.some((/** @type {unknown} */ c) => !CURRENCY_SIGNS.includes(/** @type {string} */ (c))) : !CURRENCY_SIGNS.includes(cs)) red('invalid-value', `${fw}.currency`, 'one of $ | € | £, or a list of them');
|
|
106
|
+
else {
|
|
107
|
+
const list = asList(cs);
|
|
108
|
+
if (list.length > MAX_CURRENCIES) red('invalid-value', `${fw}.currency`, `at most ${MAX_CURRENCIES} currencies (${list.length} given)`);
|
|
109
|
+
else if (new Set(list).size !== list.length) red('invalid-value', `${fw}.currency`, 'the currencies are not repeated');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
79
112
|
if (f.shape === 'phrase') {
|
|
80
113
|
if (!nonEmpty(f.phrase)) red('invalid-value', `${fw}.phrase`, 'the fixed words the quote must contain, copied from the input');
|
|
81
114
|
for (const k of ['anchor', 'until', 'rule']) if (has(k)) red('invalid-value', `${fw}.${k}`, 'a phrase is fixed words the quote must contain; it has no anchor, until or rule');
|
|
@@ -112,7 +145,7 @@ export function factDecides(part) {
|
|
|
112
145
|
if (f.shape === 'phrase') return { phrases: [f.phrase] };
|
|
113
146
|
/** @type {Record<string, any>} */
|
|
114
147
|
const d = { valueKind: f.shape };
|
|
115
|
-
if (f.shape === 'money') { if (f.unit !== undefined) d.unit = f.unit;
|
|
148
|
+
if (f.shape === 'money') { if (f.unit !== undefined) d.unit = f.unit; if (f.currency !== undefined) d.currency = f.currency; }
|
|
116
149
|
if (f.anchor !== undefined) d.anchor = f.anchor;
|
|
117
150
|
if (f.until !== undefined) d.until = f.until;
|
|
118
151
|
if (f.rule) {
|
|
@@ -151,12 +184,24 @@ export function factClaims(part, results) {
|
|
|
151
184
|
|
|
152
185
|
/** @param {unknown} n @param {string} shape @param {any} f @returns {string} one bound, as the person would say it */
|
|
153
186
|
function sayBound(n, shape, f) {
|
|
154
|
-
if (shape === 'money') return f
|
|
187
|
+
if (shape === 'money') return moneyAs(f, /** @type {string|number} */ (n), false);
|
|
155
188
|
return String(n);
|
|
156
189
|
}
|
|
157
190
|
|
|
191
|
+
/**
|
|
192
|
+
* One amount of a money fact in the fact's OWN signed forms, never a hardcoded sign: `symbolFirst` puts the currency sign before the amount
|
|
193
|
+
* ("$144") when the fact has one, else the unit after it ("144 US dollars"); not `symbolFirst` (a rule's bound) prefers the unit.
|
|
194
|
+
* @param {any} f a money fact @param {string|number} n @param {boolean} symbolFirst @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
export function moneyAs(f, n, symbolFirst) {
|
|
197
|
+
const cur = asList(f.currency)[0];
|
|
198
|
+
const uni = asList(f.unit)[0];
|
|
199
|
+
if (symbolFirst) return cur !== undefined ? `${cur}${n}` : `${n} ${uni}`;
|
|
200
|
+
return uni !== undefined ? `${n} ${uni}` : `${cur}${n}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
158
203
|
/** @param {any} f a fact with a rule @returns {string} the rule in words, e.g. "at most 450 US dollars" */
|
|
159
|
-
function ruleWords(f) {
|
|
204
|
+
export function ruleWords(f) {
|
|
160
205
|
const r = f.rule;
|
|
161
206
|
return r.op === 'between' ? `between ${sayBound(r.lo, f.shape, f)} and ${sayBound(r.hi, f.shape, f)}`
|
|
162
207
|
: `${{ lt: 'under', lte: 'at most', gt: 'over', gte: 'at least' }[/** @type {'lt'|'lte'|'gt'|'gte'} */ (r.op)]} ${sayBound(r.value, f.shape, f)}`;
|
|
@@ -173,7 +218,7 @@ export function factSentence(f, source) {
|
|
|
173
218
|
const bits = [];
|
|
174
219
|
if (f.shape === 'phrase') bits.push('phrase', `must say "${f.phrase}"`);
|
|
175
220
|
else {
|
|
176
|
-
bits.push(f.shape === 'money' ? `money in ${f.
|
|
221
|
+
bits.push(f.shape === 'money' ? `money in ${moneyForms(f).join(' or ')}` : f.shape);
|
|
177
222
|
if (f.rule) bits.push(ruleWords(f));
|
|
178
223
|
else if (f.anchor === undefined) bits.push('as the quote writes it');
|
|
179
224
|
if (f.anchor !== undefined) bits.push(f.until !== undefined ? `the words after "${f.anchor}" up to "${f.until}"` : `after "${f.anchor}"`);
|
|
@@ -190,7 +235,7 @@ export function factSentence(f, source) {
|
|
|
190
235
|
export function factBrief(f) {
|
|
191
236
|
if (f.shape === 'phrase') return `${f.name} — no value to write; the quote you copy must say "${f.phrase}"`;
|
|
192
237
|
/** @type {string[]} */
|
|
193
|
-
const bits = [f.shape === 'money' ? `an amount in ${f.
|
|
238
|
+
const bits = [f.shape === 'money' ? `an amount in ${moneyForms(f).join(' or ')}` : `a ${f.shape}`];
|
|
194
239
|
if (f.shape === 'text') bits[0] = 'words';
|
|
195
240
|
if (f.anchor !== undefined) bits.push(f.until !== undefined ? `the words right after "${f.anchor}" up to "${f.until}"` : `right after "${f.anchor}"`);
|
|
196
241
|
if (f.rule) bits.push(`must be ${ruleWords(f)}`);
|
package/src/factwords.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// page) is one space on both sides.
|
|
8
8
|
|
|
9
9
|
import { labelOnList, normLabel, scanLabels } from './labelscan.js';
|
|
10
|
+
import { asList } from './facts.js';
|
|
11
|
+
import { dropSentence } from './pagedropwords.js';
|
|
10
12
|
|
|
11
13
|
const WS = /[\s ]+/g;
|
|
12
14
|
const escapeRe = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -46,19 +48,63 @@ export function countPhrase(source, words) {
|
|
|
46
48
|
/** @param {string} shape @returns {'money'|'number'|'date'|'time'|'text'} the label list a fact's anchor is picked from */
|
|
47
49
|
const listOf = (shape) => /** @type {any} */ (['money', 'number', 'date', 'time'].includes(shape) ? shape : 'text');
|
|
48
50
|
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What the page-word map (src/pagemap.js) KEPT and DROPPED for one input: the sets the second proof reads. A look from the old search-and-read
|
|
54
|
+
* path has no `dropped` list, so it gives null and the label is proved against the code-made list as before.
|
|
55
|
+
* @param {{facts?: any[], dropped?: (import('./pagerules.js').Dropped & {source?: string})[]}|null|undefined} look @param {string} source
|
|
56
|
+
* @returns {{labels: Set<string>, units: Set<string>, currencies: Set<string>, words: Set<string>, dropped: Map<string, import('./pagerules.js').Dropped>}|null}
|
|
57
|
+
*/
|
|
58
|
+
export function keptSetOf(look, source) {
|
|
59
|
+
if (!look || !Array.isArray(look.dropped)) return null;
|
|
60
|
+
const facts = (Array.isArray(look.facts) ? look.facts : []).filter((f) => f && f.source === source && Array.isArray(f.words));
|
|
61
|
+
const set = (/** @type {string[]} */ a) => new Set(a.map((w) => normLabel(String(w))));
|
|
62
|
+
/** @type {Map<string, import('./pagerules.js').Dropped>} */
|
|
63
|
+
const dropped = new Map();
|
|
64
|
+
for (const d of look.dropped) if (d.source === source && typeof d.word === 'string' && !dropped.has(normLabel(d.word))) dropped.set(normLabel(d.word), d);
|
|
65
|
+
return {
|
|
66
|
+
labels: set(facts.filter((f) => f.shape !== 'phrase').flatMap((f) => f.words)),
|
|
67
|
+
units: set(facts.flatMap((f) => f.unit ?? [])),
|
|
68
|
+
currencies: set(facts.flatMap((f) => f.currency ?? [])),
|
|
69
|
+
words: set(facts.flatMap((f) => [...f.words, ...(f.unit ?? []), ...(f.currency ?? [])])),
|
|
70
|
+
dropped,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
/**
|
|
50
75
|
* The reds for ONE fact part against its frozen source text.
|
|
51
|
-
* @param {{part: any, text: string, line: number|string, scan?: import('./labelscan.js').LabelScan}} o
|
|
76
|
+
* @param {{part: any, text: string, line: number|string, scan?: import('./labelscan.js').LabelScan, kept?: ReturnType<typeof keptSetOf>}} o
|
|
52
77
|
* @returns {{code: string, detail: string}[]}
|
|
53
78
|
*/
|
|
54
|
-
export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
|
|
79
|
+
export function checkFactWords({ part, text, line, scan = scanLabels(text), kept = null }) {
|
|
55
80
|
/** @type {{code: string, detail: string}[]} */
|
|
56
81
|
const reds = [];
|
|
57
82
|
const src = part.source;
|
|
58
83
|
const red = (/** @type {string} */ code, /** @type {string} */ msg) => reds.push({ code, detail: `line ${line}: ${msg} Nothing was signed.` });
|
|
59
84
|
for (const f of Array.isArray(part.facts) ? part.facts : []) {
|
|
60
85
|
const who = `fact "${f.name}"`;
|
|
61
|
-
|
|
86
|
+
// a word the page rules DROPPED (and no other fact kept) can never come back through the drafter (idea A core, spec 4.3)
|
|
87
|
+
const dropRed = (/** @type {string} */ what, /** @type {unknown} */ w) => {
|
|
88
|
+
const n = normLabel(String(w));
|
|
89
|
+
const d = kept?.dropped.get(n);
|
|
90
|
+
if (!kept || !d || kept.words.has(n)) return false;
|
|
91
|
+
red('rubric-fact-word-dropped', `${who}: the ${what} ${JSON.stringify(n)} cannot be used. ${dropSentence(d)}`);
|
|
92
|
+
return true;
|
|
93
|
+
};
|
|
94
|
+
if (f.anchor !== undefined && f.shape !== 'phrase' && kept) {
|
|
95
|
+
// a page input read by the page-word map: the label must be one the map KEPT (the code-made list cannot hold a marker after a number)
|
|
96
|
+
const label = normLabel(String(f.anchor));
|
|
97
|
+
if (dropRed('label', f.anchor)) { /* said above */ } else if (!kept.labels.has(label)) {
|
|
98
|
+
const some = [...kept.labels].slice(0, 12).map((l) => JSON.stringify(l)).join(', ');
|
|
99
|
+
red('rubric-fact-label-off-list', `${who}: the label ${JSON.stringify(label)} is not one of the labels the page words for "${src}" kept. A label is picked from those, never typed${some ? `: ${some}` : ' (there are none - leave the label out)'}.`);
|
|
100
|
+
} else if (countExact(text, label) < 1) {
|
|
101
|
+
red('rubric-fact-word-missing', `${who}: the label ${JSON.stringify(label)} is not in the input "${src}".`);
|
|
102
|
+
} else if (Number.isInteger(part.count?.min) && countExact(text, label) < part.count.min) {
|
|
103
|
+
const n = part.count.min;
|
|
104
|
+
const seen = countExact(text, label);
|
|
105
|
+
red('rubric-fact-label-rare', `${who}: the label ${JSON.stringify(label)} is in the input "${src}" only ${seen} time${seen === 1 ? '' : 's'}, but this line asks for ${n} results, so a label that sits in each result must be there at least ${n} times. Pick a label that is counted ${n} or more times.`);
|
|
106
|
+
}
|
|
107
|
+
} else if (f.anchor !== undefined && f.shape !== 'phrase') {
|
|
62
108
|
const list = listOf(f.shape);
|
|
63
109
|
const row = labelOnList(scan, list, f.anchor);
|
|
64
110
|
if (!row) {
|
|
@@ -73,6 +119,7 @@ export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
|
|
|
73
119
|
red('rubric-fact-label-rare', `${who}: the label ${JSON.stringify(row.label)} is in the input "${src}" only ${row.count} time${row.count === 1 ? '' : 's'}, but this line asks for ${n} results, so a label that sits in each result must be there at least ${n} times. Pick a label from the list that is counted ${n} or more times.`);
|
|
74
120
|
}
|
|
75
121
|
}
|
|
122
|
+
if (f.until !== undefined) dropRed('closing label', f.until);
|
|
76
123
|
if (f.until !== undefined && countExact(text, f.until) < 1) red('rubric-fact-word-missing', `${who}: ${JSON.stringify(f.until)} is not in the input "${src}". Copy the closing words from the input, or leave them out.`);
|
|
77
124
|
if (f.shape === 'phrase' && typeof f.phrase === 'string') {
|
|
78
125
|
const seen = countPhrase(text, f.phrase);
|
|
@@ -84,7 +131,11 @@ export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
|
|
|
84
131
|
red('rubric-fact-phrase-rare', `${who}: the words ${JSON.stringify(f.phrase)} are in the input "${src}" only ${seen} time${seen === 1 ? '' : 's'}, but this line asks for ${n} results - each result's own quote must say them. Pick words every result says, or drop this check.`);
|
|
85
132
|
}
|
|
86
133
|
}
|
|
87
|
-
|
|
134
|
+
// a unit (one string, or every one of a list) must be found as whole words; a currency sign (each of a list) must be found as written
|
|
135
|
+
for (const u of asList(f.unit)) dropRed('unit', u);
|
|
136
|
+
for (const c of asList(f.currency)) dropRed('currency', c);
|
|
137
|
+
for (const u of asList(f.unit)) if (countPhrase(text, u) < 1) red('rubric-fact-word-missing', `${who}: the unit ${JSON.stringify(u)} is not in the input "${src}". Copy the words the input really uses.`);
|
|
138
|
+
for (const c of asList(f.currency)) if (countExact(text, c) < 1) red('rubric-fact-word-missing', `${who}: the currency ${JSON.stringify(c)} is not in the input "${src}". Copy the sign the input really uses.`);
|
|
88
139
|
}
|
|
89
140
|
return reds;
|
|
90
141
|
}
|
|
@@ -93,9 +144,10 @@ export function checkFactWords({ part, text, line, scan = scanLabels(text) }) {
|
|
|
93
144
|
* The reds for every fact part of a declaration. A fact part whose input text is not at hand is a red: a check whose words were never
|
|
94
145
|
* proved against the source is never signed.
|
|
95
146
|
* @param {any} declaration @param {Record<string, string>|null} texts label -> frozen text
|
|
147
|
+
* @param {{facts?: any[], dropped?: any[]}|null} [look] the page-word map's report: what it kept and dropped (null = no map, the code-made list is the proof)
|
|
96
148
|
* @returns {{code: string, path: string, detail: string}[]}
|
|
97
149
|
*/
|
|
98
|
-
export function checkDeclarationWords(declaration, texts) {
|
|
150
|
+
export function checkDeclarationWords(declaration, texts, look = null) {
|
|
99
151
|
/** @type {{code: string, path: string, detail: string}[]} */
|
|
100
152
|
const reds = [];
|
|
101
153
|
if (!declaration || !Array.isArray(declaration.stages)) return reds;
|
|
@@ -114,7 +166,7 @@ export function checkDeclarationWords(declaration, texts) {
|
|
|
114
166
|
return;
|
|
115
167
|
}
|
|
116
168
|
if (!scans.has(p.source)) scans.set(p.source, scanLabels(text));
|
|
117
|
-
for (const r of checkFactWords({ part: p, text, line: st.params.step, scan: scans.get(p.source) })) reds.push({ code: r.code, path: at, detail: r.detail });
|
|
169
|
+
for (const r of checkFactWords({ part: p, text, line: st.params.step, scan: scans.get(p.source), kept: keptSetOf(look, p.source) })) reds.push({ code: r.code, path: at, detail: r.detail });
|
|
118
170
|
});
|
|
119
171
|
});
|
|
120
172
|
return reds;
|
package/src/fitcheck.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// THE FIT CHECK (docs/product/ITEM33-BUILD.md "Addendum 2026-10-21 - fit check build spec", F243, fit POC v6). A rubric job, at drafting, once, after the
|
|
2
|
+
// inputs are frozen and before the look: ONE paid AI call splits each job line into the short parts it asks for (words copied from that line) and says,
|
|
3
|
+
// for each, whether the saved page shows it, with a quote copied from the page. The AI never decides: plain code checks every ask against its own
|
|
4
|
+
// line and every quote against the page, and a part not found becomes one question to the person (never a stop by itself, never a count). Failure is
|
|
5
|
+
// OPEN - a wrong stop is the costly mistake - so a call that does not finish is `absent` and drafting goes on. The call/book/retry pattern is
|
|
6
|
+
// src/pagemap.js's `runPageMap`.
|
|
7
|
+
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { priceOf, scrubRaw, tallyCalls, capStop, callCasualty } from './text.js';
|
|
10
|
+
import { redactSecrets } from './validate.js';
|
|
11
|
+
import { isTransportFailure } from './transport.js';
|
|
12
|
+
import { isRateLimited, rateLimitWaitMs } from './ratelimit.js';
|
|
13
|
+
import { isOutputCutOff } from './authorflow.js';
|
|
14
|
+
import { AUTHOR_CALL_TIMEOUT_MS, AUTHOR_SCOUT_MAX_TOKENS, SURVEY_CAUSES } from './authorscout.js';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const { Loop } = require('bare-agent');
|
|
18
|
+
|
|
19
|
+
/** the label of the one paid call, as the drafting book and the progress line see it */
|
|
20
|
+
export const FIT_CALL_LABEL = 'fit-check';
|
|
21
|
+
/** what the chat says when the check did not finish (drafting goes on) */
|
|
22
|
+
export const FIT_ABSENT_NOTE = 'The fit check did not finish; drafting goes on.';
|
|
23
|
+
/** the answer that keeps drafting after the fit question (any case, a closing full stop allowed) */
|
|
24
|
+
export const FIT_GO_ON = 'go on';
|
|
25
|
+
/** @param {unknown} text @returns {boolean} */
|
|
26
|
+
export const isGoOn = (text) => norm(String(text ?? '').trim().replace(/\.$/, '')) === FIT_GO_ON;
|
|
27
|
+
|
|
28
|
+
export const FIT_SYSTEM = 'You check whether saved web pages show what a job asks for. You never invent words: every ask must be copied from its job line and every quote must be copied from the page, character for character.';
|
|
29
|
+
|
|
30
|
+
/** collapse whitespace, lower case: the one spelling every fit comparison uses @param {unknown} s @returns {string} */
|
|
31
|
+
export const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What the AI is told: the job lines (the numbered text only, no `~` rules) and the WHOLE text of every input under its label. Adapted from the POC
|
|
35
|
+
* call (.claude/scratch/fit-poc6/run.mjs): the asks are copied from each job line instead of hand-written.
|
|
36
|
+
* @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>}} o
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function fitPrompt({ jobLines, texts }) {
|
|
40
|
+
const job = jobLines.map((l) => `LINE ${l.n}: ${l.text}`).join('\n');
|
|
41
|
+
const pages = Object.entries(texts).map(([label, text]) => `THE PAGE (the input "${label}"):\n${text}`).join('\n\n');
|
|
42
|
+
return `THE JOB:\n${job}\n\n${pages}\n\n`
|
|
43
|
+
+ 'For EACH job line, split it into the short parts it asks for (for example where it starts, where it ends, a price, a time), using ONLY words copied from that line. '
|
|
44
|
+
+ 'Every line has at least one part. For EACH part, say whether the page shows it the way the job means it. A page that shows the opposite (for example a flight that arrives where the job says it leaves from, or a different city) does NOT show it. '
|
|
45
|
+
+ 'If found, copy a short piece of page text that shows it, character for character. If not found, quote is null. '
|
|
46
|
+
+ 'Reply with JSON only, no prose, no code fence: {"asks":[{"line":<line number>,"ask":"<the part, copied from that line>","found":true|false,"quote":"<page text or null>"}]}';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @typedef {{line: number, ask: string, found: boolean, quote: string|null, madeUp: boolean, ignored?: 'number'}} FitAsk */
|
|
50
|
+
|
|
51
|
+
/** an ask that is only a number (a count the job gives, not something the page shows): digits, optional spaces, commas, dots @param {string} ask @returns {boolean} */
|
|
52
|
+
const isNumberOnly = (ask) => /^\d[\d\s,.]*$/.test(norm(ask));
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the AI's reply and decide, all in code. Malformed (no repair): not JSON, another form, a line that is not a job line, an ask not copied from its own
|
|
56
|
+
* line, found not a boolean, a quote neither text nor null, or a job line with no ask. An ask is found only if `found` is true AND its quote is on a page;
|
|
57
|
+
* `found` with a quote that is not on any page (or no quote) is made up and counts as not found.
|
|
58
|
+
* @param {unknown} text the reply
|
|
59
|
+
* @param {{n: number, text: string}[]} jobLines
|
|
60
|
+
* @param {Record<string, string>} texts the frozen input texts by label
|
|
61
|
+
* @returns {{status: 'malformed'}|{status: 'ok', state: 'fits'|'ask', asks: FitAsk[], notFound: {line: number, ask: string}[]}}
|
|
62
|
+
*/
|
|
63
|
+
export function decideFit(text, jobLines, texts) {
|
|
64
|
+
/** @type {any} */
|
|
65
|
+
let j;
|
|
66
|
+
try { j = JSON.parse(String(text ?? '').trim()); } catch { return { status: 'malformed' }; }
|
|
67
|
+
if (j === null || typeof j !== 'object' || Array.isArray(j) || !Array.isArray(j.asks)) return { status: 'malformed' };
|
|
68
|
+
const lines = new Map(jobLines.map((l) => [l.n, norm(l.text)]));
|
|
69
|
+
const pages = Object.values(texts).map(norm);
|
|
70
|
+
/** @type {FitAsk[]} */
|
|
71
|
+
const asks = [];
|
|
72
|
+
for (const a of j.asks) {
|
|
73
|
+
if (a === null || typeof a !== 'object' || Array.isArray(a)) return { status: 'malformed' };
|
|
74
|
+
if (!Number.isInteger(a.line) || !lines.has(a.line)) return { status: 'malformed' };
|
|
75
|
+
if (typeof a.ask !== 'string' || norm(a.ask) === '' || !(/** @type {string} */ (lines.get(a.line))).includes(norm(a.ask))) return { status: 'malformed' };
|
|
76
|
+
if (typeof a.found !== 'boolean') return { status: 'malformed' };
|
|
77
|
+
const quote = a.quote ?? null;
|
|
78
|
+
if (quote !== null && typeof quote !== 'string') return { status: 'malformed' };
|
|
79
|
+
const q = quote === null ? '' : norm(quote);
|
|
80
|
+
const onPage = q !== '' && pages.some((p) => p.includes(q));
|
|
81
|
+
// a bare number is ignored: kept in the record (flagged), never counted, never asked about
|
|
82
|
+
asks.push({ line: a.line, ask: a.ask, found: a.found && onPage, quote, madeUp: a.found && !onPage, ...(isNumberOnly(a.ask) ? { ignored: /** @type {'number'} */ ('number') } : {}) });
|
|
83
|
+
}
|
|
84
|
+
if (!jobLines.every((l) => asks.some((a) => a.line === l.n))) return { status: 'malformed' };
|
|
85
|
+
const notFound = asks.filter((a) => !a.ignored && !a.found).map((a) => ({ line: a.line, ask: a.ask }));
|
|
86
|
+
return { status: 'ok', state: notFound.length === 0 ? 'fits' : 'ask', asks, notFound };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** the not-found asks as the person reads them: `<ask> (line N), <ask> (line N)` @param {{line: number, ask: string}[]} notFound @returns {string} */
|
|
90
|
+
export const notFoundList = (notFound) => notFound.map((x) => `${x.ask} (line ${x.line})`).join(', ');
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The one question, worded by code.
|
|
94
|
+
* @param {{found: number, total: number, notFound: {line: number, ask: string}[]}} o
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export const fitQuestionText = ({ found, total, notFound }) => redactSecrets(
|
|
98
|
+
`I found ${found} of ${total} of your asks on this page. Not found: ${notFoundList(notFound)}. Type go on to keep drafting, or anything else to stop and change the job or the page.`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
/** the default loop seam - one spelling of how this repo drives a model */
|
|
102
|
+
const defaultLoop = (/** @type {any} */ { system, onLlmResult, provider, rates }) =>
|
|
103
|
+
new Loop({ provider, system, onLlmResult, ...(rates ? { rates } : {}) });
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run the fit check: one call (one retry on a malformed reply or a transport failure or a 429). Never throws on a call casualty; every failure is `absent`.
|
|
107
|
+
* @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>, provider?: any, maxTokens?: number, ceilingUsd?: number|null,
|
|
108
|
+
* callTimeoutMs?: number, onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string, raw?: string}) => void,
|
|
109
|
+
* createLoop?: Function, rates?: {in: number, out: number}|null, callOptions?: Record<string, any>, sleep?: (ms: number) => Promise<void>}} o
|
|
110
|
+
* @returns {Promise<{state: 'fits'|'ask'|'absent', asks: FitAsk[], notFound: {line: number, ask: string}[], reason: string|null, cause: string|null,
|
|
111
|
+
* calls: {label: string, costUsd: number|null, unpricedRounds: number}[], budgetStop: 'cap-halt'|'pricing-red'|null}>}
|
|
112
|
+
*/
|
|
113
|
+
export async function runFitCheck({
|
|
114
|
+
jobLines, texts, provider = null, maxTokens = AUTHOR_SCOUT_MAX_TOKENS, ceilingUsd = null, callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS,
|
|
115
|
+
onCall = () => {}, createLoop = defaultLoop, rates = null, callOptions = {}, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
116
|
+
}) {
|
|
117
|
+
const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
|
|
118
|
+
? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS) : AUTHOR_CALL_TIMEOUT_MS;
|
|
119
|
+
const priced = rates ? { rates } : {};
|
|
120
|
+
/** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
|
|
121
|
+
const calls = [];
|
|
122
|
+
let attempts = 0;
|
|
123
|
+
const record = (/** @type {any} */ r) => {
|
|
124
|
+
const call = { label: FIT_CALL_LABEL, ...priceOf(r) };
|
|
125
|
+
calls.push(call);
|
|
126
|
+
const raw = scrubRaw({ label: FIT_CALL_LABEL, attempt: attempts, text: r?.text });
|
|
127
|
+
onCall({ ...call, raw: raw.text, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
|
|
128
|
+
};
|
|
129
|
+
const absent = (/** @type {string} */ reason, /** @type {string} */ cause, /** @type {'cap-halt'|'pricing-red'|null} */ budgetStop = null) => ({
|
|
130
|
+
state: /** @type {'absent'} */ ('absent'), asks: [], notFound: [], reason, cause, calls, budgetStop,
|
|
131
|
+
});
|
|
132
|
+
const content = fitPrompt({ jobLines, texts });
|
|
133
|
+
let malformed = 0;
|
|
134
|
+
let transportRetried = false;
|
|
135
|
+
let rateRetried = false;
|
|
136
|
+
for (;;) {
|
|
137
|
+
// the cap is checked BEFORE every call, never inside one
|
|
138
|
+
const halt = capStop({ ceilingUsd, ...tallyCalls(calls) });
|
|
139
|
+
if (halt) return absent(halt === 'cap-halt' ? `the fit check was not made: the $${ceilingUsd} ceiling was already spent` : 'the fit check was not made: spend could not be priced, so the ceiling could not be enforced', SURVEY_CAUSES.NOT_FUNDED, halt);
|
|
140
|
+
attempts += 1;
|
|
141
|
+
const loop = createLoop({ system: FIT_SYSTEM, onLlmResult: undefined, provider, ...priced });
|
|
142
|
+
/** @type {any} */
|
|
143
|
+
let r;
|
|
144
|
+
try {
|
|
145
|
+
r = await loop.run([{ role: 'user', content }], [], { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
if (isRateLimited(e) && !rateRetried) {
|
|
148
|
+
const wait = rateLimitWaitMs(e);
|
|
149
|
+
if (wait !== null) { rateRetried = true; await sleep(wait); continue; }
|
|
150
|
+
}
|
|
151
|
+
const casualty = callCasualty(e) ?? (isTransportFailure(e) ? String(/** @type {any} */ (e)?.message ?? e) : null);
|
|
152
|
+
if (casualty === null) throw e;
|
|
153
|
+
// the failed attempt may already be billed: booked UNPRICED, so the spend stays "not complete" (never a $0)
|
|
154
|
+
record({ error: casualty });
|
|
155
|
+
if (!transportRetried) { transportRetried = true; continue; }
|
|
156
|
+
return absent(`the fit check call failed: ${redactSecrets(casualty)}`, SURVEY_CAUSES.CALL_FAILED);
|
|
157
|
+
}
|
|
158
|
+
record(r);
|
|
159
|
+
if (isOutputCutOff(r)) return absent('the fit check answer was cut off', SURVEY_CAUSES.UNPARSEABLE);
|
|
160
|
+
if (r?.error) return absent(`the fit check call failed: ${redactSecrets(String(r.error))}`, SURVEY_CAUSES.CALL_FAILED);
|
|
161
|
+
const d = decideFit(r?.text, jobLines, texts);
|
|
162
|
+
if (d.status === 'ok') return { state: d.state, asks: d.asks, notFound: d.notFound, reason: null, cause: null, calls, budgetStop: null };
|
|
163
|
+
malformed += 1;
|
|
164
|
+
if (malformed >= 2) return absent('the fit check answer was not readable twice', SURVEY_CAUSES.UNPARSEABLE);
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/inputlook.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { scanLabels, labelOnList, renderLabelList, normLabel, LABEL_SHAPES } from './labelscan.js';
|
|
12
12
|
import { FACT_SHAPES, FACT_OPS } from './facts.js';
|
|
13
13
|
import { lineRules } from './rubricstep.js';
|
|
14
|
+
import { dropRows, dropLine } from './pagedropwords.js';
|
|
14
15
|
|
|
15
16
|
/** the name of the answer tool - the one place the AI's report enters */
|
|
16
17
|
export const LOOK_REPORT_TOOL = 'report_look';
|
|
@@ -149,13 +150,31 @@ export function lookPrompt({ jobLines, labels, lists }) {
|
|
|
149
150
|
+ `Report only through ${LOOK_REPORT_TOOL}.`;
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
/** @typedef {{line: number, name: string, shape: string, source: string, label?: string, labelId?: string, value?: string, phrase?: string, example: string, rule?: any, aiProposal?: boolean, forRule?: number
|
|
153
|
+
/** @typedef {{line: number, name: string, shape: string, source: string, label?: string, labelId?: string, value?: string, phrase?: string, example: string, rule?: any, aiProposal?: boolean, forRule?: number, reading?: import('./pagerules.js').Reading,
|
|
154
|
+
* words?: string[], labels?: string[], unit?: string[], currency?: string[]}} LookFact `words` / `labels` / `unit` / `currency`: what the page-word map kept (idea A core) */
|
|
153
155
|
/** @typedef {{line: number, rule?: number, words?: string, why: string}} LookNotFound */
|
|
154
|
-
/** @typedef {{facts: LookFact[], prose: {line: number, text?: string}[], notFound: LookNotFound[]}} LookReport */
|
|
156
|
+
/** @typedef {{facts: LookFact[], prose: {line: number, text?: string}[], notFound: LookNotFound[], dropped?: import('./pagerules.js').Dropped[]}} LookReport */
|
|
155
157
|
|
|
156
158
|
/** @param {unknown} v @returns {v is Record<string, any>} */
|
|
157
159
|
const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
158
160
|
|
|
161
|
+
/**
|
|
162
|
+
* A fact the page-word map kept (src/pagerules.js `KeptFact`), as the look report carries it: a phrase fact holds its alternative
|
|
163
|
+
* phrases; any other holds its labels (the first is the primary), and a money fact its units and currencies.
|
|
164
|
+
* @param {import('./pagerules.js').KeptFact} k @param {string} source @returns {LookFact}
|
|
165
|
+
*/
|
|
166
|
+
export function lookFactOf(k, source) {
|
|
167
|
+
/** @type {LookFact} */
|
|
168
|
+
const out = { line: k.line, name: k.name, shape: k.shape, source, example: '', words: [...k.words] };
|
|
169
|
+
if (k.shape === 'phrase') out.phrase = k.words[0];
|
|
170
|
+
else { out.labels = [...k.words]; if (k.words.length) out.label = k.words[0]; }
|
|
171
|
+
if (k.unit) out.unit = [...k.unit];
|
|
172
|
+
if (k.currency) out.currency = [...k.currency];
|
|
173
|
+
if (k.forRule !== undefined) out.forRule = k.forRule;
|
|
174
|
+
if (k.reading) out.reading = { ...k.reading };
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
159
178
|
/**
|
|
160
179
|
* Turn the AI's raw report into a VERIFIED one, by code, at $0. A fact is carried only when: its shape is a real shape, its source is
|
|
161
180
|
* a real input, and its label is ON THE LIST for that input and shape (an id is resolved to its exact words). Anything else is moved
|
|
@@ -225,7 +244,17 @@ export function renderLookReport(report) {
|
|
|
225
244
|
const add = (/** @type {number} */ n, /** @type {string} */ s) => byLine.set(n, [...(byLine.get(n) ?? []), s]);
|
|
226
245
|
for (const f of report.facts) {
|
|
227
246
|
const bits = [`${f.name} - ${f.shape}`, `from "${f.source}"`];
|
|
228
|
-
if (
|
|
247
|
+
if (Array.isArray(f.words)) {
|
|
248
|
+
// a fact the page-word map kept (idea A core): the words the page holds, each one proven by code; the first is the main one
|
|
249
|
+
const quote = (/** @type {string[]|undefined} */ a) => (a ?? []).map((w) => JSON.stringify(w)).join(' or ');
|
|
250
|
+
if (f.shape === 'phrase') bits.push(`the quote must say ${f.words.length > 1 ? 'ONE of these page words, copied exactly' : 'this page word'}: ${quote(f.words)}`);
|
|
251
|
+
else {
|
|
252
|
+
if (f.words.length) bits.push(`label ${quote(f.words)}${f.words.length > 1 ? ' (the first is the main one)' : ''}`);
|
|
253
|
+
else bits.push('no label (read by its unit or sign alone)');
|
|
254
|
+
if (f.unit?.length) bits.push(`unit ${quote(f.unit)}`);
|
|
255
|
+
if (f.currency?.length) bits.push(`currency ${quote(f.currency)}`);
|
|
256
|
+
}
|
|
257
|
+
} else if (f.phrase !== undefined) bits.push(`the quote must say ${JSON.stringify(f.phrase)}`);
|
|
229
258
|
else if (f.label !== undefined) bits.push(`label ${JSON.stringify(f.label)} (id ${f.labelId})`);
|
|
230
259
|
else bits.push('no label (the value as the quote writes it)');
|
|
231
260
|
if (f.example) bits.push(`seen: ${JSON.stringify(f.example)}`);
|
|
@@ -234,6 +263,12 @@ export function renderLookReport(report) {
|
|
|
234
263
|
if (f.forRule !== undefined) bits.push(`carries ~ rule ${f.forRule}`);
|
|
235
264
|
add(f.line, `CAN CHECK: ${bits.join(', ')}`);
|
|
236
265
|
}
|
|
266
|
+
// NO SILENT DROP: a word, fact or rule the page rules removed is said to the drafter too, so it is never written back as a check
|
|
267
|
+
for (const row of dropRows(report.dropped)) {
|
|
268
|
+
add(row.line, row.full
|
|
269
|
+
? `NOT CHECKED${row.rule !== undefined ? ` (rule ${row.rule})` : ''}: "${row.fact}" - ${row.sentences.join(' ')}`
|
|
270
|
+
: `LEFT OUT of "${row.fact}" (the rest is still checked): ${row.sentences.join(' ')}`);
|
|
271
|
+
}
|
|
237
272
|
for (const p of report.prose) add(p.line, 'WRITTEN TEXT (not looked up; keep its word/section counts)');
|
|
238
273
|
for (const n of report.notFound) add(n.line, `NOT FOUND${n.rule ? ` (rule ${n.rule})` : ''}: ${n.words ? `${JSON.stringify(n.words)} - ` : ''}${n.why}`);
|
|
239
274
|
const lines = [...byLine.keys()].sort((a, b) => a - b).map((n) => `line ${n}:\n${byLine.get(n).map((/** @type {string} */ s) => ` ${s}`).join('\n')}`);
|