@things-factory/ai-assistant 10.1.91 → 10.1.97

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 (40) hide show
  1. package/client/components/assistant-chat.ts +5 -3
  2. package/client/components/entry-review.ts +56 -0
  3. package/client/utils/entry-proposal.ts +20 -5
  4. package/dist-client/components/assistant-chat.js +5 -3
  5. package/dist-client/components/assistant-chat.js.map +1 -1
  6. package/dist-client/components/entry-review.d.ts +5 -0
  7. package/dist-client/components/entry-review.js +55 -0
  8. package/dist-client/components/entry-review.js.map +1 -1
  9. package/dist-client/tsconfig.tsbuildinfo +1 -1
  10. package/dist-client/utils/entry-proposal.d.ts +5 -0
  11. package/dist-client/utils/entry-proposal.js +16 -5
  12. package/dist-client/utils/entry-proposal.js.map +1 -1
  13. package/dist-server/service/data-entry/entry-apply.d.ts +3 -3
  14. package/dist-server/service/data-entry/entry-apply.js +55 -2
  15. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  16. package/dist-server/service/data-entry/entry-resolver.js +3 -7
  17. package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
  18. package/dist-server/service/data-entry/entry-target.d.ts +51 -0
  19. package/dist-server/service/data-entry/entry-target.js +77 -34
  20. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  21. package/dist-server/service/data-entry/entry-tools.d.ts +7 -1
  22. package/dist-server/service/data-entry/entry-tools.js +73 -3
  23. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  24. package/dist-server/service/data-entry/entry-update.d.ts +99 -0
  25. package/dist-server/service/data-entry/entry-update.js +196 -0
  26. package/dist-server/service/data-entry/entry-update.js.map +1 -0
  27. package/dist-server/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +2 -2
  29. package/server/service/data-entry/data-entry-update.test.ts +192 -0
  30. package/server/service/data-entry/entry-apply.ts +63 -6
  31. package/server/service/data-entry/entry-resolver.ts +4 -9
  32. package/server/service/data-entry/entry-target.ts +97 -31
  33. package/server/service/data-entry/entry-tools.ts +77 -3
  34. package/server/service/data-entry/entry-update.ts +209 -0
  35. package/test/entry-proposal.test.ts +15 -2
  36. package/translations/en.json +16 -1
  37. package/translations/ja.json +16 -1
  38. package/translations/ko.json +16 -1
  39. package/translations/ms.json +16 -1
  40. package/translations/zh.json +16 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.91",
3
+ "version": "10.1.97",
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": "e9a052f7faaeb5884db1ff8b862b8f05567a31f9"
54
+ "gitHead": "79bebdf2ce31c5e82134757678be2cc1bb6e4dec"
55
55
  }
