@things-factory/figure-service 10.1.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.9",
3
+ "version": "10.1.13",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -32,5 +32,5 @@
32
32
  "graphql-upload": "^15.0.2",
33
33
  "koa-router": "^7.4.0"
34
34
  },
35
- "gitHead": "ccbb6cfee4f73360a863d17f58a6c4134221dae1"
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('열린 도형도 없고 id 도 없으면 명시 실패한다 — 아무 도형이나 고르지 않는다', async () => {
164
- await expect(toolNamed('getFigureSource').builder({}, ctx({}))).rejects.toThrow(/figureId is required/)
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
- throw new Error('figureId is required: this surface has no figure open to resolve it from')
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 to use the one open in the editor.'
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?.state?.host)
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?.state?.host)
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?.state?.host)
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?.state?.host)
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?.state?.host?.figureId
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?.state?.host).source ?? undefined
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?.state?.host?.palette) ? ctx.state.host.palette : []
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?.state?.host, base ? figureIdFrom(args, ctx?.state?.host) : undefined)
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: { prompt, figureId: base ? figureIdFrom(args, ctx?.state?.host) : undefined },
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
  ' 판정의 각 항목은 **왜 문제인지와 무엇을 바꾸면 되는지**를 함께 담고 있다. 그대로 전한다 — 요약하면 저작자가 어느 부품의 어느 축을 고칠지 다시 찾아야 한다.',