@ggui-ai/ui-gen 0.20.0 → 0.22.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/dist/adapters/base.d.ts +7 -0
- package/dist/adapters/base.js +1 -0
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/generation-dispatch.d.ts +17 -3
- package/dist/adapters/generation-dispatch.js +1078 -3464
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +60 -3
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +4 -4
- package/dist/advanced/index.js +1346 -3557
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +10 -3
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.js +2 -2
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +1 -1
- package/dist/check/index.js.map +1 -1
- package/dist/{create-ui-generator-CxPvudbs.d.ts → create-ui-generator-BEkzEtNz.d.ts} +2 -2
- package/dist/{dispatch-N8wdNOX-.d.ts → dispatch-DJNpQ7DI.d.ts} +1 -1
- package/dist/evaluation/axis-checks/index.d.ts +3 -3
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +1 -1
- package/dist/evaluation/index.d.ts +38 -6
- package/dist/evaluation/index.js +670 -3374
- package/dist/evaluation/index.js.map +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +2 -2
- package/dist/harness/check/runtime-render/index.js +10 -3
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/check/runtime-render/render-check-worker.js +5 -3
- package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
- package/dist/harness/index.d.ts +2 -2
- package/dist/harness/index.js +587 -30
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/llm-trace-sink.d.ts +7 -1
- package/dist/harness/llm-trace-sink.js.map +1 -1
- package/dist/harness/prompts.js +1 -1
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +15 -2
- package/dist/harness/result-types.js.map +1 -1
- package/dist/harness/runtime.d.ts +11 -2
- package/dist/harness/runtime.js +3 -3
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +1 -1
- package/dist/harness/types-public.js +2 -2
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +1 -1
- package/dist/index.d.ts +23 -7
- package/dist/index.js +1346 -3557
- package/dist/index.js.map +1 -1
- package/dist/{llm-router-D7fjBhtr.d.ts → llm-router-DwwUK1bz.d.ts} +16 -3
- package/dist/patch.d.ts +1 -1
- package/dist/providers/index.js +5 -4
- package/dist/providers/index.js.map +1 -1
- package/dist/{render-check-WfOVO9kU.d.ts → render-check-C0CqVceF.d.ts} +17 -0
- package/dist/{types-public-DKR_h70H.d.ts → types-public-mM_-1Yk3.d.ts} +59 -1
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/index.js.map +1 -1
- package/dist/workflows.d.ts +1 -1
- package/package.json +16 -12
- package/src/adapters/base.ts +8 -0
- package/src/adapters/claude/claude-code-login.ts +128 -0
- package/src/adapters/claude/sdk.ts +9 -1
- package/src/adapters/generation-dispatch.ts +19 -2
- package/src/adapters/openai/tokens.ts +18 -0
- package/src/adapters/provider-router.ts +28 -0
- package/src/boilerplate/generate.ts +19 -4
- package/src/boilerplate/hard-sections.ts +1 -1
- package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +7 -3
- package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +7 -3
- package/src/contract-context.ts +1 -1
- package/src/create-ui-generator.ts +49 -10
- package/src/evaluation/design-identity.ts +16 -5
- package/src/evaluation/index.ts +1 -0
- package/src/evaluation/llm-evaluator.ts +40 -13
- package/src/evaluation/types-public.ts +58 -0
- package/src/evaluation/visual-evaluator.ts +240 -30
- package/src/generator-build.ts +158 -0
- package/src/harness/check/runtime-render/adapter.ts +9 -0
- package/src/harness/check/runtime-render/render-check.ts +26 -5
- package/src/harness/claude-code-login-query.ts +529 -0
- package/src/harness/coding/assemble-result.ts +18 -2
- package/src/harness/coding/generate-task-runner.ts +75 -36
- package/src/harness/coding/run-coding-turn.ts +9 -0
- package/src/harness/coding/run-eval-round.ts +338 -38
- package/src/harness/enforced-coding.ts +20 -0
- package/src/harness/llm-router.ts +390 -55
- package/src/harness/llm-trace-sink.ts +7 -1
- package/src/harness/result-types.ts +14 -0
- package/src/harness/runtime.ts +6 -0
- package/src/index.ts +2 -1
- package/src/providers/openai.ts +31 -19
- package/src/validation/primitives.ts +1 -1
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
} from './types-public.js';
|
|
26
26
|
import { LLM_EVAL_STATIC_CRITERIA } from './types-public.js';
|
|
27
27
|
import { createAgent } from '../harness/llm-router';
|
|
28
|
-
import type { AgentConfig, LLMToolDef, LLMAgent } from '../harness/llm-router';
|
|
28
|
+
import type { AgentConfig, LLMToolDef, LLMAgent, LLMToolCallResponse } from '../harness/llm-router';
|
|
29
29
|
import type { JsonObject } from '@ggui-ai/protocol';
|
|
30
30
|
import { getCriterionById } from './types-public.js';
|
|
31
31
|
import { DEFAULT_DESIGN_MODE, type DesignMode } from '../design-mode.js';
|
|
@@ -596,10 +596,36 @@ interface CriterionResult {
|
|
|
596
596
|
coverage: CriterionRunStatus;
|
|
597
597
|
/** Populated for `skipped` — why the criterion produced no verdict. */
|
|
598
598
|
skipReason?: string;
|
|
599
|
-
|
|
599
|
+
/** This call's token accounting. The cache counts are present only when the
|
|
600
|
+
* provider reported them — absent means unreported, never zero (ggui#1281). */
|
|
601
|
+
tokens: { in: number; out: number; cacheRead?: number; cacheCreation?: number };
|
|
600
602
|
durationMs: number;
|
|
601
603
|
}
|
|
602
604
|
|
|
605
|
+
/** A call's token accounting, carrying the prompt-cache counts only when the
|
|
606
|
+
* provider reported them (the router's rule: absent is "unreported"). */
|
|
607
|
+
function callTokens(response: LLMToolCallResponse): CriterionResult['tokens'] {
|
|
608
|
+
return {
|
|
609
|
+
in: response.inputTokens,
|
|
610
|
+
out: response.outputTokens,
|
|
611
|
+
...(response.cacheReadTokens !== undefined ? { cacheRead: response.cacheReadTokens } : {}),
|
|
612
|
+
...(response.cacheCreationTokens !== undefined ? { cacheCreation: response.cacheCreationTokens } : {}),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The evaluation's summed token accounting. `inputTokens` is the NON-cached
|
|
618
|
+
* input (the router's convention); the cache counts are the sums of what the
|
|
619
|
+
* calls reported, and stay absent when no call reported one — so a caller can
|
|
620
|
+
* tell "no cache reads" from "the provider does not say" (ggui#1281).
|
|
621
|
+
*/
|
|
622
|
+
export interface LLMEvalTokenUsage {
|
|
623
|
+
inputTokens: number;
|
|
624
|
+
outputTokens: number;
|
|
625
|
+
cacheReadTokens?: number;
|
|
626
|
+
cacheCreationTokens?: number;
|
|
627
|
+
}
|
|
628
|
+
|
|
603
629
|
/**
|
|
604
630
|
* All valid categories for LLM evaluation — derived from the ONE static
|
|
605
631
|
* list in types-public so the coverage stamp here and the harness's
|
|
@@ -642,7 +668,7 @@ async function evalCriterion(
|
|
|
642
668
|
pass: true,
|
|
643
669
|
coverage: 'skipped',
|
|
644
670
|
skipReason: 'no tool call returned',
|
|
645
|
-
tokens:
|
|
671
|
+
tokens: callTokens(response),
|
|
646
672
|
durationMs,
|
|
647
673
|
};
|
|
648
674
|
}
|
|
@@ -700,7 +726,7 @@ async function evalCriterion(
|
|
|
700
726
|
issues,
|
|
701
727
|
pass,
|
|
702
728
|
coverage: 'ran',
|
|
703
|
-
tokens:
|
|
729
|
+
tokens: callTokens(response),
|
|
704
730
|
durationMs,
|
|
705
731
|
};
|
|
706
732
|
} catch (e) {
|
|
@@ -833,7 +859,7 @@ export async function runLLMEvaluation(
|
|
|
833
859
|
context: LLMEvalContext,
|
|
834
860
|
config: LLMEvalConfig,
|
|
835
861
|
preWarmedContext?: PreWarmedEvalContext | null,
|
|
836
|
-
): Promise<EvalResult &
|
|
862
|
+
): Promise<EvalResult & LLMEvalTokenUsage> {
|
|
837
863
|
const providerName = config.provider;
|
|
838
864
|
const model = config.model || getDefaultEvalModel(providerName);
|
|
839
865
|
const routerProvider =
|
|
@@ -887,7 +913,10 @@ export async function runLLMEvaluation(
|
|
|
887
913
|
const passCategories: string[] = [];
|
|
888
914
|
let totalIn = 0;
|
|
889
915
|
let totalOut = 0;
|
|
890
|
-
|
|
916
|
+
// Measured, not estimated: summed from what the calls reported; absent
|
|
917
|
+
// while no call has reported one (ggui#1281).
|
|
918
|
+
let cacheRead: number | undefined;
|
|
919
|
+
let cacheCreation: number | undefined;
|
|
891
920
|
|
|
892
921
|
for (const r of results) {
|
|
893
922
|
allIssues.push(...r.issues);
|
|
@@ -896,12 +925,8 @@ export async function runLLMEvaluation(
|
|
|
896
925
|
}
|
|
897
926
|
totalIn += r.tokens.in;
|
|
898
927
|
totalOut += r.tokens.out;
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
// Estimate cached tokens (mother prompt sent N times, cached N-1 times)
|
|
902
|
-
const firstResult = results[0];
|
|
903
|
-
if (firstResult && firstResult.tokens.in > 0) {
|
|
904
|
-
cachedTokensEstimate = firstResult.tokens.in * (totalCriteriaCount - 1);
|
|
928
|
+
if (r.tokens.cacheRead !== undefined) cacheRead = (cacheRead ?? 0) + r.tokens.cacheRead;
|
|
929
|
+
if (r.tokens.cacheCreation !== undefined) cacheCreation = (cacheCreation ?? 0) + r.tokens.cacheCreation;
|
|
905
930
|
}
|
|
906
931
|
|
|
907
932
|
const wallMs = Date.now() - evalStart;
|
|
@@ -928,7 +953,7 @@ export async function runLLMEvaluation(
|
|
|
928
953
|
}
|
|
929
954
|
console.log(
|
|
930
955
|
`[eval] total: ${(wallMs / 1000).toFixed(1)}s wall (${totalCriteriaCount} parallel) | ` +
|
|
931
|
-
`in=${totalIn} out=${totalOut}
|
|
956
|
+
`in=${totalIn} out=${totalOut} cacheRead=${cacheRead ?? 'unreported'} cacheWrite=${cacheCreation ?? 'unreported'} | ` +
|
|
932
957
|
`issues=${allIssues.length} pass=${passCategories.length}` +
|
|
933
958
|
(skippedCount > 0 ? ` skipped=${skippedCount}` : ''),
|
|
934
959
|
);
|
|
@@ -939,5 +964,7 @@ export async function runLLMEvaluation(
|
|
|
939
964
|
criteriaCoverage,
|
|
940
965
|
inputTokens: totalIn,
|
|
941
966
|
outputTokens: totalOut,
|
|
967
|
+
...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}),
|
|
968
|
+
...(cacheCreation !== undefined ? { cacheCreationTokens: cacheCreation } : {}),
|
|
942
969
|
};
|
|
943
970
|
}
|
|
@@ -123,6 +123,24 @@ export interface CriterionCoverage {
|
|
|
123
123
|
readonly reason?: string;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Coverage of the screenshot JUDGE leg for one eval round (ggui#1221) — a
|
|
128
|
+
* different surface from the `visual` CRITERION in `criteriaCoverage` (the
|
|
129
|
+
* LLM evaluator's read of the source). `ran` = a frame was captured and
|
|
130
|
+
* judged; `skipped` = the leg was configured but produced no verdict
|
|
131
|
+
* (no browser, a launch or in-page failure, an unbundlable component, an
|
|
132
|
+
* unparsable judge answer) with `reason` naming why; `not-applicable` =
|
|
133
|
+
* the round had no visual leg configured. Before this row a skipped leg
|
|
134
|
+
* left NO trace on the result — `{ issues: [] }` read exactly like a
|
|
135
|
+
* clean run, and every bootstrap mint on a browserless runtime "passed"
|
|
136
|
+
* its visual round that way.
|
|
137
|
+
*/
|
|
138
|
+
export interface VisualCoverage {
|
|
139
|
+
readonly status: CriterionRunStatus;
|
|
140
|
+
/** Populated for `skipped` / `not-applicable` — why no verdict. */
|
|
141
|
+
readonly reason?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
/**
|
|
127
145
|
* The static criteria `runLLMEvaluation` runs, in stamp order — the
|
|
128
146
|
* ONE list both the evaluator's coverage stamp and the harness's
|
|
@@ -157,6 +175,14 @@ export function notApplicableCoverage(reason: string): CriterionCoverage[] {
|
|
|
157
175
|
}));
|
|
158
176
|
}
|
|
159
177
|
|
|
178
|
+
/** One contract-feedback round (ggui#1261): what bought it, and the source it was fed back on. */
|
|
179
|
+
export interface ContractFeedbackRecord {
|
|
180
|
+
/** The exit-probe issue subcategories that bought the round, e.g. `runtime:prop-sensitivity:currentUser`. */
|
|
181
|
+
readonly firedOn: readonly string[];
|
|
182
|
+
/** The component source as it stood when the model was told — the round's BEFORE. */
|
|
183
|
+
readonly sourceBefore: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
160
186
|
export interface EvalResult {
|
|
161
187
|
issues: EvalIssue[];
|
|
162
188
|
pass: string[];
|
|
@@ -176,6 +202,14 @@ export interface EvalResult {
|
|
|
176
202
|
* probe runner; absent on eval paths that never invoke the probe.
|
|
177
203
|
*/
|
|
178
204
|
runtimeProbe?: RuntimeProbeMeta;
|
|
205
|
+
/**
|
|
206
|
+
* The contract-feedback round, when one fired this generation (ggui#1261):
|
|
207
|
+
* which exit-probe findings bought it and the source as it stood when the
|
|
208
|
+
* model was told. `runtimeProbe` and the `runtime:*` issues on this result
|
|
209
|
+
* are the probe's verdict AFTER that round; `sourceBefore` beside the final
|
|
210
|
+
* source is the round's before/after. Absent when no such round fired.
|
|
211
|
+
*/
|
|
212
|
+
contractFeedback?: ContractFeedbackRecord;
|
|
179
213
|
/**
|
|
180
214
|
* Per-canvas visual verdicts. Stamped by the harness ONLY when the
|
|
181
215
|
* visual leg ran with `visualEvaluation.canvases` set (arm-neutral —
|
|
@@ -185,6 +219,13 @@ export interface EvalResult {
|
|
|
185
219
|
* never here — this object is serialized into reports.
|
|
186
220
|
*/
|
|
187
221
|
visual?: VisualEvalSummary;
|
|
222
|
+
/**
|
|
223
|
+
* Whether the screenshot judge leg ran, was skipped (with the reason) or
|
|
224
|
+
* was not configured — ALWAYS stamped by the harness's eval round
|
|
225
|
+
* (ggui#1221), so a consumer can tell a clean visual run from a leg
|
|
226
|
+
* that silently could not run. See {@link VisualCoverage}.
|
|
227
|
+
*/
|
|
228
|
+
visualCoverage?: VisualCoverage;
|
|
188
229
|
}
|
|
189
230
|
|
|
190
231
|
/** One canvas's visual verdict, PNG-free — see `EvalResult.visual`. */
|
|
@@ -227,6 +268,21 @@ export interface CanvasVisualSummary {
|
|
|
227
268
|
fit?: 'fill';
|
|
228
269
|
}
|
|
229
270
|
|
|
271
|
+
/**
|
|
272
|
+
* ggui#1195 — the fit reading on the canvas whose policy FAILS an overflow
|
|
273
|
+
* (the inline chat card): the box it was judged at (`ceiling` — the order's
|
|
274
|
+
* declared viewport when one was carried, else the class box), whether that
|
|
275
|
+
* box was declared, and how many px of content sit below it (0 = fits).
|
|
276
|
+
* Present only when such a canvas was judged with a measurable height, so
|
|
277
|
+
* the bar and the ledger read one number for "does the hello fit".
|
|
278
|
+
*/
|
|
279
|
+
export interface VisualFitStamp {
|
|
280
|
+
readonly canvas: CanvasClass;
|
|
281
|
+
readonly ceiling: { readonly width: number; readonly height: number };
|
|
282
|
+
readonly declared: boolean;
|
|
283
|
+
readonly overflowPx: number;
|
|
284
|
+
}
|
|
285
|
+
|
|
230
286
|
/** The visual leg's per-canvas summary — see `EvalResult.visual`. */
|
|
231
287
|
export interface VisualEvalSummary {
|
|
232
288
|
/** Mean of the canvas scores (rounded). */
|
|
@@ -234,6 +290,8 @@ export interface VisualEvalSummary {
|
|
|
234
290
|
/** Every canvas passed. */
|
|
235
291
|
passed: boolean;
|
|
236
292
|
canvases: CanvasVisualSummary[];
|
|
293
|
+
/** ggui#1195 — see {@link VisualFitStamp}; absent when no fail-policy canvas was judged. */
|
|
294
|
+
fit?: VisualFitStamp;
|
|
237
295
|
/** The design tree the judge painted with (ggui#1042): `src` + `srcSha256` — `design@judge`. */
|
|
238
296
|
design?: { readonly src: string; readonly srcSha256: string };
|
|
239
297
|
/** The mode the judge's tokens were composed in, when the caller said (ggui#1076). */
|
|
@@ -19,12 +19,12 @@ import { judgeDesignIdentity, type JudgeDesignIdentity } from './design-identity
|
|
|
19
19
|
import { resolve, dirname } from 'path';
|
|
20
20
|
import { fileURLToPath } from 'url';
|
|
21
21
|
import { createRequire } from 'node:module';
|
|
22
|
-
import { existsSync, writeFileSync,
|
|
22
|
+
import { existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
23
23
|
import { createHash } from 'node:crypto';
|
|
24
24
|
import { tmpdir } from 'os';
|
|
25
25
|
import { createVisionAgent, type AgentConfig } from '../harness/llm-router';
|
|
26
26
|
import type { EvaluationResult, EvaluationIssue, DimensionScores } from './types';
|
|
27
|
-
import type { CanvasJudgeRecord, CanvasVisualSummary, EvalIssue, VisualEvalSummary } from './types-public.js';
|
|
27
|
+
import type { CanvasJudgeRecord, CanvasVisualSummary, EvalIssue, VisualCoverage, VisualEvalSummary, VisualFitStamp } from './types-public.js';
|
|
28
28
|
import type { LaunchOptions } from 'puppeteer-core';
|
|
29
29
|
import { CANVAS_VIEWPORTS, displayModeForCanvas, type CanvasClass, type CanvasViewport } from '../design-mode.js';
|
|
30
30
|
|
|
@@ -55,6 +55,20 @@ export interface VisualEvalConfig {
|
|
|
55
55
|
* single screenshot at `viewport`; no `canvases` field on the result.
|
|
56
56
|
*/
|
|
57
57
|
canvases?: readonly CanvasClass[];
|
|
58
|
+
/**
|
|
59
|
+
* ggui#1195 — the box a canvas is COMPOSED for and CAPTURED at when the
|
|
60
|
+
* order declared one (the chat card at the visitor's real column, not the
|
|
61
|
+
* class's 400×640). Keyed by canvas; a canvas without an entry is judged
|
|
62
|
+
* at `CANVAS_VIEWPORTS[canvas]` as before. The composer's
|
|
63
|
+
* `rendering.viewport` and this entry are ONE value written once (the
|
|
64
|
+
* seam derives it), so the judge never scores a box the mint did not
|
|
65
|
+
* target. Only read in per-canvas mode. Captured at WHOLE CSS px: the
|
|
66
|
+
* browser's device-metrics override takes integers (measured — a 383.5 px
|
|
67
|
+
* width is refused by CDP), so a fractional declared box is rounded once
|
|
68
|
+
* where it enters and the capture, the ceiling and the reported
|
|
69
|
+
* `viewport` all carry that same integer box.
|
|
70
|
+
*/
|
|
71
|
+
canvasViewports?: Partial<Readonly<Record<CanvasClass, CanvasViewport>>>;
|
|
58
72
|
/**
|
|
59
73
|
* Optional provider-routing override — see
|
|
60
74
|
* `AgentConfig.routeOverride`. Threaded onto the agent this
|
|
@@ -110,6 +124,8 @@ export interface CanvasVisualResult {
|
|
|
110
124
|
judge: CanvasJudgeRecord;
|
|
111
125
|
/** How the judge composed the mount (ggui#1100): `'fill'` on every fullscreen canvas, absent on the inline card. */
|
|
112
126
|
fit?: 'fill';
|
|
127
|
+
/** ggui#1195 — `true` when the order DECLARED this canvas's box (`config.canvasViewports` carried an entry), whatever its value. */
|
|
128
|
+
declared?: true;
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
/** How a canvas is captured for the judge and what an overflow means there (ggui#1027). */
|
|
@@ -158,6 +174,23 @@ export function canvasFit(canvas: CanvasClass): 'fill' | undefined {
|
|
|
158
174
|
return displayModeForCanvas(canvas) === 'fullscreen' ? 'fill' : undefined;
|
|
159
175
|
}
|
|
160
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The box a browser can be asked for: whole CSS px. CDP's
|
|
179
|
+
* `Emulation.setDeviceMetricsOverride` deserialises width/height as int32 and
|
|
180
|
+
* refuses a fraction (measured on Chrome: `383.5` → "int32 value expected"),
|
|
181
|
+
* while a host measuring its chat column with `getBoundingClientRect()` may
|
|
182
|
+
* legitimately declare one. Rounded once, at the judge's boundary.
|
|
183
|
+
*/
|
|
184
|
+
export function integerBox(viewport: { width: number; height: number }): { width: number; height: number } {
|
|
185
|
+
return { width: Math.round(viewport.width), height: Math.round(viewport.height) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** ggui#1195 — `true` when a canvas was judged at a box other than its class viewport (the order declared one). */
|
|
189
|
+
export function isDeclaredViewport(canvas: CanvasClass, viewport: CanvasViewport): boolean {
|
|
190
|
+
const classBox = CANVAS_VIEWPORTS[canvas];
|
|
191
|
+
return viewport.width !== classBox.width || viewport.height !== classBox.height;
|
|
192
|
+
}
|
|
193
|
+
|
|
161
194
|
/** The deterministic fit issue — one per overflowing canvas, in the judge's issue shape so it rides the same channel. */
|
|
162
195
|
export function canvasOverflowIssue(
|
|
163
196
|
canvas: CanvasClass,
|
|
@@ -166,11 +199,18 @@ export function canvasOverflowIssue(
|
|
|
166
199
|
verdict: 'fail' | 'warn',
|
|
167
200
|
): EvaluationIssue {
|
|
168
201
|
const hidden = contentHeight - viewport.height;
|
|
202
|
+
// ggui#1195 — when the judge captured at a DECLARED box, the refusal names
|
|
203
|
+
// both boxes: the ceiling the content was measured against and the class
|
|
204
|
+
// box a reader would otherwise assume.
|
|
205
|
+
const classBox = CANVAS_VIEWPORTS[canvas];
|
|
206
|
+
const box = isDeclaredViewport(canvas, viewport)
|
|
207
|
+
? `declared ${viewport.width}×${viewport.height}; class box ${classBox.width}×${classBox.height}`
|
|
208
|
+
: `${viewport.width}×${viewport.height}`;
|
|
169
209
|
return {
|
|
170
210
|
dimension: 'canvas-overflow',
|
|
171
211
|
severity: verdict === 'fail' ? 'critical' : 'major',
|
|
172
212
|
description:
|
|
173
|
-
`Rendered content is ${contentHeight}px tall on the ${canvas} canvas (${
|
|
213
|
+
`Rendered content is ${contentHeight}px tall on the ${canvas} canvas (${box}) — ` +
|
|
174
214
|
`${hidden}px ${verdict === 'fail' ? 'is cut off: the inline card does not scroll' : 'sits below the first screen'}.`,
|
|
175
215
|
fix:
|
|
176
216
|
'Fit the composition to the canvas: fewer and shorter sections, one compact row of chips, no hero taller than ' +
|
|
@@ -306,9 +346,12 @@ async function bundleForRendering(
|
|
|
306
346
|
sampleProps: Record<string, unknown>,
|
|
307
347
|
designSrc: string = resolve(resolveDesignPackageDir(), 'src'),
|
|
308
348
|
): Promise<string> {
|
|
309
|
-
// Write component + entry to temp files for esbuild
|
|
310
|
-
|
|
311
|
-
|
|
349
|
+
// Write component + entry to temp files for esbuild — in a directory of THIS
|
|
350
|
+
// call's own. `mkdtempSync` is atomic and unique: a timestamp-named directory
|
|
351
|
+
// was shared by two bundles started in the same millisecond, so the first
|
|
352
|
+
// build read the second's component (a judge scoring the wrong generation)
|
|
353
|
+
// and the first's cleanup deleted the files the second was building.
|
|
354
|
+
const tmpDir = mkdtempSync(resolve(tmpdir(), 'ggui-visual-eval-'));
|
|
312
355
|
const componentFile = resolve(tmpDir, 'component.tsx');
|
|
313
356
|
const entryFile = resolve(tmpDir, 'entry.tsx');
|
|
314
357
|
|
|
@@ -399,8 +442,8 @@ try {
|
|
|
399
442
|
|
|
400
443
|
return result.outputFiles[0]?.text ?? '';
|
|
401
444
|
} finally {
|
|
402
|
-
|
|
403
|
-
|
|
445
|
+
// The whole per-call directory; `force` makes an already-absent path a no-op.
|
|
446
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
404
447
|
}
|
|
405
448
|
}
|
|
406
449
|
|
|
@@ -427,7 +470,10 @@ function buildRenderHTML(
|
|
|
427
470
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
428
471
|
body {
|
|
429
472
|
font-family: var(--ggui-font-family-sans, system-ui, -apple-system, sans-serif);
|
|
430
|
-
|
|
473
|
+
/* ggui#1083 — the host stand-in (neutral-50) under the theme's scrim (its tint at
|
|
474
|
+
its opacity): the ground a frosted host puts under the card, so the judge scores
|
|
475
|
+
the card where the visitor sees it. */
|
|
476
|
+
background: color-mix(in oklch, var(--ggui-scrim-tint, var(--ggui-color-ground, #ffffff)) calc(var(--ggui-scrim-opacity, 0.45) * 100%), var(--ggui-color-neutral-50, #ffffff));
|
|
431
477
|
color: var(--ggui-color-neutral-900, #111827);
|
|
432
478
|
}
|
|
433
479
|
.error { color: #dc2626; padding: 16px; font-family: monospace; white-space: pre-wrap; }
|
|
@@ -523,18 +569,20 @@ export async function resolveLaunchOptions(
|
|
|
523
569
|
): Promise<LaunchOptions> {
|
|
524
570
|
const env = deps.env ?? process.env;
|
|
525
571
|
const override = env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
|
572
|
+
// Whole px at the one place `defaultViewport` is built — the single-shot path takes the caller's box verbatim.
|
|
573
|
+
const defaultViewport = integerBox(viewport);
|
|
526
574
|
if (override) {
|
|
527
575
|
return {
|
|
528
576
|
executablePath: override,
|
|
529
577
|
args: [...EXECUTABLE_PATH_LAUNCH_ARGS],
|
|
530
|
-
defaultViewport
|
|
578
|
+
defaultViewport,
|
|
531
579
|
headless: true,
|
|
532
580
|
};
|
|
533
581
|
}
|
|
534
582
|
const chromium = await (deps.loadChromium ?? loadSparticuzChromium)();
|
|
535
583
|
return {
|
|
536
584
|
args: chromium.args,
|
|
537
|
-
defaultViewport
|
|
585
|
+
defaultViewport,
|
|
538
586
|
executablePath: await chromium.executablePath(),
|
|
539
587
|
headless: true,
|
|
540
588
|
};
|
|
@@ -742,6 +790,51 @@ export interface VisualEvalDetailed {
|
|
|
742
790
|
}
|
|
743
791
|
|
|
744
792
|
|
|
793
|
+
/**
|
|
794
|
+
* One canvas, framed: the box it is captured at (ggui#1195 — the DECLARED box
|
|
795
|
+
* when the order carried one, else the class box), the canvas's fit policy,
|
|
796
|
+
* the runtime's fit for the mount (ggui#1100 — fullscreen canvases fill, the
|
|
797
|
+
* inline card does not) and the capture. The judge path and the fit-only path
|
|
798
|
+
* ({@link runVisualFit}) frame through this ONE function, so a fit verdict
|
|
799
|
+
* reached without the judge is taken on the frame the judge would have seen.
|
|
800
|
+
*/
|
|
801
|
+
interface CanvasFrame {
|
|
802
|
+
readonly viewport: CanvasViewport;
|
|
803
|
+
readonly policy: CanvasFitPolicy;
|
|
804
|
+
readonly fit: 'fill' | undefined;
|
|
805
|
+
/** `true` when `canvasViewports` carried an entry for this canvas, whatever its value. */
|
|
806
|
+
readonly declared: boolean;
|
|
807
|
+
readonly attempt: ScreenshotAttempt;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function frameCanvas(
|
|
811
|
+
bundledCode: string,
|
|
812
|
+
context: VisualEvalContext,
|
|
813
|
+
html: string,
|
|
814
|
+
canvas: CanvasClass,
|
|
815
|
+
canvasViewports: VisualEvalConfig['canvasViewports'],
|
|
816
|
+
deps: ScreenshotDeps,
|
|
817
|
+
): Promise<CanvasFrame> {
|
|
818
|
+
const declaredBox = canvasViewports?.[canvas];
|
|
819
|
+
const viewport = declaredBox !== undefined ? integerBox(declaredBox) : CANVAS_VIEWPORTS[canvas];
|
|
820
|
+
const policy = canvasFitPolicy(canvas);
|
|
821
|
+
const fit = canvasFit(canvas);
|
|
822
|
+
const canvasHtml = fit !== undefined ? buildRenderHTML(bundledCode, context.cssTokens, fit) : html;
|
|
823
|
+
const attempt = await captureScreenshotDetailed(canvasHtml, viewport, deps, policy.capture);
|
|
824
|
+
return { viewport, policy, fit, declared: declaredBox !== undefined, attempt };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* The deterministic fit verdict on one frame (ggui#1027): the issue the
|
|
829
|
+
* canvas's policy gives an overflow, or `null` when the content fits, could
|
|
830
|
+
* not be measured, or the canvas scrolls by design.
|
|
831
|
+
*/
|
|
832
|
+
function fitVerdict(canvas: CanvasClass, frame: CanvasFrame): EvaluationIssue | null {
|
|
833
|
+
const contentHeight = frame.attempt.contentHeight;
|
|
834
|
+
if (contentHeight === null || contentHeight <= frame.viewport.height || frame.policy.overflow === 'none') return null;
|
|
835
|
+
return canvasOverflowIssue(canvas, frame.viewport, contentHeight, frame.policy.overflow);
|
|
836
|
+
}
|
|
837
|
+
|
|
745
838
|
/** One judge call parsed, retried ONCE on a malformed answer (#1017); the FIRST reason is kept verbatim. */
|
|
746
839
|
type JudgedAnswer =
|
|
747
840
|
| { readonly kind: 'ok'; readonly result: EvaluationResult; readonly inputTokens: number; readonly outputTokens: number }
|
|
@@ -848,12 +941,8 @@ export async function runVisualEvaluationDetailed(
|
|
|
848
941
|
const perCanvas: CanvasVisualResult[] = [];
|
|
849
942
|
const perCanvasResults: EvaluationResult[] = [];
|
|
850
943
|
for (const canvas of config.canvases) {
|
|
851
|
-
const
|
|
852
|
-
const policy =
|
|
853
|
-
// ggui#1100: the page is composed per canvas with the runtime's fit — fullscreen canvases fill, the inline card does not.
|
|
854
|
-
const fit = canvasFit(canvas);
|
|
855
|
-
const canvasHtml = fit !== undefined ? buildRenderHTML(bundledCode, context.cssTokens, fit) : html;
|
|
856
|
-
const attempt = await captureScreenshotDetailed(canvasHtml, viewport, deps, policy.capture);
|
|
944
|
+
const frame = await frameCanvas(bundledCode, context, html, canvas, config.canvasViewports, deps);
|
|
945
|
+
const { viewport, policy, fit, attempt } = frame;
|
|
857
946
|
const screenshot = attempt.png;
|
|
858
947
|
if (!screenshot) {
|
|
859
948
|
const unavailableReason = attempt.reason ?? 'no browser available';
|
|
@@ -890,8 +979,9 @@ export async function runVisualEvaluationDetailed(
|
|
|
890
979
|
// The fit verdict (ggui#1027): deterministic, in the judge's issue channel.
|
|
891
980
|
const contentHeight = attempt.contentHeight;
|
|
892
981
|
const overflow = contentHeight !== null && contentHeight > viewport.height;
|
|
893
|
-
|
|
894
|
-
|
|
982
|
+
const fitIssue = fitVerdict(canvas, frame);
|
|
983
|
+
if (fitIssue !== null) {
|
|
984
|
+
result.issues.push(fitIssue);
|
|
895
985
|
if (policy.overflow === 'fail') result.passed = false;
|
|
896
986
|
}
|
|
897
987
|
perCanvasResults.push(result);
|
|
@@ -905,6 +995,7 @@ export async function runVisualEvaluationDetailed(
|
|
|
905
995
|
overflow,
|
|
906
996
|
judge: judgeRecord,
|
|
907
997
|
...(fit !== undefined ? { fit } : {}),
|
|
998
|
+
...(frame.declared ? { declared: true as const } : {}),
|
|
908
999
|
});
|
|
909
1000
|
console.log(
|
|
910
1001
|
`[visual-eval] canvas=${canvas} ${viewport.width}×${viewport.height} score=${result.finalScore}${k > 1 ? ` (median of ${judgeRecord.samples.length}/${k}: ${judgeRecord.samples.join(',')} σ=${judgeRecord.sigma})` : ''} ` +
|
|
@@ -960,6 +1051,11 @@ export async function runVisualEvaluationDetailed(
|
|
|
960
1051
|
* per-dimension means, `issues` = union (description prefixed with the
|
|
961
1052
|
* canvas), `critique` = one line per canvas, tokens summed.
|
|
962
1053
|
*/
|
|
1054
|
+
/** A canvas's issue as the aggregate carries it: the description prefixed `[<canvas>]`. */
|
|
1055
|
+
function canvasScopedIssue(canvas: CanvasClass, issue: EvaluationIssue): EvaluationIssue {
|
|
1056
|
+
return { ...issue, description: `[${canvas}] ${issue.description}` };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
963
1059
|
function aggregateCanvasResults(
|
|
964
1060
|
perCanvas: readonly CanvasVisualResult[],
|
|
965
1061
|
results: readonly EvaluationResult[],
|
|
@@ -980,7 +1076,7 @@ function aggregateCanvasResults(
|
|
|
980
1076
|
let outputTokens = 0;
|
|
981
1077
|
results.forEach((r, i) => {
|
|
982
1078
|
const canvas = perCanvas[i]!.canvas;
|
|
983
|
-
for (const issue of r.issues) issues.push(
|
|
1079
|
+
for (const issue of r.issues) issues.push(canvasScopedIssue(canvas, issue));
|
|
984
1080
|
if (r.critique) critiques.push(`${canvas}: ${r.critique}`);
|
|
985
1081
|
inputTokens += r.inputTokens ?? 0;
|
|
986
1082
|
outputTokens += r.outputTokens ?? 0;
|
|
@@ -1010,10 +1106,24 @@ export function summarizeVisualResult(result: VisualEvaluationResult): VisualEva
|
|
|
1010
1106
|
judge: c.judge,
|
|
1011
1107
|
...(c.fit !== undefined ? { fit: c.fit } : {}),
|
|
1012
1108
|
}));
|
|
1109
|
+
// ggui#1195 — the fit stamp: the first canvas whose policy FAILS an
|
|
1110
|
+
// overflow (the inline card), judged with a measurable height.
|
|
1111
|
+
const fitCanvas = result.canvases.find((c) => canvasFitPolicy(c.canvas).overflow === 'fail' && c.contentHeight !== null);
|
|
1112
|
+
const fit: VisualFitStamp | undefined =
|
|
1113
|
+
fitCanvas !== undefined && fitCanvas.contentHeight !== null
|
|
1114
|
+
? {
|
|
1115
|
+
canvas: fitCanvas.canvas,
|
|
1116
|
+
ceiling: { width: fitCanvas.viewport.width, height: fitCanvas.viewport.height },
|
|
1117
|
+
// Declared = the order carried a box, even one equal to the class box (the review's point).
|
|
1118
|
+
declared: fitCanvas.declared === true,
|
|
1119
|
+
overflowPx: Math.max(0, fitCanvas.contentHeight - fitCanvas.viewport.height),
|
|
1120
|
+
}
|
|
1121
|
+
: undefined;
|
|
1013
1122
|
return {
|
|
1014
1123
|
score: result.finalScore,
|
|
1015
1124
|
passed: result.passed,
|
|
1016
1125
|
canvases,
|
|
1126
|
+
...(fit !== undefined ? { fit } : {}),
|
|
1017
1127
|
...(result.design !== undefined ? { design: result.design } : {}),
|
|
1018
1128
|
...(result.themeMode !== undefined ? { themeMode: result.themeMode } : {}),
|
|
1019
1129
|
};
|
|
@@ -1148,6 +1258,12 @@ export interface VisualEvalOutcome {
|
|
|
1148
1258
|
* the single-shot path (byte-identical to the pre-canvas result).
|
|
1149
1259
|
*/
|
|
1150
1260
|
summary?: VisualEvalSummary;
|
|
1261
|
+
/**
|
|
1262
|
+
* ggui#1221 — whether the judge leg ran or was skipped (with the reason
|
|
1263
|
+
* `runVisualEvaluationDetailed` names). Always present: the absence of a
|
|
1264
|
+
* summary used to be indistinguishable from a clean single-shot run.
|
|
1265
|
+
*/
|
|
1266
|
+
coverage: VisualCoverage;
|
|
1151
1267
|
}
|
|
1152
1268
|
|
|
1153
1269
|
/**
|
|
@@ -1157,19 +1273,113 @@ export interface VisualEvalOutcome {
|
|
|
1157
1273
|
export async function runVisualEval(
|
|
1158
1274
|
context: VisualEvalContext,
|
|
1159
1275
|
config: VisualEvalConfig,
|
|
1276
|
+
deps: VisualEvalDeps = {},
|
|
1160
1277
|
): Promise<VisualEvalOutcome> {
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1278
|
+
const detailed = await runVisualEvaluationDetailed(context, config, deps);
|
|
1279
|
+
const result = detailed.result;
|
|
1280
|
+
if (!result) {
|
|
1281
|
+
// ggui#1221 — no browser / launch failure / unbundlable / unparsable judge:
|
|
1282
|
+
// the leg is SKIPPED, and says so on the outcome instead of leaving `{ issues: [] }`.
|
|
1283
|
+
const reason = detailed.unavailableReason ?? 'no browser available';
|
|
1284
|
+
return { issues: [], coverage: { status: 'skipped', reason } };
|
|
1285
|
+
}
|
|
1163
1286
|
|
|
1164
|
-
const issues: EvalIssue[] = (result.issues || []).map(
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1287
|
+
const issues: EvalIssue[] = (result.issues || []).map(toEvalIssue);
|
|
1288
|
+
const summary = summarizeVisualResult(result);
|
|
1289
|
+
const coverage: VisualCoverage = { status: 'ran' };
|
|
1290
|
+
return summary === undefined ? { issues, coverage } : { issues, summary, coverage };
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** A judge-shaped issue as the harness's tier-2 issue: critical ⇒ a blocking fail, anything else ⇒ a warn. */
|
|
1294
|
+
function toEvalIssue(issue: EvaluationIssue): EvalIssue {
|
|
1295
|
+
return {
|
|
1296
|
+
tier: 2,
|
|
1297
|
+
result: issue.severity === 'critical' ? 'fail' : 'warn',
|
|
1298
|
+
category: 'visual',
|
|
1168
1299
|
subcategory: issue.dimension,
|
|
1169
|
-
severity:
|
|
1300
|
+
severity: issue.severity === 'critical' ? 'critical' : 'major',
|
|
1170
1301
|
description: issue.description,
|
|
1171
1302
|
fix: issue.fix || '',
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// ---------------------------------------------------------------------------
|
|
1307
|
+
// The fit half without the judge
|
|
1308
|
+
// ---------------------------------------------------------------------------
|
|
1309
|
+
|
|
1310
|
+
/** What the fit half reads — the frame's inputs; nothing about a judge. */
|
|
1311
|
+
export type VisualFitConfig = Pick<VisualEvalConfig, 'sampleProps' | 'canvases' | 'canvasViewports' | 'designSrcDir'>;
|
|
1312
|
+
|
|
1313
|
+
/** One canvas's fit reading: the box, the measure, whether it overflowed — no score. */
|
|
1314
|
+
export interface CanvasFitReading {
|
|
1315
|
+
readonly canvas: CanvasClass;
|
|
1316
|
+
readonly viewport: CanvasViewport;
|
|
1317
|
+
readonly contentHeight: number | null;
|
|
1318
|
+
readonly overflow: boolean;
|
|
1319
|
+
/** `true` when the order declared this canvas's box. */
|
|
1320
|
+
readonly declared?: true;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** `measured` = every canvas was framed (its fit issues, possibly none); `unavailable` = no verdict, with why. */
|
|
1324
|
+
export type VisualFitOutcome =
|
|
1325
|
+
| { readonly status: 'measured'; readonly issues: EvalIssue[]; readonly readings: CanvasFitReading[] }
|
|
1326
|
+
| { readonly status: 'unavailable'; readonly reason: string };
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* The visual leg's deterministic half, taken WITHOUT the vision judge.
|
|
1330
|
+
*
|
|
1331
|
+
* The fit verdict needs a browser and nothing else: each canvas is framed at
|
|
1332
|
+
* its box (the declared one when the order carried it), its scroll height is
|
|
1333
|
+
* measured against the box's height, and the canvas's fit policy decides what
|
|
1334
|
+
* an overflow earns — the same frame ({@link frameCanvas}), the same measure
|
|
1335
|
+
* and the same issue ({@link fitVerdict}, canvas-scoped) as the judge path, so
|
|
1336
|
+
* the two reach one verdict. What is absent is the score. A caller whose
|
|
1337
|
+
* generation provider has no vision judge takes this half alone, so a chat
|
|
1338
|
+
* card cut off at its declared box is still reported in the loop, where the
|
|
1339
|
+
* round can buy the composition fix.
|
|
1340
|
+
*
|
|
1341
|
+
* Per-canvas only: without `canvases` there is no box to hold the content
|
|
1342
|
+
* against, and the outcome is `unavailable`. A frame that cannot be taken is
|
|
1343
|
+
* `unavailable` with the capture's reason — never a pass.
|
|
1344
|
+
*/
|
|
1345
|
+
export async function runVisualFit(
|
|
1346
|
+
context: VisualEvalContext,
|
|
1347
|
+
config: VisualFitConfig,
|
|
1348
|
+
deps: ScreenshotDeps = {},
|
|
1349
|
+
): Promise<VisualFitOutcome> {
|
|
1350
|
+
if (config.canvases === undefined || config.canvases.length === 0) {
|
|
1351
|
+
return { status: 'unavailable', reason: 'no canvases — the fit verdict is taken per canvas' };
|
|
1352
|
+
}
|
|
1353
|
+
const designSrc = config.designSrcDir ?? resolve(resolveDesignPackageDir(), 'src');
|
|
1354
|
+
let bundledCode: string;
|
|
1355
|
+
try {
|
|
1356
|
+
bundledCode = await bundleForRendering(context.compiledCode, config.sampleProps ?? {}, designSrc);
|
|
1357
|
+
} catch (e) {
|
|
1358
|
+
return { status: 'unavailable', reason: `bundle failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
1359
|
+
}
|
|
1360
|
+
const html = buildRenderHTML(bundledCode, context.cssTokens);
|
|
1361
|
+
const issues: EvalIssue[] = [];
|
|
1362
|
+
const readings: CanvasFitReading[] = [];
|
|
1363
|
+
for (const canvas of config.canvases) {
|
|
1364
|
+
const frame = await frameCanvas(bundledCode, context, html, canvas, config.canvasViewports, deps);
|
|
1365
|
+
if (!frame.attempt.png) {
|
|
1366
|
+
return { status: 'unavailable', reason: `${frame.attempt.reason ?? 'no browser available'} at canvas ${canvas}` };
|
|
1367
|
+
}
|
|
1368
|
+
const contentHeight = frame.attempt.contentHeight;
|
|
1369
|
+
const overflow = contentHeight !== null && contentHeight > frame.viewport.height;
|
|
1370
|
+
const verdict = fitVerdict(canvas, frame);
|
|
1371
|
+
if (verdict !== null) issues.push(toEvalIssue(canvasScopedIssue(canvas, verdict)));
|
|
1372
|
+
readings.push({
|
|
1373
|
+
canvas,
|
|
1374
|
+
viewport: frame.viewport,
|
|
1375
|
+
contentHeight,
|
|
1376
|
+
overflow,
|
|
1377
|
+
...(frame.declared ? { declared: true as const } : {}),
|
|
1378
|
+
});
|
|
1379
|
+
console.log(
|
|
1380
|
+
`[visual-fit] canvas=${canvas} ${frame.viewport.width}×${frame.viewport.height} ` +
|
|
1381
|
+
`content=${contentHeight ?? '?'}px${overflow ? ` OVERFLOW (${frame.policy.overflow})` : ''} | no judge`,
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
return { status: 'measured', issues, readings };
|
|
1175
1385
|
}
|