@things-factory/figure-service 10.1.244 → 10.1.246

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.
Files changed (39) hide show
  1. package/README.md +19 -0
  2. package/dist-server/routers/figure-thumbnail-router.js +16 -1
  3. package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
  4. package/dist-server/service/figure/figure-ai-scenarios-run.d.ts +44 -0
  5. package/dist-server/service/figure/figure-ai-scenarios-run.js +94 -0
  6. package/dist-server/service/figure/figure-ai-scenarios-run.js.map +1 -0
  7. package/dist-server/service/figure/figure-ai-scenarios.d.ts +68 -0
  8. package/dist-server/service/figure/figure-ai-scenarios.js +814 -0
  9. package/dist-server/service/figure/figure-ai-scenarios.js.map +1 -0
  10. package/dist-server/service/figure/figure-context.js +3 -0
  11. package/dist-server/service/figure/figure-context.js.map +1 -1
  12. package/dist-server/service/figure/figure-llm-provider.d.ts +10 -0
  13. package/dist-server/service/figure/figure-llm-provider.js +19 -0
  14. package/dist-server/service/figure/figure-llm-provider.js.map +1 -0
  15. package/dist-server/service/figure/figure-mutation.js +19 -23
  16. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  17. package/dist-server/service/figure/figure-propose-steps.d.ts +5 -1
  18. package/dist-server/service/figure/figure-propose-steps.js +14 -2
  19. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  20. package/dist-server/service/figure/figure-query.d.ts +10 -2
  21. package/dist-server/service/figure/figure-query.js +59 -11
  22. package/dist-server/service/figure/figure-query.js.map +1 -1
  23. package/dist-server/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +8 -8
  25. package/scripts/figure-ai-scenarios-agent.cjs +98 -0
  26. package/server/routers/figure-thumbnail-router.ts +18 -2
  27. package/server/service/figure/figure-ai-scenarios-run.ts +127 -0
  28. package/server/service/figure/figure-ai-scenarios.live.test.ts +59 -0
  29. package/server/service/figure/figure-ai-scenarios.test.ts +77 -0
  30. package/server/service/figure/figure-ai-scenarios.ts +792 -0
  31. package/server/service/figure/figure-context.ts +3 -0
  32. package/server/service/figure/figure-llm-provider.ts +20 -0
  33. package/server/service/figure/figure-llm-smoke.test.ts +5 -68
  34. package/server/service/figure/figure-mutation.ts +17 -23
  35. package/server/service/figure/figure-propose-steps.test.ts +55 -0
  36. package/server/service/figure/figure-propose-steps.ts +14 -3
  37. package/server/service/figure/figure-public-db.test.ts +108 -0
  38. package/server/service/figure/figure-query.ts +55 -10
  39. package/server/service/figure/figure-thumbnail-db.test.ts +16 -0
