@things-factory/figure-service 10.1.7 → 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-tools.js +55 -9
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +11 -0
- package/dist-server/service/figure/figure-type.js +43 -1
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- 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-tools.test.ts +62 -1
- package/server/service/figure/figure-tools.ts +60 -8
- package/server/service/figure/figure-type.ts +31 -0
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.
|
|
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
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,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
|
})
|
|
@@ -131,6 +131,14 @@ describe('후보를 받아 볼 때', () => {
|
|
|
131
131
|
expect(looked.reasons[0]).toContain('palette.brand')
|
|
132
132
|
expect(looked.reasons[0]).toContain('palette.primary')
|
|
133
133
|
})
|
|
134
|
+
|
|
135
|
+
it('요청한 저장 식별자와 다른 type을 지어내면 막는다', () => {
|
|
136
|
+
const looked = inspect(candidate({ type: 'FORKLIFT' }), PALETTE, 'MIXER')
|
|
137
|
+
|
|
138
|
+
expect(looked.source).toBeUndefined()
|
|
139
|
+
expect(looked.reasons).toEqual([expect.stringContaining('"MIXER"')])
|
|
140
|
+
expect(looked.reasons[0]).toContain('"FORKLIFT"')
|
|
141
|
+
})
|
|
134
142
|
})
|
|
135
143
|
|
|
136
144
|
describe('후보를 만들 때', () => {
|
|
@@ -143,6 +151,52 @@ describe('후보를 만들 때', () => {
|
|
|
143
151
|
expect(result.source.parts).toHaveLength(1)
|
|
144
152
|
expect(result.score.grade).toMatch(/^[ABCD]$/)
|
|
145
153
|
expect(result.cost.triangles).toBeGreaterThan(0)
|
|
154
|
+
expect(result.quality.status).toMatch(/^(ready|review)$/)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('요청하면 측정된 품질 근거를 다음 후보 개선에 돌려준다', async () => {
|
|
158
|
+
const generic = candidate({ parts: [
|
|
159
|
+
{ name: 'part-1', primitive: 'cube', transform: { position: { x: -100, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
160
|
+
{ name: 'part-2', primitive: 'cube', transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
161
|
+
{ name: 'part-3', primitive: 'cube', transform: { position: { x: 100, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
162
|
+
{ name: 'part-4', primitive: 'cube', transform: { position: { x: 150, y: 0, z: 0 }, size: { x: 50, y: 100, z: 100 } }, material: { token: 'palette.primary' } }
|
|
163
|
+
] })
|
|
164
|
+
const refined = candidate({ parts: [{
|
|
165
|
+
name: 'vessel', primitive: 'cylinder', segments: 12,
|
|
166
|
+
transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 280, y: 400, z: 280 } }, material: { token: 'palette.primary' }
|
|
167
|
+
}] })
|
|
168
|
+
const client = stubClient([generic, refined])
|
|
169
|
+
setDefaultAIClient(client)
|
|
170
|
+
|
|
171
|
+
const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER', refine: true })
|
|
172
|
+
|
|
173
|
+
expect(result.attempts).toBe(2)
|
|
174
|
+
expect(result.source.parts[0]!.name).toBe('vessel')
|
|
175
|
+
expect(client.sent[1]![2]!.content).toContain('evidence-grounded refinement')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('명시적 세션 피드백은 제한된 참고 데이터로 다음 후보 요청에 들어간다', async () => {
|
|
179
|
+
const client = stubClient([candidate()])
|
|
180
|
+
setDefaultAIClient(client)
|
|
181
|
+
|
|
182
|
+
await proposeFigure({
|
|
183
|
+
prompt: '포트가 더 눈에 띄게',
|
|
184
|
+
palette: PALETTE,
|
|
185
|
+
type: 'MIXER',
|
|
186
|
+
feedback: [
|
|
187
|
+
{
|
|
188
|
+
outcome: 'discarded', selectedChanges: 0, totalChanges: 4,
|
|
189
|
+
note: '실루엣은 유지하고 포트만 더 잘 보이게',
|
|
190
|
+
quality: { status: 'review', findingCodes: ['generic-part-name', 'bad code!'] }
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const ask = firstAsk(client)
|
|
196
|
+
expect(ask).toContain('session-only author feedback')
|
|
197
|
+
expect(ask).toContain('실루엣은 유지하고 포트만 더 잘 보이게')
|
|
198
|
+
expect(ask).toContain('generic-part-name')
|
|
199
|
+
expect(ask).not.toContain('bad code!')
|
|
146
200
|
})
|
|
147
201
|
|
|
148
202
|
it('틀리면 사유를 돌려주고 다시 시킨다', async () => {
|
|
@@ -155,6 +209,27 @@ describe('후보를 만들 때', () => {
|
|
|
155
209
|
expect(client.calls).toBe(2)
|
|
156
210
|
})
|
|
157
211
|
|
|
212
|
+
it('다른 type을 낸 후보도 다시 시킨다 — Figure 행과 source가 갈라지지 않게', async () => {
|
|
213
|
+
const client = stubClient([candidate({ type: 'FORKLIFT' }), candidate()])
|
|
214
|
+
setDefaultAIClient(client)
|
|
215
|
+
|
|
216
|
+
const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
|
|
217
|
+
|
|
218
|
+
expect(result.attempts).toBe(2)
|
|
219
|
+
expect(result.source.type).toBe('MIXER')
|
|
220
|
+
expect(client.sent[1]![2]!.content).toContain('The figure type must remain "MIXER"')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('수정 후보는 원래 type을 바꿀 수 없다', async () => {
|
|
224
|
+
const client = stubClient([candidate({ type: 'FORKLIFT' }), candidate({ type: 'MIXER' })])
|
|
225
|
+
setDefaultAIClient(client)
|
|
226
|
+
|
|
227
|
+
const result = await proposeFigure({ prompt: '재질을 줄여줘', palette: PALETTE, base: candidate() as never })
|
|
228
|
+
|
|
229
|
+
expect(result.attempts).toBe(2)
|
|
230
|
+
expect(result.source.type).toBe('MIXER')
|
|
231
|
+
})
|
|
232
|
+
|
|
158
233
|
it('끝내 못 만들면 못 만들었다고 말한다 — 반쯤 만든 것을 내밀지 않는다', async () => {
|
|
159
234
|
setDefaultAIClient(stubClient([candidate({ parts: [] }), candidate({ parts: [] }), candidate({ parts: [] })]))
|
|
160
235
|
|
|
@@ -195,6 +270,8 @@ describe('무엇을 시키는지가 프롬프트에 실린다', () => {
|
|
|
195
270
|
const ask = firstAsk(client)
|
|
196
271
|
expect(ask).toContain('Revise this existing figure')
|
|
197
272
|
expect(ask).toMatch(/renaming a part breaks bindings/)
|
|
273
|
+
expect(ask).toContain('Measured evidence for the existing figure')
|
|
274
|
+
expect(ask).toContain('top-view readability')
|
|
198
275
|
// 지금 정본이 실제로 실려 나가야 한다 — 없으면 모델이 처음부터 만든다.
|
|
199
276
|
expect(ask).toContain('"name":"body"')
|
|
200
277
|
})
|