@things-factory/ai-assistant 10.1.176 → 10.1.177

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/ai-assistant",
3
- "version": "10.1.176",
3
+ "version": "10.1.177",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -34,7 +34,7 @@
34
34
  "@operato/popup": "^10.16.0",
35
35
  "@operato/shell": "^10.0.0",
36
36
  "@operato/styles": "^10.0.0",
37
- "@things-factory/ai-client-base": "^10.1.176",
37
+ "@things-factory/ai-client-base": "^10.1.177",
38
38
  "@things-factory/apptool-base": "^10.1.165",
39
39
  "@things-factory/attachment-base": "^10.1.176",
40
40
  "@things-factory/auth-base": "^10.1.176",
@@ -54,5 +54,5 @@
54
54
  "copyfiles": "^2.4.1",
55
55
  "rimraf": "^5.0.0"
56
56
  },
57
- "gitHead": "24928f127d73de0e9946423bd4c3295f54cce03d"
57
+ "gitHead": "91da09c945ec5e77ab960074db8f53b9ea8ac534"
58
58
  }
@@ -119,7 +119,8 @@ export class ChatMessage {
119
119
  * 로 parse 된 객체. UI 의 fold-able 박스 ("AI 가 이런 도구를 사용했습니다") 가
120
120
  * 페이지 reload / loadHistory 후에도 살아있도록 영속.
121
121
  *
122
- * 형태: ToolUsage[] (assistant.ts 의 시간순 trace, summarizeToolResult 로 압축).
122
+ * 형태: ToolUsage[] (시간순 trace). result 는 값이 아니라 모양(resultShapeOf — 크기 · 건수 · 키 이름 · 오류 코드,
123
+ * ADR-0061 결정 8). 급여 · 지분 값이 대화 행 · 백업 · 내보내기로 복제되지 않는다.
123
124
  * null 이면 도구 호출 없었던 메시지 (text-only 응답).
124
125
  */
125
126
  @Column({
@@ -4,8 +4,8 @@
4
4
  * card says which conditions it does not take.
5
5
  */
6
6
  import { modeToolCategories } from '../assistant-mode/resolve'
7
- import { clearReadTargets, declareReadTarget, groupsOf, MAX_READ_ROWS, queryToolName, readCategoryName, screenAddress, screenToolName, type ReadTarget } from './read-target'
8
- import { openScreen, queryRecords, readCategoryOf } from './read-tools'
7
+ import { clearReadTargets, declareDetailRead, declareReadTarget, detailToolName, groupsOf, MAX_DETAIL_CHARS, MAX_READ_ROWS, queryToolName, readCategoryName, screenAddress, screenToolName, type DetailRead, type ReadTarget } from './read-target'
8
+ import { detailCategoryOf, openScreen, queryDetail, queryRecords, readCategoryOf } from './read-tools'
9
9
 
10
10
  /* The application, as a fake: a bank ledger behind a list query that pages and filters like ListParam. */
11
11
  let ledger: any[]
@@ -264,18 +264,18 @@ describe('amounts in more than one currency', () => {
264
264
  })
265
265
 
266
266
  it('★ the card shows one figure and one group column per currency', async () => {
267
- ledger = [...Array.from({ length: 21 }, (_, i) => fxt(i, 1000, 'KRW', 'A')), fxt(99, 100, 'USD', 'B')]
267
+ ledger = [...Array.from({ length: 21 }, (_, i) => fxt(i, 1000, 'KRW', 'A')), fxt(98, 500, 'KRW', 'B'), fxt(99, 100, 'USD', 'B')]
268
268
  const result: any = await queryRecords(fx, { groupBy: 'customerName' }, { t: (key: string) => (key === 'field.amount' ? '금액' : key) }, run)
269
269
  const [metrics, byCustomer] = result.forScreen.workspace.blocks
270
270
  expect(metrics.items.map((one: any) => [one.label, one.value])).toEqual([
271
- ['rows', '22'],
272
- ['금액 (KRW)', '21000'],
271
+ ['rows', '23'],
272
+ ['금액 (KRW)', '21500'],
273
273
  ['금액 (USD)', '100']
274
274
  ])
275
275
  expect(byCustomer.columns.map((one: any) => one.name)).toEqual(['key', 'count', 'amount:KRW', 'amount:USD'])
276
276
  expect(byCustomer.rows).toEqual([
277
277
  { key: 'A', count: 21, 'amount:KRW': 21000, 'amount:USD': 0 },
278
- { key: 'B', count: 1, 'amount:KRW': 0, 'amount:USD': 100 }
278
+ { key: 'B', count: 2, 'amount:KRW': 500, 'amount:USD': 100 }
279
279
  ])
280
280
  })
281
281
 
@@ -301,3 +301,66 @@ describe('amounts in more than one currency', () => {
301
301
  expect(bad([{ name: 'amount', type: 'number', currency: 'currency' }, { name: 'currency', type: 'string' }])).toThrow(/not a summed number/)
302
302
  })
303
303
  })
304
+
305
+ describe('one answer of a query - a detail read', () => {
306
+ const summary: DetailRead = {
307
+ key: 'office.contract-360',
308
+ description: "one contract's summary - what it bills, what came in, what is still owed.",
309
+ query: 'query ($id: String!, $year: Int) { contract360(id: $id, year: $year) { code amount currency collected receivable rounds { round paid } } }',
310
+ args: [
311
+ { name: 'id', type: 'string', required: true, description: 'the contract id' },
312
+ { name: 'year', type: 'integer' }
313
+ ]
314
+ }
315
+ const seen: any[] = []
316
+ const answering = (value: any) => async (document: string, variables: any) => {
317
+ seen.push({ document, variables })
318
+ return { data: { contract360: value } }
319
+ }
320
+ beforeEach(() => {
321
+ seen.length = 0
322
+ })
323
+
324
+ it('★ the answer comes as the query returned it, with the arguments the model set in their types', async () => {
325
+ const value = { code: 'H2026012', amount: 100, currency: 'USD', collected: 40, receivable: 60, rounds: [{ round: 1, paid: 40 }] }
326
+ const result: any = await queryDetail(summary, { id: 'c-1', year: '2026' }, answering(value))
327
+ expect(seen[0].variables).toEqual({ id: 'c-1', year: 2026 })
328
+ expect(result).toMatchObject({ target: 'office.contract-360', answer: value })
329
+ expect(result.message).toMatch(/never add two currencies/)
330
+ })
331
+
332
+ it('a missing or unreadable argument is refused before anything is read', async () => {
333
+ const result: any = await queryDetail(summary, { year: 'last year' }, answering({}))
334
+ expect(seen).toHaveLength(0)
335
+ expect(result).toMatchObject({ rejected: true, error: 'argument-unreadable' })
336
+ expect(result.refused).toEqual([{ name: 'id', code: 'required' }, { name: 'year', code: 'not-an-integer' }])
337
+ })
338
+
339
+ it('★ a query the person may not run, nothing found, and an answer too long are each said as such', async () => {
340
+ expect(await queryDetail(summary, { id: 'c-1' }, async () => ({ errors: [{ message: 'unauthorized! office:query privilege required' }] }))).toMatchObject({ rejected: true, error: 'query-failed' })
341
+ expect(await queryDetail(summary, { id: 'c-1' }, answering(null))).toMatchObject({ found: false })
342
+ const long: any = await queryDetail(summary, { id: 'c-1' }, answering({ notes: 'x'.repeat(MAX_DETAIL_CHARS + 1) }))
343
+ expect(long).toMatchObject({ rejected: true, error: 'answer-too-large' })
344
+ expect(long.answer).toBeUndefined()
345
+ })
346
+
347
+ it('★ the tool is locked by the query\'s door, and a mode opens it by key like a list', () => {
348
+ const category = detailCategoryOf(summary, () => answering({}))
349
+ expect(category.name).toBe(readCategoryName(summary.key))
350
+ expect(category.specs.map(spec => [spec.name, (spec as any).doors])).toEqual([[detailToolName(summary.key), ['Query.contract360']]])
351
+ expect(detailToolName(summary.key)).toBe('readOfficeContract360')
352
+ expect((category.specs[0] as any).schema).toMatchObject({ required: ['id'], properties: { id: { type: 'string' }, year: { type: 'integer' } } })
353
+ expect(modeToolCategories({ readTargets: [summary.key] })).toContain('data-read:office.contract-360')
354
+ })
355
+
356
+ it('a declaration that would mislead is refused at boot', () => {
357
+ expect(() => declareDetailRead({ ...summary, key: 'office.a', args: [{ name: 'year', type: 'integer' }] })).toThrow(/takes \$id, which is no argument/)
358
+ expect(() => declareDetailRead({ ...summary, key: 'office.b', args: [{ name: 'id', type: 'string' }, { name: 'year', type: 'integer' }] })).toThrow(/\$id is required by the query/)
359
+ expect(() => declareDetailRead({ ...summary, key: 'office.c', args: [...summary.args!, { name: 'month', type: 'integer' }] })).toThrow(/declares 'month', which its query does not take/)
360
+ expect(() => declareDetailRead({ ...summary, key: 'office.d', query: 'query { a { x } b { y } }', args: [] })).toThrow(/exactly one root field/)
361
+ declareReadTarget(target)
362
+ expect(() => declareDetailRead({ ...summary, key: target.key })).toThrow(/declared twice/)
363
+ declareDetailRead(summary)
364
+ expect(() => declareReadTarget({ ...target, key: summary.key })).toThrow(/declared twice/)
365
+ })
366
+ })
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { readFieldValue, rootFieldOf, type EntryField } from '../data-entry/entry-target.js'
13
13
  import type { GraphqlRunner } from '../data-entry/entry-update.js'
14
+ import { parse } from 'graphql'
14
15
 
15
16
  export interface ReadFilter {
16
17
  name: string
@@ -101,7 +102,7 @@ export const readCategoryName = (key: string) => `data-read:${key}`
101
102
 
102
103
  export function declareReadTarget(target: ReadTarget): void {
103
104
  if (!/^[a-z0-9-]+\.[a-z0-9-]+$/.test(target.key)) throw new Error(`read target key '${target.key}' is not <app>.<record>`)
104
- if (registry.has(target.key)) throw new Error(`read target '${target.key}' is declared twice`)
105
+ if (registry.has(target.key) || details.has(target.key)) throw new Error(`read target '${target.key}' is declared twice`)
105
106
  rootFieldOf(target.query, 'query')
106
107
  if (!target.columns.length) throw new Error(`read target '${target.key}' shows no column`)
107
108
  for (const column of target.columns.filter(one => one.currency)) {
@@ -127,6 +128,69 @@ export function readTargetOf(key: string | undefined): ReadTarget | undefined {
127
128
 
128
129
  export function clearReadTargets(): void {
129
130
  registry.clear()
131
+ details.clear()
132
+ }
133
+
134
+ /* ── One answer of a query ──────────────────────────────────────────────────────────────────────────────────── */
135
+
136
+ /** An argument of a detail read - one variable of its query, read with the entry fields' rules. */
137
+ export interface DetailArg {
138
+ name: string
139
+ type: 'string' | 'integer' | 'number' | 'boolean' | 'date' | 'enum'
140
+ values?: string[]
141
+ description?: string
142
+ required?: boolean
143
+ }
144
+
145
+ /**
146
+ * A query whose answer is one thing, not a list - a 360's summary, the receivables, a statement (design/ai/read-targets.md
147
+ * §9). The application declares the query with the fields its screen reads; the framework runs it as the person, with
148
+ * the arguments the model set, and hands the answer over as the query returned it. Nothing is summed or cut here: the
149
+ * selection is the projection, and an answer too long to hand over is refused, never cut.
150
+ */
151
+ export interface DetailRead {
152
+ /** `<app>.<thing>` - the tool is named after it. */
153
+ key: string
154
+ labelKey?: string
155
+ /** What it answers, for the model. */
156
+ description: string
157
+ /** One query with one root field. Its variables are the arguments - each declared in `args`. */
158
+ query: string
159
+ args?: DetailArg[]
160
+ }
161
+
162
+ /** Characters of an answer handed to the model - a longer one is refused, and the model narrows it. */
163
+ export const MAX_DETAIL_CHARS = 60_000
164
+
165
+ const details = new Map<string, DetailRead>()
166
+
167
+ export const detailToolName = (key: string) => `read${pascalOf(key)}`
168
+
169
+ /** The variables a query declares, and whether each is required (`!`). */
170
+ function variablesOf(document: string): { name: string; required: boolean }[] {
171
+ const operation: any = parse(document).definitions.find((one: any) => one.kind === 'OperationDefinition')
172
+ return (operation?.variableDefinitions ?? []).map((one: any) => ({ name: one.variable.name.value, required: one.type.kind === 'NonNullType' && !one.defaultValue }))
173
+ }
174
+
175
+ export function declareDetailRead(target: DetailRead): void {
176
+ if (!/^[a-z0-9-]+\.[a-z0-9-]+$/.test(target.key)) throw new Error(`detail read key '${target.key}' is not <app>.<thing>`)
177
+ if (details.has(target.key) || registry.has(target.key)) throw new Error(`read target '${target.key}' is declared twice`)
178
+ rootFieldOf(target.query, 'query')
179
+ const args = target.args ?? []
180
+ for (const variable of variablesOf(target.query)) {
181
+ const arg = args.find(one => one.name === variable.name)
182
+ if (!arg) throw new Error(`detail read '${target.key}' takes $${variable.name}, which is no argument`)
183
+ if (variable.required && !arg.required) throw new Error(`detail read '${target.key}': $${variable.name} is required by the query - declare it required`)
184
+ }
185
+ for (const arg of args) {
186
+ if (!variablesOf(target.query).some(one => one.name === arg.name)) throw new Error(`detail read '${target.key}' declares '${arg.name}', which its query does not take`)
187
+ if (arg.type === 'enum' && !arg.values?.length) throw new Error(`argument '${arg.name}' of '${target.key}' lists no values`)
188
+ }
189
+ details.set(target.key, target)
190
+ }
191
+
192
+ export function detailReadOf(key: string | undefined): DetailRead | undefined {
193
+ return key ? details.get(key) : undefined
130
194
  }
131
195
 
132
196
  /** The conditions the model set, read by their declared types; a value that does not fit is said, never guessed at. */
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
10
10
 
11
- import { rootFieldOf } from '../data-entry/entry-target.js'
11
+ import { readFieldValue, rootFieldOf } from '../data-entry/entry-target.js'
12
12
  import type { GraphqlRunner } from '../data-entry/entry-update.js'
13
13
  import { runAsCaller } from '../data-entry/entry-tools.js'
14
14
  import { normalizeWorkspace, workspaceContents } from '../workspace/workspace-blocks.js'
@@ -16,6 +16,11 @@ import {
16
16
  declareReadTarget,
17
17
  filtersOf,
18
18
  currenciesOf,
19
+ declareDetailRead,
20
+ detailToolName,
21
+ MAX_DETAIL_CHARS,
22
+ type DetailArg,
23
+ type DetailRead,
19
24
  groupsOf,
20
25
  queryToolName,
21
26
  readCategoryName,
@@ -222,3 +227,72 @@ export function registerReadTarget(target: ReadTarget): void {
222
227
  declareReadTarget(target)
223
228
  registerToolCategory(readCategoryOf(target))
224
229
  }
230
+
231
+ /* ── One answer of a query (DetailRead) ───────────────────────────────────────────────────────────────────── */
232
+
233
+ function argSchema(arg: DetailArg): any {
234
+ const type = arg.type === 'number' ? 'number' : arg.type === 'integer' ? 'integer' : arg.type === 'boolean' ? 'boolean' : 'string'
235
+ const said = [arg.description, arg.type === 'date' ? 'YYYY-MM-DD' : undefined, arg.type === 'enum' ? `one of: ${arg.values!.join(', ')}` : undefined].filter(Boolean).join('. ')
236
+ return { type, ...(said ? { description: said } : {}) }
237
+ }
238
+
239
+ /** Runs the query as the person with the arguments the model set; the answer as the query returned it. */
240
+ export async function queryDetail(target: DetailRead, args: any, run: GraphqlRunner) {
241
+ const variables: Record<string, unknown> = {}
242
+ const refused: { name: string; code: string }[] = []
243
+ for (const arg of target.args ?? []) {
244
+ const value = args?.[arg.name]
245
+ if (value === undefined || value === null || value === '') {
246
+ if (arg.required) refused.push({ name: arg.name, code: 'required' })
247
+ continue
248
+ }
249
+ const { read, code } = readFieldValue({ name: arg.name, type: arg.type === 'string' ? 'text' : arg.type, values: arg.values } as any, value)
250
+ if (read === undefined) refused.push({ name: arg.name, code: code ?? 'unreadable' })
251
+ else variables[arg.name] = read
252
+ }
253
+ if (refused.length) return { rejected: true, error: 'argument-unreadable', refused, message: `these arguments are missing or do not fit their type: ${refused.map(one => `${one.name} (${one.code})`).join(', ')}. Fix them and call again.` }
254
+ let answer
255
+ try {
256
+ answer = await run(target.query, variables)
257
+ } catch (error: any) {
258
+ return { rejected: true, error: 'query-failed', message: `it could not be read: ${error?.message ?? error}` }
259
+ }
260
+ if (answer.errors?.length) return { rejected: true, error: 'query-failed', message: `it could not be read: ${answer.errors.map(one => one.message).join('; ')}` }
261
+ const root = rootFieldOf(target.query, 'query').replace(/^Query\./, '')
262
+ const value = answer.data?.[root]
263
+ if (value === null || value === undefined) return { target: target.key, found: false, message: 'nothing was found for these arguments - say so; do not fill it in.' }
264
+ const size = JSON.stringify(value).length
265
+ if (size > MAX_DETAIL_CHARS) return { rejected: true, error: 'answer-too-large', size, message: `the answer runs to ${size} characters, more than ${MAX_DETAIL_CHARS} - narrow the arguments, or read the list it is made of.` }
266
+ return { target: target.key, answer: value, message: 'answer from these fields; every number you say comes from them. Amounts are in the currency beside them - never add two currencies into one number.' }
267
+ }
268
+
269
+ export function detailCategoryOf(target: DetailRead, run: (context: any) => GraphqlRunner = runAsCaller): ToolCategory {
270
+ const door = rootFieldOf(target.query, 'query')
271
+ const name = detailToolName(target.key)
272
+ return {
273
+ name: readCategoryName(target.key),
274
+ description: `data read: ${target.key}`,
275
+ guidance: `- To answer about ${target.description.replace(/\.$/, '')}, call ${name}. Every number you say comes from its answer.`,
276
+ specs: [
277
+ {
278
+ kind: 'read',
279
+ name,
280
+ /* The person must be able to run the query on its screen - the tool is not a second way in. */
281
+ doors: [door],
282
+ description: `Read ${target.description}`,
283
+ schema: {
284
+ type: 'object',
285
+ ...((target.args ?? []).some(one => one.required) ? { required: (target.args ?? []).filter(one => one.required).map(one => one.name) } : {}),
286
+ properties: Object.fromEntries((target.args ?? []).map(arg => [arg.name, argSchema(arg)]))
287
+ },
288
+ builder: (args: any, context: any) => queryDetail(target, args, run(context))
289
+ } as ToolSpec
290
+ ]
291
+ }
292
+ }
293
+
294
+ /** What an application calls at boot to let the assistant read one answer of its API. A mode opens it by key in `readTargets`. */
295
+ export function registerDetailRead(target: DetailRead): void {
296
+ declareDetailRead(target)
297
+ registerToolCategory(detailCategoryOf(target))
298
+ }
@@ -31,7 +31,7 @@ export * from './data-entry/entry-target.js'
31
31
  export { registerEntryTarget, entryCategoryOf } from './data-entry/entry-tools.js'
32
32
  /* Reading an application's lists - an app registers what it lets read; a mode names it (design/ai/read-targets.md). */
33
33
  export * from './data-read/read-target.js'
34
- export { registerReadTarget, readCategoryOf } from './data-read/read-tools.js'
34
+ export { registerReadTarget, readCategoryOf, registerDetailRead, detailCategoryOf } from './data-read/read-tools.js'
35
35
  export * from './data-read/list-read.js'
36
36
  /* The AI 작업판 - large results on a page of their own (design/ai/workspace.md). */
37
37
  export * from './workspace/workspace-blocks.js'