@things-factory/figure-service 10.1.153 → 10.1.155
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/service/figure/figure-mutation.d.ts +1 -1
- package/dist-server/service/figure/figure-mutation.js +17 -2
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-package-export.js +3 -1
- package/dist-server/service/figure/figure-package-export.js.map +1 -1
- package/dist-server/service/figure/figure-propose-steps.d.ts +3 -0
- package/dist-server/service/figure/figure-propose-steps.js +1 -4
- package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +1 -1
- package/dist-server/service/figure/figure-query.js +3 -3
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +7 -0
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/server/service/figure/figure-llm-smoke.test.ts +5 -4
- package/server/service/figure/figure-mutation.ts +16 -1
- package/server/service/figure/figure-namespace-db.test.ts +31 -2
- package/server/service/figure/figure-package-export.ts +3 -1
- package/server/service/figure/figure-propose-clips.test.ts +3 -7
- package/server/service/figure/figure-propose-steps.test.ts +8 -15
- package/server/service/figure/figure-propose-steps.ts +4 -3
- package/server/service/figure/figure-query.ts +3 -3
- package/server/service/figure/figure-thumbnail-db.test.ts +24 -0
- package/server/service/figure/figure-tools.test.ts +9 -2
- package/server/service/figure/figure-tools.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.155",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -27,16 +27,16 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@hatiolab/figure-model": "^0.6.3",
|
|
29
29
|
"@operato/typeorm-history": "^10.0.0",
|
|
30
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
31
|
-
"@things-factory/attachment-base": "^10.1.
|
|
32
|
-
"@things-factory/auth-base": "^10.1.
|
|
30
|
+
"@things-factory/ai-client-base": "^10.1.155",
|
|
31
|
+
"@things-factory/attachment-base": "^10.1.155",
|
|
32
|
+
"@things-factory/auth-base": "^10.1.155",
|
|
33
33
|
"@things-factory/env": "^10.1.149",
|
|
34
|
-
"@things-factory/setting-base": "^10.1.
|
|
35
|
-
"@things-factory/shell": "^10.1.
|
|
34
|
+
"@things-factory/setting-base": "^10.1.155",
|
|
35
|
+
"@things-factory/shell": "^10.1.155",
|
|
36
36
|
"graphql-scalars": "^1.22.4",
|
|
37
37
|
"graphql-upload": "^15.0.2",
|
|
38
38
|
"jszip": "^3.10.1",
|
|
39
39
|
"koa-router": "^7.4.0"
|
|
40
40
|
},
|
|
41
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "0211c9c0764a26c10151fdc7d60094fc334905ff"
|
|
42
42
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* RUN_FIGURE_LLM_SMOKE=1 OPENAI_API_KEY=... yarn test:figure-llm-smoke
|
|
10
10
|
* 선택: FIGURE_LLM_PROVIDER=anthropic|gemini|openai, FIGURE_LLM_MODEL=<model id>
|
|
11
11
|
*/
|
|
12
|
-
import { createAIClient,
|
|
12
|
+
import { createAIClient, type AIClient } from '@things-factory/ai-client-base'
|
|
13
13
|
|
|
14
14
|
import { applyFigureProposal, createFigureAsset, inspectFigureAsset } from '@hatiolab/figure-model'
|
|
15
15
|
|
|
@@ -39,18 +39,19 @@ const picked = process.env.RUN_FIGURE_LLM_SMOKE === '1' ? provider() : undefined
|
|
|
39
39
|
const describeIf = picked ? describe : describe.skip
|
|
40
40
|
|
|
41
41
|
describeIf('Figure 실제 LLM smoke', () => {
|
|
42
|
+
let client: AIClient
|
|
42
43
|
beforeAll(() => {
|
|
43
|
-
|
|
44
|
+
client = createAIClient({ ...picked!, ...(process.env.FIGURE_LLM_MODEL ? { model: process.env.FIGURE_LLM_MODEL } : {}) })
|
|
44
45
|
// eslint-disable-next-line no-console
|
|
45
46
|
console.log(`[figure-llm-smoke] provider=${picked!.provider} model=${process.env.FIGURE_LLM_MODEL ?? '(adapter default)'}`)
|
|
46
47
|
})
|
|
47
48
|
|
|
48
|
-
afterAll(() => setDefaultAIClient(undefined))
|
|
49
49
|
|
|
50
50
|
test(
|
|
51
51
|
'새 Figure 는 게이트를 지나는 명령 목록으로 온다',
|
|
52
52
|
async () => {
|
|
53
53
|
const result = await proposeFigureSteps({
|
|
54
|
+
client,
|
|
54
55
|
prompt: 'two cylindrical rollers inside a simple rectangular press frame; make the rollers visually distinct',
|
|
55
56
|
name: 'Roll press'
|
|
56
57
|
})
|
|
@@ -74,7 +75,7 @@ describeIf('Figure 실제 LLM smoke', () => {
|
|
|
74
75
|
{ kind: 'attach', part: 'port', face: 'back', to: { part: 'body', face: 'front' } },
|
|
75
76
|
{ kind: 'fit-room', placement: 'floor' }
|
|
76
77
|
] as any)
|
|
77
|
-
const result = await proposeFigureSteps({ prompt: 'make the port twice as wide and give it an accent colour', base: draft })
|
|
78
|
+
const result = await proposeFigureSteps({ client, prompt: 'make the port twice as wide and give it an accent colour', base: draft })
|
|
78
79
|
|
|
79
80
|
const ids = result.source.document.model.nodes.map((n: any) => n.id)
|
|
80
81
|
expect(ids.some((id: string) => id.includes('body'))).toBe(true)
|
|
@@ -3,6 +3,7 @@ import { Arg, Ctx, Directive, Mutation, Resolver, UseMiddleware } from 'type-gra
|
|
|
3
3
|
|
|
4
4
|
import { isFigureType, parseFigureAsset, placeholderType, TYPE_LENGTH } from '@hatiolab/figure-model'
|
|
5
5
|
import { getRepository, Refusal, SpeakRefusals } from '@things-factory/shell'
|
|
6
|
+
import { aiClientFor } from '@things-factory/ai-client-base'
|
|
6
7
|
|
|
7
8
|
import { Figure } from './figure.js'
|
|
8
9
|
import { FigureHistory } from './figure-history.js'
|
|
@@ -225,6 +226,19 @@ export class FigureMutation {
|
|
|
225
226
|
throw new Error(`Figure ${id} 를 찾을 수 없다`)
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/*
|
|
230
|
+
**그림만 바꾸면 발행이 풀리지 않는다.** 미리보기에서 사람이 돌려 놓은 각도로 찍은 그림이 그렇게 온다.
|
|
231
|
+
그림은 발행이 약속하는 것이 아니다 — 발행본은 제 그림을 따로 갖고, 다음 발행이 이 그림을 가져간다.
|
|
232
|
+
판본 구독자를 거치지 않는다: 발행된 행의 저장을 판으로 베끼는데, 이것은 판이 아니다.
|
|
233
|
+
*/
|
|
234
|
+
if (patch.thumbnail !== undefined && Object.keys(patch).every(key => key === 'thumbnail' || (patch as any)[key] === undefined)) {
|
|
235
|
+
const picture = await pictureOf(tx, domain, user, patch.thumbnail, figure)
|
|
236
|
+
if (picture.thumbnailHash) {
|
|
237
|
+
await repository.createQueryBuilder().update(Figure).set({ ...picture, updater: user }).where('id = :id', { id }).callListeners(false).execute()
|
|
238
|
+
}
|
|
239
|
+
return { figure: await repository.findOneOrFail({ where: { id } }), violations: [] }
|
|
240
|
+
}
|
|
241
|
+
|
|
228
242
|
// 정본이 바뀌면 파생값도 다시 계산한다. 안 바뀌었으면 손대지 않는다 —
|
|
229
243
|
// 이름만 고쳤는데 점수가 흔들리면 읽는 사람이 원인을 못 찾는다.
|
|
230
244
|
let derived: { score: number; triangles: number; groups: number } | undefined
|
|
@@ -542,7 +556,7 @@ export class FigureMutation {
|
|
|
542
556
|
@Mutation(returns => FigureProposal, {
|
|
543
557
|
description: 'Asks the authoring assistant for modeller commands that make or revise a figure. Nothing is saved.'
|
|
544
558
|
})
|
|
545
|
-
async proposeFigure(@Arg('request') request: ProposeRequest): Promise<FigureProposal> {
|
|
559
|
+
async proposeFigure(@Arg('request') request: ProposeRequest, @Ctx() context: ResolverContext): Promise<FigureProposal> {
|
|
546
560
|
// 그림은 메모리로 받는다. 참고용이라 저장하지 않는다 — 남기면 지울 사람이 없다.
|
|
547
561
|
let image
|
|
548
562
|
if (request.image) {
|
|
@@ -556,6 +570,7 @@ export class FigureMutation {
|
|
|
556
570
|
|
|
557
571
|
try {
|
|
558
572
|
const result = await proposeFigureSteps({
|
|
573
|
+
client: await aiClientFor({ domain: context.state.domain, user: context.state.user }),
|
|
559
574
|
prompt: request.prompt,
|
|
560
575
|
base: request.base ? parseFigureAsset(request.base) : undefined,
|
|
561
576
|
name: request.name,
|
|
@@ -17,6 +17,7 @@ let Figure: any
|
|
|
17
17
|
let FigureMutation: any
|
|
18
18
|
let FigureQuery: any
|
|
19
19
|
let figurePackageOf: any
|
|
20
|
+
let FigureHistory: any
|
|
20
21
|
|
|
21
22
|
function loadCompiled(modulePath: string): any {
|
|
22
23
|
const { existsSync } = require('fs')
|
|
@@ -29,7 +30,7 @@ function loadCompiled(modulePath: string): any {
|
|
|
29
30
|
|
|
30
31
|
beforeAll(async () => {
|
|
31
32
|
Figure = loadCompiled('service/figure/figure').Figure
|
|
32
|
-
|
|
33
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
33
34
|
const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
|
|
34
35
|
;({ FigureMutation } = loadCompiled('service/figure/figure-mutation'))
|
|
35
36
|
;({ FigureQuery } = loadCompiled('service/figure/figure-query'))
|
|
@@ -130,7 +131,7 @@ describe('figureBlueprints answers each full name, an item or a refusal', () =>
|
|
|
130
131
|
context
|
|
131
132
|
)
|
|
132
133
|
|
|
133
|
-
expect(answer.
|
|
134
|
+
expect(Object.keys(answer).sort()).toEqual(['items', 'refused'])
|
|
134
135
|
expect(answer.items.map((item: any) => [item.type, item.ref])).toEqual([['hatiolab.com/conveyor', 'hatiolab.com/conveyor@1']])
|
|
135
136
|
expect(answer.items[0].asset.version).toBe('figure-asset-1')
|
|
136
137
|
expect(answer.refused.map((r: any) => [r.type, r.code])).toEqual([
|
|
@@ -170,6 +171,34 @@ describe('the figure package', () => {
|
|
|
170
171
|
expect(read.releases[0].source).toBe(stored.source)
|
|
171
172
|
})
|
|
172
173
|
|
|
174
|
+
it('dates each release when it was released, not when the figure was made, and carries its note', async () => {
|
|
175
|
+
const { domain, context } = await domainWith('hatiolab.com')
|
|
176
|
+
const figure = await releasedFigure(context, 'trailer-23')
|
|
177
|
+
/* sqlite keeps these times to the second */
|
|
178
|
+
await new Promise(resolve => setTimeout(resolve, 1100))
|
|
179
|
+
const current = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
|
|
180
|
+
await new FigureMutation().saveFigure(releasable('Trailer 23 wide'), context, figure.id, current.source)
|
|
181
|
+
await new FigureMutation().releaseFigure(figure.id, 'wider body', context)
|
|
182
|
+
|
|
183
|
+
const { bytes } = await figurePackageOf({
|
|
184
|
+
refs: ['hatiolab.com/trailer-23@1', 'hatiolab.com/trailer-23@2'],
|
|
185
|
+
domain,
|
|
186
|
+
exportedBy: 'author',
|
|
187
|
+
sourceUrl: 'x',
|
|
188
|
+
files: { put: async () => ({}), send: async () => {}, read: async () => Buffer.alloc(0) }
|
|
189
|
+
})
|
|
190
|
+
const zip = await JSZip.loadAsync(bytes)
|
|
191
|
+
const files = new Map<string, Uint8Array>()
|
|
192
|
+
for (const path of Object.keys(zip.files)) if (!zip.files[path].dir) files.set(path, await zip.files[path].async('uint8array'))
|
|
193
|
+
const [first, second] = readFigurePackage(files, sha256).releases
|
|
194
|
+
|
|
195
|
+
const histories = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id }, order: { version: 'ASC' } })
|
|
196
|
+
expect(first.releasedAt).toBe(new Date(histories[0].updatedAt).toISOString())
|
|
197
|
+
expect(second.releasedAt).toBe(new Date(histories[1].updatedAt).toISOString())
|
|
198
|
+
expect(second.releasedAt > first.releasedAt).toBe(true)
|
|
199
|
+
expect([first.comment, second.comment]).toEqual(['first', 'wider body'])
|
|
200
|
+
})
|
|
201
|
+
|
|
173
202
|
it('refuses a release this domain cannot read, and a malformed reference', async () => {
|
|
174
203
|
const { domain, context } = await domainWith('hatiolab.com')
|
|
175
204
|
await releasedFigure(context, 'conveyor')
|
|
@@ -126,6 +126,8 @@ function packageReleaseOf(
|
|
|
126
126
|
thumbnail: history.thumbnailHash && blobs.has(history.thumbnailHash) ? history.thumbnailHash : null,
|
|
127
127
|
deprecated: !!figure.deprecated,
|
|
128
128
|
supersededBy: figure.supersededBy && figure.namespace ? `${figure.namespace}/${figure.supersededBy}` : null,
|
|
129
|
-
|
|
129
|
+
/* The history row copies the figure's own createdAt; the save that released this version is its updatedAt. */
|
|
130
|
+
releasedAt: new Date(history.updatedAt ?? history.createdAt ?? Date.now()).toISOString(),
|
|
131
|
+
comment: history.comment ?? null
|
|
130
132
|
}
|
|
131
133
|
}
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart } from '@hatiolab/figure-model'
|
|
7
7
|
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
8
|
-
import { setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
9
8
|
import type { AIClient } from '@things-factory/ai-client-base'
|
|
10
9
|
|
|
11
10
|
import { ProposeFigureFailure, judgeFigureAnswer, proposeFigureSteps } from './figure-propose-steps'
|
|
@@ -26,8 +25,6 @@ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[]
|
|
|
26
25
|
return client as unknown as AIClient & { calls: number; sent: any[][] }
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
afterEach(() => setDefaultAIClient(undefined))
|
|
30
|
-
|
|
31
28
|
function door(): FigureAsset {
|
|
32
29
|
let a = createFigureAsset('Door')
|
|
33
30
|
a = addFigureBox(a, { id: 'frame', size: { x: 1000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
|
|
@@ -52,8 +49,7 @@ describe('an AI answer and the clips of the draft it revises', () => {
|
|
|
52
49
|
|
|
53
50
|
it('asked three times and three times lossy, nothing comes back: no half-made figure to save', async () => {
|
|
54
51
|
const client = stubClient([lossy])
|
|
55
|
-
|
|
56
|
-
await expect(proposeFigureSteps({ prompt: '문을 없애 줘', base: door() })).rejects.toBeInstanceOf(ProposeFigureFailure)
|
|
52
|
+
await expect(proposeFigureSteps({ client, prompt: '문을 없애 줘', base: door() })).rejects.toBeInstanceOf(ProposeFigureFailure)
|
|
57
53
|
expect(client.calls).toBe(3)
|
|
58
54
|
expect(JSON.stringify(client.sent)).not.toMatch(/dropClips/)
|
|
59
55
|
})
|
|
@@ -66,8 +62,8 @@ describe('an AI answer and the clips of the draft it revises', () => {
|
|
|
66
62
|
|
|
67
63
|
it('an answer that stops no clip keeps every clip, a rename of the part it hangs from included', async () => {
|
|
68
64
|
const base = door()
|
|
69
|
-
|
|
70
|
-
const made = await proposeFigureSteps({ prompt: '이름을 바꿔 줘', base })
|
|
65
|
+
const client = stubClient([{ steps: [{ kind: 'rename', name: 'Door 2' }, { kind: 'nudge-part', part: 'sign', by: { x: 50 } }, { kind: 'rename-part', target: 'frame', name: 'jamb' }] }])
|
|
66
|
+
const made = await proposeFigureSteps({ client, prompt: '이름을 바꿔 줘', base })
|
|
71
67
|
expect(made.source.drivers).toEqual(base.drivers)
|
|
72
68
|
})
|
|
73
69
|
})
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* the proposer says it could not make it, and nothing half made comes back.
|
|
7
7
|
*/
|
|
8
8
|
import { FIGURE_PROPOSAL_GRAMMAR, applyFigureProposal, createFigureAsset, inspectFigureAsset } from '@hatiolab/figure-model'
|
|
9
|
-
import { setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
10
9
|
import type { AIClient } from '@things-factory/ai-client-base'
|
|
11
10
|
|
|
12
11
|
import { ProposeFigureFailure, proposeFigureSteps, rulesPromptFigure } from './figure-propose-steps'
|
|
@@ -32,8 +31,6 @@ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[]
|
|
|
32
31
|
return client as unknown as AIClient & { calls: number; sent: any[][]; system?: string }
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
afterEach(() => setDefaultAIClient(undefined))
|
|
36
|
-
|
|
37
34
|
const liftTable = [
|
|
38
35
|
{ kind: 'add-part', part: { id: 'base', kind: 'box', dimensions: { width: 1200, height: 80, depth: 800 } } },
|
|
39
36
|
{ kind: 'add-part', part: { id: 'column', kind: 'cylinder', dimensions: { radius: 90, length: 600 } } },
|
|
@@ -66,8 +63,7 @@ describe('the model is told the kernel\'s commands and the open draft', () => {
|
|
|
66
63
|
it('a revision carries the draft\'s source', async () => {
|
|
67
64
|
const draft = applyFigureProposal(createFigureAsset('Lift table'), liftTable as any)
|
|
68
65
|
const client = stubClient([{ steps: [{ kind: 'rename', name: 'Lift table 2' }] }])
|
|
69
|
-
|
|
70
|
-
const made = await proposeFigureSteps({ prompt: '이름을 바꿔 줘', base: draft })
|
|
66
|
+
const made = await proposeFigureSteps({ client, prompt: '이름을 바꿔 줘', base: draft })
|
|
71
67
|
expect(client.sent[0]![0]!.content).toContain('"version":"figure-asset-1"')
|
|
72
68
|
expect(made.source.name).toBe('Lift table 2')
|
|
73
69
|
expect(draft.name).toBe('Lift table')
|
|
@@ -76,8 +72,8 @@ describe('the model is told the kernel\'s commands and the open draft', () => {
|
|
|
76
72
|
|
|
77
73
|
describe('the steps are held to the kernel and the gate', () => {
|
|
78
74
|
it('a list the kernel takes comes back with the figure it makes, the gate clean, on the first attempt', async () => {
|
|
79
|
-
|
|
80
|
-
const made = await proposeFigureSteps({ prompt: '리프트 테이블', name: 'Lift table' })
|
|
75
|
+
const client = stubClient([{ steps: liftTable }])
|
|
76
|
+
const made = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' })
|
|
81
77
|
expect(made.attempts).toBe(1)
|
|
82
78
|
expect(made.steps).toEqual(liftTable)
|
|
83
79
|
expect(inspectFigureAsset(made.source).violations).toEqual([])
|
|
@@ -86,8 +82,7 @@ describe('the steps are held to the kernel and the gate', () => {
|
|
|
86
82
|
|
|
87
83
|
it('a refused step goes back with its place and the kernel\'s code, and the next answer is taken', async () => {
|
|
88
84
|
const client = stubClient([{ steps: wrongFace }, { steps: liftTable }])
|
|
89
|
-
|
|
90
|
-
const made = await proposeFigureSteps({ prompt: '리프트 테이블', name: 'Lift table' })
|
|
85
|
+
const made = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' })
|
|
91
86
|
expect(made.attempts).toBe(2)
|
|
92
87
|
const said = client.sent[1]!.at(-1)!.content as string
|
|
93
88
|
expect(said).toMatch(/ATTACH_FACE: steps\[4\]: attach: /)
|
|
@@ -95,23 +90,21 @@ describe('the steps are held to the kernel and the gate', () => {
|
|
|
95
90
|
|
|
96
91
|
it('a new figure must come out whole: a list that leaves the room undeclared is sent back', async () => {
|
|
97
92
|
const client = stubClient([{ steps: withoutRoom }, { steps: liftTable }])
|
|
98
|
-
|
|
99
|
-
const made = await proposeFigureSteps({ prompt: '리프트 테이블', name: 'Lift table' })
|
|
93
|
+
const made = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' })
|
|
100
94
|
expect(made.attempts).toBe(2)
|
|
101
95
|
expect(client.sent[1]!.at(-1)!.content).toMatch(/OCCUPANCY_UNDECLARED/)
|
|
102
96
|
})
|
|
103
97
|
|
|
104
98
|
it('a revision is not blamed for what the draft already had', async () => {
|
|
105
99
|
const draft = applyFigureProposal(createFigureAsset('Lift table'), withoutRoom as any)
|
|
106
|
-
|
|
107
|
-
const made = await proposeFigureSteps({ prompt: '이름', base: draft })
|
|
100
|
+
const client = stubClient([{ steps: [{ kind: 'rename', name: 'Lift' }] }])
|
|
101
|
+
const made = await proposeFigureSteps({ client, prompt: '이름', base: draft })
|
|
108
102
|
expect(made.attempts).toBe(1)
|
|
109
103
|
})
|
|
110
104
|
|
|
111
105
|
it('three refusals said back, then it says it could not make it, with the last reasons -- nothing half made', async () => {
|
|
112
106
|
const client = stubClient([{ steps: wrongFace }, new Error('cut short'), { nope: true }])
|
|
113
|
-
|
|
114
|
-
const failure = await proposeFigureSteps({ prompt: '리프트 테이블', name: 'Lift table' }).catch(e => e)
|
|
107
|
+
const failure = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' }).catch(e => e)
|
|
115
108
|
expect(failure).toBeInstanceOf(ProposeFigureFailure)
|
|
116
109
|
expect(failure.message).toBe('만들지 못했습니다.')
|
|
117
110
|
expect(failure.attempts).toBe(3)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FIGURE_PROPOSAL_GRAMMAR, applyFigureProposal, createFigureAsset, inspectFigureAsset, serializeFigureAsset, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureAsset, FigureProposalStep, FigureRefusalDetails } from '@hatiolab/figure-model'
|
|
3
|
-
import {
|
|
3
|
+
import type { AIClient } from '@things-factory/ai-client-base'
|
|
4
4
|
import type { AIImageMediaType, AIMessage } from '@things-factory/ai-client-base'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -20,6 +20,8 @@ const MAX_ATTEMPTS = 3
|
|
|
20
20
|
const GENERATE_MAX_TOKENS = 16384
|
|
21
21
|
|
|
22
22
|
export interface ProposeFigureInput {
|
|
23
|
+
/** The domain's AI agent that answers (aiClientFor). */
|
|
24
|
+
client: AIClient
|
|
23
25
|
/** What the author asked for, in their words. */
|
|
24
26
|
prompt: string
|
|
25
27
|
/** The open draft to revise. Absent: a new figure. */
|
|
@@ -140,8 +142,7 @@ export function judgeFigureAnswer(base: FigureAsset, answer: unknown, revising:
|
|
|
140
142
|
|
|
141
143
|
/** Asks the model for steps and holds them to the kernel and the gate, three times at most. */
|
|
142
144
|
export async function proposeFigureSteps(input: ProposeFigureInput): Promise<ProposeFigureResult> {
|
|
143
|
-
const client =
|
|
144
|
-
if (!client) throw new ProposeFigureFailure('AI 모델이 설정돼 있지 않습니다.', [], 0)
|
|
145
|
+
const { client } = input
|
|
145
146
|
|
|
146
147
|
const base = input.base ?? createFigureAsset(input.name ?? 'Figure')
|
|
147
148
|
const system = rulesPromptFigure()
|
|
@@ -321,12 +321,12 @@ export class FigureQuery {
|
|
|
321
321
|
*
|
|
322
322
|
* One answer per name: an item, or a refusal that names why. A refusal never takes another item away -- the board
|
|
323
323
|
* draws what it got and a stand-in with the reason for the rest. This server keeps its policy: the latest release,
|
|
324
|
-
* shadowed by the child domain as `figureBlueprint` does.
|
|
324
|
+
* shadowed by the child domain as `figureBlueprint` does. A type map is the board server's.
|
|
325
325
|
*/
|
|
326
326
|
@Directive('@privilege(category: "figure-published", privilege: "query", domainOwnerGranted: true)')
|
|
327
327
|
@Query(returns => GraphQLJSON, {
|
|
328
328
|
description:
|
|
329
|
-
'Released figures asked together by full name {namespace}/{type}: {
|
|
329
|
+
'Released figures asked together by full name {namespace}/{type}: { items: [{ type, ref, asset }], refused: [{ type, code, message }] }. The latest release of each; one refusal never removes another item.'
|
|
330
330
|
})
|
|
331
331
|
async figureBlueprints(
|
|
332
332
|
@Arg('types', type => [String], { description: 'Full figure names {namespace}/{type}. At most 500.' }) types: string[],
|
|
@@ -376,7 +376,7 @@ export class FigureQuery {
|
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
return {
|
|
379
|
+
return { items, refused }
|
|
380
380
|
}
|
|
381
381
|
}
|
|
382
382
|
|
|
@@ -135,6 +135,30 @@ describe('a picture kept by its content', () => {
|
|
|
135
135
|
expect(row.thumbnailUpdatedAt).toBeTruthy()
|
|
136
136
|
useThumbnailFiles(was)
|
|
137
137
|
})
|
|
138
|
+
|
|
139
|
+
it('a patch of the picture alone replaces only the picture: a released figure stays released, at its version, with no new version row', async () => {
|
|
140
|
+
const was = useThumbnailFiles(mapFiles())
|
|
141
|
+
const domain = await newDomain()
|
|
142
|
+
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
143
|
+
const figure = await dataSource!.getRepository(Figure).save({ type: 'CHOSEN', name: 'chosen', source: SOURCE, state: 'draft', version: 0, domain })
|
|
144
|
+
await new FigureMutation().updateFigure(figure.id, { thumbnail: picture(11) }, context)
|
|
145
|
+
/* Released as a row, without the subscriber that would copy it into a version. */
|
|
146
|
+
await dataSource!.getRepository(Figure).createQueryBuilder().update(Figure).set({ state: 'released', version: 3 }).where('id = :id', { id: figure.id }).callListeners(false).execute()
|
|
147
|
+
const versions = await dataSource!.getRepository(FigureHistory).count({ where: { originalId: figure.id } })
|
|
148
|
+
const before = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
|
|
149
|
+
|
|
150
|
+
const { figure: chosen } = await new FigureMutation().updateFigure(figure.id, { thumbnail: picture(12) }, context)
|
|
151
|
+
expect([chosen.state, chosen.version, chosen.source]).toEqual(['released', 3, SOURCE])
|
|
152
|
+
expect(chosen.thumbnailHash).toBe(sha256Of(bytesOf(picture(12))))
|
|
153
|
+
expect(chosen.thumbnailHash).not.toBe(before.thumbnailHash)
|
|
154
|
+
expect(await dataSource!.getRepository(FigureHistory).count({ where: { originalId: figure.id } })).toBe(versions)
|
|
155
|
+
|
|
156
|
+
/* The same picture again is no change: the time stays. */
|
|
157
|
+
const { figure: again } = await new FigureMutation().updateFigure(figure.id, { thumbnail: picture(12) }, context)
|
|
158
|
+
expect(again.thumbnailUpdatedAt).toEqual(chosen.thumbnailUpdatedAt)
|
|
159
|
+
await expect(new FigureMutation().updateFigure(figure.id, { thumbnail: 'not a picture' }, context)).rejects.toThrow(/data URL/)
|
|
160
|
+
useThumbnailFiles(was)
|
|
161
|
+
})
|
|
138
162
|
})
|
|
139
163
|
|
|
140
164
|
describe('served behind the permission, from the figure\'s own domain', () => {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* 실제 모델은 부르지 않는다. 여기서 볼 것은 모델의 솜씨가 아니라 **우리 배선**이다.
|
|
14
14
|
*/
|
|
15
|
-
import { clearToolRegistry, getToolCategories, registryAssistantChat } from '@things-factory/ai-client-base'
|
|
15
|
+
import { clearToolRegistry, getToolCategories, registryAssistantChat, setAIClientForTesting } from '@things-factory/ai-client-base'
|
|
16
16
|
|
|
17
17
|
import { __setFigureToolDepsForTest, registerFigureToolCategories } from './figure-tools'
|
|
18
18
|
|
|
@@ -50,7 +50,11 @@ const ROWS = [
|
|
|
50
50
|
/** 부른 것을 적어 두는 가짜 제안기. */
|
|
51
51
|
const proposed: any[] = []
|
|
52
52
|
|
|
53
|
+
/* The proposal tool asks the domain's AI agent; here a stand-in answers - the proposer below never calls it. */
|
|
54
|
+
const agentClient: any = { id: 'test:figure', chat: async () => '', generateJSON: async () => ({}), listModels: async () => [] }
|
|
55
|
+
|
|
53
56
|
function install() {
|
|
57
|
+
setAIClientForTesting(agentClient)
|
|
54
58
|
const model = require('@hatiolab/figure-model')
|
|
55
59
|
__setFigureToolDepsForTest({
|
|
56
60
|
getRepository: () => stubRepository(ROWS),
|
|
@@ -93,7 +97,10 @@ beforeEach(() => {
|
|
|
93
97
|
registerFigureToolCategories()
|
|
94
98
|
})
|
|
95
99
|
|
|
96
|
-
afterEach(() =>
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
clearToolRegistry()
|
|
102
|
+
setAIClientForTesting(undefined)
|
|
103
|
+
})
|
|
97
104
|
|
|
98
105
|
test('실제 registryAssistantChat 경계가 열린 모델 문맥을 Figure 도구에 전달한다', async () => {
|
|
99
106
|
const seen: any[] = []
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* 있다. 그래서 호출자 도메인 소속을 먼저 확인한다.
|
|
36
36
|
*/
|
|
37
37
|
import { randomUUID } from 'crypto'
|
|
38
|
-
import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
|
|
38
|
+
import { aiClientFor, registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
|
|
39
39
|
|
|
40
40
|
/*
|
|
41
41
|
서버 의존성은 lazy require 다. 등록은 부팅 초반에 평가되고 그때 typeorm DataSource 가 준비되지
|
|
@@ -353,6 +353,13 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
353
353
|
let result: any
|
|
354
354
|
try {
|
|
355
355
|
result = await proposeFigureSteps({
|
|
356
|
+
/* The agent of this conversation's turn - the one the person picked, or the domain default. */
|
|
357
|
+
client: await aiClientFor({
|
|
358
|
+
domain: ctx?.state?.domain,
|
|
359
|
+
user: ctx?.state?.user,
|
|
360
|
+
agentId: ctx?.state?.aiAgentId,
|
|
361
|
+
modeKind: ctx?.mode?.kind
|
|
362
|
+
}),
|
|
356
363
|
prompt,
|
|
357
364
|
base,
|
|
358
365
|
name: typeof args?.name === 'string' && args.name.trim() ? args.name.trim() : undefined,
|