@things-factory/ai-assistant 10.1.100 → 10.1.101

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.100",
3
+ "version": "10.1.101",
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": "e3c2444ded42290fdc77b189f5befa419f575f9b"
54
+ "gitHead": "44abc5cac98642b8028b17bf3e019d083539ccab"
55
55
  }
@@ -265,3 +265,49 @@ describe('a field that points at another record', () => {
265
265
  })
266
266
  })
267
267
 
268
+ describe('a record with lines', () => {
269
+ const advice: EntryTarget = {
270
+ key: 'warehouse.inbound-advice',
271
+ labelKey: 'x',
272
+ description: 'one inbound advice line.',
273
+ mutation: 'mutation ($advice: InboundAdviceInput!) { receiveInboundAdvice(advice: $advice) { name } }',
274
+ fields: [
275
+ { name: 'despatchAdvice', type: 'string', required: true },
276
+ { name: 'epcClass', type: 'string', required: true },
277
+ { name: 'quantity', type: 'number', required: true }
278
+ ],
279
+ group: {
280
+ by: 'despatchAdvice',
281
+ variables: rows => ({ advice: { name: rows[0].values.despatchAdvice, lines: rows.map(row => ({ epcClass: row.values.epcClass, quantity: row.values.quantity })) } }),
282
+ appliedOf: data => ({ id: data?.receiveInboundAdvice?.name })
283
+ }
284
+ }
285
+ const line = (despatchAdvice: string, epcClass: string, quantity: any) => ({ values: { despatchAdvice, epcClass, quantity }, sources: [{ where: 'x' }] })
286
+
287
+ it('★ the lines of one record go in by one call; a record with a line kept out stays out whole', async () => {
288
+ declareEntryTarget(advice)
289
+ const proposed: any = await proposeEntries(advice, { rows: [line('DA-1', 'A', 20), line('DA-1', 'B', 5), line('DA-2', 'C', 'many'), line('DA-2', 'D', 1)] }, { state: {} }, async () => new Map(), run)
290
+ expect(proposed.counts).toMatchObject({ rows: 4, groups: 2, groupsReady: 1 })
291
+ expect(proposed.groupBy).toBe('despatchAdvice')
292
+ const sent: any[] = []
293
+ const outcome = await applyEntryProposal('g1', {
294
+ messages: [{ id: 'm1', proposals: [{ ...proposed, key: 'g1' }] }],
295
+ answered: false,
296
+ passesDoor: async () => true,
297
+ execute: async (_document, variables) => (sent.push(variables), { data: { receiveInboundAdvice: { name: variables.advice.name } } }),
298
+ state: {}
299
+ })
300
+ expect(sent).toEqual([{ advice: { name: 'DA-1', lines: [{ epcClass: 'A', quantity: 20 }, { epcClass: 'B', quantity: 5 }] } }])
301
+ expect(outcome.rows).toEqual([
302
+ { index: 0, ok: true, id: 'DA-1' },
303
+ { index: 1, ok: true, id: 'DA-1' },
304
+ { index: 2, ok: false, error: 'quantity: not-a-number' },
305
+ { index: 3, ok: false, error: 'group-kept-out (row 3)' }
306
+ ])
307
+ })
308
+
309
+ it('a group by a field that is not declared is refused at boot', () => {
310
+ expect(() => declareEntryTarget({ ...advice, group: { ...advice.group!, by: 'order' } })).toThrow(/group by 'order'/)
311
+ })
312
+ })
313
+
@@ -341,3 +341,17 @@ describe('the model names the total its own way', () => {
341
341
  })
342
342
  })
343
343
 
