@things-factory/figure-service 10.1.6 → 10.1.8
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-gate.d.ts +18 -0
- package/dist-server/service/figure/figure-gate.js +52 -1
- package/dist-server/service/figure/figure-gate.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +8 -0
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-propose-type.d.ts +30 -0
- package/dist-server/service/figure/figure-propose-type.js +112 -1
- package/dist-server/service/figure/figure-propose-type.js.map +1 -1
- package/dist-server/service/figure/figure-propose.d.ts +25 -1
- package/dist-server/service/figure/figure-propose.js +102 -4
- package/dist-server/service/figure/figure-propose.js.map +1 -1
- package/dist-server/service/figure/figure-quality.d.ts +29 -0
- package/dist-server/service/figure/figure-quality.js +62 -0
- package/dist-server/service/figure/figure-quality.js.map +1 -0
- package/dist-server/service/figure/figure-query.js +8 -1
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +55 -9
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +13 -0
- package/dist-server/service/figure/figure-type.js +51 -1
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +17 -1
- package/dist-server/service/figure/figure.js +6 -1
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/figure/catalog-db.test.ts +162 -0
- package/server/service/figure/figure-ai-flow.test.ts +95 -0
- package/server/service/figure/figure-gate.test.ts +32 -0
- package/server/service/figure/figure-gate.ts +71 -2
- package/server/service/figure/figure-llm-smoke.test.ts +91 -0
- package/server/service/figure/figure-mutation.ts +8 -0
- package/server/service/figure/figure-propose-type.ts +80 -0
- package/server/service/figure/figure-propose.test.ts +77 -0
- package/server/service/figure/figure-propose.ts +137 -4
- package/server/service/figure/figure-quality.test.ts +43 -0
- package/server/service/figure/figure-quality.ts +98 -0
- package/server/service/figure/figure-query.ts +8 -1
- package/server/service/figure/figure-tools.test.ts +62 -1
- package/server/service/figure/figure-tools.ts +60 -8
- package/server/service/figure/figure-type.ts +41 -0
- package/server/service/figure/figure.ts +22 -2
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.8",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -23,14 +23,14 @@
|
|
|
23
23
|
"migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hatiolab/figure-model": "
|
|
26
|
+
"@hatiolab/figure-model": "file:../../../figure-model",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
29
|
-
"@things-factory/auth-base": "^10.1.
|
|
28
|
+
"@things-factory/ai-client-base": "^10.1.8",
|
|
29
|
+
"@things-factory/auth-base": "^10.1.7",
|
|
30
30
|
"@things-factory/env": "^10.1.3",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.7",
|
|
32
32
|
"graphql-upload": "^15.0.2",
|
|
33
33
|
"koa-router": "^7.4.0"
|
|
34
34
|
},
|
|
35
|
-
"gitHead": "
|
|
35
|
+
"gitHead": "c402982e29aab3b49e56bea4e41948be6a749c7f"
|
|
36
36
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 분류와 태그가 **정말 써지는가** — sqlite 를 실제로 들고 확인한다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 순수 시험으로는 부족한가 ─────────────────────────────────────────────
|
|
5
|
+
* 이 저장 경로는 읽어 온 엔티티를 펼쳐 `save` 한다(`{ ...figure, ...patch }`). 그 모양은 칸이
|
|
6
|
+
* **조용히 안 써지는** 자리로 알려져 있다 — `save` 는 정상 반환하고 로그도 성공이라고 말한다.
|
|
7
|
+
* 다른 레인이 도메인 시각대를 그렇게 잃었고, `select` 를 해 보기 전에는 못 찾았다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 여기서는 반환값을 믿지 않고 **저장소에서 다시 읽는다.**
|
|
10
|
+
*
|
|
11
|
+
* ── 이 하네스로 볼 수 없는 것 ───────────────────────────────────────────────
|
|
12
|
+
* sqlite 다. 동시성·격리·컬럼 타입 방언은 여기서 초록이어도 근거가 아니다. `simple-json` 이
|
|
13
|
+
* 드라이버마다 어떻게 저장되는지도 postgres 에서 따로 봐야 한다 — 여기서 보는 것은 **왕복**이다.
|
|
14
|
+
*/
|
|
15
|
+
import type { DataSource } from 'typeorm'
|
|
16
|
+
|
|
17
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
18
|
+
const shell = require('@things-factory/shell')
|
|
19
|
+
/* Figure 가 creator·updater 로 User 를 가리킨다 — 그 엔티티가 없으면 메타데이터가 안 선다. */
|
|
20
|
+
const authBase = require('@things-factory/auth-base')
|
|
21
|
+
|
|
22
|
+
let dataSource: DataSource | null = null
|
|
23
|
+
let Figure: any
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
엔티티는 컴파일된 산출물에서 든다.
|
|
27
|
+
|
|
28
|
+
`@Field({ nullable: true })` 처럼 명시 타입이 없는 type-graphql 데코레이터는
|
|
29
|
+
`emitDecoratorMetadata` 가 있어야 읽히는데 jest 의 변환기는 babel 이라 그것을 못 낸다.
|
|
30
|
+
소스에서 들면 import 시점에 죽는다. auth-base 가 같은 이유로 같은 길을 간다(tests/compiled.ts).
|
|
31
|
+
*/
|
|
32
|
+
function loadCompiled(modulePath: string): any {
|
|
33
|
+
const { existsSync } = require('fs')
|
|
34
|
+
const { join } = require('path')
|
|
35
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
36
|
+
|
|
37
|
+
if (!existsSync(resolved)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`figure-service 산출물이 없다: ${resolved}\n` +
|
|
40
|
+
`이 시험은 tsc 산출물을 든다. 먼저 빌드할 것:\n` +
|
|
41
|
+
` yarn workspace @things-factory/figure-service build:server`
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return require(resolved)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
50
|
+
|
|
51
|
+
dataSource = new TypeOrmDataSource({
|
|
52
|
+
type: 'sqlite',
|
|
53
|
+
database: ':memory:',
|
|
54
|
+
synchronize: true,
|
|
55
|
+
dropSchema: true,
|
|
56
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
57
|
+
entities: [...shell.entities, ...authBase.entities, Figure],
|
|
58
|
+
logging: false
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await dataSource.initialize()
|
|
62
|
+
shell.addDataSource('default', dataSource)
|
|
63
|
+
shell.addDataSource('tx', dataSource)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
await dataSource!.synchronize(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
afterAll(async () => {
|
|
71
|
+
if (!dataSource) return
|
|
72
|
+
shell.removeDataSource('default')
|
|
73
|
+
shell.removeDataSource('tx')
|
|
74
|
+
await dataSource.destroy()
|
|
75
|
+
dataSource = null
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/** 저장소에서 **다시 읽은** 것. 반환값이 아니라 이것이 근거다. */
|
|
79
|
+
async function readBack(id: string) {
|
|
80
|
+
return await dataSource!.getRepository(Figure).findOne({ where: { id } })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function seed(over: Record<string, unknown> = {}) {
|
|
84
|
+
const repository = dataSource!.getRepository(Figure)
|
|
85
|
+
return await repository.save({
|
|
86
|
+
type: 'CONVEYOR',
|
|
87
|
+
name: '컨베이어',
|
|
88
|
+
source: '{}',
|
|
89
|
+
state: 'draft',
|
|
90
|
+
...over
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('태그가 정말 써진다', () => {
|
|
95
|
+
it('여럿 붙고, 다시 읽어도 그대로다', async () => {
|
|
96
|
+
const saved = await seed({ tags: ['반출', '자동'] })
|
|
97
|
+
const read = await readBack(saved.id)
|
|
98
|
+
|
|
99
|
+
expect(read!.tags).toEqual(['반출', '자동'])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
이 저장 경로의 모양이다 — 읽어 온 것을 펼쳐 새 값을 얹는다. 여기서 칸이 조용히 빠지는 것이
|
|
104
|
+
기억에 남은 그 함정이다.
|
|
105
|
+
*/
|
|
106
|
+
it('읽어 온 것을 펼쳐 얹어도 써진다 — save 가 그 칸을 흘리지 않는다', async () => {
|
|
107
|
+
const saved = await seed({ tags: ['반출'] })
|
|
108
|
+
const before = await readBack(saved.id)
|
|
109
|
+
|
|
110
|
+
await dataSource!.getRepository(Figure).save({ ...before, tags: ['반출', '2층'] })
|
|
111
|
+
|
|
112
|
+
expect((await readBack(saved.id))!.tags).toEqual(['반출', '2층'])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('빈 배열은 전부 지운다 — 지울 방법이 있어야 한다', async () => {
|
|
116
|
+
const saved = await seed({ tags: ['반출'] })
|
|
117
|
+
const before = await readBack(saved.id)
|
|
118
|
+
|
|
119
|
+
await dataSource!.getRepository(Figure).save({ ...before, tags: [] })
|
|
120
|
+
|
|
121
|
+
expect((await readBack(saved.id))!.tags).toEqual([])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('안 준 도형은 태그가 없다 — 빈 배열로 갈음하지 않는다', async () => {
|
|
125
|
+
const saved = await seed()
|
|
126
|
+
|
|
127
|
+
expect((await readBack(saved.id))!.tags).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('분류는 하나다', () => {
|
|
132
|
+
it('써지고 다시 읽힌다', async () => {
|
|
133
|
+
const saved = await seed({ category: '컨베이어' })
|
|
134
|
+
|
|
135
|
+
expect((await readBack(saved.id))!.category).toBe('컨베이어')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('분류와 태그는 서로 손대지 않는다', async () => {
|
|
139
|
+
const saved = await seed({ category: '컨베이어', tags: ['반출'] })
|
|
140
|
+
const before = await readBack(saved.id)
|
|
141
|
+
|
|
142
|
+
await dataSource!.getRepository(Figure).save({ ...before, tags: ['반출', '자동'] })
|
|
143
|
+
const read = await readBack(saved.id)
|
|
144
|
+
|
|
145
|
+
expect(read!.category).toBe('컨베이어')
|
|
146
|
+
expect(read!.tags).toEqual(['반출', '자동'])
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('★ 이 시험이 실제로 DB 를 읽고 있다', () => {
|
|
151
|
+
it('없는 것을 물으면 없다고 한다 — 늘 초록이 아니다', async () => {
|
|
152
|
+
expect(await readBack('없는-id')).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('표가 실제로 서 있다', async () => {
|
|
156
|
+
const saved = await seed()
|
|
157
|
+
const rows = await dataSource!.query('select count(*) as n from figures')
|
|
158
|
+
|
|
159
|
+
expect(Number(rows[0].n)).toBe(1)
|
|
160
|
+
expect(saved.id).toBeTruthy()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figure AI의 결정적 종단 흐름.
|
|
3
|
+
*
|
|
4
|
+
* 외부 공급자·DB를 띄우지 않되, 도크 host context → 도구 → 실제 후보 생성/검사 → 후보 카드라는
|
|
5
|
+
* 경계를 한 번에 지난다. 단위 시험만으로는 피드백이 도구 입력에서 끊기거나, 검증된 후보가 카드
|
|
6
|
+
* 표시를 잃는 연결 결함을 놓칠 수 있다.
|
|
7
|
+
*/
|
|
8
|
+
import { clearToolRegistry, getToolCategories, setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
9
|
+
import type { AIClient } from '@things-factory/ai-client-base'
|
|
10
|
+
|
|
11
|
+
import { __setFigureToolDepsForTest, registerFigureToolCategories } from './figure-tools'
|
|
12
|
+
import { proposeFigure } from './figure-propose'
|
|
13
|
+
|
|
14
|
+
const DOMAIN = { id: 'domain-mine' }
|
|
15
|
+
const SOURCE = {
|
|
16
|
+
type: 'MIXER', base: { x: 300, y: 500, z: 300 }, detailLevel: 'M',
|
|
17
|
+
parts: [{
|
|
18
|
+
name: 'body', primitive: 'cylinder', segments: 12,
|
|
19
|
+
transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 300, y: 400, z: 300 } },
|
|
20
|
+
material: { token: 'palette.primary' }
|
|
21
|
+
}]
|
|
22
|
+
}
|
|
23
|
+
const ROW = { id: 'fig-1', type: 'MIXER', name: '믹서', state: 'draft', domain: DOMAIN, source: JSON.stringify(SOURCE) }
|
|
24
|
+
|
|
25
|
+
function client(): AIClient & { sent: any[][] } {
|
|
26
|
+
const fake = {
|
|
27
|
+
id: 'deterministic-figure-flow',
|
|
28
|
+
sent: [] as any[][],
|
|
29
|
+
async chat() { return '' },
|
|
30
|
+
async generateJSON(messages: any[]) {
|
|
31
|
+
fake.sent.push(JSON.parse(JSON.stringify(messages)))
|
|
32
|
+
return SOURCE
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return fake as unknown as AIClient & { sent: any[][] }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
clearToolRegistry()
|
|
40
|
+
const model = require('@hatiolab/figure-model')
|
|
41
|
+
__setFigureToolDepsForTest({
|
|
42
|
+
getRepository: () => ({
|
|
43
|
+
async findOne({ where }: any) { return where.id === ROW.id && where.domain?.id === DOMAIN.id ? ROW : null },
|
|
44
|
+
async find({ where }: any) { return where.domain?.id === DOMAIN.id ? [ROW] : [] }
|
|
45
|
+
}),
|
|
46
|
+
Figure: class {},
|
|
47
|
+
inspectSource: () => ({ blocked: false, findings: [] }),
|
|
48
|
+
proposeFigure,
|
|
49
|
+
compile: model.compile,
|
|
50
|
+
costOf: model.costOf,
|
|
51
|
+
scoreOf: model.scoreOf,
|
|
52
|
+
validate: model.validate
|
|
53
|
+
})
|
|
54
|
+
registerFigureToolCategories()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
clearToolRegistry()
|
|
59
|
+
setDefaultAIClient(undefined)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('초안·세션 피드백을 반영한 후보가 검증·품질 근거와 카드 표시를 거쳐 돌아온다', async () => {
|
|
63
|
+
const fake = client()
|
|
64
|
+
setDefaultAIClient(fake)
|
|
65
|
+
const propose = getToolCategories()
|
|
66
|
+
.find((category: any) => category.name === 'figure-authoring')!
|
|
67
|
+
.specs.find((spec: any) => spec.name === 'proposeFigureCandidate') as any
|
|
68
|
+
|
|
69
|
+
const result = await propose.builder(
|
|
70
|
+
{ prompt: '포트가 더 잘 읽히도록 고쳐줘' },
|
|
71
|
+
{
|
|
72
|
+
state: {
|
|
73
|
+
domain: DOMAIN,
|
|
74
|
+
host: {
|
|
75
|
+
figureId: 'fig-1',
|
|
76
|
+
palette: ['palette.primary'],
|
|
77
|
+
draftSource: SOURCE,
|
|
78
|
+
proposalFeedback: [{
|
|
79
|
+
outcome: 'discarded', selectedChanges: 0, totalChanges: 3,
|
|
80
|
+
note: '실루엣은 유지하고 포트의 대비를 높여줘',
|
|
81
|
+
quality: { status: 'review', findingCodes: ['top-view-readability'] }
|
|
82
|
+
}]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expect(fake.sent).toHaveLength(1)
|
|
89
|
+
expect(fake.sent[0]![0]!.content).toContain('Revise this existing figure')
|
|
90
|
+
expect(fake.sent[0]![0]!.content).toContain('실루엣은 유지하고 포트의 대비를 높여줘')
|
|
91
|
+
expect(result.proposed).toBe(true)
|
|
92
|
+
expect(result.source.type).toBe('MIXER')
|
|
93
|
+
expect(result.quality).toEqual(expect.objectContaining({ status: expect.stringMatching(/^(ready|review)$/) }))
|
|
94
|
+
expect(result.cost.triangles).toBeGreaterThan(0)
|
|
95
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { inspectSource } from './figure-gate'
|
|
2
|
+
|
|
3
|
+
describe('release-gate repair candidates', () => {
|
|
4
|
+
it('returns data, not parsed prose, for a measured sizing split', () => {
|
|
5
|
+
const source = {
|
|
6
|
+
type: 'STACK',
|
|
7
|
+
base: { x: 100, y: 200, z: 100 },
|
|
8
|
+
detailLevel: 'M',
|
|
9
|
+
parts: [
|
|
10
|
+
{
|
|
11
|
+
name: 'foot', primitive: 'cube',
|
|
12
|
+
transform: { position: { x: 0, y: -50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
13
|
+
material: { token: 'palette.primary' }, anchor: { y: 'min' }
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'lamp', primitive: 'cube',
|
|
17
|
+
transform: { position: { x: 0, y: 10, z: 0 }, size: { x: 60, y: 20, z: 60 } },
|
|
18
|
+
material: { token: 'palette.accent' }, anchor: { y: 'max' }
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const inspection = inspectSource(JSON.stringify(source))
|
|
24
|
+
const split = inspection.findings.find(finding => finding.code === 'sizing-splits')
|
|
25
|
+
|
|
26
|
+
expect(inspection.blocked).toBe(true)
|
|
27
|
+
expect(split?.fixes).toEqual(expect.arrayContaining([
|
|
28
|
+
expect.objectContaining({ part: expect.any(String), axis: 'y', to: expect.any(String), resolved: expect.any(Number), safe: expect.any(Boolean) })
|
|
29
|
+
]))
|
|
30
|
+
expect(split?.fixes?.every(fix => fix.resolved > 0)).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { compile, sizingReport, validate } from '@hatiolab/figure-model'
|
|
2
|
-
import type { FigureSource } from '@hatiolab/figure-model'
|
|
2
|
+
import type { Axis, FigureSource } from '@hatiolab/figure-model'
|
|
3
|
+
|
|
4
|
+
/** Service transport shape. It deliberately contains only an explicit, reversible FigureSource edit. */
|
|
5
|
+
export interface GateFix {
|
|
6
|
+
findingWay: string
|
|
7
|
+
part: string
|
|
8
|
+
axis: Axis
|
|
9
|
+
from?: string
|
|
10
|
+
to: 'min' | 'center' | 'max' | 'span'
|
|
11
|
+
resolved: number
|
|
12
|
+
/** True only when rechecking every sizing case leaves no release-blocking split. */
|
|
13
|
+
safe: boolean
|
|
14
|
+
}
|
|
3
15
|
|
|
4
16
|
/**
|
|
5
17
|
* 발행해도 되나 — **서버가 판정한다.**
|
|
@@ -43,6 +55,12 @@ export interface GateFinding {
|
|
|
43
55
|
how?: string
|
|
44
56
|
/** 어디를 가리키나 — 갈라짐은 갈라진 부품 이름들, 형식 위반은 정본 안의 경로. */
|
|
45
57
|
at?: string
|
|
58
|
+
/**
|
|
59
|
+
* 실제로 재 본, 되돌릴 수 있는 한 앵커 수정안들. 문장을 파싱해서 고치는 것이 아니라
|
|
60
|
+
* FigureSource 에 적용할 정확한 값만 내보낸다. 화면은 이것을 후보로만 보여 주고 저작자가
|
|
61
|
+
* 수락하기 전에는 정본을 바꾸지 않는다.
|
|
62
|
+
*/
|
|
63
|
+
fixes?: GateFix[]
|
|
46
64
|
blocking: boolean
|
|
47
65
|
}
|
|
48
66
|
|
|
@@ -52,6 +70,54 @@ export interface Inspection {
|
|
|
52
70
|
blocked: boolean
|
|
53
71
|
}
|
|
54
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Figure model publishes the same capability for direct model consumers. Keep the service calculation
|
|
75
|
+
* here as well because this package is deployed independently and receives a compiled local model package.
|
|
76
|
+
* Each alternative is remeasured; prose from `how` is never parsed into an edit.
|
|
77
|
+
*/
|
|
78
|
+
function measuredFixes(source: FigureSource, factor: number): GateFix[] {
|
|
79
|
+
const baseline = sizingReport(compile(source), factor)
|
|
80
|
+
const baselineCount = baseline.findings.filter(finding => finding.code === 'sizing-splits').length
|
|
81
|
+
const seen = new Set<string>()
|
|
82
|
+
const fixes: GateFix[] = []
|
|
83
|
+
|
|
84
|
+
for (const finding of baseline.findings) {
|
|
85
|
+
if (finding.code !== 'sizing-splits' || !finding.way) continue
|
|
86
|
+
const axes = finding.way.split('-').filter((axis): axis is Axis => ['x', 'y', 'z'].includes(axis))
|
|
87
|
+
const names = new Set(finding.clumps.flat())
|
|
88
|
+
|
|
89
|
+
for (const part of source.parts) {
|
|
90
|
+
if (!names.has(part.name)) continue
|
|
91
|
+
for (const axis of axes) {
|
|
92
|
+
for (const to of ['min', 'center', 'max', 'span'] as const) {
|
|
93
|
+
if (part.anchor?.[axis] === to) continue
|
|
94
|
+
const changed: FigureSource = {
|
|
95
|
+
...source,
|
|
96
|
+
parts: source.parts.map(one => one.name === part.name ? { ...one, anchor: { ...(one.anchor ?? {}), [axis]: to } } : one)
|
|
97
|
+
}
|
|
98
|
+
const afterCount = sizingReport(compile(changed), factor).findings.filter(one => one.code === 'sizing-splits').length
|
|
99
|
+
const resolved = baselineCount - afterCount
|
|
100
|
+
const key = `${finding.way}:${part.name}:${axis}:${to}`
|
|
101
|
+
if (resolved <= 0 || seen.has(key)) continue
|
|
102
|
+
seen.add(key)
|
|
103
|
+
fixes.push({
|
|
104
|
+
findingWay: finding.way,
|
|
105
|
+
part: part.name,
|
|
106
|
+
axis,
|
|
107
|
+
from: part.anchor?.[axis],
|
|
108
|
+
to,
|
|
109
|
+
resolved,
|
|
110
|
+
/* 한 finding만 없앤 것이 아니라 전수 검사에서 막는 split이 0일 때만 원클릭 후보가 된다. */
|
|
111
|
+
safe: afterCount === 0
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return fixes.sort((a, b) => b.resolved - a.resolved || a.part.localeCompare(b.part)).slice(0, 12)
|
|
119
|
+
}
|
|
120
|
+
|
|
55
121
|
/**
|
|
56
122
|
* 정본 하나를 판정한다.
|
|
57
123
|
*
|
|
@@ -99,7 +165,9 @@ export function inspectSource(sourceJson?: string | null): Inspection {
|
|
|
99
165
|
}
|
|
100
166
|
|
|
101
167
|
if (result.errors.length === 0) {
|
|
102
|
-
const
|
|
168
|
+
const source = parsed as FigureSource
|
|
169
|
+
const report = sizingReport(compile(source))
|
|
170
|
+
const fixes = measuredFixes(source, report.factor)
|
|
103
171
|
|
|
104
172
|
for (const finding of report.findings) {
|
|
105
173
|
findings.push({
|
|
@@ -110,6 +178,7 @@ export function inspectSource(sourceJson?: string | null): Inspection {
|
|
|
110
178
|
how: finding.how,
|
|
111
179
|
/* 숫자가 아니라 자리를 말한다 — 어느 부품들이 갈라져 나왔나. */
|
|
112
180
|
at: finding.clumps.map(clump => [...clump].sort().join('+')).join(' | ') || undefined,
|
|
181
|
+
fixes: finding.code === 'sizing-splits' ? fixes.filter(fix => fix.findingWay === finding.way) : undefined,
|
|
113
182
|
blocking: finding.code === 'sizing-splits'
|
|
114
183
|
})
|
|
115
184
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 실제 LLM Figure 후보 생성 smoke test (기본 비활성).
|
|
3
|
+
*
|
|
4
|
+
* 결정적 시험은 프롬프트·도구·검사 배선을 고정하지만, 실제 모델이 FigureSource JSON을 지키는지는
|
|
5
|
+
* 대신할 수 없다. 이 시험은 `RUN_FIGURE_LLM_SMOKE=1`과 공급자 API 키가 모두 있을 때만 실행한다.
|
|
6
|
+
* DB·GraphQL·Figure 저장을 건드리지 않고 후보 생성 함수만 호출하므로, 외부 영향은 API 비용뿐이다.
|
|
7
|
+
*
|
|
8
|
+
* 실행:
|
|
9
|
+
* RUN_FIGURE_LLM_SMOKE=1 OPENAI_API_KEY=... yarn test:figure-llm-smoke
|
|
10
|
+
* 선택: FIGURE_LLM_PROVIDER=anthropic|gemini|openai, FIGURE_LLM_MODEL=<model id>
|
|
11
|
+
*/
|
|
12
|
+
import { createAIClient, setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
13
|
+
|
|
14
|
+
import { proposeFigure } from './figure-propose'
|
|
15
|
+
|
|
16
|
+
// Provider modules register themselves on import. Keep this separate from the deterministic tests.
|
|
17
|
+
import '@things-factory/ai-client-base'
|
|
18
|
+
|
|
19
|
+
type Provider = 'anthropic' | 'gemini' | 'openai'
|
|
20
|
+
|
|
21
|
+
function provider(): { provider: Provider; apiKey: string } | undefined {
|
|
22
|
+
const requested = process.env.FIGURE_LLM_PROVIDER as Provider | undefined
|
|
23
|
+
const candidates: Array<{ provider: Provider; keys: string[] }> = [
|
|
24
|
+
{ provider: 'anthropic', keys: ['ANTHROPIC_API_KEY'] },
|
|
25
|
+
{ provider: 'gemini', keys: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] },
|
|
26
|
+
{ provider: 'openai', keys: ['OPENAI_API_KEY'] }
|
|
27
|
+
]
|
|
28
|
+
const usable = requested ? candidates.filter(candidate => candidate.provider === requested) : candidates
|
|
29
|
+
for (const candidate of usable) {
|
|
30
|
+
const apiKey = candidate.keys.map(key => process.env[key]).find(Boolean)
|
|
31
|
+
if (apiKey) return { provider: candidate.provider, apiKey }
|
|
32
|
+
}
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const picked = process.env.RUN_FIGURE_LLM_SMOKE === '1' ? provider() : undefined
|
|
37
|
+
const describeIf = picked ? describe : describe.skip
|
|
38
|
+
const PALETTE = ['palette.primary', 'palette.accent', 'palette.neutral']
|
|
39
|
+
|
|
40
|
+
describeIf('Figure 실제 LLM smoke', () => {
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
setDefaultAIClient(createAIClient({ ...picked!, ...(process.env.FIGURE_LLM_MODEL ? { model: process.env.FIGURE_LLM_MODEL } : {}) }))
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.log(`[figure-llm-smoke] provider=${picked!.provider} model=${process.env.FIGURE_LLM_MODEL ?? '(adapter default)'}`)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterAll(() => setDefaultAIClient(undefined))
|
|
48
|
+
|
|
49
|
+
test(
|
|
50
|
+
'새 Figure 후보는 유효한 어휘·팔레트·품질 근거를 가진다',
|
|
51
|
+
async () => {
|
|
52
|
+
const result = await proposeFigure({
|
|
53
|
+
prompt: 'two cylindrical rollers inside a simple rectangular press frame; make the rollers visually distinct',
|
|
54
|
+
type: 'ROLL_PRESS_SMOKE',
|
|
55
|
+
palette: PALETTE,
|
|
56
|
+
refine: true
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(result.source.type).toBe('ROLL_PRESS_SMOKE')
|
|
60
|
+
expect(result.source.parts.length).toBeGreaterThan(0)
|
|
61
|
+
expect(result.source.parts.every(part => PALETTE.includes(part.material.token))).toBe(true)
|
|
62
|
+
expect(result.quality.status).toMatch(/^(ready|review)$/)
|
|
63
|
+
expect(result.cost.triangles).toBeGreaterThan(0)
|
|
64
|
+
},
|
|
65
|
+
90_000
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
test(
|
|
69
|
+
'수정 후보는 type을 보존하고 명시적 세션 피드백을 참고한다',
|
|
70
|
+
async () => {
|
|
71
|
+
const result = await proposeFigure({
|
|
72
|
+
prompt: 'make the port more visible from above while keeping the body and port part names',
|
|
73
|
+
palette: PALETTE,
|
|
74
|
+
refine: true,
|
|
75
|
+
base: {
|
|
76
|
+
type: 'MIXER_SMOKE', base: { x: 300, y: 400, z: 300 }, detailLevel: 'M',
|
|
77
|
+
parts: [
|
|
78
|
+
{ name: 'body', primitive: 'cylinder', segments: 12, transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 280, y: 320, z: 280 } }, material: { token: 'palette.primary' } },
|
|
79
|
+
{ name: 'port', primitive: 'cube', transform: { position: { x: 0, y: 30, z: -155 }, size: { x: 80, y: 60, z: 30 } }, material: { token: 'palette.accent' } }
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
feedback: [{ outcome: 'discarded', selectedChanges: 0, totalChanges: 2, note: 'the port must read clearly in top view' }]
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(result.source.type).toBe('MIXER_SMOKE')
|
|
86
|
+
expect(result.source.parts.map(part => part.name)).toEqual(expect.arrayContaining(['body', 'port']))
|
|
87
|
+
expect(result.quality).toBeDefined()
|
|
88
|
+
},
|
|
89
|
+
90_000
|
|
90
|
+
)
|
|
91
|
+
})
|
|
@@ -318,6 +318,8 @@ export class FigureMutation {
|
|
|
318
318
|
base: request.base ? JSON.parse(request.base) : undefined,
|
|
319
319
|
type: request.type,
|
|
320
320
|
palette: request.palette,
|
|
321
|
+
refine: request.refine,
|
|
322
|
+
feedback: request.feedback,
|
|
321
323
|
image
|
|
322
324
|
})
|
|
323
325
|
|
|
@@ -327,6 +329,12 @@ export class FigureMutation {
|
|
|
327
329
|
grade: result.score.grade,
|
|
328
330
|
triangles: result.cost.triangles,
|
|
329
331
|
groups: result.cost.groups,
|
|
332
|
+
quality: {
|
|
333
|
+
...result.quality,
|
|
334
|
+
visualRegions: result.quality.visual.regions,
|
|
335
|
+
visualCoverage: result.quality.visual.coverage,
|
|
336
|
+
visualReadability: result.quality.visual.readability
|
|
337
|
+
},
|
|
330
338
|
violations: result.violations,
|
|
331
339
|
attempts: result.attempts
|
|
332
340
|
}
|
|
@@ -4,6 +4,72 @@ import { Field, InputType, Int, ObjectType } from 'type-graphql'
|
|
|
4
4
|
|
|
5
5
|
import { FigureFinding } from './figure-type.js'
|
|
6
6
|
|
|
7
|
+
@ObjectType({ description: 'One explainable consideration from the model-grounded candidate review.' })
|
|
8
|
+
export class FigureQualityFinding {
|
|
9
|
+
@Field({ description: 'What the consideration concerns: identity, silhouette, palette, performance, or resilience.' })
|
|
10
|
+
dimension: string
|
|
11
|
+
|
|
12
|
+
@Field({ description: 'Stable machine-readable review code.' })
|
|
13
|
+
code: string
|
|
14
|
+
|
|
15
|
+
@Field({ description: 'Human-readable evidence and recommended review.' })
|
|
16
|
+
message: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@ObjectType({ description: 'Explainable design review based on Figure-model evidence, not a visual aesthetic claim.' })
|
|
20
|
+
export class FigureQualityReview {
|
|
21
|
+
@Field({ description: 'ready when measurable checks are clear; review when author attention is recommended.' })
|
|
22
|
+
status: string
|
|
23
|
+
|
|
24
|
+
@Field({ description: 'Compact explanation of this review.' })
|
|
25
|
+
summary: string
|
|
26
|
+
|
|
27
|
+
@Field(type => [FigureQualityFinding], { description: 'Measured considerations for the author and assistant.' })
|
|
28
|
+
findings: FigureQualityFinding[]
|
|
29
|
+
|
|
30
|
+
@Field(type => Int, { description: 'Visible material regions in the reproducible top-view evidence.' })
|
|
31
|
+
visualRegions: number
|
|
32
|
+
|
|
33
|
+
@Field({ description: 'Top-view footprint coverage from 0 to 1.' })
|
|
34
|
+
visualCoverage: number
|
|
35
|
+
|
|
36
|
+
@Field({ description: 'Normalized top-view readability evidence from 0 to 1.' })
|
|
37
|
+
visualReadability: number
|
|
38
|
+
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 제한된 후보 품질 코드. 현재 요청의 참고 데이터이며 어떤 Figure에도 저장되지 않는다. */
|
|
42
|
+
@InputType({ description: 'Measured review summary from an earlier candidate in this authoring session.' })
|
|
43
|
+
export class ProposalFeedbackQualityInput {
|
|
44
|
+
@Field({ nullable: true })
|
|
45
|
+
status?: string
|
|
46
|
+
|
|
47
|
+
@Field(type => [String], { nullable: true })
|
|
48
|
+
findingCodes?: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 사람이 명시적으로 수락하거나 거절한 후보의 제한된 세션 반응. */
|
|
52
|
+
@InputType({ description: 'Explicit author feedback for the next candidate. Session-only; never persisted.' })
|
|
53
|
+
export class ProposalFeedbackInput {
|
|
54
|
+
@Field()
|
|
55
|
+
outcome: string
|
|
56
|
+
|
|
57
|
+
@Field(type => Int)
|
|
58
|
+
selectedChanges: number
|
|
59
|
+
|
|
60
|
+
@Field(type => Int)
|
|
61
|
+
totalChanges: number
|
|
62
|
+
|
|
63
|
+
@Field({ nullable: true })
|
|
64
|
+
note?: string
|
|
65
|
+
|
|
66
|
+
@Field({ nullable: true })
|
|
67
|
+
grade?: string
|
|
68
|
+
|
|
69
|
+
@Field(type => ProposalFeedbackQualityInput, { nullable: true })
|
|
70
|
+
quality?: ProposalFeedbackQualityInput
|
|
71
|
+
}
|
|
72
|
+
|
|
7
73
|
/**
|
|
8
74
|
* 저작 보조에 넘기는 것.
|
|
9
75
|
*
|
|
@@ -39,6 +105,15 @@ export class ProposeRequest {
|
|
|
39
105
|
|
|
40
106
|
@Field(type => [String], { description: 'Palette tokens the client can resolve. Anything else is rejected.' })
|
|
41
107
|
palette: string[]
|
|
108
|
+
|
|
109
|
+
@Field({ nullable: true, description: 'Ask for one evidence-grounded refinement when the first valid candidate needs review.' })
|
|
110
|
+
refine?: boolean
|
|
111
|
+
|
|
112
|
+
@Field(type => [ProposalFeedbackInput], {
|
|
113
|
+
nullable: true,
|
|
114
|
+
description: 'Explicit session-only feedback on earlier candidates. It is constrained again before prompting and never stored.'
|
|
115
|
+
})
|
|
116
|
+
feedback?: ProposalFeedbackInput[]
|
|
42
117
|
}
|
|
43
118
|
|
|
44
119
|
/**
|
|
@@ -64,6 +139,11 @@ export class FigureProposal {
|
|
|
64
139
|
@Field(type => Int, { description: 'Material groups — the draw calls one instance costs' })
|
|
65
140
|
groups: number
|
|
66
141
|
|
|
142
|
+
@Field(type => FigureQualityReview, {
|
|
143
|
+
description: 'Explainable design review. It advises the author and never saves or rejects the candidate.'
|
|
144
|
+
})
|
|
145
|
+
quality: FigureQualityReview
|
|
146
|
+
|
|
67
147
|
@Field(type => [FigureFinding], {
|
|
68
148
|
description: 'Policy violations. These do not block the proposal — the author decides.'
|
|
69
149
|
})
|