@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.
- package/dist-server/service/figure/figure-mutation.d.ts +4 -8
- package/dist-server/service/figure/figure-mutation.js +13 -27
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-propose-type.d.ts +8 -59
- package/dist-server/service/figure/figure-propose-type.js +15 -150
- package/dist-server/service/figure/figure-propose-type.js.map +1 -1
- package/dist-server/service/figure/figure-propose-v3.d.ts +50 -0
- package/dist-server/service/figure/figure-propose-v3.js +141 -0
- package/dist-server/service/figure/figure-propose-v3.js.map +1 -0
- package/dist-server/service/figure/figure-tools.js +64 -90
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/figure/figure-llm-smoke.test.ts +28 -32
- package/server/service/figure/figure-mutation.ts +14 -28
- package/server/service/figure/figure-propose-type.ts +16 -124
- package/server/service/figure/figure-propose-v3.test.ts +113 -0
- package/server/service/figure/figure-propose-v3.ts +172 -0
- package/server/service/figure/figure-tools.test.ts +86 -133
- package/server/service/figure/figure-tools.ts +60 -91
- package/dist-server/service/figure/figure-propose.d.ts +0 -91
- package/dist-server/service/figure/figure-propose.js +0 -439
- package/dist-server/service/figure/figure-propose.js.map +0 -1
- package/dist-server/service/figure/figure-quality.d.ts +0 -29
- package/dist-server/service/figure/figure-quality.js +0 -62
- package/dist-server/service/figure/figure-quality.js.map +0 -1
- package/server/service/figure/figure-ai-flow.test.ts +0 -93
- package/server/service/figure/figure-e2e-smoke.test.ts +0 -72
- package/server/service/figure/figure-propose.test.ts +0 -719
- package/server/service/figure/figure-propose.ts +0 -551
- package/server/service/figure/figure-quality.test.ts +0 -43
- 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.
|
|
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
|
-
|
|
67
|
+
proposeFigureV3: async (input: any) => {
|
|
68
68
|
proposed.push(input)
|
|
69
69
|
return {
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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('
|
|
389
|
-
const
|
|
390
|
-
expect(
|
|
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('
|
|
394
|
-
const
|
|
395
|
-
|
|
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('
|
|
402
|
-
await toolNamed('proposeFigureCandidate').builder({ prompt: '새로 만들어줘',
|
|
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('
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
않는다. 그때 모델이 「후보를 만들었습니다」라고 쓴 것이 거짓이 된다.
|
|
455
|
-
없는 단추를 누르라고 안내했다.
|
|
456
|
+
제안이 화면에 닿는 길은 하나다 — 도구 결과의 `proposed: true` 가 카드를 만들고, 카드의 실행 단추가
|
|
457
|
+
figure-ui 의 처리기에 넘긴다. 이 표시가 빠지면 제안은 도구 추적 안에 묻히고 화면에는 아무것도 생기지
|
|
458
|
+
않는다. 그때 모델이 「후보를 만들었습니다」라고 쓴 것이 거짓이 된다.
|
|
456
459
|
*/
|
|
457
|
-
describe('
|
|
458
|
-
it('proposed
|
|
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('카드에 그릴 두 줄이
|
|
465
|
-
const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '교반기', create: true }, ctx())
|
|
466
|
-
|
|
467
|
-
expect(out.
|
|
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` · `
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
164
|
-
* what is true, with what the assistant can do instead
|
|
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
|
|
166
|
+
function figureNotOpen() {
|
|
168
167
|
return {
|
|
169
|
-
error: '
|
|
170
|
-
message: '
|
|
168
|
+
error: 'FIGURE_NOT_OPEN',
|
|
169
|
+
message: '고치는 제안은 모델러에 열려 있는 도형에만 낼 수 있습니다.',
|
|
171
170
|
nextStep:
|
|
172
|
-
'Tell the person
|
|
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
|
|
399
|
-
'Returns the
|
|
400
|
-
'
|
|
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
|
|
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
|
-
|
|
394
|
+
name: {
|
|
413
395
|
type: 'string',
|
|
414
|
-
description: '
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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 =
|
|
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
|
|
449
|
+
result = await proposeFigureV3({
|
|
466
450
|
prompt,
|
|
467
|
-
palette,
|
|
468
451
|
base,
|
|
469
|
-
|
|
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
|
-
*
|
|
477
|
-
*
|
|
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
|
-
|
|
474
|
+
icon: 'view_in_ar',
|
|
475
|
+
label: create ? `${name} 만들기` : `${name} 고치기`,
|
|
497
476
|
reason:
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
'- 목록 화면에는 열린 도형이 없다. 기존 도형을
|
|
546
|
-
'- 타입 이름을 지어내지 않는다 — `listFigures` 로 확인한다. **타입 이름은 저장되는 식별자**이고 보드가 그 이름으로 도형을 찾는다.',
|
|
514
|
+
'- 목록 화면에는 열린 도형이 없다. 기존 도형을 조회할 때는 `listFigures`에서 사용자가 말한 이름·타입의 id를 찾는다. 대상이 모호할 때만 어느 도형인지 묻는다. 기존 도형을 고치려면 모델러에서 그 도형을 열고 거기서 요청하도록 안내한다. 새 제작 요청에는 기존 정본을 조회하지 않고 `proposeFigureCandidate`를 create: true로 호출한다.',
|
|
515
|
+
'- 타입 이름을 지어내지 않는다 — `listFigures` 로 확인한다. **타입 이름은 저장되는 식별자**이고 보드가 그 이름으로 도형을 찾는다. 새 도형의 타입은 첫 저장 때 사람이 정한다.',
|
|
547
516
|
'- 발행이 안 되는 이유를 물으면 `inspectFigure` 를 부른다. 판정 없이 "발행할 수 있습니다"라고 말하지 않는다.',
|
|
548
517
|
' 판정의 각 항목은 **왜 문제인지와 무엇을 바꾸면 되는지**를 함께 담고 있다. 그대로 전한다 — 요약하면 저작자가 어느 부품의 어느 축을 고칠지 다시 찾아야 한다.',
|
|
549
518
|
'- 무게·삼각형 수를 물으면 `getFigureSource` 의 `cost` 를 쓴다. 눈대중으로 세지 않는다.'
|