@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,213 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProposeFailure = void 0;
4
+ exports.rulesPrompt = rulesPrompt;
5
+ exports.inspect = inspect;
6
+ exports.proposeFigure = proposeFigure;
7
+ const figure_model_1 = require("@hatiolab/figure-model");
8
+ const ai_client_base_1 = require("@things-factory/ai-client-base");
9
+ /**
10
+ * 저작 보조 — 말이나 그림으로 Figure 후보를 만든다.
11
+ *
12
+ * ## 왜 이 문제에 AI 가 맞나
13
+ *
14
+ * 3D 생성에 AI 를 붙이는 시도는 대개 잘 안 된다. 출력이 메시라서 **틀렸는지 기계가
15
+ * 판정할 수 없기** 때문이다. 여기는 다르다 — 출력이 닫힌 어휘로 된 JSON 하나이고,
16
+ * `validate()` 와 `scoreOf()` 가 **객관적인 채점기**다.
17
+ *
18
+ * ## 정본에 직접 쓰지 않는다
19
+ *
20
+ * 이 함수는 **후보**를 낸다. 형식을 어긴 후보는 사람에게 보이지 않는다 — 사유를
21
+ * 모델에 돌려주고 다시 시킨다. 정해진 횟수 안에 못 만들면 **못 만들었다고 말한다.**
22
+ * 반쯤 만든 것을 내밀면 저작자가 그것을 고치느라 처음부터 만드는 것보다 오래 걸린다.
23
+ *
24
+ * ## 어휘를 프롬프트에서 지어내지 않는다
25
+ *
26
+ * 도형·프리셋·한도는 전부 `@hatiolab/figure-model` 이 내보내는 상수에서 만든다.
27
+ * 프롬프트에 손으로 적으면 모델이 바뀔 때 프롬프트만 옛말을 하게 된다.
28
+ */
29
+ /*
30
+ `slot` 은 시키지 않는다.
31
+
32
+ 형식에는 있지만 그리는 쪽이 읽지 않아 **아무 일도 하지 않는다.** 죽은 필드를 채우게
33
+ 시키면 저작자가 「상태 색이 되는구나」로 읽는다. 속성·바인딩 설계가 서면(figure-model#2)
34
+ 그때 다시 넣는다.
35
+
36
+ 강제 규칙과 취향 규칙을 갈라 적는다.
37
+
38
+ `validate()` 가 막는 것만 「hard」다. 한때 「부품은 기준 상자 안에 있어야 한다」를
39
+ 강제 규칙으로 적었는데 **그런 규칙이 없다** — `base` 는 경계 상자가 아니라 배치
40
+ 크기가 나오는 비율 기준이고, 밖으로 나가도 오류가 아니다 [확인함]. 없는 규칙을
41
+ 강제라고 적으면 모델이 그것 때문에 좋은 답을 버린다.
42
+ */
43
+ /** 몇 번까지 다시 시키나. 이보다 늘려도 대개 같은 자리에서 막힌다. */
44
+ const MAX_ATTEMPTS = 3;
45
+ /** 못 만들었을 때. 조용히 빈 것을 돌려주지 않는다. */
46
+ class ProposeFailure extends Error {
47
+ constructor(message,
48
+ /** 마지막 시도가 낸 사유들. 저작자에게 그대로 보여 준다. */
49
+ reasons, attempts) {
50
+ super(message);
51
+ this.reasons = reasons;
52
+ this.attempts = attempts;
53
+ this.name = 'ProposeFailure';
54
+ }
55
+ }
56
+ exports.ProposeFailure = ProposeFailure;
57
+ /**
58
+ * 규칙을 프롬프트로 옮긴다.
59
+ *
60
+ * 값을 손으로 적지 않는다 — figure-model 의 상수에서 만든다. 그래야 한도가 바뀌면
61
+ * 프롬프트도 같이 바뀐다.
62
+ */
63
+ function rulesPrompt(palette) {
64
+ const partLimits = figure_model_1.DETAIL_LEVELS.map(level => `${level}=${figure_model_1.PART_LIMIT[level]}`).join(' · ');
65
+ return [
66
+ 'You compose low-poly 3D figures for a factory digital twin.',
67
+ 'Answer with a single JSON object and nothing else — no prose, no code fence.',
68
+ '',
69
+ 'The JSON is a FigureSource:',
70
+ ' { type, base: {x,y,z}, detailLevel, parts: [...] }',
71
+ '',
72
+ 'A part is:',
73
+ ' { name, primitive, transform: { position: {x,y,z}, size: {x,y,z}, rotation?: {x,y,z} },',
74
+ ' material: {token, preset?}, segments?, shape?, sizing? }',
75
+ '',
76
+ 'Hard vocabulary — anything outside these is rejected:',
77
+ ` primitive ${figure_model_1.PRIMITIVE_KINDS.join(' | ')}`,
78
+ ` segments ${figure_model_1.SEGMENT_PRESETS.join(' | ')} (only for cylinder and sphere)`,
79
+ ` material.token ${palette.join(' | ')}`,
80
+ ` material.preset ${figure_model_1.MATERIAL_PRESETS.join(' | ')}`,
81
+ ` sizing ${figure_model_1.SIZING_RULES.join(' | ')}`,
82
+ ` animation.path ${figure_model_1.CHANNEL_PATHS.join(' | ')}`,
83
+ ` detailLevel ${figure_model_1.DETAIL_LEVELS.join(' | ')}`,
84
+ '',
85
+ 'Hard limits — exceeding these makes the figure unusable at scale:',
86
+ ` parts by detailLevel: ${partLimits}`,
87
+ ` material groups (distinct token+preset+slot combinations) <= ${figure_model_1.LIMITS.materialGroups}`,
88
+ ` animated parts <= ${figure_model_1.LIMITS.animatedParts}`,
89
+ ` transparent materials <= ${figure_model_1.LIMITS.transparentMaterials}`,
90
+ '',
91
+ 'Geometry rules:',
92
+ ' Axes are the usual 3D ones: x right, y UP, z towards the viewer — same as glTF and three.js.',
93
+ ' transform.position is the CENTRE of the part, not a corner.',
94
+ ' The origin (0,0,0) is the centre of the base box, so the floor is at y = -base.y / 2.',
95
+ ' base and transform.size are extents along each axis: size.y is the height.',
96
+ ' part names are stored identifiers — lowercase, hyphenated, unique, and descriptive',
97
+ ' of the real thing (body, lid, motor, leg-front-left), never part-1.',
98
+ '',
99
+ 'Taste rules — this is what makes it low-poly rather than a pile of boxes:',
100
+ ' Few colours. Reuse one token for the mass and reserve accents for what must stand out.',
101
+ ' Recognisable silhouette from directly above — that is how it is seen on a board.',
102
+ ' Prefer fewer, larger parts. A detail smaller than a twentieth of the base is not visible.',
103
+ ' Use the coarsest segment count that keeps the silhouette right.',
104
+ ' Keep parts within the base box unless the real thing overhangs. The base is the reference',
105
+ ' the instance is scaled from, so a part far outside it grows in a way nobody intended.'
106
+ ].join('\n');
107
+ }
108
+ /** 무엇을 만들라는 것인지 한 덩어리로. */
109
+ function taskPrompt(input) {
110
+ const lines = [`Request: ${input.prompt}`];
111
+ if (input.base) {
112
+ lines.push('', 'Revise this existing figure. Keep the part names that still mean the same thing —', 'renaming a part breaks bindings on instances already placed.', JSON.stringify(input.base));
113
+ }
114
+ else {
115
+ lines.push('', `Create a new figure with type "${input.type ?? 'FIGURE'}".`);
116
+ }
117
+ if (input.image) {
118
+ lines.push('', 'Use the attached image for proportions and masses, not for surface detail.');
119
+ }
120
+ return lines.join('\n');
121
+ }
122
+ /**
123
+ * 후보 하나를 받아 본다.
124
+ *
125
+ * 던지는 것이 아니라 **사유를 모아 돌려준다** — 사유가 다음 시도의 입력이 되기
126
+ * 때문이다. 사람에게 보여 줄 것과 모델에 돌려줄 것이 같은 글이다.
127
+ */
128
+ function inspect(parsed, palette) {
129
+ const result = (0, figure_model_1.validate)(parsed);
130
+ if (result.errors.length > 0) {
131
+ return { reasons: result.errors.map(error => `${error.path}: ${error.message}`) };
132
+ }
133
+ const source = parsed;
134
+ /*
135
+ 토큰은 형식상 자유 문자열이라 validate 를 지난다. 그런데 팔레트에 없는 토큰은
136
+ 그리는 시점에 `resolveToken` 이 던진다 — 저장까지 되고 나서 화면에서 죽는다.
137
+ 그래서 여기서 막는다.
138
+ */
139
+ const unknown = [
140
+ ...new Set(source.parts.map(part => part.material?.token).filter(token => token && !palette.includes(token)))
141
+ ];
142
+ if (unknown.length > 0) {
143
+ return {
144
+ reasons: [`Unknown palette tokens: ${unknown.join(', ')}. Use only: ${palette.join(', ')}.`]
145
+ };
146
+ }
147
+ return { source, reasons: [] };
148
+ }
149
+ /**
150
+ * 말과 그림으로 후보를 만든다.
151
+ *
152
+ * 생성-검사 루프다. 도구 루프가 아니다 — 첫 조각에 도구는 필요 없다.
153
+ *
154
+ * 도구가 필요해지면 `@things-factory/ai-client-base` 의 `runAgenticLoop` 을 부른다.
155
+ * 전에는 그것이 board-ai 안에만 있어 밖에서 쓸 수 없었는데, 공용으로 옮겨졌다.
156
+ */
157
+ async function proposeFigure(input) {
158
+ const client = (0, ai_client_base_1.getDefaultAIClient)();
159
+ if (!client) {
160
+ // 없는 것을 있는 척하지 않는다. 부르는 쪽이 사람에게 그대로 옮길 수 있는 말로.
161
+ throw new ProposeFailure('AI 모델이 설정돼 있지 않습니다.', [], 0);
162
+ }
163
+ const system = rulesPrompt(input.palette);
164
+ const messages = [
165
+ {
166
+ role: 'user',
167
+ content: input.image
168
+ ? [
169
+ { type: 'text', text: taskPrompt(input) },
170
+ { type: 'image', source: { kind: 'base64', data: input.image.data, mediaType: input.image.mediaType } }
171
+ ]
172
+ : taskPrompt(input)
173
+ }
174
+ ];
175
+ let reasons = [];
176
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
177
+ // `generateJSON` 을 쓴다 — 어댑터가 JSON 출력을 강제하고 파싱까지 해 준다.
178
+ // 코드 울타리나 앞뒤 설명을 우리가 걷어내지 않아도 된다.
179
+ let answer;
180
+ try {
181
+ answer = await client.generateJSON(messages, { systemPrompt: system });
182
+ }
183
+ catch (e) {
184
+ // 모델이 JSON 을 못 냈다. 그것도 사유이므로 돌려주고 다시 시킨다.
185
+ reasons = [`The answer was not valid JSON: ${e.message}`];
186
+ messages.push({
187
+ role: 'user',
188
+ content: ['That was rejected. Answer with the JSON object only:', ...reasons].join('\n')
189
+ });
190
+ continue;
191
+ }
192
+ const looked = inspect(answer, input.palette);
193
+ if (looked.source) {
194
+ const blueprint = (0, figure_model_1.compile)(looked.source);
195
+ return {
196
+ source: looked.source,
197
+ score: (0, figure_model_1.scoreOf)(blueprint),
198
+ cost: (0, figure_model_1.costOf)(blueprint, 100),
199
+ violations: blueprint.violations.map(v => ({ code: v.code, message: v.message })),
200
+ attempts: attempt
201
+ };
202
+ }
203
+ reasons = looked.reasons;
204
+ // 사유를 그대로 돌려준다. 요약하면 모델이 무엇을 고쳐야 하는지 모른다.
205
+ messages.push({ role: 'assistant', content: JSON.stringify(answer) });
206
+ messages.push({
207
+ role: 'user',
208
+ content: ['That was rejected. Fix these and answer again with the JSON only:', ...reasons].join('\n')
209
+ });
210
+ }
211
+ throw new ProposeFailure('만들지 못했습니다.', reasons, MAX_ATTEMPTS);
212
+ }
213
+ //# sourceMappingURL=figure-propose.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-propose.js","sourceRoot":"","sources":["../../../server/service/figure/figure-propose.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n CHANNEL_PATHS,\n DETAIL_LEVELS,\n LIMITS,\n MATERIAL_PRESETS,\n PART_LIMIT,\n PRIMITIVE_KINDS,\n SEGMENT_PRESETS,\n SIZING_RULES,\n compile,\n costOf,\n scoreOf,\n validate\n} from '@hatiolab/figure-model'\nimport type { FigureSource } from '@hatiolab/figure-model'\nimport { getDefaultAIClient } from '@things-factory/ai-client-base'\nimport type { AIImageMediaType, AIMessage } from '@things-factory/ai-client-base'\n\n/**\n * 저작 보조 — 말이나 그림으로 Figure 후보를 만든다.\n *\n * ## 왜 이 문제에 AI 가 맞나\n *\n * 3D 생성에 AI 를 붙이는 시도는 대개 잘 안 된다. 출력이 메시라서 **틀렸는지 기계가\n * 판정할 수 없기** 때문이다. 여기는 다르다 — 출력이 닫힌 어휘로 된 JSON 하나이고,\n * `validate()` 와 `scoreOf()` 가 **객관적인 채점기**다.\n *\n * ## 정본에 직접 쓰지 않는다\n *\n * 이 함수는 **후보**를 낸다. 형식을 어긴 후보는 사람에게 보이지 않는다 — 사유를\n * 모델에 돌려주고 다시 시킨다. 정해진 횟수 안에 못 만들면 **못 만들었다고 말한다.**\n * 반쯤 만든 것을 내밀면 저작자가 그것을 고치느라 처음부터 만드는 것보다 오래 걸린다.\n *\n * ## 어휘를 프롬프트에서 지어내지 않는다\n *\n * 도형·프리셋·한도는 전부 `@hatiolab/figure-model` 이 내보내는 상수에서 만든다.\n * 프롬프트에 손으로 적으면 모델이 바뀔 때 프롬프트만 옛말을 하게 된다.\n */\n\n/*\n `slot` 은 시키지 않는다.\n\n 형식에는 있지만 그리는 쪽이 읽지 않아 **아무 일도 하지 않는다.** 죽은 필드를 채우게\n 시키면 저작자가 「상태 색이 되는구나」로 읽는다. 속성·바인딩 설계가 서면(figure-model#2)\n 그때 다시 넣는다.\n\n 강제 규칙과 취향 규칙을 갈라 적는다.\n\n `validate()` 가 막는 것만 「hard」다. 한때 「부품은 기준 상자 안에 있어야 한다」를\n 강제 규칙으로 적었는데 **그런 규칙이 없다** — `base` 는 경계 상자가 아니라 배치\n 크기가 나오는 비율 기준이고, 밖으로 나가도 오류가 아니다 [확인함]. 없는 규칙을\n 강제라고 적으면 모델이 그것 때문에 좋은 답을 버린다.\n*/\n\n/** 몇 번까지 다시 시키나. 이보다 늘려도 대개 같은 자리에서 막힌다. */\nconst MAX_ATTEMPTS = 3\n\nexport interface ProposeInput {\n /** 사람이 시킨 말. */\n prompt: string\n /** 고쳐 달라는 것이면 지금 정본. 없으면 새로 만든다. */\n base?: FigureSource\n /** 참고 그림. 설비 사진·렌더. */\n image?: { data: Buffer; mediaType: AIImageMediaType }\n /** 만들 타입 이름. 새로 만들 때 필요하다. */\n type?: string\n /** 쓸 수 있는 팔레트 토큰. 여기 없는 토큰은 거절한다. */\n palette: string[]\n}\n\nexport interface ProposeResult {\n source: FigureSource\n /** 재활용 점수와 한도 여유. */\n score: ReturnType<typeof scoreOf>\n /** 무게. */\n cost: ReturnType<typeof costOf>\n /** 형식은 맞으나 정책을 넘은 것. 막지 않고 알린다. */\n violations: { code: string; message: string }[]\n /** 몇 번 만에 됐나. 저작자에게 보여 줄 값은 아니지만 진단에 쓴다. */\n attempts: number\n}\n\n/** 못 만들었을 때. 조용히 빈 것을 돌려주지 않는다. */\nexport class ProposeFailure extends Error {\n constructor(\n message: string,\n /** 마지막 시도가 낸 사유들. 저작자에게 그대로 보여 준다. */\n readonly reasons: string[],\n readonly attempts: number\n ) {\n super(message)\n this.name = 'ProposeFailure'\n }\n}\n\n/**\n * 규칙을 프롬프트로 옮긴다.\n *\n * 값을 손으로 적지 않는다 — figure-model 의 상수에서 만든다. 그래야 한도가 바뀌면\n * 프롬프트도 같이 바뀐다.\n */\nexport function rulesPrompt(palette: string[]): string {\n const partLimits = DETAIL_LEVELS.map(level => `${level}=${PART_LIMIT[level]}`).join(' · ')\n\n return [\n 'You compose low-poly 3D figures for a factory digital twin.',\n 'Answer with a single JSON object and nothing else — no prose, no code fence.',\n '',\n 'The JSON is a FigureSource:',\n ' { type, base: {x,y,z}, detailLevel, parts: [...] }',\n '',\n 'A part is:',\n ' { name, primitive, transform: { position: {x,y,z}, size: {x,y,z}, rotation?: {x,y,z} },',\n ' material: {token, preset?}, segments?, shape?, sizing? }',\n '',\n 'Hard vocabulary — anything outside these is rejected:',\n ` primitive ${PRIMITIVE_KINDS.join(' | ')}`,\n ` segments ${SEGMENT_PRESETS.join(' | ')} (only for cylinder and sphere)`,\n ` material.token ${palette.join(' | ')}`,\n ` material.preset ${MATERIAL_PRESETS.join(' | ')}`,\n ` sizing ${SIZING_RULES.join(' | ')}`,\n ` animation.path ${CHANNEL_PATHS.join(' | ')}`,\n ` detailLevel ${DETAIL_LEVELS.join(' | ')}`,\n '',\n 'Hard limits — exceeding these makes the figure unusable at scale:',\n ` parts by detailLevel: ${partLimits}`,\n ` material groups (distinct token+preset+slot combinations) <= ${LIMITS.materialGroups}`,\n ` animated parts <= ${LIMITS.animatedParts}`,\n ` transparent materials <= ${LIMITS.transparentMaterials}`,\n '',\n 'Geometry rules:',\n ' Axes are the usual 3D ones: x right, y UP, z towards the viewer — same as glTF and three.js.',\n ' transform.position is the CENTRE of the part, not a corner.',\n ' The origin (0,0,0) is the centre of the base box, so the floor is at y = -base.y / 2.',\n ' base and transform.size are extents along each axis: size.y is the height.',\n ' part names are stored identifiers — lowercase, hyphenated, unique, and descriptive',\n ' of the real thing (body, lid, motor, leg-front-left), never part-1.',\n '',\n 'Taste rules — this is what makes it low-poly rather than a pile of boxes:',\n ' Few colours. Reuse one token for the mass and reserve accents for what must stand out.',\n ' Recognisable silhouette from directly above — that is how it is seen on a board.',\n ' Prefer fewer, larger parts. A detail smaller than a twentieth of the base is not visible.',\n ' Use the coarsest segment count that keeps the silhouette right.',\n ' Keep parts within the base box unless the real thing overhangs. The base is the reference',\n ' the instance is scaled from, so a part far outside it grows in a way nobody intended.'\n ].join('\\n')\n}\n\n/** 무엇을 만들라는 것인지 한 덩어리로. */\nfunction taskPrompt(input: ProposeInput): string {\n const lines = [`Request: ${input.prompt}`]\n\n if (input.base) {\n lines.push(\n '',\n 'Revise this existing figure. Keep the part names that still mean the same thing —',\n 'renaming a part breaks bindings on instances already placed.',\n JSON.stringify(input.base)\n )\n } else {\n lines.push('', `Create a new figure with type \"${input.type ?? 'FIGURE'}\".`)\n }\n\n if (input.image) {\n lines.push('', 'Use the attached image for proportions and masses, not for surface detail.')\n }\n\n return lines.join('\\n')\n}\n\n/**\n * 후보 하나를 받아 본다.\n *\n * 던지는 것이 아니라 **사유를 모아 돌려준다** — 사유가 다음 시도의 입력이 되기\n * 때문이다. 사람에게 보여 줄 것과 모델에 돌려줄 것이 같은 글이다.\n */\nexport function inspect(parsed: unknown, palette: string[]): { source?: FigureSource; reasons: string[] } {\n const result = validate(parsed as FigureSource)\n if (result.errors.length > 0) {\n return { reasons: result.errors.map(error => `${error.path}: ${error.message}`) }\n }\n\n const source = parsed as FigureSource\n\n /*\n 토큰은 형식상 자유 문자열이라 validate 를 지난다. 그런데 팔레트에 없는 토큰은\n 그리는 시점에 `resolveToken` 이 던진다 — 저장까지 되고 나서 화면에서 죽는다.\n 그래서 여기서 막는다.\n */\n const unknown = [\n ...new Set(source.parts.map(part => part.material?.token).filter(token => token && !palette.includes(token)))\n ]\n if (unknown.length > 0) {\n return {\n reasons: [`Unknown palette tokens: ${unknown.join(', ')}. Use only: ${palette.join(', ')}.`]\n }\n }\n\n return { source, reasons: [] }\n}\n\n/**\n * 말과 그림으로 후보를 만든다.\n *\n * 생성-검사 루프다. 도구 루프가 아니다 — 첫 조각에 도구는 필요 없다.\n *\n * 도구가 필요해지면 `@things-factory/ai-client-base` 의 `runAgenticLoop` 을 부른다.\n * 전에는 그것이 board-ai 안에만 있어 밖에서 쓸 수 없었는데, 공용으로 옮겨졌다.\n */\nexport async function proposeFigure(input: ProposeInput): Promise<ProposeResult> {\n const client = getDefaultAIClient()\n if (!client) {\n // 없는 것을 있는 척하지 않는다. 부르는 쪽이 사람에게 그대로 옮길 수 있는 말로.\n throw new ProposeFailure('AI 모델이 설정돼 있지 않습니다.', [], 0)\n }\n\n const system = rulesPrompt(input.palette)\n const messages: AIMessage[] = [\n {\n role: 'user',\n content: input.image\n ? [\n { type: 'text', text: taskPrompt(input) },\n { type: 'image', source: { kind: 'base64', data: input.image.data, mediaType: input.image.mediaType } }\n ]\n : taskPrompt(input)\n }\n ]\n\n let reasons: string[] = []\n\n for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {\n // `generateJSON` 을 쓴다 — 어댑터가 JSON 출력을 강제하고 파싱까지 해 준다.\n // 코드 울타리나 앞뒤 설명을 우리가 걷어내지 않아도 된다.\n let answer: unknown\n try {\n answer = await client.generateJSON(messages, { systemPrompt: system })\n } catch (e) {\n // 모델이 JSON 을 못 냈다. 그것도 사유이므로 돌려주고 다시 시킨다.\n reasons = [`The answer was not valid JSON: ${(e as Error).message}`]\n messages.push({\n role: 'user',\n content: ['That was rejected. Answer with the JSON object only:', ...reasons].join('\\n')\n })\n continue\n }\n\n const looked = inspect(answer, input.palette)\n if (looked.source) {\n const blueprint = compile(looked.source)\n return {\n source: looked.source,\n score: scoreOf(blueprint),\n cost: costOf(blueprint, 100),\n violations: blueprint.violations.map(v => ({ code: v.code, message: v.message })),\n attempts: attempt\n }\n }\n\n reasons = looked.reasons\n\n // 사유를 그대로 돌려준다. 요약하면 모델이 무엇을 고쳐야 하는지 모른다.\n messages.push({ role: 'assistant', content: JSON.stringify(answer) })\n messages.push({\n role: 'user',\n content: ['That was rejected. Fix these and answer again with the JSON only:', ...reasons].join('\\n')\n })\n }\n\n throw new ProposeFailure('만들지 못했습니다.', reasons, MAX_ATTEMPTS)\n}\n"]}
@@ -0,0 +1,16 @@
1
+ import { ListParam } from '@things-factory/shell';
2
+ import { Figure } from './figure.js';
3
+ import { FigureList } from './figure-type.js';
4
+ export declare class FigureQuery {
5
+ figure(id: string, context: ResolverContext): Promise<Figure>;
6
+ figures(params: ListParam, context: ResolverContext): Promise<FigureList>;
7
+ /**
8
+ * 등록된 타입 이름 목록.
9
+ *
10
+ * 저작 화면이 **새 타입 이름이 이미 쓰이는지** 물어보는 데 쓴다. `type` 은 저장되는
11
+ * 식별자라 만든 뒤에 고칠 수 없으므로, 만들기 전에 알려 줘야 한다.
12
+ *
13
+ * `state` 로 걸러지지 않는다 — `draft` 인 Figure 의 이름도 이미 점유된 것이다.
14
+ */
15
+ figureTypeNames(context: ResolverContext): Promise<string[]>;
16
+ }
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FigureQuery = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const typeorm_1 = require("typeorm");
6
+ const type_graphql_1 = require("type-graphql");
7
+ const shell_1 = require("@things-factory/shell");
8
+ const figure_js_1 = require("./figure.js");
9
+ const figure_type_js_1 = require("./figure-type.js");
10
+ let FigureQuery = class FigureQuery {
11
+ async figure(id, context) {
12
+ const { domain } = context.state;
13
+ // 부모 도메인의 Figure 도 보인다 — Board 와 같은 범위 규칙이다.
14
+ return await (0, shell_1.getRepository)(figure_js_1.Figure).findOne({
15
+ where: { domain: { id: (0, typeorm_1.In)([domain.id, domain.parentId].filter(Boolean)) }, id }
16
+ });
17
+ }
18
+ async figures(params, context) {
19
+ const { domain } = context.state;
20
+ const queryBuilder = (0, shell_1.getQueryBuilderFromListParams)({
21
+ repository: (0, shell_1.getRepository)(figure_js_1.Figure),
22
+ params,
23
+ domain,
24
+ alias: 'figure',
25
+ searchables: ['type', 'name', 'description', 'category']
26
+ });
27
+ const [items, total] = await queryBuilder.getManyAndCount();
28
+ return { items, total };
29
+ }
30
+ /**
31
+ * 등록된 타입 이름 목록.
32
+ *
33
+ * 저작 화면이 **새 타입 이름이 이미 쓰이는지** 물어보는 데 쓴다. `type` 은 저장되는
34
+ * 식별자라 만든 뒤에 고칠 수 없으므로, 만들기 전에 알려 줘야 한다.
35
+ *
36
+ * `state` 로 걸러지지 않는다 — `draft` 인 Figure 의 이름도 이미 점유된 것이다.
37
+ */
38
+ async figureTypeNames(context) {
39
+ const { domain } = context.state;
40
+ const rows = await (0, shell_1.getRepository)(figure_js_1.Figure).find({
41
+ where: { domain: { id: (0, typeorm_1.In)([domain.id, domain.parentId].filter(Boolean)) } },
42
+ select: { type: true }
43
+ });
44
+ return rows.map(r => r.type).filter(Boolean);
45
+ }
46
+ };
47
+ exports.FigureQuery = FigureQuery;
48
+ tslib_1.__decorate([
49
+ (0, type_graphql_1.Directive)('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)'),
50
+ (0, type_graphql_1.Query)(returns => figure_js_1.Figure, { nullable: true, description: 'Finds a single figure by its ID.' }),
51
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('id')),
52
+ tslib_1.__param(1, (0, type_graphql_1.Ctx)()),
53
+ tslib_1.__metadata("design:type", Function),
54
+ tslib_1.__metadata("design:paramtypes", [String, Object]),
55
+ tslib_1.__metadata("design:returntype", Promise)
56
+ ], FigureQuery.prototype, "figure", null);
57
+ tslib_1.__decorate([
58
+ (0, type_graphql_1.Directive)('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)'),
59
+ (0, type_graphql_1.Query)(returns => figure_type_js_1.FigureList, { description: 'Lists figures. Sortable by score, triangles, name, updatedAt.' }),
60
+ tslib_1.__param(0, (0, type_graphql_1.Args)()),
61
+ tslib_1.__param(1, (0, type_graphql_1.Ctx)()),
62
+ tslib_1.__metadata("design:type", Function),
63
+ tslib_1.__metadata("design:paramtypes", [shell_1.ListParam, Object]),
64
+ tslib_1.__metadata("design:returntype", Promise)
65
+ ], FigureQuery.prototype, "figures", null);
66
+ tslib_1.__decorate([
67
+ (0, type_graphql_1.Directive)('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)'),
68
+ (0, type_graphql_1.Query)(returns => [String], { description: 'All figure type names in scope — used to check name collisions before create.' }),
69
+ tslib_1.__param(0, (0, type_graphql_1.Ctx)()),
70
+ tslib_1.__metadata("design:type", Function),
71
+ tslib_1.__metadata("design:paramtypes", [Object]),
72
+ tslib_1.__metadata("design:returntype", Promise)
73
+ ], FigureQuery.prototype, "figureTypeNames", null);
74
+ exports.FigureQuery = FigureQuery = tslib_1.__decorate([
75
+ (0, type_graphql_1.Resolver)(figure_js_1.Figure)
76
+ ], FigureQuery);
77
+ //# sourceMappingURL=figure-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-query.js","sourceRoot":"","sources":["../../../server/service/figure/figure-query.ts"],"names":[],"mappings":"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","sourcesContent":["import { In } from 'typeorm'\nimport { Args, Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'\n\nimport { Domain, getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'\n\nimport { Figure } from './figure.js'\nimport { FigureList } from './figure-type.js'\n\n@Resolver(Figure)\nexport class FigureQuery {\n @Directive('@privilege(category: \"figure\", privilege: \"query\", domainOwnerGranted: true)')\n @Query(returns => Figure, { nullable: true, description: 'Finds a single figure by its ID.' })\n async figure(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<Figure> {\n const { domain } = context.state\n\n // 부모 도메인의 Figure 도 보인다 — Board 와 같은 범위 규칙이다.\n return await getRepository(Figure).findOne({\n where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id }\n })\n }\n\n @Directive('@privilege(category: \"figure\", privilege: \"query\", domainOwnerGranted: true)')\n @Query(returns => FigureList, { description: 'Lists figures. Sortable by score, triangles, name, updatedAt.' })\n async figures(@Args() params: ListParam, @Ctx() context: ResolverContext): Promise<FigureList> {\n const { domain } = context.state\n\n const queryBuilder = getQueryBuilderFromListParams({\n repository: getRepository(Figure),\n params,\n domain,\n alias: 'figure',\n searchables: ['type', 'name', 'description', 'category']\n })\n\n const [items, total] = await queryBuilder.getManyAndCount()\n\n return { items, total }\n }\n\n /**\n * 등록된 타입 이름 목록.\n *\n * 저작 화면이 **새 타입 이름이 이미 쓰이는지** 물어보는 데 쓴다. `type` 은 저장되는\n * 식별자라 만든 뒤에 고칠 수 없으므로, 만들기 전에 알려 줘야 한다.\n *\n * `state` 로 걸러지지 않는다 — `draft` 인 Figure 의 이름도 이미 점유된 것이다.\n */\n @Directive('@privilege(category: \"figure\", privilege: \"query\", domainOwnerGranted: true)')\n @Query(returns => [String], { description: 'All figure type names in scope — used to check name collisions before create.' })\n async figureTypeNames(@Ctx() context: ResolverContext): Promise<string[]> {\n const { domain } = context.state\n\n const rows = await getRepository(Figure).find({\n where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) } },\n select: { type: true }\n })\n\n return rows.map(r => r.type).filter(Boolean)\n }\n}\n"]}
@@ -0,0 +1,45 @@
1
+ import { FigureFinding } from './figure-type.js';
2
+ /**
3
+ * 정본을 받아 **검증하고 파생값을 계산한다.**
4
+ *
5
+ * ## 왜 서버가 검증하나
6
+ *
7
+ * 저작 화면을 못 믿어서가 아니다. **저작 화면을 거치지 않는 길이 있다** — 가져오기,
8
+ * API 직접 호출, AI 어시스턴트. 그 길로 들어온 정본이 형식을 어기면 보드에서 조용히
9
+ * 안 그려진다. 들어오는 문에서 막는다.
10
+ *
11
+ * ## 막는 것과 알리는 것
12
+ *
13
+ * `figure-model` 이 두 단계로 나눠 놓았고, 서버가 그 경계를 다시 정하지 않는다.
14
+ *
15
+ * errors 형식을 어긴 것. **거절한다**
16
+ * violations 한도를 넘은 것. 거절하지 않고 **함께 돌려준다**
17
+ *
18
+ * ## 파생값은 서버가 계산한다
19
+ *
20
+ * `score`·`triangles`·`groups` 를 클라이언트에서 받지 않는다. 받으면 거짓이 들어오고
21
+ * 목록 화면이 실물과 다른 것을 보여 준다. 정본에서 계산한다.
22
+ */
23
+ export interface ParsedSource {
24
+ /** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 — 다시 직렬화하지 않는다. */
25
+ source: string;
26
+ /** 목록 화면이 정렬·필터에 쓰는 파생값. */
27
+ derived: {
28
+ score: number;
29
+ triangles: number;
30
+ groups: number;
31
+ };
32
+ /** 막지 않는 발견. 호출한 쪽이 응답에 실어 보낸다. */
33
+ violations: FigureFinding[];
34
+ }
35
+ /** 정본이 형식을 어겼다. 저장하지 않는다. */
36
+ export declare class FigureSourceError extends Error {
37
+ readonly findings: FigureFinding[];
38
+ constructor(findings: FigureFinding[]);
39
+ }
40
+ /**
41
+ * 정본 JSON 문자열을 검증하고 파생값을 계산한다.
42
+ *
43
+ * @throws {FigureSourceError} JSON 이 아니거나 형식을 어긴 경우
44
+ */
45
+ export declare function parseSource(sourceJson: string): ParsedSource;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FigureSourceError = void 0;
4
+ exports.parseSource = parseSource;
5
+ const figure_model_1 = require("@hatiolab/figure-model");
6
+ /** 정본이 형식을 어겼다. 저장하지 않는다. */
7
+ class FigureSourceError extends Error {
8
+ constructor(findings) {
9
+ super(`정본이 형식을 어겼다 — ${findings.length}건: ${findings.map(f => f.code).join(', ')}`);
10
+ this.name = 'FigureSourceError';
11
+ this.findings = findings;
12
+ }
13
+ }
14
+ exports.FigureSourceError = FigureSourceError;
15
+ /**
16
+ * 정본 JSON 문자열을 검증하고 파생값을 계산한다.
17
+ *
18
+ * @throws {FigureSourceError} JSON 이 아니거나 형식을 어긴 경우
19
+ */
20
+ function parseSource(sourceJson) {
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(sourceJson);
24
+ }
25
+ catch (e) {
26
+ throw new FigureSourceError([
27
+ { code: 'source-not-json', message: `정본이 JSON 이 아니다: ${e.message}`, at: 'source' }
28
+ ]);
29
+ }
30
+ const result = (0, figure_model_1.validate)(parsed);
31
+ if (result.errors.length > 0) {
32
+ throw new FigureSourceError(result.errors.map(e => ({ code: e.code, message: e.message, at: e.path })));
33
+ }
34
+ // 여기까지 왔으면 형식은 맞다. 청사진을 만들어 셈에 쓴다.
35
+ // 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.
36
+ const blueprint = (0, figure_model_1.compile)(parsed);
37
+ const cost = (0, figure_model_1.costOf)(blueprint, 1);
38
+ const score = (0, figure_model_1.scoreOf)(blueprint);
39
+ return {
40
+ source: sourceJson,
41
+ derived: {
42
+ score: score.score,
43
+ triangles: cost.triangles,
44
+ groups: cost.groups
45
+ },
46
+ violations: result.violations.map(v => ({
47
+ code: v.code,
48
+ message: v.message,
49
+ at: undefined
50
+ }))
51
+ };
52
+ }
53
+ //# sourceMappingURL=figure-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-source.js","sourceRoot":"","sources":["../../../server/service/figure/figure-source.ts"],"names":[],"mappings":";;;AAkDA,kCAoCC;AAtFD,yDAA2E;AAkC3E,6BAA6B;AAC7B,MAAa,iBAAkB,SAAQ,KAAK;IAG1C,YAAY,QAAyB;QACnC,KAAK,CAAC,iBAAiB,QAAQ,CAAC,MAAM,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACnF,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAA;QAC/B,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;IAC1B,CAAC;CACF;AARD,8CAQC;AAED;;;;GAIG;AACH,SAAgB,WAAW,CAAC,UAAkB;IAC5C,IAAI,MAAe,CAAA;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;IACjC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,MAAM,IAAI,iBAAiB,CAAC;YAC1B,EAAE,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,mBAAoB,CAAW,CAAC,OAAO,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE;SAC9F,CAAC,CAAA;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,IAAA,uBAAQ,EAAC,MAAM,CAAC,CAAA;IAC/B,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC7B,MAAM,IAAI,iBAAiB,CACzB,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAC3E,CAAA;IACH,CAAC;IAED,mCAAmC;IACnC,kDAAkD;IAClD,MAAM,SAAS,GAAG,IAAA,sBAAO,EAAC,MAAa,CAAC,CAAA;IACxC,MAAM,IAAI,GAAG,IAAA,qBAAM,EAAC,SAAS,EAAE,CAAC,CAAC,CAAA;IACjC,MAAM,KAAK,GAAG,IAAA,sBAAO,EAAC,SAAS,CAAC,CAAA;IAEhC,OAAO;QACL,MAAM,EAAE,UAAU;QAClB,OAAO,EAAE;YACP,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,MAAM,EAAE,IAAI,CAAC,MAAM;SACpB;QACD,UAAU,EAAE,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YACtC,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,OAAO,EAAE,CAAC,CAAC,OAAO;YAClB,EAAE,EAAE,SAAS;SACd,CAAC,CAAC;KACJ,CAAA;AACH,CAAC","sourcesContent":["import { compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'\n\nimport { FigureFinding } from './figure-type.js'\n\n/**\n * 정본을 받아 **검증하고 파생값을 계산한다.**\n *\n * ## 왜 서버가 검증하나\n *\n * 저작 화면을 못 믿어서가 아니다. **저작 화면을 거치지 않는 길이 있다** — 가져오기,\n * API 직접 호출, AI 어시스턴트. 그 길로 들어온 정본이 형식을 어기면 보드에서 조용히\n * 안 그려진다. 들어오는 문에서 막는다.\n *\n * ## 막는 것과 알리는 것\n *\n * `figure-model` 이 두 단계로 나눠 놓았고, 서버가 그 경계를 다시 정하지 않는다.\n *\n * errors 형식을 어긴 것. **거절한다**\n * violations 한도를 넘은 것. 거절하지 않고 **함께 돌려준다**\n *\n * ## 파생값은 서버가 계산한다\n *\n * `score`·`triangles`·`groups` 를 클라이언트에서 받지 않는다. 받으면 거짓이 들어오고\n * 목록 화면이 실물과 다른 것을 보여 준다. 정본에서 계산한다.\n */\nexport interface ParsedSource {\n /** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 — 다시 직렬화하지 않는다. */\n source: string\n /** 목록 화면이 정렬·필터에 쓰는 파생값. */\n derived: { score: number; triangles: number; groups: number }\n /** 막지 않는 발견. 호출한 쪽이 응답에 실어 보낸다. */\n violations: FigureFinding[]\n}\n\n/** 정본이 형식을 어겼다. 저장하지 않는다. */\nexport class FigureSourceError extends Error {\n readonly findings: FigureFinding[]\n\n constructor(findings: FigureFinding[]) {\n super(`정본이 형식을 어겼다 — ${findings.length}건: ${findings.map(f => f.code).join(', ')}`)\n this.name = 'FigureSourceError'\n this.findings = findings\n }\n}\n\n/**\n * 정본 JSON 문자열을 검증하고 파생값을 계산한다.\n *\n * @throws {FigureSourceError} JSON 이 아니거나 형식을 어긴 경우\n */\nexport function parseSource(sourceJson: string): ParsedSource {\n let parsed: unknown\n try {\n parsed = JSON.parse(sourceJson)\n } catch (e) {\n throw new FigureSourceError([\n { code: 'source-not-json', message: `정본이 JSON 이 아니다: ${(e as Error).message}`, at: 'source' }\n ])\n }\n\n const result = validate(parsed)\n if (result.errors.length > 0) {\n throw new FigureSourceError(\n result.errors.map(e => ({ code: e.code, message: e.message, at: e.path }))\n )\n }\n\n // 여기까지 왔으면 형식은 맞다. 청사진을 만들어 셈에 쓴다.\n // 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.\n const blueprint = compile(parsed as any)\n const cost = costOf(blueprint, 1)\n const score = scoreOf(blueprint)\n\n return {\n source: sourceJson,\n derived: {\n score: score.score,\n triangles: cost.triangles,\n groups: cost.groups\n },\n violations: result.violations.map(v => ({\n code: v.code,\n message: v.message,\n at: undefined\n }))\n }\n}\n"]}
@@ -0,0 +1,57 @@
1
+ import { Figure } from './figure.js';
2
+ /**
3
+ * 새 Figure.
4
+ *
5
+ * `score`·`triangles`·`groups` 는 **받지 않는다.** 서버가 `source` 에서 계산한다 —
6
+ * 받으면 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.
7
+ */
8
+ export declare class NewFigure {
9
+ type: string;
10
+ name: string;
11
+ description?: string;
12
+ category?: string;
13
+ source: string;
14
+ properties?: string;
15
+ state?: 'draft' | 'released';
16
+ thumbnail?: string;
17
+ }
18
+ /**
19
+ * 수정.
20
+ *
21
+ * `type` 이 없다. **저장되는 식별자라 고칠 수 없다** — 바꾸면 이미 배치된 보드가
22
+ * 타입을 못 찾아 오류 없이 빈 화면이 된다. 고칠 수 있는 이름은 `name` 뿐이다.
23
+ */
24
+ export declare class FigurePatch {
25
+ name?: string;
26
+ description?: string;
27
+ category?: string;
28
+ source?: string;
29
+ properties?: string;
30
+ state?: 'draft' | 'released';
31
+ thumbnail?: string;
32
+ }
33
+ export declare class FigureList {
34
+ items: Figure[];
35
+ total: number;
36
+ }
37
+ /**
38
+ * 검증 결과 한 줄.
39
+ *
40
+ * 정본 형식의 검증은 `@hatiolab/figure-model` 이 두 단계로 나눈다 — `errors` 는
41
+ * 막고, `violations` 는 알린다. 그 경계를 서버가 다시 정하지 않는다.
42
+ */
43
+ export declare class FigureFinding {
44
+ code: string;
45
+ message: string;
46
+ at?: string;
47
+ }
48
+ /**
49
+ * 저장 결과.
50
+ *
51
+ * `violations` 가 있어도 저장은 된다 — 막는 것과 알리는 것을 가른다. 화면이
52
+ * 이것을 그대로 보여 줘야 한다. 조용히 버리면 저작자가 모른다.
53
+ */
54
+ export declare class FigureSaveResult {
55
+ figure: Figure;
56
+ violations: FigureFinding[];
57
+ }