344
+ describe('a moment', () => {
345
+ const shift: EntryTarget = { ...expense, fields: [{ name: 'startTime', type: 'datetime', required: true }] }
346
+ it('reads the ways a moment is written - without an offset it stays without one', () => {
347
+ const { rows } = normalizeEntryRows(shift, [
348
+ { values: { startTime: '2026-10-01 08:30' }, sources: [{ where: 'x' }] },
349
+ { values: { startTime: '2026.10.1 8:05:09' }, sources: [{ where: 'x' }] },
350
+ { values: { startTime: '2026-10-01T08:30:00+0900' }, sources: [{ where: 'x' }] },
351
+ { values: { startTime: '2026-10-01 25:00' }, sources: [{ where: 'x' }] }
352
+ ])
353
+ expect(rows.map(row => row.values.startTime)).toEqual(['2026-10-01T08:30:00', '2026-10-01T08:05:09', '2026-10-01T08:30:00+09:00', '2026-10-01 25:00'])
354
+ expect(rows[3].issues).toEqual([{ field: 'startTime', code: 'not-a-datetime', blocking: true }])
355
+ })
356
+ })
357
+
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { PROPOSAL_OUTCOME_PREFIX, PROPOSAL_VERDICTS } from '../chat-message/proposal-outcome.js'
14
14
  import { readRecords, stillAsProposed, type ChangeRow } from './entry-update.js'
