@things-factory/ai-assistant 10.1.99 → 10.1.100

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 (35) hide show
  1. package/client/components/entry-review.ts +17 -4
  2. package/client/components/workspace-view.ts +6 -1
  3. package/client/utils/entry-proposal.ts +2 -1
  4. package/dist-client/components/entry-review.d.ts +1 -1
  5. package/dist-client/components/entry-review.js +19 -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.map +1 -1
  13. package/dist-server/service/data-entry/entry-apply.js +2 -0
  14. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  15. package/dist-server/service/data-entry/entry-target.d.ts +52 -4
  16. package/dist-server/service/data-entry/entry-target.js +115 -10
  17. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  18. package/dist-server/service/data-entry/entry-tools.d.ts +1 -1
  19. package/dist-server/service/data-entry/entry-tools.js +20 -9
  20. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  21. package/dist-server/service/data-entry/entry-update.js +3 -3
  22. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  23. package/dist-server/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +2 -2
  25. package/server/service/data-entry/data-entry-update.test.ts +61 -1
  26. package/server/service/data-entry/data-entry.test.ts +27 -4
  27. package/server/service/data-entry/entry-apply.ts +3 -1
  28. package/server/service/data-entry/entry-target.ts +137 -14
  29. package/server/service/data-entry/entry-tools.ts +21 -9
  30. package/server/service/data-entry/entry-update.ts +4 -3
  31. package/translations/en.json +7 -1
  32. package/translations/ja.json +7 -1
  33. package/translations/ko.json +7 -1
  34. package/translations/ms.json +7 -1
  35. package/translations/zh.json +7 -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.100",
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": "e3c2444ded42290fdc77b189f5befa419f575f9b"
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,63 @@ 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
+
@@ -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,23 @@ 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
+
@@ -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, 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 }))
@@ -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' | '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 {
@@ -135,9 +157,9 @@ export type EntryRecord = { id: string } & Record<string, unknown>
135
157
  */
136
158
  export interface EntryUpdate {
137
159
  /** 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[] }
160
+ read: { query: string; variables(ids: string[]): Record<string, unknown>; recordsOf(data: any, ids: string[]): EntryRecord[] }
139
161
  /** 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[] }
162
+ find?: { query: string; variables(criteria: Record<string, unknown>): Record<string, unknown>; recordsOf(data: any, criteria: Record<string, unknown>): EntryRecord[]; criteria: EntryField[] }
141
163
  /** The application's own mutation that changes one record. */
142
164
  mutation: string
143
165
  variables(change: { id: string; record: EntryRecord; values: Record<string, unknown> }): Record<string, unknown>
@@ -177,6 +199,9 @@ export async function recordEntryEvent(target: EntryTarget, event: EntryEvent, c
177
199
 
178
200
  export const DEFAULT_MAX_ROWS = 200
179
201
 
202
+ /* The model's way of saying a value came from the person - kept only to read older rows and loose words. */
203
+ const PERSON = /person|message|user|사용자|말/i
204
+
180
205
  const targets = new Map<string, EntryTarget>()
181
206
 
182
207
  const KEY = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/
@@ -208,6 +233,13 @@ export function declareEntryTarget(target: EntryTarget): void {
208
233
  if (!target.fields?.length) throw new Error(`[ai-assistant] entry target '${target.key}' declares no fields.`)
209
234
  const names = new Set<string>()
210
235
  for (const field of target.fields) {
236
+ if (field.type === 'reference') {
237
+ try {
238
+ rootFieldOf(field.reference?.query ?? '', 'query')
239
+ } catch (error: any) {
240
+ throw new Error(`[ai-assistant] entry target '${target.key}': reference field '${field.name}' ${error?.message ?? 'has no query'}.`)
241
+ }
242
+ }
211
243
  if (names.has(field.name)) throw new Error(`[ai-assistant] entry target '${target.key}' declares '${field.name}' twice.`)
212
244
  names.add(field.name)
213
245
  if (field.type === 'enum' && !field.values?.length)
@@ -306,6 +338,11 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
306
338
  }
307
339
  case 'enum':
308
340
  return field.values!.includes(String(value).trim()) ? { read: String(value).trim() } : { code: 'not-in-list' }
341
+ case 'reference':
342
+ /* Resolved already ({ id }), or a name to look up ({ text }) - resolveReferences settles the second. */
343
+ 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) } : {}) } }
344
+ if (value && typeof value === 'object' && typeof (value as any).text === 'string') return { read: { text: (value as any).text } }
345
+ return typeof value === 'string' || typeof value === 'number' ? { read: { text: String(value).trim() } } : { code: 'not-a-reference' }
309
346
  default:
