@things-factory/ai-assistant 10.1.98 → 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 (36) hide show
  1. package/client/components/entry-review.ts +27 -8
  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 +27 -7
  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 +54 -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 +2 -1
  19. package/dist-server/service/data-entry/entry-tools.js +27 -10
  20. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  21. package/dist-server/service/data-entry/entry-update.d.ts +1 -0
  22. package/dist-server/service/data-entry/entry-update.js +4 -4
  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 +61 -1
  27. package/server/service/data-entry/data-entry.test.ts +36 -4
  28. package/server/service/data-entry/entry-apply.ts +3 -1
  29. package/server/service/data-entry/entry-target.ts +139 -14
  30. package/server/service/data-entry/entry-tools.ts +28 -10
  31. package/server/service/data-entry/entry-update.ts +5 -4
  32. package/translations/en.json +7 -1
  33. package/translations/ja.json +7 -1
  34. package/translations/ko.json +7 -1
  35. package/translations/ms.json +7 -1
  36. 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.98",
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": "6321b11b694621c8cf7ba105650071eb88e9beb7"
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
  })
@@ -309,3 +312,32 @@ describe('telling the application', () => {
309
312
  expect(result.proposed).toBe(true)
310
313
  })
311
314
  })
315
+
316
+ describe('the card speaks the person\'s words', () => {
317
+ it('an enum field hands its value key prefix to the card - PERSONAL_CARD shows as the app\'s own word for it', async () => {
318
+ const target: EntryTarget = { ...expense, fields: [...expense.fields, { name: 'paidBy', type: 'enum', values: ['PERSONAL_CARD'], valueKeyPrefix: 'text.expense-payment-' }] }
319
+ const result: any = await proposeEntries(target, { rows: [{ values: { spentOn: '2026-09-12', amount: 1, paidBy: 'PERSONAL_CARD' }, sources: [{ where: 'x' }] }] }, { state: {} }, async () => new Map())
320
+ expect(result.columns.find((one: any) => one.name === 'paidBy')).toMatchObject({ valueKeyPrefix: 'text.expense-payment-' })
321
+ })
322
+ })
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
@@ -28,13 +28,37 @@ export interface EntryField {
28
28
  labelKey?: string
29
29
  /** A number field the card sums - and compares with the total the material prints (§ totals). */
30
30
  total?: boolean
31
+ /** An enum's values on the card: `<prefix><value in lowercase>` - `text.expense-payment-` shows PERSONAL_CARD as 개인카드. */
32
+ valueKeyPrefix?: string
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
31
53
  }
32
54
 
33
55
  /** Where a row came from - which file, and where in it. Shown on the card beside the row. */
34
56
  export interface EntrySource {
35
57
  attachmentId?: string
36
- /** "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. */
37
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
38
62
  }
39
63
 
40
64
  export interface EntryIssue {
@@ -133,9 +157,9 @@ export type EntryRecord = { id: string } & Record<string, unknown>
133
157
  */
134
158
  export interface EntryUpdate {
135
159
  /** Reads the records by id, as the person - one they may not see is not found. */
136
- 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[] }
137
161
  /** Finds candidates from what the person said - "last week's 12,400 taxi". */
138
- 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[] }
139
163
  /** The application's own mutation that changes one record. */
140
164
  mutation: string
141
165
  variables(change: { id: string; record: EntryRecord; values: Record<string, unknown> }): Record<string, unknown>
@@ -175,6 +199,9 @@ export async function recordEntryEvent(target: EntryTarget, event: EntryEvent, c
175
199
 
176
200
  export const DEFAULT_MAX_ROWS = 200
177
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
+
178
205
  const targets = new Map<string, EntryTarget>()
179
206
 
180
207
  const KEY = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/
@@ -206,6 +233,13 @@ export function declareEntryTarget(target: EntryTarget): void {
206
233
  if (!target.fields?.length) throw new Error(`[ai-assistant] entry target '${target.key}' declares no fields.`)
207
234
  const names = new Set<string>()
208
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
+ }
209
243
  if (names.has(field.name)) throw new Error(`[ai-assistant] entry target '${target.key}' declares '${field.name}' twice.`)
210
244
  names.add(field.name)
211
245
  if (field.type === 'enum' && !field.values?.length)
@@ -304,6 +338,11 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
304
338
  }