15
- import { changeToolName, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
15
+ import { changeToolName, entryGroups, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
16
16
 
17
17
  export interface StoredProposalMessage {
18
18
  id: string
@@ -97,14 +97,35 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
97
97
  }
98
98
 
99
99
  const results: EntryRowResult[] = []
100
- for (const row of rows) {
100
+ if (target.group) {
101
+ /* One call per group; a group with a row kept out stays out whole - an order missing a line is not entered. */
102
+ for (const [, members] of entryGroups(rows, target.group.by)) {
103
+ const blocked = members.find(isBlocked)
104
+ if (blocked) {
105
+ const issue = blocked.issues.find(one => one.blocking)!
106
+ for (const row of members)
107
+ results.push({ index: row.index, ok: false, error: row === blocked ? (issue.field ? `${issue.field}: ${issue.code}` : issue.code) : `group-kept-out (row ${blocked.index + 1})` })
108
+ continue
109
+ }
110
+ try {
111
+ const answer = await deps.execute(target.mutation, target.group.variables(members))
112
+ const error = answer.errors?.length ? answer.errors.map(one => one.message).join('; ') || 'refused' : undefined
113
+ const applied = error ? undefined : target.group.appliedOf?.(answer.data, members)
114
+ for (const row of members) results.push(error ? { index: row.index, ok: false, error } : { index: row.index, ok: true, ...(applied?.id ? { id: applied.id } : {}), ...(applied?.label ? { label: applied.label } : {}) })
115
+ } catch (error: any) {
116
+ for (const row of members) results.push({ index: row.index, ok: false, error: error?.message || 'refused' })
117
+ }
118
+ }
119
+ results.sort((a, b) => a.index - b.index)
120
+ }
121
+ for (const row of target.group ? [] : rows) {
101
122
  if (isBlocked(row)) {
102
123
  const issue = row.issues.find(one => one.blocking)!
103
124
  results.push({ index: row.index, ok: false, error: issue.field ? `${issue.field}: ${issue.code}` : issue.code })
104
125
  continue
105
126
  }
106
127
  try {
107
- const answer = await deps.execute(target.mutation, target.variables(row))
128
+ const answer = await deps.execute(target.mutation, target.variables!(row))
108
129
  if (answer.errors?.length) {
109
130
  results.push({ index: row.index, ok: false, error: answer.errors.map(one => one.message).join('; ') || 'refused' })
110
131
  continue
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { parse } from 'graphql'
16
16
 
17
- export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'boolean' | 'enum' | 'reference'
17
+ export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'datetime' | 'boolean' | 'enum' | 'reference'
18
18
 
19
19
  export interface EntryField {
20
20
  name: string
@@ -127,10 +127,20 @@ export interface EntryTarget {
127
127
  * to a person typing it in.
128
128
  */
129
129
  mutation: string
130
- /** The mutation's variables for one row. */
131
- variables(row: EntryRow): Record<string, unknown>
130
+ /** The mutation's variables for one row. Absent when the target enters rows by group (below). */
131
+ variables?(row: EntryRow): Record<string, unknown>
132
132
  /** What the conversation says of an entered row - its id and a short name. */
133
133
  appliedOf?(data: any, row: EntryRow): EntryApplied | undefined
134
+ /**
135
+ * A record with lines - one order, several item lines. Rows that share the value of `by` (the order number) are one
136
+ * record, entered by one call of the mutation with `variables(rows)`. If any row of a group is kept out, the whole
137
+ * group is: an order missing a line must not go in unseen.
138
+ */
139
+ group?: {
140
+ by: string
141
+ variables(rows: EntryRow[]): Record<string, unknown>
142
+ appliedOf?(data: any, rows: EntryRow[]): EntryApplied | undefined
143
+ }
134
144
  /** At most this many rows in one proposal. Default 200. */
135
145
  maxRows?: number
136
146
  /**
@@ -214,7 +224,10 @@ export function declareEntryTarget(target: EntryTarget): void {
214
224
  } catch (error: any) {
215
225
  throw new Error(`[ai-assistant] entry target '${target.key}': its mutation ${error?.message ?? 'does not parse'}.`)
216
226
  }
217
- if (typeof target.variables !== 'function') throw new Error(`[ai-assistant] entry target '${target.key}' has no variables.`)
227
+ if (typeof target.variables !== 'function' && typeof target.group?.variables !== 'function')
228
+ throw new Error(`[ai-assistant] entry target '${target.key}' has no variables (per row, or per group).`)
229
+ if (target.group && !target.fields.some(field => field.name === target.group!.by))
230
+ throw new Error(`[ai-assistant] entry target '${target.key}': group by '${target.group.by}' is not a declared field.`)
218
231
  if (target.update) {
219
232
  for (const [what, document, operation] of [
220
233
  ['update.read', target.update.read?.query, 'query'],
@@ -308,6 +321,22 @@ function toDate(value: unknown): string | undefined {
308
321
  return `${match[1]}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
309
322
  }
310
323
 
324
+ /**
325
+ * A moment as `YYYY-MM-DDTHH:mm:ss`, with the offset when it was written - "2026-10-01 08:30", "2026.10.1 8:30",
326
+ * an ISO string. Without an offset it stays without one: the application reads it in its own time zone, as it
327
+ * reads a time typed into its own screen.
328
+ */
329
+ function toDateTime(value: unknown): string | undefined {
330
+ if (typeof value !== 'string') return undefined
331
+ const match = value.trim().match(/^(\d{4})[-./](\d{1,2})[-./](\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/)
332
+ if (!match) return undefined
333
+ const day = toDate(`${match[1]}-${match[2]}-${match[3]}`)
334
+ const [hour, minute, second] = [Number(match[4]), Number(match[5]), Number(match[6] ?? 0)]
335
+ if (!day || hour > 23 || minute > 59 || second > 59) return undefined
336
+ const offset = match[7] ? (match[7] === 'Z' ? 'Z' : match[7].replace(/^([+-]\d{2})(\d{2})$/, '$1:$2')) : ''
337
+ return `${day}T${String(hour).padStart(2, '0')}:${match[5]}:${String(second).padStart(2, '0')}${offset}`
338
+ }
339
+
311
340
  function toBoolean(value: unknown): boolean | undefined {
312
341
  if (typeof value === 'boolean') return value
313
342
  if (typeof value !== 'string') return undefined
@@ -332,6 +361,10 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
332
361
  const read = toDate(value)
333
362
  return read === undefined ? { code: 'not-a-date' } : { read }
334
363
  }
364
+ case 'datetime': {
365
+ const read = toDateTime(value)
366
+ return read === undefined ? { code: 'not-a-datetime' } : { read }
367
+ }
335
368
  case 'boolean': {
336
369
  const read = toBoolean(value)
337
370
  return read === undefined ? { code: 'not-a-boolean' } : { read }
@@ -452,6 +485,12 @@ export function writtenForms(type: EntryFieldType, value: unknown): string[] {
452
485
  }
453
486
  return [...forms].map(form => (negative ? `-${form}` : form))
454
487
  }
488
+ if (type === 'datetime') {
489
+ /* A moment is found by its day and its hh:mm - the file writes seconds and zones its own way. */
490
+ const match = typeof value === 'string' ? value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/) : null
491
+ if (!match) return []
492
+ return writtenForms('date', match[1])
493
+ }
455
494
  if (type === 'date') {
456
495
  const match = typeof value === 'string' ? value.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
457
496
  if (!match) return []
@@ -631,3 +670,14 @@ export async function resolveReferences<T extends { values: Record<string, unkno
631
670
  return rows
632
671
  }
633
672
 
673
+ /** The rows by group, in the order groups first appear - `[key, rows]`. A row without the key is a group of its own. */
674
+ export function entryGroups<T extends { index: number; values: Record<string, unknown> }>(rows: T[], by: string): [string, T[]][] {
675
+ const groups = new Map<string, T[]>()
676
+ for (const row of rows) {
677
+ const value = row.values[by]
678
+ const key = value === undefined || value === null || value === '' ? `\u0000row-${row.index}` : String(typeof value === 'object' ? (value as any).id ?? (value as any).text : value)
679
+ groups.set(key, [...(groups.get(key) ?? []), row])
680
+ }
681
+ return [...groups.entries()]
682
+ }
683
+
@@ -22,6 +22,7 @@ import {
22
22
  entryToolName,
23
23
  isBlocked,
24
24
  normalizeEntryRows,
25
+ entryGroups,
25
26
  referenceDoorsOf,
26
27
  resolveReferences,
27
28
  recordEntryEvent,
@@ -42,6 +43,7 @@ function fieldSchema(field: EntryField): any {
42
43
  field.description,
43
44
  field.required ? 'required' : 'optional',
44
45
  field.type === 'date' ? 'YYYY-MM-DD' : undefined,
46
+ field.type === 'datetime' ? 'YYYY-MM-DD HH:mm, with the offset only when the material gives one' : undefined,
45
47
  field.type === 'enum' ? `one of: ${field.values!.join(', ')}` : undefined
46
48
  ]
47
49
  .filter(Boolean)
@@ -92,6 +94,9 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
92
94
  }
93
95
 
94
96
  const blocked = rows.filter(isBlocked).length
97
+ /* With lines grouped into records, the card counts the records and says which are kept out whole. */
98
+ const groups = target.group ? entryGroups(rows, target.group.by) : undefined
99
+ const groupCounts = groups ? { groups: groups.length, groupsReady: groups.filter(([, members]) => !members.some(isBlocked)).length } : {}
95
100
  const totals = entryTotals(rows, target.fields, declared, texts)
96
101
  const verified = rows.filter(row => row.evidence).length
97
102
  const unmatched = rows.filter(row => row.issues.some(issue => issue.code === 'not-in-source')).length
@@ -105,7 +110,8 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
105
110
  icon: 'playlist_add',
106
111
  columns: columnsOf(target),
107
112
  rows,
108
- counts: { rows: rows.length, ready: rows.length - blocked, blocked, cut, verified, unmatched },
113
+ counts: { rows: rows.length, ready: rows.length - blocked, blocked, cut, verified, unmatched, ...groupCounts },
114
+ ...(target.group ? { groupBy: target.group.by } : {}),
109
115
  ...(totals.length ? { totals } : {}),
110
116
  ...(typeof args?.note === 'string' && args.note.trim() ? { reason: args.note.trim().slice(0, 300) } : {}),
111
117
  ...(checkFailed ? { checkFailed } : {}),
@@ -191,6 +197,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
191
197
  doors: [entryDoorOf(target.mutation), ...referenceDoorsOf(target.fields)],
192
198
  description:
193
199
  `Propose records to enter: ${target.description} ` +
200
+ (target.group ? `Each row is one line; the rows of one record share the same ${target.group.by}. ` : '') +
194
201
  'Pass every record found in the material as one row. The rows are shown to the person as a card; nothing is written until they press it.',
195
202
  schema: {
196
203
  type: 'object',
@@ -77,4 +77,15 @@ describe('the entry card', () => {
77
77
  'changed 1 records.\nrow 2: amount: changed-since'
78
78
  )
79
79
  })
80
+
81
+ it('★ the button counts only the lines of records that go in whole', () => {
82
+ const p = { ...card('g'), groupBy: 'order', rows: [
83
+ { index: 0, values: { order: 'A' }, sources: [], issues: [] },
84
+ { index: 1, values: { order: 'A' }, sources: [], issues: [] },
85
+ { index: 2, values: { order: 'B' }, sources: [], issues: [] },
86
+ { index: 3, values: { order: 'B' }, sources: [], issues: [{ code: 'required', field: 'qty', blocking: true }] }
87
+ ] }
88
+ expect(entryReadyCount(p)).toBe(2)
89
+ })
80
90
  })
91
+
@@ -273,5 +273,7 @@
273
273
  "ai-assistant.entry.issue.reference-not-found": "\"{text}\" - not found",
274
274
  "ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" - matches several: {candidates}",
275
275
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - could not be looked up",
276
- "ai-assistant.entry.issue.not-a-reference": "not a record it can point to"
276
+ "ai-assistant.entry.issue.not-a-reference": "not a record it can point to",
277
+ "ai-assistant.entry.groups": "{count} records",
278
+ "ai-assistant.entry.groups-kept-out": "{count} records kept out whole"
277
279
  }
@@ -273,5 +273,7 @@
273
273
  "ai-assistant.entry.issue.reference-not-found": "「{text}」— 見つかりません",
274
274
  "ai-assistant.entry.issue.reference-ambiguous": "「{text}」— 複数に一致: {candidates}",
275
275
  "ai-assistant.entry.issue.reference-unreadable": "「{text}」— 検索できませんでした",
276
- "ai-assistant.entry.issue.not-a-reference": "参照先の記録が分かりません"
276
+ "ai-assistant.entry.issue.not-a-reference": "参照先の記録が分かりません",
277
+ "ai-assistant.entry.groups": "{count}件",
278
+ "ai-assistant.entry.groups-kept-out": "{count}件は行が止まり全体を入力しません"
277
279
  }
@@ -273,5 +273,7 @@
273
273
  "ai-assistant.entry.issue.reference-not-found": "\"{text}\" — 찾지 못했습니다",
274
274
  "ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" — 여럿과 맞습니다: {candidates}",
275
275
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 찾아보지 못했습니다",
276
- "ai-assistant.entry.issue.not-a-reference": "가리키는 기록을 알 수 없습니다"
276
+ "ai-assistant.entry.issue.not-a-reference": "가리키는 기록을 알 수 없습니다",
277
+ "ai-assistant.entry.groups": "{count}건",
278
+ "ai-assistant.entry.groups-kept-out": "{count}건은 줄이 막혀 통째로 입력하지 않습니다"
277
279
  }
@@ -273,5 +273,7 @@
273
273
  "ai-assistant.entry.issue.reference-not-found": "\"{text}\" - tidak ditemui",
274
274
  "ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" - sepadan dengan beberapa: {candidates}",
275
275
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - tidak dapat dicari",
276
- "ai-assistant.entry.issue.not-a-reference": "bukan rekod yang boleh dirujuk"
276
+ "ai-assistant.entry.issue.not-a-reference": "bukan rekod yang boleh dirujuk",
277
+ "ai-assistant.entry.groups": "{count} rekod",
278
+ "ai-assistant.entry.groups-kept-out": "{count} rekod ditahan sepenuhnya"
277
279
  }
@@ -273,5 +273,7 @@
273
273
  "ai-assistant.entry.issue.reference-not-found": "\"{text}\" — 未找到",
274
274
  "ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" — 匹配多个:{candidates}",
275
275
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 无法查找",
276
- "ai-assistant.entry.issue.not-a-reference": "无法确定所指记录"
276
+ "ai-assistant.entry.issue.not-a-reference": "无法确定所指记录",
277
+ "ai-assistant.entry.groups": "{count}条",
278
+ "ai-assistant.entry.groups-kept-out": "{count}条因有行被拦而整体不录入"
277
279
  }