@things-factory/figure-service 10.1.7 → 10.1.9

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 (34) 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-tools.js +55 -9
  16. package/dist-server/service/figure/figure-tools.js.map +1 -1
  17. package/dist-server/service/figure/figure-type.d.ts +11 -0
  18. package/dist-server/service/figure/figure-type.js +43 -1
  19. package/dist-server/service/figure/figure-type.js.map +1 -1
  20. package/dist-server/tsconfig.tsbuildinfo +1 -1
  21. package/package.json +4 -4
  22. package/server/service/figure/figure-ai-flow.test.ts +95 -0
  23. package/server/service/figure/figure-gate.test.ts +32 -0
  24. package/server/service/figure/figure-gate.ts +71 -2
  25. package/server/service/figure/figure-llm-smoke.test.ts +91 -0
  26. package/server/service/figure/figure-mutation.ts +8 -0
  27. package/server/service/figure/figure-propose-type.ts +80 -0
  28. package/server/service/figure/figure-propose.test.ts +77 -0
  29. package/server/service/figure/figure-propose.ts +137 -4
  30. package/server/service/figure/figure-quality.test.ts +43 -0
  31. package/server/service/figure/figure-quality.ts +98 -0
  32. package/server/service/figure/figure-tools.test.ts +62 -1
  33. package/server/service/figure/figure-tools.ts +60 -8
  34. 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.7",
3
+ "version": "10.1.9",
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": "^0.1.16",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
- "@things-factory/ai-client-base": "^10.1.7",
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": "9a036f83f5751f9e4af9180e9a0018f716a4ba27"
35
+ "gitHead": "ccbb6cfee4f73360a863d17f58a6c4134221dae1"
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 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
  })
@@ -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
  })