@things-factory/figure-service 10.1.15 → 10.1.17
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 +11 -7
- package/dist-server/service/figure/figure-gate.js +6 -1
- package/dist-server/service/figure/figure-gate.js.map +1 -1
- package/dist-server/service/figure/figure-propose.js +79 -9
- package/dist-server/service/figure/figure-propose.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +12 -2
- package/dist-server/service/figure/figure-query.js +18 -2
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/figure/figure-blueprint-query.test.ts +29 -0
- package/server/service/figure/figure-e2e-smoke.test.ts +72 -0
- package/server/service/figure/figure-gate.test.ts +68 -0
- package/server/service/figure/figure-gate.ts +18 -9
- package/server/service/figure/figure-propose.test.ts +182 -1
- package/server/service/figure/figure-propose.ts +90 -8
- package/server/service/figure/figure-query.ts +18 -2
- package/server/service/figure/figure-source.test.ts +8 -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.17",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
"migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hatiolab/figure-model": "^0.1.
|
|
26
|
+
"@hatiolab/figure-model": "^0.1.18",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
28
|
"@things-factory/ai-client-base": "^10.1.8",
|
|
29
|
-
"@things-factory/auth-base": "^10.1.
|
|
29
|
+
"@things-factory/auth-base": "^10.1.17",
|
|
30
30
|
"@things-factory/env": "^10.1.3",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.16",
|
|
32
32
|
"graphql-scalars": "^1.22.4",
|
|
33
33
|
"graphql-upload": "^15.0.2",
|
|
34
34
|
"koa-router": "^7.4.0"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "0c8214182702162ceb5398bdcf42ef14ea672394"
|
|
37
37
|
}
|
|
@@ -175,4 +175,33 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위
|
|
|
175
175
|
expect(result.groups[0].members[0].name).toBe('box-1')
|
|
176
176
|
expect(result.groups[0].members[0].transform.size.x).toBe(100)
|
|
177
177
|
})
|
|
178
|
+
|
|
179
|
+
/*
|
|
180
|
+
Every test above calls the resolver method directly, so none of them passes through the
|
|
181
|
+
`@privilege` directive — that is attached when the schema is assembled. They stay green
|
|
182
|
+
whether the declaration is there or not.
|
|
183
|
+
|
|
184
|
+
Without a declaration this field is open to callers who have not even signed in
|
|
185
|
+
(`auth-rebuild.md` §1), so that gap is the thing worth guarding. The check reads the source
|
|
186
|
+
rather than the compiled output because the declaration is authored there, and it pins the
|
|
187
|
+
category: `figure` is the authoring permission, and requiring it here would leave board and
|
|
188
|
+
twin users with an empty palette. Someone who hits that wall will be tempted to delete this
|
|
189
|
+
line; the failure message is for them.
|
|
190
|
+
*/
|
|
191
|
+
it('declares figure-published on the consumer door, not the authoring permission', () => {
|
|
192
|
+
const { readFileSync } = require('fs')
|
|
193
|
+
const { join } = require('path')
|
|
194
|
+
const source = readFileSync(join(__dirname, 'figure-query.ts'), 'utf-8')
|
|
195
|
+
|
|
196
|
+
const at = source.indexOf('async figureBlueprint')
|
|
197
|
+
expect(at).toBeGreaterThan(-1)
|
|
198
|
+
|
|
199
|
+
const preceding = source.slice(0, at)
|
|
200
|
+
const directive = preceding.lastIndexOf("@Directive('@privilege(")
|
|
201
|
+
const declaration = preceding.slice(directive, preceding.indexOf('\n', directive))
|
|
202
|
+
|
|
203
|
+
expect(declaration).toContain('category: "figure-published"')
|
|
204
|
+
expect(declaration).toContain('privilege: "query"')
|
|
205
|
+
expect(declaration).toContain('domainOwnerGranted: true')
|
|
206
|
+
})
|
|
178
207
|
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 로 figure 를 완성하는 길에 막히는 데가 있나 — 끝까지 걸어 본다.
|
|
3
|
+
*
|
|
4
|
+
* 개별 단계는 각자 시험이 있다. 없던 것은 **이어 붙였을 때**다. 만드는 데까지 되는데
|
|
5
|
+
* 발행에서 막히면 저작자는 AI 로 시작해서 손으로 끝내야 하고, 그것은 이 기능이 있으나 마나다.
|
|
6
|
+
*/
|
|
7
|
+
import { createAIClient, setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
8
|
+
import '@things-factory/ai-client-base'
|
|
9
|
+
|
|
10
|
+
import { proposeFigure } from './figure-propose'
|
|
11
|
+
import { inspectSource } from './figure-gate'
|
|
12
|
+
import { parseSource } from './figure-source'
|
|
13
|
+
|
|
14
|
+
const PALETTE = ['palette.primary', 'palette.accent', 'palette.neutral']
|
|
15
|
+
const on = process.env.RUN_FIGURE_LLM_SMOKE === '1'
|
|
16
|
+
const describeIf = on ? describe : describe.skip
|
|
17
|
+
|
|
18
|
+
describeIf('AI 로 만든 figure 가 발행까지 가나', () => {
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
setDefaultAIClient(createAIClient({ provider: 'gemini', apiKey: process.env.GEMINI_API_KEY!, model: 'gemini-3.5-flash' } as any))
|
|
21
|
+
})
|
|
22
|
+
afterAll(() => setDefaultAIClient(undefined))
|
|
23
|
+
|
|
24
|
+
const walk = async (label: string, prompt: string, type: string) => {
|
|
25
|
+
const made = await proposeFigure({ prompt, type, palette: PALETTE, refine: true })
|
|
26
|
+
|
|
27
|
+
/* 1. 저장 문을 지나는가 — 형식 오류면 여기서 던진다. */
|
|
28
|
+
const saved = parseSource(JSON.stringify(made.source))
|
|
29
|
+
|
|
30
|
+
/* 2. 발행 게이트를 지나는가 — 여기서 막히면 AI 로 시작해 손으로 끝내야 한다. */
|
|
31
|
+
const gate = inspectSource(saved.source)
|
|
32
|
+
const blocking = gate.findings.filter(f => f.blocking)
|
|
33
|
+
|
|
34
|
+
// eslint-disable-next-line no-console
|
|
35
|
+
console.log(
|
|
36
|
+
`[${label}] 부품 ${made.source.parts.length} · 삼각형 ${made.cost.triangles} · 등급 ${made.score.grade}` +
|
|
37
|
+
` · 위반 ${made.violations.length} · 발행 ${gate.blocked ? '막힘: ' + blocking.map(f => f.code + '(' + (f.at ?? '') + ')').join(', ') : '통과'}`
|
|
38
|
+
)
|
|
39
|
+
return { made, gate, blocking }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('늘려도 안 부서지는 것을 만든다 — 앵커를 가르친 뒤', async () => {
|
|
43
|
+
const { gate, blocking } = await walk(
|
|
44
|
+
'신호탑',
|
|
45
|
+
'a signal tower: a tall slim pole standing on a foot, with three stacked lamps at the very top. the lamps must stay on top of the pole when the tower is made taller.',
|
|
46
|
+
'SIGNAL_TOWER_E2E'
|
|
47
|
+
)
|
|
48
|
+
expect(gate.blocked).toBe(false)
|
|
49
|
+
expect(blocking).toEqual([])
|
|
50
|
+
}, 120_000)
|
|
51
|
+
|
|
52
|
+
test('반복하는 것을 만든다 — 컨베이어', async () => {
|
|
53
|
+
const { made, gate } = await walk(
|
|
54
|
+
'컨베이어',
|
|
55
|
+
'a roller conveyor: a frame with rollers repeating along its length. when the conveyor is made longer the number of rollers must grow, not their size.',
|
|
56
|
+
'CONVEYOR_E2E'
|
|
57
|
+
)
|
|
58
|
+
expect(gate.blocked).toBe(false)
|
|
59
|
+
expect(made.source.parts.some((p: any) => p.sizing === 'repeat')).toBe(true)
|
|
60
|
+
}, 120_000)
|
|
61
|
+
|
|
62
|
+
test('움직이는 것을 만든다 — 문', async () => {
|
|
63
|
+
const { made, gate } = await walk(
|
|
64
|
+
'자동문',
|
|
65
|
+
'a двух-panel automatic door in a frame. add an animation clip that opens both panels together by sliding them apart.',
|
|
66
|
+
'AUTO_DOOR_E2E'
|
|
67
|
+
)
|
|
68
|
+
expect(gate.blocked).toBe(false)
|
|
69
|
+
expect(made.source.animations?.length).toBeGreaterThan(0)
|
|
70
|
+
expect(made.source.animations![0]!.channels.length).toBeGreaterThan(0)
|
|
71
|
+
}, 120_000)
|
|
72
|
+
})
|
|
@@ -30,3 +30,71 @@ describe('release-gate repair candidates', () => {
|
|
|
30
30
|
expect(split?.fixes?.every(fix => fix.resolved > 0)).toBe(true)
|
|
31
31
|
})
|
|
32
32
|
})
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* The base box is a promise to whoever places this figure.
|
|
36
|
+
*
|
|
37
|
+
* `base` is the number an instance is scaled by (`instanceScale = width/base.x`) and the placement
|
|
38
|
+
* height comes out of `base.y`. A figure whose box disagrees with its parts renders at the wrong
|
|
39
|
+
* size and the wrong height the moment it lands on a board, so publishing it breaks that promise
|
|
40
|
+
* even though the source itself is well formed.
|
|
41
|
+
*
|
|
42
|
+
* Saving is still allowed — these are violations, not errors — so a figure already standing on a
|
|
43
|
+
* board keeps standing. It just cannot be published again until it is fixed.
|
|
44
|
+
*/
|
|
45
|
+
describe('release gate — the base box', () => {
|
|
46
|
+
/** A figure that sits correctly in its box: 200 tall, foot on the floor at -100. */
|
|
47
|
+
const sound = {
|
|
48
|
+
type: 'POST',
|
|
49
|
+
base: { x: 100, y: 200, z: 100 },
|
|
50
|
+
detailLevel: 'M',
|
|
51
|
+
parts: [
|
|
52
|
+
{
|
|
53
|
+
name: 'post', primitive: 'cube',
|
|
54
|
+
transform: { position: { x: 0, y: -50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
55
|
+
material: { token: 'palette.primary' }
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const inspect = (source: unknown) => inspectSource(JSON.stringify(source))
|
|
61
|
+
|
|
62
|
+
it('lets a sound figure through', () => {
|
|
63
|
+
const inspection = inspect(sound)
|
|
64
|
+
expect(inspection.blocked).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('refuses a part that overflows the box', () => {
|
|
68
|
+
const over = { ...sound, parts: [{ ...sound.parts[0], transform: { position: { x: 0, y: -120, z: 0 }, size: { x: 100, y: 100, z: 100 } } }] }
|
|
69
|
+
const inspection = inspect(over)
|
|
70
|
+
const finding = inspection.findings.find(f => f.code === 'part-outside-base')
|
|
71
|
+
|
|
72
|
+
expect(finding?.blocking).toBe(true)
|
|
73
|
+
expect(inspection.blocked).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('refuses a figure that does not reach the face its placement names', () => {
|
|
77
|
+
const floating = { ...sound, parts: [{ ...sound.parts[0], transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } } }] }
|
|
78
|
+
const inspection = inspect(floating)
|
|
79
|
+
const finding = inspection.findings.find(f => f.code === 'parts-off-placement-face')
|
|
80
|
+
|
|
81
|
+
expect(finding?.blocking).toBe(true)
|
|
82
|
+
expect(inspection.blocked).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('still only reports the violations that are about taste', () => {
|
|
86
|
+
/*
|
|
87
|
+
Slack in the box is a declaration, not a defect — clearance in front of a conveyor, the pitch
|
|
88
|
+
of a rack bay. A roomy box with an odd segment count is told about and published.
|
|
89
|
+
*/
|
|
90
|
+
const roomy = {
|
|
91
|
+
...sound,
|
|
92
|
+
base: { x: 3000, y: 200, z: 3000 },
|
|
93
|
+
parts: [{ ...sound.parts[0], primitive: 'cylinder', segments: 9 }]
|
|
94
|
+
}
|
|
95
|
+
const inspection = inspect(roomy)
|
|
96
|
+
|
|
97
|
+
expect(inspection.findings.some(f => f.code === 'segments-off-preset')).toBe(true)
|
|
98
|
+
expect(inspection.blocked).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { compile, sizingReport, validate } from '@hatiolab/figure-model'
|
|
1
|
+
import { blocksRelease, compile, sizingReport, validate } from '@hatiolab/figure-model'
|
|
2
2
|
import type { Axis, FigureSource } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
/** Service transport shape. It deliberately contains only an explicit, reversible FigureSource edit. */
|
|
@@ -26,20 +26,24 @@ export interface GateFix {
|
|
|
26
26
|
*
|
|
27
27
|
* 판정은 `figure-model` 의 `gate.ts` 가 이미 문서로 정해 두었다. 여기서 새로 정하지 않는다.
|
|
28
28
|
*
|
|
29
|
-
* sizing-splits
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* sizing-splits 크기를 바꾸면 조각이 갈라진다 **막는다**
|
|
30
|
+
* part-outside-base 부품이 기준 상자를 넘는다 **막는다**
|
|
31
|
+
* parts-off-placement-face 기준면에 안 닿는다 **막는다**
|
|
32
|
+
* sizing-loose 1배에서 이미 여러 조각이다 알린다
|
|
33
|
+
* 그 밖의 violations 형식이 권하지 않는 것 알린다 (저장 때 이미 보여 준 것들)
|
|
32
34
|
*
|
|
33
35
|
* 갈라짐을 막는 이유는 사람 눈에 「부서졌다」로 보이기 때문이다 — 시그널타워를 3배로 세우니 램프가
|
|
34
36
|
* 기둥 위 공중에 떴던 그것이고, 앵커 규칙이 생긴 이유다. 소비처가 형상을 늘려 쓰는 것은 막을 수
|
|
35
37
|
* 없으니(그것이 이 형식의 값이다), 늘려도 견디는지를 발행 전에 본다.
|
|
36
38
|
*
|
|
37
|
-
* ##
|
|
39
|
+
* ## 몇 개가 걸리나는 여기 안 적는다
|
|
38
40
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
+
* 한동안 「표본 15개 중 갈라짐 1 · 조용함 14」를 적어 두고 **게이트가 벽이 아니라는 근거**로
|
|
42
|
+
* 삼았다. 수는 굳고 카탈로그는 움직인다 — 기준 상자 판정이 붙자 그 비율이 하루 만에 뒤집혔다.
|
|
43
|
+
* 굳은 수를 근거로 든 문장은 그때부터 사실이 아닌 채로 사람을 안심시킨다.
|
|
41
44
|
*
|
|
42
|
-
*
|
|
45
|
+
* 무엇이 걸리고 얼마나 넘쳤나는 **저작 도구가 재서 보여 준다.** 그것을 볼 사람은 고치는
|
|
46
|
+
* 사람이고, 그 사람이 보는 자리는 여기가 아니다.
|
|
43
47
|
*/
|
|
44
48
|
|
|
45
49
|
/** 게이트가 낸 한 가지. `blocking` 이 참이면 발행이 안 된다. */
|
|
@@ -161,7 +165,12 @@ export function inspectSource(sourceJson?: string | null): Inspection {
|
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
for (const violation of result.violations) {
|
|
164
|
-
findings.push({
|
|
168
|
+
findings.push({
|
|
169
|
+
code: violation.code,
|
|
170
|
+
message: violation.message,
|
|
171
|
+
/* 어느 위반이 막나는 형식이 안다 — 여기 목록을 또 들면 둘이 갈린다(ADR-0044 결정 ②). */
|
|
172
|
+
blocking: blocksRelease(violation.code)
|
|
173
|
+
})
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
if (result.errors.length === 0) {
|
|
@@ -10,12 +10,48 @@
|
|
|
10
10
|
* 모델은 부르지 않는다. 부르면 시험이 느리고 답이 매번 다르다 — 여기서 볼 것은
|
|
11
11
|
* 모델의 솜씨가 아니라 **우리 거름망**이다.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ANCHOR_RULES,
|
|
15
|
+
DETAIL_LEVELS,
|
|
16
|
+
CHANNEL_PATHS,
|
|
17
|
+
CLIP_DRIVES,
|
|
18
|
+
FIGURE_PLACEMENTS,
|
|
19
|
+
INTERPOLATIONS,
|
|
20
|
+
LABEL_WHENS,
|
|
21
|
+
LIMITS,
|
|
22
|
+
MATERIAL_SLOTS,
|
|
23
|
+
PART_LIMIT,
|
|
24
|
+
PART_ROLES,
|
|
25
|
+
PRIMITIVE_KINDS,
|
|
26
|
+
REPEAT_LIMIT,
|
|
27
|
+
SEGMENT_PRESETS,
|
|
28
|
+
SIZING_RULES
|
|
29
|
+
} from '@hatiolab/figure-model'
|
|
14
30
|
import { setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
15
31
|
import type { AIClient } from '@things-factory/ai-client-base'
|
|
16
32
|
|
|
33
|
+
import { readFileSync } from 'fs'
|
|
34
|
+
|
|
35
|
+
import * as model from '@hatiolab/figure-model'
|
|
36
|
+
|
|
17
37
|
import { ProposeFailure, inspect, proposeFigure, rulesPrompt } from './figure-propose'
|
|
18
38
|
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 형식이 선언한 칸 이름을 **형식 소스에서** 읽는다.
|
|
42
|
+
*
|
|
43
|
+
* 손으로 옮겨 적으면 형식이 자라도 이 파일은 그대로 있고, 그러면 이 시험이 지키려는 것을
|
|
44
|
+
* 스스로 어긴다. 타입은 런타임에 안 남으므로 선언을 읽는다.
|
|
45
|
+
*/
|
|
46
|
+
function declaredFields(name: string): string[] {
|
|
47
|
+
const path = require.resolve('@hatiolab/figure-model/package.json')
|
|
48
|
+
const root = path.replace(/package\.json$/, '')
|
|
49
|
+
const source = readFileSync(`${root}dist/types.d.ts`, 'utf8')
|
|
50
|
+
const body = source.match(new RegExp(`interface ${name} \\{([\\s\\S]*?)\\n\\}`))
|
|
51
|
+
if (!body) throw new Error(`형식에서 ${name} 을 못 찾았다 — 이름이 바뀌었으면 시험도 따라가야 한다`)
|
|
52
|
+
return [...body[1].matchAll(/^\s{4}([a-zA-Z]+)\??:/gm)].map(m => m[1]!)
|
|
53
|
+
}
|
|
54
|
+
|
|
19
55
|
const PALETTE = ['palette.primary', 'palette.accent', 'palette.neutral']
|
|
20
56
|
|
|
21
57
|
/** 온전한 후보. 시험할 곳만 덮어쓴다. */
|
|
@@ -94,6 +130,151 @@ describe('규칙을 프롬프트로 옮길 때', () => {
|
|
|
94
130
|
})
|
|
95
131
|
})
|
|
96
132
|
|
|
133
|
+
/*
|
|
134
|
+
* ── Every authorable feature has to reach the model ──────────────────────────
|
|
135
|
+
*
|
|
136
|
+
* The prompt is the only place the model learns what a figure may contain. Until 2026-09-16 it
|
|
137
|
+
* taught `{ type, base, placement, detailLevel, parts }` and one stray line naming the animation
|
|
138
|
+
* paths, so anchors, repeats, capabilities, labels, lamps, hollows and movement were unreachable:
|
|
139
|
+
* the model was told there is a limit of two animated parts without ever being told where an
|
|
140
|
+
* animation goes. What came back was coloured boxes.
|
|
141
|
+
*
|
|
142
|
+
* These check per feature rather than per word, because a vocabulary list the model cannot place
|
|
143
|
+
* in the JSON is worse than no mention at all — it produces confident, invalid output.
|
|
144
|
+
*/
|
|
145
|
+
/*
|
|
146
|
+
* ── The format is the only vocabulary, and the prompt must follow it ─────────
|
|
147
|
+
*
|
|
148
|
+
* Checking feature by feature does not hold: a list of features has to be extended by hand every
|
|
149
|
+
* time the format grows, which is the failure it is supposed to prevent. Until 2026-09-16 the
|
|
150
|
+
* prompt taught five fields out of thirteen and a single stray line naming the animation paths,
|
|
151
|
+
* and nothing failed — because nothing compared the prompt against the format.
|
|
152
|
+
*
|
|
153
|
+
* So these walk `@hatiolab/figure-model` itself. Every enum it exports is either something an
|
|
154
|
+
* author writes into a FigureSource, in which case the model has to be taught all of its values,
|
|
155
|
+
* or it is not, in which case it is named below with the reason. Adding an enum to the format
|
|
156
|
+
* forces that decision instead of letting it be forgotten.
|
|
157
|
+
*/
|
|
158
|
+
describe('프롬프트는 형식을 따라간다 — 손으로 적은 목록이 아니라', () => {
|
|
159
|
+
/*
|
|
160
|
+
저작자가 FigureSource 에 적는 값이 아닌 열거. 여기 없는 새 열거가 형식에 생기면 아래 시험이
|
|
161
|
+
깨지고, 가르칠지 말지를 그때 정하게 된다.
|
|
162
|
+
*/
|
|
163
|
+
const NOT_AUTHORED: Record<string, string> = {
|
|
164
|
+
ERROR_CODES: '검증 결과다 — 저작자가 적는 값이 아니다',
|
|
165
|
+
VIOLATION_CODES: '같다',
|
|
166
|
+
RELEASE_BLOCKING_VIOLATIONS: '위반 코드의 부분집합이다 — 어느 것이 발행을 막나는 관문이 알 일이지 저작자가 적는 값이 아니다',
|
|
167
|
+
AXES: "'x'·'y'·'z' 는 축 이름이고 값이 아니다 — 쓰는 자리에서 anchor.<axis> 로 가르친다",
|
|
168
|
+
SURFACE_KINDS: 'material.surface 는 things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
it('저작에 쓰이는 열거는 값 하나도 빠짐없이 프롬프트에 있다', () => {
|
|
172
|
+
const prompt = rulesPrompt(PALETTE)
|
|
173
|
+
const missing: string[] = []
|
|
174
|
+
|
|
175
|
+
for (const [name, value] of Object.entries(model as Record<string, unknown>)) {
|
|
176
|
+
if (!Array.isArray(value) || value.some(v => typeof v !== 'string')) continue
|
|
177
|
+
if (name in NOT_AUTHORED) continue
|
|
178
|
+
for (const member of value as string[]) {
|
|
179
|
+
if (!prompt.includes(member)) missing.push(`${name}.${member}`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
expect(missing).toEqual([])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('저작에 안 쓰이는 열거는 이유가 적혀 있다 — 조용히 빠지지 않는다', () => {
|
|
187
|
+
const enums = Object.entries(model as Record<string, unknown>)
|
|
188
|
+
.filter(([, v]) => Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string'))
|
|
189
|
+
.map(([k]) => k)
|
|
190
|
+
|
|
191
|
+
/* 형식에 열거가 늘면 가르치거나 NOT_AUTHORED 에 이유와 함께 적어야 한다. */
|
|
192
|
+
const prompt = rulesPrompt(PALETTE)
|
|
193
|
+
const unclassified = enums.filter(
|
|
194
|
+
name => !(name in NOT_AUTHORED) && !(model as any)[name].every((m: string) => prompt.includes(m))
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
expect(unclassified).toEqual([])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('한도는 형식의 값을 그대로 인용한다 — 프롬프트에 숫자를 적어 두지 않는다', () => {
|
|
201
|
+
const prompt = rulesPrompt(PALETTE)
|
|
202
|
+
|
|
203
|
+
for (const [key, limit] of Object.entries(LIMITS)) {
|
|
204
|
+
if (typeof limit !== 'number') continue
|
|
205
|
+
/* 한도를 안 가르치는 것은 괜찮다. 다른 수를 가르치는 것은 안 된다. */
|
|
206
|
+
const wrong = new RegExp(`${key}[^\\n]*<=\\s*(?!${limit}\\b)\\d+`, 'i')
|
|
207
|
+
expect(prompt).not.toMatch(wrong)
|
|
208
|
+
}
|
|
209
|
+
expect(prompt).toContain(String(REPEAT_LIMIT))
|
|
210
|
+
for (const level of DETAIL_LEVELS) expect(prompt).toContain(`${level}=${PART_LIMIT[level]}`)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
/*
|
|
214
|
+
가르치지 않는 칸. 열거와 같은 규율이다 — 여기 없는 새 칸이 형식에 생기면 아래가 깨지고,
|
|
215
|
+
그때 가르칠지 말지를 정하게 된다. 「아직 안 가르쳤다」가 조용히 남지 않는다.
|
|
216
|
+
*/
|
|
217
|
+
const NOT_TAUGHT: Record<string, Record<string, string>> = {
|
|
218
|
+
FigureSource: {
|
|
219
|
+
type: '요청에서 온다 — 모델이 지어내면 거절한다',
|
|
220
|
+
parts: '모양 첫 줄에 이미 있다',
|
|
221
|
+
version: '저장할 때 서버가 찍는다',
|
|
222
|
+
styleKit: '읽는 쪽이 0건이다 — 그릴 수 없는 것을 시키면 무동작 설정이 된다'
|
|
223
|
+
},
|
|
224
|
+
FigurePart: {
|
|
225
|
+
name: '모양 첫 줄에 있다',
|
|
226
|
+
primitive: '같다',
|
|
227
|
+
transform: '같다',
|
|
228
|
+
material: '같다'
|
|
229
|
+
},
|
|
230
|
+
PartMaterial: {
|
|
231
|
+
token: '모양 첫 줄에 있다',
|
|
232
|
+
preset: '같다',
|
|
233
|
+
surface: 'things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다'
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const iface of ['FigureSource', 'FigurePart', 'PartMaterial']) {
|
|
238
|
+
it(`${iface} 의 칸은 가르치거나 이유를 적었거나 둘 중 하나다`, () => {
|
|
239
|
+
const prompt = rulesPrompt(PALETTE)
|
|
240
|
+
const silent = declaredFields(iface).filter(
|
|
241
|
+
field => !(field in NOT_TAUGHT[iface]!) && !prompt.includes(field)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
expect(silent).toEqual([])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it(`${iface} 의 제외 목록에 죽은 이름이 없다`, () => {
|
|
248
|
+
/* 형식에서 없어진 칸을 제외 목록이 계속 들고 있으면, 그 목록이 형식을 안 따라간 것이다. */
|
|
249
|
+
const declared = new Set(declaredFields(iface))
|
|
250
|
+
const stale = Object.keys(NOT_TAUGHT[iface]!).filter(field => !declared.has(field))
|
|
251
|
+
|
|
252
|
+
expect(stale).toEqual([])
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('고치라는 요청일 때', () => {
|
|
258
|
+
it('요청이 안 건드린 것을 그대로 실으라고 못 박는다', async () => {
|
|
259
|
+
/*
|
|
260
|
+
이 문장이 없으면 애니메이션이 있는 도형을 「색만 바꿔 줘」로 고쳤을 때 모델이 clip 을
|
|
261
|
+
안 실어 보내고, 그것이 오류 없이 통과해 조용히 사라진다.
|
|
262
|
+
*/
|
|
263
|
+
const client = stubClient([candidate()])
|
|
264
|
+
setDefaultAIClient(client as any)
|
|
265
|
+
await proposeFigure({ prompt: '색을 바꿔 줘', base: candidate() as any, palette: PALETTE })
|
|
266
|
+
|
|
267
|
+
const sent = firstAsk(client)
|
|
268
|
+
expect(sent).toMatch(/Carry over everything the request did not ask you to change/)
|
|
269
|
+
expect(sent).toMatch(/dropping one is silent/)
|
|
270
|
+
|
|
271
|
+
/* 잃기 쉬운 칸은 이름으로 짚어 준다 — 「전부 지켜라」만으로는 모델이 빠뜨린다. */
|
|
272
|
+
for (const field of ['animations', 'capabilities', 'anchor', 'repeat', 'label', 'materialSlot']) {
|
|
273
|
+
expect(sent).toContain(field)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
97
278
|
describe('후보를 받아 볼 때', () => {
|
|
98
279
|
it('온전하면 통과시킨다', () => {
|
|
99
280
|
const looked = inspect(candidate(), PALETTE)
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ANCHOR_RULES,
|
|
3
|
+
AXES,
|
|
2
4
|
CHANNEL_PATHS,
|
|
5
|
+
CLIP_DRIVES,
|
|
3
6
|
DETAIL_LEVELS,
|
|
7
|
+
INTERPOLATIONS,
|
|
8
|
+
LABEL_WHENS,
|
|
4
9
|
LIMITS,
|
|
5
10
|
MATERIAL_PRESETS,
|
|
11
|
+
MATERIAL_SLOTS,
|
|
6
12
|
PART_LIMIT,
|
|
13
|
+
PART_ROLES,
|
|
7
14
|
PRIMITIVE_KINDS,
|
|
15
|
+
REPEAT_LIMIT,
|
|
8
16
|
SEGMENT_PRESETS,
|
|
9
17
|
SIZING_RULES,
|
|
10
18
|
FIGURE_PLACEMENTS,
|
|
@@ -51,9 +59,14 @@ import type { FigureQualityReview } from './figure-quality.js'
|
|
|
51
59
|
강제 규칙과 취향 규칙을 갈라 적는다.
|
|
52
60
|
|
|
53
61
|
`validate()` 가 막는 것만 「hard」다. 한때 「부품은 기준 상자 안에 있어야 한다」를
|
|
54
|
-
강제 규칙으로
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
강제 규칙으로 적었다가, 그런 규칙이 없다는 이유로 취향 쪽으로 내렸다. **지금은 다시
|
|
63
|
+
강제다** — `part-outside-base` 와 `parts-off-placement-face` 가 생겼고 발행 관문이
|
|
64
|
+
그 둘을 거절한다(2026-09-16, 아키텍트 판정).
|
|
65
|
+
|
|
66
|
+
**검사가 거절하는 것을 지시가 같은 낱말로 말해야 한다.** 갈리면 모델은 거절당하는
|
|
67
|
+
것을 계속 만들고 사람이 매번 손으로 고친다. 실제로 그 자국이 카탈로그에 남았다 —
|
|
68
|
+
AI 가 만든 OHT 하나만 원점·상자·단위 셋이 한꺼번에 어긋나 있었는데, 그 표본이 특이한
|
|
69
|
+
것이 아니라 **여기에 그 말이 없었다.**
|
|
57
70
|
*/
|
|
58
71
|
|
|
59
72
|
/** 몇 번까지 다시 시키나. 이보다 늘려도 대개 같은 자리에서 막힌다. */
|
|
@@ -132,11 +145,12 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
132
145
|
'Answer with a single JSON object and nothing else — no prose, no code fence.',
|
|
133
146
|
'',
|
|
134
147
|
'The JSON is a FigureSource:',
|
|
135
|
-
' { type, base: {x,y,z}, placement?, detailLevel, parts: [...] }',
|
|
148
|
+
' { type, base: {x,y,z}, placement?, detailLevel, parts: [...], animations?: [...], capabilities?: [...] }',
|
|
136
149
|
'',
|
|
137
150
|
'A part is:',
|
|
138
151
|
' { name, primitive, transform: { position: {x,y,z}, size: {x,y,z}, rotation?: {x,y,z} },',
|
|
139
|
-
' material: {token, preset
|
|
152
|
+
' material: { token, preset?, flatShading?, transparent?, emissive? },',
|
|
153
|
+
' segments?, shape?, materialSlot?, keepRound?, sizing?, anchor?, repeat?, label?, capability? }',
|
|
140
154
|
'',
|
|
141
155
|
'Hard vocabulary — anything outside these is rejected:',
|
|
142
156
|
` placement ${FIGURE_PLACEMENTS.join(' | ')} (floor: default, ground equipment; ceiling: overhead hoist/OHT; center: airborne/drone/sensor)`,
|
|
@@ -144,10 +158,25 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
144
158
|
` segments ${SEGMENT_PRESETS.join(' | ')} (only for cylinder and sphere)`,
|
|
145
159
|
` material.token ${palette.join(' | ')}`,
|
|
146
160
|
` material.preset ${MATERIAL_PRESETS.join(' | ')}`,
|
|
161
|
+
` materialSlot ${MATERIAL_SLOTS.join(' | ')}`,
|
|
147
162
|
` sizing ${SIZING_RULES.join(' | ')}`,
|
|
163
|
+
` anchor.<axis> ${ANCHOR_RULES.join(' | ')} (axis is one of ${AXES.join(', ')})`,
|
|
164
|
+
` repeat.axis ${AXES.join(' | ')}`,
|
|
165
|
+
` capability.roles ${PART_ROLES.join(' | ')}`,
|
|
166
|
+
` label.when ${LABEL_WHENS.join(' | ')}`,
|
|
148
167
|
` animation.path ${CHANNEL_PATHS.join(' | ')}`,
|
|
168
|
+
` animation.drive ${CLIP_DRIVES.join(' | ')}`,
|
|
169
|
+
` animation.interpolation ${INTERPOLATIONS.join(' | ')}`,
|
|
149
170
|
` detailLevel ${DETAIL_LEVELS.join(' | ')}`,
|
|
150
171
|
'',
|
|
172
|
+
'Hard rules about the base box — publication is refused when either is broken:',
|
|
173
|
+
' Every part must sit inside the base box: |position.<axis>| + size.<axis>/2 <= base.<axis>/2.',
|
|
174
|
+
' base is the number an instance is scaled by, so a part outside it grows wrongly on a board.',
|
|
175
|
+
' The figure must touch the face its placement names — floor: the lowest part reaches',
|
|
176
|
+
' y = -base.y/2; ceiling: the highest reaches y = +base.y/2; center: no requirement.',
|
|
177
|
+
' base may be LARGER than the parts. That slack is a declaration (clearance in front of a',
|
|
178
|
+
' conveyor, the pitch of a rack bay) and is never an error. Only overflow and not reaching are.',
|
|
179
|
+
'',
|
|
151
180
|
'Hard limits — exceeding these makes the figure unusable at scale:',
|
|
152
181
|
` parts by detailLevel: ${partLimits}`,
|
|
153
182
|
` material groups (distinct token+preset+slot combinations) <= ${LIMITS.materialGroups}`,
|
|
@@ -159,16 +188,65 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
159
188
|
' transform.position is the CENTRE of the part, not a corner.',
|
|
160
189
|
' The origin (0,0,0) is the centre of the base box, so the floor is at y = -base.y / 2.',
|
|
161
190
|
' base and transform.size are extents along each axis: size.y is the height.',
|
|
191
|
+
' Every number is MILLIMETRES. A conveyor is about 2000 long and 800 tall, not 2 and 0.8.',
|
|
162
192
|
' part names are stored identifiers — lowercase, hyphenated, unique, and descriptive',
|
|
163
193
|
' of the real thing (body, lid, motor, leg-front-left), never part-1.',
|
|
164
194
|
'',
|
|
195
|
+
'How a figure reacts to being resized — this is the point of the format:',
|
|
196
|
+
' An instance is scaled from the base box, and by default every part scales with it.',
|
|
197
|
+
' sizing says what a part does instead:',
|
|
198
|
+
" scale default. grows with the instance",
|
|
199
|
+
" fixed keeps its real size. bolts, lamps, sensors, control boxes",
|
|
200
|
+
" stretch grows along its longest axis only. beams, rails, belts",
|
|
201
|
+
" repeat the part is laid out along one axis at a fixed pitch, and the COUNT grows",
|
|
202
|
+
' repeat is { axis, pitch } — pitch is centre-to-centre distance, not the gap between copies.',
|
|
203
|
+
` Rollers on a conveyor, shelves in a rack, legs under a table. At most ${REPEAT_LIMIT} copies.`,
|
|
204
|
+
' anchor is which face of the base a part holds on to while the rest grows:',
|
|
205
|
+
` { x?, y?, z? }, each one of ${ANCHOR_RULES.join(' | ')}. Unset axes hold the nearer face.`,
|
|
206
|
+
' Anchors are what keeps a figure whole when it is stretched. A lamp on top of a mast anchors',
|
|
207
|
+
' y:"max" so it stays on top; a foot anchors y:"min". Getting this wrong is the single most',
|
|
208
|
+
' common reason a figure is rejected at publish — the parts drift apart and it looks broken.',
|
|
209
|
+
' keepRound keeps a cylinder or sphere circular under uneven scaling. Default true. Turn it',
|
|
210
|
+
' off for small details nobody inspects; leave it on for wheels, rolls and tanks.',
|
|
211
|
+
'',
|
|
212
|
+
'Shapes beyond a plain box:',
|
|
213
|
+
' rect and polygon are extruded from a 2D outline. shape carries that outline:',
|
|
214
|
+
' { path?: [{x,y}, ...], round?, hollow?: { wall, floor? } }',
|
|
215
|
+
' path is for polygon and needs at least three points. round softens the corners.',
|
|
216
|
+
' hollow scoops the inside out, so a tray, tote, bin, case or frame is ONE part and not a',
|
|
217
|
+
' floor with four walls around it. wall is the side thickness; floor is the bottom, and',
|
|
218
|
+
' floor 0 leaves both ends open — a duct or a frame rather than a container.',
|
|
219
|
+
' transform.size is the outside measurement; the cavity is inside it.',
|
|
220
|
+
'',
|
|
221
|
+
'Parts that mean something to the running factory:',
|
|
222
|
+
' capability marks a part as a place the simulation uses, rather than only something drawn.',
|
|
223
|
+
' { roles: [...], accepts?: [type names], capacity?: n, direction?: {x,y,z} }',
|
|
224
|
+
" slot where a thing sits — a pallet bed, a rack cell, an AGV deck",
|
|
225
|
+
" port-in where flow enters port-out where flow leaves",
|
|
226
|
+
' A part may hold several roles: an AGV deck is a slot that things also enter and leave.',
|
|
227
|
+
' Omit capacity when the part repeats — the count then follows the instance size.',
|
|
228
|
+
' direction is the way a port faces, in the part\'s own axes; default is +z.',
|
|
229
|
+
' capabilities (top level) is what the whole figure can do, as plain names the scene knows.',
|
|
230
|
+
' materialSlot + material.emissive make a status lamp: the token is the OFF look and',
|
|
231
|
+
' emissive { token, intensity, on? } is the ON look. A dark lamp is not a red lamp dimmed.',
|
|
232
|
+
' label is a place a number is shown on the figure: { name, source, when }. The figure says',
|
|
233
|
+
' where it goes; the scene says what the value is.',
|
|
234
|
+
'',
|
|
235
|
+
'Movement:',
|
|
236
|
+
' animations is on the FigureSource, not inside a part, because one clip moves several parts.',
|
|
237
|
+
' [{ name, drive?, channels: [{ target, path, pivot?, interpolation?, keys: [...] }] }]',
|
|
238
|
+
' target is a part name. keys are [{ at: seconds, value: {x,y,z} }].',
|
|
239
|
+
' At least TWO keys, with `at` strictly increasing — one key is a pose, not motion.',
|
|
240
|
+
' translation is an offset, rotation is DEGREES, scale is a multiplier where 1 is full size.',
|
|
241
|
+
' pivot is the centre of rotation in the part\'s own axes; without it the part spins on itself.',
|
|
242
|
+
' drive loop repeats forever, hold plays once and stays. Clip length is the last key\'s time.',
|
|
243
|
+
' Doors that open together belong in one clip with two channels, not two clips.',
|
|
244
|
+
'',
|
|
165
245
|
'Taste rules — this is what makes it low-poly rather than a pile of boxes:',
|
|
166
246
|
' Few colours. Reuse one token for the mass and reserve accents for what must stand out.',
|
|
167
247
|
' Recognisable silhouette from directly above — that is how it is seen on a board.',
|
|
168
248
|
' Prefer fewer, larger parts. A detail smaller than a twentieth of the base is not visible.',
|
|
169
|
-
' Use the coarsest segment count that keeps the silhouette right.'
|
|
170
|
-
' Keep parts within the base box unless the real thing overhangs. The base is the reference',
|
|
171
|
-
' the instance is scaled from, so a part far outside it grows in a way nobody intended.'
|
|
249
|
+
' Use the coarsest segment count that keeps the silhouette right.'
|
|
172
250
|
].join('\n')
|
|
173
251
|
}
|
|
174
252
|
|
|
@@ -181,6 +259,10 @@ function taskPrompt(input: ProposeInput): string {
|
|
|
181
259
|
'',
|
|
182
260
|
'Revise this existing figure. Keep the part names that still mean the same thing —',
|
|
183
261
|
'renaming a part breaks bindings on instances already placed.',
|
|
262
|
+
'Carry over everything the request did not ask you to change. animations, capabilities,',
|
|
263
|
+
'capability, anchor, repeat, label and materialSlot are easy to drop by writing them out of',
|
|
264
|
+
'the JSON, and dropping one is silent — the figure still validates, it just stops doing what',
|
|
265
|
+
'it did. If the request says nothing about movement, the clips come back unchanged.',
|
|
184
266
|
JSON.stringify(input.base)
|
|
185
267
|
)
|
|
186
268
|
|
|
@@ -140,9 +140,25 @@ export class FigureQuery {
|
|
|
140
140
|
* 4. 한 번도 발행된 적 없는 초안 자산이거나 존재하지 않는 타입인 경우 null을 반환하여
|
|
141
141
|
* 미완성 자산이 씬 화면에 유출되는 것을 엄격히 차단한다.
|
|
142
142
|
* 5. 도메인 상속 시 자식 도메인의 타입이 부모 도메인의 동일 타입을 항상 오버라이드한다.
|
|
143
|
-
* 6.
|
|
144
|
-
*
|
|
143
|
+
* 6. 권한은 저작(`figure`)과 **다른 category** 인 `figure-published` 다. 소비처 사용자에게
|
|
144
|
+
* 저작 권한을 요구하면 board·twin 의 팔레트가 안 뜨고, 그렇다고 선언을 비우면 이 집에서는
|
|
145
|
+
* 로그인조차 안 한 사람에게 열린다(`auth-rebuild.md` §1). `<module>-published` 는
|
|
146
|
+
* 「그 모듈이 발행한 것을 남이 읽는 문」이라는 규약이다 — 아키텍트 판정(2026-09-15).
|
|
147
|
+
*
|
|
148
|
+
* ## 열거는 막혀 있다 — 지금은 우연이다
|
|
149
|
+
*
|
|
150
|
+
* 이 문의 열쇠는 `type` 하나뿐이라 이름을 모르면 못 꺼낸다. 그런데 그것이 **설계가 아니다** —
|
|
151
|
+
* 목록을 내는 `figures`·`figureTypeNames` 가 `figure` category 로 잠겨 있어서 결과적으로
|
|
152
|
+
* 그런 것뿐이다. 나중에 누가 소비처용 목록 문을 하나 더 열고 `figure-published` 를 붙이면
|
|
153
|
+
* 열거가 함께 열리고, 「이름을 알아야 한다」에 기대고 있던 가정이 조용히 사라진다.
|
|
154
|
+
* 그 문을 낼 사람이 이 단락을 읽고 그때 정하면 된다.
|
|
145
155
|
*/
|
|
156
|
+
/*
|
|
157
|
+
This directive is attached by schema assembly, so a test that calls the resolver method
|
|
158
|
+
directly never exercises it. `figure-blueprint-query.test.ts` reads this source for the
|
|
159
|
+
declaration instead — see the test named for it there.
|
|
160
|
+
*/
|
|
161
|
+
@Directive('@privilege(category: "figure-published", privilege: "query", domainOwnerGranted: true)')
|
|
146
162
|
@Query(returns => GraphQLJSON, {
|
|
147
163
|
nullable: true,
|
|
148
164
|
description: 'Compiled FigureBlueprint for external consumers (board, twin, things-scene). Only returns released figures.'
|