bareloop 0.42.0 → 0.44.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 +88 -0
- package/bareloop.context.md +103 -41
- package/package.json +2 -2
- package/src/authorflow.js +26 -11
- package/src/authoring.js +1 -1
- package/src/authorjob.js +33 -15
- package/src/authorreadout.js +7 -6
- package/src/declaredclose.js +6 -3
- package/src/draftspend.js +20 -2
- package/src/facts.js +54 -9
- package/src/factwords.js +58 -6
- package/src/inputlook.js +38 -3
- package/src/interviewrun.js +0 -1
- package/src/job.js +39 -12
- package/src/kinds.js +5 -0
- package/src/pagedropwords.js +64 -0
- package/src/pagemap.js +264 -0
- package/src/pagerules.js +319 -0
- package/src/panel/authorroutes.js +138 -60
- package/src/panel/authorsession.js +88 -32
- package/src/panel/index.html +1251 -522
- package/src/panel/jobplan.js +4 -2
- package/src/panel/runroutes.js +188 -6
- package/src/panel/scheduler.js +156 -0
- package/src/panel/schedules.js +667 -0
- package/src/panel/schedulestart.js +243 -0
- package/src/panel/server.js +1234 -68
- package/src/panel/status.js +33 -0
- package/src/panelmoney.js +29 -0
- package/src/planrun.js +92 -11
- package/src/promptregisters.js +4 -0
- package/src/providerrows.js +3 -3
- package/src/replay.js +118 -7
- package/src/reviewdoor.js +37 -0
- package/src/rubricstep.js +123 -11
- package/src/runlist.js +37 -1
- package/src/schedulewords.js +299 -0
- package/src/source.js +18 -0
- package/src/thinking.js +63 -2
- package/src/trigger.js +48 -0
- package/src/triggerclock.js +127 -0
- package/src/triggerfiles.js +215 -0
- package/src/triggerledger.js +79 -0
- package/src/userrun.js +34 -21
- package/types/authorflow.d.ts +42 -6
- package/types/authorjob.d.ts +10 -2
- package/types/declaredclose.d.ts +3 -1
- package/types/draftspend.d.ts +27 -1
- package/types/facts.d.ts +15 -0
- package/types/factwords.d.ts +26 -3
- package/types/inputlook.d.ts +15 -0
- package/types/job.d.ts +8 -1
- package/types/kinds.d.ts +5 -0
- package/types/pagedropwords.d.ts +26 -0
- package/types/pagemap.d.ts +154 -0
- package/types/pagerules.d.ts +86 -0
- package/types/panel/authorroutes.d.ts +50 -14
- package/types/panel/authorsession.d.ts +37 -12
- package/types/panel/runroutes.d.ts +32 -1
- package/types/panel/scheduler.d.ts +17 -0
- package/types/panel/schedules.d.ts +412 -0
- package/types/panel/schedulestart.d.ts +106 -0
- package/types/panel/server.d.ts +297 -15
- package/types/panel/status.d.ts +51 -0
- package/types/panelmoney.d.ts +5 -0
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/replay.d.ts +21 -1
- package/types/reviewdoor.d.ts +27 -0
- package/types/rubricstep.d.ts +57 -1
- package/types/runlist.d.ts +24 -1
- package/types/schedulewords.d.ts +137 -0
- package/types/source.d.ts +11 -0
- package/types/thinking.d.ts +39 -0
- package/types/trigger.d.ts +37 -0
- package/types/triggerclock.d.ts +52 -0
- package/types/triggerfiles.d.ts +93 -0
- package/types/triggerledger.d.ts +35 -0
- package/types/userrun.d.ts +4 -0
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')}`);
|
package/src/interviewrun.js
CHANGED
|
@@ -690,7 +690,6 @@ export async function main(argv, deps = {}) {
|
|
|
690
690
|
// endpoint on its own in that case (PRD item 28 ruling (d)).
|
|
691
691
|
...(baseUrlArg === null ? {} : { baseUrl: baseUrlArg }),
|
|
692
692
|
...(modelArg === null ? {} : { model: modelArg }),
|
|
693
|
-
cadence: { unit: 'day', every: 1 },
|
|
694
693
|
budgetUsd,
|
|
695
694
|
...(wallMin === null ? {} : { maxWallMs: Math.round(wallMin * 60_000) }),
|
|
696
695
|
// ABSENT for a plain folder (`writeScope === null`, D5/step S6) — never a
|
package/src/job.js
CHANGED
|
@@ -18,6 +18,8 @@ import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiter
|
|
|
18
18
|
import { validateCloseDecl, DECLARED_CLOSE_CLASSES } from './declaredclose.js';
|
|
19
19
|
import { inputLabelsOf } from './rubricstep.js';
|
|
20
20
|
import { CLOSE_TIMEOUT_FLOOR_MS } from './closetimeout.js';
|
|
21
|
+
import { validateTrigger } from './trigger.js';
|
|
22
|
+
import { resolveJobJudge } from './judged.js';
|
|
21
23
|
|
|
22
24
|
// The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
|
|
23
25
|
// they are read at call time, so a mutable export would let adopter code
|
|
@@ -168,7 +170,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
|
|
|
168
170
|
// `steps` stays in the field list ONLY so a retired spec reds by name
|
|
169
171
|
// (`shape-retired`) instead of falling through to a generic unknown-field —
|
|
170
172
|
// the operator gets told what happened, not just that something is wrong.
|
|
171
|
-
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'thinking', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
|
|
173
|
+
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'trigger', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'thinking', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
|
|
172
174
|
/** the exact fields of a signed `judge` override — nested objects red unknown
|
|
173
175
|
* keys too, because no smuggling level exists in a signed spec */
|
|
174
176
|
const JUDGE_FIELDS = ['provider', 'model'];
|
|
@@ -278,15 +280,25 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
|
278
280
|
}
|
|
279
281
|
}
|
|
280
282
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
283
|
+
// T (PRD v1.99): `cadence` is no longer required. A spec signed before triggers keeps its `cadence` (still validated
|
|
284
|
+
// when present, read as by hand, never rewritten, never re-hashed); a new spec carries none.
|
|
285
|
+
if (spec.cadence !== undefined) {
|
|
286
|
+
if (!isObj(spec.cadence)) red('invalid-value', 'cadence', 'object of {unit, every}');
|
|
287
|
+
else {
|
|
288
|
+
const cad = spec.cadence;
|
|
289
|
+
for (const key of Object.keys(cad)) {
|
|
290
|
+
if (key !== 'unit' && key !== 'every') red('unknown-field', `cadence.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
|
|
291
|
+
}
|
|
292
|
+
if (!CADENCE_UNITS.includes(cad.unit)) red('invalid-value', 'cadence.unit', CADENCE_UNITS.join('|'));
|
|
293
|
+
if (!(Number.isInteger(cad.every) && cad.every >= 1 && cad.every <= 30)) red('bounds', 'cadence.every', '1..30');
|
|
287
294
|
}
|
|
288
|
-
|
|
289
|
-
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// T: the trigger the person signed (a typed object, never cron text). ABSENT = by hand. Set only by the panel's
|
|
298
|
+
// two-click schedule door; the agent never writes it (assembleSpec refuses a draft that carries it).
|
|
299
|
+
if (spec.trigger !== undefined) {
|
|
300
|
+
const tv = validateTrigger(spec.trigger);
|
|
301
|
+
if (!tv.ok) red(tv.code, tv.path, tv.say);
|
|
290
302
|
}
|
|
291
303
|
|
|
292
304
|
if (spec.budgetUsd === undefined) red('missing-required', 'budgetUsd');
|
|
@@ -333,7 +345,7 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
|
333
345
|
red('invalid-value', 'model', 'non-empty string — the exact model id of a line ticked in Settings > Providers');
|
|
334
346
|
}
|
|
335
347
|
// THINKING (P8) — the job's "Thinking on" tick, OPTIONAL: absent means off (and an old spec keeps the hash it always had); present it is
|
|
336
|
-
// signed like `model`. It covers DRAFTING and the rubric JUDGE
|
|
348
|
+
// signed like `model`. It covers DRAFTING and the rubric JUDGE, and makes every WORKER call think (src/thinking.js `workerThinkingMode`; without it the worker's mode is decided from phase and attempt). A boolean, nothing else.
|
|
337
349
|
if (spec.thinking !== undefined && typeof spec.thinking !== 'boolean') {
|
|
338
350
|
red('invalid-value', 'thinking', 'true or false — the job\'s "Thinking on" tick (absent = off)');
|
|
339
351
|
}
|
|
@@ -931,6 +943,13 @@ export function jobSpecHash(job) {
|
|
|
931
943
|
*/
|
|
932
944
|
export const REUSE_OPEN_SPEC_FIELDS = Object.freeze(['source', 'destination', 'writeScope', 'budgetUsd', 'maxWallMs', 'inputs']);
|
|
933
945
|
|
|
946
|
+
/**
|
|
947
|
+
* The signed spec's schedule fields: {@link workflowKey} ignores them too, so a scheduled spec keeps the workflow
|
|
948
|
+
* key of the green run it was scheduled from (else Reuse would refuse every scheduled spec). They stay inside
|
|
949
|
+
* {@link jobSpecHash}. Kept apart from {@link REUSE_OPEN_SPEC_FIELDS}, which names the Reuse boxes.
|
|
950
|
+
*/
|
|
951
|
+
export const SCHEDULE_SPEC_FIELDS = Object.freeze(['trigger']);
|
|
952
|
+
|
|
934
953
|
/**
|
|
935
954
|
* The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
|
|
936
955
|
* chosen in Settings carries its provider and baseUrl with it). They stay inside {@link jobSpecHash}, so what the person
|
|
@@ -945,7 +964,8 @@ export const WORKFLOW_KEY_IDENTITY_FIELDS = Object.freeze(['provider', 'baseUrl'
|
|
|
945
964
|
* share a workflow key iff they ran the same goal, checks, guardrails and close — whatever source they ran on, wherever they wrote, whatever caps they had.
|
|
946
965
|
* It is NOT a signature: {@link jobSpecHash} (which covers the caps and the fence) stays what the person signs, and
|
|
947
966
|
* a reuse lands on a NEW jobSpecHash with the SAME workflowKey. Same never-throws contract as `jobSpecHash`, and
|
|
948
|
-
* the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key.
|
|
967
|
+
* the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key. A soft-green spec keys on its
|
|
968
|
+
* RESOLVED judge identity (an absent `judge` is its worker's), so pinning the default judge never changes the key.
|
|
949
969
|
* @param {object} job
|
|
950
970
|
* @returns {string} sha256 hex
|
|
951
971
|
*/
|
|
@@ -954,7 +974,14 @@ export function workflowKey(job) {
|
|
|
954
974
|
try {
|
|
955
975
|
/** @type {any} */
|
|
956
976
|
const rest = isObj(job) ? { ...job } : job;
|
|
957
|
-
|
|
977
|
+
// A RUBRIC job's judge defaults to its own worker. The worker identity is dropped below, so resolve the judge FIRST
|
|
978
|
+
// (the runner's one spelling) and key on that: a spec with no `judge` and the same spec with the judge pinned to
|
|
979
|
+
// its default (what a reuse/schedule signs) are one workflow; an explicitly different judge is another.
|
|
980
|
+
// Deterministic specs have no judge, so their key is untouched. An unresolvable judge leaves the spec as signed.
|
|
981
|
+
if (isObj(rest) && rest.verdictType === 'soft-green') {
|
|
982
|
+
try { rest.judge = resolveJobJudge(/** @type {any} */ (rest), /** @type {any} */ (rest.provider), resolveWorkerModel); } catch { /* validateJob reds it */ }
|
|
983
|
+
}
|
|
984
|
+
if (isObj(rest)) for (const f of [...REUSE_OPEN_SPEC_FIELDS, ...WORKFLOW_KEY_IDENTITY_FIELDS, ...SCHEDULE_SPEC_FIELDS]) delete rest[f];
|
|
958
985
|
c = canon(resolveSpec(rest));
|
|
959
986
|
} catch { c = '\u0000unhashable'; }
|
|
960
987
|
return createHash('sha256').update(`workflow-key-v1\n${c}`).digest('hex');
|
package/src/kinds.js
CHANGED
|
@@ -304,6 +304,7 @@ export function mechanicalStages(stages) {
|
|
|
304
304
|
* onJudgeCost?: (c: {stage: string|null, kind: string|null, path: string, attempt: number,
|
|
305
305
|
* label: string, model: string, costUsd: number|null, unpricedRounds: number}) => void,
|
|
306
306
|
* callBounds?: () => {timeoutMs?: number, deadlineMs?: number},
|
|
307
|
+
* onRubricEvidence?: (e: {step: number, evidence: any[], evidenceTotal?: number}) => void,
|
|
307
308
|
* onStage?: (row: {stage: string|null, kind: string|null, verdict: string, durationMs: number}) => void}} Ctx
|
|
308
309
|
* The arbiter's half of a stage run. Every field here is operator/runner
|
|
309
310
|
* territory: a declaration parameterises kinds, never the contracts.
|
|
@@ -1593,6 +1594,10 @@ async function runRubricStep(stage, ctx) {
|
|
|
1593
1594
|
}
|
|
1594
1595
|
const graded = await gradeStep({ params: p, bytes: notRegular ? null : bytes, outputBytes: notRegular ? null : bytes, inputs, factQuotes, factMissing }, inputShas);
|
|
1595
1596
|
if (graded.verdict === 'stopped') return stop(`the rubric stopped: ${graded.fault}`, STOP_FAULTS.CRASHED);
|
|
1597
|
+
// REPORT-ONLY: what each cited check read, handed to the runner's seam (the spine's `rubric-evidence`); nothing below reads it
|
|
1598
|
+
if (graded.evidence && typeof ctx.onRubricEvidence === 'function') {
|
|
1599
|
+
ctx.onRubricEvidence({ step, evidence: graded.evidence, ...(graded.evidenceTotal !== undefined ? { evidenceTotal: graded.evidenceTotal } : {}) });
|
|
1600
|
+
}
|
|
1596
1601
|
const gap = new Gap(ctx.gapKeep, ctx.gapCap);
|
|
1597
1602
|
if (notRegular) gap.push(`${stage.name}: line ${step}: ${stepFileRel(step)} is not a regular file`);
|
|
1598
1603
|
for (const l of graded.gapLines) gap.push(`${stage.name}: ${l}`);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// IDEA A CORE (docs/product/ITEM33-BUILD.md "Addendum 2026-10-18 — Idea A core: build spec", sections 4.6 and 7): NO SILENT DROP. The ONE place
|
|
2
|
+
// that writes the sentence for a word, fact or rule that a page rule dropped. The plan card, the Job tab, the confirm card and the drafter's
|
|
3
|
+
// block all read these words; no other file writes a drop reason (a test greps for it). Plain words, written by CODE from the drop's reason
|
|
4
|
+
// code, never a raw code and never model-written. `<label>` is the page's own word.
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('./pagerules.js').Dropped} Dropped */
|
|
7
|
+
|
|
8
|
+
/** reason code -> the plain sentence (`d.word` = the page's word, `d.fact` = the fact's name, `d.other` = what it was compared to) */
|
|
9
|
+
const WORDS = /** @type {Record<string, (d: Dropped) => string>} */ ({
|
|
10
|
+
'not-on-page': (d) => `"${d.word}" is not written on the page.`,
|
|
11
|
+
'no-value': (d) => `The page never shows a number right after or right before "${d.word}".`,
|
|
12
|
+
'n2': (d) => `"${d.word}" is written more than once inside one line of the page, so one result cannot show it once.`,
|
|
13
|
+
'r1': (d) => `"${d.word}" does not show the same values as "${d.other}", so it was not used as a second way to read it.`,
|
|
14
|
+
'sign-twice': (d) => `"${d.word}" was listed twice, as a unit and as a currency; it is used once, as the currency.`,
|
|
15
|
+
'r1l': (d) => d.word === d.other
|
|
16
|
+
? `"${d.word}" was listed twice, as a unit and as a currency; it is used once, as the first way to write the price.`
|
|
17
|
+
: `"${d.word}" does not show the same amounts as "${d.other}", so it was not used as a second way to write the price.`,
|
|
18
|
+
's1-name': () => 'One result is one record; ask for N results instead.',
|
|
19
|
+
's1': (d) => `"${d.fact}" would read the same value as "${d.other}". The page gives no second label for it.`,
|
|
20
|
+
'no-label': (d) => `No word on this page reads "${d.fact}" safely.`,
|
|
21
|
+
'limit-currency': (d) => `Only the first 3 currencies are used; "${d.word}" was left out.`,
|
|
22
|
+
'limit-unit': (d) => `Only the first 10 units are used; "${d.word}" was left out.`,
|
|
23
|
+
'unit-too-long': (d) => `"${d.word}" is longer than 100 characters, so it was not used.`,
|
|
24
|
+
'currency-not-symbol': (d) => `"${d.word}" is not a currency sign ($, € or £); a word the page writes after the amount is read as a unit.`,
|
|
25
|
+
'person-left': () => 'You chose to leave it Not checked.',
|
|
26
|
+
'bad-shape': (d) => `The AI named a kind of value for "${d.fact}" that a check cannot read.`,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** @param {Dropped} d @returns {string} the plain sentence for one drop */
|
|
30
|
+
export function dropSentence(d) {
|
|
31
|
+
const f = WORDS[d.reason];
|
|
32
|
+
return f ? f(d) : `"${d.word ?? d.fact}" was left out.`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One row per fact that lost something: the sentences (one per dropped word, then the fact's own), and whether the whole fact went.
|
|
37
|
+
* @param {Dropped[]|undefined|null} dropped
|
|
38
|
+
* @returns {{line: number, rule?: number, fact: string, full: boolean, sentences: string[]}[]}
|
|
39
|
+
*/
|
|
40
|
+
export function dropRows(dropped) {
|
|
41
|
+
/** @type {Map<string, {line: number, rule?: number, fact: string, full: boolean, sentences: string[]}>} */
|
|
42
|
+
const rows = new Map();
|
|
43
|
+
for (const d of Array.isArray(dropped) ? dropped : []) {
|
|
44
|
+
const key = `${d.line}\u0000${d.rule ?? ''}\u0000${d.fact}`;
|
|
45
|
+
let row = rows.get(key);
|
|
46
|
+
if (!row) { row = { line: d.line, ...(d.rule === undefined ? {} : { rule: d.rule }), fact: d.fact, full: false, sentences: [] }; rows.set(key, row); }
|
|
47
|
+
if (d.word === undefined) row.full = true;
|
|
48
|
+
const sentence = dropSentence(d);
|
|
49
|
+
if (!row.sentences.includes(sentence)) row.sentences.push(sentence);
|
|
50
|
+
}
|
|
51
|
+
// a fact's own sentence ("No word ... reads it safely") reads last, after the words that were lost
|
|
52
|
+
return [...rows.values()].map((r) => ({ ...r, sentences: [...r.sentences.filter((s) => !/^No word on this page reads /.test(s)), ...r.sentences.filter((s) => /^No word on this page reads /.test(s))] }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A row as one Not-checked line, in the person's own words: `line N: "<the ~ rule as written, or the fact's name>" — <sentences>`.
|
|
57
|
+
* @param {{line: number, rule?: number, fact: string, sentences: string[]}} row @param {any[]|null|undefined} jobLines @param {(l: any) => string[]} rulesOf
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function dropLine(row, jobLines, rulesOf) {
|
|
61
|
+
const jl = (jobLines ?? []).find((/** @type {any} */ l) => l && l.n === row.line);
|
|
62
|
+
const rule = row.rule === undefined || !jl ? undefined : rulesOf(jl)[row.rule - 1];
|
|
63
|
+
return `line ${row.line}: "${rule ?? row.fact}" — ${row.sentences.join(' ')}`;
|
|
64
|
+
}
|
package/src/pagemap.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// IDEA A CORE (docs/product/ITEM33-BUILD.md "Addendum 2026-10-18 — Idea A core: build spec", sections 4.1 and 5): THE MAP CALL.
|
|
2
|
+
// ONE paid AI call per page input maps the person's job to the page's OWN words. The AI never grades and never invents: every word it
|
|
3
|
+
// names is checked by plain code against the saved page (src/pagerules.js) before anything else sees the answer. This replaces the
|
|
4
|
+
// search-and-read look (`runInputLook`, src/authorscout.js) as the default `lookFn`; it takes the same arguments and returns the same
|
|
5
|
+
// envelope (`state`, `facts: {rubricFiles, inputs, look}`, `calls`, `raws`, `budgetStop`), so the phase line, the spend booking and
|
|
6
|
+
// every downstream reader keep working. The prompt is ported from the POC call (.claude/scratch/idea-a-v2/mapcall.mjs, sha256 recorded
|
|
7
|
+
// in commit 0bcc5fe).
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { priceOf, scrubRaw, tallyCalls, capStop, callCasualty } from './text.js';
|
|
11
|
+
import { redactSecrets } from './validate.js';
|
|
12
|
+
import { isTransportFailure } from './transport.js';
|
|
13
|
+
import { isRateLimited, rateLimitWaitMs } from './ratelimit.js';
|
|
14
|
+
import { isOutputCutOff } from './authorflow.js';
|
|
15
|
+
import { AUTHOR_CALL_TIMEOUT_MS, AUTHOR_SCOUT_MAX_TOKENS, SURVEY_CAUSES } from './authorscout.js';
|
|
16
|
+
import { lineRules, boundInWords, wordsSaidOn } from './rubricstep.js';
|
|
17
|
+
import { applyPageRules, validReading } from './pagerules.js';
|
|
18
|
+
import { ruleWords } from './facts.js';
|
|
19
|
+
import { lookFactOf } from './inputlook.js';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const { Loop } = require('bare-agent');
|
|
23
|
+
|
|
24
|
+
/** the label of the one paid call, as the drafting book and the progress line see it */
|
|
25
|
+
export const MAP_CALL_LABEL = 'page-map';
|
|
26
|
+
/** the shapes the map may name; a `place` is read by code as a phrase */
|
|
27
|
+
export const MAP_SHAPES = Object.freeze(['place', 'money', 'time', 'phrase']);
|
|
28
|
+
|
|
29
|
+
export const MAP_SYSTEM = 'You map a person\'s job to the exact words of ONE saved web page. You never grade and never invent words: every string you return must be copied from the page text, character for character.';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the AI is told, for ONE page input. Ported from the POC prompt: the job, the `~` rules, "a result is one item", the answer form
|
|
33
|
+
* and the shape meanings.
|
|
34
|
+
* @param {{jobLines: {n: number, text: string, rule?: string, rules?: string[]}[], label: string, text: string}} o
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function mapPrompt({ jobLines, label, text }) {
|
|
38
|
+
const job = jobLines.map((l) => `LINE ${l.n}: ${l.text}${lineRules(l).map((r, i) => `\n~ rule ${i + 1}: ${r}`).join('')}`).join('\n');
|
|
39
|
+
return `THE JOB (each ~ rule applies to the line above it):\n${job}\n\n`
|
|
40
|
+
+ 'A "result" is one item the page lists (one flight, one stay, one row). For each job line and for each rule, say which fact(s) every result must carry, and give the PAGE\'S OWN WORDS for it.\n\n'
|
|
41
|
+
+ 'Return ONLY a JSON object, no prose, no code fence:\n'
|
|
42
|
+
+ '{"facts":[{"name":"<letters and spaces only>","from":"<line N | line N rule M>","shape":"place|money|time|phrase","words":["<page words>", ...],"currency":["<symbol>"],"unit":["<words after the amount>"],"reading":<optional, see below>}]}\n\n'
|
|
43
|
+
+ 'shape meanings:\n'
|
|
44
|
+
+ '- place: words = ways the page writes the place (a city, an airport name), copied from the page.\n'
|
|
45
|
+
+ '- money: words = the label(s) written right BEFORE the amount, copied from the page. currency / unit = how the page writes the currency ("$") and/or the words after the amount ("US dollars"); give every spelling the page uses, as lists.\n'
|
|
46
|
+
+ '- time: words = the label(s) written right BEFORE the time that is the one the rule is about, copied from the page.\n'
|
|
47
|
+
+ '- phrase: words = fixed words a result\'s text must contain, copied from the page.\n'
|
|
48
|
+
+ 'Several words in one list are alternatives for the same fact. Put the best one first.\n'
|
|
49
|
+
+ 'reading (optional, only on a time or money fact): when a ~ rule is a limit on that fact that the page cannot hold in words (for example "not a red-eye" is a limit on the departure time), give your own best reading of the rule as that limit: {"op":"between","lo":<bound>,"hi":<bound>} or {"op":"lt|lte|gt|gte","value":<bound>}. A time bound is a "HH:MM" string on a 24-hour clock; a money bound is a number. Leave reading out when a page word already carries the rule.\n\n'
|
|
50
|
+
+ `PAGE TEXT (the input "${label}"):\n${text}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read the AI's answer: ONE JSON object of the answer form, and nothing else. No repair (hamr's ruling): text that is not JSON, JSON of
|
|
55
|
+
* another form, or a fact whose shape or lists are not of the form is a malformed answer.
|
|
56
|
+
* @param {unknown} text @returns {{ok: true, mapping: import('./pagerules.js').PageMap}|{ok: false}}
|
|
57
|
+
*/
|
|
58
|
+
export function parseMapAnswer(text) {
|
|
59
|
+
/** @type {any} */
|
|
60
|
+
let m;
|
|
61
|
+
try { m = JSON.parse(String(text ?? '').trim()); } catch { return { ok: false }; }
|
|
62
|
+
const strs = (/** @type {unknown} */ v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
63
|
+
if (m === null || typeof m !== 'object' || Array.isArray(m) || !Array.isArray(m.facts)) return { ok: false };
|
|
64
|
+
for (const f of m.facts) {
|
|
65
|
+
if (f === null || typeof f !== 'object' || Array.isArray(f)) return { ok: false };
|
|
66
|
+
if (typeof f.name !== 'string' || !MAP_SHAPES.includes(f.shape) || !strs(f.words)) return { ok: false };
|
|
67
|
+
if (f.from !== undefined && typeof f.from !== 'string') return { ok: false };
|
|
68
|
+
for (const k of ['currency', 'unit']) if (f[k] !== undefined && !strs(f[k])) return { ok: false };
|
|
69
|
+
// PRD v1.107 Part 5b: a `reading` is a limit of the signed-rule shape on a time or money fact; any other is a malformed answer (no repair)
|
|
70
|
+
if (f.reading !== undefined && !validReading(f.shape, f.reading)) return { ok: false };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, mapping: m };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** the default loop seam - one spelling of how this repo drives a model */
|
|
76
|
+
const defaultLoop = (/** @type {any} */ { system, onLlmResult, provider, rates }) =>
|
|
77
|
+
new Loop({ provider, system, onLlmResult, ...(rates ? { rates } : {}) });
|
|
78
|
+
|
|
79
|
+
/** the plain-word stop when the answer was not readable twice (no Resume: a draft is not a run) */
|
|
80
|
+
export const MAP_UNREADABLE = 'The AI\'s answer about the page was not readable twice. Nothing was signed.';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run the map: one call per page input.
|
|
84
|
+
* @param {{texts: Record<string, string>, inputs: {n: number, label: string}[], jobLines: {n: number, text: string, rule?: string, rules?: string[]}[],
|
|
85
|
+
* provider?: any, maxTokens?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
|
|
86
|
+
* onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string, raw?: string}) => void,
|
|
87
|
+
* createLoop?: (o: {system: string, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
|
|
88
|
+
* rates?: {in: number, out: number}|null, callOptions?: Record<string, any>, sleep?: (ms: number) => Promise<void>, prove?: Function}} o
|
|
89
|
+
* @returns {Promise<{state: 'PRESENT'|'ABSENT', facts: any, reason: string|null, cause: string|null, meta: any, raw: string,
|
|
90
|
+
* raws: ReturnType<typeof scrubRaw>[], calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
|
|
91
|
+
* budgetStop: 'cap-halt'|'pricing-red'|null}>}
|
|
92
|
+
*/
|
|
93
|
+
export async function runPageMap({
|
|
94
|
+
texts, inputs, jobLines, provider = null, maxTokens = AUTHOR_SCOUT_MAX_TOKENS, ceilingUsd = null,
|
|
95
|
+
callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS, onCall = () => {}, createLoop = defaultLoop, rates = null, callOptions = {},
|
|
96
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
97
|
+
}) {
|
|
98
|
+
const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
|
|
99
|
+
? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS) : AUTHOR_CALL_TIMEOUT_MS;
|
|
100
|
+
const priced = rates ? { rates } : {};
|
|
101
|
+
/** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
|
|
102
|
+
const calls = [];
|
|
103
|
+
/** @type {ReturnType<typeof scrubRaw>[]} */
|
|
104
|
+
const raws = [];
|
|
105
|
+
let attempts = 0;
|
|
106
|
+
const record = (/** @type {any} */ r, /** @type {number} */ attempt) => {
|
|
107
|
+
const call = { label: MAP_CALL_LABEL, ...priceOf(r) };
|
|
108
|
+
calls.push(call);
|
|
109
|
+
const raw = scrubRaw({ label: MAP_CALL_LABEL, attempt, text: r?.text });
|
|
110
|
+
raws.push(raw);
|
|
111
|
+
// the scrubbed answer rides the call, so the session's drafting log (draft-log.jsonl) keeps the AI's map and a run can be replayed at $0
|
|
112
|
+
onCall({ ...call, raw: raw.text, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
|
|
113
|
+
};
|
|
114
|
+
const absent = (/** @type {string} */ reason, /** @type {string} */ cause, /** @type {'cap-halt'|'pricing-red'|null} */ budgetStop = null) => ({
|
|
115
|
+
state: /** @type {'ABSENT'} */ ('ABSENT'), facts: null, reason, cause, meta: { attempts }, raw: '', raws, calls, budgetStop,
|
|
116
|
+
});
|
|
117
|
+
const halted = () => capStop({ ceilingUsd, ...tallyCalls(calls) });
|
|
118
|
+
const labels = Object.keys(texts);
|
|
119
|
+
/** @type {any[]} */
|
|
120
|
+
const looks = [];
|
|
121
|
+
/** @type {any[]} */
|
|
122
|
+
const dropped = [];
|
|
123
|
+
/** @type {any[]} */
|
|
124
|
+
const facts = [];
|
|
125
|
+
/** @type {any[]} */
|
|
126
|
+
const maps = [];
|
|
127
|
+
|
|
128
|
+
for (const label of labels) {
|
|
129
|
+
const content = mapPrompt({ jobLines, label, text: texts[label] });
|
|
130
|
+
/** @type {any} */
|
|
131
|
+
let mapping = null;
|
|
132
|
+
let malformed = 0;
|
|
133
|
+
let transportRetried = false;
|
|
134
|
+
let rateRetried = false;
|
|
135
|
+
while (mapping === null) {
|
|
136
|
+
// the cap is checked BEFORE every call, never inside one
|
|
137
|
+
const halt = halted();
|
|
138
|
+
if (halt) return absent(halt === 'cap-halt' ? `the AI's look at the page was not made: the $${ceilingUsd} ceiling was already spent` : 'the AI\'s look at the page was not made: spend could not be priced, so the ceiling could not be enforced', SURVEY_CAUSES.NOT_FUNDED, halt);
|
|
139
|
+
attempts += 1;
|
|
140
|
+
const loop = createLoop({ system: MAP_SYSTEM, onLlmResult: undefined, provider, ...priced });
|
|
141
|
+
/** @type {any} */
|
|
142
|
+
let r;
|
|
143
|
+
try {
|
|
144
|
+
r = await loop.run([{ role: 'user', content }], [], { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions });
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (isRateLimited(e) && !rateRetried) {
|
|
147
|
+
// a 429 is a REFUSAL (nothing billed): wait the vendor's stated time once, then ask again
|
|
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: it is booked UNPRICED, so the spend stays "not complete" (never a $0)
|
|
154
|
+
record({ error: casualty }, attempts);
|
|
155
|
+
if (!transportRetried) { transportRetried = true; continue; }
|
|
156
|
+
return absent(`the call about the page failed: ${redactSecrets(casualty)}`, SURVEY_CAUSES.CALL_FAILED);
|
|
157
|
+
}
|
|
158
|
+
record(r, attempts);
|
|
159
|
+
if (r?.error && !isOutputCutOff(r)) return absent(`the call about the page failed: ${redactSecrets(String(r.error))}`, SURVEY_CAUSES.CALL_FAILED);
|
|
160
|
+
// a truncated answer (max_tokens) is a malformed one
|
|
161
|
+
const parsed = isOutputCutOff(r) ? { ok: false } : parseMapAnswer(r?.text);
|
|
162
|
+
if (parsed.ok) { mapping = /** @type {any} */ (parsed).mapping; break; }
|
|
163
|
+
malformed += 1;
|
|
164
|
+
if (malformed >= 2) return absent(MAP_UNREADABLE, SURVEY_CAUSES.UNPARSEABLE);
|
|
165
|
+
}
|
|
166
|
+
const kept = applyPageRules(texts[label], mapping, jobLines);
|
|
167
|
+
for (const f of kept.facts) facts.push(lookFactOf(f, label));
|
|
168
|
+
for (const d of kept.dropped) dropped.push({ ...d, source: label });
|
|
169
|
+
maps.push({ source: label, facts: mapping.facts.length });
|
|
170
|
+
looks.push(label);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
state: 'PRESENT', reason: null, cause: null, meta: { attempts, maps }, raw: '', raws, calls, budgetStop: null,
|
|
174
|
+
facts: { rubricFiles: true, inputs: inputs.map((i) => ({ n: i.n, label: i.label })), look: { facts, prose: [], notFound: [], dropped } },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** the answer that drops a `~` rule the page cannot check: the person types it (any case, a closing full stop allowed) */
|
|
179
|
+
export const DROP_IT = 'drop it';
|
|
180
|
+
/** @param {unknown} text @returns {boolean} */
|
|
181
|
+
export const isDropIt = (text) => String(text ?? '').trim().replace(/\.$/, '').toLowerCase() === DROP_IT;
|
|
182
|
+
/** the answers that accept the code's reading of a rule; anything else the person types is their own reading */
|
|
183
|
+
const AGREE_WORDS = ['agree', 'yes'];
|
|
184
|
+
/** @param {unknown} text @returns {boolean} */
|
|
185
|
+
export const isAgree = (text) => AGREE_WORDS.includes(String(text ?? '').trim().replace(/[.!]$/, '').toLowerCase());
|
|
186
|
+
|
|
187
|
+
/** @typedef {{line: number, rule: number, source: 'look', question: string, ask: 'reading'|'cant-check', ruleText: string, reading?: string}} LookAsk
|
|
188
|
+
* `reading` = the plain words an `agree` writes as a `~` rule (a reading ask only) */
|
|
189
|
+
|
|
190
|
+
/** the words of a reading ask: the ONE spelling, and {@link readingOfQuestion} reads it back (the Result tab's "(you confirmed)") */
|
|
191
|
+
const READING_HEAD = ' I read it as: ';
|
|
192
|
+
const READING_TAIL = '. Agree? Answer "agree"';
|
|
193
|
+
/** @param {number} n @param {string} rule @param {string} reading @returns {string} */
|
|
194
|
+
const readingQuestion = (n, rule, reading) => `Your line ${n} says "${rule}".${READING_HEAD}${reading}${READING_TAIL}, or type your own reading (your answer is added as a new ~ rule on line ${n}).`;
|
|
195
|
+
/** @param {unknown} q a recorded question @returns {string|null} the reading a reading ask proposed, or null when it was another kind of question */
|
|
196
|
+
export function readingOfQuestion(q) {
|
|
197
|
+
const s = String(q ?? '');
|
|
198
|
+
const a = s.indexOf(READING_HEAD);
|
|
199
|
+
const b = s.indexOf(READING_TAIL);
|
|
200
|
+
return a >= 0 && b > a ? s.slice(a + READING_HEAD.length, b) : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* PRD v1.107 Part 5b ("the look reads, code asks"): the early asks CODE forces before any paid drafting, from the look's own result. Every `~` rule is
|
|
205
|
+
* one of three kinds:
|
|
206
|
+
* - PAGE: a kept page word carries it (a kept fact with `forRule` and no `reading`): no ask.
|
|
207
|
+
* - READING: a kept fact carries it with a `reading` (a limit the page cannot hold, e.g. "not a red-eye" -> departure time between 06:00 and 21:00).
|
|
208
|
+
* Asked only when a bound of the reading is NOT in the words the person wrote (the same test as `rubric-fact-unmarked`, `boundInWords`); a
|
|
209
|
+
* bound they wrote themselves is theirs and nothing is asked. `agree` writes the reading as a `~` rule in plain words (the answer text is
|
|
210
|
+
* `reading`), so the drafter is told it and the fact signs plain.
|
|
211
|
+
* - CAN'T CHECK: no kept fact carries it (PRD v1.106 Part 5 Q3 T2). `drop it` leaves it Not checked.
|
|
212
|
+
* One ask per rule text on a line; readings first, then can't-check asks, each in line order. The caller applies the machine's cap and the answer gate.
|
|
213
|
+
* @param {{facts?: {line: number, forRule?: number, name?: string, shape?: string, unit?: string[], currency?: string[], reading?: import('./pagerules.js').Reading}[]}|null|undefined} look
|
|
214
|
+
* @param {{n: number, text?: string, rule?: string, rules?: string[]}[]|null|undefined} jobLines
|
|
215
|
+
* @returns {LookAsk[]}
|
|
216
|
+
*/
|
|
217
|
+
export function askableFromLook(look, jobLines) {
|
|
218
|
+
const facts = Array.isArray(look?.facts) ? look.facts : [];
|
|
219
|
+
/** @type {LookAsk[]} */
|
|
220
|
+
const readings = [];
|
|
221
|
+
/** @type {LookAsk[]} */
|
|
222
|
+
const cants = [];
|
|
223
|
+
/** @type {Set<string>} */
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
for (const l of Array.isArray(jobLines) ? jobLines : []) {
|
|
226
|
+
lineRules(l).forEach((rule, k) => {
|
|
227
|
+
const n = k + 1;
|
|
228
|
+
const key = `${l.n}\u0000${rule.trim().toLowerCase()}`;
|
|
229
|
+
const tied = facts.filter((f) => f.line === l.n && f.forRule === n);
|
|
230
|
+
if (tied.length > 0) {
|
|
231
|
+
const said = wordsSaidOn(l);
|
|
232
|
+
const f = tied.find((x) => x.reading && validReading(x.shape, x.reading) && ruleBounds(x.reading).some((b) => !boundInWords(b, said)));
|
|
233
|
+
if (f === undefined || seen.has(key)) return;
|
|
234
|
+
seen.add(key);
|
|
235
|
+
const reading = `${f.name} ${ruleWords({ shape: f.shape, unit: f.unit, currency: f.currency, rule: f.reading })}`;
|
|
236
|
+
readings.push({
|
|
237
|
+
line: l.n, rule: n, source: 'look', ask: 'reading', ruleText: rule, reading,
|
|
238
|
+
question: readingQuestion(l.n, rule, reading),
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (seen.has(key)) return;
|
|
243
|
+
seen.add(key);
|
|
244
|
+
cants.push({
|
|
245
|
+
line: l.n, rule: n, source: 'look', ask: 'cant-check', ruleText: rule,
|
|
246
|
+
question: `Your line ${l.n} says "${rule}". I can't check this on the page. Drop it? Answer "${DROP_IT}", or type how to check it (your answer is added as a new ~ rule on line ${l.n}).`,
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return [...readings, ...cants];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** @param {import('./pagerules.js').Reading} r @returns {unknown[]} the bounds a reading holds */
|
|
254
|
+
const ruleBounds = (r) => (r.op === 'between' ? [r.lo, r.hi] : [r.value]);
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* PRD v1.107 Part 5b: more asks than the machine's cap means the job needs a rewrite, not a questionnaire. The code-owned stop words list EVERY ask and
|
|
258
|
+
* its reading as the suggestion (no extra paid call). A secret in a rule is masked.
|
|
259
|
+
* @param {LookAsk[]} asks @returns {string}
|
|
260
|
+
*/
|
|
261
|
+
export function tooManyAsksText(asks) {
|
|
262
|
+
const list = asks.map((a) => `line ${a.line} "${a.ruleText}" → ${a.reading ?? "I can't check this on the page"}`).join('; ');
|
|
263
|
+
return redactSecrets(`Your job has ${asks.length} rules I can't check or had to guess. Please rewrite it so each one says what to look for. My readings: ${list}.`);
|
|
264
|
+
}
|