Skip to content

Commit 2d0262a

Browse files
committed
fix: exclude non-visual framework fields from vision-expand + stable secret row ids (#2175)
1 parent 3aa0289 commit 2d0262a

3 files changed

Lines changed: 50 additions & 7 deletions

File tree

client/src/components/universe/CharacterDetailEditor.jsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,21 @@ function ListSectionEditor({ section, entry, onPatchList, disabled }) {
248248
// column name. Marshal string → { [col]: string } on the way in and back to
249249
// string on the way out so the section stays server-shape-correct.
250250
const col0 = section.columns[0].name;
251-
const persisted = section.stringList
252-
? rawPersisted.map((s) => ({ [col0]: typeof s === 'string' ? s : '' }))
253-
: rawPersisted;
251+
// Stamp a CONTENT-derived stable id on each marshalled string-list row so
252+
// ListRow's React key (and its `useRowDraft` buffer) survives a delete/reorder
253+
// of an earlier row — a plain index key would shift a sibling's draft onto the
254+
// wrong secret, the exact footgun the ListRow key comment warns about. The
255+
// per-content occurrence counter disambiguates exact-duplicate strings.
256+
const stringListRows = () => {
257+
const seen = new Map();
258+
return rawPersisted.map((s) => {
259+
const text = typeof s === 'string' ? s : '';
260+
const n = seen.get(text) || 0;
261+
seen.set(text, n + 1);
262+
return { id: `${section.key}-${n}-${text}`, [col0]: text };
263+
});
264+
};
265+
const persisted = section.stringList ? stringListRows() : rawPersisted;
254266
const emit = (next) => onPatchList(
255267
section.field,
256268
section.stringList

server/services/universeVisionExpand.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ const LIST_FIELD_SHAPES = {
5656
handGestures: '{ "name": "<gesture>", "description": "<the hand pose>" }',
5757
};
5858

59+
// Non-visual character-framework fields (CWQE Phase 10, #2175). They live in the
60+
// shared STRING_FIELDS / LIST_FIELDS lists (the text/vision expand flows share
61+
// one source of truth) but a vision model CANNOT infer a character's Ghost, Lie,
62+
// Want, Need, or secrets from an image — those are narrative, not renderable. So
63+
// the vision prompt excludes them (they'd only invite hallucinated backstory,
64+
// and `secrets` has no LIST_FIELD_SHAPES row shape to emit). arcType / sliders
65+
// are already absent from both shared lists (merged specially in the text flow),
66+
// so they never reach the vision prompt either.
67+
const NON_VISUAL_FRAMEWORK_FIELDS = new Set(['ghost', 'wound', 'lie', 'want', 'need', 'secrets']);
68+
5969
/**
6070
* Build the vision prompt. The model is told to return ONE JSON object keyed by
6171
* the structured character fields, populating only those it can infer from the
@@ -74,12 +84,16 @@ export function buildVisionExpandPrompt({ name, context, imageCount = 1, blankFi
7484
// Narrow the ask to the fields that are still blank when we have that list;
7585
// otherwise offer the full set. Populated fields are no-clobber server-side
7686
// regardless, so this is purely to focus the model.
87+
// Non-visual framework fields (ghost/lie/…/secrets) are never asked of a
88+
// vision model — filter them out of both the full-set and blank-narrowed asks.
89+
const visualStringFields = STRING_FIELDS.filter((f) => !NON_VISUAL_FRAMEWORK_FIELDS.has(f));
90+
const visualListFields = LIST_FIELDS.filter((f) => !NON_VISUAL_FRAMEWORK_FIELDS.has(f));
7791
const stringTargets = blankFields.length
78-
? STRING_FIELDS.filter((f) => blankFields.includes(f))
79-
: STRING_FIELDS;
92+
? visualStringFields.filter((f) => blankFields.includes(f))
93+
: visualStringFields;
8094
const listTargets = blankFields.length
81-
? LIST_FIELDS.filter((f) => blankFields.includes(f))
82-
: LIST_FIELDS;
95+
? visualListFields.filter((f) => blankFields.includes(f))
96+
: visualListFields;
8397

8498
const stringLines = stringTargets.map((f) => ` "${f}": "<string>"`).join(',\n');
8599
const listLines = listTargets

server/services/universeVisionExpand.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ describe('buildVisionExpandPrompt', () => {
6868
expect(p).toMatch(/"handGestures"/);
6969
});
7070

71+
it('excludes non-visual character-framework fields from the vision prompt (#2175)', () => {
72+
// A vision model cannot infer Ghost/Lie/Want/Need/secrets from an image, and
73+
// `secrets` has no list row-shape — asking for them would invite hallucinated
74+
// backstory / emit `[ undefined ]`. They must never reach the vision schema,
75+
// in either the full-set or blank-narrowed ask.
76+
const full = buildVisionExpandPrompt({ imageCount: 1 });
77+
for (const f of ['ghost', 'wound', 'lie', 'want', 'need', 'secrets']) {
78+
expect(full).not.toMatch(new RegExp(`"${f}"`));
79+
}
80+
// Even when a framework field is blank (and would otherwise be "targeted"),
81+
// it is filtered out — while a real visual field in the same list still shows.
82+
const narrowed = buildVisionExpandPrompt({ imageCount: 1, blankFields: ['lie', 'secrets', 'pronouns'] });
83+
expect(narrowed).not.toMatch(/"lie"/);
84+
expect(narrowed).not.toMatch(/"secrets"/);
85+
expect(narrowed).toMatch(/"pronouns"/);
86+
});
87+
7188
it('is re-exported on __testing', () => {
7289
expect(__testing.buildVisionExpandPrompt).toBe(buildVisionExpandPrompt);
7390
});

0 commit comments

Comments
 (0)