@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.99",
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": "15e86d38824d6d2af94043eb14a928232a8924a3"
54
+ "gitHead": "44abc5cac98642b8028b17bf3e019d083539ccab"
55
55
  }
@@ -3,7 +3,7 @@
3
3
  * and what is asked again before a record is changed.
4
4
  */
5
5
  import { clearEntryTargets, declareEntryTarget, changeToolName, findToolName, type EntryTarget } from './entry-target'
6
- import { entryCategoryOf } from './entry-tools'
6
+ import { entryCategoryOf, proposeEntries } from './entry-tools'
7
7
  import { changeRows, findRecords, proposeChanges, resolveChangeRefs, stillAsProposed } from './entry-update'
8
8
  import { applyEntryProposal, type EntryApplyDeps } from './entry-apply'
9
9
 
@@ -205,3 +205,109 @@ describe('the tool schemas', () => {
205
205
  for (const spec of entryCategoryOf(expense, () => run).specs as any[]) expect(typesOf(spec.schema)).toEqual([])
206
206
  })
207
207
  })
208
+
209
+ describe('a field that points at another record', () => {
210
+ const loans: Record<string, any> = { a: { id: 'loan-a', name: '기업은행 운전자금' }, b: { id: 'loan-b', name: '중진공 시설자금' }, c: { id: 'loan-c', name: '기업은행 시설자금' } }
211
+ const lookup = async (document: string, variables: any) => {
212
+ calls.push({ document, variables })
213
+ if (document.includes('loans(')) return { data: { loans: Object.values(loans).filter(one => one.name.includes(variables.text)) } }
214
+ return run(document, variables)
215
+ }
216
+ const repayment: EntryTarget = {
217
+ key: 'office.loan-repayment',
218
+ labelKey: 'x',
219
+ description: 'one repayment.',
220
+ mutation: 'mutation ($r: NewLoanRepayment!) { createLoanRepayment(loanRepayment: $r) { id } }',
221
+ variables: row => ({ r: { ...row.values, loan: { id: (row.values.loan as any).id } } }),
222
+ fields: [
223
+ { name: 'loan', type: 'reference', required: true, reference: { query: 'query ($text: String) { loans(text: $text) { id name } }', variables: text => ({ text }), recordsOf: (data, text) => data.loans.filter((one: any) => one.name.includes(text)).map((one: any) => ({ id: one.id, label: one.name })) } },
224
+ { name: 'dueDate', type: 'date', required: true },
225
+ { name: 'principal', type: 'number' }
226
+ ]
227
+ }
228
+
229
+ it('★ one match keeps its id and name; none or several keep the row out, several with their names - the server never picks', async () => {
230
+ const result: any = await proposeEntries(
231
+ repayment,
232
+ { rows: [{ values: { loan: '중진공', dueDate: '2026-10-25', principal: 1 }, sources: [{ where: 'x' }] }, { values: { loan: '기업은행', dueDate: '2026-10-25' }, sources: [{ where: 'x' }] }, { values: { loan: '신한', dueDate: '2026-10-25' }, sources: [{ where: 'x' }] }] },
233
+ { state: {} },
234
+ async () => new Map(),
235
+ lookup
236
+ )
237
+ expect(result.rows[0].values.loan).toEqual({ id: 'loan-b', label: '중진공 시설자금' })
238
+ expect(result.rows[0].issues).toEqual([])
239
+ expect(result.rows[1].issues).toEqual([{ field: 'loan', code: 'reference-ambiguous', params: { text: '기업은행', candidates: '기업은행 운전자금, 기업은행 시설자금' }, blocking: true }])
240
+ expect(result.rows[2].issues).toEqual([{ field: 'loan', code: 'reference-not-found', params: { text: '신한' }, blocking: true }])
241
+ expect(result.counts).toMatchObject({ ready: 1, blocked: 2 })
242
+ })
243
+
244
+ it("★ the tool carries the reference query's door too - reading the names is the person's privilege", () => {
245
+ expect((entryCategoryOf(repayment, () => lookup).specs[0] as any).doors).toEqual(['Mutation.createLoanRepayment', 'Query.loans'])
246
+ })
247
+
248
+ it('a declaration whose reference has no query is refused at boot', () => {
249
+ expect(() => declareEntryTarget({ ...repayment, fields: [{ name: 'loan', type: 'reference' }] as any })).toThrow(/reference field 'loan'/)
250
+ })
251
+
252
+ it('the entering passes the resolved id, never the name', async () => {
253
+ declareEntryTarget(repayment)
254
+ const proposed: any = await proposeEntries(repayment, { rows: [{ values: { loan: '중진공', dueDate: '2026-10-25' }, sources: [{ where: 'x' }] }] }, { state: {} }, async () => new Map(), lookup)
255
+ const sent: any[] = []
256
+ const outcome = await applyEntryProposal('r1', {
257
+ messages: [{ id: 'm1', proposals: [{ ...proposed, key: 'r1' }] }],
258
+ answered: false,
259
+ passesDoor: async () => true,
260
+ execute: async (document, variables) => (document.includes('createLoanRepayment') ? (sent.push(variables), { data: { createLoanRepayment: { id: 'x' } } }) : lookup(document, variables)),
261
+ state: {}
262
+ })
263
+ expect(outcome).toMatchObject({ verdict: 'accepted', applied: 1 })
264
+ expect(sent[0].r.loan).toEqual({ id: 'loan-b' })
265
+ })
266
+ })
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
+
@@ -2,7 +2,7 @@
2
2
  * Data entry from material (design/ai/data-entry.md) - the rules a row passes, what the tool hands back,
