@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.64",
3
+ "version": "10.1.66",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -23,7 +23,7 @@
23
23
  "migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
24
24
  },
25
25
  "dependencies": {
26
- "@hatiolab/figure-model": "^0.1.75",
26
+ "@hatiolab/figure-model": "^0.1.76",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
28
  "@things-factory/ai-client-base": "^10.1.53",
29
29
  "@things-factory/auth-base": "^10.1.47",
@@ -33,5 +33,5 @@
33
33
  "graphql-upload": "^15.0.2",
34
34
  "koa-router": "^7.4.0"
35
35
  },
36
- "gitHead": "9e10bdcadcc265c8e48b37a772d861221c4680ba"
36
+ "gitHead": "f92ae2a32e389f17eba0ce5770a98866ac3b1209"
37
37
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * 실제 LLM Figure 후보 생성 smoke test (기본 비활성).
3
3
  *
4
- * 결정적 시험은 프롬프트·도구·검사 배선을 고정하지만, 실제 모델이 FigureSource JSON을 지키는지는
5
- * 대신할 수 없다. 이 시험은 `RUN_FIGURE_LLM_SMOKE=1`과 공급자 API 키가 모두 있을 때만 실행한다.
4
+ * 결정적 시험은 프롬프트·도구·검사 배선을 고정하지만, 실제 모델이 V3 명령 목록(ADR-0094)을 써서 게이트를
5
+ * 지나는 도형을 만드는지는 대신할 수 없다. 이 시험은 `RUN_FIGURE_LLM_SMOKE=1`과 공급자 API 키가 모두 있을 때만 실행한다.
6
6
  * DB·GraphQL·Figure 저장을 건드리지 않고 후보 생성 함수만 호출하므로, 외부 영향은 API 비용뿐이다.
7
7
  *
8
8
  * 실행:
@@ -11,7 +11,9 @@
11
11
  */
12
12
  import { createAIClient, setDefaultAIClient } from '@things-factory/ai-client-base'
13
13
 
14
- import { proposeFigure } from './figure-propose'
14
+ import { applyV3Proposal, createV3Asset, inspectV3Asset } from '@hatiolab/figure-model'
15
+
16
+ import { proposeFigureV3 } from './figure-propose-v3'
15
17
 
16
18
  // Provider modules register themselves on import. Keep this separate from the deterministic tests.
17
19
  import '@things-factory/ai-client-base'
