@things-factory/ai-assistant 10.1.97 → 10.1.99

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 (26) hide show
  1. package/client/components/entry-review.ts +10 -4
  2. package/dist-client/components/entry-review.js +8 -2
  3. package/dist-client/components/entry-review.js.map +1 -1
  4. package/dist-client/tsconfig.tsbuildinfo +1 -1
  5. package/dist-server/service/data-entry/entry-target.d.ts +2 -0
  6. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  7. package/dist-server/service/data-entry/entry-tools.d.ts +1 -0
  8. package/dist-server/service/data-entry/entry-tools.js +13 -3
  9. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  10. package/dist-server/service/data-entry/entry-update.d.ts +1 -0
  11. package/dist-server/service/data-entry/entry-update.js +1 -1
  12. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  13. package/dist-server/service/workspace/workspace-blocks.js +9 -1
  14. package/dist-server/service/workspace/workspace-blocks.js.map +1 -1
  15. package/dist-server/service/workspace/workspace-tools.js +43 -1
  16. package/dist-server/service/workspace/workspace-tools.js.map +1 -1
  17. package/dist-server/tsconfig.tsbuildinfo +1 -1
  18. package/package.json +2 -2
  19. package/server/service/data-entry/data-entry-update.test.ts +15 -0
  20. package/server/service/data-entry/data-entry.test.ts +9 -0
  21. package/server/service/data-entry/entry-target.ts +2 -0
  22. package/server/service/data-entry/entry-tools.ts +13 -3
  23. package/server/service/data-entry/entry-update.ts +1 -1
  24. package/server/service/workspace/workspace-blocks.ts +8 -1
  25. package/server/service/workspace/workspace-tools.ts +43 -1
  26. package/server/service/workspace/workspace.test.ts +16 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.97",
3
+ "version": "10.1.99",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -51,5 +51,5 @@
51
51
  "copyfiles": "^2.4.1",
52
52
  "rimraf": "^5.0.0"
53
53
  },
54
- "gitHead": "79bebdf2ce31c5e82134757678be2cc1bb6e4dec"
54
+ "gitHead": "15e86d38824d6d2af94043eb14a928232a8924a3"
55
55
  }
@@ -190,3 +190,18 @@ describe('declaring and offering', () => {
190
190
  expect(rows[0].issues).toEqual([{ code: 'record-not-found', blocking: true }])
191
191
  })
192
192
  })
193
+
194
+ describe('the tool schemas', () => {
195
+ /* Gemini's function schema refuses a list of types - one such field failed every office conversation (2026-10-01). */
196
+ const typesOf = (schema: any, path = ''): string[] =>
197
+ !schema || typeof schema !== 'object'
198
+ ? []
199
+ : [
200
+ ...(Array.isArray(schema.type) ? [`${path}: ${JSON.stringify(schema.type)}`] : []),
201
+ ...Object.entries(schema).flatMap(([key, value]) => (typeof value === 'object' ? typesOf(value, `${path}/${key}`) : []))
202
+ ]
203
+
204
+ it('★ every field has one type - no list of types anywhere in the tools', () => {
205
+ for (const spec of entryCategoryOf(expense, () => run).specs as any[]) expect(typesOf(spec.schema)).toEqual([])
206
+ })
207
+ })
@@ -309,3 +309,12 @@ describe('telling the application', () => {
309
309
  expect(result.proposed).toBe(true)
310
310
  })
311
311
  })
312
+
313
+ describe('the card speaks the person\'s words', () => {
314
+ it('an enum field hands its value key prefix to the card - PERSONAL_CARD shows as the app\'s own word for it', async () => {
315
+ const target: EntryTarget = { ...expense, fields: [...expense.fields, { name: 'paidBy', type: 'enum', values: ['PERSONAL_CARD'], valueKeyPrefix: 'text.expense-payment-' }] }
316
+ const result: any = await proposeEntries(target, { rows: [{ values: { spentOn: '2026-09-12', amount: 1, paidBy: 'PERSONAL_CARD' }, sources: [{ where: 'x' }] }] }, { state: {} }, async () => new Map())
317
+ expect(result.columns.find((one: any) => one.name === 'paidBy')).toMatchObject({ valueKeyPrefix: 'text.expense-payment-' })
318
+ })
319
+ })
320
+
@@ -28,6 +28,8 @@ export interface EntryField {
28
28
  labelKey?: string
29
29
  /** A number field the card sums - and compares with the total the material prints (§ totals). */
30
30
  total?: boolean
31
+ /** An enum's values on the card: `<prefix><value in lowercase>` - `text.expense-payment-` shows PERSONAL_CARD as 개인카드. */
32
+ valueKeyPrefix?: string
31
33
  }
