@things-factory/figure-service 10.1.13 → 10.1.15
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-propose.js +2 -1
- package/dist-server/service/figure/figure-propose.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +15 -0
- package/dist-server/service/figure/figure-query.js +67 -0
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-source.d.ts +1 -1
- package/dist-server/service/figure/figure-source.js +8 -1
- package/dist-server/service/figure/figure-source.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +79 -17
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/logs/.08636eb59927f12972f6774f5947c8507b3564c2-audit.json +15 -0
- package/logs/application-2026-09-14-23.log +0 -0
- package/package.json +4 -3
- package/server/service/figure/figure-blueprint-query.test.ts +178 -0
- package/server/service/figure/figure-propose.ts +3 -1
- package/server/service/figure/figure-query.ts +68 -0
- package/server/service/figure/figure-source.test.ts +15 -9
- package/server/service/figure/figure-source.ts +11 -3
- package/server/service/figure/figure-tools.test.ts +21 -0
- package/server/service/figure/figure-tools.ts +87 -15
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"keep": {
|
|
3
|
+
"days": true,
|
|
4
|
+
"amount": 2
|
|
5
|
+
},
|
|
6
|
+
"auditLog": "logs/.08636eb59927f12972f6774f5947c8507b3564c2-audit.json",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"date": 1789397538290,
|
|
10
|
+
"name": "logs/application-2026-09-14-23.log",
|
|
11
|
+
"hash": "69c3188e63ed8031b901bd7314adfe3a91152378397ce14edd2f4fafb5a8770e"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"hashType": "sha256"
|
|
15
|
+
}
|
|
File without changes
|
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.15",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -23,14 +23,15 @@
|
|
|
23
23
|
"migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hatiolab/figure-model": "^0.1.
|
|
26
|
+
"@hatiolab/figure-model": "^0.1.17",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
28
|
"@things-factory/ai-client-base": "^10.1.8",
|
|
29
29
|
"@things-factory/auth-base": "^10.1.7",
|
|
30
30
|
"@things-factory/env": "^10.1.3",
|
|
31
31
|
"@things-factory/shell": "^10.1.7",
|
|
32
|
+
"graphql-scalars": "^1.22.4",
|
|
32
33
|
"graphql-upload": "^15.0.2",
|
|
33
34
|
"koa-router": "^7.4.0"
|
|
34
35
|
},
|
|
35
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "0186eb7ec008781fd9f80b6f59379a91e0908820"
|
|
36
37
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { DataSource } from 'typeorm'
|
|
2
|
+
|
|
3
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
4
|
+
const shell = require('@things-factory/shell')
|
|
5
|
+
const authBase = require('@things-factory/auth-base')
|
|
6
|
+
|
|
7
|
+
let dataSource: DataSource | null = null
|
|
8
|
+
let Figure: any
|
|
9
|
+
let FigureHistory: any
|
|
10
|
+
let FigureQuery: any
|
|
11
|
+
let testDomain: any
|
|
12
|
+
|
|
13
|
+
function loadCompiled(modulePath: string): any {
|
|
14
|
+
const { existsSync } = require('fs')
|
|
15
|
+
const { join } = require('path')
|
|
16
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
17
|
+
|
|
18
|
+
if (!existsSync(resolved)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`figure-service 산출물이 없다: ${resolved}\n` +
|
|
21
|
+
`이 시험은 tsc 산출물을 든다. 먼저 빌드할 것:\n` +
|
|
22
|
+
` yarn workspace @things-factory/figure-service build:server`
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return require(resolved)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sampleSource = JSON.stringify({
|
|
30
|
+
version: 1,
|
|
31
|
+
type: 'TEST_BOX',
|
|
32
|
+
base: { x: 100, y: 100, z: 100 },
|
|
33
|
+
parts: [
|
|
34
|
+
{
|
|
35
|
+
name: 'box-1',
|
|
36
|
+
primitive: 'cube',
|
|
37
|
+
transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
38
|
+
material: { token: 'palette.primary' }
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
45
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
46
|
+
FigureQuery = loadCompiled('service/figure/figure-query').FigureQuery
|
|
47
|
+
|
|
48
|
+
dataSource = new TypeOrmDataSource({
|
|
49
|
+
type: 'sqlite',
|
|
50
|
+
database: ':memory:',
|
|
51
|
+
synchronize: true,
|
|
52
|
+
dropSchema: true,
|
|
53
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
54
|
+
entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
|
|
55
|
+
logging: false
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await dataSource.initialize()
|
|
59
|
+
shell.addDataSource('default', dataSource)
|
|
60
|
+
shell.addDataSource('tx', dataSource)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
await dataSource!.synchronize(true)
|
|
65
|
+
testDomain = await dataSource!.getRepository(shell.Domain).save({
|
|
66
|
+
name: 'test-domain',
|
|
67
|
+
subdomain: 'test-domain'
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
if (!dataSource) return
|
|
73
|
+
shell.removeDataSource('default')
|
|
74
|
+
shell.removeDataSource('tx')
|
|
75
|
+
await dataSource.destroy()
|
|
76
|
+
dataSource = null
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
function makeContext() {
|
|
80
|
+
return {
|
|
81
|
+
state: {
|
|
82
|
+
domain: testDomain,
|
|
83
|
+
user: { id: 'user-1' }
|
|
84
|
+
}
|
|
85
|
+
} as any
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위한 청사진 전달', () => {
|
|
89
|
+
it('존재하지 않는 타입은 null을 반환한다', async () => {
|
|
90
|
+
const query = new FigureQuery()
|
|
91
|
+
const result = await query.figureBlueprint('NON_EXISTENT', makeContext())
|
|
92
|
+
expect(result).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('초안(draft) 상태이고 발행 이력이 없는 Figure는 null을 반환한다 (초안 유출 차단)', async () => {
|
|
96
|
+
const repo = dataSource!.getRepository(Figure)
|
|
97
|
+
await repo.save({
|
|
98
|
+
domain: testDomain,
|
|
99
|
+
type: 'DRAFT_FIGURE',
|
|
100
|
+
name: '초안 도형',
|
|
101
|
+
state: 'draft',
|
|
102
|
+
source: sampleSource,
|
|
103
|
+
version: 0
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const query = new FigureQuery()
|
|
107
|
+
const result = await query.figureBlueprint('DRAFT_FIGURE', makeContext())
|
|
108
|
+
expect(result).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('발행된(released) Figure는 올바르게 컴파일된 Blueprint를 반환한다', async () => {
|
|
112
|
+
const repo = dataSource!.getRepository(Figure)
|
|
113
|
+
await repo.save({
|
|
114
|
+
domain: testDomain,
|
|
115
|
+
type: 'RELEASED_FIGURE',
|
|
116
|
+
name: '발행 도형',
|
|
117
|
+
state: 'released',
|
|
118
|
+
source: sampleSource,
|
|
119
|
+
version: 1
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const query = new FigureQuery()
|
|
123
|
+
const result = await query.figureBlueprint('RELEASED_FIGURE', makeContext())
|
|
124
|
+
expect(result).not.toBeNull()
|
|
125
|
+
expect(result.type).toBe('TEST_BOX')
|
|
126
|
+
expect(result.groups).toBeDefined()
|
|
127
|
+
expect(result.groups.length).toBe(1)
|
|
128
|
+
expect(result.groups[0].members[0].name).toBe('box-1')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('현재 저작 중(draft)이더라도 과거 발행판(FigureHistory)이 있으면 최신 발행판을 컴파일해 반환한다', async () => {
|
|
132
|
+
const repo = dataSource!.getRepository(Figure)
|
|
133
|
+
const historyRepo = dataSource!.getRepository(FigureHistory)
|
|
134
|
+
|
|
135
|
+
const v1Source = sampleSource
|
|
136
|
+
const v2DraftSource = JSON.stringify({
|
|
137
|
+
version: 1,
|
|
138
|
+
type: 'UPDATING_FIGURE',
|
|
139
|
+
base: { x: 200, y: 200, z: 200 },
|
|
140
|
+
parts: [
|
|
141
|
+
{
|
|
142
|
+
name: 'box-modified',
|
|
143
|
+
primitive: 'cube',
|
|
144
|
+
transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 200, y: 200, z: 200 } },
|
|
145
|
+
material: { token: 'palette.primary' }
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const figure = await repo.save({
|
|
151
|
+
domain: testDomain,
|
|
152
|
+
type: 'UPDATING_FIGURE',
|
|
153
|
+
name: '수정 중인 도형',
|
|
154
|
+
state: 'draft',
|
|
155
|
+
source: v2DraftSource,
|
|
156
|
+
version: 1
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// 과거 발행된 v1 이력
|
|
160
|
+
await historyRepo.save({
|
|
161
|
+
domain: testDomain,
|
|
162
|
+
originalId: figure.id,
|
|
163
|
+
type: 'UPDATING_FIGURE',
|
|
164
|
+
name: 'v1 발행판',
|
|
165
|
+
state: 'released',
|
|
166
|
+
version: 1,
|
|
167
|
+
source: v1Source
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const query = new FigureQuery()
|
|
171
|
+
const result = await query.figureBlueprint('UPDATING_FIGURE', makeContext())
|
|
172
|
+
|
|
173
|
+
expect(result).not.toBeNull()
|
|
174
|
+
// 소비처는 수정 중인 초안(box-modified, size 200)이 아니라 기발행된 판(box-1, size 100)을 본다!
|
|
175
|
+
expect(result.groups[0].members[0].name).toBe('box-1')
|
|
176
|
+
expect(result.groups[0].members[0].transform.size.x).toBe(100)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
PRIMITIVE_KINDS,
|
|
8
8
|
SEGMENT_PRESETS,
|
|
9
9
|
SIZING_RULES,
|
|
10
|
+
FIGURE_PLACEMENTS,
|
|
10
11
|
compile,
|
|
11
12
|
costOf,
|
|
12
13
|
scoreOf,
|
|
@@ -131,13 +132,14 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
131
132
|
'Answer with a single JSON object and nothing else — no prose, no code fence.',
|
|
132
133
|
'',
|
|
133
134
|
'The JSON is a FigureSource:',
|
|
134
|
-
' { type, base: {x,y,z}, detailLevel, parts: [...] }',
|
|
135
|
+
' { type, base: {x,y,z}, placement?, detailLevel, parts: [...] }',
|
|
135
136
|
'',
|
|
136
137
|
'A part is:',
|
|
137
138
|
' { name, primitive, transform: { position: {x,y,z}, size: {x,y,z}, rotation?: {x,y,z} },',
|
|
138
139
|
' material: {token, preset?}, segments?, shape?, sizing? }',
|
|
139
140
|
'',
|
|
140
141
|
'Hard vocabulary — anything outside these is rejected:',
|
|
142
|
+
` placement ${FIGURE_PLACEMENTS.join(' | ')} (floor: default, ground equipment; ceiling: overhead hoist/OHT; center: airborne/drone/sensor)`,
|
|
141
143
|
` primitive ${PRIMITIVE_KINDS.join(' | ')}`,
|
|
142
144
|
` segments ${SEGMENT_PRESETS.join(' | ')} (only for cylinder and sphere)`,
|
|
143
145
|
` material.token ${palette.join(' | ')}`,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { In } from 'typeorm'
|
|
2
2
|
import { Args, Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'
|
|
3
|
+
import { GraphQLJSON } from 'graphql-scalars'
|
|
4
|
+
import { compile } from '@hatiolab/figure-model'
|
|
3
5
|
|
|
4
6
|
import { Domain, getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'
|
|
5
7
|
|
|
@@ -126,4 +128,70 @@ export class FigureQuery {
|
|
|
126
128
|
|
|
127
129
|
return rows.map(r => r.type).filter(Boolean)
|
|
128
130
|
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 소비처(operato-board, operato-twin, things-scene)를 위한 컴파일된 청사진(FigureBlueprint) 질의.
|
|
134
|
+
*
|
|
135
|
+
* ## 규칙
|
|
136
|
+
* 1. 소비처는 초안(draft)을 볼 수 없다 — **반드시 발행된(released) 판만 전달**한다.
|
|
137
|
+
* 2. 현재 Figure가 released 상태이면 현재 source를 컴파일한다.
|
|
138
|
+
* 3. 현재 Figure가 저작 중(draft)이더라도 이전에 발행된 이력(FigureHistory)이 있으면
|
|
139
|
+
* 가장 최근의 released 판본을 컴파일하여 하위 호환성을 유지한다.
|
|
140
|
+
* 4. 한 번도 발행된 적 없는 초안 자산이거나 존재하지 않는 타입인 경우 null을 반환하여
|
|
141
|
+
* 미완성 자산이 씬 화면에 유출되는 것을 엄격히 차단한다.
|
|
142
|
+
* 5. 도메인 상속 시 자식 도메인의 타입이 부모 도메인의 동일 타입을 항상 오버라이드한다.
|
|
143
|
+
* 6. 소비처(board, twin, things-scene) 사용자에게 저작 권한(figure:query)이 없어도
|
|
144
|
+
* 발행된 청사진을 열람할 수 있도록 도메인 인증 상태에서 열람을 허용한다.
|
|
145
|
+
*/
|
|
146
|
+
@Query(returns => GraphQLJSON, {
|
|
147
|
+
nullable: true,
|
|
148
|
+
description: 'Compiled FigureBlueprint for external consumers (board, twin, things-scene). Only returns released figures.'
|
|
149
|
+
})
|
|
150
|
+
async figureBlueprint(
|
|
151
|
+
@Arg('type') type: string,
|
|
152
|
+
@Ctx() context: ResolverContext
|
|
153
|
+
): Promise<any> {
|
|
154
|
+
const { domain } = context.state
|
|
155
|
+
|
|
156
|
+
// 자식 도메인 자산 우선 탐색 후, 부모 도메인 표준 자산으로 폴백 (오버라이드 보장)
|
|
157
|
+
let figure = await getRepository(Figure).findOne({
|
|
158
|
+
where: { domain: { id: domain.id }, type }
|
|
159
|
+
})
|
|
160
|
+
if (!figure && domain.parentId) {
|
|
161
|
+
figure = await getRepository(Figure).findOne({
|
|
162
|
+
where: { domain: { id: domain.parentId }, type }
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!figure) {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let sourceToCompile: string | null = null
|
|
171
|
+
|
|
172
|
+
if (figure.state === 'released' && figure.source) {
|
|
173
|
+
sourceToCompile = figure.source
|
|
174
|
+
} else if (figure.id) {
|
|
175
|
+
// 저작 중(draft)인 경우 최근 발행 이력(FigureHistory)을 확인한다
|
|
176
|
+
const latestReleased = await getRepository(FigureHistory).findOne({
|
|
177
|
+
where: { domain: { id: figure.domain?.id ?? domain.id }, originalId: figure.id, state: 'released' },
|
|
178
|
+
order: { version: 'DESC' }
|
|
179
|
+
})
|
|
180
|
+
if (latestReleased && latestReleased.source) {
|
|
181
|
+
sourceToCompile = latestReleased.source
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!sourceToCompile) {
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const parsed = typeof sourceToCompile === 'string' ? JSON.parse(sourceToCompile) : sourceToCompile
|
|
191
|
+
return compile(parsed)
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 청사진 컴파일 실패:`, err)
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
}
|
|
129
197
|
}
|
|
@@ -24,6 +24,7 @@ function part(over: Record<string, unknown> = {}) {
|
|
|
24
24
|
/** 온전한 정본 하나. */
|
|
25
25
|
function source(over: Record<string, unknown> = {}) {
|
|
26
26
|
return {
|
|
27
|
+
version: 1,
|
|
27
28
|
type: 'MIXER',
|
|
28
29
|
base: { x: 300, y: 800, z: 300 },
|
|
29
30
|
detailLevel: 'M',
|
|
@@ -59,22 +60,21 @@ describe('figure-source — 들어오는 문', () => {
|
|
|
59
60
|
it('형식을 어기면 던지고, figure-model 의 코드를 그대로 전한다', () => {
|
|
60
61
|
let thrown: FigureSourceError | undefined
|
|
61
62
|
try {
|
|
62
|
-
// 부품이 없는 정본
|
|
63
63
|
parseSource(json(source({ parts: [] })))
|
|
64
64
|
} catch (e) {
|
|
65
65
|
thrown = e as FigureSourceError
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
expect(thrown).toBeInstanceOf(FigureSourceError)
|
|
69
|
-
expect(thrown!.findings.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
69
|
+
expect(thrown!.findings[0].code).toBe('missing-parts')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('필수 필드가 빠지면 던진다', () => {
|
|
73
|
+
// type 없음
|
|
74
|
+
expect(() => parseSource(json({ base: { x: 1, y: 1, z: 1 }, parts: [part()] }))).toThrow(FigureSourceError)
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
-
it('
|
|
77
|
+
it('오류의 위치(at)를 알린다 — 어느 부품인지 저작 화면이 짚을 수 있다', () => {
|
|
78
78
|
let thrown: FigureSourceError | undefined
|
|
79
79
|
try {
|
|
80
80
|
parseSource(json(source({ parts: [part({ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: -1, y: 1, z: 1 } } })] })))
|
|
@@ -94,10 +94,16 @@ describe('figure-source — 들어오는 문', () => {
|
|
|
94
94
|
})
|
|
95
95
|
|
|
96
96
|
it('정본을 다시 직렬화하지 않는다 — 들어온 문자열을 그대로 저장한다', () => {
|
|
97
|
-
//
|
|
97
|
+
// 이미 version이 있는 정본은 키 순서·공백을 그대로 보존한다
|
|
98
98
|
const raw = json(source())
|
|
99
99
|
expect(parseSource(raw).source).toBe(raw)
|
|
100
100
|
})
|
|
101
|
+
|
|
102
|
+
it('version이 누락된 구형/외부 정본은 FIGURE_SOURCE_VERSION으로 보정 직렬화한다', () => {
|
|
103
|
+
const withoutVersion = json({ type: 'MIXER', base: { x: 300, y: 800, z: 300 }, detailLevel: 'M', parts: [part()] })
|
|
104
|
+
const parsed = JSON.parse(parseSource(withoutVersion).source)
|
|
105
|
+
expect(parsed.version).toBe(1)
|
|
106
|
+
})
|
|
101
107
|
})
|
|
102
108
|
|
|
103
109
|
describe('파생값은 서버가 계산한다', () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
|
|
1
|
+
import { FIGURE_SOURCE_VERSION, compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
|
|
2
2
|
|
|
3
3
|
import { FigureFinding } from './figure-type.js'
|
|
4
4
|
|
|
@@ -24,7 +24,7 @@ import { FigureFinding } from './figure-type.js'
|
|
|
24
24
|
* 목록 화면이 실물과 다른 것을 보여 준다. 정본에서 계산한다.
|
|
25
25
|
*/
|
|
26
26
|
export interface ParsedSource {
|
|
27
|
-
/** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 —
|
|
27
|
+
/** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 — 단, version 누락 시에만 정규화 직렬화한다. */
|
|
28
28
|
source: string
|
|
29
29
|
/** 목록 화면이 정렬·필터에 쓰는 파생값. */
|
|
30
30
|
derived: { score: number; triangles: number; groups: number }
|
|
@@ -65,6 +65,14 @@ export function parseSource(sourceJson: string): ParsedSource {
|
|
|
65
65
|
)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// version이 누락된 구형/외부 입력의 경우 FIGURE_SOURCE_VERSION을 부여하여 스키마를 정규화한다.
|
|
69
|
+
// 이미 version이 있는 정본은 포맷 훼손을 막기 위해 sourceJson 원본을 그대로 보존한다.
|
|
70
|
+
let normalizedSource = sourceJson
|
|
71
|
+
if (parsed && typeof parsed === 'object' && (parsed as any).version === undefined) {
|
|
72
|
+
(parsed as any).version = FIGURE_SOURCE_VERSION
|
|
73
|
+
normalizedSource = JSON.stringify(parsed, null, 2)
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
// 여기까지 왔으면 형식은 맞다. 청사진을 만들어 셈에 쓴다.
|
|
69
77
|
// 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.
|
|
70
78
|
const blueprint = compile(parsed as any)
|
|
@@ -72,7 +80,7 @@ export function parseSource(sourceJson: string): ParsedSource {
|
|
|
72
80
|
const score = scoreOf(blueprint)
|
|
73
81
|
|
|
74
82
|
return {
|
|
75
|
-
source:
|
|
83
|
+
source: normalizedSource,
|
|
76
84
|
derived: {
|
|
77
85
|
score: score.score,
|
|
78
86
|
triangles: cost.triangles,
|
|
@@ -246,6 +246,27 @@ describe('대화면 문맥에서 대상을 해소한다', () => {
|
|
|
246
246
|
)
|
|
247
247
|
).rejects.toThrow(/does not match Figure/)
|
|
248
248
|
})
|
|
249
|
+
|
|
250
|
+
it('재현: 기존 도형 편집 중 미저장 초안의 type이 FIGURE여도 현재 도형 초안으로 허용해야 한다', async () => {
|
|
251
|
+
// DB의 row는 MIXER(또는 OHT)이지만, 캔버스/도크의 초안 type은 일반 'FIGURE'일 수 있음
|
|
252
|
+
const result = await toolNamed('getFigureSource').builder(
|
|
253
|
+
{},
|
|
254
|
+
ctx({ figureId: 'fig-1', palette: ['palette.primary'], draftSource: { ...SOURCE, type: 'FIGURE' } })
|
|
255
|
+
)
|
|
256
|
+
expect(result.draft).toBe(true)
|
|
257
|
+
expect(result.type).toBe('MIXER')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('재현: LLM이 새 미저장 모델의 임시 타입(FIGURE_MTZO5R10)을 figureId로 전달해도 초안을 읽어야 한다', async () => {
|
|
261
|
+
const tempDraft = { ...SOURCE, type: 'FIGURE_MTZO5R10' }
|
|
262
|
+
const host = { editorMode: 'new', draftSource: tempDraft, palette: ['palette.primary'] }
|
|
263
|
+
const result = await toolNamed('getFigureSource').builder(
|
|
264
|
+
{ figureId: 'FIGURE_MTZO5R10' },
|
|
265
|
+
ctx(host)
|
|
266
|
+
)
|
|
267
|
+
expect(result.draft).toBe(true)
|
|
268
|
+
expect(result.type).toBe('FIGURE_MTZO5R10')
|
|
269
|
+
})
|
|
249
270
|
})
|
|
250
271
|
|
|
251
272
|
/*
|
|
@@ -93,6 +93,48 @@ async function figureInDomain(figureId: string, domainId: string): Promise<any>
|
|
|
93
93
|
return found
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* figureId 또는 hostContext로부터 대상 Figure row(또는 미저장 초안 가상 row)를 해소한다.
|
|
98
|
+
*/
|
|
99
|
+
async function resolveFigureTarget(figureId: string | undefined, domainId: string, host: any): Promise<any> {
|
|
100
|
+
if (
|
|
101
|
+
host?.editorMode === 'new' &&
|
|
102
|
+
host?.draftSource &&
|
|
103
|
+
(!figureId || figureId === host.draftSource.type || figureId.startsWith('FIGURE'))
|
|
104
|
+
) {
|
|
105
|
+
return unsavedFigure(host)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (figureId) {
|
|
109
|
+
const { getRepository, Figure } = getDeps()
|
|
110
|
+
let found = await getRepository(Figure).findOne({
|
|
111
|
+
where: { id: figureId, domain: { id: domainId } }
|
|
112
|
+
})
|
|
113
|
+
if (!found) {
|
|
114
|
+
found = await getRepository(Figure).findOne({
|
|
115
|
+
where: { type: figureId, domain: { id: domainId } }
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
if (!found) {
|
|
119
|
+
if (
|
|
120
|
+
host?.editorMode === 'new' ||
|
|
121
|
+
(host?.draftSource &&
|
|
122
|
+
(host.draftSource.type === figureId ||
|
|
123
|
+
figureId.startsWith('FIGURE') ||
|
|
124
|
+
!host.figureId ||
|
|
125
|
+
host.figureId === figureId))
|
|
126
|
+
) {
|
|
127
|
+
const unsaved = unsavedFigure(host)
|
|
128
|
+
if (unsaved) return unsaved
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`figure not found in this tenant: ${figureId}`)
|
|
131
|
+
}
|
|
132
|
+
return found
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return unsavedFigure(host)
|
|
136
|
+
}
|
|
137
|
+
|
|
96
138
|
/**
|
|
97
139
|
* 모델러가 아직 저장하지 않은 source를 도구가 읽는 유일한 길.
|
|
98
140
|
*
|
|
@@ -101,7 +143,8 @@ async function figureInDomain(figureId: string, domainId: string): Promise<any>
|
|
|
101
143
|
* 프롬프트로 옮기면 비용만 태우고, DB에 있던 Figure와 다른 것을 고친다고 잘못 말할 수 있다.
|
|
102
144
|
*/
|
|
103
145
|
function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
|
|
104
|
-
const
|
|
146
|
+
const isMatch = host?.figureId === row.id || (!row.id && host?.editorMode === 'new') || (!row.id && !host?.figureId)
|
|
147
|
+
const draft = isMatch ? host?.draftSource : undefined
|
|
105
148
|
if (!draft) {
|
|
106
149
|
return { source: row.source ? JSON.parse(row.source) : null, draft: false }
|
|
107
150
|
}
|
|
@@ -120,11 +163,20 @@ function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
|
|
|
120
163
|
if (checked.errors.length > 0) {
|
|
121
164
|
throw new Error(`the unsaved draft is invalid: ${checked.errors.map((one: any) => one.message).join(' · ')}`)
|
|
122
165
|
}
|
|
166
|
+
|
|
167
|
+
let normalizedDraft = draft
|
|
123
168
|
if (draft.type !== row.type) {
|
|
124
|
-
|
|
169
|
+
// 템플릿 기본값 'FIGURE', 임시 draftType('FIGURE_...'), 또는 빈 값인 경우
|
|
170
|
+
// 현재 열려 있는 Figure의 실제 type으로 보정/정규화한다.
|
|
171
|
+
const isGenericOrTempType = !draft.type || draft.type === 'FIGURE' || draft.type.startsWith('FIGURE_')
|
|
172
|
+
if (isGenericOrTempType && row.type) {
|
|
173
|
+
normalizedDraft = { ...draft, type: row.type }
|
|
174
|
+
} else {
|
|
175
|
+
throw new Error(`the unsaved draft type '${draft.type}' does not match Figure '${row.type}'`)
|
|
176
|
+
}
|
|
125
177
|
}
|
|
126
178
|
|
|
127
|
-
return { source:
|
|
179
|
+
return { source: normalizedDraft, draft: true }
|
|
128
180
|
}
|
|
129
181
|
|
|
130
182
|
/**
|
|
@@ -134,8 +186,14 @@ function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
|
|
|
134
186
|
* 도메인에서 Figure를 찾은 뒤, `proposeFigure`가 다시 길이·형식을 정리할 참고 데이터만 건넨다.
|
|
135
187
|
*/
|
|
136
188
|
function feedbackForTool(host: any, figureId?: string): unknown[] | undefined {
|
|
137
|
-
if (!
|
|
138
|
-
|
|
189
|
+
if (!Array.isArray(host?.proposalFeedback)) return undefined
|
|
190
|
+
if (figureId && host?.figureId === figureId) {
|
|
191
|
+
return host.proposalFeedback.slice(-5)
|
|
192
|
+
}
|
|
193
|
+
if (host?.editorMode === 'new' || !host?.figureId) {
|
|
194
|
+
return host.proposalFeedback.slice(-5)
|
|
195
|
+
}
|
|
196
|
+
return undefined
|
|
139
197
|
}
|
|
140
198
|
|
|
141
199
|
/**
|
|
@@ -163,8 +221,8 @@ function missingFigureTarget() {
|
|
|
163
221
|
}
|
|
164
222
|
|
|
165
223
|
function unsavedFigure(host: any): any | undefined {
|
|
166
|
-
if (host?.editorMode !== 'new' ||
|
|
167
|
-
const row = { id: host.figureId, type: host.draftSource.type, name: '', state: 'draft' }
|
|
224
|
+
if (host?.editorMode !== 'new' || !host?.draftSource) return undefined
|
|
225
|
+
const row = { id: host.figureId, type: host.draftSource.type || 'FIGURE', name: '', state: 'draft' }
|
|
168
226
|
// Reuse size, shape and vocabulary validation before trusting browser content.
|
|
169
227
|
const current = sourceForTool(row, host)
|
|
170
228
|
return { ...row, source: JSON.stringify(current.source) }
|
|
@@ -223,7 +281,7 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
223
281
|
builder: async (args: any, ctx: any) => {
|
|
224
282
|
const domainId = requireDomainId(ctx?.state)
|
|
225
283
|
const figureId = figureIdFrom(args, ctx?.host)
|
|
226
|
-
const row =
|
|
284
|
+
const row = await resolveFigureTarget(figureId, domainId, ctx?.host)
|
|
227
285
|
if (!row) return missingFigureTarget()
|
|
228
286
|
|
|
229
287
|
const current = sourceForTool(row, ctx?.host)
|
|
@@ -258,7 +316,7 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
258
316
|
builder: async (args: any, ctx: any) => {
|
|
259
317
|
const domainId = requireDomainId(ctx?.state)
|
|
260
318
|
const figureId = figureIdFrom(args, ctx?.host)
|
|
261
|
-
const row =
|
|
319
|
+
const row = await resolveFigureTarget(figureId, domainId, ctx?.host)
|
|
262
320
|
if (!row) return missingFigureTarget()
|
|
263
321
|
|
|
264
322
|
const current = sourceForTool(row, ctx?.host)
|
|
@@ -308,12 +366,9 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
308
366
|
let base: unknown
|
|
309
367
|
if (!args?.create && args?.figureId !== null) {
|
|
310
368
|
const open = typeof args?.figureId === 'string' ? args.figureId : ctx?.host?.figureId
|
|
311
|
-
|
|
312
|
-
|
|
369
|
+
const row = await resolveFigureTarget(open ? open.trim() : undefined, domainId, ctx?.host)
|
|
370
|
+
if (row) {
|
|
313
371
|
base = sourceForTool(row, ctx?.host).source ?? undefined
|
|
314
|
-
} else {
|
|
315
|
-
const draft = unsavedFigure(ctx?.host)
|
|
316
|
-
if (draft) base = JSON.parse(draft.source)
|
|
317
372
|
}
|
|
318
373
|
}
|
|
319
374
|
|
|
@@ -328,13 +383,23 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
328
383
|
|
|
329
384
|
const { proposeFigure } = getDeps()
|
|
330
385
|
const feedback = feedbackForTool(ctx?.host, base ? figureIdFrom(args, ctx?.host) : undefined)
|
|
386
|
+
|
|
387
|
+
let hostImage: { data: Buffer; mediaType: any } | undefined
|
|
388
|
+
const rawImage = ctx?.host?.image || (Array.isArray(ctx?.host?.attachments) ? ctx.host.attachments.find((a: any) => a?.mediaType?.startsWith('image/')) : undefined)
|
|
389
|
+
if (rawImage?.data) {
|
|
390
|
+
const rawData = typeof rawImage.data === 'string' ? rawImage.data.replace(/^data:image\/[a-zA-Z]+;base64,/, '') : rawImage.data
|
|
391
|
+
const buf = typeof rawData === 'string' ? Buffer.from(rawData, 'base64') : Buffer.from(rawData)
|
|
392
|
+
hostImage = { data: buf, mediaType: rawImage.mediaType || 'image/png' }
|
|
393
|
+
}
|
|
394
|
+
|
|
331
395
|
const result = await proposeFigure({
|
|
332
396
|
prompt,
|
|
333
397
|
palette,
|
|
334
398
|
base,
|
|
335
399
|
type: typeof args?.type === 'string' ? args.type : undefined,
|
|
336
400
|
feedback,
|
|
337
|
-
refine: !!feedback?.length
|
|
401
|
+
refine: !!feedback?.length,
|
|
402
|
+
image: hostImage
|
|
338
403
|
})
|
|
339
404
|
|
|
340
405
|
/*
|
|
@@ -351,6 +416,13 @@ function figureToolSpecs(): ToolSpec[] {
|
|
|
351
416
|
reason:
|
|
352
417
|
`등급 ${result.score.grade} · 삼각형 ${result.cost.triangles} · ` +
|
|
353
418
|
`한 도면 ${result.cost.at.drawCalls}회 · ${result.attempts}번에 만들었다`,
|
|
419
|
+
choices: [
|
|
420
|
+
{ id: 'save', action: 'save', label: '초안 저장', icon: 'save', primary: true },
|
|
421
|
+
{ id: 'repropose', action: 'prompt', label: '다른 콘셉트 재제안', icon: 'refresh', prompt: '현재 제안과 완전히 다른 스타일과 콘셉트로 새로 제안해줘' },
|
|
422
|
+
{ id: 'slim', action: 'prompt', label: '비율 슬림 조정', icon: 'aspect_ratio', prompt: '전체적인 가로세로 비율을 좀 더 슬림하고 날렵하게 조정해줘' },
|
|
423
|
+
{ id: 'palette', action: 'prompt', label: '색상 테마 변경', icon: 'palette', prompt: '다른 팔레트 색상 테마로 부품 색상을 변경해줘' },
|
|
424
|
+
{ id: 'detail', action: 'prompt', label: '디테일 보강', icon: 'extension', prompt: '주요 디테일과 센서 부품을 좀 더 풍부하게 보강해줘' }
|
|
425
|
+
],
|
|
354
426
|
args: {
|
|
355
427
|
baseSource: base ? JSON.stringify(base) : undefined,
|
|
356
428
|
prompt, figureId: base ? figureIdFrom(args, ctx?.host) : undefined,
|