@things-factory/figure-service 10.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +34 -0
  2. package/dist-server/index.d.ts +2 -0
  3. package/dist-server/index.js +6 -0
  4. package/dist-server/index.js.map +1 -0
  5. package/dist-server/migrations/index.d.ts +1 -0
  6. package/dist-server/migrations/index.js +12 -0
  7. package/dist-server/migrations/index.js.map +1 -0
  8. package/dist-server/service/figure/figure-mutation.d.ts +19 -0
  9. package/dist-server/service/figure/figure-mutation.js +171 -0
  10. package/dist-server/service/figure/figure-mutation.js.map +1 -0
  11. package/dist-server/service/figure/figure-propose-type.d.ts +43 -0
  12. package/dist-server/service/figure/figure-propose-type.js +89 -0
  13. package/dist-server/service/figure/figure-propose-type.js.map +1 -0
  14. package/dist-server/service/figure/figure-propose.d.ts +67 -0
  15. package/dist-server/service/figure/figure-propose.js +213 -0
  16. package/dist-server/service/figure/figure-propose.js.map +1 -0
  17. package/dist-server/service/figure/figure-query.d.ts +16 -0
  18. package/dist-server/service/figure/figure-query.js +77 -0
  19. package/dist-server/service/figure/figure-query.js.map +1 -0
  20. package/dist-server/service/figure/figure-source.d.ts +45 -0
  21. package/dist-server/service/figure/figure-source.js +53 -0
  22. package/dist-server/service/figure/figure-source.js.map +1 -0
  23. package/dist-server/service/figure/figure-type.d.ts +57 -0
  24. package/dist-server/service/figure/figure-type.js +149 -0
  25. package/dist-server/service/figure/figure-type.js.map +1 -0
  26. package/dist-server/service/figure/figure.d.ts +71 -0
  27. package/dist-server/service/figure/figure.js +160 -0
  28. package/dist-server/service/figure/figure.js.map +1 -0
  29. package/dist-server/service/figure/index.d.ts +5 -0
  30. package/dist-server/service/figure/index.js +9 -0
  31. package/dist-server/service/figure/index.js.map +1 -0
  32. package/dist-server/service/index.d.ts +5 -0
  33. package/dist-server/service/index.js +13 -0
  34. package/dist-server/service/index.js.map +1 -0
  35. package/package.json +33 -0
  36. package/server/index.ts +2 -0
  37. package/server/migrations/index.ts +9 -0
  38. package/server/service/figure/figure-mutation.ts +163 -0
  39. package/server/service/figure/figure-propose-type.ts +74 -0
  40. package/server/service/figure/figure-propose.test.ts +164 -0
  41. package/server/service/figure/figure-propose.ts +271 -0
  42. package/server/service/figure/figure-query.ts +60 -0
  43. package/server/service/figure/figure-source.test.ts +129 -0
  44. package/server/service/figure/figure-source.ts +87 -0
  45. package/server/service/figure/figure-type.ts +108 -0
  46. package/server/service/figure/figure.ts +168 -0
  47. package/server/service/figure/index.ts +6 -0
  48. package/server/service/index.ts +10 -0
  49. package/things-factory.config.js +1 -0
  50. package/tsconfig.json +14 -0