305
339
  case 'enum':
306
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' }
307
346
  default:
308
347
  return { read: String(value).trim() }
309
348
  }
@@ -313,6 +352,10 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
313
352
  export function sameFieldValue(field: EntryField, a: unknown, b: unknown): boolean {
314
353
  if (empty(a) && empty(b)) return true
315
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
+ }
316
359
  const x = readFieldValue(field, a)
317
360
  const y = readFieldValue(field, b)
318
361
  return x.read !== undefined && y.read !== undefined ? x.read === y.read : String(a) === String(b)
@@ -355,11 +398,15 @@ export function normalizeEntryRows(target: EntryTarget, input: unknown): { rows:
355
398
 
356
399
  const sources: EntrySource[] = Array.isArray(raw?.sources)
357
400
  ? raw.sources
358
- .filter((one: any) => one && (one.attachmentId || one.where))
359
- .map((one: any) => ({
360
- ...(typeof one.attachmentId === 'string' ? { attachmentId: one.attachmentId } : {}),
361
- ...(typeof one.where === 'string' ? { where: one.where.slice(0, 120) } : {})
362
- }))
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
+ })
363
410
  : []
364
411
  if (!sources.length) issues.push({ code: 'no-source', blocking: false })
365
412
 
@@ -388,7 +435,6 @@ export function isBlocked(row: EntryRow): boolean {
388
435
  * Names and descriptions are not looked for: the model tidies them, and a tidied name is not a wrong one.
389
436
  */
390
437
 
391
- const PERSON = /person|message|user|사용자|말/i
392
438
 
393
439
  /** The ways a number or a date is written in a file. */
394
440
  export function writtenForms(type: EntryFieldType, value: unknown): string[] {
@@ -460,7 +506,7 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
460
506
  if (at >= 0) return { ...row, evidence: { attachmentId: id, line: at + 1 } }
461
507
  }
462
508
 
463
- const saidByPerson = row.sources.some(source => !source.attachmentId && PERSON.test(source.where ?? ''))
509
+ const saidByPerson = row.sources.some(source => source.person)
464
510
  const issues = [...row.issues]
465
511
  for (const one of forms) {
466
512
  const anywhere = readable.some(id => texts.get(id)!.split(/\r?\n/).some(line => lineHas(line, one.forms)))
@@ -471,6 +517,20 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
471
517
  })
472
518
  }
