@things-factory/figure-service 10.1.4 → 10.1.6

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 (47) hide show
  1. package/coverage/clover.xml +6 -0
  2. package/coverage/coverage-final.json +1 -0
  3. package/coverage/lcov-report/base.css +224 -0
  4. package/coverage/lcov-report/block-navigation.js +87 -0
  5. package/coverage/lcov-report/favicon.png +0 -0
  6. package/coverage/lcov-report/index.html +101 -0
  7. package/coverage/lcov-report/prettify.css +1 -0
  8. package/coverage/lcov-report/prettify.js +2 -0
  9. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  10. package/coverage/lcov-report/sorter.js +210 -0
  11. package/coverage/lcov.info +0 -0
  12. package/dist-server/index.d.ts +2 -0
  13. package/dist-server/index.js +13 -0
  14. package/dist-server/index.js.map +1 -1
  15. package/dist-server/routers/figure-thumbnail-router.d.ts +24 -0
  16. package/dist-server/routers/figure-thumbnail-router.js +93 -0
  17. package/dist-server/routers/figure-thumbnail-router.js.map +1 -0
  18. package/dist-server/routes.d.ts +1 -0
  19. package/dist-server/routes.js +21 -0
  20. package/dist-server/routes.js.map +1 -0
  21. package/dist-server/service/figure/figure-gate.d.ts +55 -0
  22. package/dist-server/service/figure/figure-gate.js +64 -0
  23. package/dist-server/service/figure/figure-gate.js.map +1 -0
  24. package/dist-server/service/figure/figure-mutation.d.ts +6 -0
  25. package/dist-server/service/figure/figure-mutation.js +21 -0
  26. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  27. package/dist-server/service/figure/figure-query.d.ts +9 -1
  28. package/dist-server/service/figure/figure-query.js +28 -0
  29. package/dist-server/service/figure/figure-query.js.map +1 -1
  30. package/dist-server/service/figure/figure-tools.d.ts +10 -0
  31. package/dist-server/service/figure/figure-tools.js +304 -0
  32. package/dist-server/service/figure/figure-tools.js.map +1 -0
  33. package/dist-server/service/figure/figure-type.d.ts +25 -0
  34. package/dist-server/service/figure/figure-type.js +58 -1
  35. package/dist-server/service/figure/figure-type.js.map +1 -1
  36. package/dist-server/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +8 -6
  38. package/server/index.ts +13 -0
  39. package/server/routers/figure-thumbnail-router.ts +110 -0
  40. package/server/routes.ts +24 -0
  41. package/server/service/figure/figure-gate.ts +119 -0
  42. package/server/service/figure/figure-mutation.ts +23 -0
  43. package/server/service/figure/figure-propose.test.ts +136 -4
  44. package/server/service/figure/figure-query.ts +26 -1
  45. package/server/service/figure/figure-tools.test.ts +284 -0
  46. package/server/service/figure/figure-tools.ts +320 -0
  47. package/server/service/figure/figure-type.ts +43 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.4",
3
+ "version": "10.1.6",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -23,12 +23,14 @@
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.12",
26
+ "@hatiolab/figure-model": "^0.1.15",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
- "@things-factory/ai-client-base": "^10.1.3",
29
- "@things-factory/auth-base": "^10.1.4",
28
+ "@things-factory/ai-client-base": "^10.1.6",
29
+ "@things-factory/auth-base": "^10.1.6",
30
30
  "@things-factory/env": "^10.1.3",
31
- "graphql-upload": "^15.0.2"
31
+ "@things-factory/shell": "^10.1.4",
32
+ "graphql-upload": "^15.0.2",
33
+ "koa-router": "^7.4.0"
32
34
  },
33
- "gitHead": "c653a2967daa76b3828f1eb7f4eb18934263d4b8"
35
+ "gitHead": "963aeb9ac470be5aea05bb5842a0a92afcab6036"
34
36
  }
package/server/index.ts CHANGED
@@ -1,2 +1,15 @@
1
+ import './routes.js'
2
+
3
+ /*
4
+ * 저작 도구를 어시스턴트 레지스트리에 등록한다.
5
+ *
6
+ * 이 패키지가 적재되면 대화면(도크)이 저작 중인 Figure 의 정본·비용·발행 판정을 읽고 후보를
7
+ * 요청할 수 있다. 능력을 복제하지 않고 figure-service 가 이미 가진 진입점을 투영한다 —
8
+ * 노출 범위는 대화면이 `toolCategories` 로 고른다(twin-ai · board-import 와 같은 방식).
9
+ */
10
+ import { registerFigureToolCategories } from './service/figure/figure-tools.js'
11
+ registerFigureToolCategories()
12
+
1
13
  export * from './service/index.js'
