@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.
- package/README.md +19 -0
- package/dist-server/routers/figure-thumbnail-router.js +16 -1
- package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
- package/dist-server/service/figure/figure-ai-scenarios-run.d.ts +44 -0
- package/dist-server/service/figure/figure-ai-scenarios-run.js +94 -0
- package/dist-server/service/figure/figure-ai-scenarios-run.js.map +1 -0
- package/dist-server/service/figure/figure-ai-scenarios.d.ts +68 -0
- package/dist-server/service/figure/figure-ai-scenarios.js +814 -0
- package/dist-server/service/figure/figure-ai-scenarios.js.map +1 -0
- package/dist-server/service/figure/figure-context.js +3 -0
- package/dist-server/service/figure/figure-context.js.map +1 -1
- package/dist-server/service/figure/figure-llm-provider.d.ts +10 -0
- package/dist-server/service/figure/figure-llm-provider.js +19 -0
- package/dist-server/service/figure/figure-llm-provider.js.map +1 -0
- package/dist-server/service/figure/figure-mutation.js +19 -23
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-propose-steps.d.ts +5 -1
- package/dist-server/service/figure/figure-propose-steps.js +14 -2
- package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +10 -2
- package/dist-server/service/figure/figure-query.js +59 -11
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/scripts/figure-ai-scenarios-agent.cjs +98 -0
- package/server/routers/figure-thumbnail-router.ts +18 -2
- package/server/service/figure/figure-ai-scenarios-run.ts +127 -0
- package/server/service/figure/figure-ai-scenarios.live.test.ts +59 -0
- package/server/service/figure/figure-ai-scenarios.test.ts +77 -0
- package/server/service/figure/figure-ai-scenarios.ts +792 -0
- package/server/service/figure/figure-context.ts +3 -0
- package/server/service/figure/figure-llm-provider.ts +20 -0
- package/server/service/figure/figure-llm-smoke.test.ts +5 -68
- package/server/service/figure/figure-mutation.ts +17 -23
- package/server/service/figure/figure-propose-steps.test.ts +55 -0
- package/server/service/figure/figure-propose-steps.ts +14 -3
- package/server/service/figure/figure-public-db.test.ts +108 -0
- package/server/service/figure/figure-query.ts +55 -10
- 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 {
|
|
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' ?
|
|
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(
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
/*
|
|
301
|
+
**바뀐 칸만 쓴다** (2026-11-03 점검 2). 처음 읽은 행을 통째로 펼쳐 다시 쓰면, 그 사이 다른 편집이
|
|
302
|
+
`saveFigure` 로 저장한 정본이 읽어 둔 옛 정본으로 되돌아간다 — 설명 한 줄을 고쳤을 뿐인데. 정본을 쓰는
|
|
303
|
+
경우(정본 패치, 타입 바꾸기)는 `saveFigure` 와 같이 읽어 둔 정본이 그대로일 때만 쓴다.
|
|
303
304
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
...
|
|
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:
|
|
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.
|
|
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(
|
|
104
|
+
repository: getRepository(FigureHistory),
|
|
77
105
|
params,
|
|
78
106
|
alias: 'figure',
|
|
79
|
-
searchables:
|
|
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
|
-
.
|
|
111
|
+
.leftJoinAndSelect('figure.updater', 'releaser')
|
|
83
112
|
.andWhere('owner.id NOT IN (:...mine)', { mine })
|
|
84
|
-
|
|
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(),
|