@things-factory/figure-service 10.1.33 → 10.1.35

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.33",
3
+ "version": "10.1.35",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -23,15 +23,15 @@
23
23
  "migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
24
24
  },
25
25
  "dependencies": {
26
- "@hatiolab/figure-model": "^0.1.27",
26
+ "@hatiolab/figure-model": "^0.1.32",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
28
  "@things-factory/ai-client-base": "^10.1.31",
29
- "@things-factory/auth-base": "^10.1.33",
29
+ "@things-factory/auth-base": "^10.1.34",
30
30
  "@things-factory/env": "^10.1.20",
31
- "@things-factory/shell": "^10.1.33",
31
+ "@things-factory/shell": "^10.1.34",
32
32
  "graphql-scalars": "^1.22.4",
33
33
  "graphql-upload": "^15.0.2",
34
34
  "koa-router": "^7.4.0"
35
35
  },
36
- "gitHead": "7fb7b6587846b5ff6ee7a4983652d9561b4d6b2c"
36
+ "gitHead": "396f43b648b704f1938a2d890ab2f6efa33fdb86"
37
37
  }
@@ -30,6 +30,26 @@ describe('release-gate repair candidates', () => {
30
30
  ]))
31
31
  expect(split?.fixes?.every(fix => fix.resolved > 0)).toBe(true)
32
32
  })
33
+
34
+ it('offers one coordinated draft repair only after the full sizing suite clears', () => {
35
+ const source = {
36
+ version: 2,
37
+ type: 'STACK',
38
+ base: { x: 100, y: 200, z: 100 },
39
+ detailLevel: 'M',
40
+ parts: [
41
+ { name: 'body', primitive: 'cube', transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' }, anchor: { y: 'min' } },
42
+ { name: 'cap-a', primitive: 'cube', transform: { position: { x: -25, y: 110, z: 0 }, size: { x: 20, y: 20, z: 20 } }, material: { token: 'palette.accent' }, anchor: { y: 'max' } },
43
+ { name: 'cap-b', primitive: 'cube', transform: { position: { x: 25, y: 110, z: 0 }, size: { x: 20, y: 20, z: 20 } }, material: { token: 'palette.accent' }, anchor: { y: 'max' } }
44
+ ]
45
+ }
46
+
47
+ const split = inspectSource(JSON.stringify(source)).findings.find(finding => finding.code === 'sizing-splits')
48
+
49
+ expect(split?.fixPlan).toEqual(expect.objectContaining({ safe: true, resolved: expect.any(Number) }))
50
+ expect(split?.fixPlan?.fixes.length).toBeGreaterThan(0)
51
+ expect(split?.fixPlan?.fixes.every(fix => fix.safe)).toBe(true)
52
+ })
33
53
  })
34
54
 
35
55
  /*
@@ -13,6 +13,18 @@ export interface GateFix {
13
13
  safe: boolean
14
14
  }
15
15
 
16
+ /**
17
+ * A coordinated repair, measured from the current source all the way through the sizing suite.
18
+ * A split often involves several independently anchored parts, so no single anchor edit can be
19
+ * called a solution. This remains a draft-only edit; it is never saved or released by the gate.
20
+ */
21
+ export interface GateFixPlan {
22
+ fixes: GateFix[]
23
+ resolved: number
24
+ /** True only after the complete plan leaves no release-blocking sizing split. */
25
+ safe: boolean
26
+ }
27
+
16
28
  /**
17
29
  * 발행해도 되나 — **서버가 판정한다.**
18
30
  *
@@ -65,6 +77,8 @@ export interface GateFinding {
65
77
  * 수락하기 전에는 정본을 바꾸지 않는다.
66
78
  */
67
79
  fixes?: GateFix[]
80
+ /** The one-click, fully remeasured repair when individual candidates are insufficient. */
81
+ fixPlan?: GateFixPlan
68
82
  blocking: boolean
69
83
  }
70
84
 