310
347
  return { read: String(value).trim() }
311
348
  }
@@ -315,6 +352,10 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
315
352
  export function sameFieldValue(field: EntryField, a: unknown, b: unknown): boolean {
316
353
  if (empty(a) && empty(b)) return true
317
354
  if (empty(a) || empty(b)) return false
355
+ if (field.type === 'reference') {
356
+ const idOf = (v: any) => (v && typeof v === 'object' ? v.id ?? v.text : String(v))
357
+ return idOf(a) === idOf(b)
358
+ }
318
359
  const x = readFieldValue(field, a)
319
360
  const y = readFieldValue(field, b)
320
361
  return x.read !== undefined && y.read !== undefined ? x.read === y.read : String(a) === String(b)
@@ -357,11 +398,15 @@ export function normalizeEntryRows(target: EntryTarget, input: unknown): { rows:
357
398
 
358
399
  const sources: EntrySource[] = Array.isArray(raw?.sources)
359
400
  ? 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
- }))
401
+ .filter((one: any) => one && (one.attachmentId || one.where || one.person === true))
402
+ .map((one: any): EntrySource => {
403
+ /* "the person's message" written by the model is the person flag - the screen says it in its own words. */
404
+ if (one.person === true || (!one.attachmentId && typeof one.where === 'string' && PERSON.test(one.where))) return { person: true }
405
+ return {
406
+ ...(typeof one.attachmentId === 'string' ? { attachmentId: one.attachmentId } : {}),
407
+ ...(typeof one.where === 'string' ? { where: one.where.slice(0, 120) } : {})
408
+ }
409
+ })
365
410
  : []
366
411
  if (!sources.length) issues.push({ code: 'no-source', blocking: false })
367
412
 
@@ -390,7 +435,6 @@ export function isBlocked(row: EntryRow): boolean {
390
435
  * Names and descriptions are not looked for: the model tidies them, and a tidied name is not a wrong one.
391
436
  */
392
437
 
393
- const PERSON = /person|message|user|사용자|말/i
394
438
 
395
439
  /** The ways a number or a date is written in a file. */
396
440
  export function writtenForms(type: EntryFieldType, value: unknown): string[] {
@@ -462,7 +506,7 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
462
506
  if (at >= 0) return { ...row, evidence: { attachmentId: id, line: at + 1 } }
463
507
  }
464
508
 
465
- const saidByPerson = row.sources.some(source => !source.attachmentId && PERSON.test(source.where ?? ''))
509
+ const saidByPerson = row.sources.some(source => source.person)
466
510
  const issues = [...row.issues]
467
511
  for (const one of forms) {
468
512
  const anywhere = readable.some(id => texts.get(id)!.split(/\r?\n/).some(line => lineHas(line, one.forms)))
@@ -473,6 +517,20 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
473
517
  })
474
518
  }
475
519
 
