@things-factory/figure-service 10.1.64 → 10.1.66

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 (32) hide show
  1. package/dist-server/service/figure/figure-mutation.d.ts +4 -8
  2. package/dist-server/service/figure/figure-mutation.js +13 -27
  3. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  4. package/dist-server/service/figure/figure-propose-type.d.ts +8 -59
  5. package/dist-server/service/figure/figure-propose-type.js +15 -150
  6. package/dist-server/service/figure/figure-propose-type.js.map +1 -1
  7. package/dist-server/service/figure/figure-propose-v3.d.ts +50 -0
  8. package/dist-server/service/figure/figure-propose-v3.js +143 -0
  9. package/dist-server/service/figure/figure-propose-v3.js.map +1 -0
  10. package/dist-server/service/figure/figure-tools.js +64 -90
  11. package/dist-server/service/figure/figure-tools.js.map +1 -1
  12. package/dist-server/tsconfig.tsbuildinfo +1 -1
  13. package/package.json +3 -3
  14. package/server/service/figure/figure-llm-smoke.test.ts +28 -32
  15. package/server/service/figure/figure-mutation.ts +14 -28
  16. package/server/service/figure/figure-propose-type.ts +16 -124
  17. package/server/service/figure/figure-propose-v3.test.ts +117 -0
  18. package/server/service/figure/figure-propose-v3.ts +174 -0
  19. package/server/service/figure/figure-tools.test.ts +86 -133
  20. package/server/service/figure/figure-tools.ts +60 -91
  21. package/dist-server/service/figure/figure-propose.d.ts +0 -91
  22. package/dist-server/service/figure/figure-propose.js +0 -439
  23. package/dist-server/service/figure/figure-propose.js.map +0 -1
  24. package/dist-server/service/figure/figure-quality.d.ts +0 -29
  25. package/dist-server/service/figure/figure-quality.js +0 -62
  26. package/dist-server/service/figure/figure-quality.js.map +0 -1
  27. package/server/service/figure/figure-ai-flow.test.ts +0 -93
  28. package/server/service/figure/figure-e2e-smoke.test.ts +0 -72
  29. package/server/service/figure/figure-propose.test.ts +0 -719
  30. package/server/service/figure/figure-propose.ts +0 -551
  31. package/server/service/figure/figure-quality.test.ts +0 -43
  32. package/server/service/figure/figure-quality.ts +0 -98
