@things-factory/figure-service 10.1.16 → 10.1.18
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/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- 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-mutation.ts +1 -1
- package/server/service/figure/figure-propose.test.ts +256 -4
- package/server/service/figure/figure-propose.ts +116 -10
- package/server/service/figure/figure-source.test.ts +8 -2
- package/server/service/figure/figure-source.ts +1 -1
- package/server/service/figure/figure.ts +1 -1
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.18",
|
|
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.19",
|
|
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.18",
|
|
30
30
|
"@things-factory/env": "^10.1.3",
|
|
31
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": "608fe1a70f430ae9c3aa20497173cecc8121373e"
|
|
37
37
|
}
|
|
@@ -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 is placed, 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
|
+
* drawing 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) {
|
|
@@ -278,7 +278,7 @@ export class FigureMutation {
|
|
|
278
278
|
async deleteFigure(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
279
279
|
const { domain, tx } = context.state
|
|
280
280
|
|
|
281
|
-
// 소프트 삭제다 — 이미 배치된
|
|
281
|
+
// 소프트 삭제다 — 이미 배치된 도면이 이 타입을 참조할 수 있으므로 행을 지우지
|
|
282
282
|
// 않는다. 되살릴 길을 남긴다.
|
|
283
283
|
await tx.getRepository(Figure).softDelete({ domain: { id: domain.id }, id })
|
|
284
284
|
|
|
@@ -10,12 +10,47 @@
|
|
|
10
10
|
* 모델은 부르지 않는다. 부르면 시험이 느리고 답이 매번 다르다 — 여기서 볼 것은
|
|
11
11
|
* 모델의 솜씨가 아니라 **우리 거름망**이다.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ANCHOR_RULES,
|
|
15
|
+
DETAIL_LEVELS,
|
|
16
|
+
CHANNEL_PATHS,
|
|
17
|
+
FIGURE_PLACEMENTS,
|
|
18
|
+
INTERPOLATIONS,
|
|
19
|
+
LABEL_WHENS,
|
|
20
|
+
LIMITS,
|
|
21
|
+
MATERIAL_SLOTS,
|
|
22
|
+
PART_LIMIT,
|
|
23
|
+
PART_ROLES,
|
|
24
|
+
PRIMITIVE_KINDS,
|
|
25
|
+
REPEAT_LIMIT,
|
|
26
|
+
SEGMENT_PRESETS,
|
|
27
|
+
SIZING_RULES
|
|
28
|
+
} from '@hatiolab/figure-model'
|
|
14
29
|
import { setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
15
30
|
import type { AIClient } from '@things-factory/ai-client-base'
|
|
16
31
|
|
|
32
|
+
import { readFileSync } from 'fs'
|
|
33
|
+
|
|
34
|
+
import * as model from '@hatiolab/figure-model'
|
|
35
|
+
|
|
17
36
|
import { ProposeFailure, inspect, proposeFigure, rulesPrompt } from './figure-propose'
|
|
18
37
|
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 형식이 선언한 칸 이름을 **형식 소스에서** 읽는다.
|
|
41
|
+
*
|
|
42
|
+
* 손으로 옮겨 적으면 형식이 자라도 이 파일은 그대로 있고, 그러면 이 시험이 지키려는 것을
|
|
43
|
+
* 스스로 어긴다. 타입은 런타임에 안 남으므로 선언을 읽는다.
|
|
44
|
+
*/
|
|
45
|
+
function declaredFields(name: string): string[] {
|
|
46
|
+
const path = require.resolve('@hatiolab/figure-model/package.json')
|
|
47
|
+
const root = path.replace(/package\.json$/, '')
|
|
48
|
+
const source = readFileSync(`${root}dist/types.d.ts`, 'utf8')
|
|
49
|
+
const body = source.match(new RegExp(`interface ${name} \\{([\\s\\S]*?)\\n\\}`))
|
|
50
|
+
if (!body) throw new Error(`형식에서 ${name} 을 못 찾았다 — 이름이 바뀌었으면 시험도 따라가야 한다`)
|
|
51
|
+
return [...body[1].matchAll(/^\s{4}([a-zA-Z]+)\??:/gm)].map(m => m[1]!)
|
|
52
|
+
}
|
|
53
|
+
|
|
19
54
|
const PALETTE = ['palette.primary', 'palette.accent', 'palette.neutral']
|
|
20
55
|
|
|
21
56
|
/** 온전한 후보. 시험할 곳만 덮어쓴다. */
|
|
@@ -42,7 +77,7 @@ function candidate(over: Record<string, unknown> = {}) {
|
|
|
42
77
|
*
|
|
43
78
|
* **받은 말을 적어 둔다.** 프롬프트에 실려 나가는 지시 중에 지켜져야 할 것이 있어서다 —
|
|
44
79
|
* 고치는 요청에서 「부품 이름을 바꾸지 말라」가 빠지면 AI 가 이름을 갈아 버리고, 그러면
|
|
45
|
-
* 이미 배치된
|
|
80
|
+
* 이미 배치된 도면의 바인딩이 끊긴다. 답만 보는 모형으로는 그것이 나갔는지 알 수 없다.
|
|
46
81
|
*/
|
|
47
82
|
function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; system?: string } {
|
|
48
83
|
const client = {
|
|
@@ -94,6 +129,153 @@ describe('규칙을 프롬프트로 옮길 때', () => {
|
|
|
94
129
|
})
|
|
95
130
|
})
|
|
96
131
|
|
|
132
|
+
/*
|
|
133
|
+
* ── Every authorable feature has to reach the model ──────────────────────────
|
|
134
|
+
*
|
|
135
|
+
* The prompt is the only place the model learns what a figure may contain. Until 2026-09-16 it
|
|
136
|
+
* taught `{ type, base, placement, detailLevel, parts }` and one stray line naming the animation
|
|
137
|
+
* paths, so anchors, repeats, capabilities, labels, lamps, hollows and movement were unreachable:
|
|
138
|
+
* the model was told there is a limit of two animated parts without ever being told where an
|
|
139
|
+
* animation goes. What came back was coloured boxes.
|
|
140
|
+
*
|
|
141
|
+
* These check per feature rather than per word, because a vocabulary list the model cannot place
|
|
142
|
+
* in the JSON is worse than no mention at all — it produces confident, invalid output.
|
|
143
|
+
*/
|
|
144
|
+
/*
|
|
145
|
+
* ── The format is the only vocabulary, and the prompt must follow it ─────────
|
|
146
|
+
*
|
|
147
|
+
* Checking feature by feature does not hold: a list of features has to be extended by hand every
|
|
148
|
+
* time the format grows, which is the failure it is supposed to prevent. Until 2026-09-16 the
|
|
149
|
+
* prompt taught five fields out of thirteen and a single stray line naming the animation paths,
|
|
150
|
+
* and nothing failed — because nothing compared the prompt against the format.
|
|
151
|
+
*
|
|
152
|
+
* So these walk `@hatiolab/figure-model` itself. Every enum it exports is either something an
|
|
153
|
+
* author writes into a FigureSource, in which case the model has to be taught all of its values,
|
|
154
|
+
* or it is not, in which case it is named below with the reason. Adding an enum to the format
|
|
155
|
+
* forces that decision instead of letting it be forgotten.
|
|
156
|
+
*/
|
|
157
|
+
describe('프롬프트는 형식을 따라간다 — 손으로 적은 목록이 아니라', () => {
|
|
158
|
+
/*
|
|
159
|
+
저작자가 FigureSource 에 적는 값이 아닌 열거. 여기 없는 새 열거가 형식에 생기면 아래 시험이
|
|
160
|
+
깨지고, 가르칠지 말지를 그때 정하게 된다.
|
|
161
|
+
*/
|
|
162
|
+
const NOT_AUTHORED: Record<string, string> = {
|
|
163
|
+
ERROR_CODES: '검증 결과다 — 저작자가 적는 값이 아니다',
|
|
164
|
+
VIOLATION_CODES: '같다',
|
|
165
|
+
RELEASE_BLOCKING_VIOLATIONS: '위반 코드의 부분집합이다 — 어느 것이 발행을 막나는 관문이 알 일이지 저작자가 적는 값이 아니다',
|
|
166
|
+
AXES: "'x'·'y'·'z' 는 축 이름이고 값이 아니다 — 쓰는 자리에서 anchor.<axis> 로 가르친다",
|
|
167
|
+
SURFACE_KINDS: 'material.surface 는 things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다',
|
|
168
|
+
CLIP_DRIVES:
|
|
169
|
+
'없어지는 이름이다 — 값이 만드는 자세가 `parameters` 로 나갔고(ADR-0051 ①) `compile()` 이 옛 원본의 hold 를 옮길 때만 읽는다. 새로 적는 값이 아니다'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it('저작에 쓰이는 열거는 값 하나도 빠짐없이 프롬프트에 있다', () => {
|
|
173
|
+
const prompt = rulesPrompt(PALETTE)
|
|
174
|
+
const missing: string[] = []
|
|
175
|
+
|
|
176
|
+
for (const [name, value] of Object.entries(model as Record<string, unknown>)) {
|
|
177
|
+
if (!Array.isArray(value) || value.some(v => typeof v !== 'string')) continue
|
|
178
|
+
if (name in NOT_AUTHORED) continue
|
|
179
|
+
for (const member of value as string[]) {
|
|
180
|
+
if (!prompt.includes(member)) missing.push(`${name}.${member}`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
expect(missing).toEqual([])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('저작에 안 쓰이는 열거는 이유가 적혀 있다 — 조용히 빠지지 않는다', () => {
|
|
188
|
+
const enums = Object.entries(model as Record<string, unknown>)
|
|
189
|
+
.filter(([, v]) => Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string'))
|
|
190
|
+
.map(([k]) => k)
|
|
191
|
+
|
|
192
|
+
/* 형식에 열거가 늘면 가르치거나 NOT_AUTHORED 에 이유와 함께 적어야 한다. */
|
|
193
|
+
const prompt = rulesPrompt(PALETTE)
|
|
194
|
+
const unclassified = enums.filter(
|
|
195
|
+
name => !(name in NOT_AUTHORED) && !(model as any)[name].every((m: string) => prompt.includes(m))
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
expect(unclassified).toEqual([])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('한도는 형식의 값을 그대로 인용한다 — 프롬프트에 숫자를 적어 두지 않는다', () => {
|
|
202
|
+
const prompt = rulesPrompt(PALETTE)
|
|
203
|
+
|
|
204
|
+
for (const [key, limit] of Object.entries(LIMITS)) {
|
|
205
|
+
if (typeof limit !== 'number') continue
|
|
206
|
+
/* 한도를 안 가르치는 것은 괜찮다. 다른 수를 가르치는 것은 안 된다. */
|
|
207
|
+
const wrong = new RegExp(`${key}[^\\n]*<=\\s*(?!${limit}\\b)\\d+`, 'i')
|
|
208
|
+
expect(prompt).not.toMatch(wrong)
|
|
209
|
+
}
|
|
210
|
+
expect(prompt).toContain(String(REPEAT_LIMIT))
|
|
211
|
+
for (const level of DETAIL_LEVELS) expect(prompt).toContain(`${level}=${PART_LIMIT[level]}`)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
/*
|
|
215
|
+
가르치지 않는 칸. 열거와 같은 규율이다 — 여기 없는 새 칸이 형식에 생기면 아래가 깨지고,
|
|
216
|
+
그때 가르칠지 말지를 정하게 된다. 「아직 안 가르쳤다」가 조용히 남지 않는다.
|
|
217
|
+
*/
|
|
218
|
+
const NOT_TAUGHT: Record<string, Record<string, string>> = {
|
|
219
|
+
FigureSource: {
|
|
220
|
+
type: '요청에서 온다 — 모델이 지어내면 거절한다',
|
|
221
|
+
parts: '모양 첫 줄에 이미 있다',
|
|
222
|
+
version: '저장할 때 서버가 찍는다',
|
|
223
|
+
styleKit: '읽는 쪽이 0건이다 — 그릴 수 없는 것을 시키면 무동작 설정이 된다'
|
|
224
|
+
},
|
|
225
|
+
FigurePart: {
|
|
226
|
+
name: '모양 첫 줄에 있다',
|
|
227
|
+
primitive: '같다',
|
|
228
|
+
transform: '같다',
|
|
229
|
+
material: '같다'
|
|
230
|
+
},
|
|
231
|
+
PartMaterial: {
|
|
232
|
+
token: '모양 첫 줄에 있다',
|
|
233
|
+
preset: '같다',
|
|
234
|
+
surface: 'things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다'
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const iface of ['FigureSource', 'FigurePart', 'PartMaterial']) {
|
|
239
|
+
it(`${iface} 의 칸은 가르치거나 이유를 적었거나 둘 중 하나다`, () => {
|
|
240
|
+
const prompt = rulesPrompt(PALETTE)
|
|
241
|
+
const silent = declaredFields(iface).filter(
|
|
242
|
+
field => !(field in NOT_TAUGHT[iface]!) && !prompt.includes(field)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
expect(silent).toEqual([])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it(`${iface} 의 제외 목록에 죽은 이름이 없다`, () => {
|
|
249
|
+
/* 형식에서 없어진 칸을 제외 목록이 계속 들고 있으면, 그 목록이 형식을 안 따라간 것이다. */
|
|
250
|
+
const declared = new Set(declaredFields(iface))
|
|
251
|
+
const stale = Object.keys(NOT_TAUGHT[iface]!).filter(field => !declared.has(field))
|
|
252
|
+
|
|
253
|
+
expect(stale).toEqual([])
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('고치라는 요청일 때', () => {
|
|
259
|
+
it('요청이 안 건드린 것을 그대로 실으라고 못 박는다', async () => {
|
|
260
|
+
/*
|
|
261
|
+
이 문장이 없으면 애니메이션이 있는 도형을 「색만 바꿔 줘」로 고쳤을 때 모델이 clip 을
|
|
262
|
+
안 실어 보내고, 그것이 오류 없이 통과해 조용히 사라진다.
|
|
263
|
+
*/
|
|
264
|
+
const client = stubClient([candidate()])
|
|
265
|
+
setDefaultAIClient(client as any)
|
|
266
|
+
await proposeFigure({ prompt: '색을 바꿔 줘', base: candidate() as any, palette: PALETTE })
|
|
267
|
+
|
|
268
|
+
const sent = firstAsk(client)
|
|
269
|
+
expect(sent).toMatch(/Carry over everything the request did not ask you to change/)
|
|
270
|
+
expect(sent).toMatch(/dropping one is silent/)
|
|
271
|
+
|
|
272
|
+
/* 잃기 쉬운 칸은 이름으로 짚어 준다 — 「전부 지켜라」만으로는 모델이 빠뜨린다. */
|
|
273
|
+
for (const field of ['animations', 'capabilities', 'anchor', 'repeat', 'label', 'materialSlot']) {
|
|
274
|
+
expect(sent).toContain(field)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
97
279
|
describe('후보를 받아 볼 때', () => {
|
|
98
280
|
it('온전하면 통과시킨다', () => {
|
|
99
281
|
const looked = inspect(candidate(), PALETTE)
|
|
@@ -257,8 +439,8 @@ describe('후보를 만들 때', () => {
|
|
|
257
439
|
describe('무엇을 시키는지가 프롬프트에 실린다', () => {
|
|
258
440
|
/*
|
|
259
441
|
고치는 요청에서 이 지시가 빠지면 AI 가 부품 이름을 갈아 버린다. 부품 이름은 **저장되는
|
|
260
|
-
식별자**라서, 이미 배치된
|
|
261
|
-
끊기고, 끊긴 것은 그
|
|
442
|
+
식별자**라서, 이미 배치된 도면의 바인딩·애니메이션이 그 이름을 가리키고 있다. 조용히
|
|
443
|
+
끊기고, 끊긴 것은 그 도면을 열어 봐야 드러난다.
|
|
262
444
|
*/
|
|
263
445
|
it('고치는 요청이면 지금 정본과 「이름을 바꾸지 말라」가 함께 나간다', async () => {
|
|
264
446
|
const client = stubClient([candidate()])
|
|
@@ -371,3 +553,73 @@ describe('정책을 넘은 후보는 막지 않고 알린다', () => {
|
|
|
371
553
|
expect(result.violations.map(one => one.code)).toContain('too-many-parts')
|
|
372
554
|
})
|
|
373
555
|
})
|
|
556
|
+
|
|
557
|
+
/*
|
|
558
|
+
* `parameters` — the one field that answers "expose a value an instance can be given".
|
|
559
|
+
*
|
|
560
|
+
* A user asked for a hoist that lowers and raises, with the travel exposed as a variable. The
|
|
561
|
+
* model answered with `sizing`/`anchor` on the figure's own height and invented
|
|
562
|
+
* `capabilities: ["hoist"]`, which threw at render. It had not declined; it had gone looking and
|
|
563
|
+
* not found -- because the door was hidden inside animations as `drive: 'hold'`, and the prompt
|
|
564
|
+
* described that as a playback mode ("hold plays once and stays") when what it actually said was
|
|
565
|
+
* WHO SUPPLIES THE VALUE.
|
|
566
|
+
*
|
|
567
|
+
* ADR-0051 moved it into its own field with a declared physical range, which is what makes the
|
|
568
|
+
* value nameable: an instance gives 1200mm, not 0.67.
|
|
569
|
+
*
|
|
570
|
+
* These guard the wording, because the wording is the defect. A prompt that hides the one door
|
|
571
|
+
* leaves the model to invent a door.
|
|
572
|
+
*/
|
|
573
|
+
describe('값이 만드는 자세를 parameters 로 가르친다', () => {
|
|
574
|
+
const prompt = () => rulesPrompt(PALETTE)
|
|
575
|
+
|
|
576
|
+
it('재생 방식으로 적지 않는다', () => {
|
|
577
|
+
expect(prompt()).not.toMatch(/plays once|repeats forever/i)
|
|
578
|
+
expect(prompt()).toMatch(/Nothing is played back/)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('두 갈래를 갈라서 적는다 — 어느 쪽이 무엇을 받는지까지', () => {
|
|
582
|
+
const text = prompt()
|
|
583
|
+
expect(text).toMatch(/TWO kinds and they are different fields/)
|
|
584
|
+
expect(text).toMatch(/animations\s+TIME RUNS\. The instance supplies a SPEED/)
|
|
585
|
+
expect(text).toMatch(/parameters\s+A VALUE MAKES THE POSE/)
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
it('인스턴스가 받는 값을 물으면 parameters 라고 못 박는다', () => {
|
|
589
|
+
/* 이 문장이 없으면 모델이 크기 반응 쪽으로 간다 — 실제로 갔다. */
|
|
590
|
+
expect(prompt()).toMatch(/USE parameters WHENEVER THE REQUEST ASKS FOR A VALUE THE INSTANCE IS GIVEN/)
|
|
591
|
+
expect(prompt()).toMatch(/EXPOSE something AS A VARIABLE/)
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('세 가지 잘못된 답을 각각 막는다 — 크기 반응 · 애니메이션 · 능력', () => {
|
|
595
|
+
const text = prompt()
|
|
596
|
+
expect(text).toMatch(/Do NOT answer that request with sizing or anchor/)
|
|
597
|
+
expect(text).toMatch(/Do NOT answer it with animations either/)
|
|
598
|
+
/* `capabilities: ['hoist']` 가 실제로 나왔던 답이다. */
|
|
599
|
+
expect(text).toMatch(/Do NOT answer it with a capability/)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('범위를 사람이 말하는 단위로 적으라고 한다 — 0~1 이 아니라', () => {
|
|
603
|
+
/*
|
|
604
|
+
곡선의 값 축은 0~1 이지만 그것은 런타임의 말이다. 저작자와 인스턴스가 주고받는 것은
|
|
605
|
+
mm 이고, 그 선언이 없으면 「길이값을 변수로 노출」이 성립하지 않는다.
|
|
606
|
+
*/
|
|
607
|
+
const text = prompt()
|
|
608
|
+
expect(text).toMatch(/range: \{ unit, min, max \}/)
|
|
609
|
+
expect(text).toMatch(/not 0 to 1/)
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('파라미터의 at 이 초가 아니라고 말한다', () => {
|
|
613
|
+
/*
|
|
614
|
+
값 0~1 이 `[0, 마지막 키]` 로 맵되므로 키의 절대값은 자세를 안 바꾼다 — 간격만 바꾼다.
|
|
615
|
+
그 마지막 값이 전 구간 이동 시간을 겸한다는 것도 함께 적혀 있어야 한다.
|
|
616
|
+
*/
|
|
617
|
+
const text = prompt()
|
|
618
|
+
expect(text).toMatch(/only the SPACING between keys matters/)
|
|
619
|
+
expect(text).toMatch(/doubles as the seconds a full min-to-max move takes/)
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('두 목록이 이름을 나눠 쓴다고 말한다 — 겹치면 형식이 막는다', () => {
|
|
623
|
+
expect(prompt()).toMatch(/may not be called the same thing/)
|
|
624
|
+
})
|
|
625
|
+
})
|