@things-factory/figure-service 10.1.8 → 10.1.13
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-tools.js +49 -16
- 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-ai-flow.test.ts +1 -3
- package/server/service/figure/figure-tools.test.ts +72 -4
- package/server/service/figure/figure-tools.ts +47 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.13",
|
|
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": "
|
|
26
|
+
"@hatiolab/figure-model": "^0.1.16",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
28
|
"@things-factory/ai-client-base": "^10.1.8",
|
|
29
29
|
"@things-factory/auth-base": "^10.1.7",
|
|
@@ -32,5 +32,5 @@
|
|
|
32
32
|
"graphql-upload": "^15.0.2",
|
|
33
33
|
"koa-router": "^7.4.0"
|
|
34
34
|
},
|
|
35
|
-
"gitHead": "
|
|
35
|
+
"gitHead": "f2cd3d27e32e71cca1d3af5499ef54249b1cc111"
|
|
36
36
|
}
|
|
@@ -69,8 +69,7 @@ test('초안·세션 피드백을 반영한 후보가 검증·품질 근거와
|
|
|
69
69
|
const result = await propose.builder(
|
|
70
70
|
{ prompt: '포트가 더 잘 읽히도록 고쳐줘' },
|
|
71
71
|
{
|
|
72
|
-
state: {
|
|
73
|
-
domain: DOMAIN,
|
|
72
|
+
state: { domain: DOMAIN },
|
|
74
73
|
host: {
|
|
75
74
|
figureId: 'fig-1',
|
|
76
75
|
palette: ['palette.primary'],
|
|
@@ -81,7 +80,6 @@ test('초안·세션 피드백을 반영한 후보가 검증·품질 근거와
|
|
|
81
80
|
quality: { status: 'review', findingCodes: ['top-view-readability'] }
|
|
82
81
|
}]
|
|
83
82
|
}
|
|
84
|
-
}
|
|
85
83
|
}
|
|
86
84
|
)
|
|
87
85
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* 실제 모델은 부르지 않는다. 여기서 볼 것은 모델의 솜씨가 아니라 **우리 배선**이다.
|
|
14
14
|
*/
|
|
15
|
-
import { clearToolRegistry, getToolCategories } from '@things-factory/ai-client-base'
|
|
15
|
+
import { clearToolRegistry, getToolCategories, registryAssistantChat } from '@things-factory/ai-client-base'
|
|
16
16
|
|
|
17
17
|
import { __setFigureToolDepsForTest, registerFigureToolCategories } from './figure-tools'
|
|
18
18
|
|
|
@@ -91,7 +91,7 @@ function toolNamed(name: string) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
const ctx = (host: any = { figureId: 'fig-1', palette: ['palette.primary'] }) => ({
|
|
94
|
-
state: { domain: MINE, host
|
|
94
|
+
state: { domain: MINE }, host
|
|
95
95
|
})
|
|
96
96
|
|
|
97
97
|
beforeEach(() => {
|
|
@@ -103,6 +103,25 @@ beforeEach(() => {
|
|
|
103
103
|
|
|
104
104
|
afterEach(() => clearToolRegistry())
|
|
105
105
|
|
|
106
|
+
test('실제 registryAssistantChat 경계가 열린 모델 문맥을 Figure 도구에 전달한다', async () => {
|
|
107
|
+
const seen: any[] = []
|
|
108
|
+
let turn = 0
|
|
109
|
+
await registryAssistantChat({
|
|
110
|
+
client: { chatWithTools: async (messages: any[]) => {
|
|
111
|
+
seen.push(messages)
|
|
112
|
+
return turn++ === 0
|
|
113
|
+
? { toolCalls: [{ id: 'read-1', name: 'getFigureSource', arguments: {}, type: 'tool_use' }], stopReason: 'tool_use' }
|
|
114
|
+
: { text: '조회했습니다.', toolCalls: [], stopReason: 'end_turn' }
|
|
115
|
+
} } as any,
|
|
116
|
+
messages: [{ role: 'user', content: '현재 모델을 조회해줘' }],
|
|
117
|
+
systemPrompt: 'Read the current figure.', toolCategories: ['figure-authoring'],
|
|
118
|
+
state: { domain: MINE }, hostContext: { figureId: 'fig-1', draftSource: SOURCE }
|
|
119
|
+
})
|
|
120
|
+
expect(seen).toHaveLength(2)
|
|
121
|
+
expect(JSON.stringify(seen[1])).toContain('MIXER')
|
|
122
|
+
expect(JSON.stringify(seen[1])).not.toContain('FIGURE_TARGET_REQUIRED')
|
|
123
|
+
})
|
|
124
|
+
|
|
106
125
|
describe('범주가 등록된다', () => {
|
|
107
126
|
it('figure-authoring 이 도구 넷과 함께 올라온다', () => {
|
|
108
127
|
const category = getToolCategories().find((one: any) => one.name === 'figure-authoring')!
|
|
@@ -160,8 +179,20 @@ describe('대화면 문맥에서 대상을 해소한다', () => {
|
|
|
160
179
|
expect(result.source.type).toBe('MIXER')
|
|
161
180
|
})
|
|
162
181
|
|
|
163
|
-
it('
|
|
164
|
-
await
|
|
182
|
+
it.each(['getFigureSource', 'inspectFigure'])('%s: 목록의 대상 누락은 채팅을 끊지 않고 다음 동작을 안내한다', async name => {
|
|
183
|
+
const result = await toolNamed(name).builder({}, ctx({}))
|
|
184
|
+
expect(result.error).toBe('FIGURE_TARGET_REQUIRED')
|
|
185
|
+
expect(result.nextStep).toContain('listFigures')
|
|
186
|
+
expect(result.nextStep).toContain('create: true')
|
|
187
|
+
expect(result.source).toBeUndefined()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('목록에서 대상을 찾아 다시 호출하면 해당 도형의 정본을 읽는다', async () => {
|
|
191
|
+
const catalog = await toolNamed('listFigures').builder({}, ctx({}))
|
|
192
|
+
const target = catalog.figures.find((figure: any) => figure.name === '믹서')
|
|
193
|
+
const result = await toolNamed('getFigureSource').builder({ figureId: target.id }, ctx({}))
|
|
194
|
+
expect(result.id).toBe('fig-1')
|
|
195
|
+
expect(result.source.type).toBe('MIXER')
|
|
165
196
|
})
|
|
166
197
|
|
|
167
198
|
it('정본이 없는 도형은 없다고 말한다 — 지어내지 않는다', async () => {
|
|
@@ -246,6 +277,43 @@ describe('모델이 넘긴 id 를 믿지 않는다', () => {
|
|
|
246
277
|
})
|
|
247
278
|
|
|
248
279
|
describe('후보를 요청할 때', () => {
|
|
280
|
+
it('저장 전 새 모델도 현재 초안을 읽고 수정 후보의 기반으로 사용한다', async () => {
|
|
281
|
+
const host = { editorMode: 'new', draftSource: SOURCE, palette: ['palette.primary'] }
|
|
282
|
+
const read = await toolNamed('getFigureSource').builder({}, ctx(host))
|
|
283
|
+
expect(read.source).toEqual(SOURCE)
|
|
284
|
+
expect(read.draft).toBe(true)
|
|
285
|
+
const result = await toolNamed('proposeFigureCandidate').builder({ prompt: '몸체를 키워줘' }, ctx(host))
|
|
286
|
+
expect(proposed[0].base).toEqual(SOURCE)
|
|
287
|
+
expect(result.args.draftType).toBe('MIXER')
|
|
288
|
+
expect(result.args.figureId).toBeUndefined()
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('목록에 남은 새 모델 초안은 현재 모델로 사용하지 않는다', async () => {
|
|
292
|
+
const result = await toolNamed('getFigureSource').builder({}, ctx({ editorMode: 'catalog', draftSource: SOURCE }))
|
|
293
|
+
expect(result.error).toBe('FIGURE_TARGET_REQUIRED')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('목록에서 새 제작은 기존 도형을 요구하지 않는다', async () => {
|
|
297
|
+
const result = await toolNamed('proposeFigureCandidate').builder(
|
|
298
|
+
{ prompt: '믹서를 새로 만들어줘', create: true }, ctx({ palette: ['palette.primary'] })
|
|
299
|
+
)
|
|
300
|
+
expect(result.proposed).toBe(true)
|
|
301
|
+
expect(proposed[0].base).toBeUndefined()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('figureId null은 도구 설명대로 새 제작을 의미한다', async () => {
|
|
305
|
+
await toolNamed('proposeFigureCandidate').builder({ prompt: '새로 만들어줘', figureId: null }, ctx())
|
|
306
|
+
expect(proposed[0].base).toBeUndefined()
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('다른 도형을 지정하면 열린 도형의 미저장 초안을 섞지 않는다', async () => {
|
|
310
|
+
const result = await toolNamed('getFigureSource').builder(
|
|
311
|
+
{ figureId: 'fig-blank' }, ctx({ figureId: 'fig-1', draftSource: SOURCE })
|
|
312
|
+
)
|
|
313
|
+
expect(result.id).toBe('fig-blank')
|
|
314
|
+
expect(result.source).toBeNull()
|
|
315
|
+
})
|
|
316
|
+
|
|
249
317
|
it('열린 도형이 있으면 고치는 것으로 본다 — 그 정본이 함께 나간다', async () => {
|
|
250
318
|
await toolNamed('proposeFigureCandidate').builder({ prompt: '재질을 줄여줘' }, ctx())
|
|
251
319
|
|
|
@@ -101,7 +101,7 @@ async function figureInDomain(figureId: string, domainId: string): Promise<any>
|
|
|
101
101
|
* 프롬프트로 옮기면 비용만 태우고, DB에 있던 Figure와 다른 것을 고친다고 잘못 말할 수 있다.
|
|
102
102
|
*/
|
|
103
103
|
function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
|
|
104
|
-
const draft = host?.draftSource
|
|
104
|
+
const draft = host?.figureId === row.id ? host?.draftSource : undefined
|
|
105
105
|
if (!draft) {
|
|
106
106
|
return { source: row.source ? JSON.parse(row.source) : null, draft: false }
|
|
107
107
|
}
|
|
@@ -144,20 +144,36 @@ function feedbackForTool(host: any, figureId?: string): unknown[] | undefined {
|
|
|
144
144
|
* 모델이 대상을 안 줬으면 **저작면이 지금 열고 있는 도형**이다. 사용자에게 식별자를 되묻지
|
|
145
145
|
* 않는다 — 화면에 그 도형이 떠 있는데 「어느 도형입니까」를 묻는 것은 문맥을 안 읽은 것이다.
|
|
146
146
|
*/
|
|
147
|
-
function figureIdFrom(args: any, host: any): string {
|
|
147
|
+
function figureIdFrom(args: any, host: any): string | undefined {
|
|
148
148
|
const said = typeof args?.figureId === 'string' ? args.figureId.trim() : ''
|
|
149
149
|
if (said) return said
|
|
150
150
|
|
|
151
151
|
const open = typeof host?.figureId === 'string' ? host.figureId.trim() : ''
|
|
152
152
|
if (open) return open
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function missingFigureTarget() {
|
|
158
|
+
return {
|
|
159
|
+
error: 'FIGURE_TARGET_REQUIRED',
|
|
160
|
+
message: '현재 화면에는 선택된 도형이 없습니다.',
|
|
161
|
+
nextStep: 'For an existing figure, call listFigures and match the user’s requested name or type; ask which figure only if ambiguous. For a new figure, call proposeFigureCandidate with create: true and the user’s prompt. Do not invent an id or repeat this call without a target.'
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function unsavedFigure(host: any): any | undefined {
|
|
166
|
+
if (host?.editorMode !== 'new' || host?.figureId || !host?.draftSource) return undefined
|
|
167
|
+
const row = { id: host.figureId, type: host.draftSource.type, name: '', state: 'draft' }
|
|
168
|
+
// Reuse size, shape and vocabulary validation before trusting browser content.
|
|
169
|
+
const current = sourceForTool(row, host)
|
|
170
|
+
return { ...row, source: JSON.stringify(current.source) }
|
|
155
171
|
}
|
|
156
172
|
|
|
157
173
|
const FIGURE_ID_ARG = {
|
|
158
174
|
figureId: {
|
|
159
175
|
type: 'string',
|
|
160
|
-
description: 'The figure to read. Omit
|
|
176
|
+
description: 'The figure to read. Omit when host context has an open figureId or an unsaved new editor draft. On the catalog, first resolve the user’s requested figure with listFigures.'
|
|
161
177
|
}
|
|
162
178
|
} as const
|
|
163
179
|
|
|
@@ -206,10 +222,11 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
206
222
|
schema: { type: 'object', properties: { ...FIGURE_ID_ARG } },
|
|
207
223
|
builder: async (args: any, ctx: any) => {
|
|
208
224
|
const domainId = requireDomainId(ctx?.state)
|
|
209
|
-
const figureId = figureIdFrom(args, ctx?.
|
|
210
|
-
const row = await figureInDomain(figureId, domainId)
|
|
225
|
+
const figureId = figureIdFrom(args, ctx?.host)
|
|
226
|
+
const row = figureId ? await figureInDomain(figureId, domainId) : unsavedFigure(ctx?.host)
|
|
227
|
+
if (!row) return missingFigureTarget()
|
|
211
228
|
|
|
212
|
-
const current = sourceForTool(row, ctx?.
|
|
229
|
+
const current = sourceForTool(row, ctx?.host)
|
|
213
230
|
if (!current.source) {
|
|
214
231
|
// 저장된 정본이 없는 상태가 실제로 있다(새로 만들고 저장 안 한 것). 지어내지 않는다.
|
|
215
232
|
return { id: row.id, type: row.type, name: row.name, source: null, cost: null }
|
|
@@ -240,10 +257,11 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
240
257
|
schema: { type: 'object', properties: { ...FIGURE_ID_ARG } },
|
|
241
258
|
builder: async (args: any, ctx: any) => {
|
|
242
259
|
const domainId = requireDomainId(ctx?.state)
|
|
243
|
-
const figureId = figureIdFrom(args, ctx?.
|
|
244
|
-
const row = await figureInDomain(figureId, domainId)
|
|
260
|
+
const figureId = figureIdFrom(args, ctx?.host)
|
|
261
|
+
const row = figureId ? await figureInDomain(figureId, domainId) : unsavedFigure(ctx?.host)
|
|
262
|
+
if (!row) return missingFigureTarget()
|
|
245
263
|
|
|
246
|
-
const current = sourceForTool(row, ctx?.
|
|
264
|
+
const current = sourceForTool(row, ctx?.host)
|
|
247
265
|
const { inspectSource } = getDeps()
|
|
248
266
|
return { ...inspectSource(current.source ? JSON.stringify(current.source) : null), draft: current.draft }
|
|
249
267
|
}
|
|
@@ -288,15 +306,18 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
288
306
|
후보가 온다.
|
|
289
307
|
*/
|
|
290
308
|
let base: unknown
|
|
291
|
-
if (!args?.create) {
|
|
292
|
-
const open = typeof args?.figureId === 'string' ? args.figureId : ctx?.
|
|
309
|
+
if (!args?.create && args?.figureId !== null) {
|
|
310
|
+
const open = typeof args?.figureId === 'string' ? args.figureId : ctx?.host?.figureId
|
|
293
311
|
if (typeof open === 'string' && open.trim()) {
|
|
294
312
|
const row = await figureInDomain(open.trim(), domainId)
|
|
295
|
-
base = sourceForTool(row, ctx?.
|
|
313
|
+
base = sourceForTool(row, ctx?.host).source ?? undefined
|
|
314
|
+
} else {
|
|
315
|
+
const draft = unsavedFigure(ctx?.host)
|
|
316
|
+
if (draft) base = JSON.parse(draft.source)
|
|
296
317
|
}
|
|
297
318
|
}
|
|
298
319
|
|
|
299
|
-
const palette = Array.isArray(ctx?.
|
|
320
|
+
const palette = Array.isArray(ctx?.host?.palette) ? ctx.host.palette : []
|
|
300
321
|
if (palette.length === 0) {
|
|
301
322
|
/*
|
|
302
323
|
팔레트를 모르면 모델이 색을 지어내고, 그 후보는 그리는 시점에 `resolveToken` 이 던진다 —
|
|
@@ -306,7 +327,7 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
306
327
|
}
|
|
307
328
|
|
|
308
329
|
const { proposeFigure } = getDeps()
|
|
309
|
-
const feedback = feedbackForTool(ctx?.
|
|
330
|
+
const feedback = feedbackForTool(ctx?.host, base ? figureIdFrom(args, ctx?.host) : undefined)
|
|
310
331
|
const result = await proposeFigure({
|
|
311
332
|
prompt,
|
|
312
333
|
palette,
|
|
@@ -330,7 +351,12 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
330
351
|
reason:
|
|
331
352
|
`등급 ${result.score.grade} · 삼각형 ${result.cost.triangles} · ` +
|
|
332
353
|
`한 도면 ${result.cost.at.drawCalls}회 · ${result.attempts}번에 만들었다`,
|
|
333
|
-
args: {
|
|
354
|
+
args: {
|
|
355
|
+
baseSource: base ? JSON.stringify(base) : undefined,
|
|
356
|
+
prompt, figureId: base ? figureIdFrom(args, ctx?.host) : undefined,
|
|
357
|
+
draftType: base && ctx?.host?.editorMode === 'new' && !figureIdFrom(args, ctx?.host)
|
|
358
|
+
? (base as any).type : undefined
|
|
359
|
+
},
|
|
334
360
|
source: result.source,
|
|
335
361
|
score: result.score,
|
|
336
362
|
cost: result.cost,
|
|
@@ -356,10 +382,14 @@ export function registerFigureToolCategories(): void {
|
|
|
356
382
|
description: '저작 중인 Figure 의 정본·비용·발행 판정 조회와 후보 제안(저장 아님).',
|
|
357
383
|
guidance: [
|
|
358
384
|
'- **도형을 만들거나 고쳐 달라는 요청에는 반드시 `proposeFigureCandidate` 를 호출한다.**',
|
|
385
|
+
' 후보는 도구 호출 결과에서만 생긴다.',
|
|
359
386
|
' 호출 없이 "후보를 만들었습니다" · "적용 버튼을 누르세요" 라고 쓰는 것은 **거짓이다** — 화면에 후보 칸이 생기지 않으므로 사용자는 존재하지 않는 버튼을 찾는다.',
|
|
360
|
-
' 후보
|
|
387
|
+
' 도크에서는 후보 카드의 버튼을 눌러야 모델러 비교 화면이 열린다. 아직 열지 않은 비교 화면이 이미 보인다고 말하지 않는다.',
|
|
388
|
+
' 요청한 부품만 바뀌었다고 추측하지 않는다. 후보와 원본을 비교한 근거가 없으면 변경 범위는 비교 화면에서 확인하도록 안내한다.',
|
|
361
389
|
'- 정본을 직접 저장하지 않는다. 후보만 내고, 받을지 버릴지는 저작자가 화면에서 정한다.',
|
|
390
|
+
'- 좌표와 크기는 X/Y/Z 축 이름으로 정확하게 표시한다. 확인되지 않은 가로·세로·높이 의미를 임의로 붙이지 않는다.',
|
|
362
391
|
'- **고치라는 요청이면 먼저 `getFigureSource` 를 부른다.** 읽지 않고 고치면 짐작을 상대로 후보를 내게 되고, 부품 이름이 갈려 이미 배치된 보드의 바인딩이 끊긴다.',
|
|
392
|
+
'- 목록 화면에는 열린 도형이 없다. 기존 도형을 조회·수정할 때는 `listFigures`에서 사용자가 말한 이름·타입의 id를 찾는다. 대상이 모호할 때만 어느 도형인지 묻는다. 새 제작 요청에는 기존 정본을 조회하지 않고 `proposeFigureCandidate`를 create: true로 호출한다.',
|
|
363
393
|
'- 타입 이름을 지어내지 않는다 — `listFigures` 로 확인한다. **타입 이름은 저장되는 식별자**이고 보드가 그 이름으로 도형을 찾는다.',
|
|
364
394
|
'- 발행이 안 되는 이유를 물으면 `inspectFigure` 를 부른다. 판정 없이 "발행할 수 있습니다"라고 말하지 않는다.',
|
|
365
395
|
' 판정의 각 항목은 **왜 문제인지와 무엇을 바꾸면 되는지**를 함께 담고 있다. 그대로 전한다 — 요약하면 저작자가 어느 부품의 어느 축을 고칠지 다시 찾아야 한다.',
|