3
3
  * and what the server asks again before it writes.
4
4
  */
5
- import { clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
5
+ import { clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
6
6
  import { entryCategoryOf, proposeEntries } from './entry-tools'
7
7
  import { applyEntryProposal, entryProposalAnswered, findEntryProposal, type EntryApplyDeps } from './entry-apply'
8
8
  import { modeToolCategories } from '../assistant-mode/resolve'
@@ -237,6 +237,7 @@ describe('checking the rows against the material', () => {
237
237
  texts
238
238
  )
239
239
  expect(row.issues).toEqual([{ code: 'source-scattered', blocking: false }])
240
+ expect(row.sources).toEqual([{ attachmentId: 'a1', where: 'row 2' }, { person: true }])
240
241
  })
241
242
 
242
243
  it('a photo cannot be read here - the row says it was not checked', () => {
@@ -251,10 +252,12 @@ describe('checking the rows against the material', () => {
251
252
  { values: { amount: 500 }, sources: [{ where: 'x' }] }
252
253
  ])
253
254
  expect(entryTotals(rows, expense.fields, { field: 'amount', value: '21,400', attachmentId: 'a1' }, texts)).toEqual([
254
- { field: 'amount', all: 21900, ready: 21400, declared: 21400, declaredFound: true, difference: -500 }
255
+ { field: 'amount', all: 21900, ready: 21400, declared: 21400, declaredFound: true, difference: -500, declaredBy: 'model' }
255
256
  ])
256
257
  expect(entryTotals(rows, expense.fields, { value: 30000, attachmentId: 'a1' }, texts)[0]).toMatchObject({ declared: 30000, declaredFound: false, difference: 8100 })
257
- expect(entryTotals(rows, expense.fields, undefined, texts)).toEqual([{ field: 'amount', all: 21900, ready: 21400 }])
258
+ /* No total from the model: the server reads the file's own total line. */
259
+ expect(entryTotals(rows, expense.fields, undefined, texts)).toEqual([{ field: 'amount', all: 21900, ready: 21400, declared: 21400, declaredFound: true, declaredBy: 'file', difference: -500 }])
260
+ expect(entryTotals(rows, expense.fields, undefined, new Map())).toEqual([{ field: 'amount', all: 21900, ready: 21400 }])
258
261
  })
259
262
 
260
263
  it('the tool reads the cited files and puts the check and the totals on the card', async () => {
@@ -274,7 +277,7 @@ describe('checking the rows against the material', () => {
274
277
  expect(asked[0]).toEqual(['a1', 'a1', 'a1'])
275
278
  expect(result.counts).toMatchObject({ verified: 1, unmatched: 1 })
276
279
  expect(result.rows[0].evidence).toEqual({ attachmentId: 'a1', line: 2 })
277
- expect(result.totals).toEqual([{ field: 'amount', all: 21900, ready: 21900, declared: 21400, declaredFound: true, difference: -500 }])
280
+ expect(result.totals).toEqual([{ field: 'amount', all: 21900, ready: 21900, declared: 21400, declaredFound: true, difference: -500, declaredBy: 'model' }])
278
281
  expect(result.message).toMatch(/differs from the rows by -500/)
279
282
  })
280
283
  })
@@ -318,3 +321,37 @@ describe('the card speaks the person\'s words', () => {
318
321
  })
319
322
  })
