@things-factory/figure-service 10.1.168 → 10.1.170

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.168",
3
+ "version": "10.1.170",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -25,9 +25,9 @@
25
25
  "test:templates": "node --test scripts/templates.test.mts"
26
26
  },
27
27
  "dependencies": {
28
- "@hatiolab/figure-model": "^0.7.0",
28
+ "@hatiolab/figure-model": "^0.7.2",
29
29
  "@operato/typeorm-history": "^10.0.0",
30
- "@things-factory/ai-client-base": "^10.1.168",
30
+ "@things-factory/ai-client-base": "^10.1.170",
31
31
  "@things-factory/attachment-base": "^10.1.167",
32
32
  "@things-factory/auth-base": "^10.1.167",
33
33
  "@things-factory/env": "^10.1.149",
@@ -38,5 +38,5 @@
38
38
  "jszip": "^3.10.1",
39
39
  "koa-router": "^7.4.0"
40
40
  },
41
- "gitHead": "fe067ff11d90effffc2523374453d9b8313b489f"
41
+ "gitHead": "1956b73789eace6376f1d79443306e1bb8e61908"
42
42
  }
@@ -6,7 +6,7 @@
6
6
  * the proposer says it could not make it, and nothing half made comes back.
7
7
  */
8
8
  import { FIGURE_PROPOSAL_GRAMMAR, addFigureBox, applyFigureAuthoring, applyFigureProposal, createFigureAsset, inspectFigureAsset, proposeFigureOccupancy } from '@hatiolab/figure-model'
9
- import type { AIClient } from '@things-factory/ai-client-base'
9
+ import { AIAnswerNotJSON, type AIClient } from '@things-factory/ai-client-base'
10
10
 
11
11
  import { ProposeFigureFailure, judgeFigureAnswer, proposalFailureForPerson, proposeFigureSteps, rulesPromptFigure } from './figure-propose-steps'
12
12
 