@@ -0,0 +1,163 @@
1
+ import { In } from 'typeorm'
2
+ import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
3
+
4
+ import { getRepository } from '@things-factory/shell'
5
+
6
+ import { Figure } from './figure.js'
7
+ import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
8
+ import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
9
+ import { parseSource } from './figure-source.js'
10
+ import { ProposeFailure, proposeFigure } from './figure-propose.js'
11
+
12
+ @Resolver(Figure)
13
+ export class FigureMutation {
14
+ @Directive('@transaction')
15
+ @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
16
+ @Mutation(returns => FigureSaveResult, { description: 'Creates a figure. Rejects when the source violates the format.' })
17
+ async createFigure(
18
+ @Arg('figure') figure: NewFigure,
19
+ @Ctx() context: ResolverContext
20
+ ): Promise<FigureSaveResult> {
21
+ const { domain, user, tx } = context.state
22
+ const repository = tx.getRepository(Figure)
23
+
24
+ // 타입 이름은 도메인 안에서 유일하다. 유니크 인덱스가 있지만 여기서 먼저 본다 —
25
+ // DB 오류 문구는 저작자에게 아무 말도 해 주지 못한다.
26
+ const taken = await repository.findOne({ where: { domain: { id: domain.id }, type: figure.type } })
27
+ if (taken) {
28
+ throw new Error(`타입 이름 '${figure.type}' 은 이미 쓰이고 있다. 만든 뒤에는 고칠 수 없으므로 다른 이름을 고른다`)
29
+ }
30
+
31
+ // 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
32
+ const { source, derived, violations } = parseSource(figure.source)
33
+
34
+ const saved = await repository.save({
35
+ ...figure,
36
+ source,
37
+ ...derived,
38
+ state: figure.state ?? 'draft',
39
+ thumbnailUpdatedAt: figure.thumbnail ? new Date() : undefined,
40
+ domain,
41
+ creator: user,
42
+ updater: user
43
+ })
44
+
45
+ return { figure: saved, violations }
46
+ }
47
+
48
+ @Directive('@transaction')
49
+ @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
50
+ @Mutation(returns => FigureSaveResult, { description: 'Updates a figure. The `type` field cannot be changed.' })
51
+ async updateFigure(
52
+ @Arg('id') id: string,
53
+ @Arg('patch') patch: FigurePatch,
54
+ @Ctx() context: ResolverContext
55
+ ): Promise<FigureSaveResult> {
56
+ const { domain, user, tx } = context.state
57
+ const repository = tx.getRepository(Figure)
58
+
59
+ const figure = await repository.findOne({
60
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id }
61
+ })
62
+ if (!figure) {
63
+ throw new Error(`Figure ${id} 를 찾을 수 없다`)
64
+ }
65
+
66
+ // 정본이 바뀌면 파생값도 다시 계산한다. 안 바뀌었으면 손대지 않는다 —
67
+ // 이름만 고쳤는데 점수가 흔들리면 읽는 사람이 원인을 못 찾는다.
68
+ let derived: { score: number; triangles: number; groups: number } | undefined
69
+ let violations = []
70
+ let source = figure.source
71
+
72
+ if (patch.source !== undefined && patch.source !== figure.source) {
73
+ const parsed = parseSource(patch.source)
74
+ source = parsed.source
75
+ derived = parsed.derived
76
+ violations = parsed.violations
77
+ }
78
+
79
+ const saved = await repository.save({
80
+ ...figure,
81
+ ...patch,
82
+ source,
83
+ ...(derived ?? {}),
84
+ // 썸네일이 **썸네일로서** 바뀐 때만 시각을 갱신한다. 이름·설명 변경에는 안 건드린다.
85
+ thumbnailUpdatedAt:
86
+ patch.thumbnail !== undefined && patch.thumbnail !== figure.thumbnail
87
+ ? new Date()
88
+ : figure.thumbnailUpdatedAt,
89
+ version: (figure.version ?? 1) + 1,
90
+ updater: user
91
+ })
92
+
93
+ return { figure: saved, violations }
94
+ }
95
+
96
+ @Directive('@transaction')
97
+ @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
98
+ @Mutation(returns => Boolean, { description: 'Soft-deletes a figure.' })
99
+ async deleteFigure(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<boolean> {
100
+ const { domain, tx } = context.state
101
+
102
+ // 소프트 삭제다 — 이미 배치된 보드가 이 타입을 참조할 수 있으므로 행을 지우지
103
+ // 않는다. 되살릴 길을 남긴다.
104
+ await tx.getRepository(Figure).softDelete({ domain: { id: domain.id }, id })
105
+
106
+ return true
107
+ }
108
+
109
+ /**
110
+ * 저작 보조에게 후보를 시킨다.
111
+ *
112
+ * **저장하지 않는다.** 후보를 돌려줄 뿐이고, 정본이 되는 것은 저작자가 보고 받은
113
+ * 뒤다. AI 가 정본에 직접 쓰면 저작자가 무엇이 자기 것이고 무엇이 기계 것인지
114
+ * 모르게 된다.
115
+ *
116
+ * 형식을 어긴 후보는 여기까지 오지 않는다 — `proposeFigure` 가 사유를 모델에
117
+ * 돌려주고 다시 시킨다. 끝내 못 만들면 **사유와 함께 던진다.** 반쯤 만든 것을
118
+ * 돌려주지 않는다.
119
+ */
120
+ @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
121
+ @Mutation(returns => FigureProposal, {
122
+ description: 'Asks the authoring assistant for a figure. Returns a candidate — nothing is saved.'
123
+ })
124
+ async proposeFigure(@Arg('request') request: ProposeRequest): Promise<FigureProposal> {
125
+ // 그림은 메모리로 받는다. 참고용이라 저장하지 않는다 — 남기면 지울 사람이 없다.
126
+ let image
127
+ if (request.image) {
128
+ const file = await request.image
129
+ const chunks: Buffer[] = []
130
+ for await (const chunk of file.createReadStream()) {
131
+ chunks.push(chunk as Buffer)
132
+ }
133
+ image = { data: Buffer.concat(chunks), mediaType: file.mimetype as never }
134
+ }
135
+
136
+ try {
137
+ const result = await proposeFigure({
138
+ prompt: request.prompt,
139
+ base: request.base ? JSON.parse(request.base) : undefined,
140
+ type: request.type,
141
+ palette: request.palette,
142
+ image
143
+ })
144
+
145
+ return {
146
+ source: JSON.stringify(result.source),
147
+ score: Math.round(result.score.score),
148
+ grade: result.score.grade,
149
+ triangles: result.cost.triangles,
150
+ groups: result.cost.groups,
151
+ violations: result.violations,
152
+ attempts: result.attempts
153
+ }
154
+ } catch (e) {
155
+ if (e instanceof ProposeFailure) {
156
+ // 사유를 삼키지 않는다. 저작자가 무엇이 막혔는지 알아야 말을 고쳐 다시 시킨다.
157
+ throw new Error([e.message, ...e.reasons].join('\n'))
158
+ }
159
+ throw e
160
+ }
161
+ }
162
+
163
+ }
@@ -0,0 +1,74 @@
1
+ import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
2
+ import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
3
+ import { Field, InputType, Int, ObjectType } from 'type-graphql'
4
+
5
+ import { FigureFinding } from './figure-type.js'
6
+
7
+ /**
8
+ * 저작 보조에 넘기는 것.
9
+ *
10
+ * ## 팔레트를 클라이언트가 보낸다
11
+ *
12
+ * 서버가 팔레트를 알 수 없다. 팔레트는 things-scene 이 갖고 있는데, 그 패키지의
13
+ * node 조건은 transfer 번들로 가고 거기에는 `activePalette` 이 없다 [확인함].
14
+ * 그리는 쪽이 어떤 토큰을 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.
15
+ *
16
+ * **그래서 저장 문(`parseSource`)은 토큰을 검사하지 못한다.** `validate()` 도
17
+ * 못 한다 — figure-model 이 형식은 갖고 있지만 색 어휘는 things-scene 에 있다.
18
+ * 색 어휘가 형식의 일부라면 figure-model 이 갖는 것이 맞다. **figure-model 에 올릴 것.**
19
+ */
20
+ @InputType({ description: 'What to ask the authoring assistant for' })
21
+ export class ProposeRequest {
22
+ @Field({ description: 'What the author asked for, in their own words' })
23
+ prompt: string
24
+
25
+ @Field({ nullable: true, description: 'The figure source to revise, as JSON. Omit to create a new one.' })
26
+ base?: string
27
+
28
+ @Field({ nullable: true, description: 'Type name for a new figure. Ignored when base is given.' })
29
+ type?: string
30
+
31
+ /**
32
+ * 그림.
33
+ *
34
+ * 비율과 덩어리를 읽는 데 쓴다. 표면 무늬를 옮기는 데 쓰지 않는다 — 로우폴리는
35
+ * 텍스처가 0 인 것이 목표다.
36
+ */
37
+ @Field(type => GraphQLUpload, { nullable: true, description: 'Reference photo or render' })
38
+ image?: FileUpload
39
+
40
+ @Field(type => [String], { description: 'Palette tokens the client can resolve. Anything else is rejected.' })
41
+ palette: string[]
42
+ }
43
+
44
+ /**
45
+ * 후보 하나.
46
+ *
47
+ * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다. 그래서 서버는 저장하지 않고
48
+ * 돌려주기만 한다.
49
+ */
50
+ @ObjectType({ description: 'A figure the assistant proposes. Not saved — the author accepts or discards it.' })
51
+ export class FigureProposal {
52
+ @Field({ description: 'The proposed figure source, as JSON' })
53
+ source: string
54
+
55
+ @Field(type => Int, { description: 'Reuse score out of 100' })
56
+ score: number
57
+
58
+ @Field({ description: 'Grade from the score — A, B, C or D' })
59
+ grade: string
60
+
61
+ @Field(type => Int, { description: 'Triangles in one instance' })
62
+ triangles: number
63
+
64
+ @Field(type => Int, { description: 'Material groups — the draw calls one instance costs' })
65
+ groups: number
66
+
67
+ @Field(type => [FigureFinding], {
68
+ description: 'Policy violations. These do not block the proposal — the author decides.'
69
+ })
70
+ violations: FigureFinding[]
71
+
72
+ @Field(type => Int, { description: 'How many attempts the assistant needed. Diagnostic.' })
73
+ attempts: number
74
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * figure-propose — AI 가 낸 것을 무엇으로 거르나.
3
+ *
4
+ * 이 파일이 지키는 것은 하나다. **형식을 어긴 후보는 사람에게 가지 않는다.**
5
+ *
6
+ * AI 생성이 3D 에서 대개 잘 안 되는 이유는 「틀렸는지 기계가 판정할 수 없어서」인데,
7
+ * 여기는 판정기가 있다. 그 판정기를 실제로 통과시키는지, 틀렸을 때 사유를 모델에
8
+ * 돌려주고 다시 시키는지, 끝내 못 만들면 **못 만들었다고 말하는지**를 본다.
9
+ *
10
+ * 모델은 부르지 않는다. 부르면 시험이 느리고 답이 매번 다르다 — 여기서 볼 것은
11
+ * 모델의 솜씨가 아니라 **우리 거름망**이다.
12
+ */
13
+ import { LIMITS, PART_LIMIT, PRIMITIVE_KINDS, SEGMENT_PRESETS } from '@hatiolab/figure-model'
14
+ import { setDefaultAIClient } from '@things-factory/ai-client-base'
15
+ import type { AIClient } from '@things-factory/ai-client-base'
16
+
17
+ import { ProposeFailure, inspect, proposeFigure, rulesPrompt } from './figure-propose'
18
+
19
+ const PALETTE = ['palette.primary', 'palette.accent', 'palette.neutral']
20
+
21
+ /** 온전한 후보. 시험할 곳만 덮어쓴다. */
22
+ function candidate(over: Record<string, unknown> = {}) {
23
+ return {
24
+ type: 'MIXER',
25
+ base: { x: 300, y: 500, z: 300 },
26
+ detailLevel: 'M',
27
+ parts: [
28
+ {
29
+ name: 'body',
30
+ primitive: 'cylinder',
31
+ segments: 12,
32
+ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 300, y: 400, z: 300 } },
33
+ material: { token: 'palette.primary' }
34
+ }
35
+ ],
36
+ ...over
37
+ }
38
+ }
39
+
40
+ /** 정해 둔 답을 차례로 내놓는 가짜 모델. */
41
+ function stubClient(answers: unknown[]): AIClient & { calls: number } {
42
+ const client = {
43
+ id: 'stub',
44
+ calls: 0,
45
+ async chat() {
46
+ return ''
47
+ },
48
+ async generateJSON() {
49
+ const answer = answers[client.calls]
50
+ client.calls++
51
+ if (answer instanceof Error) throw answer
52
+ return answer
53
+ }
54
+ }
55
+ return client as unknown as AIClient & { calls: number }
56
+ }
57
+
58
+ afterEach(() => setDefaultAIClient(undefined))
59
+
60
+ describe('규칙을 프롬프트로 옮길 때', () => {
61
+ it('값을 손으로 적지 않고 figure-model 에서 가져온다', () => {
62
+ const prompt = rulesPrompt(PALETTE)
63
+
64
+ for (const kind of PRIMITIVE_KINDS) {
65
+ expect(prompt).toContain(kind)
66
+ }
67
+ for (const preset of SEGMENT_PRESETS) {
68
+ expect(prompt).toContain(String(preset))
69
+ }
70
+ expect(prompt).toContain(`<= ${LIMITS.materialGroups}`)
71
+ expect(prompt).toContain(`M=${PART_LIMIT.M}`)
72
+ })
73
+
74
+ it('쓸 수 있는 팔레트 토큰만 알려 준다', () => {
75
+ const prompt = rulesPrompt(PALETTE)
76
+
77
+ expect(prompt).toContain('palette.primary | palette.accent | palette.neutral')
78
+ })
79
+ })
80
+
81
+ describe('후보를 받아 볼 때', () => {
82
+ it('온전하면 통과시킨다', () => {
83
+ const looked = inspect(candidate(), PALETTE)
84
+
85
+ expect(looked.source).toBeDefined()
86
+ expect(looked.reasons).toEqual([])
87
+ })
88
+
89
+ it('형식을 어기면 어디가 왜 틀렸는지 말한다', () => {
90
+ const looked = inspect(candidate({ parts: [] }), PALETTE)
91
+
92
+ expect(looked.source).toBeUndefined()
93
+ expect(looked.reasons.length).toBeGreaterThan(0)
94
+ expect(looked.reasons[0]).toContain('parts')
95
+ })
96
+
97
+ it('JSON 이 아니면 통과시키지 않는다', () => {
98
+ const looked = inspect('그럼요, 만들어 드릴게요', PALETTE)
99
+
100
+ expect(looked.source).toBeUndefined()
101
+ expect(looked.reasons.length).toBeGreaterThan(0)
102
+ })
103
+
104
+ /*
105
+ 토큰은 형식상 자유 문자열이라 validate 를 지난다. 막지 않으면 저장까지 되고
106
+ 화면에서 resolveToken 이 던진다 — 가장 늦게 드러나는 자리다.
107
+ */
108
+ it('팔레트에 없는 색을 지어내면 막는다', () => {
109
+ const bad = candidate()
110
+ bad.parts[0].material = { token: 'palette.brand' }
111
+
112
+ const looked = inspect(bad, PALETTE)
113
+
114
+ expect(looked.source).toBeUndefined()
115
+ expect(looked.reasons[0]).toContain('palette.brand')
116
+ expect(looked.reasons[0]).toContain('palette.primary')
117
+ })
118
+ })
119
+
120
+ describe('후보를 만들 때', () => {
121
+ it('한 번에 되면 점수와 무게를 함께 낸다', async () => {
122
+ setDefaultAIClient(stubClient([candidate()]))
123
+
124
+ const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
125
+
126
+ expect(result.attempts).toBe(1)
127
+ expect(result.source.parts).toHaveLength(1)
128
+ expect(result.score.grade).toMatch(/^[ABCD]$/)
129
+ expect(result.cost.triangles).toBeGreaterThan(0)
130
+ })
131
+
132
+ it('틀리면 사유를 돌려주고 다시 시킨다', async () => {
133
+ const client = stubClient([candidate({ parts: [] }), candidate()])
134
+ setDefaultAIClient(client)
135
+
136
+ const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
137
+
138
+ expect(result.attempts).toBe(2)
139
+ expect(client.calls).toBe(2)
140
+ })
141
+
142
+ it('끝내 못 만들면 못 만들었다고 말한다 — 반쯤 만든 것을 내밀지 않는다', async () => {
143
+ setDefaultAIClient(stubClient([candidate({ parts: [] }), candidate({ parts: [] }), candidate({ parts: [] })]))
144
+
145
+ await expect(proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })).rejects.toThrow(ProposeFailure)
146
+ })
147
+
148
+ it('못 만들었을 때 마지막 사유를 함께 낸다', async () => {
149
+ setDefaultAIClient(stubClient([candidate({ parts: [] }), candidate({ parts: [] }), candidate({ parts: [] })]))
150
+
151
+ await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' }).catch((e: ProposeFailure) => {
152
+ expect(e.reasons.length).toBeGreaterThan(0)
153
+ expect(e.attempts).toBe(3)
154
+ })
155
+ })
156
+
157
+ it('모델이 없으면 있는 척하지 않는다', async () => {
158
+ setDefaultAIClient(undefined)
159
+
160
+ await expect(proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })).rejects.toThrow(
161
+ /설정돼 있지 않습니다/
162
+ )
163
+ })
164
+ })
@@ -0,0 +1,271 @@
1
+ import {
2
+ CHANNEL_PATHS,
3
+ DETAIL_LEVELS,
4
+ LIMITS,
5
+ MATERIAL_PRESETS,
6
+ PART_LIMIT,
7
+ PRIMITIVE_KINDS,
8
+ SEGMENT_PRESETS,
9
+ SIZING_RULES,
10
+ compile,
11
+ costOf,
12
+ scoreOf,
13
+ validate
14
+ } from '@hatiolab/figure-model'
15
+ import type { FigureSource } from '@hatiolab/figure-model'
16
+ import { getDefaultAIClient } from '@things-factory/ai-client-base'
17
+ import type { AIImageMediaType, AIMessage } from '@things-factory/ai-client-base'
18
+
19
+ /**
20
+ * 저작 보조 — 말이나 그림으로 Figure 후보를 만든다.
21
+ *
22
+ * ## 왜 이 문제에 AI 가 맞나
23
+ *
24
+ * 3D 생성에 AI 를 붙이는 시도는 대개 잘 안 된다. 출력이 메시라서 **틀렸는지 기계가
25
+ * 판정할 수 없기** 때문이다. 여기는 다르다 — 출력이 닫힌 어휘로 된 JSON 하나이고,
26
+ * `validate()` 와 `scoreOf()` 가 **객관적인 채점기**다.
27
+ *
28
+ * ## 정본에 직접 쓰지 않는다
29
+ *
30
+ * 이 함수는 **후보**를 낸다. 형식을 어긴 후보는 사람에게 보이지 않는다 — 사유를
31
+ * 모델에 돌려주고 다시 시킨다. 정해진 횟수 안에 못 만들면 **못 만들었다고 말한다.**
32
+ * 반쯤 만든 것을 내밀면 저작자가 그것을 고치느라 처음부터 만드는 것보다 오래 걸린다.
33
+ *
34
+ * ## 어휘를 프롬프트에서 지어내지 않는다
35
+ *
36
+ * 도형·프리셋·한도는 전부 `@hatiolab/figure-model` 이 내보내는 상수에서 만든다.
37
+ * 프롬프트에 손으로 적으면 모델이 바뀔 때 프롬프트만 옛말을 하게 된다.
38
+ */
39
+
40
+ /*
41
+ `slot` 은 시키지 않는다.
42
+
43
+ 형식에는 있지만 그리는 쪽이 읽지 않아 **아무 일도 하지 않는다.** 죽은 필드를 채우게
44
+ 시키면 저작자가 「상태 색이 되는구나」로 읽는다. 속성·바인딩 설계가 서면(figure-model#2)
45
+ 그때 다시 넣는다.
46
+
47
+ 강제 규칙과 취향 규칙을 갈라 적는다.
48
+
49
+ `validate()` 가 막는 것만 「hard」다. 한때 「부품은 기준 상자 안에 있어야 한다」를
50
+ 강제 규칙으로 적었는데 **그런 규칙이 없다** — `base` 는 경계 상자가 아니라 배치
51
+ 크기가 나오는 비율 기준이고, 밖으로 나가도 오류가 아니다 [확인함]. 없는 규칙을
52
+ 강제라고 적으면 모델이 그것 때문에 좋은 답을 버린다.
53
+ */
54
+
55
+ /** 몇 번까지 다시 시키나. 이보다 늘려도 대개 같은 자리에서 막힌다. */
56
+ const MAX_ATTEMPTS = 3
57
+
58
+ export interface ProposeInput {
59
+ /** 사람이 시킨 말. */
60
+ prompt: string
61
+ /** 고쳐 달라는 것이면 지금 정본. 없으면 새로 만든다. */
62
+ base?: FigureSource
63
+ /** 참고 그림. 설비 사진·렌더. */
64
+ image?: { data: Buffer; mediaType: AIImageMediaType }
65
+ /** 만들 타입 이름. 새로 만들 때 필요하다. */
66
+ type?: string
67
+ /** 쓸 수 있는 팔레트 토큰. 여기 없는 토큰은 거절한다. */
68
+ palette: string[]
69
+ }
70
+
71
+ export interface ProposeResult {
72
+ source: FigureSource
73
+ /** 재활용 점수와 한도 여유. */
74
+ score: ReturnType<typeof scoreOf>
75
+ /** 무게. */
76
+ cost: ReturnType<typeof costOf>
77
+ /** 형식은 맞으나 정책을 넘은 것. 막지 않고 알린다. */
78
+ violations: { code: string; message: string }[]
79
+ /** 몇 번 만에 됐나. 저작자에게 보여 줄 값은 아니지만 진단에 쓴다. */
80
+ attempts: number
81
+ }
82
+
83
+ /** 못 만들었을 때. 조용히 빈 것을 돌려주지 않는다. */
84
+ export class ProposeFailure extends Error {
85
+ constructor(
86
+ message: string,
87
+ /** 마지막 시도가 낸 사유들. 저작자에게 그대로 보여 준다. */
88
+ readonly reasons: string[],
89
+ readonly attempts: number
90
+ ) {
91
+ super(message)
92
+ this.name = 'ProposeFailure'
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 규칙을 프롬프트로 옮긴다.
98
+ *
99
+ * 값을 손으로 적지 않는다 — figure-model 의 상수에서 만든다. 그래야 한도가 바뀌면
100
+ * 프롬프트도 같이 바뀐다.
101
+ */
102
+ export function rulesPrompt(palette: string[]): string {
103
+ const partLimits = DETAIL_LEVELS.map(level => `${level}=${PART_LIMIT[level]}`).join(' · ')
104
+
105
+ return [
106
+ 'You compose low-poly 3D figures for a factory digital twin.',
107
+ 'Answer with a single JSON object and nothing else — no prose, no code fence.',
108
+ '',
109
+ 'The JSON is a FigureSource:',
110
+ ' { type, base: {x,y,z}, detailLevel, parts: [...] }',
111
+ '',
112
+ 'A part is:',
113
+ ' { name, primitive, transform: { position: {x,y,z}, size: {x,y,z}, rotation?: {x,y,z} },',
114
+ ' material: {token, preset?}, segments?, shape?, sizing? }',
115
+ '',
116
+ 'Hard vocabulary — anything outside these is rejected:',
117
+ ` primitive ${PRIMITIVE_KINDS.join(' | ')}`,
118
+ ` segments ${SEGMENT_PRESETS.join(' | ')} (only for cylinder and sphere)`,
119
+ ` material.token ${palette.join(' | ')}`,
120
+ ` material.preset ${MATERIAL_PRESETS.join(' | ')}`,
121
+ ` sizing ${SIZING_RULES.join(' | ')}`,
122
+ ` animation.path ${CHANNEL_PATHS.join(' | ')}`,
123
+ ` detailLevel ${DETAIL_LEVELS.join(' | ')}`,
124
+ '',
125
+ 'Hard limits — exceeding these makes the figure unusable at scale:',
126
+ ` parts by detailLevel: ${partLimits}`,
127
+ ` material groups (distinct token+preset+slot combinations) <= ${LIMITS.materialGroups}`,
128
+ ` animated parts <= ${LIMITS.animatedParts}`,
129
+ ` transparent materials <= ${LIMITS.transparentMaterials}`,
130
+ '',
131
+ 'Geometry rules:',
132
+ ' Axes are the usual 3D ones: x right, y UP, z towards the viewer — same as glTF and three.js.',
133
+ ' transform.position is the CENTRE of the part, not a corner.',
134
+ ' The origin (0,0,0) is the centre of the base box, so the floor is at y = -base.y / 2.',
135
+ ' base and transform.size are extents along each axis: size.y is the height.',
136
+ ' part names are stored identifiers — lowercase, hyphenated, unique, and descriptive',
137
+ ' of the real thing (body, lid, motor, leg-front-left), never part-1.',
138
+ '',
139
+ 'Taste rules — this is what makes it low-poly rather than a pile of boxes:',
140
+ ' Few colours. Reuse one token for the mass and reserve accents for what must stand out.',
141
+ ' Recognisable silhouette from directly above — that is how it is seen on a board.',
142
+ ' Prefer fewer, larger parts. A detail smaller than a twentieth of the base is not visible.',
143
+ ' Use the coarsest segment count that keeps the silhouette right.',
144
+ ' Keep parts within the base box unless the real thing overhangs. The base is the reference',
145
+ ' the instance is scaled from, so a part far outside it grows in a way nobody intended.'
146
+ ].join('\n')
147
+ }
148
+
149
+ /** 무엇을 만들라는 것인지 한 덩어리로. */
150
+ function taskPrompt(input: ProposeInput): string {
151
+ const lines = [`Request: ${input.prompt}`]
152
+
153
+ if (input.base) {
154
+ lines.push(
155
+ '',
156
+ 'Revise this existing figure. Keep the part names that still mean the same thing —',
157
+ 'renaming a part breaks bindings on instances already placed.',
158
+ JSON.stringify(input.base)
159
+ )
160
+ } else {
161
+ lines.push('', `Create a new figure with type "${input.type ?? 'FIGURE'}".`)
162
+ }
163
+
164
+ if (input.image) {
165
+ lines.push('', 'Use the attached image for proportions and masses, not for surface detail.')
166
+ }
167
+
168
+ return lines.join('\n')
169
+ }
170
+
171
+ /**
172
+ * 후보 하나를 받아 본다.
173
+ *
174
+ * 던지는 것이 아니라 **사유를 모아 돌려준다** — 사유가 다음 시도의 입력이 되기
175
+ * 때문이다. 사람에게 보여 줄 것과 모델에 돌려줄 것이 같은 글이다.
176
+ */
177
+ export function inspect(parsed: unknown, palette: string[]): { source?: FigureSource; reasons: string[] } {
178
+ const result = validate(parsed as FigureSource)
179
+ if (result.errors.length > 0) {
180
+ return { reasons: result.errors.map(error => `${error.path}: ${error.message}`) }
181
+ }
182
+
183
+ const source = parsed as FigureSource
184
+
185
+ /*
186
+ 토큰은 형식상 자유 문자열이라 validate 를 지난다. 그런데 팔레트에 없는 토큰은
187
+ 그리는 시점에 `resolveToken` 이 던진다 — 저장까지 되고 나서 화면에서 죽는다.
188
+ 그래서 여기서 막는다.
189
+ */
190
+ const unknown = [
191
+ ...new Set(source.parts.map(part => part.material?.token).filter(token => token && !palette.includes(token)))
192
+ ]
193
+ if (unknown.length > 0) {
194
+ return {
195
+ reasons: [`Unknown palette tokens: ${unknown.join(', ')}. Use only: ${palette.join(', ')}.`]
196
+ }
197
+ }
198
+
199
+ return { source, reasons: [] }
200
+ }
201
+
202
+ /**
203
+ * 말과 그림으로 후보를 만든다.
204
+ *
205
+ * 생성-검사 루프다. 도구 루프가 아니다 — 첫 조각에 도구는 필요 없다.
206
+ *
207
+ * 도구가 필요해지면 `@things-factory/ai-client-base` 의 `runAgenticLoop` 을 부른다.
208
+ * 전에는 그것이 board-ai 안에만 있어 밖에서 쓸 수 없었는데, 공용으로 옮겨졌다.
209
+ */
210
+ export async function proposeFigure(input: ProposeInput): Promise<ProposeResult> {
211
+ const client = getDefaultAIClient()
212
+ if (!client) {
213
+ // 없는 것을 있는 척하지 않는다. 부르는 쪽이 사람에게 그대로 옮길 수 있는 말로.
214
+ throw new ProposeFailure('AI 모델이 설정돼 있지 않습니다.', [], 0)
215
+ }
216
+
217
+ const system = rulesPrompt(input.palette)
218
+ const messages: AIMessage[] = [
219
+ {
220
+ role: 'user',
221
+ content: input.image
222
+ ? [
223
+ { type: 'text', text: taskPrompt(input) },
224
+ { type: 'image', source: { kind: 'base64', data: input.image.data, mediaType: input.image.mediaType } }
225
+ ]
226
+ : taskPrompt(input)
227
+ }
228
+ ]
229
+
230
+ let reasons: string[] = []
231
+
232
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
233
+ // `generateJSON` 을 쓴다 — 어댑터가 JSON 출력을 강제하고 파싱까지 해 준다.
234
+ // 코드 울타리나 앞뒤 설명을 우리가 걷어내지 않아도 된다.
235
+ let answer: unknown
236
+ try {
237
+ answer = await client.generateJSON(messages, { systemPrompt: system })
238
+ } catch (e) {
239
+ // 모델이 JSON 을 못 냈다. 그것도 사유이므로 돌려주고 다시 시킨다.
240
+ reasons = [`The answer was not valid JSON: ${(e as Error).message}`]
241
+ messages.push({
242
+ role: 'user',
243
+ content: ['That was rejected. Answer with the JSON object only:', ...reasons].join('\n')
244
+ })
245
+ continue
246
+ }
247
+
248
+ const looked = inspect(answer, input.palette)
249
+ if (looked.source) {
250
+ const blueprint = compile(looked.source)
251
+ return {
252
+ source: looked.source,
253
+ score: scoreOf(blueprint),
254
+ cost: costOf(blueprint, 100),
255
+ violations: blueprint.violations.map(v => ({ code: v.code, message: v.message })),
256
+ attempts: attempt
257
+ }
258
+ }
259
+
260
+ reasons = looked.reasons
261
+
262
+ // 사유를 그대로 돌려준다. 요약하면 모델이 무엇을 고쳐야 하는지 모른다.
263
+ messages.push({ role: 'assistant', content: JSON.stringify(answer) })
264
+ messages.push({
265
+ role: 'user',
266
+ content: ['That was rejected. Fix these and answer again with the JSON only:', ...reasons].join('\n')
267
+ })
268
+ }
269
+
270
+ throw new ProposeFailure('만들지 못했습니다.', reasons, MAX_ATTEMPTS)
271
+ }