@@ -122,6 +136,134 @@ function measuredFixes(source: FigureSource, factor: number): GateFix[] {
122
136
  return fixes.sort((a, b) => b.resolved - a.resolved || a.part.localeCompare(b.part)).slice(0, 12)
123
137
  }
124
138
 
139
+ function sourceWithFix(source: FigureSource, fix: Pick<GateFix, 'part' | 'axis' | 'to'>): FigureSource {
140
+ return {
141
+ ...source,
142
+ parts: source.parts.map(part => part.name === fix.part
143
+ ? { ...part, anchor: { ...(part.anchor ?? {}), [fix.axis]: fix.to } }
144
+ : part)
145
+ }
146
+ }
147
+
148
+ function splitCount(source: FigureSource, factor: number) {
149
+ return sizingReport(compile(source), factor).findings.filter(finding => finding.code === 'sizing-splits').length
150
+ }
151
+
152
+ /**
153
+ * Find a small coordinated repair by repeatedly choosing the measured anchor edit that removes
154
+ * the most remaining splits. Every step is evaluated against the real compiler; prose is never
155
+ * interpreted. If the complete suite is not clear, no "recommended" button is exposed.
156
+ */
157
+ function measuredFixPlan(source: FigureSource, factor: number): GateFixPlan | undefined {
158
+ const originalCount = splitCount(source, factor)
159
+ if (originalCount === 0) return undefined
160
+
161
+ let working = source
162
+ let remaining = originalCount
163
+ const used = new Set<string>()
164
+ const fixes: GateFix[] = []
165
+
166
+ for (let step = 0; step < source.parts.length * 3 && remaining > 0; step++) {
167
+ const report = sizingReport(compile(working), factor)
168
+ let best: GateFix | undefined
169
+
170
+ for (const finding of report.findings) {
171
+ if (finding.code !== 'sizing-splits' || !finding.way) continue
172
+ const axes = finding.way.split('-').filter((axis): axis is Axis => ['x', 'y', 'z'].includes(axis))
173
+ const names = new Set(finding.clumps.flat())
174
+
175
+ for (const part of working.parts) {
176
+ if (!names.has(part.name)) continue
177
+ for (const axis of axes) {
178
+ const key = `${part.name}:${axis}`
179
+ if (used.has(key)) continue
180
+ for (const to of ['min', 'center', 'max', 'span'] as const) {
181
+ if (part.anchor?.[axis] === to) continue
182
+ const afterCount = splitCount(sourceWithFix(working, { part: part.name, axis, to }), factor)
183
+ const resolved = remaining - afterCount
184
+ if (resolved <= 0) continue
185
+ const candidate: GateFix = {
186
+ findingWay: finding.way,
187
+ part: part.name,
188
+ axis,
189
+ from: source.parts.find(one => one.name === part.name)?.anchor?.[axis],
190
+ to,
191
+ resolved,
192
+ safe: false
193
+ }
194
+ if (!best || candidate.resolved > best.resolved ||
195
+ (candidate.resolved === best.resolved && `${candidate.part}:${candidate.axis}:${candidate.to}` < `${best.part}:${best.axis}:${best.to}`)) {
196
+ best = candidate
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ if (!best) break
204
+ fixes.push(best)
205
+ used.add(`${best.part}:${best.axis}`)
206
+ working = sourceWithFix(working, best)
207
+ remaining = splitCount(working, factor)
208
+ }
209
+
210
+ if (remaining === 0 && fixes.length > 0) {
211
+ return { fixes: fixes.map(fix => ({ ...fix, safe: true })), resolved: originalCount, safe: true }
212
+ }
213
+
214
+ /*
215
+ * Some assemblies need two anchors to move together. Neither first move removes a whole split,
216
+ * so greedy improvement alone cannot see the repair. Search a small, deterministic beam of those
217
+ * coordinated moves, and still return only a plan that the compiler proves clear.
218
+ */
219
+ const firstReport = sizingReport(compile(source), factor)
220
+ const candidates = new Map<string, { part: string; axis: Axis }>()
221
+ for (const finding of firstReport.findings) {
222
+ if (finding.code !== 'sizing-splits' || !finding.way) continue
223
+ const axes = finding.way.split('-').filter((axis): axis is Axis => ['x', 'y', 'z'].includes(axis))
224
+ const names = new Set(finding.clumps.flat())
225
+ for (const part of source.parts) {
226
+ if (!names.has(part.name)) continue
227
+ for (const axis of axes) candidates.set(`${part.name}:${axis}`, { part: part.name, axis })
228
+ }
229
+ }
230
+
231
+ type SearchState = { source: FigureSource; fixes: GateFix[]; used: Set<string>; remaining: number }
232
+ let frontier: SearchState[] = [{ source, fixes: [], used: new Set(), remaining: originalCount }]
233
+ const visited = new Set<string>()
234
+ for (let depth = 0; depth < 8 && frontier.length; depth++) {
235
+ const next: SearchState[] = []
236
+ for (const state of frontier) {
237
+ for (const [key, target] of candidates) {
238
+ if (state.used.has(key)) continue
239
+ const current = state.source.parts.find(part => part.name === target.part)
240
+ if (!current) continue
241
+ for (const to of ['min', 'center', 'max', 'span'] as const) {
242
+ if (current.anchor?.[target.axis] === to) continue
243
+ const changed = sourceWithFix(state.source, { ...target, to })
244
+ const signature = JSON.stringify(changed.parts.map(part => [part.name, part.anchor]))
245
+ if (visited.has(signature)) continue
246
+ visited.add(signature)
247
+ const after = splitCount(changed, factor)
248
+ const fix: GateFix = {
249
+ findingWay: 'coordinated', part: target.part, axis: target.axis,
250
+ from: source.parts.find(part => part.name === target.part)?.anchor?.[target.axis], to,
251
+ resolved: state.remaining - after, safe: false
252
+ }
253
+ const fixes = [...state.fixes, fix]
254
+ if (after === 0) return { fixes: fixes.map(one => ({ ...one, safe: true })), resolved: originalCount, safe: true }
255
+ next.push({ source: changed, fixes, used: new Set([...state.used, key]), remaining: after })
256
+ }
257
+ }
258
+ }
259
+ /* Keep enough alternatives to discover a paired repair without making release inspection expensive. */
260
+ frontier = next.sort((a, b) => a.remaining - b.remaining || a.fixes.length - b.fixes.length ||
261
+ JSON.stringify(a.fixes).localeCompare(JSON.stringify(b.fixes))).slice(0, 24)
262
+ }
263
+
264
+ return undefined
265
+ }
266
+
125
267
  /**
126
268
  * 정본 하나를 판정한다.
127
269
  *
@@ -190,6 +332,7 @@ export function inspectSource(sourceJson?: string | null, releasedJson?: string
190
332
  const source = parsed as FigureSource
191
333
  const report = sizingReport(compile(source))
192
334
  const fixes = measuredFixes(source, report.factor)
335
+ const fixPlan = measuredFixPlan(source, report.factor)
193
336
 
194
337
  for (const finding of report.findings) {
195
338
  findings.push({
@@ -201,6 +344,8 @@ export function inspectSource(sourceJson?: string | null, releasedJson?: string
201
344
  /* 숫자가 아니라 자리를 말한다 — 어느 부품들이 갈라져 나왔나. */
202
345
  at: finding.clumps.map(clump => [...clump].sort().join('+')).join(' | ') || undefined,
203
346
  fixes: finding.code === 'sizing-splits' ? fixes.filter(fix => fix.findingWay === finding.way) : undefined,
347
+ /* A plan is attached once: it clears the full suite, not merely this card's symptom. */
348
+ fixPlan: finding.code === 'sizing-splits' && finding === report.findings.find(one => one.code === 'sizing-splits') ? fixPlan : undefined,
204
349
  blocking: finding.code === 'sizing-splits'
205
350
  })
206
351
  }
@@ -82,17 +82,19 @@ function candidate(over: Record<string, unknown> = {}) {
82
82
  * 고치는 요청에서 「부품 이름을 바꾸지 말라」가 빠지면 AI 가 이름을 갈아 버리고, 그러면
83
83
  * 이미 배치된 도면의 바인딩이 끊긴다. 답만 보는 모형으로는 그것이 나갔는지 알 수 없다.
84
84
  */
85
- function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; system?: string } {
85
+ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; options: any[]; system?: string } {
86
86
  const client = {
87
87
  id: 'stub',
88
88
  calls: 0,
89
89
  sent: [] as any[][],
90
+ options: [] as any[],
90
91
  system: undefined as string | undefined,
91
92
  async chat() {
92
93
  return ''
93
94
  },
94
- async generateJSON(messages: any[], options?: { systemPrompt?: string }) {
95
+ async generateJSON(messages: any[], options?: { systemPrompt?: string; maxTokens?: number }) {
95
96
  client.sent.push(JSON.parse(JSON.stringify(messages)))
97
+ client.options.push(options)
96
98
  client.system = options?.systemPrompt
97
99
  const answer = answers[client.calls]
98
100
  client.calls++
@@ -100,7 +102,7 @@ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[]
100
102
  return answer
101
103
  }
102
104
  }
103
- return client as unknown as AIClient & { calls: number; sent: any[][]; system?: string }
105
+ return client as unknown as AIClient & { calls: number; sent: any[][]; options: any[]; system?: string }
104
106
  }
105
107
 
106
108
  /** 첫 요청에 실려 나간 사람 쪽 말. 이어 붙인 것까지 한 덩어리로 본다. */
@@ -266,6 +268,24 @@ describe('joints (ADR-0066)', () => {
266
268
  })
267
269
 
268
270
  describe('고치라는 요청일 때', () => {
271
+ it('편집 후보에는 JSON 완결에 충분한 출력 한도를 준다', async () => {
272
+ const client = stubClient([candidate()])
273
+ setDefaultAIClient(client as any)
274
+
275
+ await proposeFigure({ prompt: '색을 바꿔 줘', base: candidate() as any, palette: PALETTE })
276
+
277
+ expect((client as any).options?.[0]?.maxTokens).toBe(16384)
278
+ })
279
+
280
+ it('새 Figure 후보에도 같은 출력 한도를 준다', async () => {
281
+ const client = stubClient([candidate()])
282
+ setDefaultAIClient(client as any)
283
+
284
+ await proposeFigure({ prompt: '교반기', type: 'MIXER', palette: PALETTE })
285
+
286
+ expect((client as any).options?.[0]?.maxTokens).toBe(16384)
287
+ })
288
+
269
289
  it('요청이 안 건드린 것을 그대로 실으라고 못 박는다', async () => {
270
290
  /*
271
291
  이 문장이 없으면 애니메이션이 있는 도형을 「색만 바꿔 줘」로 고쳤을 때 모델이 clip 을
@@ -75,6 +75,12 @@ import type { FigureQualityReview } from './figure-quality.js'
75
75
  /** 몇 번까지 다시 시키나. 이보다 늘려도 대개 같은 자리에서 막힌다. */
76
76
  const MAX_ATTEMPTS = 3
77
77
 
78
+ /*
79
+ * Figure 후보는 새로 만들든 기존 것을 고치든 완결된 정본 하나를 JSON으로 낸다. 기본 4,096
80
+ * 토큰으로는 부품 수가 조금만 늘어도 JSON이 중간에서 끊겨 닫는 괄호를 잃는다.
81
+ */
82
+ const FIGURE_GENERATE_MAX_TOKENS = 16384
83
+
78
84
  export interface ProposeInput {
79
85
  /** 사람이 시킨 말. */
80
86
  prompt: string
@@ -478,7 +484,10 @@ export async function proposeFigure(input: ProposeInput): Promise<ProposeResult>
478
484
  // 코드 울타리나 앞뒤 설명을 우리가 걷어내지 않아도 된다.
479
485
  let answer: unknown
480
486
  try {
481
- answer = await client.generateJSON(messages, { systemPrompt: system })
487
+ answer = await client.generateJSON(messages, {
488
+ systemPrompt: system,
489
+ maxTokens: FIGURE_GENERATE_MAX_TOKENS
490
+ })
482
491
  } catch (e) {
483
492
  // 모델이 JSON 을 못 냈다. 그것도 사유이므로 돌려주고 다시 시킨다.
484
493
  reasons = [`The answer was not valid JSON: ${(e as Error).message}`]
@@ -408,15 +408,28 @@ function figureToolSpecs(): ToolSpec[] {
408
408
  hostImage = { data: buf, mediaType: rawImage.mediaType || 'image/png' }
409
409
  }
410
410
 
411
- const result = await proposeFigure({
412
- prompt,
413
- palette,
414
- base,
415
- type: typeof args?.type === 'string' ? args.type : undefined,
416
- feedback,
417
- refine: !!feedback?.length,
418
- image: hostImage
419
- })
411
+ let result: any
412
+ try {
413
+ result = await proposeFigure({
414
+ prompt,
415
+ palette,
416
+ base,
417
+ type: typeof args?.type === 'string' ? args.type : undefined,
418
+ feedback,
419
+ refine: !!feedback?.length,
420
+ image: hostImage
421
+ })
422
+ } catch (error) {
423
+ /*
424
+ * 후보 생성은 세 번의 JSON·형식 검사 끝에 실패할 수 있다. 그 마지막 검사 사유를
425
+ * ProposeFailure.reasons 에만 두면 agentic-loop은 "만들지 못했습니다"만 받아
426
+ * 사용자 화면에는 "응답이 비어있습니다"가 된다. 재시도할 수 있는 말로 경계를
427
+ * 넘긴다. 다른 예외(권한·통신)는 원문을 보존한다.
428
+ */
429
+ const reasons = Array.isArray((error as any)?.reasons) ? (error as any).reasons : []
430
+ if (reasons.length > 0) throw new Error([(error as Error).message, ...reasons].join('\n'))
431
+ throw error
432
+ }
420
433
 
421
434
  /*
422
435
  **`proposed: true` 가 카드를 만든다.** 이 표시가 없으면 후보는 도구 추적 안에 묻히고,
@@ -161,6 +161,18 @@ export class FigureGateFix {
161
161
  safe: boolean
162
162
  }
163
163
 
164
+ @ObjectType({ description: 'A coordinated, fully remeasured set of anchor changes that clears release-blocking sizing splits.' })
165
+ export class FigureGateFixPlan {
166
+ @Field(type => [FigureGateFix], { description: 'The draft-only anchor changes to apply together.' })
167
+ fixes: FigureGateFix[]
168
+
169
+ @Field(type => Int, { description: 'Number of blocking sizing checks cleared by the complete plan.' })
170
+ resolved: number
171
+
172
+ @Field({ description: 'True only when the full plan was remeasured with no sizing split left.' })
173
+ safe: boolean
174
+ }
175
+
164
176
  @ObjectType({ description: 'One finding from the release gate.' })
165
177
  export class FigureGateFinding {
166
178
  @Field({ description: 'Stable code — figure-model validation codes, or sizing-splits / sizing-loose.' })
@@ -184,6 +196,12 @@ export class FigureGateFinding {
184
196
  })
185
197
  fixes?: FigureGateFix[]
186
198
 
199
+ @Field(type => FigureGateFixPlan, {
200
+ nullable: true,
201
+ description: 'A coordinated recommended repair, available only when the complete sizing suite clears.'
202
+ })
203
+ fixPlan?: FigureGateFixPlan
204
+
187
205
  @Field({ description: 'True when this finding blocks the release.' })
188
206
  blocking: boolean
189
207
  }