@things-factory/ai-assistant 10.1.91 → 10.1.98
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.
- package/client/components/assistant-chat.ts +5 -3
- package/client/components/entry-review.ts +56 -0
- package/client/utils/entry-proposal.ts +20 -5
- package/dist-client/components/assistant-chat.js +5 -3
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/entry-review.d.ts +5 -0
- package/dist-client/components/entry-review.js +55 -0
- package/dist-client/components/entry-review.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/entry-proposal.d.ts +5 -0
- package/dist-client/utils/entry-proposal.js +16 -5
- package/dist-client/utils/entry-proposal.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.d.ts +3 -3
- package/dist-server/service/data-entry/entry-apply.js +55 -2
- package/dist-server/service/data-entry/entry-apply.js.map +1 -1
- package/dist-server/service/data-entry/entry-resolver.js +3 -7
- package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
- package/dist-server/service/data-entry/entry-target.d.ts +51 -0
- package/dist-server/service/data-entry/entry-target.js +77 -34
- package/dist-server/service/data-entry/entry-target.js.map +1 -1
- package/dist-server/service/data-entry/entry-tools.d.ts +7 -1
- package/dist-server/service/data-entry/entry-tools.js +79 -5
- package/dist-server/service/data-entry/entry-tools.js.map +1 -1
- package/dist-server/service/data-entry/entry-update.d.ts +99 -0
- package/dist-server/service/data-entry/entry-update.js +196 -0
- package/dist-server/service/data-entry/entry-update.js.map +1 -0
- package/dist-server/service/workspace/workspace-blocks.js +9 -1
- package/dist-server/service/workspace/workspace-blocks.js.map +1 -1
- package/dist-server/service/workspace/workspace-tools.js +43 -1
- package/dist-server/service/workspace/workspace-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/data-entry/data-entry-update.test.ts +207 -0
- package/server/service/data-entry/entry-apply.ts +63 -6
- package/server/service/data-entry/entry-resolver.ts +4 -9
- package/server/service/data-entry/entry-target.ts +97 -31
- package/server/service/data-entry/entry-tools.ts +83 -5
- package/server/service/data-entry/entry-update.ts +209 -0
- package/server/service/workspace/workspace-blocks.ts +8 -1
- package/server/service/workspace/workspace-tools.ts +43 -1
- package/server/service/workspace/workspace.test.ts +16 -0
- package/test/entry-proposal.test.ts +15 -2
- package/translations/en.json +16 -1
- package/translations/ja.json +16 -1
- package/translations/ko.json +16 -1
- package/translations/ms.json +16 -1
- 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.
|
|
3
|
+
"version": "10.1.98",
|
|
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": "
|
|
54
|
+
"gitHead": "6321b11b694621c8cf7ba105650071eb88e9beb7"
|
|
55
55
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('the tool schemas', () => {
|
|
195
|
+
/* Gemini's function schema refuses a list of types - one such field failed every office conversation (2026-10-01). */
|
|
196
|
+
const typesOf = (schema: any, path = ''): string[] =>
|
|
197
|
+
!schema || typeof schema !== 'object'
|
|
198
|
+
? []
|
|
199
|
+
: [
|
|
200
|
+
...(Array.isArray(schema.type) ? [`${path}: ${JSON.stringify(schema.type)}`] : []),
|
|
201
|
+
...Object.entries(schema).flatMap(([key, value]) => (typeof value === 'object' ? typesOf(value, `${path}/${key}`) : []))
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
it('★ every field has one type - no list of types anywhere in the tools', () => {
|
|
205
|
+
for (const spec of entryCategoryOf(expense, () => run).specs as any[]) expect(typesOf(spec.schema)).toEqual([])
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -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 {
|
|
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
|
|
46
|
-
*
|
|
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(
|
|
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
|
|
63
|
-
const superseded = messages
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 !==
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -26,7 +30,11 @@ import {
|
|
|
26
30
|
} from './entry-target.js'
|
|
27
31
|
|
|
28
32
|
function fieldSchema(field: EntryField): any {
|
|
29
|
-
|
|
33
|
+
/*
|
|
34
|
+
* One type per field - Gemini's function schema (an OpenAPI 3.0 subset) refuses a list of types, and the
|
|
35
|
+
* whole request with it: "12,400" or "₩12,400" is the model's to write as 12400 (2026-10-01, office).
|
|
36
|
+
*/
|
|
37
|
+
const type = field.type === 'number' ? 'number' : field.type === 'integer' ? 'integer' : field.type === 'boolean' ? 'boolean' : 'string'
|
|
30
38
|
const said = [
|
|
31
39
|
field.description,
|
|
32
40
|
field.required ? 'required' : 'optional',
|
|
@@ -99,7 +107,71 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
|
|
|
99
107
|
}
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The application's API as the person who asked - the tool's own request state, no transaction handed on.
|
|
112
|
+
* Loaded late so the rules are tested without the shell.
|
|
113
|
+
*/
|
|
114
|
+
export const runAsCaller = (context: any): GraphqlRunner => async (document, variables) => {
|
|
115
|
+
const [{ GraphqlLocalClient }, { default: gql }] = await Promise.all([import('@things-factory/shell'), import('graphql-tag')])
|
|
116
|
+
const { tx: _tx, afterCommit: _after, ...state } = context?.state ?? {}
|
|
117
|
+
const operation = /^\s*mutation\b/.test(document) ? 'mutate' : 'query'
|
|
118
|
+
const result: any =
|
|
119
|
+
operation === 'mutate'
|
|
120
|
+
? await GraphqlLocalClient.client.mutate({ mutation: gql(document), variables, context: { state, t: context?.t } })
|
|
121
|
+
: await GraphqlLocalClient.client.query({ query: gql(document), variables, context: { state, t: context?.t }, fetchPolicy: 'no-cache' })
|
|
122
|
+
return { data: result.data, errors: result.errors }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The two tools of changing records - finding candidates, and the 수정안 (design/ai/data-entry.md §5-1). */
|
|
126
|
+
function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner): ToolSpec[] {
|
|
127
|
+
const update = target.update
|
|
128
|
+
if (!update) return []
|
|
129
|
+
const readDoor = rootFieldOf(update.read.query, 'query')
|
|
130
|
+
const specs: ToolSpec[] = [
|
|
131
|
+
{
|
|
132
|
+
kind: 'read',
|
|
133
|
+
name: changeToolName(target.key),
|
|
134
|
+
/* The person must be able to read the records and change them by hand. */
|
|
135
|
+
doors: [rootFieldOf(update.mutation, 'mutation'), readDoor],
|
|
136
|
+
description:
|
|
137
|
+
`Propose changes to existing records: ${target.description} ` +
|
|
138
|
+
'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
|
|
139
|
+
'The server reads each record, and the card shows what it holds now and what it becomes; nothing changes until the person presses it.',
|
|
140
|
+
schema: {
|
|
141
|
+
type: 'object',
|
|
142
|
+
properties: {
|
|
143
|
+
changes: {
|
|
144
|
+
type: 'array',
|
|
145
|
+
items: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
ref: { type: 'string', description: '"current", "selected", or a record id from a find tool - never a guessed id.' },
|
|
149
|
+
values: { type: 'object', description: 'Only the fields that change, with their new values.', properties: Object.fromEntries(changeableFields(target).map(field => [field.name, fieldSchema(field)])) }
|
|
150
|
+
},
|
|
151
|
+
required: ['ref', 'values']
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
note: { type: 'string', description: 'One line for the card.' }
|
|
155
|
+
},
|
|
156
|
+
required: ['changes']
|
|
157
|
+
},
|
|
158
|
+
builder: (args: any, context: any) => proposeChanges(target, args, context, run(context))
|
|
159
|
+
} as ToolSpec
|
|
160
|
+
]
|
|
161
|
+
if (update.find) {
|
|
162
|
+
specs.push({
|
|
163
|
+
kind: 'read',
|
|
164
|
+
name: findToolName(target.key),
|
|
165
|
+
doors: [rootFieldOf(update.find.query, 'query')],
|
|
166
|
+
description: `Find existing records to change from what the person described: ${target.description} Returns candidates with their ids.`,
|
|
167
|
+
schema: { type: 'object', properties: Object.fromEntries(update.find.criteria.map(field => [field.name, fieldSchema(field)])) },
|
|
168
|
+
builder: (args: any, context: any) => findRecords(target, args, run(context))
|
|
169
|
+
} as ToolSpec)
|
|
170
|
+
}
|
|
171
|
+
return specs
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function entryCategoryOf(target: EntryTarget, run: (context: any) => GraphqlRunner = runAsCaller): ToolCategory {
|
|
103
175
|
const name = entryToolName(target.key)
|
|
104
176
|
const spec = {
|
|
105
177
|
kind: 'read',
|
|
@@ -137,7 +209,7 @@ export function entryCategoryOf(target: EntryTarget): ToolCategory {
|
|
|
137
209
|
description: 'The total the material itself prints, when it prints one - the card compares it with the sum of the rows. Never compute it yourself.',
|
|
138
210
|
properties: {
|
|
139
211
|
field: { type: 'string', description: `Which field it totals${target.fields.filter(field => field.total).length ? ` (${target.fields.filter(field => field.total).map(field => field.name).join(', ')})` : ''}.` },
|
|
140
|
-
value: { type:
|
|
212
|
+
value: { type: 'number' },
|
|
141
213
|
attachmentId: { type: 'string' },
|
|
142
214
|
where: { type: 'string' }
|
|
143
215
|
}
|
|
@@ -157,9 +229,15 @@ export function entryCategoryOf(target: EntryTarget): ToolCategory {
|
|
|
157
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.',
|
|
158
230
|
'- When the material prints a total, pass it as declaredTotal exactly as printed - do not add it up yourself.',
|
|
159
231
|
`- ${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
|
|
232
|
+
`- 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
|
+
...(target.update
|
|
234
|
+
? [
|
|
235
|
+
`- 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.'}`,
|
|
236
|
+
`- ${changeToolName(target.key)} only proposes; the card shows each record's value now and after. Never say a record was changed.`
|
|
237
|
+
]
|
|
238
|
+
: [])
|
|
161
239
|
].join('\n'),
|
|
162
|
-
specs: [spec]
|
|
240
|
+
specs: [spec, ...changeSpecs(target, run)]
|
|
163
241
|
}
|
|
164
242
|
}
|
|
165
243
|
|