@things-factory/ai-assistant 10.1.88 → 10.1.90

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 (64) hide show
  1. package/client/components/assistant-chat.ts +19 -5
  2. package/client/components/assistant-dock.ts +21 -1
  3. package/client/components/entry-review.ts +246 -0
  4. package/client/utils/assistant-proposals.ts +11 -1
  5. package/client/utils/entry-proposal.ts +103 -0
  6. package/dist-client/components/assistant-chat.d.ts +1 -0
  7. package/dist-client/components/assistant-chat.js +19 -5
  8. package/dist-client/components/assistant-chat.js.map +1 -1
  9. package/dist-client/components/assistant-dock.js +16 -1
  10. package/dist-client/components/assistant-dock.js.map +1 -1
  11. package/dist-client/components/entry-review.d.ts +9 -0
  12. package/dist-client/components/entry-review.js +247 -0
  13. package/dist-client/components/entry-review.js.map +1 -0
  14. package/dist-client/tsconfig.tsbuildinfo +1 -1
  15. package/dist-client/utils/assistant-proposals.d.ts +1 -0
  16. package/dist-client/utils/assistant-proposals.js +9 -1
  17. package/dist-client/utils/assistant-proposals.js.map +1 -1
  18. package/dist-client/utils/entry-proposal.d.ts +55 -0
  19. package/dist-client/utils/entry-proposal.js +52 -0
  20. package/dist-client/utils/entry-proposal.js.map +1 -0
  21. package/dist-server/service/assistant-mode/registry.js +13 -0
  22. package/dist-server/service/assistant-mode/registry.js.map +1 -1
  23. package/dist-server/service/assistant-mode/resolve.d.ts +11 -0
  24. package/dist-server/service/assistant-mode/resolve.js +13 -3
  25. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  26. package/dist-server/service/assistant-mode/types.d.ts +7 -1
  27. package/dist-server/service/assistant-mode/types.js.map +1 -1
  28. package/dist-server/service/data-entry/entry-apply.d.ts +46 -0
  29. package/dist-server/service/data-entry/entry-apply.js +102 -0
  30. package/dist-server/service/data-entry/entry-apply.js.map +1 -0
  31. package/dist-server/service/data-entry/entry-resolver.d.ts +4 -0
  32. package/dist-server/service/data-entry/entry-resolver.js +80 -0
  33. package/dist-server/service/data-entry/entry-resolver.js.map +1 -0
  34. package/dist-server/service/data-entry/entry-sources.d.ts +1 -0
  35. package/dist-server/service/data-entry/entry-sources.js +41 -0
  36. package/dist-server/service/data-entry/entry-sources.js.map +1 -0
  37. package/dist-server/service/data-entry/entry-target.d.ts +157 -0
  38. package/dist-server/service/data-entry/entry-target.js +343 -0
  39. package/dist-server/service/data-entry/entry-target.js.map +1 -0
  40. package/dist-server/service/data-entry/entry-tools.d.ts +43 -0
  41. package/dist-server/service/data-entry/entry-tools.js +185 -0
  42. package/dist-server/service/data-entry/entry-tools.js.map +1 -0
  43. package/dist-server/service/index.d.ts +4 -1
  44. package/dist-server/service/index.js +10 -2
  45. package/dist-server/service/index.js.map +1 -1
  46. package/dist-server/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +2 -2
  48. package/server/service/assistant-mode/registry.ts +13 -0
  49. package/server/service/assistant-mode/resolve.ts +13 -3
  50. package/server/service/assistant-mode/types.ts +7 -1
  51. package/server/service/data-entry/data-entry.test.ts +311 -0
  52. package/server/service/data-entry/entry-apply.ts +134 -0
  53. package/server/service/data-entry/entry-resolver.ts +74 -0
  54. package/server/service/data-entry/entry-sources.ts +35 -0
  55. package/server/service/data-entry/entry-target.ts +442 -0
  56. package/server/service/data-entry/entry-tools.ts +174 -0
  57. package/server/service/index.ts +7 -1
  58. package/test/entry-proposal.test.ts +67 -0
  59. package/test/open-doors.test.ts +2 -1
  60. package/translations/en.json +36 -1
  61. package/translations/ja.json +36 -1
  62. package/translations/ko.json +36 -1
  63. package/translations/ms.json +36 -1
  64. package/translations/zh.json +36 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.88",
3
+ "version": "10.1.90",
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": "11ece44d38c469f73aa28f4789f4baa244545097"
54
+ "gitHead": "cc35c48a40020554ceed0b1801dc7955fe027e79"
55
55
  }
@@ -11,6 +11,7 @@
11
11
  * registration: `getAssistantModeWarnings()` is the answer as of now, and the boot report reads it
12
12
  * after every module is in.
13
13
  */
14
+ import { entryTargetOf } from '../data-entry/entry-target.js'
14
15
  import { getToolSpecsFor } from '@things-factory/ai-client-base'
15
16
 
16
17
  import type { AssistantModeSpec, AssistantModeWarning } from './types'
