@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
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The picture a person attaches in the modeller's dock reaches the step generator, through the conversation's real
3
+ * door (ADR-0061, 2026-10-16 decision 2): `assistantChat` -> `registryAssistantChat` -> `proposeFigureCandidate` ->
4
+ * `proposeFigureSteps`.
5
+ *
6
+ * The audit found the break on this path, not in any one piece: the dock uploads files by `attachmentIds`, the door put
7
+ * them on the model's message only, and the tool looked for `hostContext.image`, which the dock never fills. So this
8
+ * runs the door itself, on sqlite, with the file stored as a conversation's attachment - the provider and the step
9
+ * generator are the only stand-ins.
10
+ *
11
+ * ai-assistant's door is loaded compiled (its type-graphql classes need tsc's metadata), so build it first:
12
+ * yarn workspace @things-factory/ai-assistant build:server
13
+ */
14
+ import type { DataSource } from 'typeorm'
15
+
16
+ /* Files of a conversation are read through the storage; the database storage keeps them in the row. Set before load. */
17
+ require('@things-factory/env').config.set('storage', { type: 'database' })
18
+
19
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
20
+ const shell = require('@things-factory/shell')
21
+ const authBase = require('@things-factory/auth-base')
22
+ const attachmentBase = require('@things-factory/attachment-base')
23
+ const aiClientBase = require('@things-factory/ai-client-base')
24
+ const aiAssistant = require('@things-factory/ai-assistant')
25
+
26
+ const { __setFigureToolDepsForTest, registerFigureToolCategories } = require('./figure-tools')
27
+
28
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
29
+
30
+ let dataSource: DataSource | null = null
31
+ let DOMAIN: any
32
+ let USER: any
33
+
34
+ /** What the step generator was asked - the generator itself is figure-propose-steps' own suite. */
35
+ const proposed: any[] = []
36
+ /** What the conversation's model was handed, each call. */
37
+ const modelCalls: any[][] = []
38
+
39
+ beforeAll(async () => {
40
+ dataSource = new TypeOrmDataSource({
41
+ type: 'sqlite',
42
+ database: ':memory:',
43
+ synchronize: true,
44
+ dropSchema: true,
45
+ namingStrategy: new shell.NamingStrategy(),
46
+ entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, ...aiAssistant.entities],
47
+ logging: false
48
+ })
49
+ await dataSource!.initialize()
50
+ /* The door reads through shell's repositories - the same data source. */
51
+ shell.addDataSource('default', dataSource)
52
+
53
+ aiAssistant.registerAssistantMode({
54
+ kind: 'figure.modeller',
55
+ attachments: { kinds: ['image', 'text'] },
56
+ systemPrompt: 'You help a person author a figure.',
57
+ toolCategories: ['figure-authoring']
58
+ })
59
+ /* AUTH's field table is filled when the schema is built; the proposal door is declared open here. */
60
+ process['PRIVILEGE_OF_FIELD']['Mutation.proposeFigure'] = null
61
+ })
62
+
63
+ afterAll(async () => {
64
+ shell.removeDataSource('default')
65
+ await dataSource?.destroy()
66
+ dataSource = null
67
+ aiAssistant.clearAssistantModes()
68
+ aiClientBase.setAIClientForTesting(undefined)
69
+ })
70
+
71
+ beforeEach(async () => {
72
+ await dataSource!.synchronize(true)
73
+ DOMAIN = await dataSource!.getRepository(shell.Domain).save({ name: 'door-probe', subdomain: 'door-probe' })
74
+ USER = await dataSource!.getRepository(authBase.User).save({ name: 'author', email: 'author@door.probe', domains: [DOMAIN] } as any)
75
+
76
+ proposed.length = 0
77
+ modelCalls.length = 0
78
+ aiClientBase.clearToolRegistry()
79
+ __setFigureToolDepsForTest({
80
+ getRepository: () => ({ findOne: async () => null, find: async () => [] }),
81
+ Figure: class {},
82
+ proposeFigureSteps: async (input: any) => {
83
+ proposed.push(input)
84
+ const model = require('@hatiolab/figure-model')
85
+ return { steps: [{ kind: 'rename', name: 'Mixer' }], source: model.createFigureAsset('Mixer'), score: { grade: 'B' }, cost: { triangles: 12 }, attempts: 1 }
86
+ }
87
+ })
88
+ registerFigureToolCategories()
89
+
90
+ /* The conversation's model asks for a new figure once, then answers. */
91
+ let turn = 0
92
+ aiClientBase.setAIClientForTesting({
93
+ id: 'test:door',
94
+ chat: async () => '',
95
+ generateJSON: async () => ({}),
96
+ listModels: async () => [],
97
+ chatWithTools: async (messages: any[]) => {
98
+ modelCalls.push(messages)
99
+ return turn++ === 0
100
+ ? { toolCalls: [{ id: 'p-1', name: 'proposeFigureCandidate', arguments: { prompt: 'make it like the picture', create: true }, type: 'tool_use' }], stopReason: 'tool_use' }
101
+ : { text: 'A candidate is on the card.', toolCalls: [], stopReason: 'end_turn' }
102
+ }
103
+ })
104
+ })
105
+
106
+ /** A conversation about the open figure, and a picture stored with it as the dock's upload stores it. */
107
+ async function conversationWithPicture() {
108
+ const session = await dataSource!.getRepository(aiAssistant.ChatSession).save({
109
+ domain: DOMAIN,
110
+ kind: 'figure.modeller',
111
+ anchorType: 'figure',
112
+ anchorId: 'fig-1',
113
+ visibility: 'open',
114
+ creator: USER,
115
+ updater: USER
116
+ } as any)
117
+ /* As the database storage writes an upload: the path is `<id>.<extension>`, and a read finds the row by that id. */
118
+ const id = require('crypto').randomUUID()
119
+ const picture = await dataSource!.getRepository(attachmentBase.Attachment).save({
120
+ id,
121
+ domain: DOMAIN,
122
+ name: 'front.png',
123
+ mimetype: 'image/png',
124
+ encoding: '7bit',
125
+ category: 'chat',
126
+ refType: 'chat-session',
127
+ refBy: session.id,
128
+ path: `${id}.png`,
129
+ size: String(PNG.length),
130
+ contents: PNG
131
+ } as any)
132
+ return { session, picture }
133
+ }
134
+
135
+ const ask = (sessionId: string, attachmentIds: string[], hostContext: any = { figureId: 'fig-1' }) =>
136
+ new aiAssistant.AssistantChatResolver().assistantChat(
137
+ {
138
+ sessionId,
139
+ messages: [{ role: 'user', content: 'make it like this picture' }],
140
+ mode: { kind: 'figure.modeller', target: 'fig-1' },
141
+ hostContext,
142
+ attachmentIds
143
+ },
144
+ { state: { domain: DOMAIN, user: USER } }
145
+ )
146
+
147
+ describe('a picture sent in the dock', () => {
148
+ it('★ reaches the step generator through the door - the bytes stored with the conversation', async () => {
149
+ const { session, picture } = await conversationWithPicture()
150
+
151
+ const answer = await ask(session.id, [picture.id])
152
+
153
+ expect(answer.proposals).toHaveLength(1)
154
+ expect(proposed).toHaveLength(1)
155
+ expect(proposed[0].images).toHaveLength(1)
156
+ expect(Buffer.from(proposed[0].images[0].data).equals(PNG)).toBe(true)
157
+ expect(proposed[0].images[0].mediaType).toBe('image/png')
158
+ })
159
+
160
+ it('the conversation\'s model reads the same picture on the question', async () => {
161
+ const { session, picture } = await conversationWithPicture()
162
+
163
+ await ask(session.id, [picture.id])
164
+
165
+ const question = modelCalls[0]!.filter((message: any) => message.role === 'user').pop()
166
+ const image = (question.content as any[]).find(part => part.type === 'image')
167
+ expect(Buffer.from(image.source.data).equals(PNG)).toBe(true)
168
+ })
169
+
170
+ it('★ a picture the screen put in the host context does not reach the generator - only what the door checked', async () => {
171
+ const { session } = await conversationWithPicture()
172
+
173
+ await ask(session.id, [], { figureId: 'fig-1', image: { data: PNG.toString('base64'), mediaType: 'image/png' } })
174
+
175
+ expect(proposed).toHaveLength(1)
176
+ expect(proposed[0].images).toBeUndefined()
177
+ })
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
+ })