2
14
  export * from './migrations/index.js'
15
+ export { registerFigureToolCategories } from './service/figure/figure-tools.js'
@@ -0,0 +1,110 @@
1
+ import Router from 'koa-router'
2
+ import { In } from 'typeorm'
3
+
4
+ import { checkPermission } from '@things-factory/auth-base'
5
+ import { getRepository } from '@things-factory/shell'
6
+
7
+ import { Figure } from '../service/figure/figure.js'
8
+
9
+ /**
10
+ * 썸네일을 **그림으로** 내보내는 길.
11
+ *
12
+ * ## 왜 필요한가
13
+ *
14
+ * 썸네일은 base64 문자열로 칸에 들어 있다(`Board.thumbnail` 과 같은 방식). 그것을 목록 질의가
15
+ * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은
16
+ * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.
17
+ *
18
+ * 그림으로 내보내면 브라우저가 캐시하고, 부르는 쪽이 `?v=thumbnailUpdatedAt` 을 붙이므로 그림이
19
+ * 다시 만들어진 것만 다시 받는다. 목록 질의에서는 `thumbnail` 을 아예 고르지 않는다.
20
+ *
21
+ * ## board 를 베끼면서 고친 것 셋
22
+ *
23
+ * `standalone-board-service-router` 의 `/thumbnail/:id` 가 본이지만 그대로 두면 안 되는 것들이 있다.
24
+ *
25
+ * 1. **길 이름이 겹친다.** 이 앱은 board 도 함께 싣는다(메뉴가 board-ui 를 끌어온다).
26
+ * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.
27
+ * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이
28
+ * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.
29
+ * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). data URL 이 스스로
30
+ * 말하는 형식을 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
31
+ */
32
+ export const figureThumbnailRouter = new Router()
33
+
34
+ /** `data:image/webp;base64,....` → 형식과 바이트. 아니면 undefined. */
35
+ function decodeDataUrl(dataUrl: string): { type: string; buffer: Buffer } | undefined {
36
+ const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl)
37
+ if (!matched) {
38
+ return undefined
39
+ }
40
+
41
+ return { type: matched[1], buffer: Buffer.from(matched[2], 'base64') }
42
+ }
43
+
44
+ figureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {
45
+ const { domain, user, unsafeIP, prohibitedPrivileges } = context.state
46
+
47
+ /*
48
+ **질의와 같은 문을 쓴다.**
49
+
50
+ `User.hasPrivilege('query','figure',…)` 만 보면 안 된다 — figure 질의는
51
+ `@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)` 로 선언되어 있고,
52
+ 도메인 소유자는 `figure:query` 권한 줄이 없어도 통과한다. 그 허용을 여기서 빼면 목록은 보이는데
53
+ 그림만 403 이 되어, 화면은 「그림이 아직 없다」고 거짓말한다. [확인함] 실제로 카드 14장이 전부
54
+ 403 이었다.
55
+
56
+ 조건을 손으로 다시 적지 않고 지시자가 쓰는 함수를 그대로 쓴다. 선언이 바뀌면 여기도 함께 바뀐다.
57
+ */
58
+ const allowed = await checkPermission(
59
+ { category: 'figure', privilege: 'query', owner: true },
60
+ user,
61
+ domain,
62
+ unsafeIP,
63
+ prohibitedPrivileges
64
+ )
65
+
66
+ if (!allowed) {
67
+ context.status = 403
68
+ return
69
+ }
70
+
71
+ const { id } = context.params
72
+
73
+ /*
74
+ 읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고
75
+ 그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.
76
+ */
77
+ const found = await getRepository(Figure).findOne({
78
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
79
+ select: { id: true, thumbnail: true, thumbnailUpdatedAt: true }
80
+ })
81
+
82
+ const decoded = found?.thumbnail ? decodeDataUrl(found.thumbnail) : undefined
83
+
84
+ if (!decoded) {
85
+ context.status = 404
86
+ return
87
+ }
88
+
89
+ /*
90
+ **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면
91
+ 안 된다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않고, `v` 없이 부른
92
+ 경우를 위해 시각을 함께 실어 다시 물어볼 수 있게 한다.
93
+ */
94
+ const stamp = found!.thumbnailUpdatedAt
95
+
96
+ context.type = decoded.type
97
+ context.set('Cache-Control', 'private, max-age=31536000')
98
+ if (stamp) {
99
+ context.set('Last-Modified', new Date(stamp).toUTCString())
100
+ context.set('ETag', `"${new Date(stamp).getTime()}"`)
101
+
102
+ /* 브라우저가 가진 것이 아직 그것이면 바이트를 보내지 않는다. */
103
+ if (context.fresh) {
104
+ context.status = 304
105
+ return
106
+ }
107
+ }
108
+
109
+ context.body = decoded.buffer
110
+ })
@@ -0,0 +1,24 @@
1
+ import { config } from '@things-factory/env'
2
+ import { getRoutePrefixForDomainType } from '@things-factory/shell'
3
+
4
+ import { figureThumbnailRouter } from './routers/figure-thumbnail-router.js'
5
+
6
+ /**
7
+ * 도메인 경로가 주소에 들어가는 배치인가.
8
+ *
9
+ * `subdomain` 설정이 있으면 도메인은 호스트 이름으로 갈리므로 경로에 도메인이 없다. 없으면
10
+ * `/domain/{subdomain}/...` 로 갈린다 — board-service 가 같은 판단을 같은 방식으로 한다.
11
+ */
12
+ const isPathBaseDomain = !config.get('subdomain')
13
+
14
+ process.on('bootstrap-module-domain-private-route' as any, (app, domainPrivateRouter) => {
15
+ if (isPathBaseDomain) {
16
+ domainPrivateRouter.use(
17
+ `/${getRoutePrefixForDomainType()}/:domain`,
18
+ figureThumbnailRouter.routes(),
19
+ figureThumbnailRouter.allowedMethods()
20
+ )
21
+ } else {
22
+ domainPrivateRouter.use('', figureThumbnailRouter.routes(), figureThumbnailRouter.allowedMethods())
23
+ }
24
+ })
@@ -0,0 +1,119 @@
1
+ import { compile, sizingReport, validate } from '@hatiolab/figure-model'
2
+ import type { FigureSource } from '@hatiolab/figure-model'
3
+
4
+ /**
5
+ * 발행해도 되나 — **서버가 판정한다.**
6
+ *
7
+ * ## 왜 서버인가
8
+ *
9
+ * 이 검사는 브라우저 하네스에만 있었다(`test/browser/check-figure-scaling.mjs`). 사람이 돌릴 때만
10
+ * 돌았고, 발행 경로에는 아무 검사도 없었다 — 형식만 맞으면 무엇이든 발행되었다. 발행은 소비처에게
11
+ * 하는 약속이므로, 그 약속을 지킬 수 있는지는 약속하는 자리가 재야 한다.
12
+ *
13
+ * ## 무엇을 막고 무엇을 알리나
14
+ *
15
+ * 판정은 `figure-model` 의 `gate.ts` 가 이미 문서로 정해 두었다. 여기서 새로 정하지 않는다.
16
+ *
17
+ * sizing-splits 크기를 바꾸면 조각이 갈라진다 **막는다**
18
+ * sizing-loose 1배에서 이미 여러 조각이다 알린다
19
+ * violations 형식이 권하지 않는 것 알린다 (저장 때 이미 보여 준 것들)
20
+ *
21
+ * 갈라짐을 막는 이유는 사람 눈에 「부서졌다」로 보이기 때문이다 — 시그널타워를 3배로 세우니 램프가
22
+ * 기둥 위 공중에 떴던 그것이고, 앵커 규칙이 생긴 이유다. 소비처가 형상을 늘려 쓰는 것은 막을 수
23
+ * 없으니(그것이 이 형식의 값이다), 늘려도 견디는지를 발행 전에 본다.
24
+ *
25
+ * ## 재 본 값
26
+ *
27
+ * [확인함] 2026-09-09 · 표본 15개(시드 14 + 저작 1)
28
+ * 형식 오류 0 · 1배 분리 0 · **갈라짐 1**(MIXER, z·y-z 조합) · 조용함 14
29
+ *
30
+ * 게이트가 벽이 아니라는 뜻이다. 열넷은 그대로 발행되고, 하나는 고칠 것이 있다.
31
+ */
32
+
33
+ /** 게이트가 낸 한 가지. `blocking` 이 참이면 발행이 안 된다. */
34
+ export interface GateFinding {
35
+ code: string
36
+ message: string
37
+ /**
38
+ * **왜 그것이 문제인가.** 잰 것만 보여 주면 읽는 사람이 「그래서?」에서 멈춘다 — 화면에
39
+ * 「1 조각이 2 조각으로 갈라진다」만 떴을 때 실제로 그랬다.
40
+ */
41
+ why?: string
42
+ /** **무엇을 바꾸면 되나.** 부품 이름과 값으로. 형식이 만든 문장을 그대로 옮긴다. */
43
+ how?: string
44
+ /** 어디를 가리키나 — 갈라짐은 갈라진 부품 이름들, 형식 위반은 정본 안의 경로. */
45
+ at?: string
46
+ blocking: boolean
47
+ }
48
+
49
+ export interface Inspection {
50
+ findings: GateFinding[]
51
+ /** 막는 것이 하나라도 있나. 화면이 이것으로 단추를 잠근다. */
52
+ blocked: boolean
53
+ }
54
+
55
+ /**
56
+ * 정본 하나를 판정한다.
57
+ *
58
+ * 정본이 없거나(아직 저장 전) JSON 이 아니면 **막는다** — 발행할 대상이 없는 것이다. 그 경우를
59
+ * 조용히 통과시키면 빈 것이 발행된다.
60
+ */
61
+ export function inspectSource(sourceJson?: string | null): Inspection {
62
+ if (!sourceJson) {
63
+ return {
64
+ blocked: true,
65
+ findings: [
66
+ {
67
+ code: 'no-source',
68
+ message: '저장된 정본이 없다. 저장하면 발행할 수 있다',
69
+ blocking: true
70
+ }
71
+ ]
72
+ }
73
+ }
74
+
75
+ let parsed: unknown
76
+ try {
77
+ parsed = JSON.parse(sourceJson)
78
+ } catch (e) {
79
+ return {
80
+ blocked: true,
81
+ findings: [{ code: 'source-not-json', message: `정본이 JSON 이 아니다: ${(e as Error).message}`, blocking: true }]
82
+ }
83
+ }
84
+
85
+ const result = validate(parsed)
86
+ const findings: GateFinding[] = []
87
+
88
+ /*
89
+ 형식 오류는 **저장을 통과할 수 없다**(`parseSource` 가 거절한다). 그래도 여기서 다시 보는 이유는,
90
+ 이 판정이 저장 경로와 독립이어야 하기 때문이다 — 옛 정본이 남아 있거나 형식이 엄해진 뒤라면
91
+ 저장 때는 맞았던 것이 지금은 틀릴 수 있다. 그때 발행이 통과하면 안 된다.
92
+ */
93
+ for (const error of result.errors) {
94
+ findings.push({ code: error.code, message: error.message, at: error.path, blocking: true })
95
+ }
96
+
97
+ for (const violation of result.violations) {
98
+ findings.push({ code: violation.code, message: violation.message, blocking: false })
99
+ }
100
+
101
+ if (result.errors.length === 0) {
102
+ const report = sizingReport(compile(parsed as FigureSource))
103
+
104
+ for (const finding of report.findings) {
105
+ findings.push({
106
+ code: finding.code,
107
+ message: finding.message,
108
+ /* 왜·어떻게는 형식이 소유한다. 서버가 다시 쓰면 두 벌이 되고 곧 어긋난다. */
109
+ why: finding.why,
110
+ how: finding.how,
111
+ /* 숫자가 아니라 자리를 말한다 — 어느 부품들이 갈라져 나왔나. */
112
+ at: finding.clumps.map(clump => [...clump].sort().join('+')).join(' | ') || undefined,
113
+ blocking: finding.code === 'sizing-splits'
114
+ })
115
+ }
116
+ }
117
+
118
+ return { findings, blocked: findings.some(finding => finding.blocking) }
119
+ }
@@ -7,6 +7,7 @@ import { Figure } from './figure.js'
7
7
  import { FigureHistory } from './figure-history.js'