@@ -104,6 +105,18 @@ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning>
104
105
  }
105
106
  }
106
107
 
108
+ for (const mode of modes.values()) {
109
+ /* A record the mode says it enters, which no app registered - the tool never appears and the person is told nothing. */
110
+ for (const key of mode.entryTargets ?? []) {
111
+ if (entryTargetOf(key)) continue
112
+ out.push({
113
+ code: 'assistant-mode-entry-target-unknown',
114
+ params: { kind: mode.kind, target: key },
115
+ message: `mode '${mode.kind}' enters '${key}', which no application registered with registerEntryTarget`
116
+ })
117
+ }
118
+ }
119
+
107
120
  for (const mode of modes.values()) {
108
121
  const exposed = new Set(getToolSpecsFor(mode.toolCategories ?? []).map(spec => spec.name))
109
122
  for (const slash of mode.slashTemplates ?? []) {
@@ -43,6 +43,18 @@ export interface ModeSettings {
43
43
  maxTokens?: number
44
44
  }
45
45
 
46
+ /**
47
+ * The categories a mode opens: its own, the attachment reader when it takes files (design/ai/shared-dock.md
48
+ * §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
49
+ */
50
+ export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[] }): string[] | undefined {
51
+ const extra = [
52
+ ...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
53
+ ...(mode.entryTargets ?? []).map(key => `data-entry:${key}`)
54
+ ]
55
+ return extra.length ? [...(mode.toolCategories ?? []), ...extra] : mode.toolCategories
56
+ }
57
+
46
58
  /** The conversation's settings, from the server's declaration. */