@@ -0,0 +1,192 @@
1
+ /*
2
+ * The 수정안 (design/ai/data-entry.md §5-1) - which record is meant is settled by the server, what the card shows,
3
+ * and what is asked again before a record is changed.
4
+ */
5
+ import { clearEntryTargets, declareEntryTarget, changeToolName, findToolName, type EntryTarget } from './entry-target'
6
+ import { entryCategoryOf } from './entry-tools'
7
+ import { changeRows, findRecords, proposeChanges, resolveChangeRefs, stillAsProposed } from './entry-update'
8
+ import { applyEntryProposal, type EntryApplyDeps } from './entry-apply'
9
+
10
+ /* The application, as a fake: its records, its read query, its find query, its change mutation. */
11
+ let records: Record<string, any>
12
+ const calls: { document: string; variables: any }[] = []
13
+ const run = async (document: string, variables: any) => {
14
+ calls.push({ document, variables })
15
+ if (document.includes('myExpenseLines')) {
16
+ const list = Object.values(records).filter(
17
+ (one: any) => (!variables.ids || variables.ids.includes(one.id)) && (!variables.merchant || one.merchant.includes(variables.merchant)) && (variables.amount === undefined || one.amount === variables.amount)
18
+ )
19
+ return { data: { myExpenseLines: list } }
20
+ }
21
+ if (variables.id === 'closed') return { errors: [{ message: 'a submitted claim cannot be changed' }] }
22
+ records[variables.id] = { ...records[variables.id], ...variables.patch }
23
+ return { data: { changeExpenseLine: { id: variables.id } } }
24
+ }
25
+
26
+ const expense: EntryTarget = {
27
+ key: 'office.expense-line',
28
+ labelKey: 'office.entry.expense-line',
29
+ description: 'one expense.',
30
+ mutation: 'mutation ($line: NewExpenseLine!) { addExpenseLine(line: $line) { id } }',
31
+ variables: row => ({ line: row.values }),
32
+ fields: [
33
+ { name: 'spentDate', type: 'date', required: true },
34
+ { name: 'merchant', type: 'string' },
35
+ { name: 'amount', type: 'number', required: true },
36
+ { name: 'category', type: 'enum', values: ['교통비', '회의비'] }
37
+ ],
38
+ update: {
39
+ read: { query: 'query ($ids: [String!]) { myExpenseLines(ids: $ids) { id spentDate merchant amount category } }', variables: ids => ({ ids }), recordsOf: data => data.myExpenseLines },
40
+ find: {
41
+ query: 'query ($merchant: String, $amount: Float) { myExpenseLines(merchant: $merchant, amount: $amount) { id spentDate merchant amount category } }',
42
+ variables: criteria => criteria,
43
+ recordsOf: data => data.myExpenseLines,
44
+ criteria: [
45
+ { name: 'merchant', type: 'string' },
46
+ { name: 'amount', type: 'number' }
47
+ ]
48
+ },
49
+ mutation: 'mutation ($id: String!, $patch: ExpenseLinePatch!) { changeExpenseLine(id: $id, patch: $patch) { id } }',
50
+ variables: change => ({ id: change.id, patch: change.values }),
51
+ labelOf: record => `${record.spentDate} · ${record.merchant}`,
52
+ fields: ['amount', 'category', 'merchant']
53
+ }
54
+ }
55
+
56
+ beforeEach(() => {
57
+ clearEntryTargets()
58
+ calls.length = 0
59
+ records = {
60
+ l1: { id: 'l1', spentDate: '2026-09-12', merchant: '카카오T', amount: '12400.00', category: '교통비' },
61
+ l2: { id: 'l2', spentDate: '2026-09-13', merchant: '카카오T', amount: 12400, category: '교통비' },
62
+ closed: { id: 'closed', spentDate: '2026-08-01', merchant: '스타벅스', amount: 5000, category: '회의비' }
63
+ }
64
+ })
65
+
66
+ describe('which record is meant', () => {
67
+ it('★ "current" and "selected" come from the screen, but only for this kind of record', () => {
68
+ const host = { record: { target: 'office.expense-line', id: 'l1' }, selection: { target: 'office.expense-line', ids: ['l1', 'l2'] } }
69
+ expect(resolveChangeRefs('office.expense-line', [{ ref: 'current', values: { amount: 1 } }, { ref: 'selected', values: { category: '회의비' } }], host).picked.map(one => `${one.via}:${one.id}`)).toEqual([
70
+ 'current:l1',
71
+ 'selected:l1',
72
+ 'selected:l2'
73
+ ])
74
+ expect(resolveChangeRefs('office.expense-line', [{ ref: 'current', values: {} }], { record: { target: 'office.contract', id: 'c1' } }).unresolved).toEqual(['current'])
75
+ })
76
+
77
+ it('★ an id from the screen is only a candidate - a record the person cannot read is not found, and nothing is shown of it', async () => {
78
+ const result: any = await proposeChanges(expense, { changes: [{ ref: 'current', values: { amount: 13000 } }] }, { host: { record: { id: 'someone-elses' } } }, run)
79
+ expect(result.rows[0].issues).toEqual([{ code: 'record-not-found', blocking: true }])
80
+ expect(result.rows[0].before).toEqual({})
81
+ expect(calls[0].document).toContain('myExpenseLines(ids: $ids)')
82
+ })
83
+
84
+ it('no ref at all is refused, with the way to name one', async () => {
85
+ const result: any = await proposeChanges(expense, { changes: [{ values: { amount: 1 } }] }, { host: {} }, run)
86
+ expect(result).toMatchObject({ rejected: true, error: 'unresolved-no-ref' })
87
+ expect(result.message).toMatch(/never guess an id/i)
88
+ })
89
+
90
+ it('★ finding says how many match and asks the model to list them - it never picks one', async () => {
91
+ const two: any = await findRecords(expense, { merchant: '카카오', amount: '12,400' }, run)
92
+ expect(two.total).toBe(1)
93
+ const many: any = await findRecords(expense, { merchant: '카카오' }, run)
94
+ expect(many.total).toBe(2)
95
+ expect(many.message).toMatch(/ask which - never pick one yourself/)
96
+ expect(many.candidates.map((one: any) => one.label)).toEqual(['2026-09-12 · 카카오T', '2026-09-13 · 카카오T'])
97
+ })
98
+ })
99
+
100
+ describe('the card', () => {
101
+ it('★ shows what each record holds now and what it becomes - read by the server, "12400.00" is 12400', async () => {
102
+ const result: any = await proposeChanges(expense, { changes: [{ ref: 'l1', values: { amount: '13,000', category: '회의비' } }] }, { host: {} }, run)
103
+ expect(result).toMatchObject({ proposed: true, kind: 'entry', op: 'update', counts: { rows: 1, ready: 1, blocked: 0 } })
104
+ expect(result.rows[0]).toMatchObject({ id: 'l1', via: 'id', label: '2026-09-12 · 카카오T', before: { amount: '12400.00', category: '교통비' }, values: { amount: 13000, category: '회의비' }, issues: [] })
105
+ expect(result.columns.map((one: any) => one.name)).toEqual(['amount', 'category'])
106
+ })
107
+
108
+ it('a change to what it already is, a field that may not change, a value that does not fit - each said', () => {
109
+ const rows = changeRows(expense, [{ id: 'l1', via: 'id', values: { amount: 12400, spentDate: '2026-09-01' } }, { id: 'l2', via: 'id', values: { category: '식대' } }], new Map(Object.entries(records)) as any)
110
+ expect(rows[0].issues).toEqual([{ field: 'spentDate', code: 'not-changeable', blocking: false }, { code: 'no-change', blocking: true }])
111
+ expect(rows[1].issues).toEqual([{ field: 'category', code: 'not-in-list', params: { values: ['교통비', '회의비'] }, blocking: true }])
112
+ })
113
+ })
114
+
115
+ describe('changing what the person pressed', () => {
116
+ const card = async (changes: any[]) => ({ ...(await proposeChanges(expense, { changes }, { host: {} }, run)), key: 'u1' })
117
+ const deps = (proposal: any, over: Partial<EntryApplyDeps> = {}): EntryApplyDeps => ({
118
+ messages: [{ id: 'm1', proposals: [proposal] }],
119
+ answered: false,
120
+ passesDoor: async () => true,
121
+ execute: run,
122
+ state: {},
123
+ ...over
124
+ })
125
+
126
+ beforeEach(() => declareEntryTarget(expense))
127
+
128
+ it("★ reads each record again and changes it through the application's own mutation", async () => {
129
+ const proposal = await card([{ ref: 'l1', values: { amount: 13000 } }])
130
+ calls.length = 0
131
+ const outcome = await applyEntryProposal('u1', deps(proposal))
132
+ expect(outcome).toMatchObject({ verdict: 'accepted', applied: 1, missed: 0 })
133
+ expect(calls.map(one => one.document.match(/(myExpenseLines|changeExpenseLine)/)![1])).toEqual(['myExpenseLines', 'changeExpenseLine'])
134
+ expect(records.l1.amount).toBe(13000)
135
+ })
136
+
137
+ it('★ a record changed by someone else since the card was made is not overwritten unseen', async () => {
138
+ const proposal = await card([{ ref: 'l1', values: { amount: 13000 } }, { ref: 'l2', values: { amount: 9000 } }])
139
+ records.l1.amount = 15000
140
+ const outcome = await applyEntryProposal('u1', deps(proposal))
141
+ expect(outcome).toMatchObject({ verdict: 'accepted', applied: 1, missed: 1 })
142
+ expect(outcome.rows[0]).toEqual({ index: 0, ok: false, error: 'amount: changed-since' })
143
+ expect(records.l1.amount).toBe(15000)
144
+ expect(records.l2.amount).toBe(9000)
145
+ })
146
+
147
+ it("the application's own refusal is said by its place, and the others still change", async () => {
148
+ const proposal = await card([{ ref: 'closed', values: { amount: 6000 } }, { ref: 'l2', values: { amount: 9000 } }])
149
+ const outcome = await applyEntryProposal('u1', deps(proposal))
150
+ expect(outcome.rows[0]).toEqual({ index: 0, ok: false, error: 'a submitted claim cannot be changed' })
151
+ expect(outcome.rows[1]).toMatchObject({ ok: true, id: 'l2' })
152
+ })
153
+
154
+ it('★ both doors are asked again - the change mutation and the read query', async () => {
155
+ const proposal = await card([{ ref: 'l1', values: { amount: 13000 } }])
156
+ const asked: string[] = []
157
+ const outcome = await applyEntryProposal('u1', deps(proposal, { passesDoor: async door => (asked.push(door), door !== 'Query.myExpenseLines') }))
158
+ expect(outcome).toMatchObject({ verdict: 'refused', errorCode: 'door-closed' })
159
+ expect(asked).toEqual(['Mutation.changeExpenseLine', 'Query.myExpenseLines'])
160
+ expect(records.l1.amount).toBe('12400.00')
161
+ })
162
+
163
+ it('★ a newer 수정안 replaces an older one - an 입력안 of the same record does not', async () => {
164
+ const older = await card([{ ref: 'l1', values: { amount: 13000 } }])
165
+ const entry = { key: 'e1', kind: 'entry', target: 'office.expense-line', rows: [] }
166
+ const newer = { ...(await card([{ ref: 'l1', values: { amount: 14000 } }])), key: 'u2' }
167
+ expect(await applyEntryProposal('u1', deps(older, { messages: [{ id: 'm1', proposals: [older] }, { id: 'm2', proposals: [entry] }] }))).toMatchObject({ verdict: 'accepted' })
168
+ records.l1.amount = '12400.00'
169
+ expect(await applyEntryProposal('u1', deps(older, { messages: [{ id: 'm1', proposals: [older] }, { id: 'm2', proposals: [newer] }] }))).toMatchObject({ verdict: 'refused', errorCode: 'superseded' })
170
+ })
171
+ })
172
+
173
+ describe('declaring and offering', () => {
174
+ it('refuses an update whose documents could not work', () => {
175
+ expect(() => declareEntryTarget({ ...expense, update: { ...expense.update!, mutation: 'query { x { id } }' } })).toThrow(/update.mutation is not one mutation/)
176
+ expect(() => declareEntryTarget({ ...expense, update: { ...expense.update!, fields: ['nope'] } })).toThrow(/update field 'nope' is not declared/)
177
+ })
178
+
179
+ it("★ each tool carries the doors of what it reaches - changing needs both the change and the read", () => {
180
+ const specs = entryCategoryOf(expense, () => run).specs as any[]
181
+ expect(specs.map(spec => `${spec.name}:${spec.doors.join('+')}`)).toEqual([
182
+ 'proposeOfficeExpenseLineEntries:Mutation.addExpenseLine',
183
+ `${changeToolName('office.expense-line')}:Mutation.changeExpenseLine+Query.myExpenseLines`,
184
+ `${findToolName('office.expense-line')}:Query.myExpenseLines`
185
+ ])
186
+ })
187
+
188
+ it('stillAsProposed marks a record gone since as not found', () => {
189
+ const rows = stillAsProposed(expense, [{ index: 0, id: 'gone', via: 'id', before: { amount: 1 }, values: { amount: 2 }, issues: [] }], new Map())
190
+ expect(rows[0].issues).toEqual([{ code: 'record-not-found', blocking: true }])
191
+ })
192
+ })
@@ -11,7 +11,8 @@
11
11
  * Dependencies come in as arguments so the rules are tested without a database.