8
8
  import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
9
9
  import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
10
+ import { inspectSource } from './figure-gate.js'
10
11
  import { parseSource } from './figure-source.js'
11
12
  import { ProposeFailure, proposeFigure } from './figure-propose.js'
12
13
 
@@ -130,6 +131,12 @@ export class FigureMutation {
130
131
  *
131
132
  * 도형의 현재 번호가 아니라 **판본의 최대 번호 + 1** 이다. 되돌리기로 옛 판이 현재가 되어도
132
133
  * 다음 발행이 이미 쓴 번호를 다시 쓰지 않는다.
134
+ *
135
+ * ## 게이트를 통과해야 한다
136
+ *
137
+ * 크기를 바꿨을 때 조각이 갈라지는 도형은 발행되지 않는다(`figure-gate.ts`). 소비처는 형상을
138
+ * 늘려 쓰므로 — 그것이 이 형식의 값이다 — 늘려도 견디는지가 약속의 일부다. 화면은 같은 판정을
139
+ * `inspectFigure` 로 미리 물어 이유를 보여 준다.
133
140
  */
134
141
  @Directive('@transaction')
135
142
  @Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
@@ -154,6 +161,22 @@ export class FigureMutation {
154
161
  throw new Error(`Figure '${figure.type}' 은 이미 발행되어 있다. 고쳐서 다시 내려면 초안으로 되돌린 뒤 발행한다`)
155
162
  }
156
163
 
164
+ /*
165
+ ── 게이트는 여기 있어야 한다 ────────────────────────────────────────────
166
+ 화면도 같은 판정을 미리 물어(`inspectFigure`) 이유를 보여 주지만, **막는 자리는 이곳뿐이다.**
167
+ 물어본 뒤 정본이 바뀌었을 수 있고, 화면을 거치지 않는 부름도 있다.
168
+
169
+ 막는 이유는 사유와 함께 낸다. 「발행할 수 없습니다」로 뭉치면 무엇을 고쳐야 하는지 모른다.
170
+ */
171
+ const inspection = inspectSource(figure.source)
172
+ if (inspection.blocked) {
173
+ const reasons = inspection.findings
174
+ .filter(finding => finding.blocking)
175
+ .map(finding => (finding.at ? `${finding.message} (${finding.at})` : finding.message))
176
+
177
+ throw new Error(`Figure '${figure.type}' 은 아직 발행할 수 없다 — ${reasons.join(' · ')}`)
178
+ }
179
+
157
180
  const highest = await historyRepository
158
181
  .createQueryBuilder('history')
159
182
  .select('MAX(history.version)', 'max')
@@ -37,22 +37,38 @@ function candidate(over: Record<string, unknown> = {}) {
37
37
  }
38
38
  }
