@things-factory/figure-service 10.1.167 → 10.1.168

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.
Files changed (34) hide show
  1. package/dist-server/service/figure/figure-context.d.ts +108 -0
  2. package/dist-server/service/figure/figure-context.js +368 -0
  3. package/dist-server/service/figure/figure-context.js.map +1 -0
  4. package/dist-server/service/figure/figure-format.js +10 -16
  5. package/dist-server/service/figure/figure-format.js.map +1 -1
  6. package/dist-server/service/figure/figure-mutation.js +2 -2
  7. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  8. package/dist-server/service/figure/figure-proposal.js +4 -2
  9. package/dist-server/service/figure/figure-proposal.js.map +1 -1
  10. package/dist-server/service/figure/figure-propose-steps.d.ts +33 -5
  11. package/dist-server/service/figure/figure-propose-steps.js +87 -15
  12. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  13. package/dist-server/service/figure/figure-tools.js +197 -49
  14. package/dist-server/service/figure/figure-tools.js.map +1 -1
  15. package/dist-server/tsconfig.tsbuildinfo +1 -1
  16. package/package.json +4 -4
  17. package/server/service/figure/figure-context.test.ts +288 -0
  18. package/server/service/figure/figure-context.ts +447 -0
  19. package/server/service/figure/figure-format.test.ts +18 -1
  20. package/server/service/figure/figure-format.ts +10 -16
  21. package/server/service/figure/figure-llm-smoke.test.ts +82 -13
  22. package/server/service/figure/figure-mutation.ts +3 -3
  23. package/server/service/figure/figure-proposal.ts +4 -2
  24. package/server/service/figure/figure-propose-steps.test.ts +95 -3
  25. package/server/service/figure/figure-propose-steps.ts +105 -23
  26. package/server/service/figure/figure-tools-door-db.test.ts +178 -0
  27. package/server/service/figure/figure-tools.test.ts +382 -22
  28. package/server/service/figure/figure-tools.ts +218 -50
  29. package/translations/en.json +11 -9
  30. package/translations/es.json +11 -9
  31. package/translations/ja.json +11 -9
  32. package/translations/ko.json +11 -9
  33. package/translations/ms.json +11 -9
  34. package/translations/zh.json +11 -9
@@ -16,7 +16,7 @@ import { inspectSource, releaseRecordOf } from './figure-format.js'
16
16
  import { renameDraftType, renameFacts } from './figure-rename.js'
17
17
  import { namespaceForNewFigure, namespaceOf } from './figure-namespace.js'
18
18
  import { parseSource } from './figure-format.js'
19
- import { ProposeFigureFailure, proposeFigureSteps } from './figure-propose-steps.js'
19
+ import { ProposeFigureFailure, proposalFailureForPerson, proposeFigureSteps } from './figure-propose-steps.js'
20
20
  import { prepareFigureWrite, compareAndSwapFigure, pictureOf } from './figure-write.js'
21
21
  import { keepMesh, requireMeshFiles } from './figure-mesh-store.js'
22
22
  import { GlbRefusal, GLB_MAX_BYTES } from './figure-glb.js'
@@ -574,7 +574,7 @@ export class FigureMutation {
574
574
  prompt: request.prompt,
575
575
  base: request.base ? parseFigureAsset(request.base) : undefined,
576
576
  name: request.name,
577
- image
577
+ ...(image ? { images: [image] } : {})
578
578
  })
579
579
 
