@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.
- 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 +143 -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 +117 -0
- package/server/service/figure/figure-propose-v3.ts +174 -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
|
@@ -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.
|
|
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
|
-
|