39
39
 
40
- /** 정해 둔 답을 차례로 내놓는 가짜 모델. */
41
- function stubClient(answers: unknown[]): AIClient & { calls: number } {
40
+ /**
41
+ * 정해 둔 답을 차례로 내놓는 가짜 모델.
42
+ *
43
+ * **받은 말을 적어 둔다.** 프롬프트에 실려 나가는 지시 중에 지켜져야 할 것이 있어서다 —
44
+ * 고치는 요청에서 「부품 이름을 바꾸지 말라」가 빠지면 AI 가 이름을 갈아 버리고, 그러면
45
+ * 이미 배치된 보드의 바인딩이 끊긴다. 답만 보는 모형으로는 그것이 나갔는지 알 수 없다.
46
+ */
47
+ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; system?: string } {
42
48
  const client = {
43
49
  id: 'stub',
44
50
  calls: 0,
51
+ sent: [] as any[][],
52
+ system: undefined as string | undefined,
45
53
  async chat() {
46
54
  return ''
47
55
  },
48
- async generateJSON() {
56
+ async generateJSON(messages: any[], options?: { systemPrompt?: string }) {
57
+ client.sent.push(JSON.parse(JSON.stringify(messages)))
58
+ client.system = options?.systemPrompt
49
59
  const answer = answers[client.calls]
50
60
  client.calls++
51
61
  if (answer instanceof Error) throw answer
52
62
  return answer
53
63
  }
54
64
  }
55
- return client as unknown as AIClient & { calls: number }
65
+ return client as unknown as AIClient & { calls: number; sent: any[][]; system?: string }
66
+ }
67
+
68
+ /** 첫 요청에 실려 나간 사람 쪽 말. 이어 붙인 것까지 한 덩어리로 본다. */
69
+ function firstAsk(client: { sent: any[][] }): string {
70
+ const content = client.sent[0]![0]!.content
71
+ return typeof content === 'string' ? content : JSON.stringify(content)
56
72
  }
57
73
 
58
74
  afterEach(() => setDefaultAIClient(undefined))
@@ -162,3 +178,119 @@ describe('후보를 만들 때', () => {
162
178
  )
163
179
  })