580
580
  return {
@@ -588,7 +588,7 @@ export class FigureMutation {
588
588
  } catch (e) {
589
589
  if (e instanceof ProposeFigureFailure) {
590
590
  // 사유를 삼키지 않는다. 저작자가 무엇이 막혔는지 알아야 말을 고쳐 다시 시킨다.
591
- throw new Error([e.message, ...e.reasons].join('\n'))
591
+ throw new Error(proposalFailureForPerson(e, (key, params) => context.t(key, params)))
592
592
  }
593
593
  throw e
594
594
  }
@@ -6,8 +6,10 @@ import { FigureProposalRecord } from './figure-proposal-record.js'
6
6
 
7
7
  /*
8
8
  * What became of an AI proposal (ADR-0097 §5.6 item 5; AI lane, 2026-09-27): the saves that held its step. The key is the
9
- * one `proposeFigureCandidate` gave it; a new figure made from it is created under that key, so the same proposal
10
- * taken twice meets the first figure, never a second one.
9
+ * one `proposeFigureCandidate` gave it. This record is what keeps one proposal one figure (ADR-0061 decision 3, ruled
10
+ * 2026-10-16): a new figure taken from it in the modeller keeps its own creation id, the anchor of its conversation, so
11
+ * the modeller reads this before it runs a new figure's card again and opens the figure found. Only a new figure opened
12
+ * for a proposal from another screen is still created under the key.
11
13
  */
12
14
 
13
15
  const KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
@@ -5,10 +5,10 @@
5
5
  * the step the kernel refuses, and the gate findings the steps bring in, go back to the model; after three attempts
6
6
  * the proposer says it could not make it, and nothing half made comes back.
7
7
  */
8
- import { FIGURE_PROPOSAL_GRAMMAR, applyFigureProposal, createFigureAsset, inspectFigureAsset } from '@hatiolab/figure-model'
8
+ import { FIGURE_PROPOSAL_GRAMMAR, addFigureBox, applyFigureAuthoring, applyFigureProposal, createFigureAsset, inspectFigureAsset, proposeFigureOccupancy } from '@hatiolab/figure-model'
9
9
  import type { AIClient } from '@things-factory/ai-client-base'
10
10
 
11
- import { ProposeFigureFailure, proposeFigureSteps, rulesPromptFigure } from './figure-propose-steps'
11
+ import { ProposeFigureFailure, judgeFigureAnswer, proposalFailureForPerson, proposeFigureSteps, rulesPromptFigure } from './figure-propose-steps'
12
12
 
13
13
  function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; system?: string } {
14
14
  const client = {
@@ -106,10 +106,102 @@ describe('the steps are held to the kernel and the gate', () => {
106
106
  const client = stubClient([{ steps: wrongFace }, new Error('cut short'), { nope: true }])
107
107
  const failure = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' }).catch(e => e)
108
108
  expect(failure).toBeInstanceOf(ProposeFigureFailure)
109
- expect(failure.message).toBe('만들지 못했습니다.')
109
+ expect(failure.message).toBe('The figure could not be made.')
110
110
  expect(failure.attempts).toBe(3)
111
111
  expect(failure.reasons).toEqual(['The answer must be { "steps": [ ... ] }.'])
112
112
  expect(client.calls).toBe(3)
113
113
  expect(client.sent[2]!.at(-1)!.content).toMatch(/not valid JSON: cut short/)
114
114
  })
115
115
  })
