@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
package/src/prompt-hint-join.ts
CHANGED
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
* ("prompt. hint", not "prompt . hint"), and drop a blank body so the result
|
|
11
11
|
* never starts with ". ".
|
|
12
12
|
*
|
|
13
|
+
* WHAT THIS JOIN COVERS NOW: the prompt BODY only. A LOOK clause no longer
|
|
14
|
+
* reaches this function from either composer — it lifts into the trailing
|
|
15
|
+
* `[style]` section (`prompt-style-section.ts`), which is `". "`-joined WITHIN a
|
|
16
|
+
* line but hung off the body by a blank line. So "every folded clause is one
|
|
17
|
+
* `". "` further along the same string" stopped being true for a look-carrying
|
|
18
|
+
* call, deliberately; `composeSectionedPrompt` is the whole-prompt shape and
|
|
19
|
+
* this is the piece of it that assembles the body. The zero-hint no-op branch is
|
|
20
|
+
* untouched and still the thing the routes' `composed !== prompt` guard reads.
|
|
21
|
+
*
|
|
13
22
|
* Never mutates its inputs.
|
|
14
23
|
*/
|
|
15
24
|
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE `[style]` SECTION — the trailing block an assembled prompt carries when a
|
|
3
|
+
* run selects any LOOK dimension, shared by the image (`assembleImageInput`)
|
|
4
|
+
* and video (`composeVideoPromptText`) composers so the two surfaces render one
|
|
5
|
+
* shape.
|
|
6
|
+
*
|
|
7
|
+
* <body>
|
|
8
|
+
*
|
|
9
|
+
* [style]:
|
|
10
|
+
* <film line>
|
|
11
|
+
* <scene line>
|
|
12
|
+
*
|
|
13
|
+
* WHY THE LOOK CLAUSES MOVED: folded inline, a broad direction buried the shot
|
|
14
|
+
* — a dozen grade/lighting/era sentences between the user's prose and the
|
|
15
|
+
* structured fields, all in the same register, with nothing telling the model
|
|
16
|
+
* which sentences describe the ACTION and which describe the LOOK. The section
|
|
17
|
+
* says it structurally instead.
|
|
18
|
+
*
|
|
19
|
+
* WHAT STAYS IN THE BODY: the user's prose, the subject fold, the whole MOTION
|
|
20
|
+
* family, and the structured fragment last. Camera motion is part of the shot
|
|
21
|
+
* prose, not the look — so the body/section boundary IS the registry's `family`
|
|
22
|
+
* column, the same column the video verbosity policy splits on. Coupling them
|
|
23
|
+
* is deliberate: one row cannot be shot-prose for the verbosity policy and look
|
|
24
|
+
* for the section.
|
|
25
|
+
*
|
|
26
|
+
* THE SECTION HAS NO TERMINATOR, so "after the section" is not a shape a caller
|
|
27
|
+
* can reach by appending: every assembler downstream of the composer (both
|
|
28
|
+
* reference resolvers, the legacy character-description wrapper, `Style:` /
|
|
29
|
+
* `Avoid:`) joins its text with a single `\n`, which lands it UNDER the header.
|
|
30
|
+
* Two helpers below are how they stay out — `insertBeforeStyleSection` for body
|
|
31
|
+
* content, `endsInsideStyleSection` for the self-labeling control lines that
|
|
32
|
+
* must stay last. A terminator instead would dangle whenever nothing follows,
|
|
33
|
+
* and would break the byte parity below.
|
|
34
|
+
*
|
|
35
|
+
* THE ZERO-CLAUSE CONTRACT (load-bearing): with no look clause — none selected,
|
|
36
|
+
* all shed, or all deduped away — there is NO header and NO extra newline, and
|
|
37
|
+
* the output is byte-identical to the plain hint join. That keeps the
|
|
38
|
+
* verbatim-and-untrimmed no-op alive (zero hints AND zero section → the user's
|
|
39
|
+
* prompt back byte-for-byte, `undefined` included), which is what the routes'
|
|
40
|
+
* `composed !== prompt` guard reads to decide whether to pin
|
|
41
|
+
* `input_data.userPrompt`.
|
|
42
|
+
*
|
|
43
|
+
* NO INDENTATION ANYWHERE: the video reference resolver collapses 2+ horizontal
|
|
44
|
+
* spaces unanchored, so an indented section line would come back flattened. The
|
|
45
|
+
* section is written flush-left rather than relying on that collapse to be
|
|
46
|
+
* harmless.
|
|
47
|
+
*/
|
|
48
|
+
import { joinPromptHints, PROMPT_HINT_SEPARATOR } from "./prompt-hint-join.js"
|
|
49
|
+
import {
|
|
50
|
+
renderDirectionHintClauses,
|
|
51
|
+
type DirectionFamily,
|
|
52
|
+
type DirectionFields,
|
|
53
|
+
type DirectionHintMode,
|
|
54
|
+
type DirectionStyleGroup,
|
|
55
|
+
} from "./direction-registry.js"
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The section header, verbatim. Lowercase and bracketed so it reads as
|
|
59
|
+
* structure rather than as a sentence — and so it can be found again by
|
|
60
|
+
* `buildImagePrompt`'s hybrid line-capitalizer, which must stop here
|
|
61
|
+
* (`[Style]:` would be a different token, and capitalized clause lines would
|
|
62
|
+
* corrupt the lowercase catalog wording).
|
|
63
|
+
*/
|
|
64
|
+
export const STYLE_SECTION_HEADER = "[style]:"
|
|
65
|
+
|
|
66
|
+
/** The blank line between the body and the section. Omitted for an empty body. */
|
|
67
|
+
export const STYLE_SECTION_GAP = "\n\n"
|
|
68
|
+
|
|
69
|
+
/** The section's opening bytes — the gap, the header and the newline before its
|
|
70
|
+
* first clause line (the section is never emitted without one). */
|
|
71
|
+
const STYLE_SECTION_OPENING = `${STYLE_SECTION_GAP}${STYLE_SECTION_HEADER}\n`
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Split a composed prompt into the BODY and the `[style]` section it ends with
|
|
75
|
+
* (`section: ""` when it carries none, the body then being the whole string;
|
|
76
|
+
* the section comes back WITHOUT the gap).
|
|
77
|
+
*
|
|
78
|
+
* Matched from the RIGHT: the composer always emits the section last, and a
|
|
79
|
+
* user's own prose is free to contain the same characters. A blank body drops
|
|
80
|
+
* the gap with it, so the section-only form is matched on its own.
|
|
81
|
+
*/
|
|
82
|
+
export function splitStyleSection(prompt: string): { body: string; section: string } {
|
|
83
|
+
const at = prompt.lastIndexOf(STYLE_SECTION_OPENING)
|
|
84
|
+
if (at >= 0) {
|
|
85
|
+
return { body: prompt.slice(0, at), section: prompt.slice(at + STYLE_SECTION_GAP.length) }
|
|
86
|
+
}
|
|
87
|
+
return prompt.startsWith(`${STYLE_SECTION_HEADER}\n`)
|
|
88
|
+
? { body: "", section: prompt }
|
|
89
|
+
: { body: prompt, section: "" }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extend a composed prompt's BODY with more lines, AHEAD of the `[style]`
|
|
94
|
+
* section — what every assembler downstream of the composer needs, because the
|
|
95
|
+
* section has no terminator: a plain append lands under the header and reads as
|
|
96
|
+
* one more look clause. Reference bindings, element directives and character
|
|
97
|
+
* descriptions are scene content that belongs with the prose, and leaving the
|
|
98
|
+
* look clauses last is where a look tail was measured to cost nothing.
|
|
99
|
+
*
|
|
100
|
+
* With no section this IS the plain `"\n"` join every caller emitted before —
|
|
101
|
+
* the byte-parity path, down to the leading newline an empty prompt produces.
|
|
102
|
+
*/
|
|
103
|
+
export function insertBeforeStyleSection(prompt: string, lines: readonly string[]): string {
|
|
104
|
+
if (lines.length === 0) return prompt
|
|
105
|
+
const block = lines.join("\n")
|
|
106
|
+
const { body, section } = splitStyleSection(prompt)
|
|
107
|
+
if (section.length === 0) return `${prompt}\n${block}`
|
|
108
|
+
return `${body.length > 0 ? `${body}\n${block}` : block}${STYLE_SECTION_GAP}${section}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* True when `prompt` ends INSIDE the section — its last `\n\n`-delimited block
|
|
113
|
+
* opens with the header. What the self-labeling control lines (`Style:`,
|
|
114
|
+
* `Avoid:`) read: they stay at the END of the prompt by design, so a blank line
|
|
115
|
+
* of their own is the only thing that can close the header's scope ahead of
|
|
116
|
+
* them.
|
|
117
|
+
*/
|
|
118
|
+
export function endsInsideStyleSection(prompt: string): boolean {
|
|
119
|
+
const at = prompt.lastIndexOf(STYLE_SECTION_GAP)
|
|
120
|
+
const lastBlock = at >= 0 ? prompt.slice(at + STYLE_SECTION_GAP.length) : prompt
|
|
121
|
+
return lastBlock.startsWith(`${STYLE_SECTION_HEADER}\n`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Where a rendered clause reads in the assembled prompt. */
|
|
125
|
+
export type PromptClauseSlot = "body" | "film" | "scene"
|
|
126
|
+
|
|
127
|
+
/** A rendered clause plus the line it belongs on. */
|
|
128
|
+
export interface SlottedPromptClause {
|
|
129
|
+
readonly text: string
|
|
130
|
+
readonly slot: PromptClauseSlot
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The slot a direction row's clause takes: motion stays in the body, the five
|
|
135
|
+
* `styleGroup: "film"` rows lead the section, every other look row follows on
|
|
136
|
+
* the scene line.
|
|
137
|
+
*/
|
|
138
|
+
export function styleSlotFor(
|
|
139
|
+
row: { readonly family: DirectionFamily; readonly styleGroup?: DirectionStyleGroup },
|
|
140
|
+
): PromptClauseSlot {
|
|
141
|
+
if (row.family === "motion") return "body"
|
|
142
|
+
return row.styleGroup === "film" ? "film" : "scene"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The SUBJECT channel's clauses: always body. Who is in the shot is the noun
|
|
147
|
+
* phrase the look modifies, not part of the look.
|
|
148
|
+
*/
|
|
149
|
+
export function asBodyClauses(texts: readonly string[]): SlottedPromptClause[] {
|
|
150
|
+
return texts.map((text) => ({ text, slot: "body" as const }))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fold a direction bag and tag each clause with its slot, in registry table
|
|
155
|
+
* order. The ORDER is the fold/survival order, not the string order — the
|
|
156
|
+
* composers slice this list from the tail when a cap forces a shed, and only
|
|
157
|
+
* then hand the surviving prefix to `composeSectionedPrompt`.
|
|
158
|
+
*/
|
|
159
|
+
export function partitionStyleClauses(
|
|
160
|
+
direction: DirectionFields | undefined,
|
|
161
|
+
opts: { surface: "image" | "video"; mode?: DirectionHintMode },
|
|
162
|
+
): SlottedPromptClause[] {
|
|
163
|
+
return renderDirectionHintClauses(direction, opts).map((clause) => ({
|
|
164
|
+
text: clause.text,
|
|
165
|
+
slot: styleSlotFor(clause),
|
|
166
|
+
}))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The section block for a set of clauses — `""` when none of them is a look
|
|
171
|
+
* clause. Each line is omitted entirely when its half of the split is empty, so
|
|
172
|
+
* a scene-only fold never emits a blank film line.
|
|
173
|
+
*/
|
|
174
|
+
export function styleSectionFromClauses(clauses: readonly SlottedPromptClause[]): string {
|
|
175
|
+
const line = (slot: PromptClauseSlot) =>
|
|
176
|
+
clauses
|
|
177
|
+
.filter((c) => c.slot === slot)
|
|
178
|
+
.map((c) => c.text)
|
|
179
|
+
.join(PROMPT_HINT_SEPARATOR)
|
|
180
|
+
const lines = [line("film"), line("scene")].filter((l) => l.length > 0)
|
|
181
|
+
return lines.length === 0 ? "" : `${STYLE_SECTION_HEADER}\n${lines.join("\n")}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The section for a raw direction bag — the entry point a client renders its
|
|
186
|
+
* preview through, so the preview and the server emit the same bytes.
|
|
187
|
+
*/
|
|
188
|
+
export function renderStyleSection(
|
|
189
|
+
direction: DirectionFields | undefined,
|
|
190
|
+
opts: { surface: "image" | "video"; mode?: DirectionHintMode },
|
|
191
|
+
): string {
|
|
192
|
+
return styleSectionFromClauses(partitionStyleClauses(direction, opts))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The assembled prompt for a set of clauses: body clauses `". "`-joined onto the
|
|
197
|
+
* user's prompt, the structured fragment last in the body, then the section.
|
|
198
|
+
*
|
|
199
|
+
* TRIMMING follows `joinPromptHints`: the prompt is trimmed whenever ANYTHING
|
|
200
|
+
* folded — a section counts, so a look-only fold trims too, or the blank line
|
|
201
|
+
* would inherit the prompt's trailing whitespace. With nothing folded at all the
|
|
202
|
+
* prompt is returned VERBATIM AND UNTRIMMED (`undefined` passes straight
|
|
203
|
+
* through), which is the byte-parity contract every existing caller rests on.
|
|
204
|
+
*
|
|
205
|
+
* A blank body drops the gap with it, so the result never opens on a newline —
|
|
206
|
+
* the same reason `joinPromptHints` filters a blank prompt out of its join.
|
|
207
|
+
*/
|
|
208
|
+
export function composeSectionedPrompt<T extends string | undefined>(
|
|
209
|
+
userPrompt: T,
|
|
210
|
+
clauses: readonly SlottedPromptClause[],
|
|
211
|
+
structuredFragment: string,
|
|
212
|
+
): string | T {
|
|
213
|
+
const bodyHints = [
|
|
214
|
+
...clauses.filter((c) => c.slot === "body").map((c) => c.text),
|
|
215
|
+
structuredFragment,
|
|
216
|
+
].filter((p) => p.length > 0)
|
|
217
|
+
const section = styleSectionFromClauses(clauses)
|
|
218
|
+
if (section.length === 0) {
|
|
219
|
+
return bodyHints.length === 0 ? userPrompt : joinPromptHints(userPrompt ?? "", bodyHints)
|
|
220
|
+
}
|
|
221
|
+
const body =
|
|
222
|
+
bodyHints.length > 0 ? joinPromptHints(userPrompt ?? "", bodyHints) : (userPrompt ?? "").trim()
|
|
223
|
+
return body.length > 0 ? `${body}${STYLE_SECTION_GAP}${section}` : section
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What each clause costs the assembled prompt, as the EXACT composed-length
|
|
228
|
+
* delta of adding it to the prefix below it. Feeds `keepableDirectionHints`,
|
|
229
|
+
* which walks the list tail-first.
|
|
230
|
+
*
|
|
231
|
+
* Exact deltas rather than "clause + separator" because the section's own bytes
|
|
232
|
+
* are not evenly distributed: the FIRST look clause carries the whole
|
|
233
|
+
* `"\n\n[style]:\n"` header (11 characters that only come back when the section
|
|
234
|
+
* disappears), the second look clause of a line carries a `". "`, the first of
|
|
235
|
+
* the scene line carries a `"\n"`. Flat costs would under-price the header, so
|
|
236
|
+
* the walk would cover a deficit with more clauses than it needs and over-shed
|
|
237
|
+
* — visible as a fold that drops two clauses where one would have fit. The
|
|
238
|
+
* deltas do not change between shed iterations (the walk only ever shortens the
|
|
239
|
+
* prefix), so they are computed once, before the loop.
|
|
240
|
+
*/
|
|
241
|
+
export function sectionedClauseCosts(
|
|
242
|
+
userPrompt: string | undefined,
|
|
243
|
+
clauses: readonly SlottedPromptClause[],
|
|
244
|
+
structuredFragment: string,
|
|
245
|
+
): number[] {
|
|
246
|
+
const lengthAt = (kept: number) =>
|
|
247
|
+
composeSectionedPrompt(userPrompt, clauses.slice(0, kept), structuredFragment)?.length ?? 0
|
|
248
|
+
const costs: number[] = []
|
|
249
|
+
let below = lengthAt(0)
|
|
250
|
+
for (let i = 0; i < clauses.length; i++) {
|
|
251
|
+
const at = lengthAt(i + 1)
|
|
252
|
+
costs.push(at - below)
|
|
253
|
+
below = at
|
|
254
|
+
}
|
|
255
|
+
return costs
|
|
256
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Narrow readers turning UNTRUSTED persisted node data (`workflows.nodes`
|
|
3
3
|
* JSONB — import, MCP write, node preset, a Studio-emitted graph) into the
|
|
4
|
-
* typed `direction` / `structured` levers `assembleImageInput`
|
|
4
|
+
* typed `subject` / `direction` / `structured` levers `assembleImageInput`
|
|
5
|
+
* accepts.
|
|
5
6
|
* `buildPayload` has no zod and workflow writes are
|
|
6
7
|
* `z.record(z.string(), z.unknown())`, so this blob may have been written years
|
|
7
8
|
* ago by any client.
|
|
@@ -46,6 +47,13 @@ import {
|
|
|
46
47
|
DIRECTION_ID_MAX_CHARS,
|
|
47
48
|
type DirectionFields,
|
|
48
49
|
} from "./direction-registry.js"
|
|
50
|
+
import {
|
|
51
|
+
SUBJECT_ARRAY_CEILING,
|
|
52
|
+
SUBJECT_CUSTOM_AGE_KEY,
|
|
53
|
+
SUBJECT_ID_MAX_CHARS,
|
|
54
|
+
getRegisteredSubjectKeys,
|
|
55
|
+
type SubjectFields,
|
|
56
|
+
} from "./subject-registry.js"
|
|
49
57
|
import type { StructuredPromptFields } from "./prompt-builder-structured-fields.js"
|
|
50
58
|
|
|
51
59
|
/**
|
|
@@ -87,6 +95,57 @@ export function readDirectionFields(value: unknown): DirectionFields | undefined
|
|
|
87
95
|
return Object.keys(out).length > 0 ? (out as DirectionFields) : undefined
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
// ── subject ──────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read a node's stored SUBJECT ids — the flat Person / Styling / prop bag the
|
|
102
|
+
* `subject` channel carries. Same three contracts as `readDirectionFields`:
|
|
103
|
+
* drop-never-throw, `undefined` never `{}`, and bounds shared with the wire
|
|
104
|
+
* door by CONSTANT (`SUBJECT_ID_MAX_CHARS` / `SUBJECT_ARRAY_CEILING`, which are
|
|
105
|
+
* defined AS the direction constants) so a body the route accepts and the same
|
|
106
|
+
* node re-run from the canvas cannot disagree about which strings are ids.
|
|
107
|
+
*
|
|
108
|
+
* DERIVED from `getRegisteredSubjectKeys()`, never a hand-authored field list —
|
|
109
|
+
* that is the whole lesson of `readStructuredFields` below, whose hand table is
|
|
110
|
+
* only viable because `StructuredPromptFields` is a small hand-authored type.
|
|
111
|
+
* The subject vocabulary is ~54 catalog-derived fields PLUS deployment-registered
|
|
112
|
+
* pack dimensions unknown at compile time, so it is read from the registry at
|
|
113
|
+
* call time. Iterating the KEY SET (never `Object.keys(value)`) is also what
|
|
114
|
+
* keeps an adversarial blob's key count off the hot path.
|
|
115
|
+
*
|
|
116
|
+
* The SEMANTIC per-dimension cap stays the renderer's job
|
|
117
|
+
* (`normalizeSubjectFields`), exactly as `maxPicks` does for direction: this
|
|
118
|
+
* reader bounds cardinality only. `customAge` is the one number on the wire and
|
|
119
|
+
* is passed through finite-only — the renderer clamps it.
|
|
120
|
+
*/
|
|
121
|
+
export function readSubjectFields(value: unknown): SubjectFields | undefined {
|
|
122
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
123
|
+
const src = value as Record<string, unknown>
|
|
124
|
+
const out: Record<string, string | string[] | number> = {}
|
|
125
|
+
for (const key of getRegisteredSubjectKeys()) {
|
|
126
|
+
const v = src[key]
|
|
127
|
+
if (key === SUBJECT_CUSTOM_AGE_KEY) {
|
|
128
|
+
if (typeof v === "number" && Number.isFinite(v)) out[key] = v
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (typeof v === "string") {
|
|
132
|
+
if (v.length > 0 && v.length <= SUBJECT_ID_MAX_CHARS) out[key] = v
|
|
133
|
+
} else if (Array.isArray(v)) {
|
|
134
|
+
// Junk is filtered BEFORE the cap, so valid ids sitting behind malformed
|
|
135
|
+
// entries survive rather than being crowded out by them.
|
|
136
|
+
const kept: string[] = []
|
|
137
|
+
for (const x of v) {
|
|
138
|
+
if (kept.length >= SUBJECT_ARRAY_CEILING) break
|
|
139
|
+
if (typeof x === "string" && x.length > 0 && x.length <= SUBJECT_ID_MAX_CHARS) {
|
|
140
|
+
kept.push(x)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (kept.length > 0) out[key] = kept
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return Object.keys(out).length > 0 ? (out as SubjectFields) : undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
90
149
|
// ── structured ───────────────────────────────────────────────────────────────
|
|
91
150
|
|
|
92
151
|
type FieldKind = "string" | "number" | "gender"
|