320
323
 
324
+ describe('what the screen says for the model', () => {
325
+ it("★ the model's \"the person's message\" becomes the person flag - the screen words it, the model's English never shows", () => {
326
+ const { rows } = normalizeEntryRows(expense, [{ values: { spentOn: '2026-09-12', amount: 1 }, sources: [{ where: "the person's message" }, { person: true }, { attachmentId: 'a1', where: '3번째 줄' }] }])
327
+ expect(rows[0].sources).toEqual([{ person: true }, { person: true }, { attachmentId: 'a1', where: '3번째 줄' }])
328
+ })
329
+
330
+ it('reads a printed total line - the last amount on a line named 합계 or total', () => {
331
+ expect(printedTotalOf('a,1\n합계,,"50,900"')).toEqual({ value: 50900, line: 2 })
332
+ expect(printedTotalOf('Total amount: 1,234.50 KRW')).toEqual({ value: 1234.5, line: 1 })
333
+ expect(printedTotalOf('nothing like it here, 100')).toBeUndefined()
334
+ })
335
+ })
336
+
337
+ describe('the model names the total its own way', () => {
338
+ it("★ with one totalled field, the model's total counts whatever field name it wrote - 이용금액 is amount", () => {
339
+ const { rows } = normalizeEntryRows(expense, [{ values: { spentOn: '2026-09-12', amount: 100 }, sources: [{ where: 'x' }] }])
340
+ expect(entryTotals(rows, expense.fields, { field: '이용금액', value: '100' }, new Map())).toEqual([{ field: 'amount', all: 100, ready: 100, declared: 100, difference: 0, declaredBy: 'model' }])
341
+ })
342
+ })
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, 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
@@ -85,6 +85,8 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
85
85
 
86
86
  /* Read again from the stored values - the same rules, and today's records for the target's checks. */
87
87
  let { rows } = normalizeEntryRows(target, (proposal.rows ?? []).map((row: EntryRow) => ({ values: row.values, sources: row.sources })))