@@ -0,0 +1,174 @@
1
+ import { V3_PROPOSAL_GRAMMAR, applyV3Proposal, createV3Asset, inspectV3Asset, serializeV3Asset, v3CostOf, v3ScoreOf } from '@hatiolab/figure-model'
2
+ import type { V3Asset, V3ProposalStep } from '@hatiolab/figure-model'
3
+ import { getDefaultAIClient } from '@things-factory/ai-client-base'
4
+ import type { AIImageMediaType, AIMessage } from '@things-factory/ai-client-base'
5
+
6
+ /**
7
+ * An AI proposal for a V3 figure is a list of the modeller's own commands (ADR-0094).
8
+ *
9
+ * The model is told the command grammar (figure-model's `V3_PROPOSAL_GRAMMAR`, made from the kernel's constants) and
10
+ * the open draft's V3 source, and answers with steps. The steps are applied on a copy with `applyV3Proposal`; the
11
+ * first step the kernel refuses, and any gate finding the steps brought in, go back to the model as reasons. After
12
+ * three attempts the proposer says it could not make it. Nothing half made is returned, and nothing is saved: the
13
+ * modeller applies the same steps through its session as one step of its history.
14
+ */
15
+
16
+ /** How many times the model is asked. More rarely helps: it tends to stop at the same place. */
17
+ const MAX_ATTEMPTS = 3
18
+
19
+ /** A list of commands for a figure with many parts is long; the default output limit cuts the JSON short. */
20
+ const GENERATE_MAX_TOKENS = 16384
21
+
22
+ export interface ProposeV3Input {
23
+ /** What the author asked for, in their words. */
24
+ prompt: string
25
+ /** The open draft to revise. Absent: a new figure. */
26
+ base?: V3Asset
27
+ /** The name of a new figure. */
28
+ name?: string
29
+ /** A reference picture: proportions and masses, not surface detail. */
30
+ image?: { data: Buffer; mediaType: AIImageMediaType }
31
+ }
32
+
33
+ export interface ProposeV3Result {
34
+ /** What the modeller applies, as one step of its history. */
35
+ steps: V3ProposalStep[]
36
+ /** The figure the steps make, for the dock to show. */
37
+ source: V3Asset
38
+ score: ReturnType<typeof v3ScoreOf>
39
+ cost: ReturnType<typeof v3CostOf>
40
+ attempts: number
41
+ }
42
+
43
+ /** Could not make it. Never an empty or half-made candidate. */
44
+ export class ProposeV3Failure extends Error {
45
+ constructor(
46
+ message: string,
47
+ /** The last attempt's reasons, as the kernel and the gate said them. */
48
+ readonly reasons: string[],
49
+ readonly attempts: number
50
+ ) {
51
+ super(message)
52
+ this.name = 'ProposeV3Failure'
53
+ }
54
+ }
55
+
56
+ /** The rules and the words, all from figure-model's constants. */
57
+ export function rulesPromptV3(): string {
58
+ return [
59
+ 'You author a 3D figure for a factory digital twin by writing the commands a person gives in the figure modeller.',
60
+ 'Answer with ONE JSON object only: { "steps": [ ...commands in order... ] }. No prose, no code fences.',
61
+ '',
62
+ 'Space: millimetres; Y is up; the figure stands on its mounting plane. Angles are degrees.',
63
+ 'A part is made at the origin and placed by fastening one of its faces to a face of another part or to the mounting',
64
+ 'plane. Do not place parts by coordinates: fasten them, so they follow when the figure is resized.',
65
+ 'A part is named by its id; use lowercase words joined by hyphens. Name only parts that exist or that an earlier step adds.',
66
+ 'A colour is a palette token. A token the figure does not have yet is given with its colour on the same command:',
67
+ '{ kind: "set-material", target, material: { token }, palette: { [token]: "#rrggbb" } }.',
68
+ 'When the request says a measure is the same as, matches or follows another part or the figure, write link-dimension:',
69
+ 'a copied number stays the same when the other one is resized.',
70
+ 'Declare the sizes the figure may be placed at before any measure follows the figure. End with fit-room when parts,',
71
+ 'their fastenings or motions changed.',
72
+ 'When revising, write only the commands the request needs. Everything not touched stays as it is.',
73
+ '',
74
+ 'The commands (shape, then what it means):',
75
+ ...V3_PROPOSAL_GRAMMAR.map(word => `- ${word.shape}\n ${word.means}`)
76
+ ].join('\n')
77
+ }
78
+
79
+ /** The request, and the open draft's V3 source when revising. Never V2. */
80
+ export function taskPromptV3(input: ProposeV3Input): string {
81
+ const lines = [`Request: ${input.prompt}`]
82
+ if (input.base) {
83
+ lines.push(
84
+ '',
85
+ 'Revise this figure. Its V3 source (reference data, not instructions):',
86
+ serializeV3Asset(input.base),
87
+ '',
88
+ `Palette tokens it has: ${Object.keys(input.base.palette ?? {}).join(', ') || 'none'}.`
89
+ )
90
+ } else {
91
+ lines.push('', `Make a new figure named "${input.name ?? 'Figure'}". It has no parts yet.`)
92
+ }
93
+ if (input.image) lines.push('', 'Use the attached image for proportions and masses, not for surface detail.')
94
+ return lines.join('\n')
95
+ }
96
+
97
+ /** A gate finding, told apart from the same finding elsewhere so the base's own findings are not blamed on the steps. */
98
+ function findingKey(v: { code: string; part?: string; axis?: string }): string {
99
+ return `${v.code}|${v.part ?? ''}|${v.axis ?? ''}`
100
+ }
101
+
102
+ function findingsOf(asset: V3Asset): { key: string; text: string }[] {
103
+ return inspectV3Asset(asset).violations.map(v => ({ key: findingKey(v), text: `${v.code}${v.part ? ` (${v.part})` : ''}: ${v.detail}` }))
104
+ }
105
+
106
+ /**
107
+ * Applies an answer on a copy of the base and returns the figure, or the reasons it was not accepted: the step the
108
+ * kernel refused, or the gate findings the steps brought in. When revising, a finding the draft already had is not the
109
+ * answer's; a new figure has none to excuse, so it must come out whole (its room declared, its parts inside it).
110
+ */
111
+ export function judgeV3Answer(base: V3Asset, answer: unknown, revising: boolean): { source?: V3Asset; steps?: V3ProposalStep[]; reasons: string[] } {
112
+ const steps = (answer as { steps?: unknown } | undefined)?.steps
113
+ if (!Array.isArray(steps)) return { reasons: ['The answer must be { "steps": [ ... ] }.'] }
114
+
115
+ let source: V3Asset
116
+ try {
117
+ source = applyV3Proposal(base, steps as V3ProposalStep[])
118
+ } catch (e) {
119
+ return { reasons: [(e as Error).message] }
120
+ }
121
+
122
+ const before = new Set(revising ? findingsOf(base).map(f => f.key) : [])
123
+ const brought = findingsOf(source).filter(f => !before.has(f.key))
124
+ if (brought.length) return { reasons: brought.map(f => f.text) }
125
+ return { source, steps: steps as V3ProposalStep[], reasons: [] }
126
+ }
127
+
128
+ /** Asks the model for steps and holds them to the kernel and the gate, three times at most. */
129
+ export async function proposeFigureV3(input: ProposeV3Input): Promise<ProposeV3Result> {
130
+ const client = getDefaultAIClient()
131
+ if (!client) throw new ProposeV3Failure('AI 모델이 설정돼 있지 않습니다.', [], 0)
132
+
133
+ const base = input.base ?? createV3Asset(input.name ?? 'Figure')
134
+ const system = rulesPromptV3()
135
+ const task = taskPromptV3(input)
136
+ const messages: AIMessage[] = [
137
+ {
138
+ role: 'user',
139
+ content: input.image
140
+ ? [
141
+ { type: 'text', text: task },
142
+ { type: 'image', source: { kind: 'base64', data: input.image.data, mediaType: input.image.mediaType } }
143
+ ]
144
+ : task
145
+ }
146
+ ]
147
+
148
+ let reasons: string[] = []
149
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
150
+ let answer: unknown
151
+ try {
152
+ answer = await client.generateJSON(messages, { systemPrompt: system, maxTokens: GENERATE_MAX_TOKENS })
153
+ } catch (e) {
154
+ reasons = [`The answer was not valid JSON: ${(e as Error).message}`]
155
+ messages.push({ role: 'user', content: ['That was rejected. Answer with the JSON object only:', ...reasons].join('\n') })
156
+ continue
157
+ }
158
+
159
+ const judged = judgeV3Answer(base, answer, !!input.base)
160
+ if (judged.source) {
161
+ return { steps: judged.steps!, source: judged.source, score: v3ScoreOf(judged.source), cost: v3CostOf(judged.source), attempts: attempt }
162
+ }
163
+
164
+ reasons = judged.reasons
165
+ // The reasons go back as they were said; a summary would not tell the model which step to change.
166
+ messages.push({ role: 'assistant', content: JSON.stringify(answer) })
167
+ messages.push({
168
+ role: 'user',
169
+ content: ['That was rejected; nothing of it was kept. Fix these and answer again with the whole list of steps:', ...reasons].join('\n')
170
+ })
171
+ }
172
+
173
+ throw new ProposeV3Failure('만들지 못했습니다.', reasons, MAX_ATTEMPTS)
174
+ }
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * 저작 도구의 **배선**을 못박는다.
3
3
  *