164
180
  })
181
+
182
+ describe('무엇을 시키는지가 프롬프트에 실린다', () => {
183
+ /*
184
+ 고치는 요청에서 이 지시가 빠지면 AI 가 부품 이름을 갈아 버린다. 부품 이름은 **저장되는
185
+ 식별자**라서, 이미 배치된 보드의 바인딩·애니메이션이 그 이름을 가리키고 있다. 조용히
186
+ 끊기고, 끊긴 것은 그 보드를 열어 봐야 드러난다.
187
+ */
188
+ it('고치는 요청이면 지금 정본과 「이름을 바꾸지 말라」가 함께 나간다', async () => {
189
+ const client = stubClient([candidate()])
190
+ setDefaultAIClient(client)
191
+
192
+ const base = candidate({ type: 'MIXER' }) as never
193
+ await proposeFigure({ prompt: '재질을 줄여줘', palette: PALETTE, base })
194
+
195
+ const ask = firstAsk(client)
196
+ expect(ask).toContain('Revise this existing figure')
197
+ expect(ask).toMatch(/renaming a part breaks bindings/)
198
+ // 지금 정본이 실제로 실려 나가야 한다 — 없으면 모델이 처음부터 만든다.
199
+ expect(ask).toContain('"name":"body"')
200
+ })
201
+
202
+ /*
203
+ 타입 이름을 안 주면 `FIGURE` 로 나간다. **조용히 지어지는 값**이라 못 박아 둔다 — 저작면이
204
+ 타입을 늘 주므로 이 자리는 평소 안 보이지만, 안 주는 부르는 쪽이 생기면 모델이 만든 것이
205
+ 전부 `FIGURE` 라는 이름을 갖게 된다. 타입 이름은 저장되는 식별자다.
206
+ */
207
+ it('타입을 안 주면 FIGURE 로 나간다 — 조용히 빈 이름을 보내지 않는다', async () => {
208
+ const client = stubClient([candidate()])
209
+ setDefaultAIClient(client)
210
+
211
+ await proposeFigure({ prompt: '무언가', palette: PALETTE })
212
+
213
+ expect(firstAsk(client)).toContain('type "FIGURE"')
214
+ })
215
+
216
+ it('새로 만드는 요청이면 타입 이름이 나가고 고치라는 말은 안 나간다', async () => {
217
+ const client = stubClient([candidate()])
218
+ setDefaultAIClient(client)
219
+
220
+ await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
221
+
222
+ const ask = firstAsk(client)
223
+ expect(ask).toContain('type "MIXER"')
224
+ expect(ask).not.toContain('Revise this existing figure')
225
+ })
226
+
227
+ /*
228
+ 그림은 **비례를 보라고** 주는 것이지 표면 무늬를 베끼라고 주는 것이 아니다. 그 말이 빠지면
229
+ 모델이 사진의 로고·녹·글자까지 부품으로 만들려 들고, 로우폴리에서 그것은 부품 낭비다.
230
+ */
231
+ it('그림을 주면 비례를 보라는 말과 함께 나간다', async () => {
232
+ const client = stubClient([candidate()])
233
+ setDefaultAIClient(client)
234
+
235
+ await proposeFigure({
236
+ prompt: '이 설비',
237
+ palette: PALETTE,
238
+ type: 'MIXER',
239
+ image: { data: Buffer.from('89504e47', 'hex'), mediaType: 'image/png' }
240
+ })
241
+
242
+ const ask = firstAsk(client)
243
+ expect(ask).toMatch(/proportions and masses, not for surface detail/)
244
+ // 그림 자체도 실려야 한다 — 말만 나가고 그림이 빠지면 모델이 볼 것이 없다.
245
+ expect(ask).toContain('"type":"image"')
246
+ })
247
+ })
248
+
249
+ describe('모델이 JSON 을 못 낼 때', () => {
250
+ /*
251
+ 어댑터가 던지는 경우다. 그것도 사유이므로 **돌려주고 다시 시킨다** — 던져 버리면 저작자는
252
+ 「안 됐다」만 보고, 무엇이 문제였는지도 모델이 고칠 기회도 없다.
253
+ */
254
+ it('던진 사유를 돌려주고 다시 시킨다', async () => {
255
+ const client = stubClient([new Error('unterminated string'), candidate()])
256
+ setDefaultAIClient(client)
257
+
258
+ const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
259
+
260
+ expect(result.attempts).toBe(2)
261
+ expect(client.calls).toBe(2)
262
+
263
+ // 두 번째 요청에 그 사유가 실려야 한다.
264
+ const retry = JSON.stringify(client.sent[1])
265
+ expect(retry).toContain('unterminated string')
266
+ expect(retry).toMatch(/Answer with the JSON object only/)
267
+ })
268
+
269
+ it('세 번 다 던지면 못 만들었다고 말한다', async () => {
270
+ setDefaultAIClient(stubClient([new Error('a'), new Error('b'), new Error('c')]))
271
+
272
+ await expect(proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })).rejects.toThrow(ProposeFailure)
273
+ })
274
+ })
275
+
276
+ describe('정책을 넘은 후보는 막지 않고 알린다', () => {
277
+ /*
278
+ 형식의 두 갈래를 그대로 지킨다 — `errors` 는 막고 `violations` 는 알린다. 정책 위반으로
279
+ 후보를 버리면 저작자가 판단할 기회가 없어진다. 랜드마크 설비 하나는 정교해도 되고, 그
280
+ 대가가 숫자로 보이면 된다.
281
+ */
282
+ it('등급 한도를 넘겨도 후보로 오고 위반이 함께 온다', async () => {
283
+ const many = Array.from({ length: PART_LIMIT.S + 1 }, (_, at) => ({
284
+ name: `part-${at}`,
285
+ primitive: PRIMITIVE_KINDS[0],
286
+ transform: { position: { x: at * 10, y: 0, z: 0 }, size: { x: 20, y: 20, z: 20 } },
287
+ material: { token: PALETTE[0] }
288
+ }))
289
+ setDefaultAIClient(stubClient([candidate({ detailLevel: 'S', parts: many })]))
290
+
291
+ const result = await proposeFigure({ prompt: '단순한 것', palette: PALETTE, type: 'MIXER' })
292
+
293
+ expect(result.source.parts).toHaveLength(PART_LIMIT.S + 1)
294
+ expect(result.violations.map(one => one.code)).toContain('too-many-parts')
295
+ })
296
+ })
@@ -5,7 +5,8 @@ import { Domain, getQueryBuilderFromListParams, getRepository, ListParam } from
5
5
 
