@things-factory/figure-service 10.1.167 → 10.1.169

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 (34) hide show
  1. package/dist-server/service/figure/figure-context.d.ts +108 -0
  2. package/dist-server/service/figure/figure-context.js +368 -0
  3. package/dist-server/service/figure/figure-context.js.map +1 -0
  4. package/dist-server/service/figure/figure-format.js +10 -16
  5. package/dist-server/service/figure/figure-format.js.map +1 -1
  6. package/dist-server/service/figure/figure-mutation.js +2 -2
  7. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  8. package/dist-server/service/figure/figure-proposal.js +4 -2
  9. package/dist-server/service/figure/figure-proposal.js.map +1 -1
  10. package/dist-server/service/figure/figure-propose-steps.d.ts +34 -6
  11. package/dist-server/service/figure/figure-propose-steps.js +128 -17
  12. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  13. package/dist-server/service/figure/figure-tools.js +197 -49
  14. package/dist-server/service/figure/figure-tools.js.map +1 -1
  15. package/dist-server/tsconfig.tsbuildinfo +1 -1
  16. package/package.json +4 -4
  17. package/server/service/figure/figure-context.test.ts +288 -0
  18. package/server/service/figure/figure-context.ts +447 -0
  19. package/server/service/figure/figure-format.test.ts +18 -1
  20. package/server/service/figure/figure-format.ts +10 -16
  21. package/server/service/figure/figure-llm-smoke.test.ts +82 -13
  22. package/server/service/figure/figure-mutation.ts +3 -3
  23. package/server/service/figure/figure-proposal.ts +4 -2
  24. package/server/service/figure/figure-propose-steps.test.ts +146 -6
  25. package/server/service/figure/figure-propose-steps.ts +145 -26
  26. package/server/service/figure/figure-tools-door-db.test.ts +227 -0
  27. package/server/service/figure/figure-tools.test.ts +382 -22
  28. package/server/service/figure/figure-tools.ts +218 -50
  29. package/translations/en.json +11 -9
  30. package/translations/es.json +11 -9
  31. package/translations/ja.json +11 -9
  32. package/translations/ko.json +11 -9
  33. package/translations/ms.json +11 -9
  34. package/translations/zh.json +11 -9
@@ -35,7 +35,22 @@
35
35
  * 있다. 그래서 호출자 도메인 소속을 먼저 확인한다.
36
36
  */
37
37
  import { randomUUID } from 'crypto'
