@things-factory/figure-service 10.1.64 → 10.1.66
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-mutation.d.ts +4 -8
- package/dist-server/service/figure/figure-mutation.js +13 -27
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-propose-type.d.ts +8 -59
- package/dist-server/service/figure/figure-propose-type.js +15 -150
- package/dist-server/service/figure/figure-propose-type.js.map +1 -1
- package/dist-server/service/figure/figure-propose-v3.d.ts +50 -0
- package/dist-server/service/figure/figure-propose-v3.js +143 -0
- package/dist-server/service/figure/figure-propose-v3.js.map +1 -0
- package/dist-server/service/figure/figure-tools.js +64 -90
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/figure/figure-llm-smoke.test.ts +28 -32
- package/server/service/figure/figure-mutation.ts +14 -28
- package/server/service/figure/figure-propose-type.ts +16 -124
- package/server/service/figure/figure-propose-v3.test.ts +117 -0
- package/server/service/figure/figure-propose-v3.ts +174 -0
- package/server/service/figure/figure-tools.test.ts +86 -133
- package/server/service/figure/figure-tools.ts +60 -91
- package/dist-server/service/figure/figure-propose.d.ts +0 -91
- package/dist-server/service/figure/figure-propose.js +0 -439
- package/dist-server/service/figure/figure-propose.js.map +0 -1
- package/dist-server/service/figure/figure-quality.d.ts +0 -29
- package/dist-server/service/figure/figure-quality.js +0 -62
- package/dist-server/service/figure/figure-quality.js.map +0 -1
- package/server/service/figure/figure-ai-flow.test.ts +0 -93
- package/server/service/figure/figure-e2e-smoke.test.ts +0 -72
- package/server/service/figure/figure-propose.test.ts +0 -719
- package/server/service/figure/figure-propose.ts +0 -551
- package/server/service/figure/figure-quality.test.ts +0 -43
- package/server/service/figure/figure-quality.ts +0 -98
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.66",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -23,7 +23,7 @@
|
|
|
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.76",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
28
|
"@things-factory/ai-client-base": "^10.1.53",
|
|
29
29
|
"@things-factory/auth-base": "^10.1.47",
|
|
@@ -33,5 +33,5 @@
|
|
|
33
33
|
"graphql-upload": "^15.0.2",
|
|
34
34
|
"koa-router": "^7.4.0"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "f92ae2a32e389f17eba0ce5770a98866ac3b1209"
|
|
37
37
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 실제 LLM Figure 후보 생성 smoke test (기본 비활성).
|
|
3
3
|
*
|
|
4
|
-
* 결정적 시험은 프롬프트·도구·검사 배선을 고정하지만, 실제 모델이
|
|
5
|
-
* 대신할 수 없다. 이 시험은 `RUN_FIGURE_LLM_SMOKE=1`과 공급자 API 키가 모두 있을 때만 실행한다.
|
|
4
|
+
* 결정적 시험은 프롬프트·도구·검사 배선을 고정하지만, 실제 모델이 V3 명령 목록(ADR-0094)을 써서 게이트를
|
|
5
|
+
* 지나는 도형을 만드는지는 대신할 수 없다. 이 시험은 `RUN_FIGURE_LLM_SMOKE=1`과 공급자 API 키가 모두 있을 때만 실행한다.
|
|
6
6
|
* DB·GraphQL·Figure 저장을 건드리지 않고 후보 생성 함수만 호출하므로, 외부 영향은 API 비용뿐이다.
|
|
7
7
|
*
|
|
8
8
|
* 실행:
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { createAIClient, setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { applyV3Proposal, createV3Asset, inspectV3Asset } from '@hatiolab/figure-model'
|
|
15
|
+
|
|
16
|
+
import { proposeFigureV3 } from './figure-propose-v3'
|
|
15
17
|
|
|
16
18
|
// Provider modules register themselves on import. Keep this separate from the deterministic tests.
|
|
17
19
|
import '@things-factory/ai-client-base'
|
|
@@ -35,7 +37,6 @@ function provider(): { provider: Provider; apiKey: string } | undefined {
|
|
|
35
37
|
|
|
36
38
|
const picked = process.env.RUN_FIGURE_LLM_SMOKE === '1' ? provider() : undefined
|
|
37
39
|
const describeIf = picked ? describe : describe.skip
|
|
38
|
-
const PALETTE = ['palette.primary', 'palette.accent', 'palette.neutral']
|
|
39
40
|
|
|
40
41
|
describeIf('Figure 실제 LLM smoke', () => {
|
|
41
42
|
beforeAll(() => {
|
|
@@ -47,45 +48,40 @@ describeIf('Figure 실제 LLM smoke', () => {
|
|
|
47
48
|
afterAll(() => setDefaultAIClient(undefined))
|
|
48
49
|
|
|
49
50
|
test(
|
|
50
|
-
'새 Figure
|
|
51
|
+
'새 Figure 는 게이트를 지나는 명령 목록으로 온다',
|
|
51
52
|
async () => {
|
|
52
|
-
const result = await
|
|
53
|
+
const result = await proposeFigureV3({
|
|
53
54
|
prompt: 'two cylindrical rollers inside a simple rectangular press frame; make the rollers visually distinct',
|
|
54
|
-
|
|
55
|
-
palette: PALETTE,
|
|
56
|
-
refine: true
|
|
55
|
+
name: 'Roll press'
|
|
57
56
|
})
|
|
58
57
|
|
|
59
|
-
expect(result.
|
|
60
|
-
expect(result.source.
|
|
61
|
-
expect(result.source.parts.every(part => PALETTE.includes(part.material.token))).toBe(true)
|
|
62
|
-
expect(result.quality.status).toMatch(/^(ready|review)$/)
|
|
58
|
+
expect(result.steps.length).toBeGreaterThan(0)
|
|
59
|
+
expect(inspectV3Asset(result.source).violations).toEqual([])
|
|
63
60
|
expect(result.cost.triangles).toBeGreaterThan(0)
|
|
61
|
+
// eslint-disable-next-line no-console
|
|
62
|
+
console.log(`[figure-llm-smoke] new: ${result.steps.length} steps, ${result.attempts} attempt(s)`)
|
|
64
63
|
},
|
|
65
|
-
|
|
64
|
+
120_000
|
|
66
65
|
)
|
|
67
66
|
|
|
68
67
|
test(
|
|
69
|
-
'
|
|
68
|
+
'고치는 제안은 건드리지 않은 부품을 그대로 둔다',
|
|
70
69
|
async () => {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{ name: 'port', primitive: 'cube', transform: { position: { x: 0, y: 230, 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
|
-
})
|
|
70
|
+
const draft = applyV3Proposal(createV3Asset('Mixer'), [
|
|
71
|
+
{ kind: 'add-part', part: { id: 'body', kind: 'cylinder', dimensions: { radius: 140, length: 320 } } },
|
|
72
|
+
{ kind: 'add-part', part: { id: 'port', kind: 'box', dimensions: { width: 80, height: 60, depth: 30 } } },
|
|
73
|
+
{ kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } },
|
|
74
|
+
{ kind: 'attach', part: 'port', face: 'back', to: { part: 'body', face: 'front' } },
|
|
75
|
+
{ kind: 'fit-room', placement: 'floor' }
|
|
76
|
+
] as any)
|
|
77
|
+
const result = await proposeFigureV3({ prompt: 'make the port twice as wide and give it an accent colour', base: draft })
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
expect(
|
|
87
|
-
expect(
|
|
79
|
+
const ids = result.source.document.model.nodes.map((n: any) => n.id)
|
|
80
|
+
expect(ids.some((id: string) => id.includes('body'))).toBe(true)
|
|
81
|
+
expect(ids.some((id: string) => id.includes('port'))).toBe(true)
|
|
82
|
+
// eslint-disable-next-line no-console
|
|
83
|
+
console.log(`[figure-llm-smoke] revise: ${JSON.stringify(result.steps)}`)
|
|
88
84
|
},
|
|
89
|
-
|
|
85
|
+
120_000
|
|
90
86
|
)
|
|
91
87
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { In } from 'typeorm'
|
|
2
2
|
import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
|
|
3
3
|
|
|
4
|
-
import { placeholderType } from '@hatiolab/figure-model'
|
|
4
|
+
import { parseV3Asset, placeholderType } from '@hatiolab/figure-model'
|
|
5
5
|
import { getRepository } from '@things-factory/shell'
|
|
6
6
|
|
|
7
7
|
import { Figure } from './figure.js'
|
|
@@ -11,7 +11,7 @@ import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
|
|
|
11
11
|
import { inspectAnySource } from './figure-format.js'
|
|
12
12
|
import { renameDraftType, renameFacts } from './figure-rename.js'
|
|
13
13
|
import { parseAnySource } from './figure-format.js'
|
|
14
|
-
import {
|
|
14
|
+
import { ProposeV3Failure, proposeFigureV3 } from './figure-propose-v3.js'
|
|
15
15
|
import { prepareV3Write, compareAndSwapV3, pictureOf } from './figure-v3-write.js'
|
|
16
16
|
|
|
17
17
|
@Resolver(Figure)
|
|
@@ -399,19 +399,15 @@ export class FigureMutation {
|
|
|
399
399
|
}
|
|
400
400
|
|
|
401
401
|
/**
|
|
402
|
-
* 저작 보조에게
|
|
402
|
+
* 저작 보조에게 제안을 시킨다(ADR-0094).
|
|
403
403
|
*
|
|
404
|
-
* **저장하지 않는다.**
|
|
405
|
-
* 뒤다.
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
* 형식을 어긴 후보는 여기까지 오지 않는다 — `proposeFigure` 가 사유를 모델에
|
|
409
|
-
* 돌려주고 다시 시킨다. 끝내 못 만들면 **사유와 함께 던진다.** 반쯤 만든 것을
|
|
410
|
-
* 돌려주지 않는다.
|
|
404
|
+
* **저장하지 않는다.** 모델러 명령 목록과 그것이 만드는 도형을 돌려줄 뿐이고, 정본이 되는 것은 저작자가 모델러에
|
|
405
|
+
* 적용하고 저장한 뒤다. 커널이 거절한 명령이나 게이트가 잡은 것은 여기까지 오지 않는다 — 제안기가 사유를 모델에
|
|
406
|
+
* 돌려주고 다시 시킨다. 끝내 못 만들면 **사유와 함께 던진다.** 반쯤 만든 것을 돌려주지 않는다.
|
|
411
407
|
*/
|
|
412
408
|
@Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
|
|
413
409
|
@Mutation(returns => FigureProposal, {
|
|
414
|
-
description: 'Asks the authoring assistant for
|
|
410
|
+
description: 'Asks the authoring assistant for modeller commands that make or revise a figure. Nothing is saved.'
|
|
415
411
|
})
|
|
416
412
|
async proposeFigure(@Arg('request') request: ProposeRequest): Promise<FigureProposal> {
|
|
417
413
|
// 그림은 메모리로 받는다. 참고용이라 저장하지 않는다 — 남기면 지울 사람이 없다.
|
|
@@ -426,33 +422,23 @@ export class FigureMutation {
|
|
|
426
422
|
}
|
|
427
423
|
|
|
428
424
|
try {
|
|
429
|
-
const result = await
|
|
425
|
+
const result = await proposeFigureV3({
|
|
430
426
|
prompt: request.prompt,
|
|
431
|
-
base: request.base ?
|
|
432
|
-
|
|
433
|
-
palette: request.palette,
|
|
434
|
-
refine: request.refine,
|
|
435
|
-
feedback: request.feedback,
|
|
427
|
+
base: request.base ? parseV3Asset(request.base) : undefined,
|
|
428
|
+
name: request.name,
|
|
436
429
|
image
|
|
437
430
|
})
|
|
438
431
|
|
|
439
432
|
return {
|
|
433
|
+
steps: JSON.stringify(result.steps),
|
|
440
434
|
source: JSON.stringify(result.source),
|
|
441
|
-
score: Math.round(result.score.score),
|
|
442
|
-
grade: result.score
|
|
435
|
+
score: result.score ? Math.round(result.score.score) : undefined,
|
|
436
|
+
grade: result.score?.grade,
|
|
443
437
|
triangles: result.cost.triangles,
|
|
444
|
-
groups: result.cost.groups,
|
|
445
|
-
quality: {
|
|
446
|
-
...result.quality,
|
|
447
|
-
visualRegions: result.quality.visual.regions,
|
|
448
|
-
visualCoverage: result.quality.visual.coverage,
|
|
449
|
-
visualReadability: result.quality.visual.readability
|
|
450
|
-
},
|
|
451
|
-
violations: result.violations,
|
|
452
438
|
attempts: result.attempts
|
|
453
439
|
}
|
|
454
440
|
} catch (e) {
|
|
455
|
-
if (e instanceof
|
|
441
|
+
if (e instanceof ProposeV3Failure) {
|
|
456
442
|
// 사유를 삼키지 않는다. 저작자가 무엇이 막혔는지 알아야 말을 고쳐 다시 시킨다.
|
|
457
443
|
throw new Error([e.message, ...e.reasons].join('\n'))
|
|
458
444
|
}
|
|
@@ -2,153 +2,45 @@ import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
|
|
|
2
2
|
import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
|
|
3
3
|
import { Field, InputType, Int, ObjectType } from 'type-graphql'
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
73
|
-
/**
|
|
74
|
-
* 저작 보조에 넘기는 것.
|
|
75
|
-
*
|
|
76
|
-
* ## 팔레트를 클라이언트가 보낸다
|
|
77
|
-
*
|
|
78
|
-
* 서버가 팔레트를 알 수 없다. 팔레트는 things-scene 이 갖고 있는데, 그 패키지의
|
|
79
|
-
* node 조건은 transfer 번들로 가고 거기에는 `activePalette` 이 없다 [확인함].
|
|
80
|
-
* 그리는 쪽이 어떤 토큰을 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.
|
|
81
|
-
*
|
|
82
|
-
* **그래서 저장 문(`parseSource`)은 토큰을 검사하지 못한다.** `validate()` 도
|
|
83
|
-
* 못 한다 — figure-model 이 형식은 갖고 있지만 색 어휘는 things-scene 에 있다.
|
|
84
|
-
* 색 어휘가 형식의 일부라면 figure-model 이 갖는 것이 맞다. **figure-model 에 올릴 것.**
|
|
85
|
-
*/
|
|
5
|
+
/** What the authoring assistant is asked for (ADR-0094). */
|
|
86
6
|
@InputType({ description: 'What to ask the authoring assistant for' })
|
|
87
7
|
export class ProposeRequest {
|
|
88
8
|
@Field({ description: 'What the author asked for, in their own words' })
|
|
89
9
|
prompt: string
|
|
90
10
|
|
|
91
|
-
@Field({ nullable: true, description: 'The figure source to revise, as JSON. Omit to create a new one.' })
|
|
11
|
+
@Field({ nullable: true, description: 'The V3 figure source to revise, as JSON. Omit to create a new one.' })
|
|
92
12
|
base?: string
|
|
93
13
|
|
|
94
|
-
@Field({ nullable: true, description: '
|
|
95
|
-
|
|
14
|
+
@Field({ nullable: true, description: 'The name a person reads for a new figure. Ignored when base is given.' })
|
|
15
|
+
name?: string
|
|
96
16
|
|
|
97
|
-
/**
|
|
98
|
-
* 그림.
|
|
99
|
-
*
|
|
100
|
-
* 비율과 덩어리를 읽는 데 쓴다. 표면 무늬를 옮기는 데 쓰지 않는다 — 로우폴리는
|
|
101
|
-
* 텍스처가 0 인 것이 목표다.
|
|
102
|
-
*/
|
|
17
|
+
/** For proportions and masses, not surface detail: a low-poly figure carries no texture. */
|
|
103
18
|
@Field(type => GraphQLUpload, { nullable: true, description: 'Reference photo or render' })
|
|
104
19
|
image?: FileUpload
|
|
105
|
-
|
|
106
|
-
@Field(type => [String], { description: 'Palette tokens the client can resolve. Anything else is rejected.' })
|
|
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[]
|
|
117
20
|
}
|
|
118
21
|
|
|
119
22
|
/**
|
|
120
|
-
*
|
|
23
|
+
* A proposal: the modeller commands, and what the figure they make weighs.
|
|
121
24
|
*
|
|
122
|
-
*
|
|
123
|
-
* 돌려주기만 한다.
|
|
25
|
+
* **Not the canon.** The modeller applies the steps as one step of its history; nothing is saved here.
|
|
124
26
|
*/
|
|
125
|
-
@ObjectType({ description: 'A figure the assistant proposes. Not saved — the author
|
|
27
|
+
@ObjectType({ description: 'A figure the assistant proposes, as modeller commands. Not saved — the author applies it or leaves it.' })
|
|
126
28
|
export class FigureProposal {
|
|
127
|
-
@Field({ description: 'The
|
|
29
|
+
@Field({ description: 'The V3 authoring commands, as a JSON array, in the order they apply' })
|
|
30
|
+
steps: string
|
|
31
|
+
|
|
32
|
+
@Field({ description: 'The V3 figure source the steps make, as JSON' })
|
|
128
33
|
source: string
|
|
129
34
|
|
|
130
|
-
@Field(type => Int, { description: 'Reuse score out of 100' })
|
|
131
|
-
score
|
|
35
|
+
@Field(type => Int, { nullable: true, description: 'Reuse score out of 100' })
|
|
36
|
+
score?: number
|
|
132
37
|
|
|
133
|
-
@Field({ description: 'Grade from the score — A, B, C or D' })
|
|
134
|
-
grade
|
|
38
|
+
@Field({ nullable: true, description: 'Grade from the score — A, B, C or D' })
|
|
39
|
+
grade?: string
|
|
135
40
|
|
|
136
41
|
@Field(type => Int, { description: 'Triangles in one instance' })
|
|
137
42
|
triangles: number
|
|
138
43
|
|
|
139
|
-
@Field(type => Int, { description: 'Material groups — the draw calls one instance costs' })
|
|
140
|
-
groups: number
|
|
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
|
-
|
|
147
|
-
@Field(type => [FigureFinding], {
|
|
148
|
-
description: 'Policy violations. These do not block the proposal — the author decides.'
|
|
149
|
-
})
|
|
150
|
-
violations: FigureFinding[]
|
|
151
|
-
|
|
152
44
|
@Field(type => Int, { description: 'How many attempts the assistant needed. Diagnostic.' })
|
|
153
45
|
attempts: number
|
|
154
46
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* figure-propose-v3 — an AI proposal for a V3 figure is a list of the modeller's commands (ADR-0094).
|
|
3
|
+
*
|
|
4
|
+
* What is held here is our filter, not the model's skill: the model is not called. The steps are applied on a copy;
|
|
5
|
+
* the step the kernel refuses, and the gate findings the steps bring in, go back to the model; after three attempts
|
|
6
|
+
* the proposer says it could not make it, and nothing half made comes back.
|
|
7
|
+
*/
|
|
8
|
+
import { V3_PROPOSAL_GRAMMAR, applyV3Proposal, createV3Asset, inspectV3Asset } from '@hatiolab/figure-model'
|
|
9
|
+
import { setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
10
|
+
import type { AIClient } from '@things-factory/ai-client-base'
|
|
11
|
+
|
|
12
|
+
import { ProposeV3Failure, proposeFigureV3, rulesPromptV3 } from './figure-propose-v3'
|
|
13
|
+
|
|
14
|
+
function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; system?: string } {
|
|
15
|
+
const client = {
|
|
16
|
+
id: 'stub',
|
|
17
|
+
calls: 0,
|
|
18
|
+
sent: [] as any[][],
|
|
19
|
+
system: undefined as string | undefined,
|
|
20
|
+
async chat() {
|
|
21
|
+
return ''
|
|
22
|
+
},
|
|
23
|
+
async generateJSON(messages: any[], options?: { systemPrompt?: string }) {
|
|
24
|
+
client.sent.push(JSON.parse(JSON.stringify(messages)))
|
|
25
|
+
client.system = options?.systemPrompt
|
|
26
|
+
const answer = answers[client.calls]
|
|
27
|
+
client.calls++
|
|
28
|
+
if (answer instanceof Error) throw answer
|
|
29
|
+
return answer
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return client as unknown as AIClient & { calls: number; sent: any[][]; system?: string }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(() => setDefaultAIClient(undefined))
|
|
36
|
+
|
|
37
|
+
const liftTable = [
|
|
38
|
+
{ kind: 'add-part', part: { id: 'base', kind: 'box', dimensions: { width: 1200, height: 80, depth: 800 } } },
|
|
39
|
+
{ kind: 'add-part', part: { id: 'column', kind: 'cylinder', dimensions: { radius: 90, length: 600 } } },
|
|
40
|
+
{ kind: 'add-part', part: { id: 'top', kind: 'box', dimensions: { width: 1200, height: 60, depth: 800 } } },
|
|
41
|
+
{ kind: 'attach', part: 'base', face: 'bottom', to: { plane: 'mounting-plane' } },
|
|
42
|
+
{ kind: 'attach', part: 'column', face: 'bottom', to: { part: 'base', face: 'top' } },
|
|
43
|
+
{ kind: 'attach', part: 'top', face: 'bottom', to: { part: 'column', face: 'top' } },
|
|
44
|
+
{ kind: 'set-material', target: 'top', material: { token: 'palette.accent' }, palette: { 'palette.accent': '#d08a2c' } },
|
|
45
|
+
{ kind: 'fit-room', placement: 'floor' }
|
|
46
|
+
]
|
|
47
|
+
const withoutRoom = liftTable.slice(0, -1)
|
|
48
|
+
const wrongFace = liftTable.map((s, i) => (i === 4 ? { ...s, face: 'left' } : s))
|
|
49
|
+
|
|
50
|
+
describe('the model is told the kernel\'s commands and the open draft, never V2', () => {
|
|
51
|
+
it('teaches every word of the grammar, in its own shape', () => {
|
|
52
|
+
const rules = rulesPromptV3()
|
|
53
|
+
for (const word of V3_PROPOSAL_GRAMMAR) expect(rules).toContain(word.shape)
|
|
54
|
+
expect(rules).not.toMatch(/figure-v1|"parts"\s*:\s*\[|anchor/i)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('a measure asked to be the same as another is linked, not copied (:3300, 2026-09-26: 「몸통 지름과 같게」 came as a fixed 100)', () => {
|
|
58
|
+
expect(rulesPromptV3()).toMatch(/same as, matches or follows another part or the figure, write link-dimension/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('a revision carries the draft\'s V3 source', async () => {
|
|
62
|
+
const draft = applyV3Proposal(createV3Asset('Lift table'), liftTable as any)
|
|
63
|
+
const client = stubClient([{ steps: [{ kind: 'rename', name: 'Lift table 2' }] }])
|
|
64
|
+
setDefaultAIClient(client)
|
|
65
|
+
const made = await proposeFigureV3({ prompt: '이름을 바꿔 줘', base: draft })
|
|
66
|
+
expect(client.sent[0]![0]!.content).toContain('"version":"figure-v3-asset-1"')
|
|
67
|
+
expect(made.source.name).toBe('Lift table 2')
|
|
68
|
+
expect(draft.name).toBe('Lift table')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('the steps are held to the kernel and the gate', () => {
|
|
73
|
+
it('a list the kernel takes comes back with the figure it makes, the gate clean, on the first attempt', async () => {
|
|
74
|
+
setDefaultAIClient(stubClient([{ steps: liftTable }]))
|
|
75
|
+
const made = await proposeFigureV3({ prompt: '리프트 테이블', name: 'Lift table' })
|
|
76
|
+
expect(made.attempts).toBe(1)
|
|
77
|
+
expect(made.steps).toEqual(liftTable)
|
|
78
|
+
expect(inspectV3Asset(made.source).violations).toEqual([])
|
|
79
|
+
expect(made.score?.grade).toMatch(/^[ABCD]$/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a refused step goes back with its place and the kernel\'s code, and the next answer is taken', async () => {
|
|
83
|
+
const client = stubClient([{ steps: wrongFace }, { steps: liftTable }])
|
|
84
|
+
setDefaultAIClient(client)
|
|
85
|
+
const made = await proposeFigureV3({ prompt: '리프트 테이블', name: 'Lift table' })
|
|
86
|
+
expect(made.attempts).toBe(2)
|
|
87
|
+
const said = client.sent[1]!.at(-1)!.content as string
|
|
88
|
+
expect(said).toMatch(/ATTACH_FACE: steps\[4\]: attach: /)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('a new figure must come out whole: a list that leaves the room undeclared is sent back', async () => {
|
|
92
|
+
const client = stubClient([{ steps: withoutRoom }, { steps: liftTable }])
|
|
93
|
+
setDefaultAIClient(client)
|
|
94
|
+
const made = await proposeFigureV3({ prompt: '리프트 테이블', name: 'Lift table' })
|
|
95
|
+
expect(made.attempts).toBe(2)
|
|
96
|
+
expect(client.sent[1]!.at(-1)!.content).toMatch(/OCCUPANCY_UNDECLARED/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('a revision is not blamed for what the draft already had', async () => {
|
|
100
|
+
const draft = applyV3Proposal(createV3Asset('Lift table'), withoutRoom as any)
|
|
101
|
+
setDefaultAIClient(stubClient([{ steps: [{ kind: 'rename', name: 'Lift' }] }]))
|
|
102
|
+
const made = await proposeFigureV3({ prompt: '이름', base: draft })
|
|
103
|
+
expect(made.attempts).toBe(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('three refusals said back, then it says it could not make it, with the last reasons -- nothing half made', async () => {
|
|
107
|
+
const client = stubClient([{ steps: wrongFace }, new Error('cut short'), { nope: true }])
|
|
108
|
+
setDefaultAIClient(client)
|
|
109
|
+
const failure = await proposeFigureV3({ prompt: '리프트 테이블', name: 'Lift table' }).catch(e => e)
|
|
110
|
+
expect(failure).toBeInstanceOf(ProposeV3Failure)
|
|
111
|
+
expect(failure.message).toBe('만들지 못했습니다.')
|
|
112
|
+
expect(failure.attempts).toBe(3)
|
|
113
|
+
expect(failure.reasons).toEqual(['The answer must be { "steps": [ ... ] }.'])
|
|
114
|
+
expect(client.calls).toBe(3)
|
|
115
|
+
expect(client.sent[2]!.at(-1)!.content).toMatch(/not valid JSON: cut short/)
|
|
116
|
+
})
|
|
117
|
+
})
|