@mcptoolshop/style-dataset-lab 3.3.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.es.md +117 -55
- package/README.fr.md +125 -65
- package/README.hi.md +117 -68
- package/README.it.md +119 -58
- package/README.ja.md +124 -74
- package/README.md +73 -7
- package/README.pt-BR.md +128 -63
- package/README.zh.md +116 -65
- package/bin/sdlab.js +100 -4
- package/lib/adapters/ai-toolkit.js +224 -35
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +94 -11
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/asset-ingest.js +575 -0
- package/lib/asset-masks.js +323 -0
- package/lib/asset-source.js +1176 -0
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-author.js +350 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/canon-review-gate.js +205 -0
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/colab-launcher-guards.js +285 -0
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +16 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +194 -6
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/lane1-render.js +668 -0
- package/lib/measure.js +422 -0
- package/lib/memory-store.js +663 -0
- package/lib/model-licences.js +305 -0
- package/lib/npy-meta.js +130 -0
- package/lib/paths.js +196 -7
- package/lib/png-meta.js +512 -0
- package/lib/records.js +54 -8
- package/lib/reingest-selected.js +113 -17
- package/lib/reingest.js +27 -3
- package/lib/run-manifest.js +159 -25
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/train-runs.js +214 -0
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +3 -0
- package/lib/verify-chain.js +367 -0
- package/lib/workflow-profiles.js +24 -3
- package/package.json +4 -3
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/asset-ingest.js +160 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-draft.js +82 -0
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +31 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
- package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
- package/scripts/colab/colab_window.sh +587 -0
- package/scripts/colab/lane1_render.py +970 -0
- package/scripts/colab/launch_guards.py +564 -0
- package/scripts/colab/launch_lane1_stub.sh +309 -0
- package/scripts/colab/launch_lane3_stub.sh +366 -0
- package/scripts/colab/remote_runner.py +59 -0
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +182 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +86 -17
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +138 -20
- package/scripts/reingest.js +2 -2
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/verify.js +67 -0
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
package/lib/comfyui.js
CHANGED
|
@@ -16,36 +16,114 @@ const HEARTBEAT_MS = 15_000; // emit a heartbeat at most every 15s
|
|
|
16
16
|
// than hang until MAX_POLL_MS.
|
|
17
17
|
const UNKNOWN_STATUS_BAIL_POLLS = 30; // ~30s of unrecognized shape
|
|
18
18
|
|
|
19
|
+
// SDL-H8: per-REQUEST timeout, distinct from the per-CALL poll budget
|
|
20
|
+
// (timeoutMs/MAX_POLL_MS above, which only bounds the interval *between*
|
|
21
|
+
// requests). Without this, a ComfyUI that accepts the TCP connection and
|
|
22
|
+
// never responds hangs `await fetch()` forever — a hang never rejects, so
|
|
23
|
+
// no retry/catch logic anywhere downstream ever fires. Configurable via env
|
|
24
|
+
// so operators can widen it for a slow/remote ComfyUI without a code change.
|
|
25
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = Number(process.env.SDLAB_COMFY_REQUEST_TIMEOUT_MS) || 30_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* fetch() with a hard per-request timeout via AbortController. Guarantees
|
|
29
|
+
* the returned promise always settles (resolve or reject) within
|
|
30
|
+
* `timeoutMs`, even against a server that accepts the connection and then
|
|
31
|
+
* never responds. On timeout, rejects with a plain Error (not a raw
|
|
32
|
+
* AbortError/DOMException) naming the URL and elapsed time, so it reads
|
|
33
|
+
* like any other network failure to callers' existing catch/retry logic.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} url
|
|
36
|
+
* @param {RequestInit} [init]
|
|
37
|
+
* @param {number} [timeoutMs]
|
|
38
|
+
* @returns {Promise<Response>}
|
|
39
|
+
*/
|
|
40
|
+
async function fetchWithTimeout(url, init = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
try {
|
|
45
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err?.name === 'AbortError') {
|
|
48
|
+
const elapsedSec = ((Date.now() - start) / 1000).toFixed(1);
|
|
49
|
+
throw new Error(
|
|
50
|
+
`ComfyUI request timed out after ${elapsedSec}s (limit ${(timeoutMs / 1000).toFixed(1)}s): ${url}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
19
59
|
/**
|
|
20
|
-
*
|
|
60
|
+
* B04: comfyHealth() used to collapse EVERY failure mode into a bare
|
|
61
|
+
* `false` via `catch { return false }` with no bound error. A 503 while a
|
|
62
|
+
* custom node loads, a refused connection, a DNS typo, and a genuine
|
|
63
|
+
* timeout all produced the identical "ComfyUI not reachable — start
|
|
64
|
+
* ComfyUI" message downstream — factually wrong for the 503 (ComfyUI IS
|
|
65
|
+
* reachable and running) and useless for the others (starting ComfyUI does
|
|
66
|
+
* not fix a DNS typo).
|
|
67
|
+
*
|
|
68
|
+
* Check if ComfyUI is reachable, WITH a reason when it isn't.
|
|
69
|
+
*
|
|
21
70
|
* @param {string} [comfyUrl]
|
|
22
|
-
* @
|
|
71
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
72
|
+
* @returns {Promise<{ok: boolean, reason: string|null}>} reason is one of
|
|
73
|
+
* 'timeout' | 'refused' | 'dns' | 'http-<status>' | 'unknown' | null (when ok)
|
|
23
74
|
*/
|
|
24
|
-
export async function
|
|
75
|
+
export async function comfyHealthDetailed(comfyUrl = DEFAULT_URL, opts = {}) {
|
|
25
76
|
try {
|
|
26
|
-
const res = await
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
77
|
+
const res = await fetchWithTimeout(`${comfyUrl}/system_stats`, {}, opts.timeoutMs);
|
|
78
|
+
if (res.ok) return { ok: true, reason: null };
|
|
79
|
+
return { ok: false, reason: `http-${res.status}` };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (/timed out/i.test(err?.message || '')) return { ok: false, reason: 'timeout' };
|
|
82
|
+
const code = err?.cause?.code || err?.code;
|
|
83
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return { ok: false, reason: 'dns' };
|
|
84
|
+
if (code === 'ECONNREFUSED' || code === 'ECONNRESET') return { ok: false, reason: 'refused' };
|
|
85
|
+
return { ok: false, reason: 'unknown' };
|
|
30
86
|
}
|
|
31
87
|
}
|
|
32
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Check if ComfyUI is reachable. Thin plain-boolean wrapper over
|
|
91
|
+
* comfyHealthDetailed() — kept as a SEPARATE, unchanged-signature export
|
|
92
|
+
* because 5 scripts (generate.js, generate-identity.js,
|
|
93
|
+
* generate-controlnet.js, generate-ipadapter.js, painterly.js) already call
|
|
94
|
+
* `if (!(await comfyHealth(...)))`; returning a richer object here would
|
|
95
|
+
* make every one of those checks permanently truthy (an object is always
|
|
96
|
+
* truthy) and silently disable their offline handling.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} [comfyUrl]
|
|
99
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
100
|
+
* @returns {Promise<boolean>}
|
|
101
|
+
*/
|
|
102
|
+
export async function comfyHealth(comfyUrl = DEFAULT_URL, opts = {}) {
|
|
103
|
+
const { ok } = await comfyHealthDetailed(comfyUrl, opts);
|
|
104
|
+
return ok;
|
|
105
|
+
}
|
|
106
|
+
|
|
33
107
|
/**
|
|
34
108
|
* Submit a workflow and poll until complete.
|
|
35
109
|
* @param {Object} workflow — ComfyUI prompt graph
|
|
36
110
|
* @param {string} [comfyUrl]
|
|
37
|
-
* @param {{ clientPrefix?: string, timeoutMs?: number }} [opts]
|
|
111
|
+
* @param {{ clientPrefix?: string, timeoutMs?: number, requestTimeoutMs?: number }} [opts]
|
|
112
|
+
* `timeoutMs` bounds the OVERALL poll loop (unchanged, default 10 min).
|
|
113
|
+
* `requestTimeoutMs` (SDL-H8) bounds each INDIVIDUAL fetch — the submit
|
|
114
|
+
* POST and every /history poll — so a server that never responds can't
|
|
115
|
+
* hang the whole call forever.
|
|
38
116
|
* @returns {Promise<Object>} — history entry
|
|
39
117
|
*/
|
|
40
118
|
export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {}) {
|
|
41
|
-
const { clientPrefix = 'sdl', timeoutMs = MAX_POLL_MS } = opts;
|
|
119
|
+
const { clientPrefix = 'sdl', timeoutMs = MAX_POLL_MS, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = opts;
|
|
42
120
|
const clientId = `${clientPrefix}-${Date.now()}`;
|
|
43
121
|
|
|
44
|
-
const res = await
|
|
122
|
+
const res = await fetchWithTimeout(`${comfyUrl}/prompt`, {
|
|
45
123
|
method: 'POST',
|
|
46
124
|
headers: { 'Content-Type': 'application/json' },
|
|
47
125
|
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
|
|
48
|
-
});
|
|
126
|
+
}, requestTimeoutMs);
|
|
49
127
|
|
|
50
128
|
if (!res.ok) {
|
|
51
129
|
const text = await res.text();
|
|
@@ -72,7 +150,10 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
|
|
|
72
150
|
await new Promise(r => setTimeout(r, 1000));
|
|
73
151
|
let histRes;
|
|
74
152
|
try {
|
|
75
|
-
|
|
153
|
+
// SDL-H8: bounded so a server that accepts the connection and never
|
|
154
|
+
// responds lands in this catch (as an AbortError → clear timeout
|
|
155
|
+
// Error) instead of hanging `await fetch()` forever.
|
|
156
|
+
histRes = await fetchWithTimeout(`${comfyUrl}/history/${promptId}`, {}, requestTimeoutMs);
|
|
76
157
|
} catch (err) {
|
|
77
158
|
consecutiveHistoryFailures++;
|
|
78
159
|
if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
|
|
@@ -91,8 +172,23 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
|
|
|
91
172
|
}
|
|
92
173
|
continue;
|
|
93
174
|
}
|
|
175
|
+
// SDL-L3: histRes.json() used to sit outside the retry block above, so
|
|
176
|
+
// a 200-OK non-JSON body (e.g. a proxy error page) threw a raw
|
|
177
|
+
// SyntaxError here, uncaught, permanently failing the image the
|
|
178
|
+
// adjacent 5-strike tolerance would otherwise have recovered from.
|
|
179
|
+
let history;
|
|
180
|
+
try {
|
|
181
|
+
history = await histRes.json();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
consecutiveHistoryFailures++;
|
|
184
|
+
if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`ComfyUI history endpoint returned an unparseable response ${MAX_CONSECUTIVE_HISTORY_FAILURES} times in a row for prompt ${promptId}: ${err.message}`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
94
191
|
consecutiveHistoryFailures = 0;
|
|
95
|
-
const history = await histRes.json();
|
|
96
192
|
const entry = history[promptId];
|
|
97
193
|
|
|
98
194
|
// PB-001: emit heartbeats so the user knows the pipeline isn't hung.
|
|
@@ -159,9 +255,10 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
|
|
|
159
255
|
* @param {string} [subfolder]
|
|
160
256
|
* @param {string} [comfyUrl]
|
|
161
257
|
* @param {string} [type='output']
|
|
258
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
162
259
|
* @returns {Promise<Buffer>}
|
|
163
260
|
*/
|
|
164
|
-
export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL, type = 'output') {
|
|
261
|
+
export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL, type = 'output', opts = {}) {
|
|
165
262
|
const ALLOWED_TYPES = ['output', 'input', 'temp'];
|
|
166
263
|
if (!ALLOWED_TYPES.includes(type)) {
|
|
167
264
|
throw new Error(`Invalid ComfyUI image type "${type}" — must be one of: ${ALLOWED_TYPES.join(', ')}`);
|
|
@@ -170,7 +267,7 @@ export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL,
|
|
|
170
267
|
throw new Error(`Invalid comfyUrl "${comfyUrl}" — must start with http:// or https://`);
|
|
171
268
|
}
|
|
172
269
|
const url = `${comfyUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || '')}&type=${encodeURIComponent(type)}`;
|
|
173
|
-
const res = await
|
|
270
|
+
const res = await fetchWithTimeout(url, {}, opts.timeoutMs);
|
|
174
271
|
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
175
272
|
return Buffer.from(await res.arrayBuffer());
|
|
176
273
|
}
|
|
@@ -180,18 +277,19 @@ export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL,
|
|
|
180
277
|
* @param {string} filePath — local file to upload
|
|
181
278
|
* @param {string} filename — name to assign in ComfyUI
|
|
182
279
|
* @param {string} [comfyUrl]
|
|
280
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
183
281
|
* @returns {Promise<string>} — assigned name from ComfyUI
|
|
184
282
|
*/
|
|
185
|
-
export async function uploadImage(filePath, filename, comfyUrl = DEFAULT_URL) {
|
|
283
|
+
export async function uploadImage(filePath, filename, comfyUrl = DEFAULT_URL, opts = {}) {
|
|
186
284
|
const data = await readFile(filePath);
|
|
187
285
|
const formData = new FormData();
|
|
188
286
|
formData.append('image', new Blob([data], { type: 'image/png' }), filename);
|
|
189
287
|
formData.append('overwrite', 'true');
|
|
190
288
|
|
|
191
|
-
const res = await
|
|
289
|
+
const res = await fetchWithTimeout(`${comfyUrl}/upload/image`, {
|
|
192
290
|
method: 'POST',
|
|
193
291
|
body: formData,
|
|
194
|
-
});
|
|
292
|
+
}, opts.timeoutMs);
|
|
195
293
|
|
|
196
294
|
if (!res.ok) {
|
|
197
295
|
const text = await res.text();
|
package/lib/config.js
CHANGED
|
@@ -10,6 +10,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
10
10
|
import { readFile } from 'node:fs/promises';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { inputError, runtimeError } from './errors.js';
|
|
13
|
+
import { warn } from './log.js';
|
|
13
14
|
|
|
14
15
|
function loadJsonFile(filePath) {
|
|
15
16
|
if (!existsSync(filePath)) return null;
|
|
@@ -25,6 +26,80 @@ function loadJsonFile(filePath) {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Validate a user-authored regex pattern string at CONFIG LOAD TIME rather
|
|
31
|
+
* than lazily inside detectLane()/detectGroup() on whatever record happens
|
|
32
|
+
* to process first.
|
|
33
|
+
*
|
|
34
|
+
* B02: detectLane()/detectGroup() build `new RegExp(pattern)` from strings
|
|
35
|
+
* loaded verbatim from lanes.json/terminology.json across 7 construction
|
|
36
|
+
* sites, with zero validation before this. One unescaped bracket
|
|
37
|
+
* (`"[freeport"`) used to abort a whole `sdlab bind` pass with a raw
|
|
38
|
+
* `SyntaxError: Invalid regular expression` naming neither the file, the
|
|
39
|
+
* lane/group, nor the offending pattern — finding it required manual
|
|
40
|
+
* bisection of the config. This throws ONE structured error naming all
|
|
41
|
+
* three, before any record is processed.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} pattern
|
|
44
|
+
* @param {{file: string, context: string}} where
|
|
45
|
+
*/
|
|
46
|
+
function validateRegexPattern(pattern, { file, context }) {
|
|
47
|
+
try {
|
|
48
|
+
// eslint-disable-next-line no-new -- validation only, result unused
|
|
49
|
+
new RegExp(pattern);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw inputError(
|
|
52
|
+
'INPUT_BAD_REGEX',
|
|
53
|
+
`Invalid regex pattern in ${file} (${context}): "${pattern}" — ${err.message}`,
|
|
54
|
+
'Fix or escape the pattern (e.g. escape literal brackets/parens/braces) and try again.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate every id_patterns entry across all lanes in a loaded lanes.json.
|
|
61
|
+
* Fails fast on the FIRST invalid pattern (not an aggregate report) so the
|
|
62
|
+
* thrown error always names one concrete offending pattern.
|
|
63
|
+
*/
|
|
64
|
+
function validateLanePatterns(lanesData) {
|
|
65
|
+
for (const lane of lanesData.lanes || []) {
|
|
66
|
+
for (const pattern of lane.id_patterns || []) {
|
|
67
|
+
validateRegexPattern(pattern, { file: 'lanes.json', context: `lane "${lane.id}"` });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate every pattern site across a loaded terminology.json: per-group
|
|
74
|
+
* id_patterns/prompt_patterns, plus the four dict/array pattern collections
|
|
75
|
+
* detectGroup() also builds RegExp from (cross_group_patterns, edge_defaults,
|
|
76
|
+
* null_faction_patterns, id_fallbacks) — the full set of 6 sites detectGroup()
|
|
77
|
+
* itself constructs (the 7th is detectLane()'s single site, covered by
|
|
78
|
+
* validateLanePatterns above).
|
|
79
|
+
*/
|
|
80
|
+
function validateTerminologyPatterns(termData) {
|
|
81
|
+
for (const [groupName, group] of Object.entries(termData.groups || {})) {
|
|
82
|
+
for (const pattern of group?.id_patterns || []) {
|
|
83
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: `group "${groupName}" id_patterns` });
|
|
84
|
+
}
|
|
85
|
+
for (const pattern of group?.prompt_patterns || []) {
|
|
86
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: `group "${groupName}" prompt_patterns` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const pattern of Object.keys(termData.cross_group_patterns || {})) {
|
|
90
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: 'cross_group_patterns' });
|
|
91
|
+
}
|
|
92
|
+
for (const pattern of Object.keys(termData.edge_defaults || {})) {
|
|
93
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: 'edge_defaults' });
|
|
94
|
+
}
|
|
95
|
+
for (const pattern of termData.null_faction_patterns || []) {
|
|
96
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: 'null_faction_patterns' });
|
|
97
|
+
}
|
|
98
|
+
for (const pattern of Object.keys(termData.id_fallbacks || {})) {
|
|
99
|
+
validateRegexPattern(pattern, { file: 'terminology.json', context: 'id_fallbacks' });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
28
103
|
/**
|
|
29
104
|
* Async-safe readFile + JSON.parse with structured error handling.
|
|
30
105
|
* Optionally validates that required top-level fields are present.
|
|
@@ -75,8 +150,12 @@ export async function readJsonFile(filePath, opts = {}) {
|
|
|
75
150
|
* Load project.json — project identity and generation defaults.
|
|
76
151
|
*/
|
|
77
152
|
export function loadProjectMeta(projectRoot) {
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
153
|
+
const filePath = join(projectRoot, 'project.json');
|
|
154
|
+
if (!existsSync(filePath)) {
|
|
155
|
+
// B05: this loader used to substitute its default with zero logging —
|
|
156
|
+
// the module didn't even import lib/log.js. A dropped/never-created
|
|
157
|
+
// project.json now names its expected path instead of degrading silently.
|
|
158
|
+
warn(`project.json not found at ${filePath} — using built-in defaults (name: "unknown")`);
|
|
80
159
|
return {
|
|
81
160
|
name: 'unknown',
|
|
82
161
|
display_name: 'Unknown Project',
|
|
@@ -85,7 +164,7 @@ export function loadProjectMeta(projectRoot) {
|
|
|
85
164
|
defaults: {},
|
|
86
165
|
};
|
|
87
166
|
}
|
|
88
|
-
return
|
|
167
|
+
return loadJsonFile(filePath);
|
|
89
168
|
}
|
|
90
169
|
|
|
91
170
|
/**
|
|
@@ -111,8 +190,14 @@ export function resolveGpuModel(projectDefaults) {
|
|
|
111
190
|
* Load constitution.json — array of rule objects.
|
|
112
191
|
*/
|
|
113
192
|
export function loadConstitution(projectRoot) {
|
|
114
|
-
const
|
|
115
|
-
if (!
|
|
193
|
+
const filePath = join(projectRoot, 'constitution.json');
|
|
194
|
+
if (!existsSync(filePath)) {
|
|
195
|
+
// B05: see loadProjectMeta above — warn once, naming the expected path,
|
|
196
|
+
// instead of silently substituting the empty-rules default.
|
|
197
|
+
warn(`constitution.json not found at ${filePath} — using built-in defaults (no rules configured)`);
|
|
198
|
+
return { rules: [] };
|
|
199
|
+
}
|
|
200
|
+
const data = loadJsonFile(filePath);
|
|
116
201
|
if (Array.isArray(data)) return { rules: data };
|
|
117
202
|
if (!data.rules) {
|
|
118
203
|
throw inputError(
|
|
@@ -128,10 +213,14 @@ export function loadConstitution(projectRoot) {
|
|
|
128
213
|
* Load lanes.json — lane definitions with detection patterns.
|
|
129
214
|
*/
|
|
130
215
|
export function loadLanes(projectRoot) {
|
|
131
|
-
const
|
|
132
|
-
if (!
|
|
216
|
+
const filePath = join(projectRoot, 'lanes.json');
|
|
217
|
+
if (!existsSync(filePath)) {
|
|
218
|
+
// B05: warn once, naming the expected path, instead of silently
|
|
219
|
+
// substituting the no-lanes default.
|
|
220
|
+
warn(`lanes.json not found at ${filePath} — using built-in defaults (no lanes configured)`);
|
|
133
221
|
return { default_lane: 'default', lanes: [] };
|
|
134
222
|
}
|
|
223
|
+
const data = loadJsonFile(filePath);
|
|
135
224
|
if (!data.lanes) {
|
|
136
225
|
throw inputError(
|
|
137
226
|
'INPUT_BAD_CONFIG',
|
|
@@ -139,6 +228,9 @@ export function loadLanes(projectRoot) {
|
|
|
139
228
|
'Top-level shape: { "default_lane": "...", "lanes": [ ... ] }.'
|
|
140
229
|
);
|
|
141
230
|
}
|
|
231
|
+
// B02: validate every id_patterns regex now, before any record is ever
|
|
232
|
+
// tested against it — see validateRegexPattern's docstring above.
|
|
233
|
+
validateLanePatterns(data);
|
|
142
234
|
return data;
|
|
143
235
|
}
|
|
144
236
|
|
|
@@ -146,23 +238,31 @@ export function loadLanes(projectRoot) {
|
|
|
146
238
|
* Load rubric.json — scoring dimensions, thresholds, failure modes.
|
|
147
239
|
*/
|
|
148
240
|
export function loadRubric(projectRoot) {
|
|
149
|
-
const
|
|
150
|
-
if (!
|
|
241
|
+
const filePath = join(projectRoot, 'rubric.json');
|
|
242
|
+
if (!existsSync(filePath)) {
|
|
243
|
+
// B05: warn once, naming the expected path, instead of silently
|
|
244
|
+
// substituting the built-in thresholds default.
|
|
245
|
+
warn(`rubric.json not found at ${filePath} — using built-in defaults (pass=0.7/partial=0.5/fail_ceiling=0.4)`);
|
|
151
246
|
return {
|
|
152
247
|
dimensions: [],
|
|
153
248
|
thresholds: { pass: 0.7, partial: 0.5, fail_ceiling: 0.4 },
|
|
154
249
|
failure_to_rules: {},
|
|
155
250
|
};
|
|
156
251
|
}
|
|
157
|
-
return
|
|
252
|
+
return loadJsonFile(filePath);
|
|
158
253
|
}
|
|
159
254
|
|
|
160
255
|
/**
|
|
161
256
|
* Load terminology.json — group vocabulary and detection patterns.
|
|
162
257
|
*/
|
|
163
258
|
export function loadTerminology(projectRoot) {
|
|
164
|
-
const
|
|
165
|
-
if (!
|
|
259
|
+
const filePath = join(projectRoot, 'terminology.json');
|
|
260
|
+
if (!existsSync(filePath)) {
|
|
261
|
+
// B05: this is the finding's own example — a dropped terminology.json
|
|
262
|
+
// used to make detectGroup() return null for every record with zero
|
|
263
|
+
// signal. Warn once, naming the expected path, instead of substituting
|
|
264
|
+
// the empty-groups default silently.
|
|
265
|
+
warn(`terminology.json not found at ${filePath} — using built-in defaults (no groups configured)`);
|
|
166
266
|
return {
|
|
167
267
|
group_label: 'group',
|
|
168
268
|
groups: {},
|
|
@@ -171,6 +271,10 @@ export function loadTerminology(projectRoot) {
|
|
|
171
271
|
prompt_fallbacks: {},
|
|
172
272
|
};
|
|
173
273
|
}
|
|
274
|
+
const data = loadJsonFile(filePath);
|
|
275
|
+
// B02: validate every pattern site now, before any record is ever tested
|
|
276
|
+
// against it — see validateRegexPattern's docstring above.
|
|
277
|
+
validateTerminologyPatterns(data);
|
|
174
278
|
return data;
|
|
175
279
|
}
|
|
176
280
|
|
package/lib/critique-engine.js
CHANGED
|
@@ -255,6 +255,17 @@ export function critiqueCandidate({ brief, workflow, candidate, activeDimensions
|
|
|
255
255
|
filename: candidate.filename,
|
|
256
256
|
seed: candidate.seed,
|
|
257
257
|
overall_fit: 'usable', // Default — human or LLM overrides
|
|
258
|
+
// H2: this is the ONLY assignment site for overall_fit anywhere in the
|
|
259
|
+
// codebase — nothing currently overrides it, so every candidate in
|
|
260
|
+
// every critique report is uniformly 'usable' by construction. That is
|
|
261
|
+
// a non-judgment ("nobody has looked at the image yet"), not a judgment
|
|
262
|
+
// ("looked, and they're all fine") — `reviewed: false` records which one
|
|
263
|
+
// this is so recommendAction() (below) and the CLI can tell the two
|
|
264
|
+
// apart instead of presenting keyword-drift tiebreaking as a considered
|
|
265
|
+
// recommendation. Flip to true at the (future, NOT built here — see the
|
|
266
|
+
// finding's explicit scoping note) point where a human or LLM judge
|
|
267
|
+
// actually assesses fit and overrides overall_fit.
|
|
268
|
+
reviewed: false,
|
|
258
269
|
// EXTERNAL_VERIFIER: this rule-based engine is a different artifact class
|
|
259
270
|
// from the image generator, so it is a legitimate cross-artifact verifier.
|
|
260
271
|
judged_by_model: RULE_BASED_JUDGE,
|
|
@@ -301,12 +312,27 @@ export function triageCandidates(candidates, { driftThreshold = DEFAULT_DRIFT_TH
|
|
|
301
312
|
|
|
302
313
|
/**
|
|
303
314
|
* Derive a run-level recommended action from candidate notes.
|
|
315
|
+
*
|
|
316
|
+
* H2: `overall_fit: 'usable'` (critiqueCandidate's default, and — today —
|
|
317
|
+
* its ONLY assignment anywhere in the codebase) means "nobody has assessed
|
|
318
|
+
* fit yet", not "assessed and found uniformly usable". Before this fix,
|
|
319
|
+
* recommendAction() could not tell those two states apart, so an unreviewed
|
|
320
|
+
* run always took the same `usable.length > 0` branch a genuinely-reviewed
|
|
321
|
+
* run would, picking a "preferred" candidate by brief-keyword drift count —
|
|
322
|
+
* not by looking at the image — and presenting it with the same confidence
|
|
323
|
+
* either way. `reviewed` on the returned recommendation lets the CLI (see
|
|
324
|
+
* scripts/critique.js) qualify or suppress the auto "Next:" suggestion when
|
|
325
|
+
* nothing has actually been reviewed, without changing `mode` /
|
|
326
|
+
* `preferred_candidate` semantics for callers that don't look at it —
|
|
327
|
+
* critique.json's shape stays backward-compatible (additive field only).
|
|
304
328
|
*/
|
|
305
329
|
export function recommendAction({ candidates }) {
|
|
306
330
|
if (candidates.length === 0) {
|
|
307
|
-
return { mode: 'discard_run', reason: 'No candidates in run' };
|
|
331
|
+
return { mode: 'discard_run', reason: 'No candidates in run', reviewed: false };
|
|
308
332
|
}
|
|
309
333
|
|
|
334
|
+
const anyReviewed = candidates.some(c => c.reviewed === true);
|
|
335
|
+
|
|
310
336
|
const strong = candidates.filter(c => c.overall_fit === 'strong');
|
|
311
337
|
const usable = candidates.filter(c => c.overall_fit === 'usable');
|
|
312
338
|
const offModel = candidates.filter(c => c.overall_fit === 'off-model');
|
|
@@ -317,6 +343,7 @@ export function recommendAction({ candidates }) {
|
|
|
317
343
|
mode: 'accept_one',
|
|
318
344
|
preferred_candidate: strong[0].filename,
|
|
319
345
|
reason: `${strong.length} strong candidate(s) — ${strong[0].filename} is the best fit`,
|
|
346
|
+
reviewed: anyReviewed,
|
|
320
347
|
};
|
|
321
348
|
}
|
|
322
349
|
|
|
@@ -327,7 +354,12 @@ export function recommendAction({ candidates }) {
|
|
|
327
354
|
return {
|
|
328
355
|
mode: 'refine_from_one',
|
|
329
356
|
preferred_candidate: sorted[0].filename,
|
|
330
|
-
reason:
|
|
357
|
+
reason: anyReviewed
|
|
358
|
+
? `${usable.length} usable candidate(s) — ${sorted[0].filename} has fewest drift issues`
|
|
359
|
+
: `${usable.length} candidate(s) at the unreviewed default "usable" — nobody has assessed fit yet. ` +
|
|
360
|
+
`${sorted[0].filename} has the fewest rule-based drift issues, which is a tiebreaker, not a ` +
|
|
361
|
+
`judgment of the image.`,
|
|
362
|
+
reviewed: anyReviewed,
|
|
331
363
|
};
|
|
332
364
|
}
|
|
333
365
|
|
|
@@ -336,6 +368,7 @@ export function recommendAction({ candidates }) {
|
|
|
336
368
|
return {
|
|
337
369
|
mode: 'discard_run',
|
|
338
370
|
reason: 'All candidates are off-model — brief may need fundamental revision',
|
|
371
|
+
reviewed: anyReviewed,
|
|
339
372
|
};
|
|
340
373
|
}
|
|
341
374
|
|
|
@@ -343,6 +376,7 @@ export function recommendAction({ candidates }) {
|
|
|
343
376
|
return {
|
|
344
377
|
mode: 'rerun_broader',
|
|
345
378
|
reason: 'No clear winner — try broader seed range or adjusted parameters',
|
|
379
|
+
reviewed: anyReviewed,
|
|
346
380
|
};
|
|
347
381
|
}
|
|
348
382
|
|