@@ -35,7 +37,6 @@ function provider(): { provider: Provider; apiKey: string } | undefined {
35
37
 
36
38
  const picked = process.env.RUN_FIGURE_LLM_SMOKE === '1' ? provider() : undefined
37
39
  const describeIf = picked ? describe : describe.skip
38
- const PALETTE = ['palette.primary', 'palette.accent', 'palette.neutral']
39
40
 
40
41
  describeIf('Figure 실제 LLM smoke', () => {
41
42
  beforeAll(() => {
@@ -47,45 +48,40 @@ describeIf('Figure 실제 LLM smoke', () => {
47
48
  afterAll(() => setDefaultAIClient(undefined))
48
49
 
49
50
  test(
50
- '새 Figure 후보는 유효한 어휘·팔레트·품질 근거를 가진다',
51
+ '새 Figure 는 게이트를 지나는 명령 목록으로 온다',
51
52
  async () => {
52
- const result = await proposeFigure({
53
+ const result = await proposeFigureV3({
53
54
  prompt: 'two cylindrical rollers inside a simple rectangular press frame; make the rollers visually distinct',
54
- type: 'ROLL_PRESS_SMOKE',
55
- palette: PALETTE,
56
- refine: true
55
+ name: 'Roll press'
57
56
  })
58
57
 
59
- expect(result.source.type).toBe('ROLL_PRESS_SMOKE')
60
- expect(result.source.parts.length).toBeGreaterThan(0)
61
- expect(result.source.parts.every(part => PALETTE.includes(part.material.token))).toBe(true)
62
- expect(result.quality.status).toMatch(/^(ready|review)$/)
58
+ expect(result.steps.length).toBeGreaterThan(0)
59
+ expect(inspectV3Asset(result.source).violations).toEqual([])
63
60
  expect(result.cost.triangles).toBeGreaterThan(0)
61
+ // eslint-disable-next-line no-console
62
+ console.log(`[figure-llm-smoke] new: ${result.steps.length} steps, ${result.attempts} attempt(s)`)
64
63
  },
65
- 90_000
64
+ 120_000
66
65
  )
67
66
 
68
67
  test(
69
- '수정 후보는 type을 보존하고 명시적 세션 피드백을 참고한다',
68
+ '고치는 제안은 건드리지 않은 부품을 그대로 둔다',
70
69
  async () => {
71
- const result = await proposeFigure({
72
- prompt: 'make the port more visible from above while keeping the body and port part names',
73
- palette: PALETTE,
74
- refine: true,
75
- base: {
76
- version: 2, type: 'MIXER_SMOKE', base: { x: 300, y: 400, z: 300 }, detailLevel: 'M',
77
- parts: [
78
- { name: 'body', primitive: 'cylinder', segments: 12, transform: { position: { x: 0, y: 200, z: 0 }, size: { x: 280, y: 320, z: 280 } }, material: { token: 'palette.primary' } },
79
- { name: 'port', primitive: 'cube', transform: { position: { x: 0, y: 230, z: -155 }, size: { x: 80, y: 60, z: 30 } }, material: { token: 'palette.accent' } }
80
- ]
81
- },
82
- feedback: [{ outcome: 'discarded', selectedChanges: 0, totalChanges: 2, note: 'the port must read clearly in top view' }]
83
- })
70
+ const draft = applyV3Proposal(createV3Asset('Mixer'), [
71
+ { kind: 'add-part', part: { id: 'body', kind: 'cylinder', dimensions: { radius: 140, length: 320 } } },
72
+ { kind: 'add-part', part: { id: 'port', kind: 'box', dimensions: { width: 80, height: 60, depth: 30 } } },
73
+ { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } },
74
+ { kind: 'attach', part: 'port', face: 'back', to: { part: 'body', face: 'front' } },
75
+ { kind: 'fit-room', placement: 'floor' }
76
+ ] as any)
77
+ const result = await proposeFigureV3({ prompt: 'make the port twice as wide and give it an accent colour', base: draft })
84
78
 
85
- expect(result.source.type).toBe('MIXER_SMOKE')
86
- expect(result.source.parts.map(part => part.name)).toEqual(expect.arrayContaining(['body', 'port']))
87
- expect(result.quality).toBeDefined()
79
+ const ids = result.source.document.model.nodes.map((n: any) => n.id)
80
+ expect(ids.some((id: string) => id.includes('body'))).toBe(true)
81
+ expect(ids.some((id: string) => id.includes('port'))).toBe(true)
82
+ // eslint-disable-next-line no-console
83
+ console.log(`[figure-llm-smoke] revise: ${JSON.stringify(result.steps)}`)
88
84
  },
89
- 90_000
85
+ 120_000
90
86
  )
91
87
  })
@@ -1,7 +1,7 @@
1
1
  import { In } from 'typeorm'
2
2
  import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
3
3
 
4
- import { placeholderType } from '@hatiolab/figure-model'
4
+ import { parseV3Asset, placeholderType } from '@hatiolab/figure-model'
5
5
  import { getRepository } from '@things-factory/shell'
6
6
 
7
7
  import { Figure } from './figure.js'
@@ -11,7 +11,7 @@ import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
11
11
  import { inspectAnySource } from './figure-format.js'
12
12
  import { renameDraftType, renameFacts } from './figure-rename.js'
13
13
  import { parseAnySource } from './figure-format.js'
14
- import { ProposeFailure, proposeFigure } from './figure-propose.js'
14
+ import { ProposeV3Failure, proposeFigureV3 } from './figure-propose-v3.js'
15
15
  import { prepareV3Write, compareAndSwapV3, pictureOf } from './figure-v3-write.js'
16
16
 
17
17
  @Resolver(Figure)
@@ -399,19 +399,15 @@ export class FigureMutation {
399
399
  }
400
400
 
401
401
  /**
402
- * 저작 보조에게 후보를 시킨다.
402
+ * 저작 보조에게 제안을 시킨다(ADR-0094).
403
403
  *
404
- * **저장하지 않는다.** 후보를 돌려줄 뿐이고, 정본이 되는 것은 저작자가 보고 받은
405
- * 뒤다. AI 가 정본에 직접 쓰면 저작자가 무엇이 자기 것이고 무엇이 기계 것인지
406
- * 모르게 된다.
407
- *
408
- * 형식을 어긴 후보는 여기까지 오지 않는다 — `proposeFigure` 가 사유를 모델에
409
- * 돌려주고 다시 시킨다. 끝내 못 만들면 **사유와 함께 던진다.** 반쯤 만든 것을
410
- * 돌려주지 않는다.
404
+ * **저장하지 않는다.** 모델러 명령 목록과 그것이 만드는 도형을 돌려줄 뿐이고, 정본이 되는 것은 저작자가 모델러에
405
+ * 적용하고 저장한 뒤다. 커널이 거절한 명령이나 게이트가 잡은 것은 여기까지 오지 않는다 — 제안기가 사유를 모델에
406
+ * 돌려주고 다시 시킨다. 끝내 못 만들면 **사유와 함께 던진다.** 반쯤 만든 것을 돌려주지 않는다.
411
407
  */
412
408
  @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
413
409
  @Mutation(returns => FigureProposal, {
414
- description: 'Asks the authoring assistant for a figure. Returns a candidate — nothing is saved.'
410
+ description: 'Asks the authoring assistant for modeller commands that make or revise a figure. Nothing is saved.'
415
411
  })
416
412
  async proposeFigure(@Arg('request') request: ProposeRequest): Promise<FigureProposal> {
417
413
  // 그림은 메모리로 받는다. 참고용이라 저장하지 않는다 — 남기면 지울 사람이 없다.
@@ -426,33 +422,23 @@ export class FigureMutation {
426
422
  }
427
423
 
428
424
  try {
429
- const result = await proposeFigure({
425
+ const result = await proposeFigureV3({
430
426
  prompt: request.prompt,
431
- base: request.base ? JSON.parse(request.base) : undefined,
432
- type: request.type,
433
- palette: request.palette,
434
- refine: request.refine,
435
- feedback: request.feedback,
427
+ base: request.base ? parseV3Asset(request.base) : undefined,
428
+ name: request.name,
436
429
  image
437
430
  })
438
431
 
439
432
  return {
433
+ steps: JSON.stringify(result.steps),
440
434
  source: JSON.stringify(result.source),
441
- score: Math.round(result.score.score),
442
- grade: result.score.grade,
435
+ score: result.score ? Math.round(result.score.score) : undefined,
436
+ grade: result.score?.grade,
443
437
  triangles: result.cost.triangles,
444
- groups: result.cost.groups,
445
- quality: {
446
- ...result.quality,
447
- visualRegions: result.quality.visual.regions,
448
- visualCoverage: result.quality.visual.coverage,
449
- visualReadability: result.quality.visual.readability
450
- },
451
- violations: result.violations,
452
438
  attempts: result.attempts
453
439
  }
454
440
  } catch (e) {
455
- if (e instanceof ProposeFailure) {
441
+ if (e instanceof ProposeV3Failure) {
456
442
  // 사유를 삼키지 않는다. 저작자가 무엇이 막혔는지 알아야 말을 고쳐 다시 시킨다.
457
443
  throw new Error([e.message, ...e.reasons].join('\n'))
458
444
  }
@@ -2,153 +2,45 @@ import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
2
2
  import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
3
3
  import { Field, InputType, Int, ObjectType } from 'type-graphql'
4
4
 
5
- import { FigureFinding } from './figure-type.js'
6
-
7
- @ObjectType({ description: 'One explainable consideration from the model-grounded candidate review.' })
8
- export class FigureQualityFinding {
9
- @Field({ description: 'What the consideration concerns: identity, silhouette, palette, performance, or resilience.' })
10
- dimension: string
11
-
12
- @Field({ description: 'Stable machine-readable review code.' })
13
- code: string
14
-
15
- @Field({ description: 'Human-readable evidence and recommended review.' })
16
- message: string
17
- }
18
-
19
- @ObjectType({ description: 'Explainable design review based on Figure-model evidence, not a visual aesthetic claim.' })
20
- export class FigureQualityReview {
21
- @Field({ description: 'ready when measurable checks are clear; review when author attention is recommended.' })
22
- status: string
23
-
24
- @Field({ description: 'Compact explanation of this review.' })
25
- summary: string
26
-
27
- @Field(type => [FigureQualityFinding], { description: 'Measured considerations for the author and assistant.' })
28
- findings: FigureQualityFinding[]
29
-
30
- @Field(type => Int, { description: 'Visible material regions in the reproducible top-view evidence.' })
31
- visualRegions: number
32
-
33
- @Field({ description: 'Top-view footprint coverage from 0 to 1.' })
34
- visualCoverage: number
35
-
36
- @Field({ description: 'Normalized top-view readability evidence from 0 to 1.' })
37
- visualReadability: number
38
-
39
- }
40
-
41
- /** 제한된 후보 품질 코드. 현재 요청의 참고 데이터이며 어떤 Figure에도 저장되지 않는다. */
42
- @InputType({ description: 'Measured review summary from an earlier candidate in this authoring session.' })
43
- export class ProposalFeedbackQualityInput {
44
- @Field({ nullable: true })
45
- status?: string
46
-
47
- @Field(type => [String], { nullable: true })
48
- findingCodes?: string[]
49
- }
50
-
51
- /** 사람이 명시적으로 수락하거나 거절한 후보의 제한된 세션 반응. */
52
- @InputType({ description: 'Explicit author feedback for the next candidate. Session-only; never persisted.' })
53
- export class ProposalFeedbackInput {
54
- @Field()
55
- outcome: string
56
-
57
- @Field(type => Int)
58
- selectedChanges: number
59
-
60
- @Field(type => Int)
61
- totalChanges: number
62
-
63
- @Field({ nullable: true })
64
- note?: string
65
-
66
- @Field({ nullable: true })
67
- grade?: string
68
-
69
- @Field(type => ProposalFeedbackQualityInput, { nullable: true })
70
- quality?: ProposalFeedbackQualityInput
71
- }
72
-
73
- /**
74
- * 저작 보조에 넘기는 것.
75
- *
76
- * ## 팔레트를 클라이언트가 보낸다
77
- *
78
- * 서버가 팔레트를 알 수 없다. 팔레트는 things-scene 이 갖고 있는데, 그 패키지의
79
- * node 조건은 transfer 번들로 가고 거기에는 `activePalette` 이 없다 [확인함].
80
- * 그리는 쪽이 어떤 토큰을 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.
81
- *
82
- * **그래서 저장 문(`parseSource`)은 토큰을 검사하지 못한다.** `validate()` 도
83
- * 못 한다 — figure-model 이 형식은 갖고 있지만 색 어휘는 things-scene 에 있다.
84
- * 색 어휘가 형식의 일부라면 figure-model 이 갖는 것이 맞다. **figure-model 에 올릴 것.**
85
- */
5
+ /** What the authoring assistant is asked for (ADR-0094). */
86
6
  @InputType({ description: 'What to ask the authoring assistant for' })
87
7
  export class ProposeRequest {
88
8
  @Field({ description: 'What the author asked for, in their own words' })
89
9
  prompt: string
90
10
 
91
- @Field({ nullable: true, description: 'The figure source to revise, as JSON. Omit to create a new one.' })
11
+ @Field({ nullable: true, description: 'The V3 figure source to revise, as JSON. Omit to create a new one.' })
92
12
  base?: string
93
13
 
94
- @Field({ nullable: true, description: 'Type name for a new figure. Ignored when base is given.' })
95
- type?: string
14
+ @Field({ nullable: true, description: 'The name a person reads for a new figure. Ignored when base is given.' })
15
+ name?: string
96
16
 
97
- /**
98
- * 그림.
99
- *
100
- * 비율과 덩어리를 읽는 데 쓴다. 표면 무늬를 옮기는 데 쓰지 않는다 — 로우폴리는
101
- * 텍스처가 0 인 것이 목표다.
102
- */
17
+ /** For proportions and masses, not surface detail: a low-poly figure carries no texture. */
103
18
  @Field(type => GraphQLUpload, { nullable: true, description: 'Reference photo or render' })
104
19
  image?: FileUpload
105
-
106
- @Field(type => [String], { description: 'Palette tokens the client can resolve. Anything else is rejected.' })
107
- palette: string[]
108
-
109
- @Field({ nullable: true, description: 'Ask for one evidence-grounded refinement when the first valid candidate needs review.' })
110
- refine?: boolean
111
-
112
- @Field(type => [ProposalFeedbackInput], {
113
- nullable: true,
114
- description: 'Explicit session-only feedback on earlier candidates. It is constrained again before prompting and never stored.'
115
- })
116
- feedback?: ProposalFeedbackInput[]
117
20
  }
118
21
 
119
22
  /**
120
- * 후보 하나.
23
+ * A proposal: the modeller commands, and what the figure they make weighs.
121
24
  *
122
- * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다. 그래서 서버는 저장하지 않고
123
- * 돌려주기만 한다.
25
+ * **Not the canon.** The modeller applies the steps as one step of its history; nothing is saved here.
124
26
  */
125
- @ObjectType({ description: 'A figure the assistant proposes. Not saved — the author accepts or discards it.' })
27
+ @ObjectType({ description: 'A figure the assistant proposes, as modeller commands. Not saved — the author applies it or leaves it.' })
126
28
  export class FigureProposal {
127
- @Field({ description: 'The proposed figure source, as JSON' })
29
+ @Field({ description: 'The V3 authoring commands, as a JSON array, in the order they apply' })
30
+ steps: string
31
+
32
+ @Field({ description: 'The V3 figure source the steps make, as JSON' })
128
33
  source: string
129
34
 
130
- @Field(type => Int, { description: 'Reuse score out of 100' })
131
- score: number
35
+ @Field(type => Int, { nullable: true, description: 'Reuse score out of 100' })
36
+ score?: number
132
37
 
133
- @Field({ description: 'Grade from the score — A, B, C or D' })
134
- grade: string
38
+ @Field({ nullable: true, description: 'Grade from the score — A, B, C or D' })
39
+ grade?: string
135
40
 
136
41
  @Field(type => Int, { description: 'Triangles in one instance' })
137
42
  triangles: number
138
43
 
139
- @Field(type => Int, { description: 'Material groups — the draw calls one instance costs' })
140
- groups: number
141
-
142
- @Field(type => FigureQualityReview, {
143
- description: 'Explainable design review. It advises the author and never saves or rejects the candidate.'
144
- })
145
- quality: FigureQualityReview
146
-
147
- @Field(type => [FigureFinding], {
148
- description: 'Policy violations. These do not block the proposal — the author decides.'
149
- })
150
- violations: FigureFinding[]
151
-
152
44
  @Field(type => Int, { description: 'How many attempts the assistant needed. Diagnostic.' })
153
45
  attempts: number
154
46
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * figure-propose-v3 — an AI proposal for a V3 figure is a list of the modeller's commands (ADR-0094).
3
+ *
4
+ * What is held here is our filter, not the model's skill: the model is not called. The steps are applied on a copy;
5
+ * the step the kernel refuses, and the gate findings the steps bring in, go back to the model; after three attempts
6
+ * the proposer says it could not make it, and nothing half made comes back.
7
+ */
8
+ import { V3_PROPOSAL_GRAMMAR, applyV3Proposal, createV3Asset, inspectV3Asset } from '@hatiolab/figure-model'
9
+ import { setDefaultAIClient } from '@things-factory/ai-client-base'
10
+ import type { AIClient } from '@things-factory/ai-client-base'
11
+
12
+ import { ProposeV3Failure, proposeFigureV3, rulesPromptV3 } from './figure-propose-v3'
13
+
14
+ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; system?: string } {
15
+ const client = {
16
+ id: 'stub',
17
+ calls: 0,
18
+ sent: [] as any[][],
19
+ system: undefined as string | undefined,
20
+ async chat() {
21
+ return ''
22
+ },
23
+ async generateJSON(messages: any[], options?: { systemPrompt?: string }) {
24
+ client.sent.push(JSON.parse(JSON.stringify(messages)))
25
+ client.system = options?.systemPrompt
26
+ const answer = answers[client.calls]
27
+ client.calls++
28
+ if (answer instanceof Error) throw answer
29
+ return answer
30
+ }
31
+ }
32
+ return client as unknown as AIClient & { calls: number; sent: any[][]; system?: string }
33
+ }
34
+
35
+ afterEach(() => setDefaultAIClient(undefined))
36
+
37
+ const liftTable = [
38
+ { kind: 'add-part', part: { id: 'base', kind: 'box', dimensions: { width: 1200, height: 80, depth: 800 } } },
39
+ { kind: 'add-part', part: { id: 'column', kind: 'cylinder', dimensions: { radius: 90, length: 600 } } },
40
+ { kind: 'add-part', part: { id: 'top', kind: 'box', dimensions: { width: 1200, height: 60, depth: 800 } } },
41
+ { kind: 'attach', part: 'base', face: 'bottom', to: { plane: 'mounting-plane' } },
42
+ { kind: 'attach', part: 'column', face: 'bottom', to: { part: 'base', face: 'top' } },
43
+ { kind: 'attach', part: 'top', face: 'bottom', to: { part: 'column', face: 'top' } },
44
+ { kind: 'set-material', target: 'top', material: { token: 'palette.accent' }, palette: { 'palette.accent': '#d08a2c' } },
45
+ { kind: 'fit-room', placement: 'floor' }
46
+ ]
47
+ const withoutRoom = liftTable.slice(0, -1)
48
+ const wrongFace = liftTable.map((s, i) => (i === 4 ? { ...s, face: 'left' } : s))
49
+
50
+ describe('the model is told the kernel\'s commands and the open draft, never V2', () => {
51
+ it('teaches every word of the grammar, in its own shape', () => {
52
+ const rules = rulesPromptV3()
53
+ for (const word of V3_PROPOSAL_GRAMMAR) expect(rules).toContain(word.shape)
54
+ expect(rules).not.toMatch(/figure-v1|"parts"\s*:\s*\[|anchor/i)
55
+ })
56
+
57
+ it('a measure asked to be the same as another is linked, not copied (:3300, 2026-09-26: 「몸통 지름과 같게」 came as a fixed 100)', () => {
58
+ expect(rulesPromptV3()).toMatch(/same as, matches or follows another part or the figure, write link-dimension/)
59
+ })
60
+
61
+ it('a revision carries the draft\'s V3 source', async () => {
62
+ const draft = applyV3Proposal(createV3Asset('Lift table'), liftTable as any)
63
+ const client = stubClient([{ steps: [{ kind: 'rename', name: 'Lift table 2' }] }])
64
+ setDefaultAIClient(client)
65
+ const made = await proposeFigureV3({ prompt: '이름을 바꿔 줘', base: draft })
66
+ expect(client.sent[0]![0]!.content).toContain('"version":"figure-v3-asset-1"')
67
+ expect(made.source.name).toBe('Lift table 2')
68
+ expect(draft.name).toBe('Lift table')
69
+ })
70
+ })
71
+
72
+ describe('the steps are held to the kernel and the gate', () => {
73
+ it('a list the kernel takes comes back with the figure it makes, the gate clean, on the first attempt', async () => {
74
+ setDefaultAIClient(stubClient([{ steps: liftTable }]))
75
+ const made = await proposeFigureV3({ prompt: '리프트 테이블', name: 'Lift table' })
76
+ expect(made.attempts).toBe(1)
77
+ expect(made.steps).toEqual(liftTable)
78
+ expect(inspectV3Asset(made.source).violations).toEqual([])
79
+ expect(made.score?.grade).toMatch(/^[ABCD]$/)
80
+ })
81
+
82
+ it('a refused step goes back with its place and the kernel\'s code, and the next answer is taken', async () => {
83
+ const client = stubClient([{ steps: wrongFace }, { steps: liftTable }])
84
+ setDefaultAIClient(client)
85
+ const made = await proposeFigureV3({ prompt: '리프트 테이블', name: 'Lift table' })
86
+ expect(made.attempts).toBe(2)
87
+ const said = client.sent[1]!.at(-1)!.content as string
88
+ expect(said).toMatch(/ATTACH_FACE: steps\[4\]: attach: /)
89
+ })
90
+
91
+ it('a new figure must come out whole: a list that leaves the room undeclared is sent back', async () => {
92
+ const client = stubClient([{ steps: withoutRoom }, { steps: liftTable }])
93
+ setDefaultAIClient(client)
94
+ const made = await proposeFigureV3({ prompt: '리프트 테이블', name: 'Lift table' })
95
+ expect(made.attempts).toBe(2)
96
+ expect(client.sent[1]!.at(-1)!.content).toMatch(/OCCUPANCY_UNDECLARED/)
97
+ })
98
+
99
+ it('a revision is not blamed for what the draft already had', async () => {
100
+ const draft = applyV3Proposal(createV3Asset('Lift table'), withoutRoom as any)
101
+ setDefaultAIClient(stubClient([{ steps: [{ kind: 'rename', name: 'Lift' }] }]))
102
+ const made = await proposeFigureV3({ prompt: '이름', base: draft })
103
+ expect(made.attempts).toBe(1)
104
+ })
105
+
106
+ it('three refusals said back, then it says it could not make it, with the last reasons -- nothing half made', async () => {
107
+ const client = stubClient([{ steps: wrongFace }, new Error('cut short'), { nope: true }])
108
+ setDefaultAIClient(client)
109
+ const failure = await proposeFigureV3({ prompt: '리프트 테이블', name: 'Lift table' }).catch(e => e)
110
+ expect(failure).toBeInstanceOf(ProposeV3Failure)
111
+ expect(failure.message).toBe('만들지 못했습니다.')
112
+ expect(failure.attempts).toBe(3)
113
+ expect(failure.reasons).toEqual(['The answer must be { "steps": [ ... ] }.'])
114
+ expect(client.calls).toBe(3)
115
+ expect(client.sent[2]!.at(-1)!.content).toMatch(/not valid JSON: cut short/)
116
+ })
117
+ })