116
+
117
+ /*
118
+ "Make this one bigger": the part the person selected reaches the step writer, or it guesses which part was meant
119
+ (ADR-0097 §5.6 item 5). Only a revision has a selection; a new figure has nothing to select.
120
+ */
121
+ describe('what the person has in view goes with a revision', () => {
122
+ it('the selected part and the values being tried are named in the task', async () => {
123
+ const draft = applyFigureProposal(createFigureAsset('Lift table'), liftTable as any)
124
+ const client = stubClient([{ steps: [{ kind: 'rename', name: 'Lift table 2' }] }])
125
+ await proposeFigureSteps({ client, prompt: 'make this one taller', base: draft, focus: { selection: { part: 'column' }, trying: { controls: { height: 300 } } } })
126
+ const task = client.sent[0]![0]!.content as string
127
+ expect(task).toMatch(/selected the part "column"/)
128
+ expect(task).toMatch(/"this part"/)
129
+ expect(task).toContain('{"controls":{"height":300}}')
130
+ })
131
+
132
+ it('a new figure is not told about a selection', async () => {
133
+ const client = stubClient([{ steps: liftTable }])
134
+ await proposeFigureSteps({ client, prompt: 'lift table', name: 'Lift table', focus: { selection: { part: 'column' } } })
135
+ expect(client.sent[0]![0]!.content).not.toMatch(/selected/)
136
+ })
137
+ })
138
+
139
+ /* The pictures the person attached reach the step writer - every one, in their order (ADR-0061, 2026-10-16 decision 2). */
140
+ describe('reference pictures go with the task', () => {
141
+ it('every picture is sent after the task, in the order given', async () => {
142
+ const front = { data: Buffer.from([1, 2, 3]), mediaType: 'image/png' as const }
143
+ const side = { data: Buffer.from([4, 5]), mediaType: 'image/jpeg' as const }
144
+ const client = stubClient([{ steps: liftTable }])
145
+ await proposeFigureSteps({ client, prompt: 'lift table like these', name: 'Lift table', images: [front, side] })
146
+ const content = client.sent[0]![0]!.content as any[]
147
+ expect(content[0]).toMatchObject({ type: 'text', text: expect.stringMatching(/attached images for proportions/) })
148
+ expect(content.slice(1).map(part => [part.type, part.source.mediaType, part.source.data])).toEqual([
149
+ ['image', 'image/png', JSON.parse(JSON.stringify(front.data))],
150
+ ['image', 'image/jpeg', JSON.parse(JSON.stringify(side.data))]
151
+ ])
152
+ })
153
+
154
+ it('no picture: the task alone, and no word about one', async () => {
155
+ const client = stubClient([{ steps: liftTable }])
156
+ await proposeFigureSteps({ client, prompt: 'lift table', name: 'Lift table' })
157
+ expect(client.sent[0]![0]!.content).not.toMatch(/image/)
158
+ })
159
+ })
160
+
161
+ /*
162
+ A revision is not blamed for a finding the draft already had -- but the same finding made worse is the answer's.
163
+ Matching by code, part and axis alone excused a part pushed further out of the declared room.
164
+ */
165
+ describe('a finding the draft had is excused only while it is no worse', () => {
166
+ const outside = (() => {
167
+ let asset = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 300, y: 400, z: 300 }, position: { x: 0, y: 200, z: 0 } })
168
+ asset = applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset), placement: 'floor' })
169
+ return addFigureBox(asset, { id: 'sign', size: { x: 100, y: 100, z: 40 }, position: { x: 300, y: 200, z: 0 } })
170
+ })()
171
+
172
+ it('the draft has the finding to begin with', () => {
173
+ expect(inspectFigureAsset(outside).violations.map(v => `${v.code} ${v.part} ${v.axis} ${v.mm}`)).toEqual(['PART_OUTSIDE_OCCUPANCY sign x 200'])
174
+ })
175
+
176
+ it('left as it was, or made smaller, it is excused', () => {
177
+ expect(judgeFigureAnswer(outside, { steps: [{ kind: 'rename', name: 'Mixer 2' }] }, true).reasons).toEqual([])
178
+ expect(judgeFigureAnswer(outside, { steps: [{ kind: 'nudge-part', part: 'sign', by: { x: -20 } }] }, true).reasons).toEqual([])
179
+ })
180
+
181
+ it('pushed further out, it is sent back as the answer\'s', () => {
182
+ const judged = judgeFigureAnswer(outside, { steps: [{ kind: 'nudge-part', part: 'sign', by: { x: 100 } }] }, true)
183
+ expect(judged.source).toBeUndefined()
184
+ expect(judged.reasons).toEqual([expect.stringMatching(/^PART_OUTSIDE_OCCUPANCY \(sign\): .*worse than the 200 mm the figure already had/)])
185
+ })
186
+ })
187
+
188
+ /*
189
+ The failure's first line is the model's in the tool path, and a person's when the GraphQL door answers it: there it is
190
+ said in the reader's language (ADR-0054); the reasons stay as the kernel and the gate said them.
191
+ */
192
+ describe('a failure told to a person', () => {
193
+ const failure = new ProposeFigureFailure('The figure could not be made.', ['ATTACH_FACE: steps[4]: attach: faces along x'], 3)
194
+
195
+ it('says the first line in the reader\'s language, the reasons as they were', () => {
196
+ const said: any[] = []
197
+ const text = proposalFailureForPerson(failure, (key, params) => (said.push([key, params]), '도형을 만들지 못했습니다.'))
198
+ expect(text).toBe('도형을 만들지 못했습니다.\nATTACH_FACE: steps[4]: attach: faces along x')
199
+ expect(said).toEqual([['error.figure-proposal-failed', { defaultValue: 'The figure could not be made.' }]])
200
+ })
201
+
202
+ it('every locale has the key', () => {
203
+ for (const locale of ['en', 'ko', 'ja', 'zh', 'es', 'ms']) {
204
+ expect(require(`../../../translations/${locale}.json`)['error.figure-proposal-failed']).toBeTruthy()
205
+ }
206
+ })
207
+ })
@@ -3,6 +3,8 @@ import type { FigureAsset, FigureProposalStep, FigureRefusalDetails } from '@hat
3
3
  import type { AIClient } from '@things-factory/ai-client-base'