@@ -56,6 +56,18 @@ describe('the model is told the kernel\'s commands and the open draft', () => {
56
56
  expect(ours).not.toMatch(/"parts"\s*:\s*\[|anchor/i)
57
57
  })
58
58
 
59
+ /*
60
+ :3300, 2026-10-16, a real model asked for a conveyor with 12 rollers: one answer added and fastened every roller and
61
+ frame rail by hand, sixty steps, and fastened a rail twice (ATTACH_REPLACED at steps[59]); another named a part it
62
+ had not added. Both are rules of writing, so they are taught with the rules.
63
+ */
64
+ it('teaches a row of the same part as one part repeated, and each part fastened once', () => {
65
+ const rules = rulesPromptFigure()
66
+ expect(rules).toMatch(/same part .*make-repeat/)
67
+ expect(rules).toMatch(/Fasten each part once/)
68
+ expect(rules).toMatch(/fasten only to the mounting plane or to a part an earlier step added/)
69
+ })
70
+
59
71
  it('a measure asked to be the same as another is linked, not copied (:3300, 2026-09-26: 「몸통 지름과 같게」 came as a fixed 100)', () => {
60
72
  expect(rulesPromptFigure()).toMatch(/same as, matches or follows another part or the figure, write link-dimension/)
61
73
  })
@@ -103,14 +115,52 @@ describe('the steps are held to the kernel and the gate', () => {
103
115
  })
104
116
 
105
117
  it('three refusals said back, then it says it could not make it, with the last reasons -- nothing half made', async () => {
106
- const client = stubClient([{ steps: wrongFace }, new Error('cut short'), { nope: true }])
118
+ const client = stubClient([{ steps: wrongFace }, new AIAnswerNotJSON('no balanced JSON found'), { nope: true }])
107
119
  const failure = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' }).catch(e => e)
108
120
  expect(failure).toBeInstanceOf(ProposeFigureFailure)
109
121
  expect(failure.message).toBe('The figure could not be made.')
110
122
  expect(failure.attempts).toBe(3)
111
123
  expect(failure.reasons).toEqual(['The answer must be { "steps": [ ... ] }.'])
112
124
  expect(client.calls).toBe(3)
113
- expect(client.sent[2]!.at(-1)!.content).toMatch(/not valid JSON: cut short/)
125
+ expect(client.sent[2]!.at(-1)!.content).toMatch(/not valid JSON: no balanced JSON found/)
126
+ })
127
+
128
+ /*
129
+ Only an answer that is not JSON is asked again (figure audit 2026-10-16 N2). A request that failed -- a rejected key,
130
+ a rate limit, a dropped connection -- was asked three times and said as "not valid JSON"; now it is said as it is,
131
+ once, and the tool's caller hears the provider's own words.
132
+ */
133
+ /*
134
+ :3300, 2026-10-16, a real model: three answers refused with "TARGET_ABSENT: steps[12]: attach: joints.frame: frame is
135
+ not a placed part". The kernel says the same for a part never added and a part not placed yet, so the writer is told
136
+ which parts there were at the refused step, and fastens to one of them.
137
+ */
138
+ it('a step that names a part not there is said back with the parts there were at that step', () => {
139
+ const steps = [...liftTable.slice(0, 3), { kind: 'attach', part: 'column', face: 'bottom', to: { part: 'frame', face: 'top' } }]
140
+ const { reasons } = judgeFigureAnswer(createFigureAsset('Lift table'), { steps }, false)
141
+ expect(reasons).toHaveLength(1)
142
+ expect(reasons[0]).toMatch(/^TARGET_ABSENT: steps\[3\]/)
143
+ expect(reasons[0]).toContain('The parts at steps[3] are: base, column, top.')
144
+ /* The kernel's sentence ends without a stop; the two sentences are not run together (:3300 "...here The parts"). */
145
+ expect(reasons[0]).toMatch(/[.!?] The parts at steps\[3\]/)
146
+ })
147
+
148
+ it('a request that failed is not asked again, and is said as it failed', async () => {
149
+ const refused = Object.assign(new Error('401 Unauthorized'), { status: 401 })
150
+ const client = stubClient([refused, { steps: liftTable }])
151
+ const failure = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' }).catch(e => e)
152
+ expect(failure).toBe(refused)
153
+ expect(client.calls).toBe(1)
154
+ })
155
+
156
+ it('an answer cut off at the output limit is asked again for fewer commands, not for the same', async () => {
157
+ const client = stubClient([new AIAnswerNotJSON('no balanced JSON found', true), { steps: liftTable }])
158
+ const made = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' })
159
+ expect(made.attempts).toBe(2)
160
+ const asked = client.sent[1]!.at(-1)!.content as string
161
+ expect(asked).toMatch(/cut off at the output limit/)
162
+ expect(asked).toMatch(/make-repeat/)
163
+ expect(asked).not.toMatch(/not valid JSON/)
114
164
  })
115
165
  })
116
166
 