47
59
  export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSettings {
48
60
  const kind = String(ref?.kind ?? '')
@@ -53,9 +65,7 @@ export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSe
53
65
  return {
54
66
  systemPrompt: mode.systemPrompt,
55
67
  /* A mode that takes attachments can open the files sent in earlier turns (design/ai/shared-dock.md §9-4). */
56
- toolCategories: mode.attachments?.kinds?.length
57
- ? [...(mode.toolCategories ?? []), 'chat-attachments']
58
- : mode.toolCategories,
68
+ toolCategories: modeToolCategories(mode),
59
69
  requireToolOnFirstTurn: mode.requireToolOnFirstTurn,
60
70
  model: mode.model,
61
71
  temperature: mode.temperature,
@@ -100,6 +100,11 @@ export interface AssistantModeSpec {
100
100
  * screen offers no attach button, so no file is taken that the model would never see.
101
101
  */
102
102
  attachments?: AttachmentPolicy
103
+ /**
104
+ * Which records this conversation may enter from material (design/ai/data-entry.md) - keys registered
105
+ * with `registerEntryTarget`. Each opens that target's propose tool; its door still decides who gets it.
106
+ */
107
+ entryTargets?: string[]
103
108
  /** i18n key for the line under an empty conversation. */
104
109
  footerNoticeKey?: string
105
110
  /** i18n key for the line shown once the host reports a proposal staged. */
@@ -121,7 +126,8 @@ export interface AssistantModeWarning {
121
126
  | 'assistant-mode-subject-without-default'
122
127
  | 'assistant-mode-subject-default-twice'
123
128
  | 'assistant-mode-offered-without-label'
124
- params: { kind: string; slash?: string; tool?: string; subject?: string; kinds?: string[] }
129
+ | 'assistant-mode-entry-target-unknown'
130
+ params: { kind: string; slash?: string; tool?: string; subject?: string; kinds?: string[]; target?: string }
125
131
  /** For a person reading the boot log; callers act on the code and params. */
126
132
  message: string
127
133
  }
@@ -0,0 +1,311 @@
1
+ /*
2
+ * Data entry from material (design/ai/data-entry.md) - the rules a row passes, what the tool hands back,
3
+ * and what the server asks again before it writes.
4
+ */
5
+ import { clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
6
+ import { entryCategoryOf, proposeEntries } from './entry-tools'
7
+ import { applyEntryProposal, entryProposalAnswered, findEntryProposal, type EntryApplyDeps } from './entry-apply'
8
+ import { modeToolCategories } from '../assistant-mode/resolve'
9
+
10
+ const written: any[] = []
11
+
12
+ const expense: EntryTarget = {
13
+ key: 'office.expense-line',
14
+ labelKey: 'office.entry.expense-line',
15
+ description: 'one expense: when, what for, how much.',
16
+ mutation: 'mutation ($line: NewExpenseLine!) { addExpenseLine(line: $line) { id } }',
17
+ variables: row => ({ line: row.values }),
18
+ appliedOf: (data, row) => ({ id: data?.addExpenseLine?.id, label: String(row.values.amount) }),
19
+ fields: [
20
+ { name: 'spentOn', type: 'date', required: true },
21
+ { name: 'amount', type: 'number', required: true, total: true },
22
+ { name: 'category', type: 'enum', values: ['taxi', 'meal'] },
23
+ { name: 'memo', type: 'string' }
24
+ ],
25
+ check: rows => rows.map(row => (row.values.memo === 'dup' ? [{ code: 'duplicate', blocking: true }] : []))
26
+ }
27
+
28
+ /* The application's API, as a fake: it refuses a memo 'refuse' the way a door refuses - with a GraphQL error. */
29
+ const api = async (mutation: string, variables: any) => {
30
+ asked.push({ mutation, variables })
31
+ if (variables.line.memo === 'refuse') return { errors: [{ message: 'closed period' }] }
32
+ written.push(variables.line)
33
+ return { data: { addExpenseLine: { id: `id-${written.length - 1}` } } }
34
+ }
35
+ const asked: any[] = []
36
+
37
+ beforeEach(() => {
38
+ written.length = 0
39
+ asked.length = 0
40
+ clearEntryTargets()
41
+ })
42
+
43
+ describe('declaring a target', () => {
44
+ it('★ refuses a declaration that could not work - at boot, not in a conversation', () => {
45
+ expect(() => declareEntryTarget({ ...expense, key: 'expense' })).toThrow(/<app>\.<record>/)
46
+ expect(() => declareEntryTarget({ ...expense, mutation: 'query { expenseLines { id } }' })).toThrow(/is not one mutation/)
47
+ expect(() => declareEntryTarget({ ...expense, mutation: 'mutation { a { id } b { id } }' })).toThrow(/exactly one root field/)
48
+ expect(() => declareEntryTarget({ ...expense, mutation: 'mutation {' })).toThrow(/its mutation/)
49
+ expect(() => declareEntryTarget({ ...expense, fields: [] })).toThrow(/no fields/)
50
+ expect(() => declareEntryTarget({ ...expense, fields: [{ name: 'c', type: 'enum' }] })).toThrow(/lists no values/)
51
+ })
52
+
53
+ it('one tool per target, named for it', () => {
54
+ expect(entryToolName('office.expense-line')).toBe('proposeOfficeExpenseLineEntries')
55
+ })
56
+
57
+ it("★ the door is the mutation's own root field - one declaration, read by the tool's gate and by the entering", () => {
58
+ expect(entryDoorOf(expense.mutation)).toBe('Mutation.addExpenseLine')
59
+ })
60
+
61
+ it("★ the tool carries the target's door - it is offered only to someone who could enter the records by hand", () => {
62
+ const category = entryCategoryOf(expense)
63
+ expect(category.name).toBe('data-entry:office.expense-line')
64
+ expect((category.specs[0] as any).doors).toEqual(['Mutation.addExpenseLine'])
65
+ expect(category.guidance).toMatch(/never say the records were entered/)
66
+ })
67
+
68
+ it('a mode opens a target by naming it', () => {
69
+ expect(modeToolCategories({ toolCategories: ['office-ops'], entryTargets: ['office.expense-line'] })).toEqual(['office-ops', 'data-entry:office.expense-line'])
70
+ expect(modeToolCategories({ toolCategories: ['office-ops'] })).toEqual(['office-ops'])
71
+ })
72
+ })
73
+
74
+ describe('reading the rows', () => {
75
+ it('puts each value in its type - the separators people write are read', () => {
76
+ const { rows } = normalizeEntryRows(expense, [{ values: { spentOn: '2026.9.12', amount: '₩12,400', category: 'taxi' }, sources: [{ where: 'row 1' }] }])
77
+ expect(rows[0].values).toEqual({ spentOn: '2026-09-12', amount: 12400, category: 'taxi' })
78
+ expect(rows[0].issues).toEqual([])
79
+ })
80
+
81
+ it('★ a value that does not fit is kept as written and marked - never dropped or guessed', () => {
82
+ const { rows } = normalizeEntryRows(expense, [{ values: { spentOn: '2026-02-30', amount: 'twelve', category: 'hotel' }, sources: [{ where: 'row 2' }] }])
83
+ expect(rows[0].values).toEqual({ spentOn: '2026-02-30', amount: 'twelve', category: 'hotel' })
84
+ expect(rows[0].issues.map(issue => `${issue.field}:${issue.code}:${issue.blocking}`)).toEqual([
85
+ 'spentOn:not-a-date:true',
86
+ 'amount:not-a-number:true',
87
+ 'category:not-in-list:true'
88
+ ])
89
+ })
90
+
91
+ it('★ a missing required value blocks the row; a missing source is only said', () => {
92
+ const { rows } = normalizeEntryRows(expense, [{ values: { amount: 1000 } }])
93
+ expect(rows[0].issues).toEqual([
94
+ { field: 'spentOn', code: 'required', blocking: true },
95
+ { code: 'no-source', blocking: false }
96
+ ])
97
+ })
98
+
99
+ it('cuts at the limit and says how many', () => {
100
+ const { rows, cut } = normalizeEntryRows({ ...expense, maxRows: 2 }, [{}, {}, {}, {}])
101
+ expect(rows).toHaveLength(2)
102
+ expect(cut).toBe(2)
103
+ })
104
+ })
105
+
106
+ describe('proposing', () => {
107
+ it("★ hands back a card, never a write - with the target's own findings on each row", async () => {
108
+ const result: any = await proposeEntries(
109
+ expense,
110
+ {
111
+ rows: [
112
+ { values: { spentOn: '2026-09-12', amount: 12400 }, sources: [{ attachmentId: 'a1', where: 'row 1' }] },
113
+ { values: { spentOn: '2026-09-13', amount: 9000, memo: 'dup' }, sources: [{ attachmentId: 'a1', where: 'row 2' }] }
114
+ ]
115
+ },
116
+ { state: {} },
117
+ async () => new Map()
118
+ )
119
+ expect(result.proposed).toBe(true)
120
+ expect(result.kind).toBe('entry')
121
+ expect(result.target).toBe('office.expense-line')
122
+ expect(result.counts).toMatchObject({ rows: 2, ready: 1, blocked: 1, cut: 0 })
123
+ expect(result.rows[1].issues).toEqual([{ code: 'source-not-checked', blocking: false }, { code: 'duplicate', blocking: true }])
124
+ expect(written).toEqual([])
125
+ })
126
+
127
+ it('no rows is refused, with what to do', async () => {
128
+ const result: any = await proposeEntries(expense, { rows: [] }, { state: {} })
129
+ expect(result).toMatchObject({ rejected: true, error: 'no-rows' })
130
+ })
131
+ })
132
+
133
+ describe('entering what the person pressed', () => {
134
+ const proposal = (key: string, rows: any[]) => ({ key, kind: 'entry', target: 'office.expense-line', rows })
135
+ const row = (values: any) => ({ values, sources: [{ where: 'row' }] })
136
+ const deps = (messages: any[], over: Partial<EntryApplyDeps> = {}): EntryApplyDeps => ({
137
+ messages,
138
+ answered: false,
139
+ passesDoor: async () => true,
140
+ execute: api,
141
+ state: {},
142
+ ...over
143
+ })
144
+
145
+ beforeEach(() => declareEntryTarget(expense))
146
+
147
+ it("★ enters each row through the application's own mutation - a refused row keeps the others, and each is reported by its place", async () => {
148
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 1 }), row({ spentOn: '2026-09-12', amount: 2, memo: 'refuse' }), row({ spentOn: '2026-09-12', amount: 3 })])] }]
149
+ const outcome = await applyEntryProposal('k1', deps(messages))
150
+ expect(outcome).toMatchObject({ verdict: 'accepted', applied: 2, missed: 1 })
151
+ expect(outcome.rows).toEqual([
152
+ { index: 0, ok: true, id: 'id-0', label: '1' },
153
+ { index: 1, ok: false, error: 'closed period' },
154
+ { index: 2, ok: true, id: 'id-1', label: '3' }
155
+ ])
156
+ expect(asked.map(one => one.mutation)).toEqual([expense.mutation, expense.mutation, expense.mutation])
157
+ expect(asked[0].variables).toEqual({ line: { spentOn: '2026-09-12', amount: 1 } })
158
+ })
159
+
160
+ it('★ asks the checks again - a record entered since the card was made keeps its row out', async () => {
161
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 1, memo: 'dup' })])] }]
162
+ const outcome = await applyEntryProposal('k1', deps(messages))
163
+ expect(outcome).toMatchObject({ verdict: 'refused', errorCode: 'no-row-entered', applied: 0, missed: 1 })
164
+ expect(outcome.rows[0]).toEqual({ index: 0, ok: false, error: 'duplicate' })
165
+ expect(written).toEqual([])
166
+ })
167
+
168
+ it('★ a newer card for the same record replaces this one', async () => {
169
+ const messages = [
170
+ { id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 1 })])] },
171
+ { id: 'm2', proposals: [proposal('k2', [row({ spentOn: '2026-09-12', amount: 10 })])] }
172
+ ]
173
+ expect(findEntryProposal(messages, 'k1').superseded).toBe(true)
174
+ expect(await applyEntryProposal('k1', deps(messages))).toMatchObject({ verdict: 'refused', errorCode: 'superseded' })
175
+ expect(await applyEntryProposal('k2', deps(messages))).toMatchObject({ verdict: 'accepted', applied: 1 })
176
+ })
177
+
178
+ it('★ the door is asked again at the moment of writing', async () => {
179
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 1 })])] }]
180
+ expect(await applyEntryProposal('k1', deps(messages, { passesDoor: async () => false }))).toMatchObject({ verdict: 'refused', errorCode: 'door-closed' })
181
+ expect(written).toEqual([])
182
+ })
183
+
184
+ it('★ an answered card is not entered twice', async () => {
185
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 1 })])] }]
186
+ expect(await applyEntryProposal('k1', deps(messages, { answered: true }))).toMatchObject({ verdict: 'refused', errorCode: 'already-answered' })
187
+ expect(written).toEqual([])
188
+ })
189
+
190
+ it("'requested' alone is not an answer - the dock writes it just before pressing", () => {
191
+ expect(entryProposalAnswered('k1', ['proposal k1: requested'])).toBe(false)
192
+ expect(entryProposalAnswered('k1', ['proposal k1: requested', 'proposal k1: accepted 2/3'])).toBe(true)
193
+ expect(entryProposalAnswered('k1', ['proposal k10: accepted'])).toBe(false)
194
+ })
195
+
196
+ it('a target no longer registered is unavailable; a key nobody proposed is refused', async () => {
197
+ const messages = [{ id: 'm1', proposals: [{ ...proposal('k1', []), target: 'office.gone' }] }]
198
+ expect(await applyEntryProposal('k1', deps(messages))).toMatchObject({ verdict: 'unavailable', errorCode: 'unknown-entry-target' })
199
+ expect(await applyEntryProposal('nope', deps(messages))).toMatchObject({ verdict: 'refused', errorCode: 'proposal-not-found' })
200
+ })
201
+ })
202
+
203
+ describe('checking the rows against the material', () => {
204
+ const csv = ['date,shop,amount', '2026-09-12,Kakao T,"12,400"', '2026.09.13,Starbucks,9000', '2026-09-14,Hotel,112400', '', 'total,,"21,400"'].join('\n')
205
+ const texts = new Map([['a1', csv]])
206
+ const rowsOf = (list: any[]) => normalizeEntryRows(expense, list).rows
207
+
208
+ it('writes a number and a date the ways a file does', () => {
209
+ expect(writtenForms('number', 12400)).toEqual(expect.arrayContaining(['12400', '12,400', '12,400.00']))
210
+ expect(writtenForms('date', '2026-09-03')).toEqual(expect.arrayContaining(['2026-09-03', '2026.09.03', '2026.9.3', '20260903']))
211
+ })
212
+
213
+ it("★ finds a number only as a whole - not inside a longer one, and a CSV's commas are not part of it", () => {
214
+ expect(lineHas('2026-09-12,Kakao T,12400', ['12400'])).toBe(true)
215
+ expect(lineHas('2026-09-12,Hotel,112400', ['12400'])).toBe(false)
216
+ expect(lineHas('12,400,000', ['12,400'])).toBe(false)
217
+ expect(lineHas('12,400.50', ['12,400'])).toBe(false)
218
+ expect(lineHas('9000,2026-09-13', ['9000'])).toBe(true)
219
+ })
220
+
221
+ it('★ a row whose numbers and dates stand together on one line of its file carries that line as evidence', () => {
222
+ const [row] = verifyRowsAgainstSources(rowsOf([{ values: { spentOn: '2026-09-13', amount: 9000 }, sources: [{ attachmentId: 'a1', where: 'row 3' }] }]), expense.fields, texts)
223
+ expect(row.evidence).toEqual({ attachmentId: 'a1', line: 3 })
224
+ expect(row.issues).toEqual([])
225
+ })
226
+
227
+ it('★ a value the file does not have is marked - the model misread 12,400 as 12,800', () => {
228
+ const [row] = verifyRowsAgainstSources(rowsOf([{ values: { spentOn: '2026-09-12', amount: 12800 }, sources: [{ attachmentId: 'a1', where: 'row 2' }] }]), expense.fields, texts)
229
+ expect(row.evidence).toBeUndefined()
230
+ expect(row.issues).toEqual([{ field: 'amount', code: 'not-in-source', blocking: false }])
231
+ })
232
+
233
+ it('a value the person corrected in the conversation is not marked against the file', () => {
234
+ const [row] = verifyRowsAgainstSources(
235
+ rowsOf([{ values: { spentOn: '2026-09-12', amount: 12000 }, sources: [{ attachmentId: 'a1', where: 'row 2' }, { where: "the person's message" }] }]),
236
+ expense.fields,
237
+ texts
238
+ )
239
+ expect(row.issues).toEqual([{ code: 'source-scattered', blocking: false }])
240
+ })
241
+
242
+ it('a photo cannot be read here - the row says it was not checked', () => {
243
+ const [row] = verifyRowsAgainstSources(rowsOf([{ values: { spentOn: '2026-09-12', amount: 1 }, sources: [{ attachmentId: 'photo' }] }]), expense.fields, texts)
244
+ expect(row.issues).toEqual([{ code: 'source-not-checked', blocking: false }])
245
+ })
246
+
247
+ it("★ the sum of the rows against the total the material prints - and whether that total is in the file", () => {
248
+ const rows = rowsOf([
249
+ { values: { spentOn: '2026-09-12', amount: 12400 }, sources: [{ where: 'x' }] },
250
+ { values: { spentOn: '2026-09-13', amount: 9000 }, sources: [{ where: 'x' }] },
251
+ { values: { amount: 500 }, sources: [{ where: 'x' }] }
252
+ ])
253
+ 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
+ ])
256
+ 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
+ })
259
+
260
+ it('the tool reads the cited files and puts the check and the totals on the card', async () => {
261
+ const asked: string[][] = []
262
+ const result: any = await proposeEntries(
263
+ expense,
264
+ {
265
+ rows: [
266
+ { values: { spentOn: '2026-09-12', amount: '12,400' }, sources: [{ attachmentId: 'a1', where: 'row 2' }] },
267
+ { values: { spentOn: '2026-09-13', amount: 9500 }, sources: [{ attachmentId: 'a1', where: 'row 3' }] }
268
+ ],
269
+ declaredTotal: { field: 'amount', value: 21400, attachmentId: 'a1' }
270
+ },
271
+ { state: {} },
272
+ async ids => (asked.push(ids), texts)
273
+ )
274
+ expect(asked[0]).toEqual(['a1', 'a1', 'a1'])
275
+ expect(result.counts).toMatchObject({ verified: 1, unmatched: 1 })
276
+ 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 }])
278
+ expect(result.message).toMatch(/differs from the rows by -500/)
279
+ })
280
+ })
281
+
282
+ describe('telling the application', () => {
283
+ it('★ every proposal and every entering is told - an application that audits its assistant writes both down', async () => {
284
+ const events: any[] = []
285
+ const audited: EntryTarget = { ...expense, record: event => void events.push(event) }
286
+ declareEntryTarget(audited)
287
+ const proposed: any = await proposeEntries(audited, { rows: [{ values: { spentOn: '2026-09-12', amount: 1 }, sources: [{ where: 'x' }] }] }, { state: {} }, async () => new Map())
288
+ await applyEntryProposal('k1', {
289
+ messages: [{ id: 'm1', proposals: [{ ...proposed, key: 'k1' }] }],
290
+ answered: false,
291
+ passesDoor: async () => true,
292
+ execute: api,
293
+ state: {}
294
+ })
295
+ await applyEntryProposal('k1', {
296
+ messages: [{ id: 'm1', proposals: [{ ...proposed, key: 'k1' }] }],
297
+ answered: false,
298
+ passesDoor: async () => false,
299
+ execute: api,
300
+ state: {}
301
+ })
302
+ expect(events.map(event => `${event.phase}:${event.ok}:${event.applied ?? ''}:${event.error ?? ''}`)).toEqual(['propose:true::', 'apply:true:1:', 'apply:false::door-closed'])
303
+ expect(events[0].tool).toBe('proposeOfficeExpenseLineEntries')
304
+ })
305
+
306
+ it('a record keeping that throws does not stop the work', async () => {
307
+ const loud: EntryTarget = { ...expense, record: () => { throw new Error('audit down') } }
308
+ const result: any = await proposeEntries(loud, { rows: [{ values: { spentOn: '2026-09-12', amount: 1 }, sources: [{ where: 'x' }] }] }, { state: {} }, async () => new Map())
309
+ expect(result.proposed).toBe(true)
310
+ })
311
+ })
@@ -0,0 +1,134 @@
1
+ /*
2
+ * Writing a proposal the person pressed (operato-application/design/ai/data-entry.md §4).
3
+ *
4
+ * The rows written are the **stored** proposal's, never rows the screen sends: what the person reviewed
5
+ * on the card is what goes in. The server asks everything again - the door, whether a newer card replaced
6
+ * this one, whether it was already answered, the target's checks (records move between proposing and
7
+ * pressing) - and enters each row by running the application's own mutation with the person's request
8
+ * state. Each call is its own request (the door's `@transaction`), so one refused row does not undo the
9
+ * others and each is reported by its place.
10
+ *
11
+ * Dependencies come in as arguments so the rules are tested without a database.
12
+ */
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'
15
+
16
+ export interface StoredProposalMessage {
17
+ id: string
18
+ proposals: any[]
19
+ }
20
+
21
+ export interface EntryRowResult {
22
+ index: number
23
+ ok: boolean
24
+ id?: string
25
+ label?: string
26
+ /** A blocking issue kept it out, or the target refused it. */
27
+ error?: string
28
+ }
29
+
30
+ export interface EntryApplyOutcome {
31
+ verdict: 'accepted' | 'refused' | 'unavailable'
32
+ errorCode?: string
33
+ applied: number
34
+ missed: number
35
+ rows: EntryRowResult[]
36
+ }
37
+
38
+ export interface EntryApplyDeps {
39
+ /** The conversation's messages that carry proposals, oldest first. */
40
+ messages: StoredProposalMessage[]
41
+ /** Whether an outcome line already answers this key. */
42
+ answered: boolean
43
+ passesDoor(door: string): Promise<boolean>
44
+ /**
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.
47
+ */
48
+ execute(mutation: string, variables: Record<string, unknown>): Promise<{ data?: any; errors?: readonly { message: string }[] }>
49
+ state: any
50
+ report?(message: string, error: unknown): void
51
+ }
52
+
53
+ const nothing = (verdict: EntryApplyOutcome['verdict'], errorCode: string): EntryApplyOutcome => ({ verdict, errorCode, applied: 0, missed: 0, rows: [] })
54
+
55
+ export function findEntryProposal(messages: StoredProposalMessage[], key: string): { proposal?: any; superseded: boolean } {
56
+ let at = -1
57
+ let proposal: any
58
+ messages.forEach((message, i) => {
59
+ for (const one of message.proposals ?? []) if (one?.key === key) (at = i), (proposal = one)
60
+ })
61
+ 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))
64
+ return { proposal, superseded }
65
+ }
66
+
67
+ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targetOf: (key: string) => EntryTarget | undefined = entryTargetOf): Promise<EntryApplyOutcome> {
68
+ const { proposal, superseded } = findEntryProposal(deps.messages, key)
69
+ if (!proposal) return nothing('refused', 'proposal-not-found')
70
+ if (proposal.kind !== 'entry') return nothing('refused', 'not-an-entry-proposal')
71
+ if (superseded) return nothing('refused', 'superseded')
72
+ if (deps.answered) return nothing('refused', 'already-answered')
73
+
74
+ const target = targetOf(proposal.target)
75
+ if (!target) return nothing('unavailable', 'unknown-entry-target')
76
+ const door = entryDoorOf(target.mutation)
77
+ if (!(await deps.passesDoor(door))) {
78
+ await recordEntryEvent(target, { phase: 'apply', tool: entryToolName(target.key), rows: 0, ok: false, error: 'door-closed', detail: { proposalKey: key } }, { state: deps.state })
79
+ return nothing('refused', 'door-closed')
80
+ }
81
+
82
+ /* Read again from the stored values - the same rules, and today's records for the target's checks. */
83
+ let { rows } = normalizeEntryRows(target, (proposal.rows ?? []).map((row: EntryRow) => ({ values: row.values, sources: row.sources })))
84
+ if (target.check) {
85
+ try {
86
+ rows = withCheckedIssues(rows, await target.check(rows, { state: deps.state }))
87
+ } catch (error) {
88
+ deps.report?.(`[ai-assistant] entry check of '${target.key}' failed`, error)
89
+ return nothing('refused', 'check-failed')
90
+ }
91
+ }
92
+
93
+ const results: EntryRowResult[] = []
94
+ for (const row of rows) {
95
+ if (isBlocked(row)) {
96
+ const issue = row.issues.find(one => one.blocking)!
97
+ results.push({ index: row.index, ok: false, error: issue.field ? `${issue.field}: ${issue.code}` : issue.code })
98
+ continue
99
+ }
100
+ try {
101
+ const answer = await deps.execute(target.mutation, target.variables(row))
102
+ if (answer.errors?.length) {
103
+ results.push({ index: row.index, ok: false, error: answer.errors.map(one => one.message).join('; ') || 'refused' })
104
+ continue
105
+ }
106
+ const applied = target.appliedOf?.(answer.data, row)
107
+ results.push({ index: row.index, ok: true, ...(applied?.id ? { id: applied.id } : {}), ...(applied?.label ? { label: applied.label } : {}) })
108
+ } catch (error: any) {
109
+ results.push({ index: row.index, ok: false, error: error?.message || 'refused' })
110
+ }
111
+ }
112
+
113
+ const applied = results.filter(one => one.ok).length
114
+ const missed = results.length - applied
115
+ await recordEntryEvent(
116
+ target,
117
+ { phase: 'apply', tool: entryToolName(target.key), rows: results.length, ok: applied > 0, applied, missed, ...(applied ? {} : { error: 'no-row-entered' }), detail: { proposalKey: key, rows: results } },
118
+ { state: deps.state }
119
+ )
120
+ return applied
121
+ ? { verdict: 'accepted', applied, missed, rows: results }
122
+ : { verdict: 'refused', errorCode: 'no-row-entered', applied, missed, rows: results }
123
+ }
124
+
125
+ /** An outcome line already answers this key - `requested` alone does not: the dock writes it just before calling here. */
126
+ export function entryProposalAnswered(key: string, lines: readonly (string | null | undefined)[]): boolean {
127
+ const answered = `${PROPOSAL_OUTCOME_PREFIX}${key}: `
128
+ return lines.some(line => {
129
+ const text = (line ?? '').trim()
130
+ if (!text.startsWith(answered)) return false
131
+ const rest = text.slice(answered.length)
132
+ return PROPOSAL_VERDICTS.some(verdict => rest === verdict || rest.startsWith(`${verdict} `))
133
+ })
134
+ }
@@ -0,0 +1,74 @@
1
+ import { Resolver, Mutation, Arg, Ctx, Directive } from 'type-graphql'
2
+ import { GraphQLJSON } from 'graphql-scalars'
3
+ import { Like, Not, IsNull } from 'typeorm'
4
+
5
+ import { logger } from '@things-factory/env'
6
+ import gql from 'graphql-tag'
7
+
8
+ import { getRepository, GraphqlLocalClient } from '@things-factory/shell'
9
+
10
+ import { ChatSession } from '../chat-session/chat-session.js'
11
+ import { ChatMessage } from '../chat-message/chat-message.js'
12
+ import { assertMayWriteSession } from '../chat-session/session-write-gate.js'
13
+ import { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'
14
+ import { PROPOSAL_OUTCOME_PREFIX } from '../chat-message/proposal-outcome.js'
15
+ import { toolAccessFor } from '../tool-access.js'
16
+ import { applyEntryProposal, entryProposalAnswered, type EntryApplyOutcome } from './entry-apply.js'
17
+
18
+ @Resolver()
19
+ export class EntryProposalResolver {
20
+ /*
21
+ * Not `@transaction`: each row is its own call of the application's mutation (entry-apply.ts), so one
22
+ * refused row keeps the others. The dock reports the outcome afterwards through `reportProposalOutcome`.
23
+ */
24
+ @Mutation(() => GraphQLJSON, {
25
+ description:
26
+ 'Enter the rows of a data entry proposal the person pressed. The rows are the stored proposal\'s; the door, a newer proposal and the target\'s checks are asked again, and each row is written in its own transaction.'
27
+ })
28
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
29
+ async applyEntryProposal(
30
+ @Arg('sessionId') sessionId: string,
31
+ @Arg('proposalKey') proposalKey: string,
32
+ @Ctx() context: ResolverContext
33
+ ): Promise<EntryApplyOutcome> {
34
+ const { domain, user } = context.state
35
+ const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } } as any)
36
+ if (!session) throw new Error(`ChatSession ${sessionId} not found`)
37
+ await assertMayWriteSession({
38
+ session,
39
+ userId: user?.id,
40
+ isParticipant: () => isSessionParticipant(domain.id, session.id, user?.id),
41
+ t: (key, options) => (context.t ? context.t(key, options) : String(options?.defaultValue ?? key))
42
+ })
43
+
44
+ const key = (proposalKey ?? '').trim()
45
+ const carrying = await getRepository(ChatMessage).find({
46
+ select: ['id', 'proposals', 'createdAt'] as any,
47
+ where: { session: { id: session.id } as any, proposals: Not(IsNull()) } as any,
48
+ order: { createdAt: 'ASC' } as any
49
+ })
50
+ const lines = await getRepository(ChatMessage).find({
51
+ select: ['content'] as any,
52
+ where: { session: { id: session.id } as any, role: 'system', content: Like(`${PROPOSAL_OUTCOME_PREFIX}${key}:%`) } as any
53
+ })
54
+
55
+ const passes = toolAccessFor(context)
56
+ return await applyEntryProposal(key, {
57
+ messages: carrying.map((message: any) => ({ id: message.id, proposals: message.proposalsJson ?? [] })),
58
+ answered: entryProposalAnswered(key, lines.map((line: any) => line.content)),
59
+ passesDoor: door => passes(door),
60
+ state: context.state,
61
+ /*
62
+ * The application's API, as the person - the same request state the door would have seen had they typed
63
+ * 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.
65
+ */
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
+ },
71
+ report: (message, error) => logger.error(message, error)
72
+ })
73
+ }
74
+ }
@@ -0,0 +1,35 @@
1
+ /*
2
+ * The text of the files a proposal cites - read through the conversation's own gate, as readChatAttachment
3
+ * reads them (chat-session/attachment-tools.ts). A file of another conversation, or of a private one the
4
+ * caller does not sit in, is not read: it is left out, and the rows citing it say they were not checked.
5
+ */
6
+ import { Attachment, STORAGE } from '@things-factory/attachment-base'
7
+ import { getRepository } from '@things-factory/shell'
8
+
9
+ import { ChatSession } from '../chat-session/chat-session.js'
10
+ import { mayReadSession } from '../chat-session/session-visibility.js'
11
+ import { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'
12
+ import { CHAT_ATTACHMENT_REF } from '../chat-session/session-attachments.js'
13
+ import { attachmentKindOf } from '../chat-session/attachment-policy.js'
14
+
15
+ /** The whole text is read - the check must see every line, not the part the model was shown. */
16
+ const MAX_CHARS = 2_000_000
17
+
18
+ export async function loadSourceTexts(ids: string[], context: any): Promise<Map<string, string>> {
19
+ const { domain, user } = context?.state ?? {}
20
+ const texts = new Map<string, string>()
21
+ if (!domain?.id) return texts
22
+ for (const id of [...new Set(ids)].slice(0, 20)) {
23
+ const row: any = await getRepository(Attachment).findOne({ where: { id, domain: { id: domain.id }, refType: CHAT_ATTACHMENT_REF } as any })
24
+ const session: any = row && (await getRepository(ChatSession).findOne({ where: { id: row.refBy, domain: { id: domain.id } } as any }))
25
+ if (!row || !session) continue
26
+ if (!mayReadSession(session.visibility, await isSessionParticipant(domain.id, session.id, user?.id))) continue
27
+ if (attachmentKindOf({ type: row.mimetype, name: row.name }) !== 'text') continue
28
+ try {
29
+ texts.set(id, String(await STORAGE.readFile(row.path, 'utf8')).slice(0, MAX_CHARS))
30
+ } catch {
31
+ /* gone from storage - the rows citing it say they were not checked */
32
+ }
33
+ }
34
+ return texts
35
+ }