@things-factory/ai-assistant 10.1.99 → 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.
Files changed (37) hide show
  1. package/client/components/entry-review.ts +19 -4
  2. package/client/components/workspace-view.ts +6 -1
  3. package/client/utils/entry-proposal.ts +12 -2
  4. package/dist-client/components/entry-review.d.ts +1 -1
  5. package/dist-client/components/entry-review.js +21 -5
  6. package/dist-client/components/entry-review.js.map +1 -1
  7. package/dist-client/components/workspace-view.d.ts +1 -1
  8. package/dist-client/components/workspace-view.js +7 -2
  9. package/dist-client/components/workspace-view.js.map +1 -1
  10. package/dist-client/tsconfig.tsbuildinfo +1 -1
  11. package/dist-client/utils/entry-proposal.d.ts +2 -0
  12. package/dist-client/utils/entry-proposal.js +11 -1
  13. package/dist-client/utils/entry-proposal.js.map +1 -1
  14. package/dist-server/service/data-entry/entry-apply.js +27 -1
  15. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  16. package/dist-server/service/data-entry/entry-target.d.ts +69 -6
  17. package/dist-server/service/data-entry/entry-target.js +159 -12
  18. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  19. package/dist-server/service/data-entry/entry-tools.d.ts +13 -1
  20. package/dist-server/service/data-entry/entry-tools.js +27 -10
  21. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  22. package/dist-server/service/data-entry/entry-update.js +3 -3
  23. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  24. package/dist-server/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +2 -2
  26. package/server/service/data-entry/data-entry-update.test.ts +107 -1
  27. package/server/service/data-entry/data-entry.test.ts +41 -4
  28. package/server/service/data-entry/entry-apply.ts +26 -3
  29. package/server/service/data-entry/entry-target.ts +190 -17
  30. package/server/service/data-entry/entry-tools.ts +29 -10
  31. package/server/service/data-entry/entry-update.ts +4 -3
  32. package/test/entry-proposal.test.ts +11 -0
  33. package/translations/en.json +9 -1
  34. package/translations/ja.json +9 -1
  35. package/translations/ko.json +9 -1
  36. package/translations/ms.json +9 -1
  37. package/translations/zh.json +9 -1
@@ -22,6 +22,9 @@ import {
22
22
  entryToolName,
23
23
  isBlocked,
24
24
  normalizeEntryRows,
25
+ entryGroups,
26
+ referenceDoorsOf,
27
+ resolveReferences,
25
28
  recordEntryEvent,
26
29
  withCheckedIssues,
27
30
  type EntryField,
@@ -35,10 +38,12 @@ function fieldSchema(field: EntryField): any {
35
38
  * whole request with it: "12,400" or "₩12,400" is the model's to write as 12400 (2026-10-01, office).
36
39
  */
37
40
  const type = field.type === 'number' ? 'number' : field.type === 'integer' ? 'integer' : field.type === 'boolean' ? 'boolean' : 'string'
41
+ if (field.type === 'reference') return { type: 'string', description: [field.description, field.required ? 'required' : 'optional', 'the name or code as the material or the person gives it - the server finds the record'].filter(Boolean).join('. ') }
38
42
  const said = [
39
43
  field.description,
40
44
  field.required ? 'required' : 'optional',
41
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,
42
47
  field.type === 'enum' ? `one of: ${field.values!.join(', ')}` : undefined
43
48
  ]
44
49
  .filter(Boolean)
@@ -61,7 +66,7 @@ export type SourceTextLoader = (attachmentIds: string[], context: any) => Promis
61
66
 
62
67
  const readSources: SourceTextLoader = async (ids, context) => (await import('./entry-sources.js')).loadSourceTexts(ids, context)
63
68
 
64
- export async function proposeEntries(target: EntryTarget, args: any, context: any, loadTexts: SourceTextLoader = readSources) {
69
+ export async function proposeEntries(target: EntryTarget, args: any, context: any, loadTexts: SourceTextLoader = readSources, run?: GraphqlRunner) {
65
70
  const tool = entryToolName(target.key)
66
71
  const told = (event: Omit<Parameters<typeof recordEntryEvent>[1], 'phase' | 'tool'>) => recordEntryEvent(target, { phase: 'propose', tool, ...event }, { state: context?.state })
67
72
  const { rows: normalized, cut } = normalizeEntryRows(target, args?.rows)
@@ -74,7 +79,9 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
74
79
  const declared = args?.declaredTotal && typeof args.declaredTotal === 'object' ? args.declaredTotal : undefined
75
80
  const cited = [...normalized.flatMap(row => row.sources.map(source => source.attachmentId)), declared?.attachmentId].filter((id): id is string => typeof id === 'string' && !!id)
76
81
  const texts = cited.length ? await loadTexts(cited, context).catch(() => new Map<string, string>()) : new Map<string, string>()
77
- const read = verifyRowsAgainstSources(normalized, target.fields, texts)
82
+ /* A name the model wrote for another record is settled to its id by the application's query, as the person. */
83
+ const resolved = await resolveReferences(target.fields, normalized, run ?? runAsCaller(context))
84
+ const read = verifyRowsAgainstSources(resolved, target.fields, texts)
78
85
 
79
86
  let rows: EntryRow[] = read
80
87
  let checkFailed: string | undefined
@@ -87,6 +94,9 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
87
94
  }
88
95
 
89
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 } : {}
90
100
  const totals = entryTotals(rows, target.fields, declared, texts)
91
101
  const verified = rows.filter(row => row.evidence).length
92
102
  const unmatched = rows.filter(row => row.issues.some(issue => issue.code === 'not-in-source')).length
@@ -100,7 +110,8 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
100
110
  icon: 'playlist_add',
101
111
  columns: columnsOf(target),
102
112
  rows,
103
- 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 } : {}),
104
115
  ...(totals.length ? { totals } : {}),
