@things-factory/figure-service 10.1.64 → 10.1.65

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 +141 -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 +113 -0
  18. package/server/service/figure/figure-propose-v3.ts +172 -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
@@ -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
-
@@ -6,12 +6,12 @@
6
6
  * 하려면 어시스턴트가 **지금 무엇이 있는지 읽을 수 있어야** 한다. 읽지 못하면 매번 사람이 정본을
7
7
  * 붙여 넣어야 하고, 그러면 대화가 아니다.
8
8
  *
9
- * 능력을 복제하지 않는다. figure-service 가 이미 가진 진입점(`inspectSource` · `proposeFigure` ·
9
+ * 능력을 복제하지 않는다. figure-service 가 이미 가진 진입점(`inspectSource` · `proposeFigureV3` ·
10
10
  * 조회)을 레지스트리에 투영한다.
11
11
  *
12
12
  * ── 전부 조회·제안 전용이다 ─────────────────────────────────────────────────
13
13
  * 정본을 고치는 도구를 넣지 않는다. 형식의 설계가 그렇게 정해져 있다 — 어시스턴트는 **후보**를
14
- * 내고, 받을지 버릴지는 저작자가 정한다(`figure-propose.ts`). 되돌릴 수 없는 조작이 대화면으로
14
+ * 내고, 받을지 버릴지는 저작자가 정한다(`figure-propose-v3.ts`, ADR-0094). 되돌릴 수 없는 조작이 대화면으로
15
15
  * 새지 않으므로 전부 `kind: 'read'` 다.
16
16
  *
17
17
  * 저장은 저작면이 `updateFigure` 로 한다. 그 mutation 은 `figure:mutation` 이 지키고, 대화는
@@ -53,7 +53,7 @@ function getDeps(): any {
53
53
  // eslint-disable-next-line @typescript-eslint/no-var-requires
54
54
  const format = require('./figure-format.js')
55
55
  // eslint-disable-next-line @typescript-eslint/no-var-requires
56
- const propose = require('./figure-propose.js')
56
+ const proposeV3 = require('./figure-propose-v3.js')
57
57
  _deps = {
58
58
  getRepository: shell.getRepository,
59
59
  Figure: entity.Figure,
@@ -65,7 +65,7 @@ function getDeps(): any {
65
65
  parseAnySource: format.parseAnySource,
66
66
  v3CostOf: model.v3CostOf,
67
67
  v3ScoreOf: model.v3ScoreOf,
68
- proposeFigure: propose.proposeFigure,
68
+ proposeFigureV3: proposeV3.proposeFigureV3,
69
69
  compile: model.compile,
70
70
  costOf: model.costOf,
71
71
  scoreOf: model.scoreOf,
@@ -160,16 +160,15 @@ async function resolveFigureTarget(figureId: string | undefined, domainId: strin
160
160
  const isV3 = (source: any) => source?.version === 'figure-v3-asset-1'
161
161
 
162
162
  /**
163
- * The V2 proposer writes V2 figures; a V3 figure open in the modeller is not something it can revise or add to. Said as
164
- * what is true, with what the assistant can do instead -- not a V2 parser's complaint about a V3 asset (user,
165
- * 2026-09-24: the dock answered 「type 이 없다 · base 가 없다」 for a V3 draft).
163
+ * A revision is applied by the modeller on the version the model saw (ADR-0094), so only the figure open there can be
164
+ * revised. Said as what is true, with what the assistant can do instead.
166
165
  */
167
- function v3ProposalUnavailable() {
166
+ function figureNotOpen() {
168
167
  return {
169
- error: 'V3_PROPOSAL_UNAVAILABLE',
170
- message: '이 도형은 V3 형식입니다. AI 후보 제안은 아직 V2 도형만 만들 수 있어 이 도형을 고치는 후보를 낼 수 없습니다.',
168
+ error: 'FIGURE_NOT_OPEN',
169
+ message: '고치는 제안은 모델러에 열려 있는 도형에만 낼 수 있습니다.',
171
170
  nextStep:
172
- 'Tell the person plainly that candidate proposals do not support V3 figures yet. You can still read the figure (getFigureSource) and explain whether it may be released and why (inspectFigure). Do not produce a V2 candidate for a V3 figure.'
171
+ 'Tell the person to open the figure in the modeller and ask again there. A new figure needs no open one: call proposeFigureCandidate with create: true.'
173
172
  }
174
173
  }
175
174
 
@@ -221,23 +220,6 @@ function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
221
220
  return { source: normalizedDraft, draft: true }
222
221
  }
223
222
 
224
- /**
225
- * 후보를 냈던 바로 그 Figure의 세션 반응만 후보 생성기에 넘긴다.
226
- *
227
- * 도크의 host context는 브라우저 값이므로 이 함수는 접근 권한을 판단하지 않는다. 이미 현재
228
- * 도메인에서 Figure를 찾은 뒤, `proposeFigure`가 다시 길이·형식을 정리할 참고 데이터만 건넨다.
229
- */
230
- function feedbackForTool(host: any, figureId?: string): unknown[] | undefined {
231
- if (!Array.isArray(host?.proposalFeedback)) return undefined
232
- if (figureId && host?.figureId === figureId) {
233
- return host.proposalFeedback.slice(-5)
234
- }
235
- if (host?.editorMode === 'new' || !host?.figureId) {
236
- return host.proposalFeedback.slice(-5)
237
- }
238
- return undefined
239
- }
240
-
241
223
  /**
242
224
  * 무엇을 고치라는 것인지 대화면 문맥에서 해소한다.
243
225
  *
@@ -395,9 +377,9 @@ function figureToolSpecs(): ToolSpec[] {
395
377
  /* 후보 생성 — 저장은 하지 않지만 같은 문의 권한을 쓴다. */
396
378
  doors: ['Mutation.proposeFigure'],
397
379
  description:
398
- 'Asks for a figure candidate from a description, optionally revising an existing one. ' +
399
- 'Returns the candidate with its score and cost. NOTHING IS SAVED — the author accepts or ' +
400
- 'discards it on screen. This is the only way a candidate reaches the screen.',
380
+ 'Asks for a proposal: the modeller commands that make a new figure or revise the one open in the modeller. ' +
381
+ 'Returns the commands with the score and cost of the figure they make. NOTHING IS SAVED — the author applies ' +
382
+ 'the proposal in the modeller as one step, or leaves it. This is the only way a proposal reaches the screen.',
401
383
  schema: {
402
384
  type: 'object',
403
385
  properties: {
@@ -407,11 +389,11 @@ function figureToolSpecs(): ToolSpec[] {
407
389
  },
408
390
  figureId: {
409
391
  type: 'string',
410
- description: 'Revise this figure. Omit to revise the one open in the editor; pass null to create anew.'
392
+ description: 'Revise this figure; it must be the one open in the modeller. Omit to revise the open one; pass null to create anew.'
411
393
  },
412
- type: {
394
+ name: {
413
395
  type: 'string',
414
- description: 'Type name for a new figure. Check listFigures first — it is a stored identifier.'
396
+ description: 'The name a person reads for a new figure.'
415
397
  },
416
398
  create: {
417
399
  type: 'boolean',
@@ -429,54 +411,52 @@ function figureToolSpecs(): ToolSpec[] {
429
411
  고치는 것인지 만드는 것인지를 **문맥에서 정한다.** 열린 도형이 있으면 고치는 것이 기본이고,
430
412
  모델이 `create` 를 켜면 만드는 것이다. 이것을 모델의 짐작에 맡기면 열려 있는 도형을 덮어쓰는
431
413
  후보가 온다.
414
+
415
+ 고치는 제안은 모델러가 **요청 당시의 판(context · revision)** 위에 한 단계로 적용한다(ADR-0094).
416
+ 그래서 고칠 수 있는 것은 모델러에 열린 도형뿐이고, 그 판을 문맥이 함께 실어 와야 한다.
432
417
  */
418
+ const host = ctx?.host
419
+ const create = args?.create === true || args?.figureId === null
433
420
  let base: unknown
434
- if (!args?.create && args?.figureId !== null) {
435
- const open = typeof args?.figureId === 'string' ? args.figureId : ctx?.host?.figureId
436
- const row = await resolveFigureTarget(open ? open.trim() : undefined, domainId, ctx?.host)
437
- if (row) {
438
- base = sourceForTool(row, ctx?.host).source ?? undefined
421
+ let figureId: string | undefined
422
+ if (!create) {
423
+ const said = typeof args?.figureId === 'string' ? args.figureId.trim() : ''
424
+ const open = typeof host?.figureId === 'string' ? host.figureId.trim() : ''
425
+ if (said && said !== open) {
426
+ await figureInDomain(said, domainId)
427
+ return figureNotOpen()
439
428
  }
440
- }
441
- if (isV3(base) || (args?.create !== true && isV3(ctx?.host?.draftSource))) return v3ProposalUnavailable()
442
-
443
- const palette = Array.isArray(ctx?.host?.palette) ? ctx.host.palette : []
444
- if (palette.length === 0) {
445
- /*
446
- 팔레트를 모르면 모델이 색을 지어내고, 그 후보는 그리는 시점에 `resolveToken` 이 던진다 —
447
- 저장까지 되고 나서 화면에서 죽는다. 빈 목록으로 진행하지 않는다.
448
- */
449
- throw new Error('the palette is missing from the host context — the candidate would invent colours')
429
+ if (!open) return missingFigureTarget()
430
+ const row = await resolveFigureTarget(open, domainId, host)
431
+ if (!row) return missingFigureTarget()
432
+ base = sourceForTool(row, host).source ?? undefined
433
+ if (!isV3(base)) return figureNotOpen()
434
+ if (typeof host?.context !== 'string' || !Number.isInteger(host?.revision)) return figureNotOpen()
435
+ figureId = open
450
436
  }
451
437
 
452
- const { proposeFigure } = getDeps()
453
- const feedback = feedbackForTool(ctx?.host, base ? figureIdFrom(args, ctx?.host) : undefined)
454
-
455
438
  let hostImage: { data: Buffer; mediaType: any } | undefined
456
- const rawImage = ctx?.host?.image || (Array.isArray(ctx?.host?.attachments) ? ctx.host.attachments.find((a: any) => a?.mediaType?.startsWith('image/')) : undefined)
439
+ const rawImage = host?.image || (Array.isArray(host?.attachments) ? host.attachments.find((a: any) => a?.mediaType?.startsWith('image/')) : undefined)
457
440
  if (rawImage?.data) {
458
441
  const rawData = typeof rawImage.data === 'string' ? rawImage.data.replace(/^data:image\/[a-zA-Z]+;base64,/, '') : rawImage.data
459
442
  const buf = typeof rawData === 'string' ? Buffer.from(rawData, 'base64') : Buffer.from(rawData)
460
443
  hostImage = { data: buf, mediaType: rawImage.mediaType || 'image/png' }
461
444
  }
462
445
 
446
+ const { proposeFigureV3 } = getDeps()
463
447
  let result: any
464
448
  try {
465
- result = await proposeFigure({
449
+ result = await proposeFigureV3({
466
450
  prompt,
467
- palette,
468
451
  base,
469
- type: typeof args?.type === 'string' ? args.type : undefined,
470
- feedback,
471
- refine: !!feedback?.length,
452
+ name: typeof args?.name === 'string' && args.name.trim() ? args.name.trim() : undefined,
472
453
  image: hostImage
473
454
  })
474
455
  } catch (error) {
475
456
  /*
476
- * 후보 생성은 세 번의 JSON·형식 검사 끝에 실패할 수 있다. 그 마지막 검사 사유를
477
- * ProposeFailure.reasons 에만 두면 agentic-loop은 "만들지 못했습니다"만 받아
478
- * 사용자 화면에는 "응답이 비어있습니다"가 된다. 재시도할 수 있는 말로 경계를
479
- * 넘긴다. 다른 예외(권한·통신)는 원문을 보존한다.
457
+ * 세 번 거절된 끝의 사유를 ProposeV3Failure.reasons 에만 두면 agentic-loop 은 「만들지 못했습니다」만
458
+ * 받아 화면에는 「응답이 비어있습니다」가 된다. 다시 시도할 수 있는 말로 경계를 넘긴다.
459
+ * 다른 예외(권한·통신)는 원문을 보존한다.
480
460
  */
481
461
  const reasons = Array.isArray((error as any)?.reasons) ? (error as any).reasons : []
482
462
  if (reasons.length > 0) throw new Error([(error as Error).message, ...reasons].join('\n'))
@@ -484,37 +464,26 @@ function figureToolSpecs(): ToolSpec[] {
484
464
  }
485
465
 
486
466
  /*
487
- **`proposed: true` 가 카드를 만든다.** 이 표시가 없으면 후보는 도구 추적 안에 묻히고,
488
- 화면에는 아무것도 생기지 않는다 — 그러면 모델이 「후보를 만들었습니다」라고 쓴 것이
489
- 거짓말이 된다(사용자가 없는 단추를 찾는다).
490
-
491
- `label`·`reason` 이 카드에 그려지는 두 줄이고, `args` 는 같은 제안을 두 번 세지 않기
492
- 위한 열쇠의 일부다 — 같은 요청을 두 번 내면 카드가 하나로 접힌다.
467
+ **`proposed: true` 가 카드를 만든다.** 이 표시가 없으면 제안은 도구 추적 안에 묻히고 화면에는
468
+ 아무것도 생기지 않는다. 카드의 실행 단추가 figure-ui 의 처리기에 넘기고, 처리기가 모델러에 명령
469
+ 목록을 한 단계로 적용한다. `args` 는 같은 제안을 두 번 세지 않기 위한 열쇠의 일부다.
493
470
  */
471
+ const name = result.source.name || 'Figure'
494
472
  return {
495
473
  proposed: true,
496
- label: base ? `${result.source.type} 고치기` : `${result.source.type} 만들기`,
474
+ icon: 'view_in_ar',
475
+ label: create ? `${name} 만들기` : `${name} 고치기`,
497
476
  reason:
498
- `등급 ${result.score.grade} · 삼각형 ${result.cost.triangles} · ` +
499
- `한 도면 ${result.cost.at.drawCalls}회 · ${result.attempts}번에 만들었다`,
500
- choices: [
501
- { id: 'save', action: 'save', label: '초안 저장', icon: 'save', primary: true },
502
- { id: 'repropose', action: 'prompt', label: '다른 콘셉트 재제안', icon: 'refresh', prompt: '현재 제안과 완전히 다른 스타일과 콘셉트로 새로 제안해줘' },
503
- { id: 'slim', action: 'prompt', label: '비율 슬림 조정', icon: 'aspect_ratio', prompt: '전체적인 가로세로 비율을 좀 더 슬림하고 날렵하게 조정해줘' },
504
- { id: 'palette', action: 'prompt', label: '색상 테마 변경', icon: 'palette', prompt: '다른 팔레트 색상 테마로 부품 색상을 변경해줘' },
505
- { id: 'detail', action: 'prompt', label: '디테일 보강', icon: 'extension', prompt: '주요 디테일과 센서 부품을 좀 더 풍부하게 보강해줘' }
506
- ],
507
- args: {
508
- baseSource: base ? JSON.stringify(base) : undefined,
509
- prompt, figureId: base ? figureIdFrom(args, ctx?.host) : undefined,
510
- draftType: base && ctx?.host?.editorMode === 'new' && !figureIdFrom(args, ctx?.host)
511
- ? (base as any).type : undefined
512
- },
513
- source: result.source,
477
+ `명령 ${result.steps.length}개 · 등급 ${result.score?.grade ?? '-'} · 삼각형 ${result.cost.triangles} · ` +
478
+ `${result.attempts}번에 만들었다`,
479
+ args: { prompt, figureId, create },
480
+ create,
481
+ figureId,
482
+ context: create ? undefined : host.context,
483
+ revision: create ? undefined : host.revision,
484
+ steps: result.steps,
514
485
  score: result.score,
515
486
  cost: result.cost,
516
- quality: result.quality,
517
- violations: result.violations,
518
487
  attempts: result.attempts
519
488
  }
520
489
  }
@@ -537,13 +506,13 @@ export function registerFigureToolCategories(): void {
537
506
  '- **도형을 만들거나 고쳐 달라는 요청에는 반드시 `proposeFigureCandidate` 를 호출한다.**',
538
507
  ' 후보는 도구 호출 결과에서만 생긴다.',
539
508
  ' 호출 없이 "후보를 만들었습니다" · "적용 버튼을 누르세요" 라고 쓰는 것은 **거짓이다** — 화면에 후보 칸이 생기지 않으므로 사용자는 존재하지 않는 버튼을 찾는다.',
540
- ' 도크에서는 후보 카드의 버튼을 눌러야 모델러 비교 화면이 열린다. 아직 열지 않은 비교 화면이 이미 보인다고 말하지 않는다.',
541
- ' 요청한 부품만 바뀌었다고 추측하지 않는다. 후보와 원본을 비교한 근거가 없으면 변경 범위는 비교 화면에서 확인하도록 안내한다.',
509
+ ' 후보는 모델러 명령 목록이다. 도크 카드의 실행 단추를 눌러야 모델러에 한 단계로 적용되고, 되돌리기 한 번에 통째로 돌아간다. 적용 전에 이미 바뀌었다고 말하지 않는다.',
510
+ ' 고치는 제안은 모델러에 열린 도형에만 낼 수 있다. 결과의 `steps` 가 무엇을 하는지 그대로 전한다 — 명령에 없는 변경을 지어내지 않는다.',
542
511
  '- 정본을 직접 저장하지 않는다. 후보만 내고, 받을지 버릴지는 저작자가 화면에서 정한다.',
543
512
  '- 좌표와 크기는 X/Y/Z 축 이름으로 정확하게 표시한다. 확인되지 않은 가로·세로·높이 의미를 임의로 붙이지 않는다.',
544
513
  '- **고치라는 요청이면 먼저 `getFigureSource` 를 부른다.** 읽지 않고 고치면 짐작을 상대로 후보를 내게 되고, 부품 이름이 갈려 이미 배치된 보드의 바인딩이 끊긴다.',
545
- '- 목록 화면에는 열린 도형이 없다. 기존 도형을 조회·수정할 때는 `listFigures`에서 사용자가 말한 이름·타입의 id를 찾는다. 대상이 모호할 때만 어느 도형인지 묻는다. 새 제작 요청에는 기존 정본을 조회하지 않고 `proposeFigureCandidate`를 create: true로 호출한다.',
546
- '- 타입 이름을 지어내지 않는다 — `listFigures` 로 확인한다. **타입 이름은 저장되는 식별자**이고 보드가 그 이름으로 도형을 찾는다.',
514
+ '- 목록 화면에는 열린 도형이 없다. 기존 도형을 조회할 때는 `listFigures`에서 사용자가 말한 이름·타입의 id를 찾는다. 대상이 모호할 때만 어느 도형인지 묻는다. 기존 도형을 고치려면 모델러에서 그 도형을 열고 거기서 요청하도록 안내한다. 새 제작 요청에는 기존 정본을 조회하지 않고 `proposeFigureCandidate`를 create: true로 호출한다.',
515
+ '- 타입 이름을 지어내지 않는다 — `listFigures` 로 확인한다. **타입 이름은 저장되는 식별자**이고 보드가 그 이름으로 도형을 찾는다. 새 도형의 타입은 첫 저장 때 사람이 정한다.',
547
516
  '- 발행이 안 되는 이유를 물으면 `inspectFigure` 를 부른다. 판정 없이 "발행할 수 있습니다"라고 말하지 않는다.',
548
517
  ' 판정의 각 항목은 **왜 문제인지와 무엇을 바꾸면 되는지**를 함께 담고 있다. 그대로 전한다 — 요약하면 저작자가 어느 부품의 어느 축을 고칠지 다시 찾아야 한다.',
549
518
  '- 무게·삼각형 수를 물으면 `getFigureSource` 의 `cost` 를 쓴다. 눈대중으로 세지 않는다.'