32
34
 
33
35
  /** Where a row came from - which file, and where in it. Shown on the card beside the row. */
@@ -30,7 +30,11 @@ import {
30
30
  } from './entry-target.js'
31
31
 
32
32
  function fieldSchema(field: EntryField): any {
33
- const type = field.type === 'number' || field.type === 'integer' ? ['number', 'string'] : field.type === 'boolean' ? ['boolean', 'string'] : 'string'
33
+ /*
34
+ * One type per field - Gemini's function schema (an OpenAPI 3.0 subset) refuses a list of types, and the
35
+ * whole request with it: "12,400" or "₩12,400" is the model's to write as 12400 (2026-10-01, office).
36
+ */
37
+ const type = field.type === 'number' ? 'number' : field.type === 'integer' ? 'integer' : field.type === 'boolean' ? 'boolean' : 'string'
34
38
  const said = [
35
39
  field.description,
36
40
  field.required ? 'required' : 'optional',
@@ -44,7 +48,13 @@ function fieldSchema(field: EntryField): any {
44
48
 
45
49
  /** What the card needs besides the rows - the columns, in the target's order. */
46
50
  function columnsOf(target: EntryTarget) {
47
- return target.fields.map(field => ({ name: field.name, type: field.type, required: !!field.required, ...(field.labelKey ? { labelKey: field.labelKey } : {}) }))
51
+ return target.fields.map(field => ({
52
+ name: field.name,
53
+ type: field.type,
54
+ required: !!field.required,
55
+ ...(field.labelKey ? { labelKey: field.labelKey } : {}),
56
+ ...(field.valueKeyPrefix ? { valueKeyPrefix: field.valueKeyPrefix } : {})
57
+ }))
48
58
  }
49
59
 
50
60
  export type SourceTextLoader = (attachmentIds: string[], context: any) => Promise<Map<string, string>>
@@ -205,7 +215,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
205
215
  description: 'The total the material itself prints, when it prints one - the card compares it with the sum of the rows. Never compute it yourself.',
206
216
  properties: {
207
217
  field: { type: 'string', description: `Which field it totals${target.fields.filter(field => field.total).length ? ` (${target.fields.filter(field => field.total).map(field => field.name).join(', ')})` : ''}.` },
208
- value: { type: ['number', 'string'] },
218
+ value: { type: 'number' },
209
219
  attachmentId: { type: 'string' },
210
220
  where: { type: 'string' }
211
221
  }
@@ -125,7 +125,7 @@ function changedColumns(target: EntryTarget, rows: ChangeRow[]) {
125
125
  const used = new Set(rows.flatMap(row => Object.keys(row.values)))
126
126
  return changeableFields(target)
127
127
  .filter(field => used.has(field.name))
128
- .map(field => ({ name: field.name, type: field.type, ...(field.labelKey ? { labelKey: field.labelKey } : {}) }))
128
+ .map(field => ({ name: field.name, type: field.type, ...(field.labelKey ? { labelKey: field.labelKey } : {}), ...(field.valueKeyPrefix ? { valueKeyPrefix: field.valueKeyPrefix } : {}) }))
129
129
  }
130
130
 
131
131
  export async function proposeChanges(target: EntryTarget, args: any, context: any, run: GraphqlRunner) {
@@ -75,8 +75,15 @@ function block(raw: any): WorkspaceBlock {
75
75
  if (!columns.length) return unreadable(kind, 'no-columns')
76
76
  const given = Array.isArray(raw.rows) ? raw.rows : []
77
77
  /* A row is an object by column name, or an array in column order - models write both. */
78
+ /* A number column reads "12,400" as 12400 - cells come as text from a model told one type per field. */
79
+ const cell = (column: any, value: unknown) => {
80
+ const read = scalar(value)
81
+ if (column.type !== 'number' || typeof read !== 'string') return read
82
+ const number = Number(read.replace(/[,\s₩$€]/g, ''))
83
+ return read.trim() && Number.isFinite(number) ? number : read
84
+ }
78
85
  const rows = given.slice(0, MAX_TABLE_ROWS).map((row: any) =>
79
- Object.fromEntries(columns.map((column: any, i: number) => [column.name, scalar(Array.isArray(row) ? row[i] : row?.[column.name])]))
86
+ Object.fromEntries(columns.map((column: any, i: number) => [column.name, cell(column, Array.isArray(row) ? row[i] : row?.[column.name])]))
80
87
  )
81
88
  return { kind, columns, rows, ...(text(raw.caption, 300) ? { caption: text(raw.caption, 300) } : {}), ...(given.length > MAX_TABLE_ROWS ? { cut: given.length - MAX_TABLE_ROWS } : {}) }
82
89
  }
@@ -41,9 +41,51 @@ const spec = {
41
41
  name: { type: 'string', description: 'A short key, lowercase with dashes ("contracts-by-customer"). The same name again makes a new version of the same workspace.' },
42
42
  title: { type: 'string' },
43
43
  summary: { type: 'string', description: 'One line under the title.' },
44
+ /*
45
+ * Every field of every block is declared, each with one type: Gemini's function schema (an OpenAPI 3.0
46
+ * subset) refuses a list of types and may leave out a field it was not told of (2026-10-01).
47
+ */
44
48
  blocks: {
45
49
  type: 'array',
46
- items: { type: 'object', properties: { kind: { type: 'string', enum: [...WORKSPACE_BLOCK_KINDS] } }, required: ['kind'] }
50
+ items: {
51
+ type: 'object',
52
+ properties: {
53
+ kind: { type: 'string', enum: [...WORKSPACE_BLOCK_KINDS] },
54
+ text: { type: 'string', description: 'heading' },
55
+ level: { type: 'integer', description: 'heading: 1-3' },
56
+ markdown: { type: 'string', description: 'text' },
57
+ caption: { type: 'string', description: 'table, chart' },
58
+ unit: { type: 'string', description: 'chart' },
59
+ chart: { type: 'string', enum: ['bar', 'line'], description: 'chart' },
60
+ items: {
61
+ type: 'array',
62
+ description: 'metrics: {label,value,unit,note}; steps: {label,state,note}',
63
+ items: {
64
+ type: 'object',
65
+ properties: {
66
+ label: { type: 'string' },
67
+ value: { type: 'string' },
68
+ unit: { type: 'string' },
69
+ note: { type: 'string' },
70
+ state: { type: 'string', enum: ['done', 'doing', 'todo', 'failed'] }
71
+ }
72
+ }
73
+ },
74
+ columns: {
75
+ type: 'array',
76
+ description: 'table',
77
+ items: { type: 'object', properties: { name: { type: 'string' }, label: { type: 'string' }, type: { type: 'string', enum: ['number', 'date', 'string'] } } }
78
+ },
79
+ rows: { type: 'array', description: 'table: each row is its cells in column order', items: { type: 'array', items: { type: 'string' } } },
80
+ x: { type: 'array', description: 'chart: the labels along the bottom', items: { type: 'string' } },
81
+ series: {
82
+ type: 'array',
83
+ description: 'chart',
84
+ items: { type: 'object', properties: { name: { type: 'string' }, values: { type: 'array', items: { type: 'number' } } } }
85
+ }
86
+ },
87
+ required: ['kind']
88
+ }
47
89
  }
48
90
  },
49
91
  required: ['name', 'title', 'blocks']
@@ -55,4 +55,20 @@ describe('the workspace', () => {
55
55
  expect(showInWorkspace({ blocks: [{ kind: 'text', markdown: 'x' }] })).toMatchObject({ rejected: true, error: 'no-title' })
56
56
  expect(showInWorkspace({ title: 'x', blocks: [] })).toMatchObject({ rejected: true, error: 'no-blocks' })
57
57
  })
58
+
59
+ it('a number column reads the text a model writes - "12,400" is 12400, a word stays a word', () => {
60
+ const table: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'table', columns: [{ name: 'a', type: 'number' }], rows: [['12,400'], ['n/a']] }] }).workspace!.blocks[0]
61
+ expect(table.rows).toEqual([{ a: 12400 }, { a: 'n/a' }])
62
+ })
63
+
64
+ it('★ every field of the tool has one type, and every object says its fields - what Gemini accepts', () => {
65
+ const walk = (schema: any, path: string, out: string[]) => {
66
+ if (!schema || typeof schema !== 'object') return out
67
+ if (Array.isArray(schema.type)) out.push(`${path}: a list of types`)
68
+ if (schema.type === 'object' && !Object.keys(schema.properties ?? {}).length) out.push(`${path}: an object with no fields`)
69
+ for (const [key, value] of Object.entries(schema)) if (value && typeof value === 'object') walk(value, `${path}/${key}`, out)
70
+ return out
71
+ }
72
+ expect(walk((workspaceCategory.specs[0] as any).schema, '', [])).toEqual([])
73
+ })
58
74
  })