@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.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 +125 -0
- package/README.md +36 -8
- package/bin/sdlab.js +129 -0
- package/lib/adapters/comfyui-runner.js +170 -0
- package/lib/adapters/comfyui-workflows.js +163 -0
- package/lib/batch-compiler.js +118 -0
- package/lib/batch-modes.js +111 -0
- package/lib/batch-runs.js +168 -0
- package/lib/batch-sheet-render.js +206 -0
- package/lib/brief-compiler.js +598 -0
- package/lib/brief-render.js +205 -0
- package/lib/critique-engine.js +413 -0
- package/lib/critique-render.js +171 -0
- package/lib/generated-provenance.js +87 -0
- package/lib/paths.js +31 -0
- package/lib/refine-briefs.js +329 -0
- package/lib/reingest-selected.js +173 -0
- package/lib/run-summary.js +131 -0
- package/lib/runtime-runs.js +168 -0
- package/lib/selections.js +323 -0
- package/lib/workflow-profiles.js +161 -0
- package/package.json +6 -3
- package/scripts/batch-generate.js +167 -0
- package/scripts/batch-sheet.js +46 -0
- package/scripts/batch-show.js +86 -0
- package/scripts/brief-compile.js +134 -0
- package/scripts/brief-show.js +52 -0
- package/scripts/critique-show.js +50 -0
- package/scripts/critique.js +70 -0
- package/scripts/doctor.js +169 -1
- package/scripts/init.js +22 -0
- package/scripts/refine.js +119 -0
- package/scripts/reingest-selected.js +78 -0
- package/scripts/reingest.js +7 -1
- package/scripts/run-generate.js +76 -0
- package/scripts/run-list.js +58 -0
- package/scripts/run-show.js +63 -0
- package/scripts/select.js +118 -0
- package/scripts/selection-show.js +88 -0
- package/scripts/workflow-list.js +61 -0
- package/scripts/workflow-show.js +96 -0
- package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
- package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
- package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
- package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
- package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
- package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
- package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
- package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
- package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
- package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brief renderer — converts compiled brief JSON to human-readable formats.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render a compiled brief as Markdown.
|
|
7
|
+
*/
|
|
8
|
+
export function renderBriefMarkdown(brief) {
|
|
9
|
+
const lines = [];
|
|
10
|
+
|
|
11
|
+
lines.push(`# Brief: ${brief.brief_id}`);
|
|
12
|
+
lines.push('');
|
|
13
|
+
lines.push(`**Workflow:** ${brief.workflow_id}`);
|
|
14
|
+
lines.push(`**Project:** ${brief.project_id}`);
|
|
15
|
+
lines.push(`**Lane:** ${brief.lane_id}`);
|
|
16
|
+
if (brief.subject_id) {
|
|
17
|
+
lines.push(`**Subject:** ${brief.subject_id}`);
|
|
18
|
+
}
|
|
19
|
+
if (brief.training_asset_ref) {
|
|
20
|
+
lines.push(`**Training Asset:** ${brief.training_asset_ref}`);
|
|
21
|
+
}
|
|
22
|
+
if (brief.implementation_pack_ref) {
|
|
23
|
+
lines.push(`**Implementation Pack:** ${brief.implementation_pack_ref}`);
|
|
24
|
+
}
|
|
25
|
+
lines.push('');
|
|
26
|
+
|
|
27
|
+
// Prompt
|
|
28
|
+
lines.push('## Prompt');
|
|
29
|
+
lines.push('');
|
|
30
|
+
lines.push('```');
|
|
31
|
+
lines.push(brief.prompt);
|
|
32
|
+
lines.push('```');
|
|
33
|
+
lines.push('');
|
|
34
|
+
|
|
35
|
+
// Negative prompt
|
|
36
|
+
lines.push('## Negative Prompt');
|
|
37
|
+
lines.push('');
|
|
38
|
+
lines.push('```');
|
|
39
|
+
lines.push(brief.negative_prompt);
|
|
40
|
+
lines.push('```');
|
|
41
|
+
lines.push('');
|
|
42
|
+
|
|
43
|
+
// Canon constraints
|
|
44
|
+
if (brief.canon_constraints?.length > 0) {
|
|
45
|
+
lines.push('## Canon Constraints');
|
|
46
|
+
lines.push('');
|
|
47
|
+
for (const c of brief.canon_constraints) {
|
|
48
|
+
lines.push(`- ${c}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Subject constraints
|
|
54
|
+
if (brief.subject_constraints?.length > 0) {
|
|
55
|
+
lines.push('## Subject Constraints');
|
|
56
|
+
lines.push('');
|
|
57
|
+
for (const c of brief.subject_constraints) {
|
|
58
|
+
lines.push(`- ${c}`);
|
|
59
|
+
}
|
|
60
|
+
lines.push('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Drift warnings
|
|
64
|
+
if (brief.drift_warnings?.length > 0) {
|
|
65
|
+
lines.push('## Drift Warnings');
|
|
66
|
+
lines.push('');
|
|
67
|
+
for (const w of brief.drift_warnings) {
|
|
68
|
+
lines.push(`- ⚠ ${w}`);
|
|
69
|
+
}
|
|
70
|
+
lines.push('');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Runtime plan
|
|
74
|
+
lines.push('## Runtime Plan');
|
|
75
|
+
lines.push('');
|
|
76
|
+
lines.push('| Parameter | Value |');
|
|
77
|
+
lines.push('|-----------|-------|');
|
|
78
|
+
const rp = brief.runtime_plan;
|
|
79
|
+
if (rp) {
|
|
80
|
+
lines.push(`| Adapter | ${rp.adapter_target} |`);
|
|
81
|
+
lines.push(`| Width | ${rp.width} |`);
|
|
82
|
+
lines.push(`| Height | ${rp.height} |`);
|
|
83
|
+
lines.push(`| Steps | ${rp.steps} |`);
|
|
84
|
+
lines.push(`| CFG | ${rp.cfg} |`);
|
|
85
|
+
lines.push(`| Sampler | ${rp.sampler} |`);
|
|
86
|
+
lines.push(`| Seed Mode | ${rp.seed_mode} |`);
|
|
87
|
+
}
|
|
88
|
+
lines.push('');
|
|
89
|
+
|
|
90
|
+
// Expected outputs
|
|
91
|
+
lines.push('## Expected Outputs');
|
|
92
|
+
lines.push('');
|
|
93
|
+
const eo = brief.expected_outputs;
|
|
94
|
+
if (eo) {
|
|
95
|
+
lines.push(`- **Mode:** ${eo.output_mode}`);
|
|
96
|
+
lines.push(`- **Count:** ${eo.output_count}`);
|
|
97
|
+
if (eo.review_surface) {
|
|
98
|
+
lines.push(`- **Review Surface:** ${eo.review_surface}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
lines.push('');
|
|
102
|
+
|
|
103
|
+
// Selected references
|
|
104
|
+
if (brief.reference_selection?.length > 0) {
|
|
105
|
+
lines.push('## Selected References');
|
|
106
|
+
lines.push('');
|
|
107
|
+
for (const ref of brief.reference_selection) {
|
|
108
|
+
lines.push(`- **${ref.record_id}** (${ref.role}): ${ref.reason}`);
|
|
109
|
+
}
|
|
110
|
+
lines.push('');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Metadata
|
|
114
|
+
lines.push('---');
|
|
115
|
+
lines.push('');
|
|
116
|
+
lines.push(`Compiled: ${brief.compiled_at}`);
|
|
117
|
+
lines.push(`Config fingerprint: \`${brief.config_fingerprint}\``);
|
|
118
|
+
lines.push('');
|
|
119
|
+
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Render a compiled brief as plain text for terminal output.
|
|
125
|
+
*/
|
|
126
|
+
export function renderBriefText(brief) {
|
|
127
|
+
const lines = [];
|
|
128
|
+
|
|
129
|
+
lines.push(`\x1b[1mBrief: ${brief.brief_id}\x1b[0m`);
|
|
130
|
+
lines.push(` Workflow: ${brief.workflow_id}`);
|
|
131
|
+
lines.push(` Project: ${brief.project_id}`);
|
|
132
|
+
lines.push(` Lane: ${brief.lane_id}`);
|
|
133
|
+
if (brief.subject_id) {
|
|
134
|
+
lines.push(` Subject: ${brief.subject_id}`);
|
|
135
|
+
}
|
|
136
|
+
if (brief.training_asset_ref) {
|
|
137
|
+
lines.push(` Asset: ${brief.training_asset_ref}`);
|
|
138
|
+
}
|
|
139
|
+
lines.push('');
|
|
140
|
+
|
|
141
|
+
lines.push('\x1b[1mPrompt:\x1b[0m');
|
|
142
|
+
lines.push(` ${brief.prompt}`);
|
|
143
|
+
lines.push('');
|
|
144
|
+
|
|
145
|
+
lines.push('\x1b[1mNegative:\x1b[0m');
|
|
146
|
+
lines.push(` ${brief.negative_prompt}`);
|
|
147
|
+
lines.push('');
|
|
148
|
+
|
|
149
|
+
if (brief.canon_constraints?.length > 0) {
|
|
150
|
+
lines.push('\x1b[1mCanon Constraints:\x1b[0m');
|
|
151
|
+
for (const c of brief.canon_constraints) {
|
|
152
|
+
lines.push(` • ${c}`);
|
|
153
|
+
}
|
|
154
|
+
lines.push('');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (brief.subject_constraints?.length > 0) {
|
|
158
|
+
lines.push('\x1b[1mSubject Constraints:\x1b[0m');
|
|
159
|
+
for (const c of brief.subject_constraints) {
|
|
160
|
+
lines.push(` • ${c}`);
|
|
161
|
+
}
|
|
162
|
+
lines.push('');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (brief.drift_warnings?.length > 0) {
|
|
166
|
+
lines.push('\x1b[1mDrift Warnings:\x1b[0m');
|
|
167
|
+
for (const w of brief.drift_warnings) {
|
|
168
|
+
lines.push(` \x1b[33m⚠\x1b[0m ${w}`);
|
|
169
|
+
}
|
|
170
|
+
lines.push('');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
lines.push('\x1b[1mRuntime Plan:\x1b[0m');
|
|
174
|
+
const rp = brief.runtime_plan;
|
|
175
|
+
if (rp) {
|
|
176
|
+
lines.push(` Adapter: ${rp.adapter_target}`);
|
|
177
|
+
lines.push(` Size: ${rp.width}×${rp.height}`);
|
|
178
|
+
lines.push(` Steps: ${rp.steps}`);
|
|
179
|
+
lines.push(` CFG: ${rp.cfg}`);
|
|
180
|
+
lines.push(` Sampler: ${rp.sampler}`);
|
|
181
|
+
lines.push(` Seed mode: ${rp.seed_mode}`);
|
|
182
|
+
}
|
|
183
|
+
lines.push('');
|
|
184
|
+
|
|
185
|
+
lines.push('\x1b[1mExpected Outputs:\x1b[0m');
|
|
186
|
+
const eo = brief.expected_outputs;
|
|
187
|
+
if (eo) {
|
|
188
|
+
lines.push(` Mode: ${eo.output_mode}`);
|
|
189
|
+
lines.push(` Count: ${eo.output_count}`);
|
|
190
|
+
}
|
|
191
|
+
lines.push('');
|
|
192
|
+
|
|
193
|
+
if (brief.reference_selection?.length > 0) {
|
|
194
|
+
lines.push('\x1b[1mSelected References:\x1b[0m');
|
|
195
|
+
for (const ref of brief.reference_selection) {
|
|
196
|
+
lines.push(` ${ref.record_id} (${ref.role}) — ${ref.reason}`);
|
|
197
|
+
}
|
|
198
|
+
lines.push('');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
lines.push(` Compiled: ${brief.compiled_at}`);
|
|
202
|
+
lines.push(` Fingerprint: ${brief.config_fingerprint}`);
|
|
203
|
+
|
|
204
|
+
return lines.join('\n');
|
|
205
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critique engine — inspect run outputs and produce structured feedback.
|
|
3
|
+
*
|
|
4
|
+
* A critique is NOT a scorecard. It answers:
|
|
5
|
+
* - Which candidates are closest to the brief?
|
|
6
|
+
* - What each candidate did well.
|
|
7
|
+
* - What drifted.
|
|
8
|
+
* - What to preserve into the next pass.
|
|
9
|
+
* - What to explicitly correct.
|
|
10
|
+
*
|
|
11
|
+
* The engine is rule-based and brief-driven — the brief determines the lens.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { getProjectRoot, getRunsDir } from './paths.js';
|
|
18
|
+
import { loadRun } from './runtime-runs.js';
|
|
19
|
+
import { getWorkflowProfile } from './workflow-profiles.js';
|
|
20
|
+
import { loadProjectConfig } from './config.js';
|
|
21
|
+
import { inputError } from './errors.js';
|
|
22
|
+
|
|
23
|
+
// ─── Dimension catalogue ─────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* All known critique dimensions.
|
|
27
|
+
* Each dimension maps to brief/workflow features that activate it.
|
|
28
|
+
*/
|
|
29
|
+
const DIMENSION_CATALOGUE = {
|
|
30
|
+
subject_continuity: {
|
|
31
|
+
label: 'Subject Continuity',
|
|
32
|
+
activatedBy: { hasSubject: true },
|
|
33
|
+
driftGuardKeywords: ['face structure', 'identity', 'subject'],
|
|
34
|
+
description: 'Does the candidate preserve the subject identity across outputs?',
|
|
35
|
+
},
|
|
36
|
+
lane_fit: {
|
|
37
|
+
label: 'Lane Fit',
|
|
38
|
+
activatedBy: { always: true },
|
|
39
|
+
driftGuardKeywords: ['lane', 'role'],
|
|
40
|
+
description: 'Does the candidate fit the lane purpose?',
|
|
41
|
+
},
|
|
42
|
+
silhouette_read: {
|
|
43
|
+
label: 'Silhouette Read',
|
|
44
|
+
activatedBy: { canonFocus: ['silhouette'] },
|
|
45
|
+
driftGuardKeywords: ['silhouette'],
|
|
46
|
+
description: 'Is the silhouette clean and recognizable?',
|
|
47
|
+
},
|
|
48
|
+
material_costume_logic: {
|
|
49
|
+
label: 'Material / Costume Logic',
|
|
50
|
+
activatedBy: { canonFocus: ['costume_logic', 'material_language'] },
|
|
51
|
+
driftGuardKeywords: ['material', 'costume', 'cloth', 'metal', 'fabric'],
|
|
52
|
+
description: 'Are materials and costume elements canon-correct?',
|
|
53
|
+
},
|
|
54
|
+
palette_mood: {
|
|
55
|
+
label: 'Palette / Mood Fidelity',
|
|
56
|
+
activatedBy: { canonFocus: ['palette', 'lighting'] },
|
|
57
|
+
driftGuardKeywords: ['palette', 'mood', 'lighting', 'color'],
|
|
58
|
+
description: 'Does the palette and mood match the brief?',
|
|
59
|
+
},
|
|
60
|
+
composition_framing: {
|
|
61
|
+
label: 'Composition / Framing',
|
|
62
|
+
activatedBy: { canonFocus: ['composition'] },
|
|
63
|
+
driftGuardKeywords: ['composition', 'framing', 'crop'],
|
|
64
|
+
description: 'Is the composition appropriate for the output mode?',
|
|
65
|
+
},
|
|
66
|
+
ornamental_drift: {
|
|
67
|
+
label: 'Ornamental Drift',
|
|
68
|
+
activatedBy: { canonFocus: ['costume_logic', 'faction_read'] },
|
|
69
|
+
driftGuardKeywords: ['ornament', 'accessori', 'extra'],
|
|
70
|
+
description: 'Has the candidate added non-canon decorative elements?',
|
|
71
|
+
},
|
|
72
|
+
era_faction_drift: {
|
|
73
|
+
label: 'Era / Faction Drift',
|
|
74
|
+
activatedBy: { canonFocus: ['era_logic', 'faction_read'] },
|
|
75
|
+
driftGuardKeywords: ['era', 'faction', 'wrong'],
|
|
76
|
+
description: 'Are there wrong-era or wrong-faction elements?',
|
|
77
|
+
},
|
|
78
|
+
identity_stability: {
|
|
79
|
+
label: 'Identity Stability',
|
|
80
|
+
activatedBy: { hasSubject: true },
|
|
81
|
+
driftGuardKeywords: ['identity', 'face', 'hairstyle', 'age'],
|
|
82
|
+
description: 'Has the candidate maintained stable identity features?',
|
|
83
|
+
},
|
|
84
|
+
readability: {
|
|
85
|
+
label: 'Readability',
|
|
86
|
+
activatedBy: { always: true },
|
|
87
|
+
driftGuardKeywords: ['read', 'clarity', 'muddy', 'clean'],
|
|
88
|
+
description: 'Is the output clearly readable at a glance?',
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ─── Workflow-specific emphasis maps ─────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Output mode → dimensions to emphasize.
|
|
96
|
+
* These get boosted in critique output ordering.
|
|
97
|
+
*/
|
|
98
|
+
const MODE_EMPHASIS = {
|
|
99
|
+
portrait_set: [
|
|
100
|
+
'subject_continuity', 'identity_stability', 'silhouette_read',
|
|
101
|
+
'material_costume_logic', 'ornamental_drift',
|
|
102
|
+
],
|
|
103
|
+
expression_sheet: [
|
|
104
|
+
'identity_stability', 'subject_continuity',
|
|
105
|
+
'material_costume_logic', 'readability',
|
|
106
|
+
],
|
|
107
|
+
moodboard: [
|
|
108
|
+
'palette_mood', 'material_costume_logic', 'era_faction_drift',
|
|
109
|
+
'composition_framing', 'readability',
|
|
110
|
+
],
|
|
111
|
+
variant_pack: [
|
|
112
|
+
'silhouette_read', 'material_costume_logic', 'palette_mood',
|
|
113
|
+
'ornamental_drift',
|
|
114
|
+
],
|
|
115
|
+
silhouette_sheet: [
|
|
116
|
+
'silhouette_read', 'composition_framing', 'readability',
|
|
117
|
+
],
|
|
118
|
+
turnaround: [
|
|
119
|
+
'subject_continuity', 'identity_stability', 'silhouette_read',
|
|
120
|
+
'material_costume_logic',
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// ─── Active dimension resolution ─────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Determine which critique dimensions are active for this brief + workflow.
|
|
128
|
+
*/
|
|
129
|
+
export function getActiveDimensions({ brief, workflow }) {
|
|
130
|
+
const active = [];
|
|
131
|
+
const hasSubject = !!brief.subject_id;
|
|
132
|
+
const canonFocus = workflow.canon_focus || [];
|
|
133
|
+
const driftGuards = workflow.drift_guards || [];
|
|
134
|
+
const driftText = driftGuards.join(' ').toLowerCase();
|
|
135
|
+
|
|
136
|
+
for (const [dimId, dim] of Object.entries(DIMENSION_CATALOGUE)) {
|
|
137
|
+
const cond = dim.activatedBy;
|
|
138
|
+
|
|
139
|
+
// Always-on dimensions
|
|
140
|
+
if (cond.always) {
|
|
141
|
+
active.push(dimId);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Subject-dependent
|
|
146
|
+
if (cond.hasSubject && hasSubject) {
|
|
147
|
+
active.push(dimId);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Canon focus match
|
|
152
|
+
if (cond.canonFocus) {
|
|
153
|
+
const match = cond.canonFocus.some(cf => canonFocus.includes(cf));
|
|
154
|
+
if (match) {
|
|
155
|
+
active.push(dimId);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Drift guard keyword match (catch dimensions invoked by guard language)
|
|
161
|
+
if (dim.driftGuardKeywords) {
|
|
162
|
+
const kwMatch = dim.driftGuardKeywords.some(kw => driftText.includes(kw));
|
|
163
|
+
if (kwMatch) {
|
|
164
|
+
active.push(dimId);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Deduplicate and order by mode emphasis
|
|
171
|
+
const outputMode = workflow.output_mode || brief.expected_outputs?.output_mode;
|
|
172
|
+
const emphasis = MODE_EMPHASIS[outputMode] || [];
|
|
173
|
+
const ordered = [
|
|
174
|
+
...emphasis.filter(d => active.includes(d)),
|
|
175
|
+
...active.filter(d => !emphasis.includes(d)),
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
return [...new Set(ordered)];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Candidate critique ──────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generate structured notes for a single candidate.
|
|
185
|
+
*
|
|
186
|
+
* In this first version, the engine uses the brief's drift guards,
|
|
187
|
+
* canon constraints, and negative prompt to produce rule-based notes.
|
|
188
|
+
* A human or LLM fills in the actual per-image observations via
|
|
189
|
+
* `sdlab critique --run <id>` which creates a template to complete.
|
|
190
|
+
*/
|
|
191
|
+
export function critiqueCandidate({ brief, workflow, candidate, activeDimensions }) {
|
|
192
|
+
const strengths = [];
|
|
193
|
+
const driftIssues = [];
|
|
194
|
+
const preserveNextPass = [];
|
|
195
|
+
const correctNextPass = [];
|
|
196
|
+
|
|
197
|
+
const driftGuards = workflow.drift_guards || [];
|
|
198
|
+
const canonFocus = workflow.canon_focus || [];
|
|
199
|
+
const negatives = (brief.negative_prompt || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
200
|
+
|
|
201
|
+
// Map active dimensions to structured notes
|
|
202
|
+
for (const dimId of activeDimensions) {
|
|
203
|
+
const dim = DIMENSION_CATALOGUE[dimId];
|
|
204
|
+
if (!dim) continue;
|
|
205
|
+
|
|
206
|
+
// For each dimension, generate a review prompt based on what the brief requires.
|
|
207
|
+
// Strengths: what should be achieved for this dimension
|
|
208
|
+
// Drift: what the drift guards warn against
|
|
209
|
+
|
|
210
|
+
// Find drift guards relevant to this dimension
|
|
211
|
+
const relevantGuards = driftGuards.filter(g => {
|
|
212
|
+
const gl = g.toLowerCase();
|
|
213
|
+
return dim.driftGuardKeywords?.some(kw => gl.includes(kw));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (relevantGuards.length > 0) {
|
|
217
|
+
// Add as structured review items
|
|
218
|
+
for (const guard of relevantGuards) {
|
|
219
|
+
driftIssues.push(`[${dim.label}] check: ${guard}`);
|
|
220
|
+
correctNextPass.push(`enforce: ${guard}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Canon focus strengths — what we want to see
|
|
225
|
+
const focusMatch = canonFocus.filter(cf => {
|
|
226
|
+
const dimMap = {
|
|
227
|
+
subject_continuity: ['anatomy'],
|
|
228
|
+
silhouette_read: ['silhouette'],
|
|
229
|
+
material_costume_logic: ['costume_logic', 'material_language'],
|
|
230
|
+
palette_mood: ['palette', 'lighting'],
|
|
231
|
+
composition_framing: ['composition'],
|
|
232
|
+
ornamental_drift: ['costume_logic'],
|
|
233
|
+
era_faction_drift: ['era_logic', 'faction_read'],
|
|
234
|
+
identity_stability: ['anatomy'],
|
|
235
|
+
readability: [],
|
|
236
|
+
lane_fit: [],
|
|
237
|
+
};
|
|
238
|
+
return (dimMap[dimId] || []).includes(cf);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (focusMatch.length > 0) {
|
|
242
|
+
strengths.push(`[${dim.label}] brief targets: ${focusMatch.join(', ')}`);
|
|
243
|
+
preserveNextPass.push(`maintain ${dim.label.toLowerCase()} from this candidate`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Negative prompt items become potential drift issues
|
|
248
|
+
const topNegatives = negatives.slice(0, 5);
|
|
249
|
+
for (const neg of topNegatives) {
|
|
250
|
+
driftIssues.push(`negative check: ${neg}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
filename: candidate.filename,
|
|
255
|
+
seed: candidate.seed,
|
|
256
|
+
overall_fit: 'usable', // Default — human or LLM overrides
|
|
257
|
+
strengths,
|
|
258
|
+
drift_issues: driftIssues,
|
|
259
|
+
preserve_next_pass: preserveNextPass,
|
|
260
|
+
correct_next_pass: correctNextPass,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Run-level recommendation ────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Derive a run-level recommended action from candidate notes.
|
|
268
|
+
*/
|
|
269
|
+
export function recommendAction({ candidates }) {
|
|
270
|
+
if (candidates.length === 0) {
|
|
271
|
+
return { mode: 'discard_run', reason: 'No candidates in run' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const strong = candidates.filter(c => c.overall_fit === 'strong');
|
|
275
|
+
const usable = candidates.filter(c => c.overall_fit === 'usable');
|
|
276
|
+
const offModel = candidates.filter(c => c.overall_fit === 'off-model');
|
|
277
|
+
|
|
278
|
+
// If any are strong, accept the best
|
|
279
|
+
if (strong.length > 0) {
|
|
280
|
+
return {
|
|
281
|
+
mode: 'accept_one',
|
|
282
|
+
preferred_candidate: strong[0].filename,
|
|
283
|
+
reason: `${strong.length} strong candidate(s) — ${strong[0].filename} is the best fit`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// If mostly usable, refine from the best one
|
|
288
|
+
if (usable.length > 0) {
|
|
289
|
+
// Pick the one with fewest drift issues
|
|
290
|
+
const sorted = [...usable].sort((a, b) => a.drift_issues.length - b.drift_issues.length);
|
|
291
|
+
return {
|
|
292
|
+
mode: 'refine_from_one',
|
|
293
|
+
preferred_candidate: sorted[0].filename,
|
|
294
|
+
reason: `${usable.length} usable candidate(s) — ${sorted[0].filename} has fewest drift issues`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// All off-model
|
|
299
|
+
if (offModel.length === candidates.length) {
|
|
300
|
+
return {
|
|
301
|
+
mode: 'discard_run',
|
|
302
|
+
reason: 'All candidates are off-model — brief may need fundamental revision',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Mixed bag — rerun
|
|
307
|
+
return {
|
|
308
|
+
mode: 'rerun_broader',
|
|
309
|
+
reason: 'No clear winner — try broader seed range or adjusted parameters',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── Main critique function ──────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Generate a full critique report for a completed run.
|
|
317
|
+
*
|
|
318
|
+
* @param {Object} opts
|
|
319
|
+
* @param {string} opts.projectRoot — absolute project path
|
|
320
|
+
* @param {string} opts.projectId — project name
|
|
321
|
+
* @param {string} opts.runId — run ID to critique
|
|
322
|
+
* @returns {Promise<Object>} critique report
|
|
323
|
+
*/
|
|
324
|
+
export async function critiqueRun({ projectRoot, projectId, runId }) {
|
|
325
|
+
// Load run manifest
|
|
326
|
+
const manifest = await loadRun(projectRoot, runId);
|
|
327
|
+
const runDir = join(getRunsDir(projectRoot), runId);
|
|
328
|
+
|
|
329
|
+
// Load the brief from the run directory
|
|
330
|
+
const briefPath = join(runDir, 'brief.json');
|
|
331
|
+
if (!existsSync(briefPath)) {
|
|
332
|
+
throw inputError(
|
|
333
|
+
'CRITIQUE_NO_BRIEF',
|
|
334
|
+
`Run "${runId}" has no brief.json`,
|
|
335
|
+
'The run must have a brief artifact to critique against.'
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
|
|
339
|
+
|
|
340
|
+
// Load workflow profile
|
|
341
|
+
const workflow = await getWorkflowProfile(projectRoot, brief.workflow_id);
|
|
342
|
+
|
|
343
|
+
// Determine active dimensions
|
|
344
|
+
const activeDimensions = getActiveDimensions({ brief, workflow });
|
|
345
|
+
|
|
346
|
+
// Build candidate list from manifest outputs
|
|
347
|
+
const successfulOutputs = (manifest.outputs || []).filter(
|
|
348
|
+
o => o.status === 'ok' || o.status === 'dry_run'
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
if (successfulOutputs.length === 0) {
|
|
352
|
+
throw inputError(
|
|
353
|
+
'CRITIQUE_NO_OUTPUTS',
|
|
354
|
+
`Run "${runId}" has no successful outputs to critique`,
|
|
355
|
+
'Run must have at least one successful output.'
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Critique each candidate
|
|
360
|
+
const candidates = successfulOutputs.map(output =>
|
|
361
|
+
critiqueCandidate({
|
|
362
|
+
brief,
|
|
363
|
+
workflow,
|
|
364
|
+
candidate: { filename: output.filename, seed: output.seed },
|
|
365
|
+
activeDimensions,
|
|
366
|
+
})
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
// Derive recommendation
|
|
370
|
+
const recommended = recommendAction({ candidates });
|
|
371
|
+
|
|
372
|
+
// Build report
|
|
373
|
+
const report = {
|
|
374
|
+
run_id: runId,
|
|
375
|
+
project_id: projectId,
|
|
376
|
+
brief_id: brief.brief_id,
|
|
377
|
+
workflow_id: brief.workflow_id,
|
|
378
|
+
created_at: new Date().toISOString(),
|
|
379
|
+
active_dimensions: activeDimensions,
|
|
380
|
+
candidates,
|
|
381
|
+
recommended_action: recommended,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
if (brief.subject_id) {
|
|
385
|
+
report.subject_id = brief.subject_id;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return report;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Save a critique report to the run directory.
|
|
393
|
+
*/
|
|
394
|
+
export async function saveCritique(projectRoot, runId, report, markdown) {
|
|
395
|
+
const runDir = join(getRunsDir(projectRoot), runId);
|
|
396
|
+
await writeFile(join(runDir, 'critique.json'), JSON.stringify(report, null, 2) + '\n');
|
|
397
|
+
await writeFile(join(runDir, 'critique.md'), markdown);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Load a saved critique from a run directory.
|
|
402
|
+
*/
|
|
403
|
+
export function loadCritique(projectRoot, runId) {
|
|
404
|
+
const critiquePath = join(getRunsDir(projectRoot), runId, 'critique.json');
|
|
405
|
+
if (!existsSync(critiquePath)) {
|
|
406
|
+
throw inputError(
|
|
407
|
+
'CRITIQUE_NOT_FOUND',
|
|
408
|
+
`No critique found for run "${runId}"`,
|
|
409
|
+
`Run: sdlab critique --run ${runId}`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return JSON.parse(readFileSync(critiquePath, 'utf-8'));
|
|
413
|
+
}
|