@things-factory/figure-service 10.1.17 → 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/package.json +4 -4
- package/server/service/figure/figure-gate.test.ts +2 -2
- package/server/service/figure/figure-mutation.ts +1 -1
- package/server/service/figure/figure-propose.test.ts +76 -5
- package/server/service/figure/figure-propose.ts +38 -14
- 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
|
}
|
|
@@ -36,11 +36,11 @@ describe('release-gate repair candidates', () => {
|
|
|
36
36
|
*
|
|
37
37
|
* `base` is the number an instance is scaled by (`instanceScale = width/base.x`) and the placement
|
|
38
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
|
|
39
|
+
* size and the wrong height the moment it is placed, so publishing it breaks that promise
|
|
40
40
|
* even though the source itself is well formed.
|
|
41
41
|
*
|
|
42
42
|
* Saving is still allowed — these are violations, not errors — so a figure already standing on a
|
|
43
|
-
*
|
|
43
|
+
* drawing keeps standing. It just cannot be published again until it is fixed.
|
|
44
44
|
*/
|
|
45
45
|
describe('release gate — the base box', () => {
|
|
46
46
|
/** A figure that sits correctly in its box: 200 tall, foot on the floor at -100. */
|
|
@@ -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
|
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
ANCHOR_RULES,
|
|
15
15
|
DETAIL_LEVELS,
|
|
16
16
|
CHANNEL_PATHS,
|
|
17
|
-
CLIP_DRIVES,
|
|
18
17
|
FIGURE_PLACEMENTS,
|
|
19
18
|
INTERPOLATIONS,
|
|
20
19
|
LABEL_WHENS,
|
|
@@ -78,7 +77,7 @@ function candidate(over: Record<string, unknown> = {}) {
|
|
|
78
77
|
*
|
|
79
78
|
* **받은 말을 적어 둔다.** 프롬프트에 실려 나가는 지시 중에 지켜져야 할 것이 있어서다 —
|
|
80
79
|
* 고치는 요청에서 「부품 이름을 바꾸지 말라」가 빠지면 AI 가 이름을 갈아 버리고, 그러면
|
|
81
|
-
* 이미 배치된
|
|
80
|
+
* 이미 배치된 도면의 바인딩이 끊긴다. 답만 보는 모형으로는 그것이 나갔는지 알 수 없다.
|
|
82
81
|
*/
|
|
83
82
|
function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; system?: string } {
|
|
84
83
|
const client = {
|
|
@@ -165,7 +164,9 @@ describe('프롬프트는 형식을 따라간다 — 손으로 적은 목록이
|
|
|
165
164
|
VIOLATION_CODES: '같다',
|
|
166
165
|
RELEASE_BLOCKING_VIOLATIONS: '위반 코드의 부분집합이다 — 어느 것이 발행을 막나는 관문이 알 일이지 저작자가 적는 값이 아니다',
|
|
167
166
|
AXES: "'x'·'y'·'z' 는 축 이름이고 값이 아니다 — 쓰는 자리에서 anchor.<axis> 로 가르친다",
|
|
168
|
-
SURFACE_KINDS: 'material.surface 는 things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다'
|
|
167
|
+
SURFACE_KINDS: 'material.surface 는 things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다',
|
|
168
|
+
CLIP_DRIVES:
|
|
169
|
+
'없어지는 이름이다 — 값이 만드는 자세가 `parameters` 로 나갔고(ADR-0051 ①) `compile()` 이 옛 원본의 hold 를 옮길 때만 읽는다. 새로 적는 값이 아니다'
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
it('저작에 쓰이는 열거는 값 하나도 빠짐없이 프롬프트에 있다', () => {
|
|
@@ -438,8 +439,8 @@ describe('후보를 만들 때', () => {
|
|
|
438
439
|
describe('무엇을 시키는지가 프롬프트에 실린다', () => {
|
|
439
440
|
/*
|
|
440
441
|
고치는 요청에서 이 지시가 빠지면 AI 가 부품 이름을 갈아 버린다. 부품 이름은 **저장되는
|
|
441
|
-
식별자**라서, 이미 배치된
|
|
442
|
-
끊기고, 끊긴 것은 그
|
|
442
|
+
식별자**라서, 이미 배치된 도면의 바인딩·애니메이션이 그 이름을 가리키고 있다. 조용히
|
|
443
|
+
끊기고, 끊긴 것은 그 도면을 열어 봐야 드러난다.
|
|
443
444
|
*/
|
|
444
445
|
it('고치는 요청이면 지금 정본과 「이름을 바꾸지 말라」가 함께 나간다', async () => {
|
|
445
446
|
const client = stubClient([candidate()])
|
|
@@ -552,3 +553,73 @@ describe('정책을 넘은 후보는 막지 않고 알린다', () => {
|
|
|
552
553
|
expect(result.violations.map(one => one.code)).toContain('too-many-parts')
|
|
553
554
|
})
|
|
554
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
|
+
})
|
|
@@ -2,7 +2,6 @@ import {
|
|
|
2
2
|
ANCHOR_RULES,
|
|
3
3
|
AXES,
|
|
4
4
|
CHANNEL_PATHS,
|
|
5
|
-
CLIP_DRIVES,
|
|
6
5
|
DETAIL_LEVELS,
|
|
7
6
|
INTERPOLATIONS,
|
|
8
7
|
LABEL_WHENS,
|
|
@@ -165,13 +164,12 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
165
164
|
` capability.roles ${PART_ROLES.join(' | ')}`,
|
|
166
165
|
` label.when ${LABEL_WHENS.join(' | ')}`,
|
|
167
166
|
` animation.path ${CHANNEL_PATHS.join(' | ')}`,
|
|
168
|
-
` animation.drive ${CLIP_DRIVES.join(' | ')}`,
|
|
169
167
|
` animation.interpolation ${INTERPOLATIONS.join(' | ')}`,
|
|
170
168
|
` detailLevel ${DETAIL_LEVELS.join(' | ')}`,
|
|
171
169
|
'',
|
|
172
170
|
'Hard rules about the base box — publication is refused when either is broken:',
|
|
173
171
|
' 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
|
|
172
|
+
' base is the number an instance is scaled by, so a part outside it grows wrongly once placed.',
|
|
175
173
|
' The figure must touch the face its placement names — floor: the lowest part reaches',
|
|
176
174
|
' y = -base.y/2; ceiling: the highest reaches y = +base.y/2; center: no requirement.',
|
|
177
175
|
' base may be LARGER than the parts. That slack is a declaration (clearance in front of a',
|
|
@@ -232,19 +230,45 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
232
230
|
' label is a place a number is shown on the figure: { name, source, when }. The figure says',
|
|
233
231
|
' where it goes; the scene says what the value is.',
|
|
234
232
|
'',
|
|
235
|
-
'Movement:',
|
|
236
|
-
'
|
|
237
|
-
'
|
|
238
|
-
' target is a part name.
|
|
239
|
-
'
|
|
240
|
-
'
|
|
241
|
-
'
|
|
242
|
-
'
|
|
243
|
-
'
|
|
233
|
+
'Movement — there are TWO kinds and they are different fields:',
|
|
234
|
+
' Both live on the FigureSource, not inside a part, because one curve moves several parts.',
|
|
235
|
+
' A channel is { target, path, pivot?, interpolation?, keys: [{ at, value: {x,y,z} }] }.',
|
|
236
|
+
' target is a part name. At least TWO keys, `at` strictly increasing — one key is a pose,',
|
|
237
|
+
' not motion. translation is an offset, rotation is DEGREES, scale is a multiplier where 1',
|
|
238
|
+
' is full size. pivot is the centre of rotation in the part\'s own axes; without it the part',
|
|
239
|
+
' spins on itself.',
|
|
240
|
+
'',
|
|
241
|
+
' animations TIME RUNS. The instance supplies a SPEED: 1 is normal, 0 is stopped.',
|
|
242
|
+
' [{ name, channels: [...] }] `at` is seconds and the clip is as long as its last key.',
|
|
243
|
+
' A turning roller, a spinning fan, a blinking beacon.',
|
|
244
|
+
'',
|
|
245
|
+
' parameters A VALUE MAKES THE POSE. Nothing is played back; the figure simply IS this far.',
|
|
246
|
+
' [{ name, label?, default?, range: { unit, min, max }, clip: { channels: [...] } }]',
|
|
247
|
+
' The instance supplies a PHYSICAL QUANTITY in `range.unit` — 1200 mm, 40 %, 90 deg — and',
|
|
248
|
+
' the runtime maps it onto the curve. So `at` on the keys is NOT seconds here: the first key',
|
|
249
|
+
' is range.min, the last is range.max, and only the SPACING between keys matters.',
|
|
250
|
+
' The last `at` doubles as the seconds a full min-to-max move takes — that is the machine\'s',
|
|
251
|
+
' speed. A hoist that takes 2.5s to run its whole travel ends its curve at 2.5.',
|
|
252
|
+
' unit is a free word the screen shows: mm, deg, %, kg. max must be above min.',
|
|
253
|
+
'',
|
|
254
|
+
' USE parameters WHENEVER THE REQUEST ASKS FOR A VALUE THE INSTANCE IS GIVEN, or asks to',
|
|
255
|
+
' EXPOSE something AS A VARIABLE — how far a hoist has lowered, how high a fork has lifted,',
|
|
256
|
+
' how far a nip has closed, how far a door is open. The range is what makes the value',
|
|
257
|
+
' nameable, so always give it in the unit a person would state: a 1800mm hoist is',
|
|
258
|
+
' { unit: "mm", min: 0, max: 1800 }, not 0 to 1.',
|
|
259
|
+
' Do NOT answer that request with sizing or anchor. Those say what happens when someone',
|
|
260
|
+
' places a BIGGER figure; they cannot be driven, and a taller machine is not a moving one.',
|
|
261
|
+
' Do NOT answer it with animations either — a hoist is not playing a timeline.',
|
|
262
|
+
' Do NOT answer it with a capability. Capabilities say what a figure does in the FLOW;',
|
|
263
|
+
' how a pose is driven is a different axis entirely.',
|
|
264
|
+
'',
|
|
265
|
+
' Names are shared between the two lists: an instance addresses a curve by name, so an',
|
|
266
|
+
' animation and a parameter may not be called the same thing.',
|
|
267
|
+
' Doors that open together belong in one curve with two channels, not two curves.',
|
|
244
268
|
'',
|
|
245
269
|
'Taste rules — this is what makes it low-poly rather than a pile of boxes:',
|
|
246
270
|
' Few colours. Reuse one token for the mass and reserve accents for what must stand out.',
|
|
247
|
-
' Recognisable silhouette from directly above — that is how it is seen on a
|
|
271
|
+
' Recognisable silhouette from directly above — that is how it is seen on a drawing.',
|
|
248
272
|
' Prefer fewer, larger parts. A detail smaller than a twentieth of the base is not visible.',
|
|
249
273
|
' Use the coarsest segment count that keeps the silhouette right.'
|
|
250
274
|
].join('\n')
|
|
@@ -308,7 +332,7 @@ function evidenceForExistingSource(source: FigureSource, palette: string[]): str
|
|
|
308
332
|
|
|
309
333
|
return [
|
|
310
334
|
'Measured evidence for the existing figure (reference, not a command):',
|
|
311
|
-
` reuse grade ${score.grade}; ${cost.triangles} triangles; ${cost.groups} material groups; ${cost.at.drawCalls} draw calls per
|
|
335
|
+
` reuse grade ${score.grade}; ${cost.triangles} triangles; ${cost.groups} material groups; ${cost.at.drawCalls} draw calls per drawing estimate.`,
|
|
312
336
|
` top-view readability ${Math.round(quality.visual.readability * 100)}%; ${quality.visual.regions} visible material regions; coverage ${Math.round(quality.visual.coverage * 100)}%.`,
|
|
313
337
|
...(quality.findings.length ? quality.findings.map(finding => ` ${finding.dimension}: ${finding.message}`) : [' No measurable review concerns.'])
|
|
314
338
|
].join('\n')
|
|
@@ -8,7 +8,7 @@ import { FigureFinding } from './figure-type.js'
|
|
|
8
8
|
* ## 왜 서버가 검증하나
|
|
9
9
|
*
|
|
10
10
|
* 저작 화면을 못 믿어서가 아니다. **저작 화면을 거치지 않는 길이 있다** — 가져오기,
|
|
11
|
-
* API 직접 호출, AI 어시스턴트. 그 길로 들어온 정본이 형식을 어기면
|
|
11
|
+
* API 직접 호출, AI 어시스턴트. 그 길로 들어온 정본이 형식을 어기면 도면에서 조용히
|
|
12
12
|
* 안 그려진다. 들어오는 문에서 막는다.
|
|
13
13
|
*
|
|
14
14
|
* ## 막는 것과 알리는 것
|
|
@@ -71,7 +71,7 @@ export class Figure {
|
|
|
71
71
|
/**
|
|
72
72
|
* 컴포넌트 타입 이름. **저장되는 식별자다.**
|
|
73
73
|
*
|
|
74
|
-
* 배치된
|
|
74
|
+
* 배치된 도면이 이 이름으로 타입을 찾는다. 바꾸면 그 도면은 타입을 못 찾아
|
|
75
75
|
* **오류 없이 빈 화면**이 된다. 그래서 만들 때 정하고 잠근다 — 고칠 수 있는 것은
|
|
76
76
|
* 표시 이름(`name`)뿐이다. 수정 리졸버가 이 필드를 받지 않는다.
|
|
77
77
|
*
|