473
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
+
474
534
  export interface EntryTotal {
475
535
  field: string
476
536
  /** The sum over every row, and over the rows that will be entered. */
@@ -481,6 +541,8 @@ export interface EntryTotal {
481
541
  declaredFound?: boolean
482
542
  /** declared - all. */
483
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'
484
546
  }
485
547
 
486
548
  /** The sums of the fields declared `total`, and how they stand against the total the material prints. */
@@ -490,13 +552,28 @@ export function entryTotals(
490
552
  declared: { field?: string; value?: unknown; attachmentId?: string } | undefined,
491
553
  texts: Map<string, string>
492
554
  ): EntryTotal[] {
493
- return fields
494
- .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
495
559
  .map(field => {
496
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
497
561
  const total: EntryTotal = { field: field.name, all: sum(rows), ready: sum(rows.filter(row => !isBlocked(row))) }
498
- 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
499
575
  if (value !== undefined) {
576
+ total.declaredBy = 'model'
500
577
  total.declared = value
501
578
  total.difference = Math.round((value - total.all) * 100) / 100
502
579
  const text = declared!.attachmentId ? texts.get(declared!.attachmentId) : undefined
@@ -506,3 +583,51 @@ export function entryTotals(
506
583
  })
507
584
  }
508
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',
@@ -48,14 +51,20 @@ function fieldSchema(field: EntryField): any {
48
51
 
49
52
  /** What the card needs besides the rows - the columns, in the target's order. */
50
53
  function columnsOf(target: EntryTarget) {
51
- return target.fields.map(field => ({ name: field.name, type: field.type, required: !!field.required, ...(field.labelKey ? { labelKey: field.labelKey } : {}) }))
54
+ return target.fields.map(field => ({
55
+ name: field.name,
56
+ type: field.type,
57
+ required: !!field.required,
58
+ ...(field.labelKey ? { labelKey: field.labelKey } : {}),
59
+ ...(field.valueKeyPrefix ? { valueKeyPrefix: field.valueKeyPrefix } : {})
60
+ }))
52
61
  }
53
62
 
54
63
  export type SourceTextLoader = (attachmentIds: string[], context: any) => Promise<Map<string, string>>
55
64
 
56
65
  const readSources: SourceTextLoader = async (ids, context) => (await import('./entry-sources.js')).loadSourceTexts(ids, context)
57
66
 
58
- 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) {
59
68
  const tool = entryToolName(target.key)
60
69
  const told = (event: Omit<Parameters<typeof recordEntryEvent>[1], 'phase' | 'tool'>) => recordEntryEvent(target, { phase: 'propose', tool, ...event }, { state: context?.state })
61
70
  const { rows: normalized, cut } = normalizeEntryRows(target, args?.rows)
@@ -68,7 +77,9 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
68
77
  const declared = args?.declaredTotal && typeof args.declaredTotal === 'object' ? args.declaredTotal : undefined
69
78
  const cited = [...normalized.flatMap(row => row.sources.map(source => source.attachmentId)), declared?.attachmentId].filter((id): id is string => typeof id === 'string' && !!id)
70
79
  const texts = cited.length ? await loadTexts(cited, context).catch(() => new Map<string, string>()) : new Map<string, string>()
71
- 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)
72
83
 
73
84
  let rows: EntryRow[] = read
74
85
  let checkFailed: string | undefined
@@ -132,7 +143,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
132
143
  kind: 'read',
133
144
  name: changeToolName(target.key),
134
145
  /* The person must be able to read the records and change them by hand. */
135
- doors: [rootFieldOf(update.mutation, 'mutation'), readDoor],
146
+ doors: [...new Set([rootFieldOf(update.mutation, 'mutation'), readDoor, ...referenceDoorsOf(changeableFields(target))])],
136
147
  description:
137
148
  `Propose changes to existing records: ${target.description} ` +
138
149
  'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
@@ -177,7 +188,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
177
188
  kind: 'read',
178
189
  name,
179
190
  /* The person must be able to enter these records themselves - the tool is not a second way in. */
180
- doors: [entryDoorOf(target.mutation)],
191
+ doors: [entryDoorOf(target.mutation), ...referenceDoorsOf(target.fields)],
181
192
  description:
182
193
  `Propose records to enter: ${target.description} ` +
183
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.',
@@ -196,8 +207,15 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
196
207
  },
197
208
  sources: {
198
209
  type: 'array',
199
- description: 'Where this row came from: the attachment id and where in it ("row 3", "the receipt photo"), or where: "the person\'s message".',
200
- 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
+ }
201
219
  }
202
220
  },
203
221
  required: ['values', 'sources']
@@ -217,7 +235,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
217
235
  },
218
236
  required: ['rows']
219
237
  },
220
- builder: (args: any, context: any) => proposeEntries(target, args, context)
238
+ builder: (args: any, context: any) => proposeEntries(target, args, context, readSources, run(context))
221
239
  } as ToolSpec
222
240
 
223
241
  return {
@@ -226,8 +244,8 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
226
244
  guidance: [
227
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.`,
228
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.',
229
- '- 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.',
230
- '- 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.',
231
249
  `- ${name} only proposes. Nothing is entered until the person presses the button on the card - never say the records were entered.`,
232
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.`,
233
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. */
@@ -125,7 +126,7 @@ function changedColumns(target: EntryTarget, rows: ChangeRow[]) {
125
126
  const used = new Set(rows.flatMap(row => Object.keys(row.values)))
126
127
  return changeableFields(target)
127
128
  .filter(field => used.has(field.name))
128
- .map(field => ({ name: field.name, type: field.type, ...(field.labelKey ? { labelKey: field.labelKey } : {}) }))
129
+ .map(field => ({ name: field.name, type: field.type, ...(field.labelKey ? { labelKey: field.labelKey } : {}), ...(field.valueKeyPrefix ? { valueKeyPrefix: field.valueKeyPrefix } : {}) }))
129
130
  }
130
131
 
131
132
  export async function proposeChanges(target: EntryTarget, args: any, context: any, run: GraphqlRunner) {
@@ -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
  }