12
12
  */
13
13
  import { PROPOSAL_OUTCOME_PREFIX, PROPOSAL_VERDICTS } from '../chat-message/proposal-outcome.js'
14
- import { entryDoorOf, entryTargetOf, entryToolName, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
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
16
 
16
17
  export interface StoredProposalMessage {
17
18
  id: string
@@ -42,10 +43,10 @@ export interface EntryApplyDeps {
42
43
  answered: boolean
43
44
  passesDoor(door: string): Promise<boolean>
44
45
  /**
45
- * Runs the target's mutation for one row with the person's request state - the application's API, its
46
- * directives and its own checks. Answers what GraphQL answers.
46
+ * Runs one of the application's queries or mutations with the person's request state - its API, its directives
47
+ * and its own checks. Answers what GraphQL answers.
47
48
  */
48
- execute(mutation: string, variables: Record<string, unknown>): Promise<{ data?: any; errors?: readonly { message: string }[] }>
49
+ execute(document: string, variables: Record<string, unknown>): Promise<{ data?: any; errors?: readonly { message: string }[] }>
49
50
  state: any
50
51
  report?(message: string, error: unknown): void
51
52
  }
@@ -59,8 +60,10 @@ export function findEntryProposal(messages: StoredProposalMessage[], key: string
59
60
  for (const one of message.proposals ?? []) if (one?.key === key) (at = i), (proposal = one)
60
61
  })
61
62
  if (!proposal) return { superseded: false }
62
- /* A later card for the same record replaces this one - entering both would enter the corrected rows twice. */
63
- const superseded = messages.slice(at + 1).some(message => (message.proposals ?? []).some(one => one?.kind === 'entry' && one?.target === proposal.target))
63
+ /* A later card of the same record and the same work replaces this one - pressing both would do the corrected work twice. */
64
+ const superseded = messages
65
+ .slice(at + 1)
66
+ .some(message => (message.proposals ?? []).some(one => one?.kind === 'entry' && one?.target === proposal.target && (one?.op ?? 'create') === (proposal.op ?? 'create')))
64
67
  return { proposal, superseded }
65
68
  }
