@nodaro/prompts 1.11.0 → 1.13.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/index.cjs +627 -177
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +726 -33
- package/dist/index.d.ts +726 -33
- package/dist/index.js +598 -179
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
- package/src/__tests__/animal-getters-parity.test.ts +82 -0
- package/src/__tests__/assemble-image-input-cap.test.ts +236 -0
- package/src/__tests__/assemble-image-input.test.ts +100 -19
- package/src/__tests__/assemble-video-input-cap.test.ts +442 -0
- package/src/__tests__/assemble-video-input.test.ts +167 -33
- package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
- package/src/__tests__/entity-convergence-image.test.ts +374 -0
- package/src/__tests__/location-convergence-image.test.ts +29 -1
- package/src/__tests__/location-default-role-image.test.ts +166 -0
- package/src/__tests__/mention-splice-spacing.test.ts +257 -0
- package/src/__tests__/prompt-style-section.test.ts +345 -0
- package/src/__tests__/read-node-subject.test.ts +140 -0
- package/src/__tests__/style-section-boundary.test.ts +179 -0
- package/src/__tests__/subject-fold.test.ts +251 -0
- package/src/__tests__/subject-registry.test.ts +312 -0
- package/src/assemble-image-input.ts +169 -41
- package/src/assemble-video-input.ts +200 -25
- package/src/direction-registry.ts +116 -28
- package/src/hint-shedding.ts +87 -0
- package/src/index.ts +3 -0
- package/src/parameter-prompt-hint.ts +8 -7
- package/src/picker-catalogs.ts +14 -7
- package/src/prompt-builder.ts +628 -88
- package/src/prompt-hint-join.ts +9 -0
- package/src/prompt-style-section.ts +256 -0
- package/src/read-node-direction.ts +60 -1
- package/src/subject-registry.ts +464 -0
- package/src/video-reference-resolver.ts +5 -2
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subject registry is the platform-owned contract for the flat `subject`
|
|
3
|
+
* wire channel: WHICH keys ride it, in WHAT order the rows fold, and HOW each
|
|
4
|
+
* catalog renders its selection. Every assertion here is a pin on that
|
|
5
|
+
* contract — a failure means a reorder / retable was intentional and the
|
|
6
|
+
* changeset has to say so.
|
|
7
|
+
*
|
|
8
|
+
* Two pins are load-bearing beyond mere table hygiene:
|
|
9
|
+
* - the FLAT-BAG dedupe (`lipState` suppressing `makeup-bold-lips`), which is
|
|
10
|
+
* the reason the wire is flat and which fails on any future nesting;
|
|
11
|
+
* - the GRAMMAR pin (person/styling arrive as ONE comma-joined clause, never N
|
|
12
|
+
* `". "`-joined fragments), which is why those rows are `kind: "group"`.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from "vitest"
|
|
15
|
+
import {
|
|
16
|
+
MAX_SUBJECT_KEYS,
|
|
17
|
+
SUBJECT_ARRAY_CEILING,
|
|
18
|
+
SUBJECT_CUSTOM_AGE_KEY,
|
|
19
|
+
SUBJECT_FIELDS,
|
|
20
|
+
SUBJECT_FOLD_KEYS,
|
|
21
|
+
SUBJECT_ID_MAX_CHARS,
|
|
22
|
+
SUBJECT_IMAGE_HINT_MODE_DEFAULT,
|
|
23
|
+
SUBJECT_KEYS,
|
|
24
|
+
SUBJECT_VIDEO_HINT_MODE_DEFAULT,
|
|
25
|
+
normalizeSubjectFields,
|
|
26
|
+
renderSubjectHints,
|
|
27
|
+
subjectFieldsForSurface,
|
|
28
|
+
} from "../subject-registry.js"
|
|
29
|
+
import {
|
|
30
|
+
DIRECTION_ARRAY_CEILING,
|
|
31
|
+
DIRECTION_ID_MAX_CHARS,
|
|
32
|
+
DIRECTION_KEYS,
|
|
33
|
+
} from "../direction-registry.js"
|
|
34
|
+
import {
|
|
35
|
+
PERSON_DIMENSION_ORDER,
|
|
36
|
+
PERSON_FIELD_BY_DIMENSION,
|
|
37
|
+
buildPersonHints,
|
|
38
|
+
getPersonPromptHint,
|
|
39
|
+
getPersonTerm,
|
|
40
|
+
} from "../person.js"
|
|
41
|
+
import {
|
|
42
|
+
STYLING_DIMENSION_ORDER,
|
|
43
|
+
STYLING_FIELD_BY_DIMENSION,
|
|
44
|
+
getStylingPromptHint,
|
|
45
|
+
} from "../styling.js"
|
|
46
|
+
import { getHeldPropPromptHint } from "../held-prop.js"
|
|
47
|
+
import { getAnimalPromptHint, getAnimalTerm } from "@nodaro/shared"
|
|
48
|
+
|
|
49
|
+
const IMAGE = { surface: "image" } as const
|
|
50
|
+
const VIDEO = { surface: "video" } as const
|
|
51
|
+
|
|
52
|
+
// Real catalog ids — every getter returns "" on a miss, so a fake id would make
|
|
53
|
+
// most of these assertions vacuously pass.
|
|
54
|
+
const NO_SUCH_ID = "__no_such_id__"
|
|
55
|
+
|
|
56
|
+
describe("SUBJECT_FIELDS — table integrity", () => {
|
|
57
|
+
it("has unique row keys", () => {
|
|
58
|
+
const keys = SUBJECT_FIELDS.map((f) => f.key)
|
|
59
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("gives every row a render function, and every ids row a positive maxPicks", () => {
|
|
63
|
+
for (const spec of SUBJECT_FIELDS) {
|
|
64
|
+
expect(typeof spec.render, spec.key).toBe("function")
|
|
65
|
+
if (spec.kind === "ids") expect(spec.maxPicks, spec.key).toBeGreaterThanOrEqual(1)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("exports SUBJECT_FOLD_KEYS in table order", () => {
|
|
70
|
+
expect(SUBJECT_FOLD_KEYS).toEqual(SUBJECT_FIELDS.map((f) => f.key))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("pins the fold order (a reorder is a deliberate, changeset-worthy change)", () => {
|
|
74
|
+
expect(SUBJECT_FOLD_KEYS).toEqual(["person", "styling", "heldProp", "material", "animal"])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("folds both group rows before any prop row", () => {
|
|
78
|
+
const lastGroup = SUBJECT_FIELDS.map((f) => f.kind).lastIndexOf("group")
|
|
79
|
+
const firstIds = SUBJECT_FIELDS.map((f) => f.kind).indexOf("ids")
|
|
80
|
+
expect(lastGroup).toBeLessThan(firstIds)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("folds every row on both surfaces today", () => {
|
|
84
|
+
expect(subjectFieldsForSurface("image").map((f) => f.key)).toEqual([...SUBJECT_FOLD_KEYS])
|
|
85
|
+
expect(subjectFieldsForSurface("video").map((f) => f.key)).toEqual([...SUBJECT_FOLD_KEYS])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("takes its bounds FROM the direction registry (one literal, both channels)", () => {
|
|
89
|
+
expect(SUBJECT_ID_MAX_CHARS).toBe(DIRECTION_ID_MAX_CHARS)
|
|
90
|
+
expect(SUBJECT_ARRAY_CEILING).toBe(DIRECTION_ARRAY_CEILING)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("defaults image to full clauses and video to compact terms", () => {
|
|
94
|
+
expect(SUBJECT_IMAGE_HINT_MODE_DEFAULT).toBe("full")
|
|
95
|
+
expect(SUBJECT_VIDEO_HINT_MODE_DEFAULT).toBe("compact")
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("SUBJECT_KEYS — the derived wire vocabulary", () => {
|
|
100
|
+
it("is unique and fits the record bound", () => {
|
|
101
|
+
expect(new Set(SUBJECT_KEYS).size).toBe(SUBJECT_KEYS.length)
|
|
102
|
+
expect(SUBJECT_KEYS.length).toBeLessThanOrEqual(MAX_SUBJECT_KEYS)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("carries every person field, every styling field, customAge and the three props", () => {
|
|
106
|
+
for (const d of PERSON_DIMENSION_ORDER) {
|
|
107
|
+
expect(SUBJECT_KEYS, d).toContain(PERSON_FIELD_BY_DIMENSION[d])
|
|
108
|
+
}
|
|
109
|
+
for (const d of STYLING_DIMENSION_ORDER) {
|
|
110
|
+
expect(SUBJECT_KEYS, d).toContain(STYLING_FIELD_BY_DIMENSION[d])
|
|
111
|
+
}
|
|
112
|
+
expect(SUBJECT_KEYS).toContain(SUBJECT_CUSTOM_AGE_KEY)
|
|
113
|
+
expect(SUBJECT_KEYS).toContain("heldProp")
|
|
114
|
+
expect(SUBJECT_KEYS).toContain("material")
|
|
115
|
+
expect(SUBJECT_KEYS).toContain("animal")
|
|
116
|
+
expect(SUBJECT_KEYS.length).toBe(
|
|
117
|
+
PERSON_DIMENSION_ORDER.length + STYLING_DIMENSION_ORDER.length + 1 + 3,
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("excludes the free-text pre/post fields (the v1 carve-out)", () => {
|
|
122
|
+
expect(SUBJECT_KEYS).not.toContain("preText")
|
|
123
|
+
expect(SUBJECT_KEYS).not.toContain("postText")
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("is DISJOINT from DIRECTION_KEYS — nothing folds twice", () => {
|
|
127
|
+
const direction = new Set<string>(DIRECTION_KEYS)
|
|
128
|
+
expect(SUBJECT_KEYS.filter((k) => direction.has(k))).toEqual([])
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe("renderSubjectHints — inertness", () => {
|
|
133
|
+
it("returns [] for undefined, {} and a non-object", () => {
|
|
134
|
+
expect(renderSubjectHints(undefined, IMAGE)).toEqual([])
|
|
135
|
+
expect(renderSubjectHints({}, IMAGE)).toEqual([])
|
|
136
|
+
expect(renderSubjectHints([] as never, IMAGE)).toEqual([])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("ignores unknown wire keys", () => {
|
|
140
|
+
expect(renderSubjectHints({ notAField: "man", hairColor: NO_SUCH_ID }, IMAGE)).toEqual([])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("skips unknown ids instead of 400ing on them", () => {
|
|
144
|
+
expect(renderSubjectHints({ type: NO_SUCH_ID, animal: NO_SUCH_ID }, IMAGE)).toEqual([])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("drops preText / postText — the carve-out that stops a double emission", () => {
|
|
148
|
+
expect(
|
|
149
|
+
renderSubjectHints(
|
|
150
|
+
{ preText: "a lone wanderer", postText: "seen from behind" } as never,
|
|
151
|
+
IMAGE,
|
|
152
|
+
),
|
|
153
|
+
).toEqual([])
|
|
154
|
+
const withPerson = renderSubjectHints(
|
|
155
|
+
{ type: "woman", preText: "a lone wanderer" } as never,
|
|
156
|
+
IMAGE,
|
|
157
|
+
)
|
|
158
|
+
expect(withPerson).toEqual([getPersonPromptHint("woman")])
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe("renderSubjectHints — grammar (the R4 pin)", () => {
|
|
163
|
+
it("emits person as ONE comma-joined clause, not N fragments", () => {
|
|
164
|
+
const bag = { type: "woman", ethnicity: "east-asian", hairBase: "base-short-straight" }
|
|
165
|
+
const out = renderSubjectHints(bag, IMAGE)
|
|
166
|
+
expect(out).toHaveLength(1)
|
|
167
|
+
expect(out[0]).toBe(buildPersonHints(bag, "full").join(", "))
|
|
168
|
+
expect(out[0]).toContain(", ")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("emits styling as its own single clause, after person", () => {
|
|
172
|
+
const out = renderSubjectHints({ type: "woman", makeup: "makeup-smoky" }, IMAGE)
|
|
173
|
+
expect(out).toEqual([getPersonPromptHint("woman"), getStylingPromptHint("makeup-smoky")])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("folds the prop rows after both group rows, in table order", () => {
|
|
177
|
+
const out = renderSubjectHints(
|
|
178
|
+
{ type: "man", heldProp: "smartphone", material: "silk", animal: "dog-corgi" },
|
|
179
|
+
IMAGE,
|
|
180
|
+
)
|
|
181
|
+
expect(out[0]).toBe(getPersonPromptHint("man"))
|
|
182
|
+
expect(out).toContain(getHeldPropPromptHint("smartphone"))
|
|
183
|
+
expect(out[out.length - 1]).toBe(getAnimalPromptHint("dog-corgi"))
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("renders compact terms in compact mode", () => {
|
|
187
|
+
const bag = { type: "not-defined", animal: "dog-corgi" }
|
|
188
|
+
expect(renderSubjectHints(bag, { ...VIDEO, mode: "compact" })).toEqual([
|
|
189
|
+
getPersonTerm("not-defined"),
|
|
190
|
+
getAnimalTerm("dog-corgi"),
|
|
191
|
+
])
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe("renderSubjectHints — the flat-bag behaviors", () => {
|
|
196
|
+
it("dedupes the lipstick clause across the person and styling catalogs (R3)", () => {
|
|
197
|
+
const out = renderSubjectHints(
|
|
198
|
+
{ lipState: "lip-state-bold-red", makeup: "makeup-bold-lips" },
|
|
199
|
+
IMAGE,
|
|
200
|
+
)
|
|
201
|
+
expect(out).toEqual([getPersonPromptHint("lip-state-bold-red")])
|
|
202
|
+
expect(out.join(" ")).not.toContain(getStylingPromptHint("makeup-bold-lips"))
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("keeps a NON-twin makeup pick alongside the bold-red lip state", () => {
|
|
206
|
+
const out = renderSubjectHints(
|
|
207
|
+
{ lipState: "lip-state-bold-red", makeup: "makeup-smoky" },
|
|
208
|
+
IMAGE,
|
|
209
|
+
)
|
|
210
|
+
expect(out).toEqual([
|
|
211
|
+
getPersonPromptHint("lip-state-bold-red"),
|
|
212
|
+
getStylingPromptHint("makeup-smoky"),
|
|
213
|
+
])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("folds midriff + navel into the single neutral safety clause", () => {
|
|
217
|
+
const out = renderSubjectHints(
|
|
218
|
+
{ distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"] },
|
|
219
|
+
IMAGE,
|
|
220
|
+
)
|
|
221
|
+
expect(out).toEqual(["wearing a cropped style, midriff and navel visible"])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("de-duplicates an exact repeated clause, first occurrence winning", () => {
|
|
225
|
+
const out = renderSubjectHints({ heldProp: ["smartphone", "smartphone"] }, IMAGE)
|
|
226
|
+
expect(out).toEqual([getHeldPropPromptHint("smartphone")])
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe("normalizeSubjectFields — the cap the builders do not apply", () => {
|
|
231
|
+
it("slices a multi-pick dimension to its registry limit (jewelry = 3)", () => {
|
|
232
|
+
const ids = ["jewelry-subtle", "jewelry-statement", "jewelry-gold", "jewelry-silver", "jewelry-layered"]
|
|
233
|
+
expect(normalizeSubjectFields({ jewelry: ids })).toEqual({ jewelry: ids.slice(0, 3) })
|
|
234
|
+
const out = renderSubjectHints({ jewelry: ids }, IMAGE)
|
|
235
|
+
expect(out).toHaveLength(1)
|
|
236
|
+
expect(out[0]).toBe(ids.slice(0, 3).map(getStylingPromptHint).join(", "))
|
|
237
|
+
expect(out[0]).not.toContain(getStylingPromptHint("jewelry-silver"))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("slices a 2-pick dimension handed the full array ceiling", () => {
|
|
241
|
+
const eight = Array.from({ length: SUBJECT_ARRAY_CEILING }, (_, i) => `texture-${i}`)
|
|
242
|
+
const normalized = normalizeSubjectFields({ skinTexture: eight }) as Record<string, unknown>
|
|
243
|
+
expect(normalized.skinTexture).toEqual(eight.slice(0, 2))
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("slices the prop rows to their row maxPicks", () => {
|
|
247
|
+
const props = ["smartphone", "smartphone-raised", "polaroid-camera"]
|
|
248
|
+
const normalized = normalizeSubjectFields({ heldProp: props }) as Record<string, unknown>
|
|
249
|
+
expect(normalized.heldProp).toEqual(props.slice(0, 2))
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("collapses a single-pick dimension's array to a bare string — without this it folds to NOTHING", () => {
|
|
253
|
+
expect(normalizeSubjectFields({ hairBase: ["base-buzz"] })).toEqual({ hairBase: "base-buzz" })
|
|
254
|
+
expect(renderSubjectHints({ hairBase: ["base-buzz"] }, IMAGE)).toEqual([
|
|
255
|
+
getPersonPromptHint("base-buzz"),
|
|
256
|
+
])
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("drops unknown keys, so the persisted input_data stays platform vocabulary", () => {
|
|
260
|
+
expect(normalizeSubjectFields({ type: "man", notAField: "x", preText: "y" })).toEqual({
|
|
261
|
+
type: "man",
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("drops empty strings and non-string entries", () => {
|
|
266
|
+
expect(normalizeSubjectFields({ type: "", jewelry: ["", 7 as never, "jewelry-gold"] })).toEqual({
|
|
267
|
+
jewelry: "jewelry-gold",
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it("returns undefined for an empty or all-unknown bag (never {})", () => {
|
|
272
|
+
expect(normalizeSubjectFields(undefined)).toBeUndefined()
|
|
273
|
+
expect(normalizeSubjectFields({})).toBeUndefined()
|
|
274
|
+
expect(normalizeSubjectFields({ notAField: "x" })).toBeUndefined()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it("never mutates the caller's object", () => {
|
|
278
|
+
const bag = { jewelry: ["jewelry-subtle", "jewelry-statement", "jewelry-gold", "jewelry-silver"] }
|
|
279
|
+
const before = JSON.stringify(bag)
|
|
280
|
+
normalizeSubjectFields(bag)
|
|
281
|
+
expect(JSON.stringify(bag)).toBe(before)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("is idempotent (a door may normalize before the renderer does)", () => {
|
|
285
|
+
const bag = { type: "man", jewelry: ["jewelry-gold", "jewelry-silver"], customAge: 34.6 }
|
|
286
|
+
const once = normalizeSubjectFields(bag)
|
|
287
|
+
expect(normalizeSubjectFields(once)).toEqual(once)
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe("customAge — the one number on the wire", () => {
|
|
292
|
+
it("renders the literal age when age === 'age-custom'", () => {
|
|
293
|
+
expect(renderSubjectHints({ age: "age-custom", customAge: 34 }, IMAGE)).toEqual([
|
|
294
|
+
"34 years old",
|
|
295
|
+
])
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it("emits nothing for age-custom with no customAge", () => {
|
|
299
|
+
expect(renderSubjectHints({ age: "age-custom" }, IMAGE)).toEqual([])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it("clamps and rounds to a whole 0..120", () => {
|
|
303
|
+
expect(normalizeSubjectFields({ customAge: 34.6 })).toEqual({ customAge: 35 })
|
|
304
|
+
expect(normalizeSubjectFields({ customAge: -5 })).toEqual({ customAge: 0 })
|
|
305
|
+
expect(normalizeSubjectFields({ customAge: 9999 })).toEqual({ customAge: 120 })
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it("drops a non-finite or non-number customAge", () => {
|
|
309
|
+
expect(normalizeSubjectFields({ customAge: Number.NaN })).toBeUndefined()
|
|
310
|
+
expect(normalizeSubjectFields({ customAge: "34" })).toBeUndefined()
|
|
311
|
+
})
|
|
312
|
+
})
|
|
@@ -16,17 +16,17 @@
|
|
|
16
16
|
* This wrapper collapses them into one.
|
|
17
17
|
*
|
|
18
18
|
* THE NO-OP CONTRACT (load-bearing — the platform-caller parity relies on it):
|
|
19
|
-
* a node that carries NO stored `direction` / `structured` (every
|
|
20
|
-
* authored before the canvas honored them) still reaches here with
|
|
21
|
-
* and `composePromptText` MUST return the caller's `userPrompt`
|
|
22
|
-
* unchanged, so the wrapper degenerates to exactly the
|
|
23
|
-
* call those sites made before. The platform callers
|
|
24
|
-
* `payload-builder`) compose their prompt from the canvas GRAPH themselves and
|
|
25
|
-
* ALSO forward a node's STORED `direction` / `structured` when it
|
|
26
|
-
* (`
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
19
|
+
* a node that carries NO stored `subject` / `direction` / `structured` (every
|
|
20
|
+
* workflow authored before the canvas honored them) still reaches here with all
|
|
21
|
+
* three absent, and `composePromptText` MUST return the caller's `userPrompt`
|
|
22
|
+
* byte-for-byte unchanged, so the wrapper degenerates to exactly the
|
|
23
|
+
* `buildImagePrompt(...)` call those sites made before. The platform callers
|
|
24
|
+
* (`execute-node` / `payload-builder`) compose their prompt from the canvas GRAPH themselves and
|
|
25
|
+
* ALSO forward a node's STORED `subject` / `direction` / `structured` when it
|
|
26
|
+
* carries them (`readSubjectFields` / `readDirectionFields` /
|
|
27
|
+
* `readStructuredFields`); those nodes get the id-hint composition on top,
|
|
28
|
+
* ADDITIVE to the graph-wired cinematography hints the caller already folded
|
|
29
|
+
* into `userPrompt`. Studio and the MCP route supply the levers directly.
|
|
30
30
|
*
|
|
31
31
|
* THE EMPTY-CHECK FLAG (also load-bearing for parity): `execute-node` rejects a
|
|
32
32
|
* truly-empty assembled prompt (its "type one, mention a character, or connect
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* guard (frontend, Studio, route) pass `throwOnEmpty: true`.
|
|
37
37
|
*/
|
|
38
38
|
import {
|
|
39
|
-
|
|
39
|
+
buildImagePromptWithOverflow,
|
|
40
40
|
type BuildImagePromptResult,
|
|
41
41
|
} from "./prompt-builder.js"
|
|
42
42
|
import {
|
|
@@ -44,11 +44,22 @@ import {
|
|
|
44
44
|
type StructuredPromptFields,
|
|
45
45
|
} from "./prompt-builder-structured-fields.js"
|
|
46
46
|
import {
|
|
47
|
-
renderDirectionHints,
|
|
48
47
|
IMAGE_HINT_MODE_DEFAULT,
|
|
49
48
|
type DirectionFields,
|
|
50
49
|
} from "./direction-registry.js"
|
|
51
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
renderSubjectHints,
|
|
52
|
+
SUBJECT_IMAGE_HINT_MODE_DEFAULT,
|
|
53
|
+
type SubjectFields,
|
|
54
|
+
} from "./subject-registry.js"
|
|
55
|
+
import {
|
|
56
|
+
asBodyClauses,
|
|
57
|
+
composeSectionedPrompt,
|
|
58
|
+
partitionStyleClauses,
|
|
59
|
+
sectionedClauseCosts,
|
|
60
|
+
type SlottedPromptClause,
|
|
61
|
+
} from "./prompt-style-section.js"
|
|
62
|
+
import { keepableDirectionHints } from "./hint-shedding.js"
|
|
52
63
|
import type { CharacterDef, ConnectedReference, IdentityMeta } from "@nodaro/shared"
|
|
53
64
|
|
|
54
65
|
/**
|
|
@@ -62,6 +73,14 @@ import type { CharacterDef, ConnectedReference, IdentityMeta } from "@nodaro/sha
|
|
|
62
73
|
*/
|
|
63
74
|
export type { DirectionFields }
|
|
64
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Flat SUBJECT ids (Person / Styling / prop catalogs) — the companion channel
|
|
78
|
+
* to `direction`, describing WHO is in the shot rather than how it is shot. Its
|
|
79
|
+
* table, fold order and per-catalog rendering live in `subject-registry.ts`;
|
|
80
|
+
* this re-export keeps one import path for a consumer that takes both levers.
|
|
81
|
+
*/
|
|
82
|
+
export type { SubjectFields }
|
|
83
|
+
|
|
65
84
|
/**
|
|
66
85
|
* Input to `assembleImageInput`. A faithful SUPERSET of what the two platform
|
|
67
86
|
* callers pass to `buildImagePrompt` today (so they can route through this
|
|
@@ -86,6 +105,13 @@ export interface AssembleImageInput {
|
|
|
86
105
|
* is a no-op for it and the result is byte-identical to today).
|
|
87
106
|
*/
|
|
88
107
|
direction?: DirectionFields
|
|
108
|
+
/**
|
|
109
|
+
* Flat subject ids (Person / Styling / props) → folded into the prompt AHEAD
|
|
110
|
+
* of the direction clauses: the subject is the noun phrase the cinematography
|
|
111
|
+
* then modifies. Same provenance as `direction` — Studio / MCP-route, or the
|
|
112
|
+
* platform callers' narrow-read of a node's STORED `data.subject`.
|
|
113
|
+
*/
|
|
114
|
+
subject?: SubjectFields
|
|
89
115
|
/** Path-1 structured fields → composed fragment appended to the prompt. */
|
|
90
116
|
structured?: StructuredPromptFields
|
|
91
117
|
/**
|
|
@@ -143,52 +169,128 @@ export interface AssembleImageInput {
|
|
|
143
169
|
}
|
|
144
170
|
|
|
145
171
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
172
|
+
* The hint pieces a fold contributes, split by whether the assembler may SHED
|
|
173
|
+
* them under a provider prompt cap.
|
|
174
|
+
*
|
|
175
|
+
* `hintClauses` are the catalog-rendered clauses — the SUBJECT fold first, then
|
|
176
|
+
* the cinematic direction fold — decorative garnish next to a reference
|
|
177
|
+
* directive or the user's own prose, and the only thing this assembler drops
|
|
178
|
+
* when the prompt won't fit. They are ONE list because the shed walks it from
|
|
179
|
+
* the TAIL: direction leaves before subject, which is the right order (who is
|
|
180
|
+
* in the shot outranks how it is lit). `structuredFragment` is user CONTENT (a
|
|
181
|
+
* Path-1 structured field the caller populated), so it is sticky and always
|
|
182
|
+
* ends the BODY, exactly as before — the `[style]` section reads after it.
|
|
183
|
+
*
|
|
184
|
+
* Each clause carries the SLOT it reads in. The list order is the SURVIVAL
|
|
185
|
+
* order; it stopped being the string order when the look clauses lifted into
|
|
186
|
+
* the section (`prompt-style-section.ts`). On this surface every direction row
|
|
187
|
+
* is `look` — the registry has no image-surface motion row — so an image
|
|
188
|
+
* `[style]` section carries the whole direction fold.
|
|
189
|
+
*/
|
|
190
|
+
interface ImageHintPieces {
|
|
191
|
+
/** Subject clauses then direction clauses, each in its registry's fold order. */
|
|
192
|
+
readonly hintClauses: readonly SlottedPromptClause[]
|
|
193
|
+
/** The structured-field fragment ("" when nothing is populated). */
|
|
194
|
+
readonly structuredFragment: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Render the fold's hint pieces once, so the cap-aware retry can re-compose a
|
|
199
|
+
* SUBSET of them without re-rendering the catalogs. Each renderer folds its own
|
|
200
|
+
* channel in its registry's canonical table order (unknown keys and unknown ids
|
|
201
|
+
* contribute nothing), and `renderStructuredFields` returns "" when nothing is
|
|
202
|
+
* populated. Never mutates inputs.
|
|
203
|
+
*
|
|
204
|
+
* SUBJECT LEADS DIRECTION: the subject is the noun phrase ("a woman in her
|
|
205
|
+
* 30s, …") the cinematographic clauses then modify. With no `subject` the list
|
|
206
|
+
* IS the direction fold, so every existing caller sheds identically.
|
|
207
|
+
*/
|
|
208
|
+
function renderImageHintPieces(
|
|
209
|
+
subject: SubjectFields | undefined,
|
|
210
|
+
direction: DirectionFields | undefined,
|
|
211
|
+
structured: StructuredPromptFields | undefined,
|
|
212
|
+
): ImageHintPieces {
|
|
213
|
+
return {
|
|
214
|
+
hintClauses: [
|
|
215
|
+
...asBodyClauses(
|
|
216
|
+
renderSubjectHints(subject, {
|
|
217
|
+
surface: "image",
|
|
218
|
+
mode: SUBJECT_IMAGE_HINT_MODE_DEFAULT,
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
...partitionStyleClauses(direction, {
|
|
222
|
+
surface: "image",
|
|
223
|
+
mode: IMAGE_HINT_MODE_DEFAULT,
|
|
224
|
+
}),
|
|
225
|
+
].filter((c) => c.text.length > 0),
|
|
226
|
+
structuredFragment: structured ? renderStructuredFields(structured) : "",
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Compose the subject + cinematic-direction hints and the structured-field
|
|
232
|
+
* fragment with the user's prompt, keeping the FIRST `keptHintClauses` clauses
|
|
233
|
+
* (the full count on the first pass; fewer only when the provider cap forced a
|
|
234
|
+
* shed). The structured fragment always ends the body; the look clauses that
|
|
235
|
+
* survived follow it in the `[style]` section.
|
|
151
236
|
*
|
|
152
|
-
* EXACT NO-OP CONTRACT: when there are no cinematic/structured hint
|
|
153
|
-
* platform-caller case for a node that carries no stored `
|
|
154
|
-
* `structured` — every workflow authored before the canvas honored
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
237
|
+
* EXACT NO-OP CONTRACT: when there are no subject/cinematic/structured hint
|
|
238
|
+
* pieces (the platform-caller case for a node that carries no stored `subject`/
|
|
239
|
+
* `direction`/`structured` — every workflow authored before the canvas honored
|
|
240
|
+
* them), the
|
|
241
|
+
* user's prompt is returned **verbatim, untrimmed**. This is load-bearing for
|
|
242
|
+
* parity: the old platform path passed the prompt straight to
|
|
243
|
+
* `buildImagePrompt`, which never trims, so trimming here would change the
|
|
158
244
|
* assembled prompt (and the recorded `jobs.input_data`) byte-for-byte. Never
|
|
159
245
|
* mutates inputs.
|
|
160
246
|
*
|
|
161
|
-
* A node that DOES carry `direction`/`structured` takes the
|
|
162
|
-
* therefore trimmed
|
|
163
|
-
*
|
|
247
|
+
* A node that DOES carry `direction`/`structured` takes the fold branch and is
|
|
248
|
+
* therefore trimmed — a `[style]` section counts as folded even when the body
|
|
249
|
+
* gained nothing. Intended, and asserted at the caller level by the
|
|
250
|
+
* payload-builder before/after test.
|
|
164
251
|
*/
|
|
165
252
|
function composePromptText(
|
|
166
253
|
userPrompt: string,
|
|
167
|
-
|
|
168
|
-
|
|
254
|
+
pieces: ImageHintPieces,
|
|
255
|
+
keptHintClauses: number,
|
|
169
256
|
): string {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
257
|
+
return composeSectionedPrompt(
|
|
258
|
+
userPrompt,
|
|
259
|
+
pieces.hintClauses.slice(0, keptHintClauses),
|
|
260
|
+
pieces.structuredFragment,
|
|
261
|
+
)
|
|
175
262
|
}
|
|
176
263
|
|
|
177
264
|
/**
|
|
178
265
|
* Assemble a node's image-generation inputs into a `BuildImagePromptResult`
|
|
179
266
|
* (`{ prompt, nativeNegativePrompt, referenceImageUrls }`).
|
|
180
267
|
*
|
|
181
|
-
* Order: (1) compose the prompt text (no-op when no direction/structured),
|
|
268
|
+
* Order: (1) compose the prompt text (no-op when no subject/direction/structured),
|
|
182
269
|
* (2) `buildImagePrompt(...)` — exactly the call the three sites make today,
|
|
183
|
-
* (3)
|
|
270
|
+
* (3) shed hint clauses and re-assemble while the provider cap overflows,
|
|
271
|
+
* (4) optional post-assembly empty-prompt throw (gated by `throwOnEmpty`).
|
|
272
|
+
*
|
|
273
|
+
* TRUNCATION ORDERING (step 3): `buildImagePrompt`'s cap clamp cuts the TAIL,
|
|
274
|
+
* which is ORDER-BLIND — on a low-cap provider (seedream = 3000) a maximal
|
|
275
|
+
* direction fold renders ~3.3K characters of clauses and the cut can sever a
|
|
276
|
+
* reference directive, mention-resolved text or the user's own prose while a
|
|
277
|
+
* decorative clause survives. So the ASSEMBLER decides instead: it knows which
|
|
278
|
+
* clauses are hints because it just built them, and drops them last-folded
|
|
279
|
+
* first until the prompt fits. Everything else — references, prose, the
|
|
280
|
+
* structured fragment, the Style/Avoid suffixes — outranks a hint. A body that
|
|
281
|
+
* still overflows with ZERO hints (long prose or many directives on its own)
|
|
282
|
+
* falls back to the builder's clamp, unchanged.
|
|
283
|
+
*
|
|
284
|
+
* UNDER-CAP PARITY: the first pass folds every hint, so a prompt that fits is
|
|
285
|
+
* byte-identical to before — the retry only ever runs on an over-cap assembly.
|
|
184
286
|
*/
|
|
185
287
|
export function assembleImageInput(
|
|
186
288
|
input: AssembleImageInput,
|
|
187
289
|
): BuildImagePromptResult {
|
|
188
|
-
const
|
|
290
|
+
const pieces = renderImageHintPieces(input.subject, input.direction, input.structured)
|
|
189
291
|
|
|
190
|
-
const
|
|
191
|
-
prompt,
|
|
292
|
+
const assembleWith = (keptHintClauses: number) => buildImagePromptWithOverflow({
|
|
293
|
+
prompt: composePromptText(input.userPrompt, pieces, keptHintClauses),
|
|
192
294
|
provider: input.provider,
|
|
193
295
|
...(input.connectedReferences !== undefined
|
|
194
296
|
? { connectedReferences: input.connectedReferences }
|
|
@@ -222,8 +324,34 @@ export function assembleImageInput(
|
|
|
222
324
|
: {}),
|
|
223
325
|
})
|
|
224
326
|
|
|
327
|
+
// Fold everything first (the under-cap byte-parity pass), then shed hints
|
|
328
|
+
// from the tail of the COMBINED fold order (subject clauses first in the list,
|
|
329
|
+
// therefore last to leave) while the assembled prompt overflows the provider
|
|
330
|
+
// cap. `keepableDirectionHints` — the one shed arithmetic, shared with
|
|
331
|
+
// `composeVideoPromptText` — strictly decreases `kept` whenever there IS an
|
|
332
|
+
// overflow, so this terminates at `kept === 0` in the worst case, at which
|
|
333
|
+
// point the body overflows on its own and the builder's clamp stands.
|
|
334
|
+
let kept = pieces.hintClauses.length
|
|
335
|
+
let fitted = assembleWith(kept)
|
|
336
|
+
if (fitted.overflowChars > 0) {
|
|
337
|
+
// Priced only on the overflow path: the deltas cost a composition per
|
|
338
|
+
// clause and the fits-first-time case is the common one.
|
|
339
|
+
const costs = sectionedClauseCosts(
|
|
340
|
+
input.userPrompt,
|
|
341
|
+
pieces.hintClauses,
|
|
342
|
+
pieces.structuredFragment,
|
|
343
|
+
)
|
|
344
|
+
const texts = pieces.hintClauses.map((c) => c.text)
|
|
345
|
+
while (fitted.overflowChars > 0 && kept > 0) {
|
|
346
|
+
kept = keepableDirectionHints(texts, kept, fitted.overflowChars, costs)
|
|
347
|
+
fitted = assembleWith(kept)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// `overflowChars` is assembly bookkeeping, not part of the callers' contract.
|
|
351
|
+
const { overflowChars, ...result } = fitted
|
|
352
|
+
|
|
225
353
|
// Post-assembly empty-prompt check (opt-in): a bound entity / `@`-mention /
|
|
226
|
-
// direction chip could have filled the assembled prompt even if the user
|
|
354
|
+
// subject or direction chip could have filled the assembled prompt even if the user
|
|
227
355
|
// typed nothing — so only reject when the FINAL prompt is truly empty.
|
|
228
356
|
if (input.throwOnEmpty && !result.prompt.trim()) {
|
|
229
357
|
throw new Error(
|