6
6
  import { Figure } from './figure.js'
7
7
  import { FigureHistory } from './figure-history.js'
8
- import { FigureList } from './figure-type.js'
8
+ import { inspectSource } from './figure-gate.js'
9
+ import { FigureInspection, FigureList } from './figure-type.js'
9
10
 
10
11
  @Resolver(Figure)
11
12
  export class FigureQuery {
@@ -82,6 +83,30 @@ export class FigureQuery {
82
83
  })
83
84
  }
84
85
 
86
+ /**
87
+ * 발행해도 되나 — **누르기 전에** 묻는 자리.
88
+ *
89
+ * 판정은 `figure-gate.ts` 가 하고 이 자리는 정본을 꺼내 넘긴다. 같은 판정을 `releaseFigure` 도
90
+ * 부른다 — 화면이 물어 보고 통과했더라도 그 사이에 정본이 바뀌었을 수 있으므로, 막는 것은 언제나
91
+ * 뮤테이션이다. 이 질의는 **이유를 미리 보여 주기 위한 것**이고 허가증이 아니다.
92
+ */
93
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
94
+ @Query(returns => FigureInspection, { description: 'Whether a figure may be released, with the reasons.' })
95
+ async inspectFigure(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureInspection> {
96
+ const { domain } = context.state
97
+
98
+ const figure = await getRepository(Figure).findOne({
99
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
100
+ select: { id: true, source: true }
101
+ })
102
+
103
+ if (!figure) {
104
+ throw new Error(`Figure ${id} 를 찾을 수 없다`)
105
+ }
106
+
107
+ return inspectSource(figure.source)
108
+ }
109
+
85
110
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
86
111
  @Query(returns => [String], { description: 'All figure type names in scope — used to check name collisions before create.' })
87
112
  async figureTypeNames(@Ctx() context: ResolverContext): Promise<string[]> {