66
69
 
@@ -73,6 +76,7 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
73
76
 
74
77
  const target = targetOf(proposal.target)
75
78
  if (!target) return nothing('unavailable', 'unknown-entry-target')
79
+ if (proposal.op === 'update') return applyChanges(key, proposal, target, deps)
76
80
  const door = entryDoorOf(target.mutation)
77
81
  if (!(await deps.passesDoor(door))) {
78
82
  await recordEntryEvent(target, { phase: 'apply', tool: entryToolName(target.key), rows: 0, ok: false, error: 'door-closed', detail: { proposalKey: key } }, { state: deps.state })
@@ -132,3 +136,56 @@ export function entryProposalAnswered(key: string, lines: readonly (string | nul
132
136
  return PROPOSAL_VERDICTS.some(verdict => rest === verdict || rest.startsWith(`${verdict} `))
133
137
  })
134
138
  }
139
+
140
+ /**
141
+ * A pressed 수정안: the doors again, each record read again as the person, a field that moved since the card was
142
+ * made keeps its row out (`changed-since`), and each record is changed by the application's own mutation.
143
+ */
144
+ async function applyChanges(key: string, proposal: any, target: EntryTarget, deps: EntryApplyDeps): Promise<EntryApplyOutcome> {
145
+ const tool = changeToolName(target.key)
146
+ const update = target.update
147
+ if (!update) return nothing('unavailable', 'target-does-not-change')
148
+ for (const door of [rootFieldOf(update.mutation, 'mutation'), rootFieldOf(update.read.query, 'query')]) {
149
+ if (!(await deps.passesDoor(door))) {
150
+ await recordEntryEvent(target, { phase: 'apply', tool, rows: 0, ok: false, error: 'door-closed', detail: { proposalKey: key } }, { state: deps.state })
151
+ return nothing('refused', 'door-closed')
152
+ }
153
+ }
154
+
155
+ const stored: ChangeRow[] = proposal.rows ?? []
156
+ let now
157
+ try {
158
+ now = await readRecords(target, stored.map(row => row.id), deps.execute)
159
+ } catch (error) {
160
+ deps.report?.(`[ai-assistant] reading '${target.key}' again failed`, error)
161
+ return nothing('refused', 'read-failed')
162
+ }
163
+
164
+ const results: EntryRowResult[] = []
165
+ for (const row of stillAsProposed(target, stored, now)) {
166
+ if (row.issues.some(one => one.blocking)) {
167
+ const issue = row.issues.find(one => one.blocking)!
168
+ results.push({ index: row.index, ok: false, error: issue.field ? `${issue.field}: ${issue.code}` : issue.code })
169
+ continue
170
+ }
171
+ try {
172
+ const answer = await deps.execute(update.mutation, update.variables({ id: row.id, record: row.now!, values: row.values }))
173
+ if (answer.errors?.length) {
174
+ results.push({ index: row.index, ok: false, error: answer.errors.map(one => one.message).join('; ') || 'refused' })
175
+ continue
176
+ }
177
+ results.push({ index: row.index, ok: true, id: row.id, ...(row.label ? { label: row.label } : {}) })
178
+ } catch (error: any) {
179
+ results.push({ index: row.index, ok: false, error: error?.message || 'refused' })
180
+ }
181
+ }
182
+
183
+ const applied = results.filter(one => one.ok).length
184
+ const missed = results.length - applied
185
+ await recordEntryEvent(
186
+ target,
187
+ { phase: 'apply', tool, rows: results.length, ok: applied > 0, applied, missed, ...(applied ? {} : { error: 'no-row-changed' }), detail: { proposalKey: key, op: 'update', rows: results } },
188
+ { state: deps.state }
189
+ )
190
+ return applied ? { verdict: 'accepted', applied, missed, rows: results } : { verdict: 'refused', errorCode: 'no-row-changed', applied, missed, rows: results }
191
+ }
@@ -3,9 +3,7 @@ import { GraphQLJSON } from 'graphql-scalars'
3
3
  import { Like, Not, IsNull } from 'typeorm'