@@ -1,6 +1,6 @@
1
1
  import { FIGURE_PROPOSAL_GRAMMAR, applyFigureProposal, createFigureAsset, inspectFigureAsset, serializeFigureAsset, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'
2
2
  import type { FigureAsset, FigureProposalStep, FigureRefusalDetails } from '@hatiolab/figure-model'
3
- import type { AIClient } from '@things-factory/ai-client-base'
3
+ import { AIAnswerNotJSON, type AIClient } from '@things-factory/ai-client-base'
4
4
  import type { AIImageMediaType, AIMessage } from '@things-factory/ai-client-base'
5
5
 
6
6
  import { figureViewOf, unreadPartEdits, unreadSectionEdits, type FigureReadRequest, type FigureView } from './figure-context.js'
@@ -86,6 +86,10 @@ export function rulesPromptFigure(): string {
86
86
  'Space: millimetres; Y is up; the figure stands on its mounting plane. Angles are degrees.',
87
87
  'A part is made at the origin and placed by fastening one of its faces to a face of another part or to the mounting',
88
88
  'plane. Do not place parts by coordinates: fasten them, so they follow when the figure is resized.',
89
+ 'Fasten each part once, and fasten only to the mounting plane or to a part an earlier step added; to move a part',
90
+ 'already fastened, attach it again with replace: true.',
91
+ 'Several of the same part in a row (rollers, legs, slats) are one part and make-repeat, not a part added and fastened',
92
+ 'for each: the row follows the figure when it is resized, and the answer stays short.',
89
93
  'A part is named by its id; use lowercase words joined by hyphens. Name only parts that exist or that an earlier step adds.',
90
94
  'A colour is a palette token. A token the figure does not have yet is given with its colour on the same command:',
91
95
  '{ kind: "set-material", target, material: { token }, palette: { [token]: "#rrggbb" } }.',
@@ -162,6 +166,29 @@ function worseThan(now: Violation, before: Violation): string | undefined {
162
166
  return undefined
163
167
  }
164
168
 
169
+ /**
170
+ * The parts there were at the step the kernel refused, said after its refusal. The kernel says "is not a placed part" of
171
+ * a part never added and of one not placed yet alike (:3300, 2026-10-16: three answers refused so, the writer never told
172
+ * which names there were); with the names before it, the writer fastens to one of them. Nothing for a refusal not at a
173
+ * step, or a step whose earlier steps cannot be replayed.
174
+ */
175
+ /** How many part names a refusal is told with: a figure of hundreds of parts is not listed whole on every retry. */
176
+ const PARTS_NAMED = 80
177
+
178
+ function partsAtRefusedStep(base: FigureAsset, steps: FigureProposalStep[], error: unknown): string {
179
+ const at = /^steps\[(\d+)\]$/.exec(String((error as { path?: unknown })?.path ?? ''))
180
+ if (!at) return ''
181
+ const step = Number(at[1])
182
+ try {
183
+ const before = applyFigureProposal(base, steps.slice(0, step))
184
+ const parts = before.appearance.map(look => look.target)
185
+ const named = parts.slice(0, PARTS_NAMED).join(', ') + (parts.length > PARTS_NAMED ? ` and ${parts.length - PARTS_NAMED} more` : '')
186
+ return ` The parts at steps[${step}] are: ${parts.length ? named : 'none'}.`
187
+ } catch {
188
+ return ''
189
+ }
190
+ }
191
+
165
192
  /**
166
193
  * Applies an answer on a copy of the base and returns the figure, or the reasons it was not accepted: the step the
167
194
  * kernel refused, or the gate findings the steps brought in. When revising, a finding the draft already had is not the
@@ -199,7 +226,10 @@ export function judgeFigureAnswer(
199
226
  `CLIPS_DROPPED: these steps would stop the clips ${clips.map(c => `${c.clip} (plays ${c.state})`).join(', ')}. A proposal may never remove a clip; only the person can. Keep the parts and the motions those clips play as they are.`
200
227
  ]
201
228
  }
202
- return { reasons: [(e as Error).message] }
229
+ const said = (e as Error).message
230
+ const parts = partsAtRefusedStep(base, steps as FigureProposalStep[], e)
231
+ /* The kernel's sentence may end without a stop; the next one is not run into it. */
232
+ return { reasons: [parts && !/[.!?]$/.test(said) ? `${said}.${parts}` : `${said}${parts}`] }
203
233
  }
204
234
 
205
235
  /* What the steps did to a section the writer was not shown, judged on the figure they made. */
@@ -247,7 +277,17 @@ export async function proposeFigureSteps(input: ProposeFigureInput): Promise<Pro
247
277
  try {
248
278
  answer = await client.generateJSON(messages, { systemPrompt: system, maxTokens: GENERATE_MAX_TOKENS })
249
279
  } catch (e) {
250
- reasons = [`The answer was not valid JSON: ${(e as Error).message}`]
280
+ /*
281
+ Only an answer that is not JSON is asked again (figure audit 2026-10-16 N2). A request that failed -- a rejected
282
+ key, a rate limit, a dropped connection -- goes up as it failed: asked again it fails again, and said as "not
283
+ valid JSON" it names a cause it did not have.
284
+ */
285
+ if (!(e instanceof AIAnswerNotJSON)) throw e
286
+ reasons = [
287
+ e.cutShort
288
+ ? `The answer was cut off at the output limit (${GENERATE_MAX_TOKENS} tokens) before the JSON ended. Write fewer commands: repeat a part with make-repeat instead of adding each copy, and write only what the request needs.`
289
+ : `The answer was not valid JSON: ${e.message}`
290
+ ]
251
291
  messages.push({ role: 'user', content: ['That was rejected. Answer with the JSON object only:', ...reasons].join('\n') })
252
292
  continue
253
293
  }
@@ -176,3 +176,52 @@ describe('a picture sent in the dock', () => {
176
176
  expect(proposed[0].images).toBeUndefined()
177
177
  })
178
178
  })
