@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.
- package/dist-server/service/figure/figure-context.d.ts +108 -0
- package/dist-server/service/figure/figure-context.js +368 -0
- package/dist-server/service/figure/figure-context.js.map +1 -0
- package/dist-server/service/figure/figure-format.js +10 -16
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +2 -2
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-proposal.js +4 -2
- package/dist-server/service/figure/figure-proposal.js.map +1 -1
- package/dist-server/service/figure/figure-propose-steps.d.ts +34 -6
- package/dist-server/service/figure/figure-propose-steps.js +128 -17
- package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +197 -49
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/server/service/figure/figure-context.test.ts +288 -0
- package/server/service/figure/figure-context.ts +447 -0
- package/server/service/figure/figure-format.test.ts +18 -1
- package/server/service/figure/figure-format.ts +10 -16
- package/server/service/figure/figure-llm-smoke.test.ts +82 -13
- package/server/service/figure/figure-mutation.ts +3 -3
- package/server/service/figure/figure-proposal.ts +4 -2
- package/server/service/figure/figure-propose-steps.test.ts +146 -6
- package/server/service/figure/figure-propose-steps.ts +145 -26
- package/server/service/figure/figure-tools-door-db.test.ts +227 -0
- package/server/service/figure/figure-tools.test.ts +382 -22
- package/server/service/figure/figure-tools.ts +218 -50
- package/translations/en.json +11 -9
- package/translations/es.json +11 -9
- package/translations/ja.json +11 -9
- package/translations/ko.json +11 -9
- package/translations/ms.json +11 -9
- package/translations/zh.json +11 -9
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What of a figure the model is shown (ADR-0061 2026-10-16 decision 4).
|
|
3
|
+
*
|
|
4
|
+
* The kernel keeps a figure up to 1 MiB (`FIGURE_JSON_MAX_BYTES`); that is whether the figure is valid. How much of it the
|
|
5
|
+
* model reads at once is another question with its own number. A figure that fits is shown whole. One that does not is
|
|
6
|
+
* shown as an outline -- its sections, and every part by name, shape and what it is fastened to -- and in full only the
|
|
7
|
+
* parts and sections asked for, while they fit. A section too large for the outline is left out, named by its JSON
|
|
8
|
+
* Pointer with the entries inside it, and read by pointer. The full source stays on the server: a proposal is applied
|
|
9
|
+
* and judged on it, on the version the request was made on.
|
|
10
|
+
*
|
|
11
|
+
* A proposal on a figure shown in part changes only what it was shown: no part not read in full, and nothing inside a
|
|
12
|
+
* section left out and not read. Read is what the server put before the writer, never what a model says it read.
|
|
13
|
+
*/
|
|
14
|
+
import type { FigureAsset, FigureProposalStep } from '@hatiolab/figure-model'
|
|
15
|
+
|
|
16
|
+
/** The most of a figure, serialized, the model is shown in one read. Not a validity limit: that is the kernel's. */
|
|
17
|
+
export const FIGURE_CONTEXT_BUDGET = 100_000
|
|
18
|
+
|
|
19
|
+
/** Of the budget, what the outline's sections may take; the rest is for the parts and sections read beside it. */
|
|
20
|
+
const OUTLINE_SHARE = FIGURE_CONTEXT_BUDGET / 2
|
|
21
|
+
|
|
22
|
+
/** How many entries of a section left out, or pointers in a reason, are named: the largest first. */
|
|
23
|
+
const ENTRIES_NAMED = 20
|
|
24
|
+
|
|
25
|
+
type Dynamic = any
|
|
26
|
+
|
|
27
|
+
export interface FigureOutlinePart {
|
|
28
|
+
id: string
|
|
29
|
+
/** The operation that makes its shape (`box@1`, `extrude@1`, ...). */
|
|
30
|
+
shape?: string
|
|
31
|
+
/** What it is fastened to: `<part>.<face>`, `mounting-plane`, or nothing when unfastened. */
|
|
32
|
+
on?: string
|
|
33
|
+
joint?: { id: string; type: string }
|
|
34
|
+
material?: string
|
|
35
|
+
/** How long its part read is: what reading it costs. */
|
|
36
|
+
chars: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FigureOmittedSection {
|
|
40
|
+
/** The JSON Pointer of the section left out. */
|
|
41
|
+
section: string
|
|
42
|
+
chars: number
|
|
43
|
+
/** Its largest entries, by pointer: what to read when the whole does not fit. */
|
|
44
|
+
entries?: { section: string; chars: number }[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FigureOutline {
|
|
48
|
+
/** The whole source's length, and the budget it does not fit. */
|
|
49
|
+
chars: number
|
|
50
|
+
budget: number
|
|
51
|
+
parts: FigureOutlinePart[]
|
|
52
|
+
/** Parts the outline had no room to list, past the ones listed. */
|
|
53
|
+
partsNotListed?: number
|
|
54
|
+
/** Sections left out because they would crowd the outline. */
|
|
55
|
+
omitted?: FigureOmittedSection[]
|
|
56
|
+
[section: string]: unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FigurePartRead {
|
|
60
|
+
appearance: Dynamic
|
|
61
|
+
/** The fastenings that name it: its own (body1) and its children's (body0). */
|
|
62
|
+
joints: Dynamic[]
|
|
63
|
+
/** The graph nodes and constants under its name: `<part>` and `<part>.*` (a part id has no dot). */
|
|
64
|
+
nodes: Dynamic[]
|
|
65
|
+
constants: Dynamic[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface FigureReadRequest {
|
|
69
|
+
parts?: readonly string[]
|
|
70
|
+
/** JSON Pointers (RFC 6901) into the source, such as `/document/capabilities/bindings/0`. */
|
|
71
|
+
sections?: readonly string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface FigureView {
|
|
75
|
+
whole: boolean
|
|
76
|
+
/** Whole: the source as it is. */
|
|
77
|
+
source?: FigureAsset
|
|
78
|
+
outline?: FigureOutline
|
|
79
|
+
/** The parts shown in full, by id, in the order asked. */
|
|
80
|
+
parts?: Record<string, FigurePartRead>
|
|
81
|
+
shown?: string[]
|
|
82
|
+
/** The sections read, by pointer. */
|
|
83
|
+
sections?: Record<string, unknown>
|
|
84
|
+
/** What was asked for and not shown, with why and what to read instead. */
|
|
85
|
+
notRead?: ({ part: string; reason: string } | { section: string; reason: string })[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const length = (value: unknown) => JSON.stringify(value)?.length ?? 0
|
|
89
|
+
const under = (part: string) => (id: unknown) => id === part || (typeof id === 'string' && id.startsWith(`${part}.`))
|
|
90
|
+
|
|
91
|
+
/* ── JSON Pointer (RFC 6901) ─────────────────────────────────────────────────────────── */
|
|
92
|
+
|
|
93
|
+
const escapeToken = (token: string) => token.replace(/~/g, '~0').replace(/\//g, '~1')
|
|
94
|
+
const pointerOf = (tokens: readonly (string | number)[]) => tokens.map(t => `/${escapeToken(String(t))}`).join('')
|
|
95
|
+
function tokensOf(pointer: string): string[] | undefined {
|
|
96
|
+
if (typeof pointer !== 'string' || !pointer.startsWith('/')) return undefined
|
|
97
|
+
return pointer.slice(1).split('/').map(t => t.replace(/~1/g, '/').replace(/~0/g, '~'))
|
|
98
|
+
}
|
|
99
|
+
function valueAt(root: unknown, tokens: readonly string[]): { found: boolean; value?: unknown } {
|
|
100
|
+
let at: Dynamic = root
|
|
101
|
+
for (const token of tokens) {
|
|
102
|
+
if (at === null || typeof at !== 'object' || !Object.prototype.hasOwnProperty.call(at, token)) return { found: false }
|
|
103
|
+
at = at[token]
|
|
104
|
+
}
|
|
105
|
+
return { found: true, value: at }
|
|
106
|
+
}
|
|
107
|
+
/** `inner` is `outer` or inside it. */
|
|
108
|
+
const within = (inner: string, outer: string) => inner === outer || inner.startsWith(`${outer}/`)
|
|
109
|
+
|
|
110
|
+
/** A value's entries by pointer, largest first, the first ENTRIES_NAMED of them. */
|
|
111
|
+
function entriesOf(pointer: string, value: unknown): { section: string; chars: number }[] {
|
|
112
|
+
if (value === null || typeof value !== 'object') return []
|
|
113
|
+
const tokens = tokensOf(pointer)!
|
|
114
|
+
return Object.entries(value as object)
|
|
115
|
+
.map(([key, entry]) => ({ section: pointerOf([...tokens, key]), chars: length(entry) }))
|
|
116
|
+
.sort((a, b) => b.chars - a.chars)
|
|
117
|
+
.slice(0, ENTRIES_NAMED)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ── Parts ───────────────────────────────────────────────────────────────────────────── */
|
|
121
|
+
|
|
122
|
+
export function figurePartReadOf(asset: FigureAsset, part: string): FigurePartRead | undefined {
|
|
123
|
+
const appearance = asset.appearance.find(a => a.target === part)
|
|
124
|
+
if (!appearance) return undefined
|
|
125
|
+
const model = asset.document.model as Dynamic
|
|
126
|
+
const own = under(part)
|
|
127
|
+
return {
|
|
128
|
+
appearance,
|
|
129
|
+
joints: ((asset as Dynamic).joints ?? []).filter((j: Dynamic) => j?.body1?.part === part || j?.body0?.part === part),
|
|
130
|
+
nodes: model.nodes.filter((n: Dynamic) => own(n.id)),
|
|
131
|
+
constants: model.constants.filter((c: Dynamic) => own(c.id))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Where a part's records are, by pointer: for a part too large to read as one. */
|
|
136
|
+
function partPointers(asset: FigureAsset, part: string): string[] {
|
|
137
|
+
const model = asset.document.model as Dynamic
|
|
138
|
+
const own = under(part)
|
|
139
|
+
return [
|
|
140
|
+
...asset.appearance.flatMap((a, i) => (a.target === part ? [pointerOf(['appearance', i])] : [])),
|
|
141
|
+
...((asset as Dynamic).joints ?? []).flatMap((j: Dynamic, i: number) => (j?.body1?.part === part || j?.body0?.part === part ? [pointerOf(['joints', i])] : [])),
|
|
142
|
+
...model.nodes.flatMap((n: Dynamic, i: number) => (own(n.id) ? [pointerOf(['document', 'model', 'nodes', i])] : [])),
|
|
143
|
+
...model.constants.flatMap((c: Dynamic, i: number) => (own(c.id) ? [pointerOf(['document', 'model', 'constants', i])] : []))
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── The outline ─────────────────────────────────────────────────────────────────────── */
|
|
148
|
+
|
|
149
|
+
/*
|
|
150
|
+
The sections of an outline: every top-level field but the parts' records, the document's fields but its model, and the
|
|
151
|
+
model's fields but its graph. Each may be left out by its pointer.
|
|
152
|
+
*/
|
|
153
|
+
const PART_RECORDS = ['/document', '/appearance', '/joints', '/document/model', '/document/model/nodes', '/document/model/constants']
|
|
154
|
+
function sectionsOf(asset: FigureAsset): string[] {
|
|
155
|
+
const out: string[] = []
|
|
156
|
+
const walk = (tokens: string[]) => {
|
|
157
|
+
const { value } = valueAt(asset, tokens)
|
|
158
|
+
for (const key of Object.keys(value as object)) {
|
|
159
|
+
const pointer = pointerOf([...tokens, key])
|
|
160
|
+
if (pointer === '/document' || pointer === '/document/model') walk([...tokens, key])
|
|
161
|
+
else if (!PART_RECORDS.includes(pointer)) out.push(pointer)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
walk([])
|
|
165
|
+
return out
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function outlinePartsOf(asset: FigureAsset): FigureOutlinePart[] {
|
|
169
|
+
const nodes = (asset.document.model as Dynamic).nodes
|
|
170
|
+
const joints = (asset as Dynamic).joints ?? []
|
|
171
|
+
return asset.appearance.map((look: Dynamic) => {
|
|
172
|
+
const id = look.target
|
|
173
|
+
const joint = joints.find((j: Dynamic) => j?.body1?.part === id)
|
|
174
|
+
const body0 = joint?.body0
|
|
175
|
+
const on = !joint ? undefined : body0?.part ? `${body0.part}.${body0.face}` : (body0?.plane ?? 'mounting-plane')
|
|
176
|
+
const shape = nodes.find((n: Dynamic) => n.id === `${id}.shape`)?.op
|
|
177
|
+
return {
|
|
178
|
+
id,
|
|
179
|
+
...(shape ? { shape } : {}),
|
|
180
|
+
...(on ? { on } : {}),
|
|
181
|
+
...(joint ? { joint: { id: joint.id, type: joint.type } } : {}),
|
|
182
|
+
...(look.material?.token ? { material: look.material.token } : {}),
|
|
183
|
+
chars: length(figurePartReadOf(asset, id))
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Writes a section into the outline at its pointer, making the objects on the way. */
|
|
189
|
+
function place(outline: Dynamic, tokens: readonly string[], value: unknown) {
|
|
190
|
+
let at = outline
|
|
191
|
+
tokens.slice(0, -1).forEach(token => (at = at[token] ??= {}))
|
|
192
|
+
at[tokens[tokens.length - 1]!] = value
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The outline: within OUTLINE_SHARE for its sections, the largest left out first; and, every field counted, within the
|
|
197
|
+
* budget -- a figure of very many parts lists them by id only, then as many as fit.
|
|
198
|
+
*/
|
|
199
|
+
export function figureOutlineOf(asset: FigureAsset, chars = length(asset)): FigureOutline {
|
|
200
|
+
const model = asset.document.model as Dynamic
|
|
201
|
+
const sections = sectionsOf(asset).map(pointer => ({ pointer, value: valueAt(asset, tokensOf(pointer)!).value }))
|
|
202
|
+
const sized = sections.map(s => ({ ...s, chars: length(s.value) })).sort((a, b) => b.chars - a.chars)
|
|
203
|
+
|
|
204
|
+
let total = sized.reduce((sum, s) => sum + s.chars, 0)
|
|
205
|
+
const omitted: FigureOmittedSection[] = []
|
|
206
|
+
const kept = new Set(sized.map(s => s.pointer))
|
|
207
|
+
for (const s of sized) {
|
|
208
|
+
if (total <= OUTLINE_SHARE) break
|
|
209
|
+
kept.delete(s.pointer)
|
|
210
|
+
total -= s.chars
|
|
211
|
+
const entries = entriesOf(s.pointer, s.value)
|
|
212
|
+
omitted.push({ section: s.pointer, chars: s.chars, ...(entries.length ? { entries } : {}) })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const outline: Dynamic = { chars, budget: FIGURE_CONTEXT_BUDGET }
|
|
216
|
+
place(outline, ['document', 'model', 'nodes'], model.nodes.length)
|
|
217
|
+
place(outline, ['document', 'model', 'constants'], model.constants.length)
|
|
218
|
+
for (const s of sections) if (kept.has(s.pointer)) place(outline, tokensOf(s.pointer)!, s.value)
|
|
219
|
+
if (omitted.length) outline.omitted = omitted
|
|
220
|
+
|
|
221
|
+
let parts: Dynamic[] = outlinePartsOf(asset)
|
|
222
|
+
outline.parts = parts
|
|
223
|
+
if (length(outline) > FIGURE_CONTEXT_BUDGET) outline.parts = parts = parts.map(p => ({ id: p.id }))
|
|
224
|
+
if (length(outline) > FIGURE_CONTEXT_BUDGET) {
|
|
225
|
+
/* As many as fit, in part order, with how many more there are. */
|
|
226
|
+
let room = FIGURE_CONTEXT_BUDGET - length({ ...outline, parts: [], partsNotListed: parts.length })
|
|
227
|
+
const listed: Dynamic[] = []
|
|
228
|
+
for (const p of parts) {
|
|
229
|
+
room -= length(p) + 1
|
|
230
|
+
if (room < 0) break
|
|
231
|
+
listed.push(p)
|
|
232
|
+
}
|
|
233
|
+
outline.parts = listed
|
|
234
|
+
outline.partsNotListed = parts.length - listed.length
|
|
235
|
+
}
|
|
236
|
+
return outline as FigureOutline
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ── The view ────────────────────────────────────────────────────────────────────────── */
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The view of a figure the model is shown: whole when it fits, else the outline and what was asked for -- parts, then
|
|
243
|
+
* sections, each in the order asked -- while it fits beside it. Every field counted, the view is within the budget.
|
|
244
|
+
*/
|
|
245
|
+
export function figureViewOf(asset: FigureAsset, request: FigureReadRequest = {}): FigureView {
|
|
246
|
+
const chars = length(asset)
|
|
247
|
+
if (chars <= FIGURE_CONTEXT_BUDGET) return { whole: true, source: asset }
|
|
248
|
+
|
|
249
|
+
const outline = figureOutlineOf(asset, chars)
|
|
250
|
+
const view: FigureView = { whole: false, outline, parts: {}, shown: [], sections: {}, notRead: [] }
|
|
251
|
+
const room = () => FIGURE_CONTEXT_BUDGET - length(view)
|
|
252
|
+
const refuse = (one: { part: string; reason: string } | { section: string; reason: string }) => {
|
|
253
|
+
if (length(one) + 1 <= room()) view.notRead!.push(one)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const part of [...new Set(request.parts ?? [])]) {
|
|
257
|
+
const one = figurePartReadOf(asset, part)
|
|
258
|
+
if (!one) {
|
|
259
|
+
refuse({ part, reason: `${part} is not a part of this figure; the outline lists its parts` })
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
const cost = length(one) + length(part) + length(part) + 3
|
|
263
|
+
view.parts![part] = one
|
|
264
|
+
view.shown!.push(part)
|
|
265
|
+
if (room() >= 0) continue
|
|
266
|
+
delete view.parts![part]
|
|
267
|
+
view.shown!.pop()
|
|
268
|
+
if (cost <= FIGURE_CONTEXT_BUDGET - length(outline)) {
|
|
269
|
+
refuse({ part, reason: `${part} (${cost} characters) does not fit beside what is already read; read it in another call with fewer parts or sections` })
|
|
270
|
+
} else {
|
|
271
|
+
const pointers = partPointers(asset, part)
|
|
272
|
+
refuse({
|
|
273
|
+
part,
|
|
274
|
+
reason:
|
|
275
|
+
`${part} (${cost} characters) is too large to show beside the outline; read its records as sections: ` +
|
|
276
|
+
`${pointers.slice(0, ENTRIES_NAMED).join(', ')}${pointers.length > ENTRIES_NAMED ? ` and ${pointers.length - ENTRIES_NAMED} more` : ''}. ` +
|
|
277
|
+
'Parts read as sections are not counted as read in full: they cannot be changed'
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const section of [...new Set(request.sections ?? [])]) {
|
|
283
|
+
const tokens = tokensOf(section)
|
|
284
|
+
const at = tokens && tokens.length ? valueAt(asset, tokens) : { found: false }
|
|
285
|
+
if (!at.found) {
|
|
286
|
+
refuse({ section, reason: `there is no section ${section}; a section is a JSON Pointer such as /document/capabilities/bindings/0` })
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
const cost = length(at.value) + length(section) + 2
|
|
290
|
+
view.sections![section] = at.value
|
|
291
|
+
if (room() >= 0) continue
|
|
292
|
+
delete view.sections![section]
|
|
293
|
+
const entries = entriesOf(section, at.value)
|
|
294
|
+
refuse({
|
|
295
|
+
section,
|
|
296
|
+
reason: entries.length
|
|
297
|
+
? `${section} (${cost} characters) is too large to show at once here; read its entries: ${entries.map(e => `${e.section} (${e.chars})`).join(', ')}`
|
|
298
|
+
: `${section} is one value of ${length(at.value)} characters, larger than can be shown here; it cannot be read or changed by a proposal`
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!view.notRead!.length) delete view.notRead
|
|
303
|
+
return view
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ── What a proposal may change ──────────────────────────────────────────────────────── */
|
|
307
|
+
|
|
308
|
+
type StepOf<K extends FigureProposalStep['kind']> = Extract<FigureProposalStep, { kind: K }>
|
|
309
|
+
type Subject = string | undefined
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The part each kind of step changes, read from the field the kernel reads for that kind (audit D4-R1: a `part` beside a
|
|
313
|
+
* `set-material`'s `target` is not read by the kernel, so it may not stand in for it). Every kind of the kernel's step
|
|
314
|
+
* type is here, or this does not compile. A step that changes no one part says so with undefined: what it does to the
|
|
315
|
+
* figure's sections is held by `unreadSectionEdits`.
|
|
316
|
+
*/
|
|
317
|
+
const SUBJECT: { [K in FigureProposalStep['kind']]: (step: StepOf<K>, base: FigureAsset) => Subject } = {
|
|
318
|
+
'add-part': () => undefined,
|
|
319
|
+
'fit-room': () => undefined,
|
|
320
|
+
'set-section-corner': step => step.part,
|
|
321
|
+
'set-cavity': step => step.part,
|
|
322
|
+
'set-section': step => step.part,
|
|
323
|
+
attach: step => step.part,
|
|
324
|
+
'link-dimension': step => step.part,
|
|
325
|
+
'follow-size': step => step.part,
|
|
326
|
+
'unlink-dimension': step => step.part,
|
|
327
|
+
'scale-position': step => step.part,
|
|
328
|
+
'fix-position': step => step.part,
|
|
329
|
+
'nudge-part': step => step.part,
|
|
330
|
+
detach: step => step.part,
|
|
331
|
+
'make-repeat': step => step.part,
|
|
332
|
+
'set-repeat': step => step.part,
|
|
333
|
+
'remove-repeat': step => step.part,
|
|
334
|
+
'set-mounting-face': step => step.part,
|
|
335
|
+
'add-binding': step => step.part,
|
|
336
|
+
'set-joint': step => step.joint?.body1?.part,
|
|
337
|
+
'remove-joint': (step, base) => ((base as Dynamic).joints ?? []).find((j: Dynamic) => j?.id === step.id)?.body1?.part,
|
|
338
|
+
'set-material': step => step.target,
|
|
339
|
+
'set-segments': step => step.target,
|
|
340
|
+
'rename-part': step => step.target,
|
|
341
|
+
'set-visibility': step => step.target,
|
|
342
|
+
'remove-part': step => step.target,
|
|
343
|
+
'duplicate-part': step => step.target,
|
|
344
|
+
'move-part': step => step.target,
|
|
345
|
+
'set-placement': () => undefined,
|
|
346
|
+
'declare-occupancy': () => undefined,
|
|
347
|
+
'declare-instance-size': () => undefined,
|
|
348
|
+
'clear-mounting-face': () => undefined,
|
|
349
|
+
'scale-uniformly': () => undefined,
|
|
350
|
+
'follow-size-in-proportion': () => undefined,
|
|
351
|
+
'set-detail-level': () => undefined,
|
|
352
|
+
'add-design-input': () => undefined,
|
|
353
|
+
'set-input-range': () => undefined,
|
|
354
|
+
'publish-input': () => undefined,
|
|
355
|
+
'unpublish-input': () => undefined,
|
|
356
|
+
'remove-binding': () => undefined,
|
|
357
|
+
'set-slot-layout': () => undefined,
|
|
358
|
+
'remove-slot-layout': () => undefined,
|
|
359
|
+
'set-extension': () => undefined,
|
|
360
|
+
rename: () => undefined,
|
|
361
|
+
'set-capabilities': () => undefined,
|
|
362
|
+
'set-state-input': () => undefined,
|
|
363
|
+
'set-state-defaults': () => undefined,
|
|
364
|
+
'set-driver': () => undefined,
|
|
365
|
+
'remove-driver': () => undefined,
|
|
366
|
+
'set-clip': () => undefined,
|
|
367
|
+
'set-motion-group': () => undefined,
|
|
368
|
+
'remove-motion-group': () => undefined,
|
|
369
|
+
'set-data-schema': () => undefined,
|
|
370
|
+
'rename-data-field': () => undefined,
|
|
371
|
+
'set-data-binding': () => undefined,
|
|
372
|
+
'remove-data-binding': () => undefined,
|
|
373
|
+
'set-status-port': () => undefined,
|
|
374
|
+
'remove-status-port': () => undefined
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The steps that change a part of the base not shown in full, as reasons said back to the writer. A part may still be
|
|
379
|
+
* named to fasten to or to follow -- the kernel works from it, nothing about it is guessed -- and a new part is the
|
|
380
|
+
* steps' own. A kind this table does not know is refused: it is not let through unjudged.
|
|
381
|
+
*/
|
|
382
|
+
export function unreadPartEdits(base: FigureAsset, steps: readonly FigureProposalStep[], view: FigureView): string[] {
|
|
383
|
+
const existing = new Set(base.appearance.map(a => a.target))
|
|
384
|
+
const shown = new Set(view.shown ?? [])
|
|
385
|
+
const allowed = [...shown].join(', ') || 'none'
|
|
386
|
+
return steps.flatMap((step, i) => {
|
|
387
|
+
const kind = (step as Dynamic)?.kind
|
|
388
|
+
if (typeof kind !== 'string') return []
|
|
389
|
+
const judge = (SUBJECT as Record<string, (step: Dynamic, base: FigureAsset) => Subject>)[kind]
|
|
390
|
+
if (!Object.prototype.hasOwnProperty.call(SUBJECT, kind) || !judge)
|
|
391
|
+
return [`UNKNOWN_STEP: steps[${i}] has the kind "${kind}", which is not one of the commands; use only the commands taught.`]
|
|
392
|
+
const part = judge(step, base)
|
|
393
|
+
if (typeof part !== 'string' || !existing.has(part) || shown.has(part)) return []
|
|
394
|
+
return [
|
|
395
|
+
`UNREAD_PART: steps[${i}] (${kind}) changes "${part}", which was not shown in full, so its source is not known here. ` +
|
|
396
|
+
`Change only the parts shown in full (${allowed}) or parts the steps add. To change ${part}, read it with getFigureSource parts and pass it in parts to proposeFigureCandidate.`
|
|
397
|
+
]
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Entries of a value, keyed by `id` when every entry of a list has a distinct one, else by place. */
|
|
402
|
+
function keyedEntries(value: unknown): Map<string, { token: string; value: unknown }> {
|
|
403
|
+
const out = new Map<string, { token: string; value: unknown }>()
|
|
404
|
+
if (Array.isArray(value)) {
|
|
405
|
+
const ids = value.map(v => (v && typeof v === 'object' && typeof (v as Dynamic).id === 'string' ? (v as Dynamic).id : undefined))
|
|
406
|
+
const byId = ids.every(Boolean) && new Set(ids).size === ids.length
|
|
407
|
+
value.forEach((v, i) => out.set(byId ? `id:${ids[i]}` : `at:${i}`, { token: String(i), value: v }))
|
|
408
|
+
} else if (value && typeof value === 'object') {
|
|
409
|
+
for (const [key, v] of Object.entries(value)) out.set(`key:${key}`, { token: key, value: v })
|
|
410
|
+
}
|
|
411
|
+
return out
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* What of the sections left out of the outline and not read the steps changed, as reasons said back to the writer. Judged
|
|
416
|
+
* on what the steps made, not on what they say: nothing that was there may be changed or taken away. A new entry -- a
|
|
417
|
+
* record in a list, a key in a map -- is the steps' own.
|
|
418
|
+
*/
|
|
419
|
+
export function unreadSectionEdits(base: FigureAsset, after: FigureAsset, view: FigureView): string[] {
|
|
420
|
+
const read = Object.keys(view.sections ?? {})
|
|
421
|
+
const reasons: string[] = []
|
|
422
|
+
const say = (pointer: string, what: string) =>
|
|
423
|
+
reasons.push(
|
|
424
|
+
`UNREAD_SECTION: the steps ${what} ${pointer}, which was left out of the outline and not read, so it is not known here. ` +
|
|
425
|
+
`Leave it as it is; to change it, read it with getFigureSource sections and pass it in sections to proposeFigureCandidate.`
|
|
426
|
+
)
|
|
427
|
+
const check = (pointer: string, before: unknown, now: unknown) => {
|
|
428
|
+
if (reasons.length >= ENTRIES_NAMED || read.some(r => within(pointer, r))) return
|
|
429
|
+
const records = (v: unknown) => v !== null && typeof v === 'object'
|
|
430
|
+
if (!records(before) || !records(now) || Array.isArray(before) !== Array.isArray(now)) {
|
|
431
|
+
if (JSON.stringify(before) !== JSON.stringify(now)) say(pointer, 'change')
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
const later = keyedEntries(now)
|
|
435
|
+
for (const [key, entry] of keyedEntries(before)) {
|
|
436
|
+
const child = `${pointer}/${escapeToken(entry.token)}`
|
|
437
|
+
const kept = later.get(key)
|
|
438
|
+
if (kept) check(child, entry.value, kept.value)
|
|
439
|
+
else if (!read.some(r => within(child, r))) say(child, 'remove')
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
for (const omitted of view.outline?.omitted ?? []) {
|
|
443
|
+
const tokens = tokensOf(omitted.section)!
|
|
444
|
+
check(omitted.section, valueAt(base, tokens).value, valueAt(after, tokens).value)
|
|
445
|
+
}
|
|
446
|
+
return reasons
|
|
447
|
+
}
|
|
@@ -185,6 +185,23 @@ describe('figure-format — 판을 알아보는 문', () => {
|
|
|
185
185
|
expect(`${finding.message}${finding.why}${finding.how}`).not.toMatch(/declared volume|leaves/)
|
|
186
186
|
})
|
|
187
187
|
|
|
188
|
+
it('확장 속성의 키가 없어지면 발행을 막고 키 이름으로 말한다; 값이 바뀌면 알리기만 한다 (ADR-0123)', () => {
|
|
189
|
+
const carrier = { type: 'ROLL', dimensions: { width: 1000, length: 600, height: 200 } }
|
|
190
|
+
const withKey = applyFigureAuthoring(figureAsset(), { kind: 'set-extension', key: 'fmsim/carrier', value: carrier })
|
|
191
|
+
const released = serializeFigureAsset(withKey)
|
|
192
|
+
|
|
193
|
+
const gone = inspectSource(serializeFigureAsset(figureAsset()), released, translatorOf(KO)).findings.find(f => f.code === 'extension-gone')!
|
|
194
|
+
expect(gone.blocking).toBe(true)
|
|
195
|
+
expect(gone.message).toBe("'fmsim/carrier' 확장 속성이 없어졌습니다.")
|
|
196
|
+
|
|
197
|
+
const changed = applyFigureAuthoring(withKey, { kind: 'set-extension', key: 'fmsim/carrier', value: { ...carrier, type: 'ROLL2' } })
|
|
198
|
+
const said = inspectSource(serializeFigureAsset(changed), released, translatorOf(KO))
|
|
199
|
+
const finding = said.findings.find(f => f.code === 'extension-changed')!
|
|
200
|
+
expect(finding.blocking).toBe(false)
|
|
201
|
+
expect(finding.message).toBe("'fmsim/carrier' 확장 속성의 값이 바뀌었습니다.")
|
|
202
|
+
expect(said.findings.some(f => f.code === 'extension-gone')).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
|
|
188
205
|
it('공간을 선언하지 않은 도형은 무엇을 누르면 되는지 말한다', () => {
|
|
189
206
|
const finding = inspectSource(bare(), null, translatorOf(KO)).findings.find(f => f.code === 'occupancy-undeclared')!
|
|
190
207
|
|
|
@@ -350,7 +367,7 @@ describe('figure-format — 판을 알아보는 문', () => {
|
|
|
350
367
|
it('every new change has its sentence in both languages', () => {
|
|
351
368
|
const KO = require('../../../translations/ko.json') as Record<string, string>
|
|
352
369
|
const EN = require('../../../translations/en.json') as Record<string, string>
|
|
353
|
-
for (const code of ['occupancy-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added', 'state-input-joints-changed', 'joint-axis-changed', 'joint-motion-unit-changed', 'joint-child-changed', 'joint-list-unread', '
|
|
370
|
+
for (const code of ['occupancy-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added', 'state-input-joints-changed', 'joint-axis-changed', 'joint-motion-unit-changed', 'joint-child-changed', 'joint-list-unread', 'extension-gone', 'extension-changed', 'extension-added'])
|
|
354
371
|
for (const dict of [KO, EN]) for (const field of ['message', 'why']) expect(dict[`figure.gate.contract.${code}.${field}`]).toBeTruthy()
|
|
355
372
|
})
|
|
356
373
|
it('the data changes have their sentences under figure-model 0.6.6\'s codes, in all six languages', () => {
|
|
@@ -478,28 +478,22 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
|
|
|
478
478
|
message: say(key('message'), { ...v, input: change.subject ?? '' }, `'${change.subject}' can now be set on a board.`),
|
|
479
479
|
why: say(key('why'), {}, 'Placed instances that give no value stand at its default.')
|
|
480
480
|
}
|
|
481
|
-
/*
|
|
482
|
-
case '
|
|
481
|
+
/* A value another product reads by its key (ADR-0123): only a key that goes refuses; what a change means is its owner's. */
|
|
482
|
+
case 'extension-gone':
|
|
483
483
|
return {
|
|
484
|
-
message: say(key('message'), v, `The
|
|
485
|
-
why: say(key('why'), {}, '
|
|
484
|
+
message: say(key('message'), v, `The extension '${v.key}' is gone.`),
|
|
485
|
+
why: say(key('why'), {}, 'Another product reads this value by its key. After this release it finds none.'),
|
|
486
486
|
...gone
|
|
487
487
|
}
|
|
488
|
-
case '
|
|
488
|
+
case 'extension-changed':
|
|
489
489
|
return {
|
|
490
|
-
message: say(key('message'), v, `The
|
|
491
|
-
why: say(key('why'), {}, '
|
|
492
|
-
...gone
|
|
493
|
-
}
|
|
494
|
-
case 'carrier-dimensions-changed':
|
|
495
|
-
return {
|
|
496
|
-
message: say(key('message'), v, `The carrier size changed from ${v.before} to ${v.after} mm.`),
|
|
497
|
-
why: say(key('why'), {}, 'Holders fit and stack carriers by this size. Where they stand and what fits is judged again once the map takes this release.')
|
|
490
|
+
message: say(key('message'), v, `The value of the extension '${v.key}' changed.`),
|
|
491
|
+
why: say(key('why'), {}, 'The product that owns this key reads the new value and judges the change.')
|
|
498
492
|
}
|
|
499
|
-
case '
|
|
493
|
+
case 'extension-added':
|
|
500
494
|
return {
|
|
501
|
-
message: say(key('message'), v, `The
|
|
502
|
-
why: say(key('why'), {}, '
|
|
495
|
+
message: say(key('message'), v, `The extension '${v.key}' is new.`),
|
|
496
|
+
why: say(key('why'), {}, 'The product that owns this key reads this value.')
|
|
503
497
|
}
|
|
504
498
|
/* What a consumer's data mapping and a board's motion selection lean on (ADR-0051 §2.2, §3.2). */
|
|
505
499
|
case 'data-field-gone':
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { createAIClient, type AIClient } from '@things-factory/ai-client-base'
|
|
13
13
|
|
|
14
|
-
import { applyFigureProposal, createFigureAsset, inspectFigureAsset } from '@hatiolab/figure-model'
|
|
14
|
+
import { applyFigureProposal, compileFigureAsset, createFigureAsset, figureShapeBoundsOf, inspectFigureAsset, type FigureAsset } from '@hatiolab/figure-model'
|
|
15
15
|
|
|
16
|
-
import { proposeFigureSteps } from './figure-propose-steps'
|
|
16
|
+
import { judgeFigureAnswer, proposeFigureSteps } from './figure-propose-steps'
|
|
17
17
|
|
|
18
18
|
// Provider modules register themselves on import. Keep this separate from the deterministic tests.
|
|
19
19
|
import '@things-factory/ai-client-base'
|
|
@@ -35,6 +35,83 @@ function provider(): { provider: Provider; apiKey: string } | undefined {
|
|
|
35
35
|
return undefined
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** A mixer: a body standing on the floor, a port fastened to its front. */
|
|
39
|
+
function mixerDraft(): FigureAsset {
|
|
40
|
+
return applyFigureProposal(createFigureAsset('Mixer'), [
|
|
41
|
+
{ kind: 'add-part', part: { id: 'body', kind: 'cylinder', dimensions: { radius: 140, length: 320 } } },
|
|
42
|
+
{ kind: 'add-part', part: { id: 'port', kind: 'box', dimensions: { width: 80, height: 60, depth: 30 } } },
|
|
43
|
+
{ kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } },
|
|
44
|
+
{ kind: 'attach', part: 'port', face: 'back', to: { part: 'body', face: 'front' } },
|
|
45
|
+
{ kind: 'fit-room', placement: 'floor' }
|
|
46
|
+
] as any)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Each part's reach along its own axes and the colour it is drawn in, at the figure's default size. The colour is the
|
|
51
|
+
* palette's value for the part's token: two tokens of one colour draw the same.
|
|
52
|
+
*/
|
|
53
|
+
function drawn(asset: FigureAsset): Record<string, { size: Record<'x' | 'y' | 'z', number>; colour: string }> {
|
|
54
|
+
const geometry = compileFigureAsset(asset).evaluate({}).geometry as any[]
|
|
55
|
+
return Object.fromEntries(
|
|
56
|
+
geometry.map(body => {
|
|
57
|
+
const bounds = figureShapeBoundsOf(body.shape)
|
|
58
|
+
const size = { x: bounds.x.max - bounds.x.min, y: bounds.y.max - bounds.y.min, z: bounds.z.max - bounds.z.min }
|
|
59
|
+
/* The kernel refuses a token the palette has no colour for, so every body has one. */
|
|
60
|
+
return [body.id, { size, colour: String(asset.palette![body.material.token]).toLowerCase() }]
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What "make the port twice as wide and give it an accent colour" asks of the mixer, said as what is missing: the port
|
|
67
|
+
* twice as wide (X), its height and depth kept, a colour it did not have, and the body left as it was. Nothing missing:
|
|
68
|
+
* an empty list. Checking only that the parts still exist let a revision that changed nothing pass.
|
|
69
|
+
*/
|
|
70
|
+
function portRevisionMisses(before: FigureAsset, after: FigureAsset): string[] {
|
|
71
|
+
const was = drawn(before),
|
|
72
|
+
now = drawn(after)
|
|
73
|
+
const misses: string[] = []
|
|
74
|
+
const near = (a: number, b: number) => Math.abs(a - b) <= 0.5
|
|
75
|
+
if (!now.port) return ['the port is gone']
|
|
76
|
+
if (!near(now.port.size.x, was.port.size.x * 2)) misses.push(`port width ${now.port.size.x}, asked ${was.port.size.x * 2}`)
|
|
77
|
+
for (const axis of ['y', 'z'] as const) if (!near(now.port.size[axis], was.port.size[axis])) misses.push(`port ${axis} changed to ${now.port.size[axis]}`)
|
|
78
|
+
if (now.port.colour === was.port.colour) misses.push(`port colour still ${was.port.colour}`)
|
|
79
|
+
if (!now.body) misses.push('the body is gone')
|
|
80
|
+
else for (const axis of ['x', 'y', 'z'] as const) if (!near(now.body.size[axis], was.body.size[axis])) misses.push(`body ${axis} changed to ${now.body.size[axis]}`)
|
|
81
|
+
if (now.body && now.body.colour !== was.body.colour) misses.push(`body colour changed to ${now.body.colour}`)
|
|
82
|
+
return misses
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* The check above, held to answers whose verdict is known - so it is seen to fail a wrong revision without a provider. */
|
|
86
|
+
describe('the revision check of the smoke test', () => {
|
|
87
|
+
const draft = mixerDraft()
|
|
88
|
+
const answer = (steps: unknown[]) => {
|
|
89
|
+
const judged = judgeFigureAnswer(draft, { steps }, true)
|
|
90
|
+
if (!judged.source) throw new Error(judged.reasons.join('\n'))
|
|
91
|
+
return judged.source
|
|
92
|
+
}
|
|
93
|
+
const accent = { kind: 'set-material', target: 'port', material: { token: 'palette.accent' }, palette: { 'palette.accent': '#d08a2c' } }
|
|
94
|
+
|
|
95
|
+
it('passes the revision asked for', () => {
|
|
96
|
+
expect(portRevisionMisses(draft, answer([{ kind: 'set-section', part: 'port', across: { x: 160 } }, accent]))).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('fails a revision that left the width, or widened the body instead, or kept the colour', () => {
|
|
100
|
+
expect(portRevisionMisses(draft, answer([accent]))).toEqual(['port width 80, asked 160'])
|
|
101
|
+
expect(portRevisionMisses(draft, answer([{ kind: 'set-section', part: 'port', across: { x: 160 } }, { kind: 'set-section', part: 'body', across: { x: 320 } }, accent]))).toEqual(
|
|
102
|
+
expect.arrayContaining(['body x changed to 320'])
|
|
103
|
+
)
|
|
104
|
+
expect(portRevisionMisses(draft, answer([{ kind: 'set-section', part: 'port', across: { x: 160 } }]))).toEqual([`port colour still ${draft.palette!['palette.primary']}`])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/* A new token is not a new colour: an alias of the colour the port already had changes nothing on screen. */
|
|
108
|
+
it('fails a new token that names the colour the port already had', () => {
|
|
109
|
+
const same = draft.palette!['palette.primary']!
|
|
110
|
+
const alias = { kind: 'set-material', target: 'port', material: { token: 'palette.accent' }, palette: { 'palette.accent': same.toUpperCase() } }
|
|
111
|
+
expect(portRevisionMisses(draft, answer([{ kind: 'set-section', part: 'port', across: { x: 160 } }, alias]))).toEqual([`port colour still ${same}`])
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
38
115
|
const picked = process.env.RUN_FIGURE_LLM_SMOKE === '1' ? provider() : undefined
|
|
39
116
|
const describeIf = picked ? describe : describe.skip
|
|
40
117
|
|
|
@@ -66,20 +143,12 @@ describeIf('Figure 실제 LLM smoke', () => {
|
|
|
66
143
|
)
|
|
67
144
|
|
|
68
145
|
test(
|
|
69
|
-
'고치는 제안은
|
|
146
|
+
'고치는 제안은 요청한 것만 바꾼다 — 포트 폭 두 배, 새 색, 몸통은 그대로',
|
|
70
147
|
async () => {
|
|
71
|
-
const draft =
|
|
72
|
-
{ kind: 'add-part', part: { id: 'body', kind: 'cylinder', dimensions: { radius: 140, length: 320 } } },
|
|
73
|
-
{ kind: 'add-part', part: { id: 'port', kind: 'box', dimensions: { width: 80, height: 60, depth: 30 } } },
|
|
74
|
-
{ kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } },
|
|
75
|
-
{ kind: 'attach', part: 'port', face: 'back', to: { part: 'body', face: 'front' } },
|
|
76
|
-
{ kind: 'fit-room', placement: 'floor' }
|
|
77
|
-
] as any)
|
|
148
|
+
const draft = mixerDraft()
|
|
78
149
|
const result = await proposeFigureSteps({ client, prompt: 'make the port twice as wide and give it an accent colour', base: draft })
|
|
79
150
|
|
|
80
|
-
|
|
81
|
-
expect(ids.some((id: string) => id.includes('body'))).toBe(true)
|
|
82
|
-
expect(ids.some((id: string) => id.includes('port'))).toBe(true)
|
|
151
|
+
expect(portRevisionMisses(draft, result.source)).toEqual([])
|
|
83
152
|
// eslint-disable-next-line no-console
|
|
84
153
|
console.log(`[figure-llm-smoke] revise: ${JSON.stringify(result.steps)}`)
|
|
85
154
|
},
|