105
116
  ...(typeof args?.note === 'string' && args.note.trim() ? { reason: args.note.trim().slice(0, 300) } : {}),
106
117
  ...(checkFailed ? { checkFailed } : {}),
@@ -138,7 +149,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
138
149
  kind: 'read',
139
150
  name: changeToolName(target.key),
140
151
  /* The person must be able to read the records and change them by hand. */
141
- doors: [rootFieldOf(update.mutation, 'mutation'), readDoor],
152
+ doors: [...new Set([rootFieldOf(update.mutation, 'mutation'), readDoor, ...referenceDoorsOf(changeableFields(target))])],
142
153
  description:
143
154
  `Propose changes to existing records: ${target.description} ` +
144
155
  'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
@@ -183,9 +194,10 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
183
194
  kind: 'read',
184
195
  name,
185
196
  /* The person must be able to enter these records themselves - the tool is not a second way in. */
186
- doors: [entryDoorOf(target.mutation)],
197
+ doors: [entryDoorOf(target.mutation), ...referenceDoorsOf(target.fields)],
187
198
  description:
188
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}. ` : '') +
189
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.',
190
202
  schema: {
191
203
  type: 'object',
@@ -202,8 +214,15 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
202
214
  },
203
215
  sources: {
204
216
  type: 'array',
205
- description: 'Where this row came from: the attachment id and where in it ("row 3", "the receipt photo"), or where: "the person\'s message".',
206
- items: { type: 'object', properties: { attachmentId: { type: 'string' }, where: { type: 'string' } } }
217
+ description: 'Where this row came from: the attachment id and where in it, and { person: true } for a value the person said.',
218
+ items: {
219
+ type: 'object',
220
+ properties: {
221
+ attachmentId: { type: 'string' },
222
+ where: { type: 'string', description: 'Where in the attachment, in the person\'s language ("3번째 줄", "영수증 사진").' },
223
+ person: { type: 'boolean', description: 'true when the value came from what the person said in the conversation.' }
224
+ }
225
+ }
207
226
  }
208
227
  },
209
228
  required: ['values', 'sources']
@@ -223,7 +242,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
223
242
  },
224
243
  required: ['rows']
225
244
  },
226
- builder: (args: any, context: any) => proposeEntries(target, args, context)
245
+ builder: (args: any, context: any) => proposeEntries(target, args, context, readSources, run(context))
227
246
  } as ToolSpec
228
247
 
229
248
  return {
@@ -232,8 +251,8 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
232
251
  guidance: [
233
252
  `- When the person gives material to enter as records (files, photos, pasted text), read all of it first - call readChatAttachment for a file sent in an earlier turn - then call ${name} once with every record as a row.`,
234
253
  '- Never invent a value. Leave a field out when the material does not say it; the card marks it, and you ask the person for it.',
235
- '- Give each row its sources: which attachment and where in it. A value the person gave or corrected in the conversation adds the source where: "the person\'s message". The server looks for each row\'s numbers and dates in the cited file and marks what it does not find.',
236
- '- When the material prints a total, pass it as declaredTotal exactly as printed - do not add it up yourself.',
254
+ '- Give each row its sources: which attachment and where in it, written in the person\'s language. A value the person gave or corrected in the conversation adds the source { person: true }. The server looks for each row\'s numbers and dates in the cited file and marks what it does not find.',
255
+ '- When the material prints a total, always pass it as declaredTotal exactly as printed - do not add it up yourself. The server also reads a total line of a text file itself.',
237
256
  `- ${name} only proposes. Nothing is entered until the person presses the button on the card - never say the records were entered.`,
238
257
  `- When the person corrects or drops rows, call ${name} again with the whole corrected set. The newest card replaces the earlier ones; an earlier card can no longer be entered.`,
239
258
  ...(target.update
@@ -13,6 +13,7 @@ import {
13
13
  changeToolName,
14
14
  findToolName,
15
15
  readFieldValue,
16
+ resolveReferences,
16
17
  sameFieldValue,
17
18
  type EntryField,
18
19
  type EntryIssue,
@@ -85,7 +86,7 @@ export async function readRecords(target: EntryTarget, ids: string[], run: Graph
85
86
  const update = target.update!
86
87
  const answer = await run(update.read.query, update.read.variables([...new Set(ids)]))
87
88
  if (answer.errors?.length) throw new Error(answer.errors.map(one => one.message).join('; '))
88
- return new Map((update.read.recordsOf(answer.data) ?? []).filter(record => record && typeof record.id === 'string').map(record => [record.id, record]))
89
+ return new Map((update.read.recordsOf(answer.data, [...new Set(ids)]) ?? []).filter(record => record && typeof record.id === 'string').map(record => [record.id, record]))
89
90
  }
90
91
 
91
92
  /** The change rows against what the records hold now. */
@@ -146,7 +147,7 @@ export async function proposeChanges(target: EntryTarget, args: any, context: an
146
147
  } catch (error: any) {
147
148
  return { rejected: true, error: 'read-failed', message: `could not read the records: ${error?.message ?? error}` }
148
149
  }
149
- const rows = changeRows(target, picked, records)
150
+ const rows = await resolveReferences(changeableFields(target), changeRows(target, picked, records), run)
150
151
  const blocked = rows.filter(blockedChange).length
151
152
  return {
152
153
  proposed: true,
@@ -181,7 +182,7 @@ export async function findRecords(target: EntryTarget, args: any, run: GraphqlRu
181
182
  }
182
183
  const answer = await run(find.query, find.variables(criteria))
183
184
  if (answer.errors?.length) return { rejected: true, error: 'find-failed', message: answer.errors.map(one => one.message).join('; ') }
184
- const found = (find.recordsOf(answer.data) ?? []).filter(record => record && typeof record.id === 'string')
185
+ const found = (find.recordsOf(answer.data, criteria) ?? []).filter(record => record && typeof record.id === 'string')
185
186
  return {
186
187
  total: found.length,
187
188
  candidates: found.slice(0, MAX_CANDIDATES).map(record => ({ id: record.id, ...(target.update?.labelOf ? { label: target.update.labelOf(record) } : {}), record })),
@@ -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
+
@@ -267,5 +267,13 @@
267
267
  "ai-assistant.entry.issue.changed-since": "changed since the card was made",
268
268
  "ai-assistant.entry.issue.no-change": "already that value",
269
269
  "ai-assistant.entry.issue.nothing-to-change": "nothing to change",
270
- "ai-assistant.entry.issue.not-changeable": "this field cannot be changed - left out"
270
+ "ai-assistant.entry.issue.not-changeable": "this field cannot be changed - left out",
271
+ "ai-assistant.entry.source-person": "said in the conversation",
272
+ "ai-assistant.entry.total-declared-file": "the file's total line {sum}",
273
+ "ai-assistant.entry.issue.reference-not-found": "\"{text}\" - not found",
274
+ "ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" - matches several: {candidates}",
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",
277
+ "ai-assistant.entry.groups": "{count} records",
278
+ "ai-assistant.entry.groups-kept-out": "{count} records kept out whole"
271
279
  }
@@ -267,5 +267,13 @@
267
267
  "ai-assistant.entry.issue.changed-since": "その間に変更されました",
268
268
  "ai-assistant.entry.issue.no-change": "すでにその値です",
269
269
  "ai-assistant.entry.issue.nothing-to-change": "修正する項目がありません",
270
- "ai-assistant.entry.issue.not-changeable": "修正できない項目のため除外しました"
270
+ "ai-assistant.entry.issue.not-changeable": "修正できない項目のため除外しました",
271
+ "ai-assistant.entry.source-person": "会話で伝えた値",
272
+ "ai-assistant.entry.total-declared-file": "資料の合計行 {sum}",
273
+ "ai-assistant.entry.issue.reference-not-found": "「{text}」— 見つかりません",
274
+ "ai-assistant.entry.issue.reference-ambiguous": "「{text}」— 複数に一致: {candidates}",
275
+ "ai-assistant.entry.issue.reference-unreadable": "「{text}」— 検索できませんでした",
276
+ "ai-assistant.entry.issue.not-a-reference": "参照先の記録が分かりません",
277
+ "ai-assistant.entry.groups": "{count}件",
278
+ "ai-assistant.entry.groups-kept-out": "{count}件は行が止まり全体を入力しません"
271
279
  }
@@ -267,5 +267,13 @@
267
267
  "ai-assistant.entry.issue.changed-since": "그사이 바뀌었습니다",
268
268
  "ai-assistant.entry.issue.no-change": "이미 그 값입니다",
269
269
  "ai-assistant.entry.issue.nothing-to-change": "고칠 칸이 없습니다",
270
- "ai-assistant.entry.issue.not-changeable": "고칠 수 없는 칸이라 뺐습니다"
270
+ "ai-assistant.entry.issue.not-changeable": "고칠 수 없는 칸이라 뺐습니다",
271
+ "ai-assistant.entry.source-person": "대화에서 말한 값",
272
+ "ai-assistant.entry.total-declared-file": "자료의 합계 줄 {sum}",
273
+ "ai-assistant.entry.issue.reference-not-found": "\"{text}\" — 찾지 못했습니다",
274
+ "ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" — 여럿과 맞습니다: {candidates}",
275
+ "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 찾아보지 못했습니다",
276
+ "ai-assistant.entry.issue.not-a-reference": "가리키는 기록을 알 수 없습니다",
277
+ "ai-assistant.entry.groups": "{count}건",
278
+ "ai-assistant.entry.groups-kept-out": "{count}건은 줄이 막혀 통째로 입력하지 않습니다"
271
279
  }
@@ -267,5 +267,13 @@
267
267
  "ai-assistant.entry.issue.changed-since": "telah berubah sejak kad dibuat",
268
268
  "ai-assistant.entry.issue.no-change": "sudah nilai itu",
269
269
  "ai-assistant.entry.issue.nothing-to-change": "tiada yang perlu diubah",
270
- "ai-assistant.entry.issue.not-changeable": "medan ini tidak boleh diubah - ditinggalkan"
270
+ "ai-assistant.entry.issue.not-changeable": "medan ini tidak boleh diubah - ditinggalkan",
271
+ "ai-assistant.entry.source-person": "dinyatakan dalam perbualan",
272
+ "ai-assistant.entry.total-declared-file": "baris jumlah fail {sum}",
273
+ "ai-assistant.entry.issue.reference-not-found": "\"{text}\" - tidak ditemui",
274
+ "ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" - sepadan dengan beberapa: {candidates}",
275
+ "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - tidak dapat dicari",
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"
271
279
  }
@@ -267,5 +267,13 @@
267
267
  "ai-assistant.entry.issue.changed-since": "在此期间已被修改",
268
268
  "ai-assistant.entry.issue.no-change": "已是该值",
269
269
  "ai-assistant.entry.issue.nothing-to-change": "没有要修改的字段",
270
- "ai-assistant.entry.issue.not-changeable": "此字段不可修改,已忽略"
270
+ "ai-assistant.entry.issue.not-changeable": "此字段不可修改,已忽略",
271
+ "ai-assistant.entry.source-person": "对话中提供的值",
272
+ "ai-assistant.entry.total-declared-file": "资料合计行 {sum}",
273
+ "ai-assistant.entry.issue.reference-not-found": "\"{text}\" — 未找到",
274
+ "ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" — 匹配多个:{candidates}",
275
+ "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 无法查找",
276
+ "ai-assistant.entry.issue.not-a-reference": "无法确定所指记录",
277
+ "ai-assistant.entry.groups": "{count}条",
278
+ "ai-assistant.entry.groups-kept-out": "{count}条因有行被拦而整体不录入"
271
279
  }