@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.
Files changed (42) hide show
  1. package/dist-server/service/figure/figure-gate.d.ts +18 -0
  2. package/dist-server/service/figure/figure-gate.js +52 -1
  3. package/dist-server/service/figure/figure-gate.js.map +1 -1
  4. package/dist-server/service/figure/figure-mutation.js +8 -0
  5. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  6. package/dist-server/service/figure/figure-propose-type.d.ts +30 -0
  7. package/dist-server/service/figure/figure-propose-type.js +112 -1
  8. package/dist-server/service/figure/figure-propose-type.js.map +1 -1
  9. package/dist-server/service/figure/figure-propose.d.ts +25 -1
  10. package/dist-server/service/figure/figure-propose.js +102 -4
  11. package/dist-server/service/figure/figure-propose.js.map +1 -1
  12. package/dist-server/service/figure/figure-quality.d.ts +29 -0
  13. package/dist-server/service/figure/figure-quality.js +62 -0
  14. package/dist-server/service/figure/figure-quality.js.map +1 -0
  15. package/dist-server/service/figure/figure-query.js +8 -1
  16. package/dist-server/service/figure/figure-query.js.map +1 -1
  17. package/dist-server/service/figure/figure-tools.js +55 -9
  18. package/dist-server/service/figure/figure-tools.js.map +1 -1
  19. package/dist-server/service/figure/figure-type.d.ts +13 -0
  20. package/dist-server/service/figure/figure-type.js +51 -1
  21. package/dist-server/service/figure/figure-type.js.map +1 -1
  22. package/dist-server/service/figure/figure.d.ts +17 -1
  23. package/dist-server/service/figure/figure.js +6 -1
  24. package/dist-server/service/figure/figure.js.map +1 -1
  25. package/dist-server/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +6 -6
  27. package/server/service/figure/catalog-db.test.ts +162 -0
  28. package/server/service/figure/figure-ai-flow.test.ts +95 -0
  29. package/server/service/figure/figure-gate.test.ts +32 -0
  30. package/server/service/figure/figure-gate.ts +71 -2
  31. package/server/service/figure/figure-llm-smoke.test.ts +91 -0
  32. package/server/service/figure/figure-mutation.ts +8 -0
  33. package/server/service/figure/figure-propose-type.ts +80 -0
  34. package/server/service/figure/figure-propose.test.ts +77 -0
  35. package/server/service/figure/figure-propose.ts +137 -4
  36. package/server/service/figure/figure-quality.test.ts +43 -0
  37. package/server/service/figure/figure-quality.ts +98 -0
  38. package/server/service/figure/figure-query.ts +8 -1
  39. package/server/service/figure/figure-tools.test.ts +62 -1
  40. package/server/service/figure/figure-tools.ts +60 -8
  41. package/server/service/figure/figure-type.ts +41 -0
  42. 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.6",
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": "^0.1.15",
26
+ "@hatiolab/figure-model": "file:../../../figure-model",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
- "@things-factory/ai-client-base": "^10.1.6",
29
- "@things-factory/auth-base": "^10.1.6",
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.4",
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": "963aeb9ac470be5aea05bb5842a0a92afcab6036"
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 report = sizingReport(compile(parsed as FigureSource))
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
  })