38
- import { aiClientFor, registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
38
+ import { ILike } from 'typeorm'
39
+ import {
40
+ aiClientFor,
41
+ registerToolCategory,
42
+ type AIImageMediaType,
43
+ type ToolAttachment,
44
+ type ToolCallContext,
45
+ type ToolCategory,
46
+ type ToolImageAttachment,
47
+ type ToolSpec
48
+ } from '@things-factory/ai-client-base'
49
+
50
+ import { FIGURE_CONTEXT_BUDGET, figureViewOf, type FigureReadRequest } from './figure-context.js'
51
+
52
+ /** How many figures a list without search names. */
53
+ const LIST_LIMIT = 200
39
54
 
40
55
  /*
41
56
  서버 의존성은 lazy require 다. 등록은 부팅 초반에 평가되고 그때 typeorm DataSource 가 준비되지
@@ -101,17 +116,29 @@ async function figureInDomain(figureId: string, domainId: string): Promise<any>
101
116
  return found
102
117
  }
103
118
 
119
+ /** The form of a creation id: the row id a new figure's first save inserts it under (`figure-mutation.ts`). */
120
+ const CREATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
121
+
104
122
  /**
105
123
  * The figure a tool is about: by id, or by its type name, in the caller's domain. None named: none.
124
+ *
125
+ * A figure not saved yet is a subject too (ADR-0061 decision 3): the modeller names it by its creation id, which its first
126
+ * save inserts the row under (ADR-0072 decision 6), so before that save there is no row. It is the figure open on that
127
+ * screen and nothing else: the id the screen has open, with the draft the screen sent. What is read is that draft, put
128
+ * through the save's door like any draft (`sourceForTool`); nothing the server holds is read for it.
106
129
  */
107
- async function resolveFigureTarget(figureId: string | undefined, domainId: string, _host: any): Promise<any> {
130
+ async function resolveFigureTarget(figureId: string | undefined, domainId: string, host: any): Promise<any> {
108
131
  if (!figureId) return undefined
109
132
  const { getRepository, Figure } = getDeps()
110
133
  const found =
111
134
  (await getRepository(Figure).findOne({ where: { id: figureId, domain: { id: domainId } } })) ??
112
135
  (await getRepository(Figure).findOne({ where: { type: figureId, domain: { id: domainId } } }))
113
- if (!found) throw new Error(`figure not found in this tenant: ${figureId}`)
114
- return found
136
+ if (found) return found
137
+ const draft = host?.draftSource
138
+ if (CREATION_ID.test(figureId) && host?.figureId === figureId && draft && typeof draft === 'object') {
139
+ return { id: figureId, name: typeof draft.name === 'string' ? draft.name : undefined, source: null }
140
+ }
141
+ throw new Error(`figure not found in this tenant: ${figureId}`)
115
142
  }
116
143
 
117
144
  /**
@@ -129,7 +156,7 @@ const isFigure = (source: any) => source?.version === 'figure-asset-1'
129
156
  function figureNotOpen() {
130
157
  return {
131
158
  error: 'FIGURE_NOT_OPEN',
132
- message: '고치는 제안은 모델러에 열려 있는 도형에만 낼 수 있습니다.',
159
+ message: 'A revision can be proposed only for the figure open in the modeller.',
133
160
  nextStep:
134
161
  'Tell the person to open the figure in the modeller and ask again there. A new figure needs no open one: call proposeFigureCandidate with create: true.'
135
162
  }
@@ -141,7 +168,7 @@ function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
141
168
 
142
169
  if (typeof draft !== 'object' || Array.isArray(draft)) throw new Error('the unsaved draft is not a figure asset')
143
170
  const serialized = JSON.stringify(draft)
144
- if (serialized.length > 100_000) throw new Error('the unsaved draft is too large to safely review')
171
+ /* Validity is the save door's (the kernel's 1 MiB); how much the model is shown is `figure-context`'s. */
145
172
  try {
146
173
  getDeps().parseSource(serialized)
147
174
  } catch (e: any) {
@@ -151,6 +178,36 @@ function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
151
178
  return { source: draft, draft: true }
152
179
  }
153
180
 
181
+ /** The finite numbers of an untrusted record, by name; none: undefined. */
182
+ function numbersOf(record: any): Record<string, number> | undefined {
183
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined
184
+ const kept = Object.entries(record).filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1]))
185
+ return kept.length ? Object.fromEntries(kept) : undefined
186
+ }
187
+
188
+ /**
189
+ * What the person has in view on the open draft: the part selected and what the preview is trying (ADR-0097 §5.6
190
+ * item 5). The model hears it only here, through a read (ADR-0061 decision 3). Told only when it was seen on the version
191
+ * being read -- a selection from another version may name a part that is gone -- and a part only when the draft has it.
192
+ */
193
+ function focusOf(host: any, source: any): { selection?: { part: string }; trying?: { clips?: Record<string, number>; controls?: Record<string, number> } } {
194
+ const onThisVersion = (seen: any) => typeof host?.context === 'string' && seen?.context === host.context && seen?.revision === host.revision
195
+ const focus: ReturnType<typeof focusOf> = {}
196
+
197
+ const part = host?.selection?.part
198
+ const nodes = source?.document?.model?.nodes
199
+ if (typeof part === 'string' && onThisVersion(host.selection) && Array.isArray(nodes) && nodes.some((node: any) => node?.id === part)) {
200
+ focus.selection = { part }
201
+ }
202
+
203
+ if (onThisVersion(host?.trying)) {
204
+ const clips = numbersOf(host.trying.clips)
205
+ const controls = numbersOf(host.trying.controls)
206
+ if (clips || controls) focus.trying = { ...(clips ? { clips } : {}), ...(controls ? { controls } : {}) }
207
+ }
208
+ return focus
209
+ }
210
+
154
211
  /**
155
212
  * 무엇을 고치라는 것인지 대화면 문맥에서 해소한다.
156
213
  *
@@ -170,7 +227,7 @@ function figureIdFrom(args: any, host: any): string | undefined {
170
227
  function missingFigureTarget() {
171
228
  return {
172
229
  error: 'FIGURE_TARGET_REQUIRED',
173
- message: '현재 화면에는 선택된 도형이 없습니다.',
230
+ message: 'No figure is open on this screen.',
174
231
  nextStep: 'For an existing figure, call listFigures and match the user’s requested name or type; ask which figure only if ambiguous. For a new figure, call proposeFigureCandidate with create: true and the user’s prompt. Do not invent an id or repeat this call without a target.'
175
232
  }
176
233
  }
@@ -178,10 +235,63 @@ function missingFigureTarget() {
178
235
  const FIGURE_ID_ARG = {
179
236
  figureId: {
180
237
  type: 'string',
181
- description: 'The figure to read. Omit to read the one the person has open, saved or not — the server fills it in, and you cannot see which it is. On the catalog nothing is open, so resolve the figure the person asked for with listFigures first.'
238
+ description: 'The figure to read. Omit to read the one open in the modeller — with its unsaved changes, and also when it is a new figure not saved yet; the server fills it in, and you cannot see which it is. A figure not saved yet is readable only while it is open in the modeller. On the catalog nothing is open, so resolve the figure the person asked for with listFigures first.'
182
239
  }
183
240
  } as const
184
241
 
242
+ const PARTS_ARG = {
243
+ parts: {
244
+ type: 'array',
245
+ items: { type: 'string' },
246
+ description:
247
+ 'Only for a figure too large to read whole (the answer then has `outline` and no `source`): the ids of the parts to read in full, from `outline.parts`. Omit to read the part selected.'
248
+ },
249
+ sections: {
250
+ type: 'array',
251
+ items: { type: 'string' },
252
+ description:
253
+ 'Only for a figure too large to read whole: JSON Pointers of sections to read, such as one named in `outline.omitted` or its `entries` (for example /document/capabilities/bindings/0).'
254
+ }
255
+ } as const
256
+
257
+ /**
258
+ * A figure too large to show whole, as the model reads it (ADR-0061 2026-10-16 decision 4): its outline and the parts
259
+ * asked for. The full source stays here; what is measured is measured on it.
260
+ */
261
+ function partialRead(source: any, request: FigureReadRequest) {
262
+ const view = figureViewOf(source, request)
263
+ if (view.whole) return { source }
264
+ return {
265
+ outline: view.outline,
266
+ partSources: view.parts,
267
+ ...(Object.keys(view.sections ?? {}).length ? { sectionSources: view.sections } : {}),
268
+ ...(view.notRead ? { notRead: view.notRead } : {}),
269
+ note:
270
+ `This figure is too large to read whole (${view.outline!.chars} characters; ${FIGURE_CONTEXT_BUDGET} are read at once), so this is its outline, the parts in partSources and the sections in sectionSources. ` +
271
+ 'Read what a change touches with parts and sections, then pass the same to proposeFigureCandidate: only parts read in full may be changed, and nothing in a section in outline.omitted that was not read.'
272
+ }
273
+ }
274
+
275
+ /** The parts the model named, as strings; anything else is dropped. */
276
+ const stringsOf = (list: unknown): string[] =>
277
+ Array.isArray(list) ? list.filter((one: unknown): one is string => typeof one === 'string' && !!one.trim()).map((one: string) => one.trim()) : []
278
+
279
+ /** An inspection whose findings would crowd the model's context, cut to the budget with how many there were. */
280
+ function findingsWithinBudget(inspection: any) {
281
+ const findings = Array.isArray(inspection?.findings) ? inspection.findings : []
282
+ if (JSON.stringify(inspection).length <= FIGURE_CONTEXT_BUDGET) return inspection
283
+ const head = JSON.stringify({ ...inspection, findings: [], truncated: { shown: findings.length, total: findings.length } }).length
284
+ let room = FIGURE_CONTEXT_BUDGET - head
285
+ const shown: any[] = []
286
+ for (const finding of findings) {
287
+ const cost = JSON.stringify(finding).length + 1
288
+ if (cost > room) break
289
+ shown.push(finding)
290
+ room -= cost
291
+ }
292
+ return { ...inspection, findings: shown, truncated: { shown: shown.length, total: findings.length } }
293
+ }
294
+
185
295
  /**
186
296
  * 도구 넷.
187
297
  *
@@ -197,18 +307,40 @@ function figureToolSpecs(): ToolSpec[] {
197
307
  doors: ['Query.figures'],
198
308
  description:
199
309
  'Lists the figures in this tenant with their type names, states and part counts. ' +
200
- 'Call it before naming a type — type names are stored identifiers and must not be invented.',
201
- schema: { type: 'object', properties: {} },
202
- builder: async (_args: any, ctx: any) => {
310
+ 'Call it before naming a type — type names are stored identifiers and must not be invented. ' +
311
+ `Without search it lists the ${LIST_LIMIT} most recently changed; when there are more it says truncated, and a figure the person names may be among the rest - list again with search.`,
312
+ schema: {
313
+ type: 'object',
314
+ properties: {
315
+ search: {
316
+ type: 'string',
317
+ description: 'Part of a figure name or type name, in any case. Omit to list the most recently changed.'
318
+ }
319
+ }
320
+ },
321
+ builder: async (args: any, ctx: any) => {
203
322
  const domainId = requireDomainId(ctx?.state)
204
323
  const { getRepository, Figure } = getDeps()
205
- const rows = await getRepository(Figure).find({
206
- where: { domain: { id: domainId } },
207
- order: { updatedAt: 'DESC' },
208
- take: 200
209
- })
324
+ const search = typeof args?.search === 'string' ? args.search.trim() : ''
325
+ const domain = { id: domainId }
326
+ /* `_` and `%` in the words are LIKE wildcards: they can only widen what is found, never hide a match. */
327
+ const where = search
328
+ ? [
329
+ { domain, name: ILike(`%${search}%`) },
330
+ { domain, type: ILike(`%${search}%`) }
331
+ ]
332
+ : { domain }
333
+ /* One more than is shown, to know whether there is more without counting the table. */
334
+ const rows = await getRepository(Figure).find({ where, order: { updatedAt: 'DESC' }, take: LIST_LIMIT + 1 })
335
+ const truncated = rows.length > LIST_LIMIT
210
336
  return {
211
- figures: rows.map((row: any) => ({
337
+ ...(truncated
338
+ ? {
339
+ truncated: true,
340
+ note: `Only the ${LIST_LIMIT} most recently changed are listed. A figure not here may still exist: call listFigures again with search.`
341
+ }
342
+ : {}),
343
+ figures: rows.slice(0, LIST_LIMIT).map((row: any) => ({
212
344
  id: row.id,
213
345
  type: row.type,
214
346
  name: row.name,
@@ -228,8 +360,12 @@ function figureToolSpecs(): ToolSpec[] {
228
360
  description:
229
361
  'Returns the source (the figure asset) of one figure, with its measured cost ' +
230
362
  '(triangles, part groups, and how many of it hold 60fps on one screen). Call it before proposing a revision — ' +
231
- 'revising without reading it means proposing against a guess.',
232
- schema: { type: 'object', properties: { ...FIGURE_ID_ARG } },
363
+ 'revising without reading it means proposing against a guess. On the figure open in the modeller it also says what ' +
364
+ 'the person has in view: `selection.part` is the part selected - "this part" or "the selected one" means it - and ' +
365
+ '`trying` the clip and control values the preview is trying, which are not part of the figure. ' +
366
+ 'A figure too large to read whole comes as `outline` (every part by name, shape and what it is fastened to) and ' +
367
+ '`partSources` (the parts read in full) instead of `source`.',
368
+ schema: { type: 'object', properties: { ...FIGURE_ID_ARG, ...PARTS_ARG } },
233
369
  builder: async (args: any, ctx: any) => {
234
370
  const domainId = requireDomainId(ctx?.state)
235
371
  const figureId = figureIdFrom(args, ctx?.host)
@@ -244,15 +380,19 @@ function figureToolSpecs(): ToolSpec[] {
244
380
 
245
381
  /* Measured as the catalogue measures it: triangles, draw groups, and its grade against one screen. */
246
382
  const { figureCostOf, figureScoreOf } = getDeps()
383
+ /* What is in view belongs to the draft on screen; the stored source may be another figure than the one open. */
384
+ const focus = current.draft ? focusOf(ctx?.host, current.source) : {}
385
+ const asked = stringsOf(args?.parts)
247
386
  return {
248
387
  id: row.id,
249
388
  type: row.type,
250
389
  name: row.name,
251
390
  state: row.state,
252
- source: current.source,
391
+ ...partialRead(current.source, { parts: asked.length ? asked : focus.selection ? [focus.selection.part] : [], sections: stringsOf(args?.sections) }),
253
392
  draft: current.draft,
254
393
  cost: figureCostOf(current.source),
255
- score: figureScoreOf(current.source) ?? null
394
+ score: figureScoreOf(current.source) ?? null,
395
+ ...focus
256
396
  }
257
397
  }
258
398
  },
@@ -274,7 +414,8 @@ function figureToolSpecs(): ToolSpec[] {
274
414
 
275
415
  const current = sourceForTool(row, ctx?.host)
276
416
  const { inspectSource } = getDeps()
277
- return { ...inspectSource(current.source ? JSON.stringify(current.source) : null, null, undefined, row.type), draft: current.draft }
417
+ /* Inspected whole, here; only what the model is told is held to its budget. */
418
+ return { ...findingsWithinBudget(inspectSource(current.source ? JSON.stringify(current.source) : null, null, undefined, row.type)), draft: current.draft }
278
419
  }
279
420
  },
280
421
  {
@@ -304,11 +445,23 @@ function figureToolSpecs(): ToolSpec[] {
304
445
  create: {
305
446
  type: 'boolean',
306
447
  description: 'True to create rather than revise, even with a figure open.'
448
+ },
449
+ parts: {
450
+ type: 'array',
451
+ items: { type: 'string' },
452
+ description:
453
+ 'Only for a figure too large to read whole: the parts this change touches, read first with getFigureSource parts. Only these and the part selected may be changed; new parts may be added and fastened to any part.'
454
+ },
455
+ sections: {
456
+ type: 'array',
457
+ items: { type: 'string' },
458
+ description:
459
+ 'Only for a figure too large to read whole: JSON Pointers of the sections in outline.omitted this change touches, read first with getFigureSource sections. Nothing else in those sections may be changed.'
307
460
  }
308
461
  },
309
462
  required: ['prompt']
310
463
  },
311
- builder: async (args: any, ctx: any) => {
464
+ builder: async (args: any, ctx: ToolCallContext) => {
312
465
  const domainId = requireDomainId(ctx?.state)
313
466
  const prompt = typeof args?.prompt === 'string' ? args.prompt.trim() : ''
314
467
  if (!prompt) throw new Error('prompt is required: there is nothing to ask for')
@@ -325,6 +478,7 @@ function figureToolSpecs(): ToolSpec[] {
325
478
  const create = args?.create === true || args?.figureId === null
326
479
  let base: unknown
327
480
  let figureId: string | undefined
481
+ let focus: ReturnType<typeof focusOf> | undefined
328
482
  if (!create) {
329
483
  const said = typeof args?.figureId === 'string' ? args.figureId.trim() : ''
330
484
  const open = typeof host?.figureId === 'string' ? host.figureId.trim() : ''
@@ -335,19 +489,22 @@ function figureToolSpecs(): ToolSpec[] {
335
489
  if (!open) return missingFigureTarget()
336
490
  const row = await resolveFigureTarget(open, domainId, host)
337
491
  if (!row) return missingFigureTarget()
338
- base = sourceForTool(row, host).source ?? undefined
492
+ const current = sourceForTool(row, host)
493
+ base = current.source ?? undefined
339
494
  if (!isFigure(base)) return figureNotOpen()
340
495
  if (typeof host?.context !== 'string' || !Number.isInteger(host?.revision)) return figureNotOpen()
341
496
  figureId = open
497
+ /* "Make this one bigger": the part selected is what the request points at. */
498
+ if (current.draft) focus = focusOf(host, base)
342
499
  }
343
500
 
344
- let hostImage: { data: Buffer; mediaType: any } | undefined
345
- const rawImage = host?.image || (Array.isArray(host?.attachments) ? host.attachments.find((a: any) => a?.mediaType?.startsWith('image/')) : undefined)
346
- if (rawImage?.data) {
347
- const rawData = typeof rawImage.data === 'string' ? rawImage.data.replace(/^data:image\/[a-zA-Z]+;base64,/, '') : rawImage.data
348
- const buf = typeof rawData === 'string' ? Buffer.from(rawData, 'base64') : Buffer.from(rawData)
349
- hostImage = { data: buf, mediaType: rawImage.mediaType || 'image/png' }
350
- }
501
+ /*
502
+ The pictures the person sent with this turn, as the server checked them (ADR-0061, 2026-10-16 decision 2) --
503
+ every one, in the order attached. Never from the host context: the client fills it.
504
+ */
505
+ const images = (ctx?.attachments ?? [])
506
+ .filter((file: ToolAttachment): file is ToolImageAttachment => file.kind === 'image')
507
+ .map(file => ({ data: file.data, mediaType: file.mediaType as AIImageMediaType }))
351
508
 
352
509
  const { proposeFigureSteps } = getDeps()
353
510
  let result: any
@@ -363,7 +520,11 @@ function figureToolSpecs(): ToolSpec[] {
363
520
  prompt,
364
521
  base,
365
522
  name: typeof args?.name === 'string' && args.name.trim() ? args.name.trim() : undefined,
366
- image: hostImage
523
+ ...(images.length ? { images } : {}),
524
+ ...(focus && Object.keys(focus).length ? { focus } : {}),
525
+ ...(!create && (stringsOf(args?.parts).length || stringsOf(args?.sections).length)
526
+ ? { read: { parts: stringsOf(args?.parts), sections: stringsOf(args?.sections) } }
527
+ : {})
367
528
  })
368
529
  } catch (error) {
369
530
  /*
@@ -382,6 +543,8 @@ function figureToolSpecs(): ToolSpec[] {
382
543
  목록을 한 단계로 적용한다. `args` 는 같은 제안을 두 번 세지 않기 위한 열쇠의 일부다.
383
544
  */
384
545
  const name = result.source.name || 'Figure'
546
+ /* The card is read by a person, so the server writes it in the request's language (ADR-0061, 2026-10-16 decision 1). */
547
+ const t = ctx.t
385
548
  return {
386
549
  proposed: true,
387
550
  /*
@@ -391,10 +554,16 @@ function figureToolSpecs(): ToolSpec[] {
391
554
  */
392
555
  key: randomUUID(),
393
556
  icon: 'view_in_ar',
394
- label: create ? `${name} 만들기` : `${name} 고치기`,
395
- reason:
396
- `명령 ${result.steps.length}개 · 등급 ${result.score?.grade ?? '-'} · 삼각형 ${result.cost.triangles} · ` +
397
- `${result.attempts}번에 만들었다`,
557
+ label: create
558
+ ? t('figure.proposal.label-create', { name, defaultValue: 'Make {name}' })
559
+ : t('figure.proposal.label-revise', { name, defaultValue: 'Revise {name}' }),
560
+ reason: t('figure.proposal.reason', {
561
+ steps: result.steps.length,
562
+ grade: result.score?.grade ?? '-',
563
+ triangles: result.cost.triangles,
564
+ attempts: result.attempts,
565
+ defaultValue: '{steps} commands · grade {grade} · {triangles} triangles · made in {attempts} tries'
566
+ }),
398
567
  args: { prompt, figureId, create },
399
568
  create,
400
569
  figureId,
@@ -420,21 +589,20 @@ function figureToolSpecs(): ToolSpec[] {
420
589
  export function registerFigureToolCategories(): void {
421
590
  const authoring: ToolCategory = {
422
591
  name: 'figure-authoring',
423
- description: '저작 중인 Figure 의 정본·비용·발행 판정 조회와 후보 제안(저장 아님).',
592
+ description: 'Reads the source, cost and release verdict of the figure being authored, and proposes changes (nothing is saved).',
424
593
  guidance: [
425
- '- **도형을 만들거나 고쳐 달라는 요청에는 반드시 `proposeFigureCandidate` 를 호출한다.**',
426
- ' 후보는 도구 호출 결과에서만 생긴다.',
427
- ' 호출 없이 "후보를 만들었습니다" · "적용 버튼을 누르세요" 라고 쓰는 것은 **거짓이다** — 화면에 후보 칸이 생기지 않으므로 사용자는 존재하지 않는 버튼을 찾는다.',
428
- ' 후보는 모델러 명령 목록이다. 도크 카드의 실행 단추를 눌러야 모델러에 한 단계로 적용되고, 되돌리기 한 번에 통째로 돌아간다. 적용 전에 이미 바뀌었다고 말하지 않는다.',
429
- ' 고치는 제안은 모델러에 열린 도형에만 낼 수 있다. 결과의 `steps` 가 무엇을 하는지 그대로 전한다 — 명령에 없는 변경을 지어내지 않는다.',
430
- '- 정본을 직접 저장하지 않는다. 후보만 내고, 받을지 버릴지는 저작자가 화면에서 정한다.',
431
- '- 좌표와 크기는 X/Y/Z 축 이름으로 정확하게 표시한다. 확인되지 않은 가로·세로·높이 의미를 임의로 붙이지 않는다.',
432
- '- **고치라는 요청이면 먼저 `getFigureSource` 를 부른다.** 읽지 않고 고치면 짐작을 상대로 후보를 내게 되고, 부품 이름이 갈려 이미 배치된 보드의 바인딩이 끊긴다.',
433
- '- 목록 화면에는 열린 도형이 없다. 기존 도형을 조회할 때는 `listFigures`에서 사용자가 말한 이름·타입의 id를 찾는다. 대상이 모호할 때만 어느 도형인지 묻는다. 기존 도형을 고치려면 모델러에서 그 도형을 열고 거기서 요청하도록 안내한다. 새 제작 요청에는 기존 정본을 조회하지 않고 `proposeFigureCandidate`를 create: true로 호출한다.',
434
- '- 타입 이름을 지어내지 않는다 — `listFigures` 로 확인한다. **타입 이름은 저장되는 식별자**이고 보드가 그 이름으로 도형을 찾는다. 새 도형의 타입은 첫 저장 때 사람이 정한다.',
435
- '- 발행이 안 되는 이유를 물으면 `inspectFigure` 를 부른다. 판정 없이 "발행할 수 있습니다"라고 말하지 않는다.',
436
- ' 판정의 각 항목은 **왜 문제인지와 무엇을 바꾸면 되는지**를 함께 담고 있다. 그대로 전한다 — 요약하면 저작자가 어느 부품의 어느 축을 고칠지 다시 찾아야 한다.',
437
- '- 무게·삼각형 수를 물으면 `getFigureSource` 의 `cost` 를 쓴다. 눈대중으로 세지 않는다.'
594
+ '- **For a request to make or change a figure, always call `proposeFigureCandidate`.** A proposal exists only as the result of that call.',
595
+ ' Writing "I made a proposal" or "press apply" without the call is **false**: no card appears, and the person looks for a button that does not exist.',
596
+ ' A proposal is a list of modeller commands. The modeller applies it as one step only when the person runs it from the dock card, and one undo takes it all back. Do not say the figure has changed before that.',
597
+ ' A revision can be proposed only for the figure open in the modeller. Say what the result\'s `steps` do as they are; do not invent changes the commands do not make.',
598
+ '- Never save the source yourself. Propose; the author decides on the screen whether to take it.',
599
+ '- Name coordinates and sizes by their X/Y/Z axes. Do not attach width, depth or height meanings that have not been confirmed.',
600
+ '- **For a request to change a figure, call `getFigureSource` first.** Revising without reading proposes against a guess, and a renamed part breaks the bindings of the boards it is already placed on.',
601
+ '- On the catalog nothing is open. To read an existing figure, find the id of the name or type the person said with `listFigures`; ask which figure only when it is ambiguous. To revise an existing figure, tell the person to open it in the modeller and ask there. For a new figure, do not read existing sources: call `proposeFigureCandidate` with create: true.',
602
+ '- Do not invent type names; check them with `listFigures`. **A type name is a stored identifier**, and boards find the figure by it. A new figure\'s type is set by a person at its first save.',
603
+ '- When asked why a figure will not release, call `inspectFigure`. Do not say it can be released without its verdict.',
604
+ ' Each finding carries **why it is a problem and what to change**. Pass it on as it is; a summary makes the author look again for which axis of which part to fix.',
605
+ '- For weight or triangle counts, use `cost` from `getFigureSource`. Do not estimate.'
438
606
  ].join('\n'),
439
607
  specs: figureToolSpecs()
440
608
  }
@@ -1,4 +1,5 @@
1
1
  {
2
+ "error.figure-proposal-failed": "The figure could not be made.",
2
3
  "error.figure-release-does-not-compile": "the released source of {type} (version {version}) does not compile. open it in the modeller, fix it and release it again before it draws on a board.",
3
4
  "figure.gate.occupancy-empty-axis.message": "The declared space is empty on {axis}.",
4
5
  "figure.gate.occupancy-undeclared.how": "Use \"fit to parts\" to declare the space from the parts as they are now.",
@@ -75,14 +76,12 @@
75
76
  "figure.gate.contract.public-input-range-narrowed.why": "An instance may have set a value outside the new range. Which values are placed is not recorded.",
76
77
  "figure.gate.contract.public-input-added.message": "'{input}' can now be set on a board.",
77
78
  "figure.gate.contract.public-input-added.why": "Placed instances that give no value stand at its default.",
78
- "figure.gate.contract.carrier-gone.message": "The figure is no longer carrier type {type}.",
79
- "figure.gate.contract.carrier-gone.why": "Live data names carriers by this code. After this release carriers of this type find no figure.",
80
- "figure.gate.contract.carrier-type-changed.message": "The carrier type changed from {before} to {after}.",
81
- "figure.gate.contract.carrier-type-changed.why": "Live data names carriers by this code. After this release carriers of the old type find no figure.",
82
- "figure.gate.contract.carrier-dimensions-changed.message": "The carrier size changed from {before} to {after} mm.",
83
- "figure.gate.contract.carrier-dimensions-changed.why": "Holders fit and stack carriers by this size. Where they stand and what fits is judged again once the map takes this release.",
84
- "figure.gate.contract.carrier-added.message": "The figure is now carrier type {type}.",
85
- "figure.gate.contract.carrier-added.why": "Live data with this code finds this figure once the type map takes this release.",
79
+ "figure.gate.contract.extension-gone.message": "The extension '{key}' is gone.",
80
+ "figure.gate.contract.extension-gone.why": "Another product reads this value by its key. After this release it finds none.",
81
+ "figure.gate.contract.extension-changed.message": "The value of the extension '{key}' changed.",
82
+ "figure.gate.contract.extension-changed.why": "The product that owns this key reads the new value and judges the change.",
83
+ "figure.gate.contract.extension-added.message": "The extension '{key}' is new.",
84
+ "figure.gate.contract.extension-added.why": "The product that owns this key reads this value.",
86
85
  "figure.gate.contract.joints-none": "nothing",
87
86
  "figure.gate.contract.state-input-joints-changed.message": "The control '{id}' moved {before}. It now moves {after}.",
88
87
  "figure.gate.contract.state-input-joints-changed.why": "A controller writes this value to drive those joints. After this release the same value moves other joints, or by other amounts.",
@@ -150,5 +149,8 @@
150
149
  "figure.gate.contract.slot-anchor-gone.message": "The anchor '{anchor}' that slots stand on is gone.",
151
150
  "figure.gate.contract.slot-anchor-gone.why": "A placement that lays its own slots on this anchor holds nothing after this release.",
152
151
  "figure.gate.contract.slot-anchor-moved.message": "The anchor '{anchor}' moved from the {before} face to the {after} face.",
153
- "figure.gate.contract.slot-anchor-moved.why": "Every slot on it, and what sits there, stands on the new face after this release."
152
+ "figure.gate.contract.slot-anchor-moved.why": "Every slot on it, and what sits there, stands on the new face after this release.",
153
+ "figure.proposal.label-create": "Make {name}",
154
+ "figure.proposal.label-revise": "Revise {name}",
155
+ "figure.proposal.reason": "{steps} commands · grade {grade} · {triangles} triangles · made in {attempts} tries"
154
156
  }
@@ -1,4 +1,5 @@
1
1
  {
2
+ "error.figure-proposal-failed": "No se pudo crear la figura.",
2
3
  "error.figure-release-does-not-compile": "el código fuente publicado de {type} (versión {version}) no compila. Ábralo en el modelador, corríjalo y vuelva a publicarlo para que se dibuje en un tablero.",
3
4
  "figure.gate.occupancy-empty-axis.message": "El espacio declarado está vacío en {axis}.",
4
5
  "figure.gate.occupancy-undeclared.how": "Use «ajustar a las piezas» para declarar el espacio a partir de las piezas tal como están ahora.",
@@ -75,14 +76,12 @@
75
76
  "figure.gate.contract.public-input-range-narrowed.why": "Puede que una instancia haya definido un valor fuera del nuevo rango. No se registra qué valores se colocaron.",
76
77
  "figure.gate.contract.public-input-added.message": "'{input}' ahora se puede definir en un tablero.",
77
78
  "figure.gate.contract.public-input-added.why": "Las instancias colocadas que no dan ningún valor quedan en su valor predeterminado.",
78
- "figure.gate.contract.carrier-gone.message": "La figura ya no es del tipo de carrier {type}.",
79
- "figure.gate.contract.carrier-gone.why": "Los datos en tiempo real identifican los carriers por este código. Tras esta publicación, los carriers de este tipo no encuentran ninguna figura.",
80
- "figure.gate.contract.carrier-type-changed.message": "El tipo de carrier cambió de {before} a {after}.",
81
- "figure.gate.contract.carrier-type-changed.why": "Los datos en tiempo real identifican los carriers por este código. Tras esta publicación, los carriers del tipo anterior no encuentran ninguna figura.",
82
- "figure.gate.contract.carrier-dimensions-changed.message": "El tamaño del carrier cambió de {before} a {after} mm.",
83
- "figure.gate.contract.carrier-dimensions-changed.why": "Los soportes alojan y apilan los carriers según este tamaño. Su posición y lo que cabe se vuelven a evaluar cuando el mapa adopte esta publicación.",
84
- "figure.gate.contract.carrier-added.message": "La figura es ahora del tipo de carrier {type}.",
85
- "figure.gate.contract.carrier-added.why": "Los datos en tiempo real con este código encuentran esta figura cuando el mapa de tipos adopte esta publicación.",
79
+ "figure.gate.contract.extension-gone.message": "La extensión '{key}' ya no está.",
80
+ "figure.gate.contract.extension-gone.why": "Otro producto lee este valor por su clave. Tras esta publicación no lo encuentra.",
81
+ "figure.gate.contract.extension-changed.message": "El valor de la extensión '{key}' cambió.",
82
+ "figure.gate.contract.extension-changed.why": "El producto dueño de esta clave lee el nuevo valor y juzga el cambio.",
83
+ "figure.gate.contract.extension-added.message": "La extensión '{key}' es nueva.",
84
+ "figure.gate.contract.extension-added.why": "El producto dueño de esta clave lee este valor.",
86
85
  "figure.gate.contract.joints-none": "ninguna",
87
86
  "figure.gate.contract.state-input-joints-changed.message": "El control '{id}' movía {before}. Ahora mueve {after}.",
88
87
  "figure.gate.contract.state-input-joints-changed.why": "Un controlador escribe este valor para accionar esas articulaciones. Tras esta publicación, el mismo valor mueve otras articulaciones, o en otra medida.",
@@ -150,5 +149,8 @@
150
149
  "figure.gate.contract.slot-anchor-gone.message": "El anclaje '{anchor}' sobre el que están las ranuras ya no existe.",
151
150
  "figure.gate.contract.slot-anchor-gone.why": "Una colocación que pone sus propias ranuras en este anclaje no contiene nada tras esta publicación.",
152
151
  "figure.gate.contract.slot-anchor-moved.message": "El anclaje '{anchor}' pasó de la cara {before} a la cara {after}.",
153
- "figure.gate.contract.slot-anchor-moved.why": "Cada ranura sobre él, y lo que contiene, queda en la nueva cara tras esta publicación."
152
+ "figure.gate.contract.slot-anchor-moved.why": "Cada ranura sobre él, y lo que contiene, queda en la nueva cara tras esta publicación.",
153
+ "figure.proposal.label-create": "Crear {name}",
154
+ "figure.proposal.label-revise": "Corregir {name}",
155
+ "figure.proposal.reason": "{steps} comandos · grado {grade} · {triangles} triángulos · hecho en {attempts} intentos"
154
156
  }
@@ -1,4 +1,5 @@
1
1
  {
2
+ "error.figure-proposal-failed": "フィギュアを作成できませんでした。",
2
3
  "error.figure-release-does-not-compile": "{type} のリリース版(バージョン {version})のソースがコンパイルできません。モデラーで開いて修正し、もう一度リリースするとボードに表示されます。",
3
4
  "figure.gate.occupancy-empty-axis.message": "空間宣言の {axis} が空です。",
4
5
  "figure.gate.occupancy-undeclared.how": "「部品に合わせる」を使うと、現在の部品から空間を宣言します。",
@@ -75,14 +76,12 @@
75
76
  "figure.gate.contract.public-input-range-narrowed.why": "新しい範囲外の値を設定したインスタンスがある可能性があります。どの値が設定されたかは記録されていません。",
76
77
  "figure.gate.contract.public-input-added.message": "'{input}' をボードで設定できるようになりました。",
77
78
  "figure.gate.contract.public-input-added.why": "値を渡していない配置済みのインスタンスは既定値になります。",
78
- "figure.gate.contract.carrier-gone.message": "この Figure は Carrier タイプ {type} ではなくなりました。",
79
- "figure.gate.contract.carrier-gone.why": "リアルタイムデータはこのコードで Carrier を呼びます。このリリース以降、このタイプの Carrier は Figure を見つけられません。",
80
- "figure.gate.contract.carrier-type-changed.message": "Carrier タイプが {before} から {after} に変わりました。",
81
- "figure.gate.contract.carrier-type-changed.why": "リアルタイムデータはこのコードで Carrier を呼びます。このリリース以降、以前のタイプの Carrier は Figure を見つけられません。",
82
- "figure.gate.contract.carrier-dimensions-changed.message": "Carrier の寸法が {before} から {after} mm に変わりました。",
83
- "figure.gate.contract.carrier-dimensions-changed.why": "ホルダーはこの寸法で Carrier を置き、積み重ねます。タイプマップがこのリリースを採用すると、置き場所と収まるかどうかが改めて判定されます。",
84
- "figure.gate.contract.carrier-added.message": "この Figure は Carrier タイプ {type} になりました。",
85
- "figure.gate.contract.carrier-added.why": "タイプマップがこのリリースを採用すると、このコードのリアルタイムデータがこの Figure を見つけます。",
79
+ "figure.gate.contract.extension-gone.message": "拡張プロパティ '{key}' がなくなりました。",
80
+ "figure.gate.contract.extension-gone.why": "他の製品がこのキーで値を読みます。このリリース以降、値が見つかりません。",
81
+ "figure.gate.contract.extension-changed.message": "拡張プロパティ '{key}' の値が変わりました。",
82
+ "figure.gate.contract.extension-changed.why": "このキーを定めた製品が新しい値を読み、変更を判定します。",
83
+ "figure.gate.contract.extension-added.message": "拡張プロパティ '{key}' が追加されました。",
84
+ "figure.gate.contract.extension-added.why": "このキーを定めた製品がこの値を読みます。",
86
85
  "figure.gate.contract.joints-none": "なし",
87
86
  "figure.gate.contract.state-input-joints-changed.message": "'{id}' 操作値が動かす関節が {before} から {after} に変わりました。",
88
87
  "figure.gate.contract.state-input-joints-changed.why": "コントローラーはこの値を書いてそれらの関節を動かします。このリリース以降、同じ値で別の関節が動くか、別の量だけ動きます。",
@@ -150,5 +149,8 @@
150
149
  "figure.gate.contract.slot-anchor-gone.message": "スロットが立つ '{anchor}' 基準面がなくなりました。",
151
150
  "figure.gate.contract.slot-anchor-gone.why": "この基準面に独自のスロットを置いた配置は、このリリース以降何も保持できません。",
152
151
  "figure.gate.contract.slot-anchor-moved.message": "'{anchor}' 基準面が {before} 面から {after} 面に移りました。",
153
- "figure.gate.contract.slot-anchor-moved.why": "その上のすべてのスロットと置かれたものは、このリリース以降新しい面に立ちます。"
152
+ "figure.gate.contract.slot-anchor-moved.why": "その上のすべてのスロットと置かれたものは、このリリース以降新しい面に立ちます。",
153
+ "figure.proposal.label-create": "{name} を作る",
154
+ "figure.proposal.label-revise": "{name} を直す",
155
+ "figure.proposal.reason": "コマンド {steps} 件 · 等級 {grade} · 三角形 {triangles} · {attempts} 回で作成"
154
156
  }
@@ -1,4 +1,5 @@
1
1
  {
2
+ "error.figure-proposal-failed": "도형을 만들지 못했습니다.",
2
3
  "error.figure-release-does-not-compile": "{type} 의 발행판({version})이 지금 컴파일되지 않습니다. 저작 화면에서 열어 수정한 후 다시 발행해야 보드에 나옵니다.",
3
4
  "figure.gate.occupancy-empty-axis.message": "공간 선언의 {axis} 가 비어 있습니다.",
4
5
  "figure.gate.occupancy-undeclared.how": "「부품에 맞추기」를 누르면 지금 부품으로 공간을 선언합니다.",
@@ -75,14 +76,12 @@
75
76
  "figure.gate.contract.public-input-range-narrowed.why": "새 범위 밖의 값을 준 인스턴스가 있을 수 있습니다. 어떤 값이 놓였는지는 기록하지 않습니다.",
76
77
  "figure.gate.contract.public-input-added.message": "'{input}' 값을 이제 보드에서 정할 수 있습니다.",
77
78
  "figure.gate.contract.public-input-added.why": "값이 지정되지 않은 인스턴스에는 기본값이 적용됩니다.",
78
- "figure.gate.contract.carrier-gone.message": "이 Figure는 이제 Carrier 타입 {type}이 아닙니다.",
79
- "figure.gate.contract.carrier-gone.why": "실시간 데이터는 이 코드로 Carrier를 부릅니다. 이 발행 뒤 이 타입의 Carrier는 Figure를 찾지 못합니다.",
80
- "figure.gate.contract.carrier-type-changed.message": "Carrier 타입이 {before}에서 {after}로 바뀌었습니다.",
81
- "figure.gate.contract.carrier-type-changed.why": "실시간 데이터는 이 코드로 Carrier를 부릅니다. 이 발행 뒤 예전 타입의 Carrier는 Figure를 찾지 못합니다.",
82
- "figure.gate.contract.carrier-dimensions-changed.message": "Carrier 치수가 {before}에서 {after} mm로 바뀌었습니다.",
83
- "figure.gate.contract.carrier-dimensions-changed.why": "홀더는 이 치수로 Carrier를 놓고 쌓습니다. 타입 맵이 이 발행본을 쓰면 자리와 적재 판정을 다시 합니다.",
84
- "figure.gate.contract.carrier-added.message": "이 Figure는 이제 Carrier 타입 {type}입니다.",
85
- "figure.gate.contract.carrier-added.why": "타입 맵이 이 발행본을 쓰면 이 코드의 실시간 데이터가 이 Figure를 찾습니다.",
79
+ "figure.gate.contract.extension-gone.message": "'{key}' 확장 속성이 없어졌습니다.",
80
+ "figure.gate.contract.extension-gone.why": "다른 제품이 이 키로 값을 읽습니다. 이 발행 뒤에는 값을 찾지 못합니다.",
81
+ "figure.gate.contract.extension-changed.message": "'{key}' 확장 속성의 값이 바뀌었습니다.",
82
+ "figure.gate.contract.extension-changed.why": "이 키를 정한 제품이 새 값을 읽고 바뀐 점을 판정합니다.",
83
+ "figure.gate.contract.extension-added.message": "'{key}' 확장 속성이 새로 생겼습니다.",
84
+ "figure.gate.contract.extension-added.why": "이 키를 정한 제품이 이 값을 읽습니다.",
86
85
  "figure.gate.contract.joints-none": "없음",
87
86
  "figure.gate.contract.state-input-joints-changed.message": "'{id}' 조작값이 움직이던 관절이 {before} 에서 {after} 로 바뀌었습니다.",
88
87
  "figure.gate.contract.state-input-joints-changed.why": "컨트롤러는 이 값을 써서 그 관절들을 움직입니다. 이 발행 뒤에는 같은 값이 다른 관절을 움직이거나 다른 양만큼 움직입니다.",
@@ -150,5 +149,8 @@
150
149
  "figure.gate.contract.slot-anchor-gone.message": "적재 위치의 '{anchor}' 기준면이 없어졌습니다.",
151
150
  "figure.gate.contract.slot-anchor-gone.why": "이 기준면을 사용하는 적재 위치는 발행 후 물품을 수용할 수 없습니다.",
152
151
  "figure.gate.contract.slot-anchor-moved.message": "'{anchor}' 기준면이 {before} 면에서 {after} 면으로 옮겨졌습니다.",
153
- "figure.gate.contract.slot-anchor-moved.why": "발행 후 해당 기준면의 모든 슬롯과 배치된 항목이 새 기준면으로 이동합니다."
152
+ "figure.gate.contract.slot-anchor-moved.why": "발행 후 해당 기준면의 모든 슬롯과 배치된 항목이 새 기준면으로 이동합니다.",
153
+ "figure.proposal.label-create": "{name} 만들기",
154
+ "figure.proposal.label-revise": "{name} 고치기",
155
+ "figure.proposal.reason": "명령 {steps}개 · 등급 {grade} · 삼각형 {triangles} · {attempts}번에 만들었다"
154
156
  }