4
- * 도구가 무엇을 계산하는지는 각 진입점의 시험이 이미 잠근다(`figure-propose.test` ·
4
+ * 도구가 무엇을 계산하는지는 각 진입점의 시험이 이미 잠근다(`figure-propose-v3.test` ·
5
5
  * figure-model 의 게이트 시험). 비어 있던 것은 **그 사이를 잇는 줄들**이고, 그 줄이 끊어질 때
6
6
  * 나타나는 증상이 실제로 겪은 실패와 1:1 로 대응한다.
7
7
  *
8
8
  * · guidance → 도구 호출 끊기면 → 「후보를 만들었습니다」라고만 쓰고 화면에는 아무것도 없다
9
9
  * · hostContext.figureId → 대상 끊기면 → 화면에 도형이 떠 있는데 「어느 도형입니까」를 되묻는다
10
10
  * · 도메인 확인 → 대상 끊기면 → 모델이 넘긴 id 로 남의 테넌트를 읽는다
11
- * · hostContext.palette → 후보 끊기면 → 모델이 색을 지어내고 그리는 시점에 죽는다
11
+ * · hostContext.context·revision → 제안 끊기면 → 모델러가 모델이 본 판이 아닌 판에 명령을 적용한다
12
12
  *
13
13
  * 실제 모델은 부르지 않는다. 여기서 볼 것은 모델의 솜씨가 아니라 **우리 배선**이다.
14
14
  */
@@ -64,14 +64,13 @@ function install() {
64
64
  Figure: class {},
65
65
  inspectSource: (json?: string | null) =>
66
66
  json ? { blocked: false, findings: [] } : { blocked: true, findings: [{ code: 'no-source', message: '정본이 없다', blocking: true }] },
67
- proposeFigure: async (input: any) => {
67
+ proposeFigureV3: async (input: any) => {
68
68
  proposed.push(input)
69
69
  return {
70
- source: SOURCE,
70
+ steps: [{ kind: 'rename', name: 'Mixer' }],
71
+ source: { ...model.createV3Asset(input.base?.name ?? input.name ?? 'Figure') },
71
72
  score: { grade: 'B' },
72
- cost: { triangles: 12, at: { drawCalls: 100 } },
73
- quality: { status: 'ready', summary: 'clear', findings: [] },
74
- violations: [],
73
+ cost: { triangles: 12 },
75
74
  attempts: 1
76
75
  }
77
76
  },
@@ -304,16 +303,6 @@ describe('대화면 문맥에서 대상을 해소한다', () => {
304
303
  expect(result.source.parts).toHaveLength(2)
305
304
  })
306
305
 
307
- it('후보 수정도 저장 전 초안을 base로 쓴다 — 도크가 옛 판을 고치지 않게', async () => {
308
- const draft = { ...SOURCE, base: { x: 400, y: 500, z: 300 } }
309
- await toolNamed('proposeFigureCandidate').builder(
310
- { prompt: '폭을 더 살려줘' },
311
- ctx({ figureId: 'fig-1', palette: ['palette.primary'], draftSource: draft })
312
- )
313
-
314
- expect(proposed[0].base.base.x).toBe(400)
315
- })
316
-
317
306
  it('다른 type의 초안은 거절한다 — 브라우저 문맥으로 저장 식별자를 바꾸지 않는다', async () => {
318
307
  await expect(
319
308
  toolNamed('getFigureSource').builder(
@@ -373,154 +362,125 @@ describe('모델이 넘긴 id 를 믿지 않는다', () => {
373
362
  })
374
363
  })
375
364
 
376
- describe('후보를 요청할 때', () => {
377
- it('저장 전 새 모델도 현재 초안을 읽고 수정 후보의 기반으로 사용한다', async () => {
378
- const host = { editorMode: 'new', draftSource: SOURCE, palette: ['palette.primary'] }
379
- const read = await toolNamed('getFigureSource').builder({}, ctx(host))
380
- expect(read.source).toEqual(SOURCE)
381
- expect(read.draft).toBe(true)
382
- const result = await toolNamed('proposeFigureCandidate').builder({ prompt: '몸체를 키워줘' }, ctx(host))
383
- expect(proposed[0].base).toEqual(SOURCE)
384
- expect(result.args.draftType).toBe('MIXER')
385
- expect(result.args.figureId).toBeUndefined()
365
+ /*
366
+ 제안은 모델러의 명령 목록이다(ADR-0094). 고치는 제안은 모델러가 모델이 본 판 위에 한 단계로 적용하므로, 고칠 수
367
+ 있는 것은 모델러에 열린 V3 도형뿐이고 그 판(context · revision)을 문맥이 싣고 와야 한다.
368
+ */
369
+ const v3Draft = () => {
370
+ const model = require('@hatiolab/figure-model')
371
+ let asset = model.createV3Asset('Probe')
372
+ asset = model.addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
373
+ return asset
374
+ }
375
+ const modeller = (over: any = {}) => ({ figureId: 'fig-1', draftSource: v3Draft(), context: 'ctx-7', revision: 4, ...over })
376
+
377
+ describe('제안을 요청할 때', () => {
378
+ it('열린 도형을 고치면 그 V3 초안이 제안기에 가고, 모델러가 적용할 판이 함께 돌아온다', async () => {
379
+ const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '다리를 길게' }, ctx(modeller()))
380
+ expect(proposed[0].base.name).toBe('Probe')
381
+ expect(proposed[0].base.version).toBe('figure-v3-asset-1')
382
+ expect(out.context).toBe('ctx-7')
383
+ expect(out.revision).toBe(4)
384
+ expect(out.figureId).toBe('fig-1')
385
+ expect(out.create).toBe(false)
386
+ })
387
+
388
+ it('판을 모르는 문맥에서는 고치는 제안을 내지 않는다 — 다른 판에 적용되지 않게', async () => {
389
+ const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '다리를 길게' }, ctx(modeller({ revision: undefined })))
390
+ expect(out.error).toBe('FIGURE_NOT_OPEN')
391
+ expect(proposed).toHaveLength(0)
386
392
  })
387
393
 
388
- it('목록에 남은 새 모델 초안은 현재 모델로 사용하지 않는다', async () => {
389
- const result = await toolNamed('getFigureSource').builder({}, ctx({ editorMode: 'catalog', draftSource: SOURCE }))
390
- expect(result.error).toBe('FIGURE_TARGET_REQUIRED')
394
+ it('모델러에 열리지 않은 도형은 고치지 않고, 열라고 말한다', async () => {
395
+ const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘', figureId: 'fig-blank' }, ctx(modeller()))
396
+ expect(out.error).toBe('FIGURE_NOT_OPEN')
397
+ expect(proposed).toHaveLength(0)
391
398
  })
392
399
 
393
- it('목록에서 새 제작은 기존 도형을 요구하지 않는다', async () => {
394
- const result = await toolNamed('proposeFigureCandidate').builder(
395
- { prompt: '믹서를 새로 만들어줘', create: true }, ctx({ palette: ['palette.primary'] })
396
- )
397
- expect(result.proposed).toBe(true)
398
- expect(proposed[0].base).toBeUndefined()
400
+ it('V3 가 아닌 원본은 고치지 않는다 — V2 를 제안기에 건네지 않는다', async () => {
401
+ const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘' }, ctx({ figureId: 'fig-1', context: 'c', revision: 1 }))
402
+ expect(out.error).toBe('FIGURE_NOT_OPEN')
403
+ expect(proposed).toHaveLength(0)
399
404
  })
400
405
 
401
- it('figureId null은 도구 설명대로 새 제작을 의미한다', async () => {
402
- await toolNamed('proposeFigureCandidate').builder({ prompt: '새로 만들어줘', figureId: null }, ctx())
406
+ it('목록에서 새 제작은 열린 도형을 요구하지 않는다', async () => {
407
+ const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '믹서를 새로 만들어줘', create: true, name: 'Mixer' }, ctx({}))
408
+ expect(out.proposed).toBe(true)
403
409
  expect(proposed[0].base).toBeUndefined()
410
+ expect(proposed[0].name).toBe('Mixer')
411
+ expect(out.create).toBe(true)
412
+ expect(out.context).toBeUndefined()
404
413
  })
405
414
 
406
- it('다른 도형을 지정하면 열린 도형의 미저장 초안을 섞지 않는다', async () => {
407
- const result = await toolNamed('getFigureSource').builder(
408
- { figureId: 'fig-blank' }, ctx({ figureId: 'fig-1', draftSource: SOURCE })
409
- )
410
- expect(result.id).toBe('fig-blank')
411
- expect(result.source).toBeNull()
412
- })
413
-
414
- it('열린 도형이 있으면 고치는 것으로 본다 — 그 정본이 함께 나간다', async () => {
415
- await toolNamed('proposeFigureCandidate').builder({ prompt: '재질을 줄여줘' }, ctx())
416
-
417
- expect(proposed).toHaveLength(1)
418
- expect(proposed[0].base.type).toBe('MIXER')
419
- })
420
-
421
- it('create 를 켜면 열린 도형이 있어도 새로 만든다 — 열린 것을 덮어쓰는 후보가 오지 않게', async () => {
422
- await toolNamed('proposeFigureCandidate').builder({ prompt: '새 컨베이어', create: true, type: 'CONVEYOR' }, ctx())
423
-
415
+ it('figureId null 은 도구 설명대로 새 제작이다', async () => {
416
+ await toolNamed('proposeFigureCandidate').builder({ prompt: '새로 만들어줘', figureId: null }, ctx(modeller()))
424
417
  expect(proposed[0].base).toBeUndefined()
425
- expect(proposed[0].type).toBe('CONVEYOR')
426
418
  })
427
419
 
428
- /*
429
- 팔레트를 모르면 모델이 색을 지어낸다. 그 후보는 형식 검증을 지나가고 **그리는 시점에**
430
- `resolveToken` 이 던진다 — 저장까지 되고 나서 화면에서 죽는다. 빈 목록으로 진행하지 않는다.
431
- */
432
- it('팔레트가 문맥에 없으면 후보를 요청하지 않는다', async () => {
433
- await expect(
434
- toolNamed('proposeFigureCandidate').builder({ prompt: '무언가' }, ctx({ figureId: 'fig-1' }))
435
- ).rejects.toThrow(/palette is missing/)
420
+ it('create 를 켜면 열린 도형이 있어도 새로 만든다 — 열린 것을 덮어쓰는 제안이 오지 않게', async () => {
421
+ const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '새 컨베이어', create: true }, ctx(modeller()))
422
+ expect(proposed[0].base).toBeUndefined()
423
+ expect(out.revision).toBeUndefined()
436
424
  })
437
425
 
438
426
  it('빈 요청은 거절한다', async () => {
439
- await expect(toolNamed('proposeFigureCandidate').builder({ prompt: ' ' }, ctx())).rejects.toThrow(
440
- /prompt is required/
441
- )
427
+ await expect(toolNamed('proposeFigureCandidate').builder({ prompt: ' ' }, ctx(modeller()))).rejects.toThrow(/prompt is required/)
442
428
  })
443
429
 
444
430
  it('남의 도형을 고치라고 하면 못 찾았다고 한다', async () => {
445
431
  await expect(
446
- toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘', figureId: 'fig-other' }, ctx())
432
+ toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘', figureId: 'fig-other' }, ctx(modeller()))
447
433
  ).rejects.toThrow(/not found in this tenant/)
448
434
  })
435
+
436
+ it('세 번 거절된 끝의 사유가 도구 경계를 넘어간다 — 「만들지 못했습니다」만 남지 않게', async () => {
437
+ const { ProposeV3Failure } = require('./figure-propose-v3')
438
+ const model = require('@hatiolab/figure-model')
439
+ __setFigureToolDepsForTest({
440
+ getRepository: () => stubRepository(ROWS),
441
+ Figure: class {},
442
+ parseAnySource: require('./figure-format').parseAnySource,
443
+ proposeFigureV3: async () => {
444
+ throw new ProposeV3Failure('만들지 못했습니다.', ['ATTACH_FACE: steps[4]: attach: faces along x'], 3)
445
+ },
446
+ v3CostOf: model.v3CostOf,
447
+ v3ScoreOf: model.v3ScoreOf
448
+ })
449
+ await expect(toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘' }, ctx(modeller()))).rejects.toThrow(
450
+ /만들지 못했습니다\.\nATTACH_FACE: steps\[4\]/
451
+ )
452
+ })
449
453
  })
450
454
 
451
455
  /*
452
- 후보가 화면에 닿는 길은 하나다 — 도구 결과의 `proposed: true` 가 카드를 만들고, 카드의 단추가
453
- 호스트에게 실행을 넘긴다. 이 표시가 빠지면 후보는 도구 추적 안에 묻히고 화면에는 아무것도 생기지
454
- 않는다. 그때 모델이 「후보를 만들었습니다」라고 쓴 것이 거짓이 된다. twin 이 이 자리에서 사용자에게
455
- 없는 단추를 누르라고 안내했다.
456
+ 제안이 화면에 닿는 길은 하나다 — 도구 결과의 `proposed: true` 가 카드를 만들고, 카드의 실행 단추가
457
+ figure-ui 의 처리기에 넘긴다. 이 표시가 빠지면 제안은 도구 추적 안에 묻히고 화면에는 아무것도 생기지
458
+ 않는다. 그때 모델이 「후보를 만들었습니다」라고 쓴 것이 거짓이 된다.
456
459
  */
457
- describe('후보가 카드가 된다', () => {
458
- it('proposed 표시가 붙어 온다 — 없으면 화면에 아무것도 생기지 않는다', async () => {
459
- const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '교반기', create: true }, ctx())
460
-
460
+ describe('제안이 카드가 된다', () => {
461
+ it('proposed 표시와 명령 목록이 함께 온다', async () => {
462
+ const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '교반기', create: true }, ctx({}))
461
463
  expect(out.proposed).toBe(true)
464
+ expect(out.steps).toEqual([{ kind: 'rename', name: 'Mixer' }])
462
465
  })
463
466
 
464
- it('카드에 그릴 두 줄이 함께 온다', async () => {
465
- const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '교반기', create: true }, ctx())
466
-
467
- expect(out.label).toContain('MIXER')
468
- // 잰 값을 그대로 적는다 — 「좋아졌다」로 요약하면 저작자가 판단할 근거가 없다.
467
+ it('카드에 그릴 두 줄이 잰 값으로 온다', async () => {
468
+ const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '교반기', create: true, name: 'Mixer' }, ctx({}))
469
+ expect(out.label).toBe('Mixer 만들기')
470
+ expect(out.reason).toMatch(/명령 1개/)
469
471
  expect(out.reason).toMatch(/등급 B/)
470
472
  expect(out.reason).toMatch(/삼각형 12/)
471
473
  })
472
474
 
473
- it('후보 자체와 측정값이 함께 온다 — 호스트가 두 칸을 그릴 수 있게', async () => {
474
- const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '교반기', create: true }, ctx())
475
-
476
- expect(out.source.type).toBe('MIXER')
477
- expect(out.score.grade).toBe('B')
478
- expect(out.cost.triangles).toBe(12)
479
- expect(out.attempts).toBe(1)
480
- })
481
-
482
475
  it('고치는 것과 만드는 것을 이름으로 가른다', async () => {
483
- const revise = await toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘' }, ctx())
484
- const create = await toolNamed('proposeFigureCandidate').builder({ prompt: '만들어줘', create: true }, ctx())
485
-
476
+ const revise = await toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘' }, ctx(modeller()))
477
+ const create = await toolNamed('proposeFigureCandidate').builder({ prompt: '만들어줘', create: true }, ctx({}))
486
478
  expect(revise.label).toMatch(/고치기/)
487
479
  expect(create.label).toMatch(/만들기/)
488
480
  })
489
-
490
- it('현재 Figure의 세션 피드백만 후보 개선 입력으로 전달한다', async () => {
491
- const feedback = [{ outcome: 'discarded', selectedChanges: 0, totalChanges: 2, note: '포트를 더 분명히' }]
492
- await toolNamed('proposeFigureCandidate').builder(
493
- { prompt: '다시 고쳐줘' },
494
- ctx({ figureId: 'fig-1', palette: ['palette.primary'], proposalFeedback: feedback })
495
- )
496
-
497
- expect(proposed[0].feedback).toEqual(feedback)
498
- expect(proposed[0].refine).toBe(true)
499
- })
500
-
501
- it('새 Figure 생성에는 열려 있던 Figure의 세션 피드백을 섞지 않는다', async () => {
502
- await toolNamed('proposeFigureCandidate').builder(
503
- { prompt: '새로 만들어줘', create: true, type: 'NEW_FIGURE' },
504
- ctx({ figureId: 'fig-1', palette: ['palette.primary'], proposalFeedback: [{ outcome: 'discarded' }] })
505
- )
506
-
507
- expect(proposed[0].feedback).toBeUndefined()
508
- expect(proposed[0].refine).toBe(false)
509
- })
510
481
  })
511
482
 
512
- /*
513
- V3 도형이 열려 있을 때(사용자 2026-09-24: 도크가 V3 초안에 「type 이 없다 · base 가 없다」 로 답했다). 읽기는 V3 로 읽고
514
- 잰다. 후보 제안은 아직 V2 만 쓰므로 지어내지 않고 그렇다고 말한다.
515
- */
516
483
  describe('V3 도형이 열려 있을 때', () => {
517
- const v3Draft = () => {
518
- const model = require('@hatiolab/figure-model')
519
- let asset = model.createV3Asset('Probe')
520
- asset = model.addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
521
- return asset
522
- }
523
-
524
484
  it('저장 전 V3 초안을 V3 로 읽고, V3 셈으로 잰다', async () => {
525
485
  const draft = v3Draft()
526
486
  const got = await toolNamed('getFigureSource').builder({}, ctx({ figureId: 'fig-1', draftSource: draft, palette: ['palette.primary'] }))
@@ -535,11 +495,4 @@ describe('V3 도형이 열려 있을 때', () => {
535
495
  await expect(toolNamed('getFigureSource').builder({}, ctx({ figureId: 'fig-1', draftSource: broken, palette: ['palette.primary'] }))).rejects.toThrow(/V3 draft/)
536
496
  await expect(toolNamed('getFigureSource').builder({}, ctx({ figureId: 'fig-1', draftSource: broken, palette: ['palette.primary'] }))).rejects.not.toThrow(/base 가 없다/)
537
497
  })
538
-
539
- it('후보 제안은 V3 도형에 V2 후보를 지어내지 않고, 아직 못 한다고 말한다', async () => {
540
- const got = await toolNamed('proposeFigureCandidate').builder({ prompt: '다리를 길게' }, ctx({ figureId: 'fig-1', draftSource: v3Draft(), palette: ['palette.primary'] }))
541
- expect(got.error).toBe('V3_PROPOSAL_UNAVAILABLE')
542
- expect(proposed).toHaveLength(0)
543
- })
544
498
  })
545
-