520
+ /**
521
+ * The total a file prints - a line named 합계 · 총계 · 총액 · total · sum, and the last amount on it. The server
522
+ * reads it itself, so the card can compare even when the model did not say it (2026-10-01: once it did, once not).
523
+ */
524
+ export function printedTotalOf(text: string): { value: number; line: number } | undefined {
525
+ const lines = text.split(/\r?\n/)
526
+ for (let i = lines.length - 1; i >= 0; i--) {
527
+ if (!/(합\s*계|총\s*계|총\s*액|total|sum)/i.test(lines[i])) continue
528
+ const amounts = [...lines[i].matchAll(/-?\d{1,3}(?:,\d{3})+(?:\.\d+)?|-?\d+(?:\.\d+)?/g)].map(match => Number(match[0].replace(/,/g, '')))
529
+ if (amounts.length) return { value: amounts[amounts.length - 1], line: i + 1 }
530
+ }
531
+ return undefined
532
+ }
533
+
476
534
  export interface EntryTotal {
477
535
  field: string
478
536
  /** The sum over every row, and over the rows that will be entered. */
@@ -483,6 +541,8 @@ export interface EntryTotal {
483
541
  declaredFound?: boolean
484
542
  /** declared - all. */
485
543
  difference?: number
544
+ /** Where the declared total came from - the model's reading, or the server's own from a file's total line. */
545
+ declaredBy?: 'model' | 'file'
486
546
  }
487
547
 
488
548
  /** The sums of the fields declared `total`, and how they stand against the total the material prints. */
@@ -492,13 +552,28 @@ export function entryTotals(
492
552
  declared: { field?: string; value?: unknown; attachmentId?: string } | undefined,
493
553
  texts: Map<string, string>
494
554
  ): EntryTotal[] {
495
- return fields
496
- .filter(field => field.total && (field.type === 'number' || field.type === 'integer'))
555
+ const totalled = fields.filter(field => field.total && (field.type === 'number' || field.type === 'integer'))
556
+ /* With one totalled field and no total from the model, the server looks for the file's own total line. */
557
+ const printed = !declared && totalled.length === 1 ? [...texts.values()].map(printedTotalOf).find(Boolean) : undefined
558
+ return totalled
497
559
  .map(field => {
498
560
  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
561
  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
562
+ if (printed) {
563
+ total.declared = printed.value
564
+ total.declaredFound = true
565
+ total.declaredBy = 'file'
566
+ total.difference = Math.round((printed.value - total.all) * 100) / 100
567
+ return total
568
+ }
569
+ /*
570
+ * With one totalled field the model's total is that field's, whatever it named it - on office's second card it
571
+ * wrote the file's column name (이용금액) for amount, and the total was dropped (2026-10-01).
572
+ */
573
+ const mine = declared && (totalled.length === 1 || (declared.field ?? field.name) === field.name)
574
+ const value = mine ? toNumber(declared!.value) : undefined
501
575
  if (value !== undefined) {
576
+ total.declaredBy = 'model'
502
577
  total.declared = value
503
578
  total.difference = Math.round((value - total.all) * 100) / 100
504
579
  const text = declared!.attachmentId ? texts.get(declared!.attachmentId) : undefined
@@ -508,3 +583,51 @@ export function entryTotals(
508
583
  })
509
584
  }
510
585
 
586
+ /** The doors of the queries a target's reference fields reach - the person must pass them too. */
587
+ export function referenceDoorsOf(fields: EntryField[]): string[] {
588
+ return [...new Set(fields.filter(field => field.type === 'reference' && field.reference).map(field => rootFieldOf(field.reference!.query, 'query')))]
589
+ }
590
+
591
+ /**
592
+ * Settles each reference a row names by text, through the application's query as the person. One match keeps its
593
+ * id and name; none or several keep the row out - several with their names, so the person can say which.
594
+ */
595
+ export async function resolveReferences<T extends { values: Record<string, unknown>; issues: EntryIssue[] }>(
596
+ fields: EntryField[],
597
+ rows: T[],
598
+ run: (document: string, variables: Record<string, unknown>) => Promise<{ data?: any; errors?: readonly { message: string }[] }>
599
+ ): Promise<T[]> {
600
+ const references = fields.filter(field => field.type === 'reference' && field.reference)
601
+ if (!references.length) return rows
602
+ const asked = new Map<string, { id: string; label?: string }[] | Error>()
603
+ for (const row of rows) {
604
+ for (const field of references) {
605
+ const value: any = row.values[field.name]
606
+ if (!value || typeof value !== 'object' || typeof value.text !== 'string') continue
607
+ const key = `${field.name}\u0000${value.text}`
608
+ if (!asked.has(key)) {
609
+ try {
610
+ const answer = await run(field.reference!.query, field.reference!.variables(value.text))
611
+ asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, value.text) ?? [])
612
+ } catch (error: any) {
613
+ asked.set(key, error instanceof Error ? error : new Error(String(error)))
614
+ }
615
+ }
616
+ const found = asked.get(key)!
617
+ if (found instanceof Error) {
618
+ row.issues.push({ field: field.name, code: 'reference-unreadable', params: { text: value.text }, blocking: true })
619
+ } else if (found.length === 1) {
620
+ row.values[field.name] = { id: found[0].id, ...(found[0].label ? { label: found[0].label } : {}) }
621
+ } else {
622
+ row.issues.push({
623
+ field: field.name,
624
+ code: found.length ? 'reference-ambiguous' : 'reference-not-found',
625
+ params: { text: value.text, ...(found.length ? { candidates: found.slice(0, 5).map(one => one.label || one.id).join(', ') } : {}) },
626
+ blocking: true
627
+ })
628
+ }
629
+ }
630
+ }
631
+ return rows
632
+ }
633
+
@@ -22,6 +22,8 @@ import {
22
22
  entryToolName,
23
23
  isBlocked,
24
24
  normalizeEntryRows,
25
+ referenceDoorsOf,
26
+ resolveReferences,
25
27
  recordEntryEvent,
26
28
  withCheckedIssues,
27
29
  type EntryField,
@@ -35,6 +37,7 @@ function fieldSchema(field: EntryField): any {
35
37
  * whole request with it: "12,400" or "₩12,400" is the model's to write as 12400 (2026-10-01, office).
36
38
  */
37
39
  const type = field.type === 'number' ? 'number' : field.type === 'integer' ? 'integer' : field.type === 'boolean' ? 'boolean' : 'string'
40
+ 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
41
  const said = [
39
42
  field.description,
40
43
  field.required ? 'required' : 'optional',
@@ -61,7 +64,7 @@ export type SourceTextLoader = (attachmentIds: string[], context: any) => Promis
61
64
 
62
65
  const readSources: SourceTextLoader = async (ids, context) => (await import('./entry-sources.js')).loadSourceTexts(ids, context)
63
66
 
64
- export async function proposeEntries(target: EntryTarget, args: any, context: any, loadTexts: SourceTextLoader = readSources) {
67
+ export async function proposeEntries(target: EntryTarget, args: any, context: any, loadTexts: SourceTextLoader = readSources, run?: GraphqlRunner) {
65
68
  const tool = entryToolName(target.key)
66
69
  const told = (event: Omit<Parameters<typeof recordEntryEvent>[1], 'phase' | 'tool'>) => recordEntryEvent(target, { phase: 'propose', tool, ...event }, { state: context?.state })
67
70
  const { rows: normalized, cut } = normalizeEntryRows(target, args?.rows)
@@ -74,7 +77,9 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
74
77
  const declared = args?.declaredTotal && typeof args.declaredTotal === 'object' ? args.declaredTotal : undefined
75
78
  const cited = [...normalized.flatMap(row => row.sources.map(source => source.attachmentId)), declared?.attachmentId].filter((id): id is string => typeof id === 'string' && !!id)
76
79
  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)
80
+ /* A name the model wrote for another record is settled to its id by the application's query, as the person. */
81
+ const resolved = await resolveReferences(target.fields, normalized, run ?? runAsCaller(context))
82
+ const read = verifyRowsAgainstSources(resolved, target.fields, texts)
78
83
 
79
84
  let rows: EntryRow[] = read
80
85
  let checkFailed: string | undefined
@@ -138,7 +143,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
138
143
  kind: 'read',
139
144
  name: changeToolName(target.key),
140
145
  /* The person must be able to read the records and change them by hand. */
141
- doors: [rootFieldOf(update.mutation, 'mutation'), readDoor],
146
+ doors: [...new Set([rootFieldOf(update.mutation, 'mutation'), readDoor, ...referenceDoorsOf(changeableFields(target))])],
142
147
  description:
143
148
  `Propose changes to existing records: ${target.description} ` +
144
149
  'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
@@ -183,7 +188,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
183
188
  kind: 'read',
184
189
  name,
185
190
  /* The person must be able to enter these records themselves - the tool is not a second way in. */
186
- doors: [entryDoorOf(target.mutation)],
191
+ doors: [entryDoorOf(target.mutation), ...referenceDoorsOf(target.fields)],
187
192
  description:
188
193
  `Propose records to enter: ${target.description} ` +
189
194
  '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.',
@@ -202,8 +207,15 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
202
207
  },
203
208
  sources: {
204
209
  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' } } }
210
+ description: 'Where this row came from: the attachment id and where in it, and { person: true } for a value the person said.',
211
+ items: {
212
+ type: 'object',
213
+ properties: {
214
+ attachmentId: { type: 'string' },
215
+ where: { type: 'string', description: 'Where in the attachment, in the person\'s language ("3번째 줄", "영수증 사진").' },
216
+ person: { type: 'boolean', description: 'true when the value came from what the person said in the conversation.' }
217
+ }
218
+ }
207
219
  }
208
220
  },
209
221
  required: ['values', 'sources']
@@ -223,7 +235,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
223
235
  },
224
236
  required: ['rows']
225
237
  },
226
- builder: (args: any, context: any) => proposeEntries(target, args, context)
238
+ builder: (args: any, context: any) => proposeEntries(target, args, context, readSources, run(context))
227
239
  } as ToolSpec
228
240
 
229
241
  return {
@@ -232,8 +244,8 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
232
244
  guidance: [
233
245
  `- 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
246
  '- 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.',
247
+ '- 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.',
248
+ '- 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
249
  `- ${name} only proposes. Nothing is entered until the person presses the button on the card - never say the records were entered.`,
238
250
  `- 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
251
  ...(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 })),
@@ -267,5 +267,11 @@
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"
271
277
  }
@@ -267,5 +267,11 @@
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": "参照先の記録が分かりません"
271
277
  }
@@ -267,5 +267,11 @@
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": "가리키는 기록을 알 수 없습니다"
271
277
  }
@@ -267,5 +267,11 @@
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"
271
277
  }
@@ -267,5 +267,11 @@
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": "无法确定所指记录"
271
277
  }