4
4
 
5
5
  import { logger } from '@things-factory/env'
6
- import gql from 'graphql-tag'
7
-
8
- import { getRepository, GraphqlLocalClient } from '@things-factory/shell'
6
+ import { getRepository } from '@things-factory/shell'
9
7
 
10
8
  import { ChatSession } from '../chat-session/chat-session.js'
11
9
  import { ChatMessage } from '../chat-message/chat-message.js'
@@ -14,6 +12,7 @@ import { isSessionParticipant } from '../chat-session-participant/participant-lo
14
12
  import { PROPOSAL_OUTCOME_PREFIX } from '../chat-message/proposal-outcome.js'
15
13
  import { toolAccessFor } from '../tool-access.js'
16
14
  import { applyEntryProposal, entryProposalAnswered, type EntryApplyOutcome } from './entry-apply.js'
15
+ import { runAsCaller } from './entry-tools.js'
17
16
 
18
17
  @Resolver()
19
18
  export class EntryProposalResolver {
@@ -61,13 +60,9 @@ export class EntryProposalResolver {
61
60
  /*
62
61
  * The application's API, as the person - the same request state the door would have seen had they typed
63
62
  * the row in: domain, user, and what their address and prohibitions say. No `tx` is handed on, so the
64
- * door opens its own transaction per row.
63
+ * door opens its own transaction per row (entry-tools.ts `runAsCaller`).
65
64
  */
66
- execute: async (mutation, variables) => {
67
- const { tx: _tx, afterCommit: _after, ...state } = context.state as any
68
- const result: any = await GraphqlLocalClient.client.mutate({ mutation: gql(mutation), variables, context: { state, t: context.t } })
69
- return { data: result.data, errors: result.errors }
70
- },
65
+ execute: runAsCaller(context),
71
66
  report: (message, error) => logger.error(message, error)
72
67
  })