179
+
180
+ /*
181
+ The next turn's model is told which card an outcome is about (figure audit 2026-10-16 N7). The outcome row names the
182
+ proposal by its key, and the model never saw the key: tool results are not in the history. So the answer that offered
183
+ the card carries, for the model, one line with the key and the card's title.
184
+ */
185
+ describe('a card the person ran, on the next turn', () => {
186
+ it('★ the model reads the card by its title beside the key the outcome names', async () => {
187
+ const { session } = await conversationWithPicture()
188
+ const first = await ask(session.id, [])
189
+ const card = first.proposals[0]
190
+ await new aiAssistant.ChatSessionResolver().reportProposalOutcome(session.id, card.key, { verdict: 'refused', errorCode: 'REVISION_CONFLICT' }, {
191
+ state: { domain: DOMAIN, user: USER }
192
+ })
193
+
194
+ await ask(session.id, [])
195
+
196
+ const history = modelCalls[2]!
197
+ const offered = history.find((message: any) => message.role === 'assistant' && String(message.content).includes(card.key))
198
+ expect(offered?.content).toContain(`"${card.label}"`)
199
+ const outcome = history.findIndex((message: any) => typeof message.content === 'string' && message.content.includes(`proposal ${card.key}: refused`))
200
+ expect(outcome).toBeGreaterThan(history.indexOf(offered))
201
+ })
202
+ })
203
+
204
+ /*
205
+ A tool that fails is the tool's answer: through the real door, the turn still answers and the model reads why
206
+ (figure audit 2026-10-16 N1). Before, `assistantChat` rejected and the dock showed the kernel's English reasons.
207
+ */
208
+ describe('a proposal that could not be made', () => {
209
+ it('★ the turn answers, and the model is handed the reasons', async () => {
210
+ __setFigureToolDepsForTest({
211
+ getRepository: () => ({ findOne: async () => null, find: async () => [] }),
212
+ Figure: class {},
213
+ proposeFigureSteps: async () => {
214
+ throw Object.assign(new Error('The figure could not be made.'), { reasons: ['ATTACH_FACE: steps[4] faces along x'] })
215
+ }
216
+ })
217
+ const { session } = await conversationWithPicture()
218
+
219
+ const answer = await ask(session.id, [])
220
+
221
+ expect(answer.proposals ?? []).toHaveLength(0)
222
+ const results = modelCalls[1]!.at(-1)!.content.filter((part: any) => part.type === 'tool_result')
223
+ expect(results).toHaveLength(1)
224
+ expect(results[0].isError).toBe(true)
225
+ expect(JSON.parse(results[0].content).message).toMatch(/could not be made\.\nATTACH_FACE: steps\[4\]/)
226
+ })
227
+ })