@things-factory/figure-service 10.1.48 → 10.1.50
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 +14 -3
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-rename.d.ts +1 -1
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +46 -1
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/figure/figure-mutation.ts +19 -2
- package/server/service/figure/figure-rename-db.test.ts +45 -0
- package/server/service/figure/figure-rename.test.ts +1 -7
- package/server/service/figure/figure-rename.ts +1 -1
- package/server/service/figure/figure-tools.test.ts +39 -1
- package/server/service/figure/figure-tools.ts +49 -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.50",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hatiolab/figure-model": "^0.1.
|
|
26
|
+
"@hatiolab/figure-model": "^0.1.57",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
28
|
"@things-factory/ai-client-base": "^10.1.39",
|
|
29
29
|
"@things-factory/auth-base": "^10.1.47",
|
|
@@ -33,5 +33,5 @@
|
|
|
33
33
|
"graphql-upload": "^15.0.2",
|
|
34
34
|
"koa-router": "^7.4.0"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "4700d39d70eb5fae4ad56cf47ea0b966c8516ae2"
|
|
37
37
|
}
|
|
@@ -25,7 +25,9 @@ export class FigureMutation {
|
|
|
25
25
|
@Arg('id', { nullable: true }) id?: string,
|
|
26
26
|
@Arg('expectedSource', { nullable: true }) expectedSource?: string,
|
|
27
27
|
@Arg('creationId', { nullable: true }) creationId?: string,
|
|
28
|
-
@Arg('thumbnail', { nullable: true }) thumbnail?: string
|
|
28
|
+
@Arg('thumbnail', { nullable: true }) thumbnail?: string,
|
|
29
|
+
@Arg('type', { nullable: true, description: 'The type name a new figure is created under. Ignored for an existing one.' })
|
|
30
|
+
type?: string
|
|
29
31
|
): Promise<Figure> {
|
|
30
32
|
const { domain, user, tx } = context.state
|
|
31
33
|
const repository = tx.getRepository(Figure)
|
|
@@ -44,8 +46,23 @@ export class FigureMutation {
|
|
|
44
46
|
not match, so a new V3 figure could be published under a machine name that then froze (ADR-0063 decision 4)
|
|
45
47
|
— seen on :3300, 2026-09-24. A retry is still safe: the row id is the creation id, not the name.
|
|
46
48
|
*/
|
|
49
|
+
/*
|
|
50
|
+
The author names it on the first save, as V2 asked (lost in the move to V3, 2026-09-25): created under that
|
|
51
|
+
name or not at all -- a taken name refuses the save, rather than saving under the temporary one and failing a
|
|
52
|
+
rename after. Without a name (a caller that has none to give) it is the temporary one, renamed later.
|
|
53
|
+
*/
|
|
54
|
+
const named = type?.trim()
|
|
55
|
+
if (named) {
|
|
56
|
+
const facts = await renameFacts(
|
|
57
|
+
{ figures: repository, histories: tx.getRepository(FigureHistory) },
|
|
58
|
+
{ id: creationId },
|
|
59
|
+
domain,
|
|
60
|
+
named
|
|
61
|
+
)
|
|
62
|
+
renameDraftType({ type: named, source: prepared.source }, named, facts)
|
|
63
|
+
}
|
|
47
64
|
// insert, not save: a concurrent retry must never turn into an unchecked overwrite.
|
|
48
|
-
await repository.insert({ ...prepared, id: creationId, type: placeholderType(),
|
|
65
|
+
await repository.insert({ ...prepared, id: creationId, type: named || placeholderType(),
|
|
49
66
|
domain, creator: user, updater: user })
|
|
50
67
|
return repository.findOneOrFail({ where: { id: creationId, domain: { id: domain.id } } })
|
|
51
68
|
}
|
|
@@ -136,3 +136,48 @@ describe('이름을 바꾸기 전에 읽는 사실', () => {
|
|
|
136
136
|
expect((await facts(figure, ' ')).takenByAnother).toBe(false)
|
|
137
137
|
})
|
|
138
138
|
})
|
|
139
|
+
|
|
140
|
+
/*
|
|
141
|
+
* The first save names the figure (V2 asked for the type on the first save; the V3 modeller lost it, 2026-09-25).
|
|
142
|
+
* Through the door itself, on sqlite: created under that name or not at all.
|
|
143
|
+
*/
|
|
144
|
+
describe('처음 저장에 타입 이름을 받는다', () => {
|
|
145
|
+
let FigureMutation: any
|
|
146
|
+
let V3_SOURCE: string
|
|
147
|
+
beforeAll(() => {
|
|
148
|
+
FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
|
|
149
|
+
const model = require('@hatiolab/figure-model')
|
|
150
|
+
V3_SOURCE = model.serializeV3Asset(
|
|
151
|
+
model.addV3Box(model.createV3Asset('상자'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const save = (creationId: string, type?: string) =>
|
|
156
|
+
new FigureMutation().saveFigureV3(
|
|
157
|
+
V3_SOURCE,
|
|
158
|
+
{ state: { domain: DOMAIN, user: undefined, tx: dataSource!.manager } },
|
|
159
|
+
undefined,
|
|
160
|
+
undefined,
|
|
161
|
+
creationId,
|
|
162
|
+
undefined,
|
|
163
|
+
type
|
|
164
|
+
)
|
|
165
|
+
const ids = ['8a1f2c3d-4b5e-4f60-8a71-92b3c4d5e6f7', '9b2e3d4c-5a6f-4e71-9b82-a3c4d5e6f708']
|
|
166
|
+
|
|
167
|
+
it('준 이름으로 만든다 — 앞뒤 빈칸은 뺀다', async () => {
|
|
168
|
+
const saved = await save(ids[0], ' ROBOT_ARM ')
|
|
169
|
+
expect(saved.type).toBe('ROBOT_ARM')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('★ 이미 쓰는 이름이면 저장하지 않는다 — 임시 이름으로 남기지 않는다', async () => {
|
|
173
|
+
await seedFigure({ type: 'CONVEYOR', name: '컨베이어' })
|
|
174
|
+
await expect(save(ids[1], 'CONVEYOR')).rejects.toThrow(/이미 쓰고 있다/)
|
|
175
|
+
expect(await dataSource!.getRepository(Figure).count({ where: { id: ids[1] } })).toBe(0)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('이름 없이 부르면 종전대로 임시 이름 — 나중에 고친다', async () => {
|
|
179
|
+
const model = require('@hatiolab/figure-model')
|
|
180
|
+
const saved = await save(ids[0])
|
|
181
|
+
expect(model.isPlaceholderType(saved.type)).toBe(true)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -110,13 +110,7 @@ describe('문이 이 규칙을 지난다', () => {
|
|
|
110
110
|
V3 저장이 임시 이름을 스스로 지었다(`FIGURE_<32 hex>`). 형식의 `isPlaceholderType` 이 그것을 못 알아봐서
|
|
111
111
|
발행 판정이 기계 이름을 통과시켰다. 짓는 쪽과 알아보는 쪽이 한 규칙이어야 한다.
|
|
112
112
|
*/
|
|
113
|
-
|
|
114
|
-
const mutation = readFileSync(join(__dirname, 'figure-mutation.ts'), 'utf8')
|
|
115
|
-
const save = mutation.slice(mutation.indexOf('async saveFigureV3'), mutation.indexOf('async createFigure'))
|
|
116
|
-
|
|
117
|
-
expect(save).toContain('type: placeholderType()')
|
|
118
|
-
expect(save).not.toMatch(/type: `FIGURE_/)
|
|
119
|
-
})
|
|
113
|
+
/* 새 V3 도형의 임시 이름이 판정이 알아보는 그 규칙인지는 문을 실제로 불러 본다 — figure-rename-db.test.ts 「이름 없이 부르면」. */
|
|
120
114
|
|
|
121
115
|
it('문이 type 을 받는 칸을 갖는다', () => {
|
|
122
116
|
const type = source('figure-type.ts')
|
|
@@ -92,7 +92,7 @@ export function renameDraftType(
|
|
|
92
92
|
*/
|
|
93
93
|
export async function renameFacts(
|
|
94
94
|
repositories: {
|
|
95
|
-
figures: { findOne(options: any): Promise<{ id
|
|
95
|
+
figures: { findOne(options: any): Promise<{ id?: string } | null> }
|
|
96
96
|
histories: { count(options: any): Promise<number> }
|
|
97
97
|
},
|
|
98
98
|
figure: { id?: string; version?: number },
|
|
@@ -79,7 +79,10 @@ function install() {
|
|
|
79
79
|
costOf: model.costOf,
|
|
80
80
|
scoreOf: model.scoreOf,
|
|
81
81
|
validate: model.validate,
|
|
82
|
-
isPlaceholderType: model.isPlaceholderType
|
|
82
|
+
isPlaceholderType: model.isPlaceholderType,
|
|
83
|
+
parseAnySource: require('./figure-format').parseAnySource,
|
|
84
|
+
v3CostOf: model.v3CostOf,
|
|
85
|
+
v3ScoreOf: model.v3ScoreOf
|
|
83
86
|
})
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -505,3 +508,38 @@ describe('후보가 카드가 된다', () => {
|
|
|
505
508
|
expect(proposed[0].refine).toBe(false)
|
|
506
509
|
})
|
|
507
510
|
})
|
|
511
|
+
|
|
512
|
+
/*
|
|
513
|
+
V3 도형이 열려 있을 때(사용자 2026-09-24: 도크가 V3 초안에 「type 이 없다 · base 가 없다」 로 답했다). 읽기는 V3 로 읽고
|
|
514
|
+
잰다. 후보 제안은 아직 V2 만 쓰므로 지어내지 않고 그렇다고 말한다.
|
|
515
|
+
*/
|
|
516
|
+
describe('V3 도형이 열려 있을 때', () => {
|
|
517
|
+
const v3Draft = () => {
|
|
518
|
+
const model = require('@hatiolab/figure-model')
|
|
519
|
+
let asset = model.createV3Asset('Probe')
|
|
520
|
+
asset = model.addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
521
|
+
return asset
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
it('저장 전 V3 초안을 V3 로 읽고, V3 셈으로 잰다', async () => {
|
|
525
|
+
const draft = v3Draft()
|
|
526
|
+
const got = await toolNamed('getFigureSource').builder({}, ctx({ figureId: 'fig-1', draftSource: draft, palette: ['palette.primary'] }))
|
|
527
|
+
expect(got.format).toBe('v3')
|
|
528
|
+
expect(got.draft).toBe(true)
|
|
529
|
+
expect(got.cost.triangles).toBe(12)
|
|
530
|
+
expect(got.score.grade).toBe('A')
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('형식을 어긴 V3 초안은 V3 의 말로 거절한다 — V2 파서의 말이 아니다', async () => {
|
|
534
|
+
const broken = { ...v3Draft(), appearance: [] }
|
|
535
|
+
await expect(toolNamed('getFigureSource').builder({}, ctx({ figureId: 'fig-1', draftSource: broken, palette: ['palette.primary'] }))).rejects.toThrow(/V3 draft/)
|
|
536
|
+
await expect(toolNamed('getFigureSource').builder({}, ctx({ figureId: 'fig-1', draftSource: broken, palette: ['palette.primary'] }))).rejects.not.toThrow(/base 가 없다/)
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('후보 제안은 V3 도형에 V2 후보를 지어내지 않고, 아직 못 한다고 말한다', async () => {
|
|
540
|
+
const got = await toolNamed('proposeFigureCandidate').builder({ prompt: '다리를 길게' }, ctx({ figureId: 'fig-1', draftSource: v3Draft(), palette: ['palette.primary'] }))
|
|
541
|
+
expect(got.error).toBe('V3_PROPOSAL_UNAVAILABLE')
|
|
542
|
+
expect(proposed).toHaveLength(0)
|
|
543
|
+
})
|
|
544
|
+
})
|
|
545
|
+
|
|
@@ -62,6 +62,9 @@ function getDeps(): any {
|
|
|
62
62
|
complaint. The tool's own contract is unchanged: it reads, it never saves (ADR-0087).
|
|
63
63
|
*/
|
|
64
64
|
inspectSource: format.inspectAnySource,
|
|
65
|
+
parseAnySource: format.parseAnySource,
|
|
66
|
+
v3CostOf: model.v3CostOf,
|
|
67
|
+
v3ScoreOf: model.v3ScoreOf,
|
|
65
68
|
proposeFigure: propose.proposeFigure,
|
|
66
69
|
compile: model.compile,
|
|
67
70
|
costOf: model.costOf,
|
|
@@ -153,6 +156,23 @@ async function resolveFigureTarget(figureId: string | undefined, domainId: strin
|
|
|
153
156
|
* 찾은 뒤에만 보며, Figure의 불변 type과 형식 검증을 다시 확인한다. 검증되지 않은 큰 객체를 LLM
|
|
154
157
|
* 프롬프트로 옮기면 비용만 태우고, DB에 있던 Figure와 다른 것을 고친다고 잘못 말할 수 있다.
|
|
155
158
|
*/
|
|
159
|
+
/** A V3 asset names its format in `version`; a V2 FigureSource carries a number there. */
|
|
160
|
+
const isV3 = (source: any) => source?.version === 'figure-v3-asset-1'
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The V2 proposer writes V2 figures; a V3 figure open in the modeller is not something it can revise or add to. Said as
|
|
164
|
+
* what is true, with what the assistant can do instead -- not a V2 parser's complaint about a V3 asset (user,
|
|
165
|
+
* 2026-09-24: the dock answered 「type 이 없다 · base 가 없다」 for a V3 draft).
|
|
166
|
+
*/
|
|
167
|
+
function v3ProposalUnavailable() {
|
|
168
|
+
return {
|
|
169
|
+
error: 'V3_PROPOSAL_UNAVAILABLE',
|
|
170
|
+
message: '이 도형은 V3 형식입니다. AI 후보 제안은 아직 V2 도형만 만들 수 있어 이 도형을 고치는 후보를 낼 수 없습니다.',
|
|
171
|
+
nextStep:
|
|
172
|
+
'Tell the person plainly that candidate proposals do not support V3 figures yet. You can still read the figure (getFigureSource) and explain whether it may be released and why (inspectFigure). Do not produce a V2 candidate for a V3 figure.'
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
|
|
157
177
|
const isMatch = host?.figureId === row.id || (!row.id && host?.editorMode === 'new') || (!row.id && !host?.figureId)
|
|
158
178
|
const draft = isMatch ? host?.draftSource : undefined
|
|
@@ -169,6 +189,17 @@ function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
|
|
|
169
189
|
throw new Error('the unsaved draft is too large to safely review')
|
|
170
190
|
}
|
|
171
191
|
|
|
192
|
+
/* A V3 draft is checked as V3 -- the same door a save goes through. It names no type; the row does. */
|
|
193
|
+
if (isV3(draft)) {
|
|
194
|
+
try {
|
|
195
|
+
getDeps().parseAnySource(serialized)
|
|
196
|
+
} catch (e: any) {
|
|
197
|
+
const findings = Array.isArray(e?.findings) ? e.findings.map((one: any) => one.message).join(' · ') : String(e?.message ?? e)
|
|
198
|
+
throw new Error(`the unsaved V3 draft is invalid: ${findings}`)
|
|
199
|
+
}
|
|
200
|
+
return { source: draft, draft: true }
|
|
201
|
+
}
|
|
202
|
+
|
|
172
203
|
const { validate, isPlaceholderType } = getDeps()
|
|
173
204
|
const checked = validate(draft)
|
|
174
205
|
if (checked.errors.length > 0) {
|
|
@@ -306,6 +337,22 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
306
337
|
return { id: row.id, type: row.type, name: row.name, source: null, cost: null }
|
|
307
338
|
}
|
|
308
339
|
|
|
340
|
+
if (isV3(current.source)) {
|
|
341
|
+
/* A V3 asset is measured as V3 -- triangles, draw groups, and its grade against one screen. */
|
|
342
|
+
const { v3CostOf, v3ScoreOf } = getDeps()
|
|
343
|
+
return {
|
|
344
|
+
id: row.id,
|
|
345
|
+
type: row.type,
|
|
346
|
+
name: row.name,
|
|
347
|
+
state: row.state,
|
|
348
|
+
format: 'v3',
|
|
349
|
+
source: current.source,
|
|
350
|
+
draft: current.draft,
|
|
351
|
+
cost: v3CostOf(current.source),
|
|
352
|
+
score: v3ScoreOf(current.source) ?? null
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
309
356
|
const { compile, costOf, scoreOf } = getDeps()
|
|
310
357
|
const blueprint = compile(current.source)
|
|
311
358
|
|
|
@@ -339,7 +386,7 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
339
386
|
|
|
340
387
|
const current = sourceForTool(row, ctx?.host)
|
|
341
388
|
const { inspectSource } = getDeps()
|
|
342
|
-
return { ...inspectSource(current.source ? JSON.stringify(current.source) : null), draft: current.draft }
|
|
389
|
+
return { ...inspectSource(current.source ? JSON.stringify(current.source) : null, null, undefined, row.type), draft: current.draft }
|
|
343
390
|
}
|
|
344
391
|
},
|
|
345
392
|
{
|
|
@@ -391,6 +438,7 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
391
438
|
base = sourceForTool(row, ctx?.host).source ?? undefined
|
|
392
439
|
}
|
|
393
440
|
}
|
|
441
|
+
if (isV3(base) || (args?.create !== true && isV3(ctx?.host?.draftSource))) return v3ProposalUnavailable()
|
|
394
442
|
|
|
395
443
|
const palette = Array.isArray(ctx?.host?.palette) ? ctx.host.palette : []
|
|
396
444
|
if (palette.length === 0) {
|