@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,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
|
+
})
|