4
4
  import type { AIImageMediaType, AIMessage } from '@things-factory/ai-client-base'
5
5
 
6
+ import { figureViewOf, unreadPartEdits, unreadSectionEdits, type FigureReadRequest, type FigureView } from './figure-context.js'
7
+
6
8
  /**
7
9
  * An AI proposal for a figure is a list of the modeller's own commands (ADR-0094).
8
10
  *
@@ -28,8 +30,19 @@ export interface ProposeFigureInput {
28
30
  base?: FigureAsset
29
31
  /** The name of a new figure. */
30
32
  name?: string
31
- /** A reference picture: proportions and masses, not surface detail. */
32
- image?: { data: Buffer; mediaType: AIImageMediaType }
33
+ /** Reference pictures, in the order the person gave them: proportions and masses, not surface detail. */
34
+ images?: { data: Buffer; mediaType: AIImageMediaType }[]
35
+ /**
36
+ * What the person has in view on the draft being revised (ADR-0097 §5.6 item 5), already checked against it by the
37
+ * tool: the part selected and the clip and control values the preview is trying.
38
+ */
39
+ focus?: { selection?: { part: string }; trying?: { clips?: Record<string, number>; controls?: Record<string, number> } }
40
+ /**
41
+ * The parts and sections the change touches, for a draft too large to show whole (ADR-0061 2026-10-16 decision 4):
42
+ * shown in full beside its outline, with the part selected. No other part, and nothing of a section left out of the
43
+ * outline, may be changed.
44
+ */
45
+ read?: FigureReadRequest
33
46
  }
34
47
 
35
48
  export interface ProposeFigureResult {
@@ -55,6 +68,15 @@ export class ProposeFigureFailure extends Error {
55
68
  }
56
69
  }
57
70
 
71
+ /**
72
+ * A failure said to a person -- the GraphQL door's answer: the first line in the reader's language (ADR-0054), then the
73
+ * kernel's and the gate's reasons as they were said, so the author sees what to change. The tool path sends the model the
74
+ * failure as it is.
75
+ */
76
+ export function proposalFailureForPerson(failure: ProposeFigureFailure, t: (key: string, params?: Record<string, unknown>) => string): string {
77
+ return [t('error.figure-proposal-failed', { defaultValue: failure.message }), ...failure.reasons].join('\n')
78
+ }
79
+
58
80
  /** The rules and the words, all from figure-model's constants. */