73
68
  }
@@ -117,6 +117,32 @@ export interface EntryTarget {
117
117
  check?(rows: EntryRow[], context: EntryContext): EntryIssue[][] | Promise<EntryIssue[][]>
118
118
  /** Told of every proposal and every entering - an application that audits its assistant writes it down. A throw is ignored. */
119
119
  record?(event: EntryEvent, context: EntryContext): void | Promise<void>
120
+ /** Changing records that exist (design/ai/data-entry.md §5-1). Absent: this target only enters new ones. */
121
+ update?: EntryUpdate
122
+ }
123
+
124
+ /** One record as the application's read query answers it - an `id` and the fields. */
125
+ export type EntryRecord = { id: string } & Record<string, unknown>
126
+
127
+ /**
128
+ * How records of a target are changed - every step through the application's API, as the person.
129
+ *
130
+ * Which record is meant is the server's to settle, never the model's memory: whichever way it was picked
131
+ * (on screen, selected, described), the server reads it with `read` before a card is made and again before
132
+ * it is written.
133
+ */
134
+ export interface EntryUpdate {
135
+ /** 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[] }
137
+ /** 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[] }
139
+ /** The application's own mutation that changes one record. */
140
+ mutation: string
141
+ variables(change: { id: string; record: EntryRecord; values: Record<string, unknown> }): Record<string, unknown>
142
+ /** How a record is named on the card - "2026-09-12 · 카카오T". */
143
+ labelOf?(record: EntryRecord): string
144
+ /** The fields that may change. Default: every declared field. */
145
+ fields?: string[]
120
146
  }
121
147
 
122
148
  /**
@@ -124,12 +150,17 @@ export interface EntryTarget {
124
150
  * entering read. A document that is not one mutation with one root field is refused at boot.
125
151
  */
126
152
  export function entryDoorOf(mutation: string): string {
127
- const doc = parse(mutation)
153
+ return rootFieldOf(mutation, 'mutation')
154
+ }
155
+
156
+ /** `Query.<field>` or `Mutation.<field>` of a document with one operation and one root field. */
157
+ export function rootFieldOf(document: string, operation: 'query' | 'mutation'): string {
158
+ const doc = parse(document)
128
159
  const operations = doc.definitions.filter((one: any) => one.kind === 'OperationDefinition') as any[]
129
- if (operations.length !== 1 || operations[0].operation !== 'mutation') throw new Error('is not one mutation')
160
+ if (operations.length !== 1 || operations[0].operation !== operation) throw new Error(`is not one ${operation}`)
130
161
  const fields = operations[0].selectionSet.selections.filter((one: any) => one.kind === 'Field')
131
162
  if (fields.length !== 1) throw new Error('must have exactly one root field')
132
- return `Mutation.${fields[0].name.value}`
163
+ return `${operation === 'query' ? 'Query' : 'Mutation'}.${fields[0].name.value}`
133
164
  }
134
165
 
135
166
  /** Tells the target, and never lets its record keeping stop the work. */
@@ -157,6 +188,21 @@ export function declareEntryTarget(target: EntryTarget): void {
157
188
  throw new Error(`[ai-assistant] entry target '${target.key}': its mutation ${error?.message ?? 'does not parse'}.`)
158
189
  }
159
190
  if (typeof target.variables !== 'function') throw new Error(`[ai-assistant] entry target '${target.key}' has no variables.`)
191
+ if (target.update) {
192
+ for (const [what, document, operation] of [
193
+ ['update.read', target.update.read?.query, 'query'],
194
+ ['update.mutation', target.update.mutation, 'mutation'],
195
+ ...(target.update.find ? [['update.find', target.update.find.query, 'query']] : [])
196
+ ] as [string, string, 'query' | 'mutation'][]) {
197
+ try {
198
+ rootFieldOf(document ?? '', operation)
199
+ } catch (error: any) {
200
+ throw new Error(`[ai-assistant] entry target '${target.key}': ${what} ${error?.message ?? 'does not parse'}.`)
201
+ }
202
+ }
203
+ const known = new Set(target.fields.map(field => field.name))
204
+ for (const name of target.update.fields ?? []) if (!known.has(name)) throw new Error(`[ai-assistant] entry target '${target.key}': update field '${name}' is not declared.`)
205
+ }
160
206
  if (!target.fields?.length) throw new Error(`[ai-assistant] entry target '${target.key}' declares no fields.`)
161
207
  const names = new Set<string>()