@@ -328,6 +328,7 @@ const SUBJECT: { [K in FigureProposalStep['kind']]: (step: StepOf<K>, base: Figu
328
328
  'set-section-corner': step => step.part,
329
329
  'set-cavity': step => step.part,
330
330
  'set-section': step => step.part,
331
+ 'set-pipe': step => step.part,
331
332
  attach: step => step.part,
332
333
  'link-dimension': step => step.part,
333
334
  'follow-size': step => step.part,
@@ -350,6 +351,7 @@ const SUBJECT: { [K in FigureProposalStep['kind']]: (step: StepOf<K>, base: Figu
350
351
  'set-visibility': step => step.target,
351
352
  'remove-part': step => step.target,
352
353
  'duplicate-part': step => step.target,
354
+ 'mirror-part': step => step.target,
353
355
  'move-part': step => step.target,
354
356
  'set-placement': () => undefined,
355
357
  'declare-instance-size': () => undefined,
@@ -374,6 +376,7 @@ const SUBJECT: { [K in FigureProposalStep['kind']]: (step: StepOf<K>, base: Figu
374
376
  'set-driver': () => undefined,
375
377
  'remove-driver': () => undefined,
376
378
  'set-clip': () => undefined,
379
+ 'copy-clip': () => undefined,
377
380
  'set-motion-group': () => undefined,
378
381
  'remove-motion-group': () => undefined,
379
382
  'set-data-schema': () => undefined,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Which provider a live figure-AI run asks (the smoke test, the scenarios): the one `FIGURE_LLM_PROVIDER` names, or the
3
+ * first whose key is set. Its model is `FIGURE_LLM_MODEL`, or the adapter's default. Undefined: no key, nothing runs.
4
+ */
5
+ export type FigureLLMProvider = 'anthropic' | 'gemini' | 'openai'
6
+
7
+ export function figureLLMProvider(env: NodeJS.ProcessEnv = process.env): { provider: FigureLLMProvider; apiKey: string; model?: string } | undefined {
8
+ const requested = env.FIGURE_LLM_PROVIDER as FigureLLMProvider | undefined
9
+ const candidates: Array<{ provider: FigureLLMProvider; keys: string[] }> = [
10
+ { provider: 'anthropic', keys: ['ANTHROPIC_API_KEY'] },
11
+ { provider: 'gemini', keys: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] },
12
+ { provider: 'openai', keys: ['OPENAI_API_KEY'] }
13
+ ]
14
+ const usable = requested ? candidates.filter(candidate => candidate.provider === requested) : candidates
15
+ for (const candidate of usable) {
16
+ const apiKey = candidate.keys.map(key => env[key]).find(Boolean)
17
+ if (apiKey) return { provider: candidate.provider, apiKey, ...(env.FIGURE_LLM_MODEL ? { model: env.FIGURE_LLM_MODEL } : {}) }
18
+ }
19
+ return undefined
20
+ }
@@ -11,78 +11,15 @@
11
11
  */
12
12
  import { createAIClient, type AIClient } from '@things-factory/ai-client-base'
13
13
 
14
- import { applyFigureProposal, compileFigureAsset, createFigureAsset, figureShapeBoundsOf, inspectFigureAsset, type FigureAsset } from '@hatiolab/figure-model'
14
+ import { inspectFigureAsset } from '@hatiolab/figure-model'
15
15
 
16
+ import { mixerDraft, portRevisionMisses } from './figure-ai-scenarios'
17
+ import { figureLLMProvider } from './figure-llm-provider'
16
18
  import { judgeFigureAnswer, proposeFigureSteps } from './figure-propose-steps'
17
19
 
18
20
  // Provider modules register themselves on import. Keep this separate from the deterministic tests.
19
21
  import '@things-factory/ai-client-base'
20
22
 
21
- type Provider = 'anthropic' | 'gemini' | 'openai'
22
-
23
- function provider(): { provider: Provider; apiKey: string } | undefined {
24
- const requested = process.env.FIGURE_LLM_PROVIDER as Provider | undefined
25
- const candidates: Array<{ provider: Provider; keys: string[] }> = [
26
- { provider: 'anthropic', keys: ['ANTHROPIC_API_KEY'] },
27
- { provider: 'gemini', keys: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] },
28
- { provider: 'openai', keys: ['OPENAI_API_KEY'] }
29
- ]
30
- const usable = requested ? candidates.filter(candidate => candidate.provider === requested) : candidates
31
- for (const candidate of usable) {
32
- const apiKey = candidate.keys.map(key => process.env[key]).find(Boolean)
33
- if (apiKey) return { provider: candidate.provider, apiKey }
34
- }
35
- return undefined
36
- }
37
-
38
- /** A mixer: a body standing on the floor, a port fastened to its front. */
39
- function mixerDraft(): FigureAsset {
40
- return applyFigureProposal(createFigureAsset('Mixer'), [
41
- { kind: 'add-part', part: { id: 'body', kind: 'cylinder', dimensions: { radius: 140, length: 320 } } },
42
- { kind: 'add-part', part: { id: 'port', kind: 'box', dimensions: { width: 80, height: 60, depth: 30 } } },
43
- { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } },
44
- /* The body's front is its curved side: a point on it, by the outward normal there (ADR-0140 3.9; push Y, +Z is 270°). */
45
- { kind: 'attach', part: 'port', to: { part: 'body', surface: { around: 270, along: 'centre' } } },
46
- { kind: 'set-placement', placement: 'floor' }
47
- ] as any)
48
- }
49
-
50
- /**
51
- * Each part's reach along its own axes and the colour it is drawn in, at the figure's default size. The colour is the
52
- * palette's value for the part's token: two tokens of one colour draw the same.
53
- */
54
- function drawn(asset: FigureAsset): Record<string, { size: Record<'x' | 'y' | 'z', number>; colour: string }> {
55
- const geometry = compileFigureAsset(asset).evaluate({}).geometry as any[]
56
- return Object.fromEntries(
57
- geometry.map(body => {
58
- const bounds = figureShapeBoundsOf(body.shape)
59
- const size = { x: bounds.x.max - bounds.x.min, y: bounds.y.max - bounds.y.min, z: bounds.z.max - bounds.z.min }
60
- /* The kernel refuses a token the palette has no colour for, so every body has one. */
61
- return [body.id, { size, colour: String(asset.palette![body.material.token]).toLowerCase() }]
62
- })
63
- )
64
- }
65
-
66
- /**
67
- * What "make the port twice as wide and give it an accent colour" asks of the mixer, said as what is missing: the port
68
- * twice as wide (X), its height and depth kept, a colour it did not have, and the body left as it was. Nothing missing:
69
- * an empty list. Checking only that the parts still exist let a revision that changed nothing pass.
70
- */
71
- function portRevisionMisses(before: FigureAsset, after: FigureAsset): string[] {
72
- const was = drawn(before),
73
- now = drawn(after)
74
- const misses: string[] = []
75
- const near = (a: number, b: number) => Math.abs(a - b) <= 0.5
76
- if (!now.port) return ['the port is gone']
77
- if (!near(now.port.size.x, was.port.size.x * 2)) misses.push(`port width ${now.port.size.x}, asked ${was.port.size.x * 2}`)
78
- for (const axis of ['y', 'z'] as const) if (!near(now.port.size[axis], was.port.size[axis])) misses.push(`port ${axis} changed to ${now.port.size[axis]}`)
79
- if (now.port.colour === was.port.colour) misses.push(`port colour still ${was.port.colour}`)
80
- if (!now.body) misses.push('the body is gone')
81
- else for (const axis of ['x', 'y', 'z'] as const) if (!near(now.body.size[axis], was.body.size[axis])) misses.push(`body ${axis} changed to ${now.body.size[axis]}`)
82
- if (now.body && now.body.colour !== was.body.colour) misses.push(`body colour changed to ${now.body.colour}`)
83
- return misses
84
- }
85
-
86
23
  /* The check above, held to answers whose verdict is known - so it is seen to fail a wrong revision without a provider. */
87
24
  describe('the revision check of the smoke test', () => {
88
25
  const draft = mixerDraft()
@@ -113,13 +50,13 @@ describe('the revision check of the smoke test', () => {
113
50
  })
114
51
  })
115
52
 
116
- const picked = process.env.RUN_FIGURE_LLM_SMOKE === '1' ? provider() : undefined
53
+ const picked = process.env.RUN_FIGURE_LLM_SMOKE === '1' ? figureLLMProvider() : undefined
117
54
  const describeIf = picked ? describe : describe.skip
118
55
 
119
56
  describeIf('Figure 실제 LLM smoke', () => {
120
57
  let client: AIClient
121
58
  beforeAll(() => {
122
- client = createAIClient({ ...picked!, ...(process.env.FIGURE_LLM_MODEL ? { model: process.env.FIGURE_LLM_MODEL } : {}) })
59
+ client = createAIClient(picked!)
123
60
  // eslint-disable-next-line no-console
124
61
  console.log(`[figure-llm-smoke] provider=${picked!.provider} model=${process.env.FIGURE_LLM_MODEL ?? '(adapter default)'}`)
125
62
  })
@@ -297,34 +297,28 @@ export class FigureMutation {
297
297
  **버전 번호는 여기서 손대지 않는다.** 저장은 저작이고, 버전은 발행에서 생긴다(`releaseFigure`).
298
298
  board 의 `updateBoard` 도 같다 — 저장 경로에는 version 이 없다.
299
299
  */
300
- const saved = await repository.save({
301
- /*
302
- `domain` 을 명시로 넣는다 — 읽어 온 엔티티에는 관계가 안 실려 있다.
300
+ /*
301
+ **바뀐 칸만 쓴다** (2026-11-03 점검 2). 처음 읽은 행을 통째로 펼쳐 다시 쓰면, 그 사이 다른 편집이
302
+ `saveFigure` 로 저장한 정본이 읽어 둔 옛 정본으로 되돌아간다 — 설명 한 줄을 고쳤을 뿐인데. 정본을 쓰는
303
+ 경우(정본 패치, 타입 바꾸기)는 `saveFigure` 와 같이 읽어 둔 정본이 그대로일 때만 쓴다.
303
304
 
304
- 버전 스냅샷은 넘겨받은 엔티티를 베끼므로, 여기서 빠지면 버전의 도메인이 비고 그 버전은
305
- 도메인으로 거르는 조회에 **한 줄도 안 걸린다**(실제로 그랬다). board 의 저장들도 같은
306
- 자리에서 domain 을 함께 넣는다.
307
- */
308
- domain,
309
- ...figure,
310
- ...patchRest,
311
- /* 앞뒤 공백을 떼고 겹침까지 본 이름. `patch.type` 을 그대로 두면 다듬기 전 값이 들어간다. */
305
+ **고치면 발행이 풀린다.** 발행은 「이 버전을 써도 된다」는 약속이고, 그 뒤에 바뀌면 약속한 것과
306
+ 배포된 것이 달라진다. 그래서 저장은 언제나 초안으로 되돌린다. 버전 번호는 여기서 손대지 않는다 —
307
+ 버전은 발행에서 생긴다(`releaseFigure`).
308
+ */
309
+ const { type: _type, source: _source, ...fields } = patchRest
310
+ const changes = {
311
+ ...Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)),
312
312
  ...(renamed !== undefined ? { type: renamed } : {}),
313
- source,
314
- /*
315
- **고치면 발행이 풀린다.**
316
-
317
- 발행은 「이 버전을 써도 된다」는 약속이고, 그 뒤에 정본이 바뀌면 약속한 것과 배포된 것이
318
- 달라진다. 그래서 저장은 언제나 초안으로 되돌리고, 다시 내보내려면 발행을 한 번 더 한다
319
- (버전 번호가 하나 오르고 버전이 하나 더 남는다). board 의 `updateBoard` 가 같은 한 줄을 쓴다.
320
- */
321
- state: 'draft',
313
+ ...(source !== figure.source ? { source } : {}),
314
+ state: 'draft' as const,
322
315
  ...(derived ?? {}),
323
- // 썸네일이 **썸네일로서** 바뀐 때만 시각을 갱신한다. 이름·설명 변경에는 안 건드린다.
324
- thumbnail: figure.thumbnail ?? null,
325
316
  ...picture,
326
317
  updater: user
327
- })
318
+ }
319
+ if ('source' in changes) await compareAndSwapFigure(criteria => repository.update(criteria, changes), id, domain.id, figure.source)
320
+ else await repository.update({ id, domain: { id: domain.id } }, changes)
321
+ const saved = await repository.findOneOrFail({ where: { id, domain: { id: domain.id } } })
328
322
 
329
323
  return { figure: saved, violations }
330
324
  }
@@ -96,6 +96,23 @@ describe('the model is told the kernel\'s commands and the open draft', () => {
96
96
  expect(made.source.name).toBe('Lift table 2')
97
97
  expect(draft.name).toBe('Lift table')
98
98
  })
99
+
100
+ it('a revision shows the draft\'s pipes as set-pipe takes them, and none for a draft without', async () => {
101
+ const draft = applyFigureProposal(createFigureAsset('Elbow'), [
102
+ { kind: 'add-part', part: { id: 'pipe', kind: 'pipe', dimensions: { radius: 40, wall: 4 }, path: [{ line: 500 }, { bend: { radius: 120, angle: 90, toward: 90 } }, { line: 400 }] } },
103
+ { kind: 'add-part', part: { id: 'stand', kind: 'box', dimensions: { width: 100, height: 100, depth: 100 } } },
104
+ { kind: 'attach', part: 'pipe', to: { plane: 'mounting-plane' } },
105
+ { kind: 'attach', part: 'stand', to: { plane: 'mounting-plane' }, align: { x: { mm: 600 } } }
106
+ ] as any)
107
+ const told = async (base: typeof draft) => {
108
+ const client = stubClient([{ steps: [{ kind: 'rename', name: 'Again' }] }])
109
+ await proposeFigureSteps({ client, prompt: '이름', base })
110
+ return client.sent[0]![0]!.content as string
111
+ }
112
+ const line = (await told(draft)).split('\n').find(l => l.startsWith('{"pipe":'))
113
+ expect(JSON.parse(line!)).toEqual({ pipe: { kind: 'pipe', radius: 40, wall: 4, path: [{ line: 500 }, { bend: { radius: 120, angle: 90, turn: '+X' } }, { line: 400 }] } })
114
+ expect(await told(applyFigureProposal(createFigureAsset('Box'), liftTable as any))).not.toContain('Its pipes and ducts')
115
+ })
99
116
  })
100
117
 
101
118
  describe('the steps are held to the kernel and the gate', () => {
@@ -108,6 +125,29 @@ describe('the steps are held to the kernel and the gate', () => {
108
125
  expect(made.score?.grade).toMatch(/^[ABCD]$/)
109
126
  })
110
127
 
128
+ /* ADR-0100 §16 (2026-11-03, 「파이프같은 경우가 많이 활용될 것 같으니까」): a pipe run and a tapered box made by a proposal. */
129
+ it('a pipe run with a flange on its end, and a tapered cover, are made by the grammar\'s words; a bend too tight is said back', async () => {
130
+ const run = [
131
+ { kind: 'add-part', part: { id: 'pipe', kind: 'pipe', dimensions: { radius: 40, wall: 4 }, path: [{ line: 500 }, { bend: { radius: 120, angle: 90, toward: 90 } }, { line: 400 }] } },
132
+ { kind: 'attach', part: 'pipe', to: { plane: 'mounting-plane' } },
133
+ { kind: 'add-part', part: { id: 'flange', kind: 'cylinder', dimensions: { radius: 70, length: 20 } } },
134
+ { kind: 'attach', part: 'flange', to: { part: 'pipe', face: 'right' } },
135
+ { kind: 'add-part', part: { id: 'cover', kind: 'tapered-box', dimensions: { width: 300, depth: 200, height: 150, topWidth: 180, topDepth: 120, round: 20, topRound: 10 } } },
136
+ { kind: 'attach', part: 'cover', to: { plane: 'mounting-plane' } }
137
+ ]
138
+ const tight = run.map((s, i) => (i === 0 ? { ...s, part: { ...(s as any).part, path: [{ line: 500 }, { bend: { radius: 30, angle: 90, toward: 90 } }] } } : s))
139
+ const client = stubClient([{ steps: tight }, { steps: run }])
140
+ const made = await proposeFigureSteps({ client, prompt: '엘보 배관과 플랜지, 덮개', name: 'Pipe run' })
141
+ expect(made.attempts).toBe(2)
142
+ expect(JSON.stringify(client.sent[1])).toContain('GEOMETRY_DOMAIN')
143
+ expect(made.steps).toEqual(run)
144
+ expect(inspectFigureAsset(made.source).violations).toEqual([])
145
+ /* The words the model was told include the pipe's and the tapered box's. */
146
+ expect(client.system).toContain("kind: 'pipe'")
147
+ expect(client.system).toContain("kind: 'set-pipe'")
148
+ expect(client.system).toContain("kind: 'tapered-box'")
149
+ })
150
+
111
151
  it('a refused step goes back with its place and the kernel\'s code, and the next answer is taken', async () => {
112
152
  const client = stubClient([{ steps: wrongFace }, { steps: liftTable }])
113
153
  const made = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' })
@@ -178,6 +218,21 @@ describe('the steps are held to the kernel and the gate', () => {
178
218
  "Make this one bigger": the part the person selected reaches the step writer, or it guesses which part was meant
179
219
  (ADR-0097 §5.6 item 5). Only a revision has a selection; a new figure has nothing to select.
180
220
  */
221
+ describe('a pipe in a revision is told as set-pipe takes it', () => {
222
+ it('its bends by the axis they turn to, so "down" is written as -Y', async () => {
223
+ const base = applyFigureProposal(createFigureAsset('Elbow'), [
224
+ { kind: 'add-part', part: { id: 'pipe', kind: 'pipe', dimensions: { radius: 40, wall: 4 }, path: [{ line: 500 }, { bend: { radius: 120, angle: 90, toward: 90 } }, { line: 400 }] } },
225
+ { kind: 'attach', part: 'pipe', to: { plane: 'mounting-plane' } }
226
+ ] as any)
227
+ const client = stubClient([{ steps: [{ kind: 'set-pipe', part: 'pipe', append: [{ bend: { radius: 120, angle: 90, turn: '-Y' } }, { line: 300 }] }] }])
228
+ const made = await proposeFigureSteps({ client, prompt: '끝에서 아래로 꺾어 300 더', base })
229
+ const task = String(client.sent[0][0].content)
230
+ expect(task).toContain('"turn":"+X"')
231
+ expect(task).toContain('set-pipe takes them')
232
+ expect(made.attempts).toBe(1)
233
+ })
234
+ })
235
+
181
236
  describe('what the person has in view goes with a revision', () => {
182
237
  it('the selected part and the values being tried are named in the task', async () => {
183
238
  const draft = applyFigureProposal(createFigureAsset('Lift table'), liftTable as any)
@@ -1,4 +1,4 @@
1
- import { FIGURE_PROPOSAL_GRAMMAR, applyFigureProposal, createFigureAsset, inspectFigureAsset, serializeFigureAsset, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'
1
+ import { FIGURE_PROPOSAL_GRAMMAR, applyFigureProposal, createFigureAsset, figurePipesOf, inspectFigureAsset, serializeFigureAsset, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'
2
2
  import type { FigureAsset, FigureProposalStep, FigureRefusalDetails } from '@hatiolab/figure-model'
3
3
  import { AIAnswerNotJSON, type AIClient } from '@things-factory/ai-client-base'
4
4
  import type { AIImageMediaType, AIMessage } from '@things-factory/ai-client-base'
@@ -61,7 +61,9 @@ export class ProposeFigureFailure extends Error {
61
61
  message: string,
62
62
  /** The last attempt's reasons, as the kernel and the gate said them. */
63
63
  readonly reasons: string[],
64
- readonly attempts: number
64
+ readonly attempts: number,
65
+ /** The last answer refused, as the model wrote it: what to look at again without asking it. */
66
+ readonly lastAnswer?: unknown
65
67
  ) {
66
68
  super(message)
67
69
  this.name = 'ProposeFigureFailure'
@@ -151,6 +153,13 @@ export function taskPromptFigure(input: ProposeFigureInput, view: FigureView | u
151
153
  'Any other part may be named to fasten to or to follow; a step that changes it is refused. A section in outline.omitted is not known here: leave what is in it as it is.'
152
154
  )
153
155
  }
156
+ /*
157
+ Its pipes as set-pipe takes them: the source holds a bend as degrees across the section where it starts, which a
158
+ writer cannot turn into "down" (AI modelling scenarios 2026-11-03); said by the axis it turns to, it is written back right.
159
+ */
160
+ const pipes = figurePipesOf(input.base)
161
+ if (Object.keys(pipes).length)
162
+ lines.push('', 'Its pipes and ducts as set-pipe takes them, each bend by the axis it turns to there (to extend one, append; to change its section, give only the measures):', JSON.stringify(pipes))
154
163
  lines.push('', `Palette tokens it has: ${Object.keys(input.base.palette ?? {}).join(', ') || 'none'}.`)
155
164
  const { selection, trying } = input.focus ?? {}
156
165
  if (selection) lines.push('', `The person has selected the part "${selection.part}": "this part", "the selected one" and the like mean it.`)
@@ -294,6 +303,7 @@ export async function proposeFigureSteps(input: ProposeFigureInput): Promise<Pro
294
303
  ]
295
304
 
296
305
  let reasons: string[] = []
306
+ let refused: unknown
297
307
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
298
308
  let answer: unknown
299
309
  try {
@@ -320,6 +330,7 @@ export async function proposeFigureSteps(input: ProposeFigureInput): Promise<Pro
320
330
  }
321
331
 
322
332
  reasons = judged.reasons
333
+ refused = answer
323
334
  // The reasons go back as they were said; a summary would not tell the model which step to change.
324
335
  messages.push({ role: 'assistant', content: JSON.stringify(answer) })
325
336
  messages.push({
@@ -328,5 +339,5 @@ export async function proposeFigureSteps(input: ProposeFigureInput): Promise<Pro
328
339
  })
329
340
  }
330
341
 
331
- throw new ProposeFigureFailure('The figure could not be made.', reasons, MAX_ATTEMPTS)
342
+ throw new ProposeFigureFailure('The figure could not be made.', reasons, MAX_ATTEMPTS, refused)
332
343
  }
@@ -178,3 +178,111 @@ describe('what another domain reads of a public figure', () => {
178
178
  expect(await dataSource!.getRepository(Figure).findOne({ where: { id: figure.id } })).not.toBeNull()
179
179
  })
180
180
  })
181
+
182
+ describe('a public figure edited after its release (2026-11-03 audit 1)', () => {
183
+ it("another domain's list shows its latest release -- not the draft's source, name or description, nor a search by them", async () => {
184
+ const owner = await domain()
185
+ const reader = await domain()
186
+ const figure = await released(owner, 'public-edited')
187
+ await new FigureMutation().setFigurePublic(figure.id, true, owner.context)
188
+ const row = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
189
+ await new FigureMutation().saveFigure(releasable('UNRELEASED DRAFT'), owner.context, figure.id, row.source)
190
+ await new FigureMutation().updateFigure(figure.id, { description: 'draft words' }, owner.context)
191
+ const list = (filters: unknown[]) => new FigureQuery().publicFigures({ filters }, reader.context)
192
+
193
+ const { items, total } = await list([{ name: 'type', operator: 'eq', value: 'public-edited' }])
194
+ expect(total).toBe(1)
195
+ expect([items[0].id, items[0].version, items[0].state, items[0].public]).toEqual([figure.id, 1, 'released', true])
196
+ expect(items[0].source).not.toContain('UNRELEASED DRAFT')
197
+ expect([items[0].name, items[0].description]).toEqual(['public-edited', null])
198
+ expect((await list([{ name: 'name', operator: 'like', value: '%UNRELEASED%' }])).total).toBe(0)
199
+ expect((await list([{ name: 'description', operator: 'like', value: '%draft words%' }])).total).toBe(0)
200
+
201
+ await new FigureMutation().releaseFigure(figure.id, 'second', owner.context)
202
+ const after = (await list([{ name: 'type', operator: 'eq', value: 'public-edited' }])).items
203
+ expect([after.length, after[0].version, after[0].name]).toEqual([1, 2, 'UNRELEASED DRAFT'])
204
+ })
205
+ })
206
+
207
+ describe('a catalogue write while another save lands (2026-11-03 audit 2)', () => {
208
+ /** Holds the catalogue write between its read and its write, while `meanwhile` runs. */
209
+ async function holdingTheWrite(description: string, meanwhile: () => Promise<unknown>, write: () => Promise<unknown>) {
210
+ const repository = dataSource!.getRepository(Figure)
211
+ const update = repository.update.bind(repository)
212
+ let resume = () => {}
213
+ let reached = () => {}
214
+ const held = new Promise<void>(r => (resume = r))
215
+ const waiting = new Promise<void>(r => (reached = r))
216
+ const spy = jest.spyOn(repository, 'update').mockImplementation(async (criteria: any, values: any) => {
217
+ if (values?.description === description || (typeof values?.source === 'string' && values.source.includes(description))) {
218
+ reached()
219
+ await held
220
+ }
221
+ return update(criteria, values)
222
+ })
223
+ try {
224
+ const pending = write()
225
+ await waiting
226
+ await meanwhile()
227
+ resume()
228
+ return await pending.then(
229
+ () => undefined,
230
+ error => error
231
+ )
232
+ } finally {
233
+ resume()
234
+ spy.mockRestore()
235
+ }
236
+ }
237
+
238
+ it('a description change keeps the source another save wrote meanwhile', async () => {
239
+ const owner = await domain()
240
+ const figure = await released(owner, 'race-description')
241
+ const row = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
242
+ const failed = await holdingTheWrite(
243
+ 'catalogue edit',
244
+ () => new FigureMutation().saveFigure(releasable('NEWER SAVED DESIGN'), owner.context, figure.id, row.source),
245
+ () => new FigureMutation().updateFigure(figure.id, { description: 'catalogue edit' }, owner.context)
246
+ )
247
+ expect(failed).toBeUndefined()
248
+ const after = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
249
+ expect(after.source).toContain('NEWER SAVED DESIGN')
250
+ expect(after.description).toBe('catalogue edit')
251
+ })
252
+
253
+ it('a patch of the source is refused as a conflict rather than writing over the source saved meanwhile', async () => {
254
+ const owner = await domain()
255
+ const figure = await released(owner, 'race-source')
256
+ const row = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
257
+ const failed: any = await holdingTheWrite(
258
+ 'PATCHED DESIGN',
259
+ () => new FigureMutation().saveFigure(releasable('NEWER SAVED DESIGN'), owner.context, figure.id, row.source),
260
+ () => new FigureMutation().updateFigure(figure.id, { source: releasable('PATCHED DESIGN') }, owner.context)
261
+ )
262
+ expect(failed?.extensions?.code).toBe('FIGURE_SOURCE_CONFLICT')
263
+ const after = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
264
+ expect(after.source).toContain('NEWER SAVED DESIGN')
265
+ })
266
+ })
267
+
268
+ describe('the release pre-checks see the category the release does (2026-11-03 audit 3)', () => {
269
+ it('both pass the saved category to an application check', async () => {
270
+ const owner = await domain()
271
+ const figure = await released(owner, 'checked-category')
272
+ await new FigureMutation().updateFigure(figure.id, { category: 'EQUIPMENT' }, owner.context)
273
+ const { registerFigureReleaseCheck } = loadCompiled('service/figure/figure-format')
274
+ const seen: unknown[] = []
275
+ const remove = registerFigureReleaseCheck((input: any) => {
276
+ seen.push(input.category)
277
+ return []
278
+ })
279
+ try {
280
+ const row = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
281
+ await new FigureQuery().inspectFigure(figure.id, owner.context)
282
+ await new FigureQuery().inspectFigureDraft(row.source, owner.context, figure.id)
283
+ expect(seen).toEqual(['EQUIPMENT', 'EQUIPMENT'])
284
+ } finally {
285
+ remove()
286
+ }
287
+ })
288
+ })
@@ -3,10 +3,12 @@ import { Args, Arg, Ctx, Directive, Int, Query, Resolver, UseMiddleware } from '
3
3
  import { GraphQLJSON } from 'graphql-scalars'
4
4
 
5
5
  import {
6
+ countListByField,
6
7
  Domain,
7
8
  getQueryBuilderFromListParams,
8
9
  getRepository,
9
10
  ListParam,
11
+ ListValueCount,
10
12
  Refusal,
11
13
  SpeakRefusals,
12
14
  refusalKey
@@ -24,6 +26,11 @@ import { meshSummaryOf } from './figure-mesh-store.js'
24
26
  import { readableFigure, publicFigureOfName } from './figure-public.js'
25
27
  import { figureCategories } from './figure-categories.js'
26
28
 
29
+ /** What a figure list finds figures by - tags too, see `figures` - and its state counts alike. */
30
+ const FIGURE_SEARCHABLES = ['type', 'name', 'description', 'category', 'tags']
31
+ /** A release records no tags (`FigureHistory`). */
32
+ const PUBLIC_FIGURE_SEARCHABLES = ['type', 'name', 'description', 'category']
33
+
27
34
  @Resolver(Figure)
28
35
  @UseMiddleware(SpeakRefusals)
29
36
  export class FigureQuery {
@@ -55,7 +62,7 @@ export class FigureQuery {
55
62
  본다. 「반출」로 찾으면 걸린다. 정확히 그 태그만 걸리는 것은 아니지만(이름에 「반출」이
56
63
  든 피규어도 걸린다) 찾기로는 그것이 오히려 낫다.
57
64
  */
58
- searchables: ['type', 'name', 'description', 'category', 'tags']
65
+ searchables: FIGURE_SEARCHABLES
59
66
  })
60
67
 
61
68
  const [items, total] = await queryBuilder.getManyAndCount()
@@ -63,25 +70,62 @@ export class FigureQuery {
63
70
  return { items, total }
64
71
  }
65
72
 
73
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
74
+ @Query(returns => [ListValueCount], { description: "How many of the domain's figures hold each state (draft, released), under the list conditions other than the state." })
75
+ async figureStateCounts(@Args() params: ListParam, @Ctx() context: ResolverContext): Promise<ListValueCount[]> {
76
+ const counted = await countListByField({
77
+ repository: getRepository(Figure),
78
+ params,
79
+ domain: context.state.domain,
80
+ alias: 'figure',
81
+ searchables: FIGURE_SEARCHABLES,
82
+ field: 'state'
83
+ })
84
+ return ['draft', 'released'].map(value => ({ value, count: counted.get(value) ?? 0 }))
85
+ }
86
+
66
87
  /**
67
88
  * Other domains' public figures (LV figure-server design G2): what this domain may read, package and draw besides its
68
- * own and its parent's. The rows carry their domain, so a screen says whose each is. Their drafts are not opened.
89
+ * own and its parent's. The rows carry their domain, so a screen says whose each is.
90
+ *
91
+ * **Each is its latest release, never its row** (2026-11-03 audit 1). A public figure edited after its release has a
92
+ * draft on its row -- its source, name, description, category and picture -- and listing the row gave that draft to
93
+ * every domain that asked for `source`. So the list is read from the releases, and searched by them too: a search
94
+ * on the row's name would tell another domain what the draft is called. Of the row only what its domain says of the
95
+ * figure as a whole is shown: that it is public and whether it is retired. Tags are not recorded with a release, so a
96
+ * public figure shows none.
69
97
  */
70
98
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
71
- @Query(returns => FigureList, { description: "Other domains' public figures. Read only: their releases, never their drafts." })
99
+ @Query(returns => FigureList, { description: "Other domains' public figures, each as its latest release. Read only: their releases, never their drafts." })
72
100
  async publicFigures(@Args() params: ListParam, @Ctx() context: ResolverContext): Promise<FigureList> {
73
101
  const { domain } = context.state
74
102
  const mine = [domain.id, domain.parentId].filter(Boolean)
75
103
  const queryBuilder = getQueryBuilderFromListParams({
76
- repository: getRepository(Figure),
104
+ repository: getRepository(FigureHistory),
77
105
  params,
78
106
  alias: 'figure',
79
- searchables: ['type', 'name', 'description', 'category', 'tags']
107
+ searchables: PUBLIC_FIGURE_SEARCHABLES
80
108
  })
109
+ .innerJoinAndMapOne('figure.row', Figure, 'row', 'row.id = figure.originalId AND row.public = :public AND row.deletedAt IS NULL', { public: true })
81
110
  .leftJoinAndSelect('figure.domain', 'owner')
82
- .andWhere('figure.public = :public', { public: true })
111
+ .leftJoinAndSelect('figure.updater', 'releaser')
83
112
  .andWhere('owner.id NOT IN (:...mine)', { mine })
84
- const [items, total] = await queryBuilder.getManyAndCount()
113
+ .andWhere(query => {
114
+ const latest = query.subQuery().select('MAX(latest.version)').from(FigureHistory, 'latest').where('latest.originalId = figure.originalId').getQuery()
115
+ return `figure.version = ${latest}`
116
+ })
117
+ const [releases, total] = await queryBuilder.getManyAndCount()
118
+ const items = releases.map(({ row, ...release }: FigureHistory & { row: Figure }) => ({
119
+ ...release,
120
+ id: release.originalId,
121
+ state: 'released' as const,
122
+ public: true,
123
+ deprecated: row.deprecated,
124
+ supersededBy: row.supersededBy,
125
+ tags: [],
126
+ /* The card asks for its picture by this stamp; the picture served to another domain is the release's. */
127
+ thumbnailUpdatedAt: release.thumbnailHash ? release.updatedAt : null
128
+ })) as unknown as Figure[]
85
129
  return { items, total }
86
130
  }
87
131
 
@@ -175,9 +219,10 @@ export class FigureQuery {
175
219
  /*
176
220
  `type` is read too. A asset carries no type, so the temporary-type refusal reads the row's; with only
177
221
  id and source selected it saw `undefined`, the panel enabled 「발행하기」, and the release mutation — which
178
- loads the whole row — refused on the press (2026-09-24).
222
+ loads the whole row — refused on the press (2026-09-24). `category` likewise: an app's check by category saw
223
+ `undefined` here and the whole row's on the release (2026-11-03 audit 3).
179
224
  */
180
- select: { id: true, source: true, type: true }
225
+ select: { id: true, source: true, type: true, category: true }
181
226
  })
182
227
 
183
228
  if (!figure) {
@@ -218,7 +263,7 @@ export class FigureQuery {
218
263
 
219
264
  const figure = await getRepository(Figure).findOne({
220
265
  where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
221
- select: { id: true, type: true }
266
+ select: { id: true, type: true, category: true }
222
267
  })
223
268
  if (!figure) throw new Error(`Figure ${id} 를 찾을 수 없다`)
224
269
 
@@ -182,6 +182,22 @@ describe('served behind the permission, from the figure\'s own domain', () => {
182
182
  expect([again.status, again.body]).toEqual([304, undefined])
183
183
  })
184
184
 
185
+ it("another domain's public figure is served its latest release's picture, not the draft's on its row", async () => {
186
+ const files = mapFiles()
187
+ const owner = await newDomain(),
188
+ reader = await newDomain()
189
+ const releasedHash = await keepThumbnail(dataSource!.manager, owner, undefined, picture(21), files)
190
+ const draftHash = await keepThumbnail(dataSource!.manager, owner, undefined, picture(22), files)
191
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'PUBLIC-PIC', name: 'x', source: SOURCE, state: 'draft', version: 1, public: true, thumbnailHash: draftHash, thumbnailUpdatedAt: new Date(), domain: owner })
192
+ await dataSource!.getRepository(FigureHistory).insert({ originalId: figure.id, version: 1, type: 'PUBLIC-PIC', state: 'released', source: SOURCE, thumbnailHash: releasedHash, updatedAt: new Date(), domain: owner, action: 'updated' })
193
+ const theirs = koa()
194
+ await sendFigureThumbnail(theirs, async () => {}, figure.id, reader, files)
195
+ expect(Buffer.compare(theirs.body, bytesOf(picture(21)))).toBe(0)
196
+ const own = koa()
197
+ await sendFigureThumbnail(own, async () => {}, figure.id, owner, files)
198
+ expect(Buffer.compare(own.body, bytesOf(picture(22)))).toBe(0)
199
+ })
200
+
185
201
  it('a figure with no picture, or of a domain the caller cannot read, is 404', async () => {
186
202
  const files = mapFiles()
187
203
  const mine = await newDomain(),