88
+ /* A reference that was not settled on the card is asked again - never entered as a name. */
89
+ rows = await resolveReferences(target.fields, rows, deps.execute)
88
90
  if (target.check) {
89
91
  try {
90
92
  rows = withCheckedIssues(rows, await target.check(rows, { state: deps.state }))
@@ -95,14 +97,35 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
95
97
  }
96
98
 
97
99
  const results: EntryRowResult[] = []
98
- 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) {
99
122
  if (isBlocked(row)) {
100
123
  const issue = row.issues.find(one => one.blocking)!
101
124
  results.push({ index: row.index, ok: false, error: issue.field ? `${issue.field}: ${issue.code}` : issue.code })
102
125
  continue
103
126
  }
104
127
  try {
105
- const answer = await deps.execute(target.mutation, target.variables(row))
128
+ const answer = await deps.execute(target.mutation, target.variables!(row))
106
129
  if (answer.errors?.length) {
107
130
  results.push({ index: row.index, ok: false, error: answer.errors.map(one => one.message).join('; ') || 'refused' })
108
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'
17
+ export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'datetime' | 'boolean' | 'enum' | 'reference'
18
18
 
19
19
  export interface EntryField {
20
20
  name: string
@@ -30,13 +30,35 @@ export interface EntryField {
30
30
  total?: boolean
31
31
  /** An enum's values on the card: `<prefix><value in lowercase>` - `text.expense-payment-` shows PERSONAL_CARD as 개인카드. */
32
32
  valueKeyPrefix?: string
33
+ /**
34
+ * A `reference` field points at another record - a loan, an item, a location. The model knows it by a name or
35
+ * a code; the server finds it through the application's query, as the person, and keeps its id. One match is
36
+ * taken; none or several keep the row out and say so - the server never picks among several.
37
+ */
38
+ reference?: EntryReference
39
+ }
40
+
41
+ export interface EntryReference {
42
+ query: string
43
+ /** The query's variables for what the model wrote - "기업은행", "A-100". */
44
+ variables(text: string): Record<string, unknown>
45
+ /** The records the answer holds that the text names - the application filters, the server does not guess. */
46
+ recordsOf(data: any, text: string): { id: string; label?: string }[]
47
+ }
48
+
49
+ /** A resolved reference, as a row keeps it. */
50
+ export interface ReferenceValue {
51
+ id: string
52
+ label?: string
33
53
  }
34
54
 
35
55
  /** Where a row came from - which file, and where in it. Shown on the card beside the row. */
36
56
  export interface EntrySource {
37
57
  attachmentId?: string
38
- /** "row 3", "the receipt photo", "the person's message" - the model's words. */
58
+ /** "row 3", "the receipt photo" - the model's words, in the person's language. */
39
59
  where?: string
60
+ /** The value came from what the person said in the conversation - shown in the screen's own words, never the model's. */
61
+ person?: boolean
40
62
  }
41
63
 
42
64
  export interface EntryIssue {
@@ -105,10 +127,20 @@ export interface EntryTarget {
105
127
  * to a person typing it in.
106
128
  */
107
129
  mutation: string
108
- /** The mutation's variables for one row. */
109
- 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>
110
132
  /** What the conversation says of an entered row - its id and a short name. */
111
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
+ }
112
144
  /** At most this many rows in one proposal. Default 200. */
113
145
  maxRows?: number
114
146
  /**
@@ -135,9 +167,9 @@ export type EntryRecord = { id: string } & Record<string, unknown>
135
167
  */
136
168
  export interface EntryUpdate {
137
169
  /** Reads the records by id, as the person - one they may not see is not found. */
138
- read: { query: string; variables(ids: string[]): Record<string, unknown>; recordsOf(data: any): EntryRecord[] }
170
+ read: { query: string; variables(ids: string[]): Record<string, unknown>; recordsOf(data: any, ids: string[]): EntryRecord[] }
139
171
  /** Finds candidates from what the person said - "last week's 12,400 taxi". */
140
- find?: { query: string; variables(criteria: Record<string, unknown>): Record<string, unknown>; recordsOf(data: any): EntryRecord[]; criteria: EntryField[] }
172
+ find?: { query: string; variables(criteria: Record<string, unknown>): Record<string, unknown>; recordsOf(data: any, criteria: Record<string, unknown>): EntryRecord[]; criteria: EntryField[] }
141
173
  /** The application's own mutation that changes one record. */
142
174
  mutation: string
143
175
  variables(change: { id: string; record: EntryRecord; values: Record<string, unknown> }): Record<string, unknown>
@@ -177,6 +209,9 @@ export async function recordEntryEvent(target: EntryTarget, event: EntryEvent, c
177
209
 
178
210
  export const DEFAULT_MAX_ROWS = 200
179
211
 
212
+ /* The model's way of saying a value came from the person - kept only to read older rows and loose words. */
213
+ const PERSON = /person|message|user|사용자|말/i
214
+
180
215
  const targets = new Map<string, EntryTarget>()
181
216
 
182
217
  const KEY = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/
@@ -189,7 +224,10 @@ export function declareEntryTarget(target: EntryTarget): void {
189
224
  } catch (error: any) {
190
225
  throw new Error(`[ai-assistant] entry target '${target.key}': its mutation ${error?.message ?? 'does not parse'}.`)
191
226
  }
192
- 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.`)
193
231
  if (target.update) {
194
232
  for (const [what, document, operation] of [
195
233
  ['update.read', target.update.read?.query, 'query'],
@@ -208,6 +246,13 @@ export function declareEntryTarget(target: EntryTarget): void {
208
246
  if (!target.fields?.length) throw new Error(`[ai-assistant] entry target '${target.key}' declares no fields.`)
209
247
  const names = new Set<string>()
210
248
  for (const field of target.fields) {
249
+ if (field.type === 'reference') {
250
+ try {
251
+ rootFieldOf(field.reference?.query ?? '', 'query')
252
+ } catch (error: any) {
253
+ throw new Error(`[ai-assistant] entry target '${target.key}': reference field '${field.name}' ${error?.message ?? 'has no query'}.`)
254
+ }
255
+ }
211
256
  if (names.has(field.name)) throw new Error(`[ai-assistant] entry target '${target.key}' declares '${field.name}' twice.`)
212
257
  names.add(field.name)
213
258
  if (field.type === 'enum' && !field.values?.length)
@@ -276,6 +321,22 @@ function toDate(value: unknown): string | undefined {
276
321
  return `${match[1]}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
277
322
  }
278
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
+
279
340
  function toBoolean(value: unknown): boolean | undefined {
280
341
  if (typeof value === 'boolean') return value
281
342
  if (typeof value !== 'string') return undefined
@@ -300,12 +361,21 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
300
361
  const read = toDate(value)
301
362
  return read === undefined ? { code: 'not-a-date' } : { read }
302
363
  }
364
+ case 'datetime': {
365
+ const read = toDateTime(value)
366
+ return read === undefined ? { code: 'not-a-datetime' } : { read }
367
+ }
303
368
  case 'boolean': {
304
369
  const read = toBoolean(value)
305
370
  return read === undefined ? { code: 'not-a-boolean' } : { read }
306
371
  }
307
372
  case 'enum':
308
373
  return field.values!.includes(String(value).trim()) ? { read: String(value).trim() } : { code: 'not-in-list' }
374
+ case 'reference':
375
+ /* Resolved already ({ id }), or a name to look up ({ text }) - resolveReferences settles the second. */
376
+ if (value && typeof value === 'object' && typeof (value as any).id === 'string') return { read: { id: (value as any).id, ...((value as any).label ? { label: String((value as any).label) } : {}) } }
377
+ if (value && typeof value === 'object' && typeof (value as any).text === 'string') return { read: { text: (value as any).text } }
378
+ return typeof value === 'string' || typeof value === 'number' ? { read: { text: String(value).trim() } } : { code: 'not-a-reference' }
309
379
  default:
310
380
  return { read: String(value).trim() }
311
381
  }
@@ -315,6 +385,10 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
315
385
  export function sameFieldValue(field: EntryField, a: unknown, b: unknown): boolean {
316
386
  if (empty(a) && empty(b)) return true
317
387
  if (empty(a) || empty(b)) return false
388
+ if (field.type === 'reference') {
389
+ const idOf = (v: any) => (v && typeof v === 'object' ? v.id ?? v.text : String(v))
390
+ return idOf(a) === idOf(b)
391
+ }
318
392
  const x = readFieldValue(field, a)
319
393
  const y = readFieldValue(field, b)
320
394
  return x.read !== undefined && y.read !== undefined ? x.read === y.read : String(a) === String(b)
@@ -357,11 +431,15 @@ export function normalizeEntryRows(target: EntryTarget, input: unknown): { rows:
357
431
 
358
432
  const sources: EntrySource[] = Array.isArray(raw?.sources)
359
433
  ? raw.sources
360
- .filter((one: any) => one && (one.attachmentId || one.where))
361
- .map((one: any) => ({
362
- ...(typeof one.attachmentId === 'string' ? { attachmentId: one.attachmentId } : {}),
363
- ...(typeof one.where === 'string' ? { where: one.where.slice(0, 120) } : {})
364
- }))
434
+ .filter((one: any) => one && (one.attachmentId || one.where || one.person === true))
435
+ .map((one: any): EntrySource => {
436
+ /* "the person's message" written by the model is the person flag - the screen says it in its own words. */
437
+ if (one.person === true || (!one.attachmentId && typeof one.where === 'string' && PERSON.test(one.where))) return { person: true }
438
+ return {
439
+ ...(typeof one.attachmentId === 'string' ? { attachmentId: one.attachmentId } : {}),
440
+ ...(typeof one.where === 'string' ? { where: one.where.slice(0, 120) } : {})
441
+ }
442
+ })
365
443
  : []
366
444
  if (!sources.length) issues.push({ code: 'no-source', blocking: false })
367
445
 
@@ -390,7 +468,6 @@ export function isBlocked(row: EntryRow): boolean {
390
468
  * Names and descriptions are not looked for: the model tidies them, and a tidied name is not a wrong one.
391
469
  */
392
470
 
393
- const PERSON = /person|message|user|사용자|말/i
394
471
 
395
472
  /** The ways a number or a date is written in a file. */
396
473
  export function writtenForms(type: EntryFieldType, value: unknown): string[] {
@@ -408,6 +485,12 @@ export function writtenForms(type: EntryFieldType, value: unknown): string[] {
408
485
  }
409
486
  return [...forms].map(form => (negative ? `-${form}` : form))
410
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
+ }
411
494
  if (type === 'date') {
412
495
  const match = typeof value === 'string' ? value.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
413
496
  if (!match) return []
@@ -462,7 +545,7 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
462
545
  if (at >= 0) return { ...row, evidence: { attachmentId: id, line: at + 1 } }
463
546
  }
464
547
 
465
- const saidByPerson = row.sources.some(source => !source.attachmentId && PERSON.test(source.where ?? ''))
548
+ const saidByPerson = row.sources.some(source => source.person)
466
549
  const issues = [...row.issues]
467
550
  for (const one of forms) {
468
551
  const anywhere = readable.some(id => texts.get(id)!.split(/\r?\n/).some(line => lineHas(line, one.forms)))
@@ -473,6 +556,20 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
473
556
  })
474
557
  }
475
558
 
559
+ /**
560
+ * The total a file prints - a line named 합계 · 총계 · 총액 · total · sum, and the last amount on it. The server
561
+ * reads it itself, so the card can compare even when the model did not say it (2026-10-01: once it did, once not).
562
+ */
563
+ export function printedTotalOf(text: string): { value: number; line: number } | undefined {
564
+ const lines = text.split(/\r?\n/)
565
+ for (let i = lines.length - 1; i >= 0; i--) {
566
+ if (!/(합\s*계|총\s*계|총\s*액|total|sum)/i.test(lines[i])) continue
567
+ const amounts = [...lines[i].matchAll(/-?\d{1,3}(?:,\d{3})+(?:\.\d+)?|-?\d+(?:\.\d+)?/g)].map(match => Number(match[0].replace(/,/g, '')))
568
+ if (amounts.length) return { value: amounts[amounts.length - 1], line: i + 1 }
569
+ }
570
+ return undefined
571
+ }
572
+
476
573
  export interface EntryTotal {
477
574
  field: string
478
575
  /** The sum over every row, and over the rows that will be entered. */
@@ -483,6 +580,8 @@ export interface EntryTotal {
483
580
  declaredFound?: boolean
484
581
  /** declared - all. */
485
582
  difference?: number
583
+ /** Where the declared total came from - the model's reading, or the server's own from a file's total line. */
584
+ declaredBy?: 'model' | 'file'
486
585
  }
487
586
 
488
587
  /** The sums of the fields declared `total`, and how they stand against the total the material prints. */
@@ -492,13 +591,28 @@ export function entryTotals(
492
591
  declared: { field?: string; value?: unknown; attachmentId?: string } | undefined,
493
592
  texts: Map<string, string>
494
593
  ): EntryTotal[] {
495
- return fields
496
- .filter(field => field.total && (field.type === 'number' || field.type === 'integer'))
594
+ const totalled = fields.filter(field => field.total && (field.type === 'number' || field.type === 'integer'))
595
+ /* With one totalled field and no total from the model, the server looks for the file's own total line. */
596
+ const printed = !declared && totalled.length === 1 ? [...texts.values()].map(printedTotalOf).find(Boolean) : undefined
597
+ return totalled
497
598
  .map(field => {
498
599
  const sum = (list: EntryRow[]) => Math.round(list.reduce((acc, row) => acc + (typeof row.values[field.name] === 'number' ? (row.values[field.name] as number) : 0), 0) * 100) / 100
499
600
  const total: EntryTotal = { field: field.name, all: sum(rows), ready: sum(rows.filter(row => !isBlocked(row))) }
500
- const value = declared && (declared.field ?? field.name) === field.name ? toNumber(declared.value) : undefined
601
+ if (printed) {
602
+ total.declared = printed.value
603
+ total.declaredFound = true
604
+ total.declaredBy = 'file'
605
+ total.difference = Math.round((printed.value - total.all) * 100) / 100
606
+ return total
607
+ }
608
+ /*
609
+ * With one totalled field the model's total is that field's, whatever it named it - on office's second card it
610
+ * wrote the file's column name (이용금액) for amount, and the total was dropped (2026-10-01).
611
+ */
612
+ const mine = declared && (totalled.length === 1 || (declared.field ?? field.name) === field.name)
613
+ const value = mine ? toNumber(declared!.value) : undefined
501
614
  if (value !== undefined) {
615
+ total.declaredBy = 'model'
502
616
  total.declared = value
503
617
  total.difference = Math.round((value - total.all) * 100) / 100
504
618
  const text = declared!.attachmentId ? texts.get(declared!.attachmentId) : undefined
@@ -508,3 +622,62 @@ export function entryTotals(
508
622
  })
509
623
  }
510
624
 
625
+ /** The doors of the queries a target's reference fields reach - the person must pass them too. */
626
+ export function referenceDoorsOf(fields: EntryField[]): string[] {
627
+ return [...new Set(fields.filter(field => field.type === 'reference' && field.reference).map(field => rootFieldOf(field.reference!.query, 'query')))]
628
+ }
629
+
630
+ /**
631
+ * Settles each reference a row names by text, through the application's query as the person. One match keeps its
632
+ * id and name; none or several keep the row out - several with their names, so the person can say which.
633
+ */
634
+ export async function resolveReferences<T extends { values: Record<string, unknown>; issues: EntryIssue[] }>(
635
+ fields: EntryField[],
636
+ rows: T[],
637
+ run: (document: string, variables: Record<string, unknown>) => Promise<{ data?: any; errors?: readonly { message: string }[] }>
638
+ ): Promise<T[]> {
639
+ const references = fields.filter(field => field.type === 'reference' && field.reference)
640
+ if (!references.length) return rows
641
+ const asked = new Map<string, { id: string; label?: string }[] | Error>()
642
+ for (const row of rows) {
643
+ for (const field of references) {
644
+ const value: any = row.values[field.name]
645
+ if (!value || typeof value !== 'object' || typeof value.text !== 'string') continue
646
+ const key = `${field.name}\u0000${value.text}`
647
+ if (!asked.has(key)) {
648
+ try {
649
+ const answer = await run(field.reference!.query, field.reference!.variables(value.text))
650
+ asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, value.text) ?? [])
651
+ } catch (error: any) {
652
+ asked.set(key, error instanceof Error ? error : new Error(String(error)))
653
+ }
654
+ }
655
+ const found = asked.get(key)!
656
+ if (found instanceof Error) {
657
+ row.issues.push({ field: field.name, code: 'reference-unreadable', params: { text: value.text }, blocking: true })
658
+ } else if (found.length === 1) {
659
+ row.values[field.name] = { id: found[0].id, ...(found[0].label ? { label: found[0].label } : {}) }
660
+ } else {
661
+ row.issues.push({
662
+ field: field.name,
663
+ code: found.length ? 'reference-ambiguous' : 'reference-not-found',
664
+ params: { text: value.text, ...(found.length ? { candidates: found.slice(0, 5).map(one => one.label || one.id).join(', ') } : {}) },
665
+ blocking: true
666
+ })
667
+ }
668
+ }
669
+ }
670
+ return rows
671
+ }
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
+