ravensight-playtest 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -24
- package/docs/NATIVE_RUNNER.md +2 -2
- package/package.json +2 -2
- package/src/api/client.js +9 -3
- package/src/briefLocal.js +126 -0
- package/src/cli.js +14 -3
- package/src/commands/brief.js +48 -9
- package/src/commands/check.js +187 -38
- package/src/commands/fix.js +12 -1
- package/src/commands/init.js +13 -6
- package/src/commands/personas.js +50 -0
- package/src/commands/run.js +264 -31
- package/src/run/aggregate.js +6 -3
- package/src/run/args.js +18 -1
- package/src/run/drivers/web.js +634 -83
- package/src/run/noise.js +102 -0
- package/src/run/personaLoop.js +658 -26
- package/src/run/report.js +187 -0
- package/src/run/resume.js +5 -0
- package/src/run/tools.js +20 -4
- package/src/runDefaults.js +90 -0
- package/src/state/index.js +4 -1
- package/src/ui/index.js +15 -1
package/src/run/personaLoop.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { rewriteMarkdown } from './dropped.js';
|
|
2
2
|
import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
|
|
3
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
6
6
|
import { withRollingCacheBreakpoint } from './fixLoop.js';
|
|
7
7
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
8
8
|
import { RUN_FILES } from './paths.js';
|
|
9
|
-
import { checkEvidence, createReportValidator, lintMarkdownSections, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
9
|
+
import { appendNormalisedMarkdown, checkEvidence, createReportValidator, lintMarkdownSections, normaliseEnums, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
10
10
|
import { assertNoSecrets } from './secretScan.js';
|
|
11
11
|
import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
|
|
12
12
|
import { createTranscript } from './transcript.js';
|
|
13
13
|
import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
14
14
|
import { fillPlaceholders, fillReportPlaceholders } from './placeholders.js';
|
|
15
|
+
import { BUILT_IN_NOISE, expectedNoiseForPrompt, isExpectedNoise } from './noise.js';
|
|
16
|
+
import { MIN_PASSWORD_CHARS } from '../briefLocal.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* One persona's playthrough: PLAYING then REPORTING (spec 03).
|
|
@@ -130,6 +132,9 @@ export async function runPersona(args) {
|
|
|
130
132
|
runDirectory,
|
|
131
133
|
skill,
|
|
132
134
|
brief = null,
|
|
135
|
+
// The brief fields that stay on this machine (../briefLocal.js):
|
|
136
|
+
// expected_noise and test_account.
|
|
137
|
+
localBrief = {},
|
|
133
138
|
intentNotes = [],
|
|
134
139
|
schemas,
|
|
135
140
|
maxActions,
|
|
@@ -267,6 +272,7 @@ export async function runPersona(args) {
|
|
|
267
272
|
validator,
|
|
268
273
|
skill: args.reportSkill || skill,
|
|
269
274
|
brief,
|
|
275
|
+
localBrief,
|
|
270
276
|
intentNotes,
|
|
271
277
|
transcript,
|
|
272
278
|
ledger,
|
|
@@ -330,6 +336,7 @@ async function playLoop({
|
|
|
330
336
|
target,
|
|
331
337
|
skill,
|
|
332
338
|
brief,
|
|
339
|
+
localBrief = {},
|
|
333
340
|
intentNotes,
|
|
334
341
|
maxActions,
|
|
335
342
|
transcript,
|
|
@@ -344,7 +351,10 @@ async function playLoop({
|
|
|
344
351
|
onBudgetWarning = null,
|
|
345
352
|
log = () => {}
|
|
346
353
|
}) {
|
|
347
|
-
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
|
|
354
|
+
const system = buildSystem({ skill, persona, brief, localBrief, intentNotes, driverName, target, maxActions, runId });
|
|
355
|
+
const identity = runIdentity({ persona, runId });
|
|
356
|
+
const secrets = secretsOf(localBrief);
|
|
357
|
+
const noisePatterns = (localBrief && Array.isArray(localBrief.expected_noise)) ? localBrief.expected_noise : [];
|
|
348
358
|
if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
|
|
349
359
|
const availableTools = driver.supportedActions
|
|
350
360
|
? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
|
|
@@ -362,6 +372,9 @@ async function playLoop({
|
|
|
362
372
|
}
|
|
363
373
|
})) : availableTools;
|
|
364
374
|
const recentActions = [];
|
|
375
|
+
// The observation and action lines of the transcript, kept for the report
|
|
376
|
+
// step's "Actions you took" list.
|
|
377
|
+
const actionRecords = [];
|
|
365
378
|
let latestObservation = '';
|
|
366
379
|
let visualObservation = false;
|
|
367
380
|
const messages = [];
|
|
@@ -387,7 +400,9 @@ async function playLoop({
|
|
|
387
400
|
return entry;
|
|
388
401
|
};
|
|
389
402
|
const noted = note => {
|
|
390
|
-
|
|
403
|
+
// A note never carries the test account password, in the transcript, a
|
|
404
|
+
// partial report or the report prompt.
|
|
405
|
+
const entry = { ...scrubSecrets(note, secrets), at_action: actions, observation: observations.length, order: nextOrder() };
|
|
391
406
|
notes.push(entry);
|
|
392
407
|
awaitingAfter.push(entry);
|
|
393
408
|
return entry;
|
|
@@ -398,6 +413,7 @@ async function playLoop({
|
|
|
398
413
|
latestObservation = first.text;
|
|
399
414
|
visualObservation = Boolean(first.screenshotBase64);
|
|
400
415
|
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
416
|
+
actionRecords.push({ kind: 'observation', text: first.text });
|
|
401
417
|
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
|
|
402
418
|
messages.push({
|
|
403
419
|
role: 'user',
|
|
@@ -508,13 +524,14 @@ async function playLoop({
|
|
|
508
524
|
driver,
|
|
509
525
|
toolUse,
|
|
510
526
|
staleRefs: screenChangedThisTurn,
|
|
527
|
+
identity,
|
|
511
528
|
onNote: note => {
|
|
512
529
|
// The note is nested rather than spread: its own `kind`
|
|
513
530
|
// (bug, confusing, good, suggestion, reaction) would otherwise
|
|
514
531
|
// overwrite the transcript line's `kind`, and every note would
|
|
515
532
|
// read as a different sort of line.
|
|
516
533
|
noted(note);
|
|
517
|
-
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
534
|
+
transcript.write({ kind: 'note', tool: toolUse.name, note: scrubSecrets(note, secrets) });
|
|
518
535
|
},
|
|
519
536
|
onScreenshot: shot => {
|
|
520
537
|
screenshots.push({ ...shot, observation: observations.length, order: nextOrder() });
|
|
@@ -524,16 +541,18 @@ async function playLoop({
|
|
|
524
541
|
results.push(executed.result);
|
|
525
542
|
if (executed.spentAction) actions += 1;
|
|
526
543
|
if (executed.changed && ACTION_TOOLS.includes(toolUse.name)) screenChangedThisTurn = true;
|
|
527
|
-
|
|
544
|
+
const actionLine = {
|
|
528
545
|
kind: 'action',
|
|
529
546
|
tool: toolUse.name,
|
|
530
|
-
input: redactToolInput(toolUse),
|
|
531
|
-
thought: (toolUse.input && toolUse.input.thought_in_character) || null,
|
|
547
|
+
input: redactToolInput(toolUse, secrets),
|
|
548
|
+
thought: scrubSecrets((toolUse.input && toolUse.input.thought_in_character) || null, secrets),
|
|
532
549
|
ok: !executed.error,
|
|
533
550
|
error: executed.error,
|
|
534
551
|
changed: executed.changed,
|
|
535
552
|
actions
|
|
536
|
-
}
|
|
553
|
+
};
|
|
554
|
+
transcript.write(actionLine);
|
|
555
|
+
actionRecords.push(actionLine);
|
|
537
556
|
if (executed.driverError) {
|
|
538
557
|
// The driver failed, not the game: a target that matched twice,
|
|
539
558
|
// locator code, a ref not on screen, a timeout on an unchanged page.
|
|
@@ -551,6 +570,7 @@ async function playLoop({
|
|
|
551
570
|
recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
|
|
552
571
|
if (recentActions.length > 10) recentActions.shift();
|
|
553
572
|
transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
|
|
573
|
+
actionRecords.push({ kind: 'observation', text: executed.observation.text });
|
|
554
574
|
// Only an input to the game can tell us anything about being stuck. A
|
|
555
575
|
// `snapshot` or a `screenshot` never changes the screen by design, so
|
|
556
576
|
// counting those would have a persona that looks twice before acting
|
|
@@ -580,9 +600,20 @@ async function playLoop({
|
|
|
580
600
|
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
|
|
581
601
|
for (const entry of consoleErrors) {
|
|
582
602
|
const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
|
|
583
|
-
const note = {
|
|
603
|
+
const note = {
|
|
604
|
+
kind,
|
|
605
|
+
text: `${entry.type}: ${entry.text}`,
|
|
606
|
+
automatic: true,
|
|
607
|
+
...(entry.url ? { url: String(entry.url).slice(0, 200) } : {}),
|
|
608
|
+
...(entry.url && Number.isInteger(entry.status) ? { status: entry.status } : {}),
|
|
609
|
+
...(shot ? { screenshot: shot.relativePath } : {})
|
|
610
|
+
};
|
|
611
|
+
// Noise the developer said to expect (or the built in rule: a 401 on
|
|
612
|
+
// an auth check while signed out) is marked, so the report step is
|
|
613
|
+
// told and the runner can drop a finding that rests only on it.
|
|
614
|
+
if (isExpectedNoise({ ...note, status: Number.isInteger(entry.status) ? entry.status : note.status }, noisePatterns)) note.expected_noise = true;
|
|
584
615
|
noted(note);
|
|
585
|
-
transcript.write({ kind: 'note', note });
|
|
616
|
+
transcript.write({ kind: 'note', note: scrubSecrets(note, secrets) });
|
|
586
617
|
}
|
|
587
618
|
asides.push(`The game logged ${consoleErrors.length} error(s): ${consoleErrors.map(entry => entry.text).join(' | ').slice(0, 800)}${shot ? ` A screenshot is saved at ${shot.relativePath}.` : ''}`);
|
|
588
619
|
}
|
|
@@ -631,7 +662,8 @@ async function playLoop({
|
|
|
631
662
|
budgetExceeded,
|
|
632
663
|
canceled,
|
|
633
664
|
imageBudget,
|
|
634
|
-
finalScreenshot: final ? final.relativePath : null
|
|
665
|
+
finalScreenshot: final ? final.relativePath : null,
|
|
666
|
+
actionLog: actionLogFrom(actionRecords)
|
|
635
667
|
};
|
|
636
668
|
}
|
|
637
669
|
|
|
@@ -647,9 +679,11 @@ async function reportPhase({
|
|
|
647
679
|
driverName,
|
|
648
680
|
target,
|
|
649
681
|
runDirectory,
|
|
682
|
+
schemas = [],
|
|
650
683
|
validator,
|
|
651
684
|
skill,
|
|
652
685
|
brief,
|
|
686
|
+
localBrief = {},
|
|
653
687
|
intentNotes,
|
|
654
688
|
transcript,
|
|
655
689
|
ledger,
|
|
@@ -661,7 +695,11 @@ async function reportPhase({
|
|
|
661
695
|
endedAt,
|
|
662
696
|
log
|
|
663
697
|
}) {
|
|
664
|
-
|
|
698
|
+
// The report step never sees the test account: it has nothing to sign in
|
|
699
|
+
// to, and the password crosses the model proxy during play only.
|
|
700
|
+
const secrets = secretsOf(localBrief);
|
|
701
|
+
const reportLocal = localBrief && Array.isArray(localBrief.expected_noise) ? { expected_noise: localBrief.expected_noise } : {};
|
|
702
|
+
const system = buildSystem({ skill, persona, brief, localBrief: reportLocal, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
|
|
665
703
|
const messages = [{
|
|
666
704
|
role: 'user',
|
|
667
705
|
content: [{
|
|
@@ -676,13 +714,22 @@ async function reportPhase({
|
|
|
676
714
|
'',
|
|
677
715
|
'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it. A finding whose only evidence is automatic network notes is capped at minor by the runner.',
|
|
678
716
|
'Driver errors are never evidence of a game bug. An observation marked [driver error: ...] or a note "written on a driver error" means the runner could not carry out the action (a target that matched two elements, locator code, a ref not on screen, a timeout on a page that did not change); the game did nothing wrong there. Never report a blocked loop, a frozen screen or a broken button from them. The runner drops any blocker or major whose only cited observations are driver errors.',
|
|
717
|
+
`Notes marked expected noise match the brief's expected_noise or the built in rule (${BUILT_IN_NOISE}); they are never a finding of any severity, and the runner drops a finding whose transcript evidence is only expected noise.`,
|
|
679
718
|
'Notes of kind waiting ("waiting for X to finish") are context, never a finding of any severity, and the runner drops a finding cited only by them. Before reporting a loading problem, check the later observations and screenshots: if they show the content loaded, it is not a finding.',
|
|
680
719
|
'',
|
|
681
720
|
'Observations the persona was shown, by index:',
|
|
682
721
|
observationLines(observations),
|
|
683
722
|
'',
|
|
723
|
+
'Actions you took (every action that went through, numbered by action; "screen changed" means the screen after it differed from the screen before):',
|
|
724
|
+
actionLogLines(play.actionLog),
|
|
725
|
+
'',
|
|
726
|
+
'A claim in report.md or report.json that something was never done, never worked, did nothing or could not be done must agree with this list. If the list shows you clicked a control and the screen changed, do not write that you never clicked it or that it did nothing; describe what the screen showed after it instead. The runner downgrades a finding that says otherwise.',
|
|
727
|
+
'',
|
|
728
|
+
'Network notes (failed requests the runner recorded, with the URL and the status; list them under a "Network notes" heading in report.md when there are any):',
|
|
729
|
+
networkNoteLines(notes),
|
|
730
|
+
'',
|
|
684
731
|
'Each note carries "after": the URL and title of the observation that followed it. A blocker or major finding must cite the observation index where the failure is visible, as evidence {"type": "transcript_step", "ref": "observation N"}, and must not be contradicted by its note\'s "after" state or by a later persona note: if the next screen or a later note shows the game working, it is not a blocker or a major. Cite a note as {"type": "transcript_step", "ref": "note N"}.',
|
|
685
|
-
'A blocker or major finding must also cite the screenshot taken at or after that observation (the list below says which observation each one followed), and that screenshot must not contradict it: if it shows the thing the finding says is missing or broken, such as the field or the button, it is not a blocker or a major. A text observation can be cut short on a long page; the screenshot is what the player saw.',
|
|
732
|
+
'A blocker or major finding must also cite the screenshot taken at or after that observation (the list below says which observation each one followed), and that screenshot must not contradict it: if it shows the thing the finding says is missing or broken, such as the field or the button, it is not a blocker or a major. A text observation can be cut short on a long page; the screenshot is what the player saw. After you write the report, the runner shows each blocker or major (at most three) its cited screenshot and downgrades one step any finding the image contradicts.',
|
|
686
733
|
'',
|
|
687
734
|
'Every evidence entry is an object with exactly two keys, "type" and "ref", and nothing else. A screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}, never "path" or "file".',
|
|
688
735
|
'',
|
|
@@ -697,6 +744,9 @@ async function reportPhase({
|
|
|
697
744
|
].join('\n')
|
|
698
745
|
}]
|
|
699
746
|
}];
|
|
747
|
+
// The prompt is built from notes, observations and the action log; none
|
|
748
|
+
// of them may carry the test account password into the report step.
|
|
749
|
+
messages[0].content[0].text = scrubSecrets(messages[0].content[0].text, secrets);
|
|
700
750
|
|
|
701
751
|
let report = null;
|
|
702
752
|
let markdown = null;
|
|
@@ -707,6 +757,9 @@ async function reportPhase({
|
|
|
707
757
|
let repaired = false;
|
|
708
758
|
let continued = false;
|
|
709
759
|
let droppedByRunner = [];
|
|
760
|
+
let downgradedByRunner = [];
|
|
761
|
+
let normalisedByRunner = [];
|
|
762
|
+
const reportSchema = ((schemas || []).find(schema => schema && schema.name === 'playtest-report') || {}).content || null;
|
|
710
763
|
|
|
711
764
|
// One repair at most when the schema or the evidence refs refuse what was
|
|
712
765
|
// written: each turn is a metered question, and the dogfood gate asks for
|
|
@@ -807,13 +860,30 @@ async function reportPhase({
|
|
|
807
860
|
if (normalised && normalised.changed > 0) {
|
|
808
861
|
transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
|
|
809
862
|
}
|
|
810
|
-
|
|
863
|
+
// Known out of range values (a category written as the severity, an
|
|
864
|
+
// unknown category) are brought back into the schema before anything
|
|
865
|
+
// judges the report, so a run is never lost to them for want of a repair
|
|
866
|
+
// turn. The server's schema stays strict.
|
|
867
|
+
const enumFixed = normalised ? normaliseEnums(normalised.report, reportSchema) : { report: null, changes: [] };
|
|
868
|
+
normalisedByRunner = enumFixed.changes;
|
|
869
|
+
if (enumFixed.changes.length > 0) {
|
|
870
|
+
transcript.write({ kind: 'report_values_normalised', attempt, changes: enumFixed.changes.map(change => ({ id: change.id, field: change.field, from: change.from, to: change.to })) });
|
|
871
|
+
}
|
|
872
|
+
const capped = normalised ? capNetworkOnlyFindings(stampReport(enumFixed.report, facts), { notes, screenshots }) : null;
|
|
811
873
|
const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
|
|
812
|
-
const candidate = runnerDrop.report;
|
|
813
874
|
for (const entry of runnerDrop.dropped) {
|
|
814
875
|
transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
|
|
815
876
|
}
|
|
816
877
|
droppedByRunner = runnerDrop.dropped;
|
|
878
|
+
const contradicted = runnerDrop.report ? downgradeContradictedFindings(runnerDrop.report, play.actionLog) : { report: null, downgraded: [] };
|
|
879
|
+
const candidate = contradicted.report;
|
|
880
|
+
for (const entry of contradicted.downgraded) {
|
|
881
|
+
transcript.write({ kind: 'finding_downgraded', id: entry.id, from: entry.from, to: entry.to, control: entry.control, action: entry.action });
|
|
882
|
+
}
|
|
883
|
+
downgradedByRunner = contradicted.downgraded;
|
|
884
|
+
if (contradicted.downgraded.length > 0) {
|
|
885
|
+
log(`downgraded ${contradicted.downgraded.length} finding(s) the action log contradicts: ${contradicted.downgraded.map(entry => `${entry.id} (${entry.from} to ${entry.to}, ${entry.control} clicked at step ${entry.action})`).join(', ')}`);
|
|
886
|
+
}
|
|
817
887
|
if (candidate) lastCandidate = candidate;
|
|
818
888
|
const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
|
|
819
889
|
const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
|
|
@@ -872,6 +942,22 @@ async function reportPhase({
|
|
|
872
942
|
});
|
|
873
943
|
}
|
|
874
944
|
|
|
945
|
+
// One bounded look at the evidence: each blocker or major (at most
|
|
946
|
+
// MAX_CLAIM_CHECKS) is shown the screenshot it cites with its claim. It runs
|
|
947
|
+
// after the report is settled, so a refusal can cost the check and never
|
|
948
|
+
// the report.
|
|
949
|
+
report = scrubSecrets(report, secrets);
|
|
950
|
+
if (!shrunk) {
|
|
951
|
+
const checked = await checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log });
|
|
952
|
+
if (checked.downgraded.length > 0) {
|
|
953
|
+
report = checked.report;
|
|
954
|
+
downgradedByRunner = [...downgradedByRunner, ...checked.downgraded];
|
|
955
|
+
log(`downgraded ${checked.downgraded.length} finding(s) their own screenshot contradicts: ${checked.downgraded.map(entry => `${entry.id} (${entry.from} to ${entry.to}, ${entry.screenshot})`).join(', ')}`);
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
transcript.write({ kind: 'claim_check_skipped', reason: 'the report turn needed a smaller output ceiling to fit the budget' });
|
|
959
|
+
}
|
|
960
|
+
|
|
875
961
|
if (!markdown) {
|
|
876
962
|
markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
|
|
877
963
|
}
|
|
@@ -879,16 +965,154 @@ async function reportPhase({
|
|
|
879
965
|
markdown = rewriteMarkdown(markdown, droppedByRunner.map(entry => entry.finding), report.findings || [], RUNNER_DROPPED_SECTION);
|
|
880
966
|
log(`dropped ${droppedByRunner.length} finding(s) the runner's evidence rules refuse: ${droppedByRunner.map(entry => `${entry.finding.id} (${entry.reason})`).join(', ')}`);
|
|
881
967
|
}
|
|
968
|
+
// The uploaded pair agree: report.md says the severity report.json does.
|
|
969
|
+
if (downgradedByRunner.length > 0) markdown = rewriteDowngradedMarkdown(markdown, downgradedByRunner);
|
|
970
|
+
if (normalisedByRunner.length > 0) {
|
|
971
|
+
markdown = appendNormalisedMarkdown(markdown, normalisedByRunner);
|
|
972
|
+
log(`normalised ${normalisedByRunner.length} value(s) the report schema does not allow: ${normalisedByRunner.map(change => `${change.id ? `${change.id} ` : ''}${change.field} ${JSON.stringify(change.from)} to ${JSON.stringify(change.to)}`).join(', ')}`);
|
|
973
|
+
}
|
|
882
974
|
const generatedAt = new Date().toISOString();
|
|
883
975
|
const facts = reportFacts({ persona, driverName, target, play });
|
|
884
976
|
markdown = stampGeneratedAt(markdown, generatedAt, facts);
|
|
885
977
|
// The same slots in the prose the dashboard shows from report.json.
|
|
886
978
|
report = fillReportPlaceholders(report, { generatedAt, ...facts });
|
|
979
|
+
// Whatever the model wrote, the password does not leave in a report.
|
|
980
|
+
markdown = scrubSecrets(markdown, secrets);
|
|
981
|
+
report = scrubSecrets(report, secrets);
|
|
887
982
|
|
|
888
983
|
await writeReportFiles({ runDirectory, markdown, report });
|
|
889
984
|
return { report, markdown, lint };
|
|
890
985
|
}
|
|
891
986
|
|
|
987
|
+
/** The most blocker or major findings whose screenshot is checked against the claim. */
|
|
988
|
+
export const MAX_CLAIM_CHECKS = 3;
|
|
989
|
+
|
|
990
|
+
/** Output ceiling for one claim check: the answer is one word. */
|
|
991
|
+
export const CLAIM_CHECK_MAX_TOKENS = 32;
|
|
992
|
+
|
|
993
|
+
/** The instruction for a claim check. */
|
|
994
|
+
export const CLAIM_CHECK_SYSTEM = 'You check playtest findings against their evidence. You are shown one screenshot and one claim about it. Answer with exactly one word: yes if the image contradicts the claim, no if it does not, unclear if the image cannot settle it.';
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* The screenshot a finding's claim is checked against: of those it cites, the
|
|
998
|
+
* one taken nearest after the latest observation it cites (the later of two on
|
|
999
|
+
* the same observation), else the last screenshot it cites. Null when it cites none that exists in the list.
|
|
1000
|
+
*
|
|
1001
|
+
* @param {Object} finding
|
|
1002
|
+
* @param {Array<Object>} screenshots
|
|
1003
|
+
* @returns {Object|null}
|
|
1004
|
+
*/
|
|
1005
|
+
export function citedAfterScreenshot(finding, screenshots) {
|
|
1006
|
+
const evidence = Array.isArray(finding && finding.evidence) ? finding.evidence : [];
|
|
1007
|
+
const byPath = new Map((screenshots || []).filter(shot => shot && shot.relativePath).map(shot => [shot.relativePath, shot]));
|
|
1008
|
+
const cited = evidence.filter(item => item && item.type === 'screenshot' && byPath.has(String(item.ref))).map(item => byPath.get(String(item.ref)));
|
|
1009
|
+
if (cited.length === 0) return null;
|
|
1010
|
+
const observations = evidence
|
|
1011
|
+
.map(item => (item && item.type === 'transcript_step' ? String(item.ref || '').match(/^\s*observation\s*#?\s*(\d+)\s*$/i) : null))
|
|
1012
|
+
.filter(Boolean)
|
|
1013
|
+
.map(match => Number(match[1]));
|
|
1014
|
+
if (observations.length > 0) {
|
|
1015
|
+
const latest = Math.max(...observations);
|
|
1016
|
+
// The nearest screen after the claim: the lowest observation at or after
|
|
1017
|
+
// it, and of two taken on the same observation the later one.
|
|
1018
|
+
const order = new Map((screenshots || []).map((shot, index) => [shot, index]));
|
|
1019
|
+
const after = cited
|
|
1020
|
+
.filter(shot => Number.isInteger(shot.observation) && shot.observation >= latest)
|
|
1021
|
+
.sort((a, b) => a.observation - b.observation || order.get(b) - order.get(a))[0];
|
|
1022
|
+
if (after) return after;
|
|
1023
|
+
}
|
|
1024
|
+
return cited[cited.length - 1];
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Read a claim check answer: yes, no or unclear, from its first word.
|
|
1029
|
+
*
|
|
1030
|
+
* @param {string} text
|
|
1031
|
+
* @returns {'yes'|'no'|'unclear'}
|
|
1032
|
+
*/
|
|
1033
|
+
export function claimCheckAnswer(text) {
|
|
1034
|
+
const match = String(text || '').match(/^[^A-Za-z]*(yes|no|unclear)\b/i);
|
|
1035
|
+
return match ? match[1].toLowerCase() : 'unclear';
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* The bounded verification turn of the report step. For each blocker or
|
|
1040
|
+
* major, at most {@link MAX_CLAIM_CHECKS}, the cited `after` screenshot is
|
|
1041
|
+
* sent with the finding's claim and the question "does this image contradict
|
|
1042
|
+
* the claim: yes, no, unclear". A yes downgrades the finding one step, with a
|
|
1043
|
+
* note naming the screenshot. Texas and Snowball both filed a finding its own
|
|
1044
|
+
* screenshot contradicted.
|
|
1045
|
+
*
|
|
1046
|
+
* It goes to `persona_playtest.act`, the run's one step that accepts an image
|
|
1047
|
+
* (the report step accepts none), with no tools and a one word ceiling, and
|
|
1048
|
+
* it is spent from the same run budget the warning at 80 percent keeps for the
|
|
1049
|
+
* report step. A budget refusal, or any other refusal, ends the checks and
|
|
1050
|
+
* keeps the report as it is.
|
|
1051
|
+
*
|
|
1052
|
+
* @returns {Promise<{report: Object, downgraded: Array<Object>}>}
|
|
1053
|
+
*/
|
|
1054
|
+
export async function checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log = () => {} }) {
|
|
1055
|
+
if (!report || !Array.isArray(report.findings)) return { report, downgraded: [] };
|
|
1056
|
+
const targets = report.findings.filter(finding => finding && ABOVE_MINOR.includes(finding.severity)).slice(0, MAX_CLAIM_CHECKS);
|
|
1057
|
+
const downgraded = [];
|
|
1058
|
+
const verdicts = new Map();
|
|
1059
|
+
for (const finding of targets) {
|
|
1060
|
+
const shot = citedAfterScreenshot(finding, screenshots);
|
|
1061
|
+
if (!shot) {
|
|
1062
|
+
transcript.write({ kind: 'claim_check', id: finding.id, screenshot: null, answer: 'skipped', reason: 'no cited screenshot' });
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
let data;
|
|
1066
|
+
try {
|
|
1067
|
+
data = (await readFile(path.join(runDirectory, shot.relativePath))).toString('base64');
|
|
1068
|
+
} catch {
|
|
1069
|
+
transcript.write({ kind: 'claim_check', id: finding.id, screenshot: shot.relativePath, answer: 'skipped', reason: 'the screenshot could not be read' });
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
let answer;
|
|
1073
|
+
const askedAt = Date.now();
|
|
1074
|
+
try {
|
|
1075
|
+
answer = await callStepWithRetry({
|
|
1076
|
+
client,
|
|
1077
|
+
jobId,
|
|
1078
|
+
runId,
|
|
1079
|
+
step: STEPS.personaAct,
|
|
1080
|
+
system: CLAIM_CHECK_SYSTEM,
|
|
1081
|
+
messages: [{
|
|
1082
|
+
role: 'user',
|
|
1083
|
+
content: [
|
|
1084
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data } },
|
|
1085
|
+
{ type: 'text', text: `Claim (${finding.severity}): ${finding.title || ''}\n${String(finding.description || '').slice(0, 1200)}\n\nDoes this image contradict the claim: yes, no, unclear?` }
|
|
1086
|
+
]
|
|
1087
|
+
}],
|
|
1088
|
+
maxTokens: CLAIM_CHECK_MAX_TOKENS,
|
|
1089
|
+
escalate: false,
|
|
1090
|
+
maxAttempts: 1
|
|
1091
|
+
});
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
const reason = error instanceof ProxyError && error.isBudget ? 'the run budget is spent' : String(error && error.message ? error.message : error);
|
|
1094
|
+
transcript.write({ kind: 'claim_check', id: finding.id, screenshot: shot.relativePath, answer: 'skipped', reason });
|
|
1095
|
+
log(`the screenshot check stopped (${reason}); the report is kept as written.`);
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
1099
|
+
const verdict = claimCheckAnswer(answer.text);
|
|
1100
|
+
transcript.write({ kind: 'claim_check', id: finding.id, screenshot: shot.relativePath, answer: verdict });
|
|
1101
|
+
if (verdict === 'yes' && DOWNGRADE[finding.severity]) verdicts.set(finding, shot.relativePath);
|
|
1102
|
+
}
|
|
1103
|
+
if (verdicts.size === 0) return { report, downgraded };
|
|
1104
|
+
const findings = report.findings.map(finding => {
|
|
1105
|
+
if (!verdicts.has(finding)) return finding;
|
|
1106
|
+
const screenshot = verdicts.get(finding);
|
|
1107
|
+
const to = DOWNGRADE[finding.severity];
|
|
1108
|
+
const note = `its own screenshot ${screenshot} contradicts the claim`;
|
|
1109
|
+
downgraded.push({ id: finding.id, from: finding.severity, to, screenshot, note });
|
|
1110
|
+
const description = String(finding.description || '');
|
|
1111
|
+
return { ...finding, severity: to, description: description ? `${description}\n\nDowngraded by the runner: ${note}.` : `Downgraded by the runner: ${note}.` };
|
|
1112
|
+
});
|
|
1113
|
+
return { report: { ...report, findings }, downgraded };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
892
1116
|
/** The smallest output ceiling worth retrying a report turn with. */
|
|
893
1117
|
export const REPORT_MIN_TOKENS = 1536;
|
|
894
1118
|
|
|
@@ -988,7 +1212,9 @@ async function callWithImageRetry({ imageBudget, keepObservations, messages, onI
|
|
|
988
1212
|
* product where the same few thousand tokens are re-sent tens of times. The
|
|
989
1213
|
* proxy allowlists `system` and passes the blocks through unchanged.
|
|
990
1214
|
*/
|
|
991
|
-
export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions }) {
|
|
1215
|
+
export function buildSystem({ skill, persona, brief, localBrief = {}, intentNotes, driverName, target, maxActions, runId = null }) {
|
|
1216
|
+
const identity = runIdentity({ persona, runId });
|
|
1217
|
+
const account = testAccountOf(localBrief);
|
|
992
1218
|
return [
|
|
993
1219
|
{ type: 'text', text: skill },
|
|
994
1220
|
{ type: 'text', text: `<persona_file>\n${persona.body}\n</persona_file>` },
|
|
@@ -999,13 +1225,87 @@ export function buildSystem({ skill, persona, brief, intentNotes, driverName, ta
|
|
|
999
1225
|
`target: ${target}`,
|
|
1000
1226
|
`max_actions: ${maxActions}`,
|
|
1001
1227
|
`<expectations_brief>\n${briefForPrompt(brief)}\n</expectations_brief>`,
|
|
1002
|
-
`<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes
|
|
1228
|
+
`<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`,
|
|
1229
|
+
`<expected_noise>\nConsole errors and failed requests matching these are expected and never a finding:\n${expectedNoiseForPrompt(localBrief && localBrief.expected_noise)}\n</expected_noise>`,
|
|
1230
|
+
...(account ? [`<test_account>\n${testAccountForPrompt(account)}\n</test_account>`] : []),
|
|
1231
|
+
...(identity ? [`<run_identity>\n${runIdentityForPrompt(identity, { testAccount: Boolean(account) })}\n</run_identity>`] : [])
|
|
1003
1232
|
].join('\n'),
|
|
1004
1233
|
cache_control: { type: 'ephemeral' }
|
|
1005
1234
|
}
|
|
1006
1235
|
];
|
|
1007
1236
|
}
|
|
1008
1237
|
|
|
1238
|
+
/**
|
|
1239
|
+
* The run id cut to something short enough for an email or a display name:
|
|
1240
|
+
* the last eight letters and digits after the `pt_run_` prefix.
|
|
1241
|
+
*
|
|
1242
|
+
* @param {string} runId
|
|
1243
|
+
* @returns {string}
|
|
1244
|
+
*/
|
|
1245
|
+
export function shortRunId(runId) {
|
|
1246
|
+
const compact = String(runId == null ? '' : runId).replace(/^pt_run_/, '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
1247
|
+
return compact.slice(-8);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* A fresh identity for every run, so a sign-up form never sees an email an
|
|
1252
|
+
* earlier run already registered. The Texas round lost actions to a 409 on
|
|
1253
|
+
* sign-up from a reused address. `example.test` is a reserved name that never
|
|
1254
|
+
* delivers mail.
|
|
1255
|
+
*
|
|
1256
|
+
* @param {{persona: {slug?: string, name?: string}, runId: string}} args
|
|
1257
|
+
* @returns {{email: string, displayName: string, short: string}|null}
|
|
1258
|
+
*/
|
|
1259
|
+
export function runIdentity({ persona, runId }) {
|
|
1260
|
+
const short = shortRunId(runId);
|
|
1261
|
+
if (!short) return null;
|
|
1262
|
+
const slug = String((persona && persona.slug) || 'player').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'player';
|
|
1263
|
+
const name = String((persona && (persona.name || persona.slug)) || 'Player').replace(/\s+/g, ' ').trim().slice(0, 40) || 'Player';
|
|
1264
|
+
return { email: `persona-${slug}-${short}@example.test`, displayName: `${name} ${short}`, short };
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* @param {{email: string, displayName: string}} identity
|
|
1269
|
+
* @returns {string}
|
|
1270
|
+
*/
|
|
1271
|
+
export function runIdentityForPrompt(identity, { testAccount = false } = {}) {
|
|
1272
|
+
return [
|
|
1273
|
+
`email: ${identity.email}`,
|
|
1274
|
+
`display_name: ${identity.displayName}`,
|
|
1275
|
+
testAccount
|
|
1276
|
+
? 'Sign in with the test account above. Use this email and this display name only if you have to make a new account, and whenever a form asks for a display name, a coach or a player name. Never reuse an email from an earlier run.'
|
|
1277
|
+
: 'Use this email and this display name whenever a form asks for an email, a name, a username or a coach or player name, unless the brief says otherwise. They are new for this run, so an account made with them does not exist yet. Never reuse an email from an earlier run.'
|
|
1278
|
+
].join('\n');
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* The local brief's test account, or null when it has none that can be used.
|
|
1283
|
+
*
|
|
1284
|
+
* @param {Object} localBrief
|
|
1285
|
+
* @returns {{email: string, password: string, how_to_sign_in: string|null}|null}
|
|
1286
|
+
*/
|
|
1287
|
+
export function testAccountOf(localBrief) {
|
|
1288
|
+
const account = localBrief && localBrief.test_account;
|
|
1289
|
+
if (!account || typeof account !== 'object' || typeof account.email !== 'string' || !account.email || typeof account.password !== 'string' || account.password.length < MIN_PASSWORD_CHARS) return null;
|
|
1290
|
+
return { email: account.email, password: account.password, how_to_sign_in: typeof account.how_to_sign_in === 'string' && account.how_to_sign_in.trim() ? account.how_to_sign_in.trim() : null };
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* The test account block of the persona prompt: an account the developer made
|
|
1295
|
+
* so a run can start past sign up.
|
|
1296
|
+
*
|
|
1297
|
+
* @param {{email: string, password: string, how_to_sign_in: string|null}} account
|
|
1298
|
+
* @returns {string}
|
|
1299
|
+
*/
|
|
1300
|
+
export function testAccountForPrompt(account) {
|
|
1301
|
+
return [
|
|
1302
|
+
`email: ${account.email}`,
|
|
1303
|
+
`password: ${account.password}`,
|
|
1304
|
+
...(account.how_to_sign_in ? [`how_to_sign_in: ${account.how_to_sign_in}`] : []),
|
|
1305
|
+
'This is a throwaway account the developer made for this playtest. Sign in with it rather than signing up, so your actions go to the game itself. Never write the password in a note or a report.'
|
|
1306
|
+
].join('\n');
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1009
1309
|
/**
|
|
1010
1310
|
* The brief as the persona skill expects it: the `fields` object the
|
|
1011
1311
|
* expectations-brief schema describes, with the version alongside so a report
|
|
@@ -1073,11 +1373,54 @@ export function assistantContent(answer) {
|
|
|
1073
1373
|
return content;
|
|
1074
1374
|
}
|
|
1075
1375
|
|
|
1076
|
-
/**
|
|
1077
|
-
|
|
1376
|
+
/** What a typed secret is written as in the transcript and the action log. */
|
|
1377
|
+
export const REDACTED_SECRET = '[test password]';
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Every string in a value with each secret replaced by {@link REDACTED_SECRET},
|
|
1381
|
+
* through arrays and plain objects. A new value; the input is not changed.
|
|
1382
|
+
*
|
|
1383
|
+
* @param {any} value
|
|
1384
|
+
* @param {Array<string>} secrets
|
|
1385
|
+
* @returns {any}
|
|
1386
|
+
*/
|
|
1387
|
+
export function scrubSecrets(value, secrets = []) {
|
|
1388
|
+
if (!Array.isArray(secrets) || secrets.length === 0) return value;
|
|
1389
|
+
if (typeof value === 'string') {
|
|
1390
|
+
let text = value;
|
|
1391
|
+
// Whole tokens only: the password with no letter, digit or underscore
|
|
1392
|
+
// directly before or after it, so it is replaced where it was written
|
|
1393
|
+
// (alone, quoted, after a colon) and never inside a longer word.
|
|
1394
|
+
for (const secret of secrets) {
|
|
1395
|
+
if (!secret) continue;
|
|
1396
|
+
const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1397
|
+
text = text.replace(new RegExp(`(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])`, 'gu'), REDACTED_SECRET);
|
|
1398
|
+
}
|
|
1399
|
+
return text;
|
|
1400
|
+
}
|
|
1401
|
+
if (Array.isArray(value)) return value.map(entry => scrubSecrets(entry, secrets));
|
|
1402
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrubSecrets(entry, secrets)]));
|
|
1403
|
+
return value;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* The strings the transcript must not repeat: the local brief's test account
|
|
1408
|
+
* password. It is in the prompt (that is how a persona can type it), but the
|
|
1409
|
+
* transcript can be uploaded and the action log is shown in the report step.
|
|
1410
|
+
*
|
|
1411
|
+
* @param {Object} localBrief
|
|
1412
|
+
* @returns {Array<string>}
|
|
1413
|
+
*/
|
|
1414
|
+
export function secretsOf(localBrief) {
|
|
1415
|
+
const password = localBrief && localBrief.test_account && localBrief.test_account.password;
|
|
1416
|
+
return typeof password === 'string' && password.length >= MIN_PASSWORD_CHARS ? [password] : [];
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** Keep the transcript readable without storing a whole screenshot in it, or a test account password. */
|
|
1420
|
+
export function redactToolInput(toolUse, secrets = []) {
|
|
1078
1421
|
if (!toolUse.input) return null;
|
|
1079
1422
|
const { thought_in_character: ignored, ...rest } = toolUse.input;
|
|
1080
|
-
return rest;
|
|
1423
|
+
return scrubSecrets(rest, secrets);
|
|
1081
1424
|
}
|
|
1082
1425
|
|
|
1083
1426
|
async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
|
|
@@ -1143,6 +1486,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
|
|
|
1143
1486
|
export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
|
|
1144
1487
|
const good = notes.filter(note => note.kind === 'good');
|
|
1145
1488
|
const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
|
|
1489
|
+
const network = notes.filter(note => note.automatic && note.url);
|
|
1146
1490
|
const list = entries => (entries.length > 0
|
|
1147
1491
|
? entries.map(note => `- **${note.kind}${note.severity ? `/${note.severity}` : ''}**: ${note.text}`).join('\n')
|
|
1148
1492
|
: '- Nothing was logged.');
|
|
@@ -1170,6 +1514,7 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
|
|
|
1170
1514
|
'## Suggested next step',
|
|
1171
1515
|
'Run this persona again once the reason above is resolved.',
|
|
1172
1516
|
'',
|
|
1517
|
+
...(network.length > 0 ? ['## Network notes', network.map(note => `- ${note.status ? `${note.status} ` : ''}${note.url}`).join('\n'), ''] : []),
|
|
1173
1518
|
'## Evidence',
|
|
1174
1519
|
screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}`).join('\n') : '- (none)',
|
|
1175
1520
|
''
|
|
@@ -1218,6 +1563,8 @@ export function notesToFindings(notes, screenshots) {
|
|
|
1218
1563
|
if (!category || !note.text) continue;
|
|
1219
1564
|
// A blocker or major written on a driver error is the runner's failure.
|
|
1220
1565
|
if (note.driver_error && ABOVE_MINOR.includes(note.severity)) continue;
|
|
1566
|
+
// Console noise the developer said to expect is never a finding.
|
|
1567
|
+
if (note.expected_noise) continue;
|
|
1221
1568
|
const text = String(note.text);
|
|
1222
1569
|
const shot = nearestScreenshot(note, screenshots);
|
|
1223
1570
|
findings.push({
|
|
@@ -1296,6 +1643,7 @@ export function noteLine(note, i) {
|
|
|
1296
1643
|
const tags = [
|
|
1297
1644
|
note.automatic ? 'automatic' : null,
|
|
1298
1645
|
note.driver_error ? 'written on a driver error' : null,
|
|
1646
|
+
note.expected_noise ? 'expected noise' : null,
|
|
1299
1647
|
Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
|
|
1300
1648
|
note.screenshot ? `screenshot ${note.screenshot}` : null
|
|
1301
1649
|
].filter(Boolean);
|
|
@@ -1368,8 +1716,8 @@ export function observationTitle(observation) {
|
|
|
1368
1716
|
*/
|
|
1369
1717
|
/** How report.md lists what the runner dropped. */
|
|
1370
1718
|
export const RUNNER_DROPPED_SECTION = Object.freeze({
|
|
1371
|
-
heading: '## Dropped by the runner (driver errors
|
|
1372
|
-
explanation: 'The runner removes a blocker or major whose only cited observations are driver errors (the runner could not carry out an action; the game did nothing wrong), and any finding cited only by waiting notes. They are not sent to the server. They are kept here so you can judge them yourself.',
|
|
1719
|
+
heading: '## Dropped by the runner (driver errors, waiting notes or expected noise only)',
|
|
1720
|
+
explanation: 'The runner removes a blocker or major whose only cited observations are driver errors (the runner could not carry out an action; the game did nothing wrong), and any finding cited only by waiting notes or by console noise the brief says to expect. They are not sent to the server. They are kept here so you can judge them yourself.',
|
|
1373
1721
|
emptyNote: '- Nothing here that the runner kept. See the dropped findings below.'
|
|
1374
1722
|
});
|
|
1375
1723
|
|
|
@@ -1392,11 +1740,26 @@ export const RUNNER_DROPPED_SECTION = Object.freeze({
|
|
|
1392
1740
|
export function dropRunnerRejectedFindings(report, { notes = [], observations = [] } = {}) {
|
|
1393
1741
|
if (!report || !Array.isArray(report.findings)) return { report, dropped: [] };
|
|
1394
1742
|
const byIndex = new Map(observations.map(entry => [entry.index, entry]));
|
|
1743
|
+
// The runner's console-error screenshot of a moment whose every console
|
|
1744
|
+
// note was expected noise says nothing more than those notes do.
|
|
1745
|
+
const noiseShots = new Set();
|
|
1746
|
+
const shotNotes = new Map();
|
|
1747
|
+
for (const note of notes) {
|
|
1748
|
+
if (!note || !note.automatic || !note.screenshot) continue;
|
|
1749
|
+
if (!shotNotes.has(note.screenshot)) shotNotes.set(note.screenshot, []);
|
|
1750
|
+
shotNotes.get(note.screenshot).push(note);
|
|
1751
|
+
}
|
|
1752
|
+
for (const [shot, attached] of shotNotes) if (attached.every(note => note.expected_noise)) noiseShots.add(shot);
|
|
1395
1753
|
const verdict = finding => {
|
|
1396
1754
|
const cited = [];
|
|
1397
1755
|
for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
|
|
1398
|
-
if (!item
|
|
1756
|
+
if (!item) continue;
|
|
1399
1757
|
const ref = String(item.ref || '');
|
|
1758
|
+
if (item.type === 'screenshot') {
|
|
1759
|
+
if (noiseShots.has(ref)) cited.push('noise');
|
|
1760
|
+
continue;
|
|
1761
|
+
}
|
|
1762
|
+
if (item.type !== 'transcript_step') continue;
|
|
1400
1763
|
const observation = ref.match(/^\s*observation\s*#?\s*(\d+)\s*$/i);
|
|
1401
1764
|
const note = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
|
|
1402
1765
|
if (observation) {
|
|
@@ -1404,7 +1767,7 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
|
|
|
1404
1767
|
cited.push(entry && entry.driver_error ? 'driver' : 'other');
|
|
1405
1768
|
} else if (note) {
|
|
1406
1769
|
const entry = notes[Number(note[1]) - 1];
|
|
1407
|
-
cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.driver_error ? 'driver' : 'other');
|
|
1770
|
+
cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.expected_noise ? 'noise' : entry && entry.driver_error ? 'driver' : 'other');
|
|
1408
1771
|
} else {
|
|
1409
1772
|
cited.push('other');
|
|
1410
1773
|
}
|
|
@@ -1412,7 +1775,9 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
|
|
|
1412
1775
|
if (cited.length === 0) return null;
|
|
1413
1776
|
if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver')) return 'driver errors only';
|
|
1414
1777
|
if (cited.every(kind => kind === 'waiting')) return 'waiting notes only';
|
|
1415
|
-
if (
|
|
1778
|
+
if (cited.every(kind => kind === 'noise')) return 'expected console noise only';
|
|
1779
|
+
if (cited.every(kind => kind === 'waiting' || kind === 'noise')) return 'expected console noise and waiting notes only';
|
|
1780
|
+
if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver' || kind === 'waiting' || kind === 'noise')) return 'driver errors, waiting notes and expected noise only';
|
|
1416
1781
|
return null;
|
|
1417
1782
|
};
|
|
1418
1783
|
const dropped = [];
|
|
@@ -1425,6 +1790,273 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
|
|
|
1425
1790
|
return { report: dropped.length > 0 ? { ...report, findings } : report, dropped };
|
|
1426
1791
|
}
|
|
1427
1792
|
|
|
1793
|
+
/** The most actions the report prompt lists; the newest are kept. */
|
|
1794
|
+
export const MAX_ACTIONS_IN_REPORT = 60;
|
|
1795
|
+
|
|
1796
|
+
/** The longest typed text an action line repeats. */
|
|
1797
|
+
const MAX_TYPED_CHARS = 40;
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* The names of the refs a compacted snapshot shows: `- button "Start" [e12]`
|
|
1801
|
+
* gives e12 -> button "Start".
|
|
1802
|
+
*
|
|
1803
|
+
* @param {string} text
|
|
1804
|
+
* @returns {Map<string, {role: string, name: string}>}
|
|
1805
|
+
*/
|
|
1806
|
+
export function refNamesFrom(text) {
|
|
1807
|
+
const names = new Map();
|
|
1808
|
+
for (const line of String(text == null ? '' : text).split('\n')) {
|
|
1809
|
+
const match = line.match(/^\s*- ([a-z]+) "([^"]*)"(?: \[[^\]]*\])*? \[(e\d+)\]/);
|
|
1810
|
+
if (match && match[2]) names.set(match[3], { role: match[1], name: match[2] });
|
|
1811
|
+
}
|
|
1812
|
+
return names;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Every action that went through, from the transcript's observation and
|
|
1817
|
+
* action lines, with the name of what it targeted when the screen before it
|
|
1818
|
+
* showed one.
|
|
1819
|
+
*
|
|
1820
|
+
* @param {Array<Object>} records transcript lines of kind observation and action, in order
|
|
1821
|
+
* @returns {Array<{action: number, kind: string, target: string, name: string|null, ref: string|null, changed: boolean}>}
|
|
1822
|
+
*/
|
|
1823
|
+
export function actionLogFrom(records) {
|
|
1824
|
+
const log = [];
|
|
1825
|
+
let names = new Map();
|
|
1826
|
+
// Observations are numbered as the report prompt numbers them: one per
|
|
1827
|
+
// observation line, in order.
|
|
1828
|
+
let observation = 0;
|
|
1829
|
+
let previousText = '';
|
|
1830
|
+
let pending = null;
|
|
1831
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
1832
|
+
if (!record) continue;
|
|
1833
|
+
if (record.kind === 'observation') {
|
|
1834
|
+
observation += 1;
|
|
1835
|
+
const text = String(record.text == null ? '' : record.text);
|
|
1836
|
+
if (pending) {
|
|
1837
|
+
pending.observationAfter = observation;
|
|
1838
|
+
pending.introducedError = introducesError(previousText, text);
|
|
1839
|
+
pending = null;
|
|
1840
|
+
}
|
|
1841
|
+
previousText = text;
|
|
1842
|
+
const next = refNamesFrom(text);
|
|
1843
|
+
// An unchanged marker or a canvas shows no refs; the last names stand.
|
|
1844
|
+
if (next.size > 0) names = next;
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
if (record.kind !== 'action') continue;
|
|
1848
|
+
pending = null;
|
|
1849
|
+
if (!record.ok || !ACTION_TOOLS.includes(record.tool)) continue;
|
|
1850
|
+
const input = record.input || {};
|
|
1851
|
+
const ref = typeof input.ref === 'string' && input.ref.trim() ? input.ref.trim() : null;
|
|
1852
|
+
const known = ref ? names.get(ref) : null;
|
|
1853
|
+
let name = known ? known.name : null;
|
|
1854
|
+
let target;
|
|
1855
|
+
if (record.tool === 'key') target = `key ${String(input.key || '')}`;
|
|
1856
|
+
else if (record.tool === 'navigate') target = String(input.url || '');
|
|
1857
|
+
else if (record.tool === 'swipe') target = `from (${input.x},${input.y}) to (${input.toX},${input.toY})`;
|
|
1858
|
+
else {
|
|
1859
|
+
const named = typeof input.target === 'string' && input.target.trim() ? input.target.trim() : null;
|
|
1860
|
+
if (!name && named) {
|
|
1861
|
+
const roleAndName = named.match(/^[a-z]+\s+"([^"]*)"$/);
|
|
1862
|
+
name = roleAndName ? roleAndName[1] : named;
|
|
1863
|
+
}
|
|
1864
|
+
const where = known ? `${known.role} "${known.name}" [${ref}]` : ref ? `[${ref}]` : named || (Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y)) ? `(${input.x},${input.y})` : 'the focused element');
|
|
1865
|
+
if (record.tool === 'type') {
|
|
1866
|
+
const text = String(input.text == null ? '' : input.text);
|
|
1867
|
+
target = `${JSON.stringify(text.length > MAX_TYPED_CHARS ? `${text.slice(0, MAX_TYPED_CHARS - 3)}...` : text)} into ${where}${input.replace ? ' (replacing)' : ''}${input.submit ? ' then Enter' : ''}`;
|
|
1868
|
+
} else {
|
|
1869
|
+
target = where;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
const entry = { action: Number(record.actions) || log.length + 1, kind: record.tool, target, name, ref, changed: Boolean(record.changed), observationBefore: observation, observationAfter: null, introducedError: false };
|
|
1873
|
+
log.push(entry);
|
|
1874
|
+
pending = entry;
|
|
1875
|
+
}
|
|
1876
|
+
return log;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/** A line that reads as an error: an alert or a dialog, or error wording. */
|
|
1880
|
+
const ERROR_LINE = /^\s*- (?:alert|alertdialog|dialog)\b|\b(?:error|errors|failed|invalid)\b/i;
|
|
1881
|
+
|
|
1882
|
+
/**
|
|
1883
|
+
* Whether the screen after an action shows an alert, a dialog or error
|
|
1884
|
+
* wording that the screen before it did not: a change, but not the one the
|
|
1885
|
+
* persona wanted.
|
|
1886
|
+
*
|
|
1887
|
+
* @param {string} before
|
|
1888
|
+
* @param {string} after
|
|
1889
|
+
* @returns {boolean}
|
|
1890
|
+
*/
|
|
1891
|
+
export function introducesError(before, after) {
|
|
1892
|
+
const seen = new Set(String(before || '').split('\n').map(line => line.trim()));
|
|
1893
|
+
return String(after || '').split('\n').some(line => !seen.has(line.trim()) && ERROR_LINE.test(line));
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* The action log as the report prompt's numbered list, bounded to the newest
|
|
1898
|
+
* {@link MAX_ACTIONS_IN_REPORT}.
|
|
1899
|
+
*
|
|
1900
|
+
* @param {Array<Object>} log
|
|
1901
|
+
* @returns {string}
|
|
1902
|
+
*/
|
|
1903
|
+
export function actionLogLines(log) {
|
|
1904
|
+
if (!Array.isArray(log) || log.length === 0) return '(none)';
|
|
1905
|
+
const shown = log.slice(-MAX_ACTIONS_IN_REPORT);
|
|
1906
|
+
const lines = shown.map(entry => `${entry.action}. ${entry.kind} ${entry.target}: ${entry.changed ? 'screen changed' : 'no change'}`);
|
|
1907
|
+
if (shown.length < log.length) lines.unshift(`(the first ${log.length - shown.length} are left out)`);
|
|
1908
|
+
return lines.join('\n');
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* The failed requests the runner recorded, one line each, for the report
|
|
1913
|
+
* prompt's network notes.
|
|
1914
|
+
*
|
|
1915
|
+
* @param {Array<Object>} notes
|
|
1916
|
+
* @returns {string}
|
|
1917
|
+
*/
|
|
1918
|
+
export function networkNoteLines(notes) {
|
|
1919
|
+
const lines = [];
|
|
1920
|
+
(notes || []).forEach((note, index) => {
|
|
1921
|
+
if (!note || !note.automatic || !note.url) return;
|
|
1922
|
+
lines.push(`- note ${index + 1}: ${note.status ? `${note.status} ` : ''}${note.url}`);
|
|
1923
|
+
});
|
|
1924
|
+
return lines.length > 0 ? lines.join('\n') : '(none)';
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* The "nothing happens" family: wording that says a control was used and
|
|
1929
|
+
* produced nothing. Deliberately narrow; "cannot" or "fails" can be true of a
|
|
1930
|
+
* control the persona clicked.
|
|
1931
|
+
*/
|
|
1932
|
+
const NOTHING_HAPPENED = /\b(?:did nothing|does nothing|never kicked off|never started|no response|nothing happened)\b/i;
|
|
1933
|
+
|
|
1934
|
+
/** One step down the severity scale. Cosmetic and praise are never touched. */
|
|
1935
|
+
const DOWNGRADE = Object.freeze({ blocker: 'major', major: 'minor', minor: 'cosmetic' });
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Whether a text names a control as a whole word, ignoring case: "Play"
|
|
1939
|
+
* matches "press Play" and never "Players".
|
|
1940
|
+
*
|
|
1941
|
+
* @param {string} text
|
|
1942
|
+
* @param {string} name
|
|
1943
|
+
* @returns {boolean}
|
|
1944
|
+
*/
|
|
1945
|
+
export function namesControl(text, name) {
|
|
1946
|
+
const value = String(name || '').trim();
|
|
1947
|
+
if (value.length < 3) return false;
|
|
1948
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1949
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'iu').test(String(text || ''));
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
/**
|
|
1953
|
+
* Downgrade, never drop, a finding the action log contradicts. All of these
|
|
1954
|
+
* have to hold, or the severity is left alone:
|
|
1955
|
+
*
|
|
1956
|
+
* - its title or description is in the "nothing happens" family ("did
|
|
1957
|
+
* nothing", "does nothing", "never kicked off", "never started", "no
|
|
1958
|
+
* response", "nothing happened") and names a control, whole word, of at
|
|
1959
|
+
* least three characters;
|
|
1960
|
+
* - the most recent click on that control changed the screen, and the screen
|
|
1961
|
+
* after it gained no alert, dialog or error wording;
|
|
1962
|
+
* - that click happened at or after the latest observation the finding cites
|
|
1963
|
+
* (a finding citing no observation is left alone).
|
|
1964
|
+
*
|
|
1965
|
+
* The Texas round's report said the season was never kicked off, with the
|
|
1966
|
+
* click on KICK OFF THE SEASON and a changed screen in its own transcript.
|
|
1967
|
+
*
|
|
1968
|
+
* @param {Object} report
|
|
1969
|
+
* @param {Array<Object>} log from {@link actionLogFrom}
|
|
1970
|
+
* @returns {{report: Object, downgraded: Array<{id: string, from: string, to: string, control: string, action: number, note: string}>}}
|
|
1971
|
+
*/
|
|
1972
|
+
export function downgradeContradictedFindings(report, log) {
|
|
1973
|
+
if (!report || !Array.isArray(report.findings) || !Array.isArray(log) || log.length === 0) return { report, downgraded: [] };
|
|
1974
|
+
const clicks = log.filter(entry => entry.kind === 'click' && entry.name && entry.name.trim().length >= 3);
|
|
1975
|
+
if (clicks.length === 0) return { report, downgraded: [] };
|
|
1976
|
+
const downgraded = [];
|
|
1977
|
+
const findings = report.findings.map(finding => {
|
|
1978
|
+
if (!finding || !DOWNGRADE[finding.severity]) return finding;
|
|
1979
|
+
const text = `${finding.title || ''}\n${finding.description || ''}`;
|
|
1980
|
+
if (!NOTHING_HAPPENED.test(text)) return finding;
|
|
1981
|
+
const cited = (Array.isArray(finding.evidence) ? finding.evidence : [])
|
|
1982
|
+
.map(item => (item && item.type === 'transcript_step' ? String(item.ref || '').match(/^\s*observation\s*#?\s*(\d+)\s*$/i) : null))
|
|
1983
|
+
.filter(Boolean)
|
|
1984
|
+
.map(match => Number(match[1]));
|
|
1985
|
+
if (cited.length === 0) return finding;
|
|
1986
|
+
const citedLatest = Math.max(...cited);
|
|
1987
|
+
// The controls the finding names, each judged by its most recent click.
|
|
1988
|
+
const named = [...new Set(clicks.map(entry => entry.name.trim().toLowerCase()))]
|
|
1989
|
+
.filter(name => namesControl(text, name));
|
|
1990
|
+
let hit = null;
|
|
1991
|
+
for (const name of named) {
|
|
1992
|
+
const latest = clicks.filter(entry => entry.name.trim().toLowerCase() === name).at(-1);
|
|
1993
|
+
if (!latest.changed || latest.introducedError) continue;
|
|
1994
|
+
if (!Number.isInteger(latest.observationBefore) || latest.observationBefore < citedLatest) continue;
|
|
1995
|
+
hit = latest;
|
|
1996
|
+
break;
|
|
1997
|
+
}
|
|
1998
|
+
if (!hit) return finding;
|
|
1999
|
+
const to = DOWNGRADE[finding.severity];
|
|
2000
|
+
const note = `the action log shows ${hit.name.trim()} clicked at step ${hit.action} with the screen changing`;
|
|
2001
|
+
const description = String(finding.description || '');
|
|
2002
|
+
downgraded.push({ id: finding.id, from: finding.severity, to, control: hit.name.trim(), action: hit.action, note });
|
|
2003
|
+
return { ...finding, severity: to, description: description ? `${description}\n\nDowngraded by the runner: ${note}.` : `Downgraded by the runner: ${note}.` };
|
|
2004
|
+
});
|
|
2005
|
+
return { report: downgraded.length > 0 ? { ...report, findings } : report, downgraded };
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/** How report.md lists the findings the runner downgraded. */
|
|
2009
|
+
export const DOWNGRADED_HEADING = '## Downgraded by the runner (the action log or its own screenshot contradicts the claim)';
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* report.md agreeing with report.json about downgraded findings: on every
|
|
2013
|
+
* line that names a downgraded finding's id, its old severity word becomes
|
|
2014
|
+
* the new one, and a section lists each downgrade with the runner's note,
|
|
2015
|
+
* replacing one written before.
|
|
2016
|
+
*
|
|
2017
|
+
* @param {string} markdown
|
|
2018
|
+
* @param {Array<{id: string, from: string, to: string, note: string}>} downgraded
|
|
2019
|
+
* @returns {string}
|
|
2020
|
+
*/
|
|
2021
|
+
export function rewriteDowngradedMarkdown(markdown, downgraded) {
|
|
2022
|
+
if (!Array.isArray(downgraded) || downgraded.length === 0) return markdown;
|
|
2023
|
+
const text = String(markdown || '');
|
|
2024
|
+
const at = text.indexOf(`\n${DOWNGRADED_HEADING}`);
|
|
2025
|
+
let body = (at === -1 ? (text.startsWith(DOWNGRADED_HEADING) ? '' : text) : text.slice(0, at)).replace(/\s+$/, '');
|
|
2026
|
+
const recase = (word, to) => (word === word.toUpperCase() ? to.toUpperCase() : word[0] === word[0].toUpperCase() ? to[0].toUpperCase() + to.slice(1) : to);
|
|
2027
|
+
const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2028
|
+
const namesId = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escape(id)}(?![0-9])`).test(line);
|
|
2029
|
+
// The finding a "Severity:" line belongs to: the one named by the nearest
|
|
2030
|
+
// heading or list item above it. A heading naming none ends it.
|
|
2031
|
+
let current = null;
|
|
2032
|
+
body = body.split('\n').map(line => {
|
|
2033
|
+
let out = line;
|
|
2034
|
+
const named = downgraded.find(entry => entry.id && namesId(line, String(entry.id)));
|
|
2035
|
+
if (named) current = named;
|
|
2036
|
+
else if (/^#{1,6} /.test(line) || /^\s*(?:[-*+]|\d+[.)])\s+\S*\b[A-Z]\d{2,}\b/.test(line)) current = null;
|
|
2037
|
+
for (const entry of downgraded) {
|
|
2038
|
+
const id = String(entry.id || '');
|
|
2039
|
+
if (!id) continue;
|
|
2040
|
+
// Only the severity slot the writer labels: right after the id in
|
|
2041
|
+
// parentheses or brackets ("F001 (major)", "**F001 [Major, bug]**"), or
|
|
2042
|
+
// after "Severity:" within that finding. A title that happens to
|
|
2043
|
+
// contain the word stays as written.
|
|
2044
|
+
if (namesId(out, id)) {
|
|
2045
|
+
out = out.replace(new RegExp(`(${escape(id)}\\**\\s*[([]\\s*)(${entry.from})\\b`, 'i'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
|
|
2046
|
+
}
|
|
2047
|
+
if (current === entry) {
|
|
2048
|
+
out = out.replace(new RegExp(`(severity\\**\\s*:\\s*\\**\\s*)(${entry.from})\\b`, 'gi'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
return out;
|
|
2052
|
+
}).join('\n');
|
|
2053
|
+
const section = [
|
|
2054
|
+
DOWNGRADED_HEADING,
|
|
2055
|
+
...downgraded.map(entry => `- ${entry.id}: ${entry.from} to ${entry.to}, because ${entry.note}.`)
|
|
2056
|
+
].join('\n');
|
|
2057
|
+
return `${body}\n\n${section}\n`;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1428
2060
|
export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
|
|
1429
2061
|
if (!report || !Array.isArray(report.findings)) return report;
|
|
1430
2062
|
const byShot = new Map();
|