162
208
  for (const field of target.fields) {
@@ -181,6 +227,17 @@ export function clearEntryTargets(): void {
181
227
  targets.clear()
182
228
  }
183
229
 
230
+ const pascalOf = (key: string) =>
231
+ key
232
+ .split(/[.-]/)
233
+ .filter(Boolean)
234
+ .map(part => part[0].toUpperCase() + part.slice(1))
235
+ .join('')
236
+
237
+ /** `office.expense-line` → `proposeOfficeExpenseLineChanges` / `findOfficeExpenseLine` - the tools of changing. */
238
+ export const changeToolName = (key: string) => `propose${pascalOf(key)}Changes`
239
+ export const findToolName = (key: string) => `find${pascalOf(key)}`
240
+
184
241
  /** `office.expense-line` → `proposeOfficeExpenseLineEntries` - one tool per target, so each has its own door. */
185
242
  export function entryToolName(key: string): string {
186
243
  const pascal = key
@@ -226,6 +283,41 @@ function toBoolean(value: unknown): boolean | undefined {
226
283
  return undefined
227
284
  }
228
285
 
286
+ /** One value in its field's type - or the code of why it is not. An empty value is the caller's to judge. */
287
+ export function readFieldValue(field: EntryField, value: unknown): { read?: unknown; code?: string } {
288
+ switch (field.type) {
289
+ case 'number': {
290
+ const read = toNumber(value)
291
+ return read === undefined ? { code: 'not-a-number' } : { read }
292
+ }
293
+ case 'integer': {
294
+ const read = toNumber(value)
295
+ return read === undefined || !Number.isInteger(read) ? { code: 'not-an-integer' } : { read }
296
+ }
297
+ case 'date': {
298
+ const read = toDate(value)
299
+ return read === undefined ? { code: 'not-a-date' } : { read }
300
+ }
301
+ case 'boolean': {
302
+ const read = toBoolean(value)
303
+ return read === undefined ? { code: 'not-a-boolean' } : { read }
304
+ }
305
+ case 'enum':
306
+ return field.values!.includes(String(value).trim()) ? { read: String(value).trim() } : { code: 'not-in-list' }
307
+ default:
308
+ return { read: String(value).trim() }
309
+ }
310
+ }
311
+
312
+ /** Two values of one field the same - "12400.00" read from the database is 12400. */
313
+ export function sameFieldValue(field: EntryField, a: unknown, b: unknown): boolean {
314
+ if (empty(a) && empty(b)) return true
315
+ if (empty(a) || empty(b)) return false
316
+ const x = readFieldValue(field, a)
317
+ const y = readFieldValue(field, b)
318
+ return x.read !== undefined && y.read !== undefined ? x.read === y.read : String(a) === String(b)
319
+ }
320
+
229
321
  /**
230
322
  * The rows as the target reads them - each value in its field's type, and what was wrong said as an issue.
231
323
  *
@@ -252,36 +344,10 @@ export function normalizeEntryRows(target: EntryTarget, input: unknown): { rows:
252
344
  if (field.required) issues.push({ field: field.name, code: 'required', blocking: true })
253
345
  continue
254
346
  }
255
- let read: unknown
256
- let code = ''
257
- switch (field.type) {
258
- case 'number':
259
- read = toNumber(value)
260
- code = 'not-a-number'
261
- break
262
- case 'integer':
263
- read = toNumber(value)
264
- if (read !== undefined && !Number.isInteger(read)) read = undefined
265
- code = 'not-an-integer'
266
- break
267
- case 'date':
268
- read = toDate(value)
269
- code = 'not-a-date'
270
- break
271
- case 'boolean':
272
- read = toBoolean(value)
273
- code = 'not-a-boolean'
274
- break
275
- case 'enum':
276
- read = field.values!.includes(String(value).trim()) ? String(value).trim() : undefined
277
- code = 'not-in-list'
278
- break
279
- default:
280
- read = String(value).trim()
281
- }
347
+ const { read, code } = readFieldValue(field, value)
282
348
  if (read === undefined) {
283
349
  values[field.name] = value
284
- issues.push({ field: field.name, code, params: code === 'not-in-list' ? { values: field.values } : undefined, blocking: true })
350
+ issues.push({ field: field.name, code: code!, params: code === 'not-in-list' ? { values: field.values } : undefined, blocking: true })
285
351
  } else {
286
352
  values[field.name] = read
287
353
  }
@@ -9,7 +9,11 @@
9
9
  */
10
10
  import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
11
11
 
12
+ import { findRecords, proposeChanges, changeableFields, type GraphqlRunner } from './entry-update.js'
12
13
  import {
14
+ changeToolName,
15
+ findToolName,
16
+ rootFieldOf,
13
17
  declareEntryTarget,
14
18
  entryTotals,
15
19
  verifyRowsAgainstSources,
@@ -99,7 +103,71 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
99
103
  }
100
104
  }
101
105
 
102
- export function entryCategoryOf(target: EntryTarget): ToolCategory {
106
+ /**
107
+ * The application's API as the person who asked - the tool's own request state, no transaction handed on.
108
+ * Loaded late so the rules are tested without the shell.
109
+ */
110
+ export const runAsCaller = (context: any): GraphqlRunner => async (document, variables) => {
111
+ const [{ GraphqlLocalClient }, { default: gql }] = await Promise.all([import('@things-factory/shell'), import('graphql-tag')])
112
+ const { tx: _tx, afterCommit: _after, ...state } = context?.state ?? {}
113
+ const operation = /^\s*mutation\b/.test(document) ? 'mutate' : 'query'
114
+ const result: any =
115
+ operation === 'mutate'
116
+ ? await GraphqlLocalClient.client.mutate({ mutation: gql(document), variables, context: { state, t: context?.t } })
117
+ : await GraphqlLocalClient.client.query({ query: gql(document), variables, context: { state, t: context?.t }, fetchPolicy: 'no-cache' })
118
+ return { data: result.data, errors: result.errors }
119
+ }
120
+
121
+ /** The two tools of changing records - finding candidates, and the 수정안 (design/ai/data-entry.md §5-1). */
122
+ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner): ToolSpec[] {
123
+ const update = target.update
124
+ if (!update) return []
125
+ const readDoor = rootFieldOf(update.read.query, 'query')
126
+ const specs: ToolSpec[] = [
127
+ {
128
+ kind: 'read',
129
+ name: changeToolName(target.key),
130
+ /* The person must be able to read the records and change them by hand. */
131
+ doors: [rootFieldOf(update.mutation, 'mutation'), readDoor],
132
+ description:
133
+ `Propose changes to existing records: ${target.description} ` +
134
+ 'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
135
+ 'The server reads each record, and the card shows what it holds now and what it becomes; nothing changes until the person presses it.',
136
+ schema: {
137
+ type: 'object',
138
+ properties: {
139
+ changes: {
140
+ type: 'array',
141
+ items: {
142
+ type: 'object',
143
+ properties: {
144
+ ref: { type: 'string', description: '"current", "selected", or a record id from a find tool - never a guessed id.' },
145
+ values: { type: 'object', description: 'Only the fields that change, with their new values.', properties: Object.fromEntries(changeableFields(target).map(field => [field.name, fieldSchema(field)])) }
146
+ },
147
+ required: ['ref', 'values']
148
+ }
149
+ },
150
+ note: { type: 'string', description: 'One line for the card.' }
151
+ },
152
+ required: ['changes']
153
+ },
154
+ builder: (args: any, context: any) => proposeChanges(target, args, context, run(context))
155
+ } as ToolSpec
156
+ ]
157
+ if (update.find) {
158
+ specs.push({
159
+ kind: 'read',
160
+ name: findToolName(target.key),
161
+ doors: [rootFieldOf(update.find.query, 'query')],
162
+ description: `Find existing records to change from what the person described: ${target.description} Returns candidates with their ids.`,
163
+ schema: { type: 'object', properties: Object.fromEntries(update.find.criteria.map(field => [field.name, fieldSchema(field)])) },
164
+ builder: (args: any, context: any) => findRecords(target, args, run(context))
165
+ } as ToolSpec)
166
+ }
167
+ return specs
168
+ }
169
+
170
+ export function entryCategoryOf(target: EntryTarget, run: (context: any) => GraphqlRunner = runAsCaller): ToolCategory {
103
171
  const name = entryToolName(target.key)
104
172
  const spec = {
105
173
  kind: 'read',
@@ -157,9 +225,15 @@ export function entryCategoryOf(target: EntryTarget): ToolCategory {
157
225
  '- 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.',
158
226
  '- When the material prints a total, pass it as declaredTotal exactly as printed - do not add it up yourself.',
159
227
  `- ${name} only proposes. Nothing is entered until the person presses the button on the card - never say the records were entered.`,
160
- `- 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.`
228
+ `- 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.`,
229
+ ...(target.update
230
+ ? [
231
+ `- To change records that exist, call ${changeToolName(target.key)}. Use ref "current" for the record on screen and "selected" for the rows selected there.${target.update.find ? ` Otherwise call ${findToolName(target.key)} first with what the person said; if more than one record matches, list them and ask which - never pick one yourself.` : ' Otherwise ask the person to open or select the record.'}`,
232
+ `- ${changeToolName(target.key)} only proposes; the card shows each record's value now and after. Never say a record was changed.`
233
+ ]
234
+ : [])
161
235
  ].join('\n'),
162
- specs: [spec]
236
+ specs: [spec, ...changeSpecs(target, run)]
163
237
  }
164
238
  }
165
239