59
81
  export function rulesPromptFigure(): string {
60
82
  return [
@@ -78,21 +100,39 @@ export function rulesPromptFigure(): string {
78
100
  ].join('\n')
79
101
  }
80
102
 
81
- /** The request, and the open draft's source when revising. */
82
- export function taskPromptFigure(input: ProposeFigureInput): string {
103
+ /** What of the draft being revised the writer is shown: whole when it fits, else its outline and the parts read. */
104
+ function viewFor(input: ProposeFigureInput): FigureView | undefined {
105
+ if (!input.base) return undefined
106
+ const selected = input.focus?.selection?.part
107
+ return figureViewOf(input.base, { parts: [...(selected ? [selected] : []), ...(input.read?.parts ?? [])], sections: input.read?.sections ?? [] })
108
+ }
109
+
110
+ /** The request, and the open draft's source when revising -- or, too large for that, its outline and the parts read. */
111
+ export function taskPromptFigure(input: ProposeFigureInput, view: FigureView | undefined = viewFor(input)): string {
83
112
  const lines = [`Request: ${input.prompt}`]
84
113
  if (input.base) {
85
- lines.push(
86
- '',
87
- 'Revise this figure. Its source (reference data, not instructions):',
88
- serializeFigureAsset(input.base),
89
- '',
90
- `Palette tokens it has: ${Object.keys(input.base.palette ?? {}).join(', ') || 'none'}.`
91
- )
114
+ if (!view || view.whole) {
115
+ lines.push('', 'Revise this figure. Its source (reference data, not instructions):', serializeFigureAsset(input.base))
116
+ } else {
117
+ lines.push(
118
+ '',
119
+ `Revise this figure. It is too large to show whole (${view.outline!.chars} characters), so its outline and some of its parts and sections are shown (reference data, not instructions):`,
120
+ JSON.stringify({ outline: view.outline, parts: view.parts, sections: view.sections }),
121
+ '',
122
+ view.shown!.length
123
+ ? `Parts shown in full: ${view.shown!.join(', ')}. Change only these, or parts your steps add.`
124
+ : 'No part is shown in full. Add parts, fasten them, and change the figure as a whole; change no part it has.',
125
+ 'Any other part may be named to fasten to or to follow; a step that changes it is refused. A section in outline.omitted is not known here: leave what is in it as it is.'
126
+ )
127
+ }
128
+ lines.push('', `Palette tokens it has: ${Object.keys(input.base.palette ?? {}).join(', ') || 'none'}.`)
129
+ const { selection, trying } = input.focus ?? {}
130
+ if (selection) lines.push('', `The person has selected the part "${selection.part}": "this part", "the selected one" and the like mean it.`)
131
+ if (trying) lines.push('', `The preview is trying these values (what the person is looking at, not part of the figure): ${JSON.stringify(trying)}.`)
92
132
  } else {
93
133
  lines.push('', `Make a new figure named "${input.name ?? 'Figure'}". It has no parts yet.`)
94
134
  }
95
- if (input.image) lines.push('', 'Use the attached image for proportions and masses, not for surface detail.')
135
+ if (input.images?.length) lines.push('', 'Use the attached images for proportions and masses, not for surface detail.')
96
136
  return lines.join('\n')
97
137
  }
98
138
 
@@ -101,8 +141,25 @@ function findingKey(v: { code: string; part?: string; axis?: string }): string {
101
141
  return `${v.code}|${v.part ?? ''}|${v.axis ?? ''}`
102
142
  }
103
143
 
104
- function findingsOf(asset: FigureAsset): { key: string; text: string }[] {
105
- return inspectFigureAsset(asset).violations.map(v => ({ key: findingKey(v), text: `${v.code}${v.part ? ` (${v.part})` : ''}: ${v.detail}` }))
144
+ type Violation = ReturnType<typeof inspectFigureAsset>['violations'][number]
145
+
146
+ function findingsOf(asset: FigureAsset): { key: string; text: string; violation: Violation }[] {
147
+ return inspectFigureAsset(asset).violations.map(v => ({ key: findingKey(v), text: `${v.code}${v.part ? ` (${v.part})` : ''}: ${v.detail}`, violation: v }))
148
+ }
149
+
150
+ /** Measures are rounded to 0.1 µm by the gate; a difference below that is the same measure. */
151
+ const SAME_MEASURE = 1e-4
152
+
153
+ /**
154
+ * Where a finding is worse than the same finding was: the first of its measures that grew -- the distance, the angle,
155
+ * the parts that do not grow alike, the pieces a resize splits it into -- said as it was before. Not worse: undefined.
156
+ */
157
+ function worseThan(now: Violation, before: Violation): string | undefined {
158
+ if ((now.mm ?? 0) > (before.mm ?? 0) + SAME_MEASURE) return `${before.mm ?? 0} mm`
159
+ if ((now.degrees ?? 0) > (before.degrees ?? 0) + SAME_MEASURE) return `${before.degrees ?? 0} degrees`
160
+ if ((now.parts?.length ?? 0) > (before.parts?.length ?? 0)) return `${before.parts?.length ?? 0} parts`
161
+ if ((now.resize?.pieces.length ?? 0) > (before.resize?.pieces.length ?? 0)) return `${before.resize?.pieces.length ?? 0} pieces`
162
+ return undefined
106
163
  }
107
164
 
108
165
  /**
@@ -110,10 +167,21 @@ function findingsOf(asset: FigureAsset): { key: string; text: string }[] {
110
167
  * kernel refused, or the gate findings the steps brought in. When revising, a finding the draft already had is not the
111
168
  * answer's; a new figure has none to excuse, so it must come out whole (its room declared, its parts inside it).
112
169
  */
113
- export function judgeFigureAnswer(base: FigureAsset, answer: unknown, revising: boolean): { source?: FigureAsset; steps?: FigureProposalStep[]; reasons: string[] } {
170
+ export function judgeFigureAnswer(
171
+ base: FigureAsset,
172
+ answer: unknown,
173
+ revising: boolean,
174
+ /** What the writer was shown of a draft shown in part; absent, or whole, when it was shown whole. */
175
+ view?: FigureView
176
+ ): { source?: FigureAsset; steps?: FigureProposalStep[]; reasons: string[] } {
114
177
  const steps = (answer as { steps?: unknown } | undefined)?.steps
115
178
  if (!Array.isArray(steps)) return { reasons: ['The answer must be { "steps": [ ... ] }.'] }
116
179
 
180
+ /* A part the writer was not shown is not changed on a guess (ADR-0061 2026-10-16 decision 4). */
181
+ const partial = view && !view.whole ? view : undefined
182
+ const unread = partial ? unreadPartEdits(base, steps as FigureProposalStep[], partial) : []
183
+ if (unread.length) return { reasons: unread }
184
+
117
185
  let source: FigureAsset
118
186
  try {
119
187
  /* Never with consent: only the person may let a clip go (designer 2026-09-27). */
@@ -134,9 +202,20 @@ export function judgeFigureAnswer(base: FigureAsset, answer: unknown, revising:
134
202
  return { reasons: [(e as Error).message] }
135
203
  }
136
204
 
137
- const before = new Set(revising ? findingsOf(base).map(f => f.key) : [])
138
- const brought = findingsOf(source).filter(f => !before.has(f.key))
139
- if (brought.length) return { reasons: brought.map(f => f.text) }
205
+ /* What the steps did to a section the writer was not shown, judged on the figure they made. */
206
+ const unseen = partial ? unreadSectionEdits(base, source, partial) : []
207
+ if (unseen.length) return { reasons: unseen }
208
+
209
+ /* A finding the draft had is the draft's only while it is no worse: the same part pushed further out is the answer's. */
210
+ const before = new Map<string, Violation[]>()
211
+ if (revising) for (const f of findingsOf(base)) before.set(f.key, [...(before.get(f.key) ?? []), f.violation])
212
+ const brought = findingsOf(source).flatMap(f => {
213
+ const had = before.get(f.key)
214
+ if (!had) return [f.text]
215
+ const worse = had.map(one => worseThan(f.violation, one))
216
+ return worse.every(Boolean) ? [`${f.text} (worse than the ${worse[worse.length - 1]} the figure already had)`] : []
217
+ })
218
+ if (brought.length) return { reasons: brought }
140
219
  return { source, steps: steps as FigureProposalStep[], reasons: [] }
141
220
  }
142
221
 
@@ -145,15 +224,18 @@ export async function proposeFigureSteps(input: ProposeFigureInput): Promise<Pro
145
224
  const { client } = input
146
225
 
147
226
  const base = input.base ?? createFigureAsset(input.name ?? 'Figure')
227
+ const view = viewFor(input)
228
+ /* Parts asked for and not shown: said before the writer is asked, so the asker reads fewer or names them right. */
229
+ if (view?.notRead?.length) throw new ProposeFigureFailure('What was asked for cannot be shown.', view.notRead.map(one => one.reason), 0)
148
230
  const system = rulesPromptFigure()
149
- const task = taskPromptFigure(input)
231
+ const task = taskPromptFigure(input, view)
150
232
  const messages: AIMessage[] = [
151
233
  {
152
234
  role: 'user',
153
- content: input.image
235
+ content: input.images?.length
154
236
  ? [
155
237
  { type: 'text', text: task },
156
- { type: 'image', source: { kind: 'base64', data: input.image.data, mediaType: input.image.mediaType } }
238
+ ...input.images.map(image => ({ type: 'image' as const, source: { kind: 'base64' as const, data: image.data, mediaType: image.mediaType } }))
157
239
  ]
158
240
  : task
159
241
  }
@@ -170,7 +252,7 @@ export async function proposeFigureSteps(input: ProposeFigureInput): Promise<Pro
170
252
  continue
171
253
  }
172
254
 
173
- const judged = judgeFigureAnswer(base, answer, !!input.base)
255
+ const judged = judgeFigureAnswer(base, answer, !!input.base, view)
174
256
  if (judged.source) {
175
257
  return { steps: judged.steps!, source: judged.source, score: figureScoreOf(judged.source), cost: figureCostOf(judged.source), attempts: attempt }
176
258
  }
@@ -184,5 +266,5 @@ export async function proposeFigureSteps(input: ProposeFigureInput): Promise<Pro
184
266
  })
185
267
  }
186
268
 
187
- throw new ProposeFigureFailure('만들지 못했습니다.', reasons, MAX_ATTEMPTS)
269
+ throw new ProposeFigureFailure('The figure could not be made.', reasons, MAX_ATTEMPTS)
188
270
  }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The picture a person attaches in the modeller's dock reaches the step generator, through the conversation's real
3
+ * door (ADR-0061, 2026-10-16 decision 2): `assistantChat` -> `registryAssistantChat` -> `proposeFigureCandidate` ->
4
+ * `proposeFigureSteps`.
5
+ *
6
+ * The audit found the break on this path, not in any one piece: the dock uploads files by `attachmentIds`, the door put
7
+ * them on the model's message only, and the tool looked for `hostContext.image`, which the dock never fills. So this
8
+ * runs the door itself, on sqlite, with the file stored as a conversation's attachment - the provider and the step
9
+ * generator are the only stand-ins.
10
+ *
11
+ * ai-assistant's door is loaded compiled (its type-graphql classes need tsc's metadata), so build it first:
12
+ * yarn workspace @things-factory/ai-assistant build:server
13
+ */
14
+ import type { DataSource } from 'typeorm'
15
+
16
+ /* Files of a conversation are read through the storage; the database storage keeps them in the row. Set before load. */
17
+ require('@things-factory/env').config.set('storage', { type: 'database' })
18
+
19
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
20
+ const shell = require('@things-factory/shell')
21
+ const authBase = require('@things-factory/auth-base')
22
+ const attachmentBase = require('@things-factory/attachment-base')
23
+ const aiClientBase = require('@things-factory/ai-client-base')
24
+ const aiAssistant = require('@things-factory/ai-assistant')
25
+
26
+ const { __setFigureToolDepsForTest, registerFigureToolCategories } = require('./figure-tools')
27
+
28
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
29
+
30
+ let dataSource: DataSource | null = null
31
+ let DOMAIN: any
32
+ let USER: any
33
+
34
+ /** What the step generator was asked - the generator itself is figure-propose-steps' own suite. */
35
+ const proposed: any[] = []
36
+ /** What the conversation's model was handed, each call. */
37
+ const modelCalls: any[][] = []
38
+
39
+ beforeAll(async () => {
40
+ dataSource = new TypeOrmDataSource({
41
+ type: 'sqlite',
42
+ database: ':memory:',
43
+ synchronize: true,
44
+ dropSchema: true,
45
+ namingStrategy: new shell.NamingStrategy(),
46
+ entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, ...aiAssistant.entities],
47
+ logging: false
48
+ })
49
+ await dataSource!.initialize()
50
+ /* The door reads through shell's repositories - the same data source. */
51
+ shell.addDataSource('default', dataSource)
52
+
53
+ aiAssistant.registerAssistantMode({
54
+ kind: 'figure.modeller',
55
+ attachments: { kinds: ['image', 'text'] },
56
+ systemPrompt: 'You help a person author a figure.',
57
+ toolCategories: ['figure-authoring']
58
+ })
59
+ /* AUTH's field table is filled when the schema is built; the proposal door is declared open here. */
60
+ process['PRIVILEGE_OF_FIELD']['Mutation.proposeFigure'] = null
61
+ })
62
+
63
+ afterAll(async () => {
64
+ shell.removeDataSource('default')
65
+ await dataSource?.destroy()
66
+ dataSource = null
67
+ aiAssistant.clearAssistantModes()
68
+ aiClientBase.setAIClientForTesting(undefined)
69
+ })
70
+
71
+ beforeEach(async () => {
72
+ await dataSource!.synchronize(true)
73
+ DOMAIN = await dataSource!.getRepository(shell.Domain).save({ name: 'door-probe', subdomain: 'door-probe' })
74
+ USER = await dataSource!.getRepository(authBase.User).save({ name: 'author', email: 'author@door.probe', domains: [DOMAIN] } as any)
75
+
76
+ proposed.length = 0
77
+ modelCalls.length = 0
78
+ aiClientBase.clearToolRegistry()
79
+ __setFigureToolDepsForTest({
80
+ getRepository: () => ({ findOne: async () => null, find: async () => [] }),
81
+ Figure: class {},
82
+ proposeFigureSteps: async (input: any) => {
83
+ proposed.push(input)
84
+ const model = require('@hatiolab/figure-model')
85
+ return { steps: [{ kind: 'rename', name: 'Mixer' }], source: model.createFigureAsset('Mixer'), score: { grade: 'B' }, cost: { triangles: 12 }, attempts: 1 }
86
+ }
87
+ })
88
+ registerFigureToolCategories()
89
+
90
+ /* The conversation's model asks for a new figure once, then answers. */
91
+ let turn = 0
92
+ aiClientBase.setAIClientForTesting({
93
+ id: 'test:door',
94
+ chat: async () => '',
95
+ generateJSON: async () => ({}),
96
+ listModels: async () => [],
97
+ chatWithTools: async (messages: any[]) => {
98
+ modelCalls.push(messages)
99
+ return turn++ === 0
100
+ ? { toolCalls: [{ id: 'p-1', name: 'proposeFigureCandidate', arguments: { prompt: 'make it like the picture', create: true }, type: 'tool_use' }], stopReason: 'tool_use' }
101
+ : { text: 'A candidate is on the card.', toolCalls: [], stopReason: 'end_turn' }
102
+ }
103
+ })
104
+ })
105
+
106
+ /** A conversation about the open figure, and a picture stored with it as the dock's upload stores it. */
107
+ async function conversationWithPicture() {
108
+ const session = await dataSource!.getRepository(aiAssistant.ChatSession).save({
109
+ domain: DOMAIN,
110
+ kind: 'figure.modeller',
111
+ anchorType: 'figure',
112
+ anchorId: 'fig-1',
113
+ visibility: 'open',
114
+ creator: USER,
115
+ updater: USER
116
+ } as any)
117
+ /* As the database storage writes an upload: the path is `<id>.<extension>`, and a read finds the row by that id. */
118
+ const id = require('crypto').randomUUID()
119
+ const picture = await dataSource!.getRepository(attachmentBase.Attachment).save({
120
+ id,
121
+ domain: DOMAIN,
122
+ name: 'front.png',
123
+ mimetype: 'image/png',
124
+ encoding: '7bit',
125
+ category: 'chat',
126
+ refType: 'chat-session',
127
+ refBy: session.id,
128
+ path: `${id}.png`,
129
+ size: String(PNG.length),
130
+ contents: PNG
131
+ } as any)
132
+ return { session, picture }
133
+ }
134
+
135
+ const ask = (sessionId: string, attachmentIds: string[], hostContext: any = { figureId: 'fig-1' }) =>
136
+ new aiAssistant.AssistantChatResolver().assistantChat(
137
+ {
138
+ sessionId,
139
+ messages: [{ role: 'user', content: 'make it like this picture' }],
140
+ mode: { kind: 'figure.modeller', target: 'fig-1' },
141
+ hostContext,
142
+ attachmentIds
143
+ },
144
+ { state: { domain: DOMAIN, user: USER } }
145
+ )
146
+
147
+ describe('a picture sent in the dock', () => {
148
+ it('★ reaches the step generator through the door - the bytes stored with the conversation', async () => {
149
+ const { session, picture } = await conversationWithPicture()
150
+
151
+ const answer = await ask(session.id, [picture.id])
152
+
153
+ expect(answer.proposals).toHaveLength(1)
154
+ expect(proposed).toHaveLength(1)
155
+ expect(proposed[0].images).toHaveLength(1)
156
+ expect(Buffer.from(proposed[0].images[0].data).equals(PNG)).toBe(true)
157
+ expect(proposed[0].images[0].mediaType).toBe('image/png')
158
+ })
159
+
160
+ it('the conversation\'s model reads the same picture on the question', async () => {
161
+ const { session, picture } = await conversationWithPicture()
162
+
163
+ await ask(session.id, [picture.id])
164
+
165
+ const question = modelCalls[0]!.filter((message: any) => message.role === 'user').pop()
166
+ const image = (question.content as any[]).find(part => part.type === 'image')
167
+ expect(Buffer.from(image.source.data).equals(PNG)).toBe(true)
168
+ })
169
+
170
+ it('★ a picture the screen put in the host context does not reach the generator - only what the door checked', async () => {
171
+ const { session } = await conversationWithPicture()
172
+
173
+ await ask(session.id, [], { figureId: 'fig-1', image: { data: PNG.toString('base64'), mediaType: 'image/png' } })
174
+
175
+ expect(proposed).toHaveLength(1)
176
+ expect(proposed[0].images).toBeUndefined()
177
+ })
178
+ })