@things-factory/ai-assistant 10.1.90 → 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.
- package/client/components/assistant-chat.ts +31 -3
- package/client/components/assistant-dock.ts +9 -1
- package/client/components/entry-review.ts +56 -0
- package/client/components/workspace-view.ts +428 -0
- package/client/pages/assistant-workspace-page.ts +237 -0
- package/client/route.ts +9 -0
- package/client/utils/entry-proposal.ts +20 -5
- package/client/utils/workspace.ts +89 -0
- package/dist-client/components/assistant-chat.d.ts +2 -0
- package/dist-client/components/assistant-chat.js +32 -3
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +1 -0
- package/dist-client/components/assistant-dock.js +9 -1
- package/dist-client/components/assistant-dock.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/components/workspace-view.d.ts +19 -0
- package/dist-client/components/workspace-view.js +416 -0
- package/dist-client/components/workspace-view.js.map +1 -0
- package/dist-client/pages/assistant-workspace-page.d.ts +27 -0
- package/dist-client/pages/assistant-workspace-page.js +263 -0
- package/dist-client/pages/assistant-workspace-page.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +10 -0
- package/dist-client/route.js.map +1 -0
- 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-client/utils/workspace.d.ts +40 -0
- package/dist-client/utils/workspace.js +68 -0
- package/dist-client/utils/workspace.js.map +1 -0
- package/dist-server/service/assistant-mode/resolve.d.ts +2 -2
- package/dist-server/service/assistant-mode/resolve.js +5 -3
- package/dist-server/service/assistant-mode/resolve.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 +73 -3
- 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/index.d.ts +2 -0
- package/dist-server/service/index.js +3 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/workspace/workspace-blocks.d.ts +24 -0
- package/dist-server/service/workspace/workspace-blocks.js +118 -0
- package/dist-server/service/workspace/workspace-blocks.js.map +1 -0
- package/dist-server/service/workspace/workspace-tools.d.ts +24 -0
- package/dist-server/service/workspace/workspace-tools.js +65 -0
- package/dist-server/service/workspace/workspace-tools.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/assistant-mode/resolve.test.ts +2 -1
- package/server/service/assistant-mode/resolve.ts +5 -3
- package/server/service/data-entry/data-entry-update.test.ts +192 -0
- package/server/service/data-entry/data-entry.test.ts +2 -2
- 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 +77 -3
- package/server/service/data-entry/entry-update.ts +209 -0
- package/server/service/index.ts +3 -0
- package/server/service/workspace/workspace-blocks.ts +127 -0
- package/server/service/workspace/workspace-tools.ts +66 -0
- package/server/service/workspace/workspace.test.ts +58 -0
- package/test/entry-proposal.test.ts +15 -2
- package/test/workspace.test.ts +54 -0
- package/things-factory.config.js +6 -1
- package/translations/en.json +36 -1
- package/translations/ja.json +36 -1
- package/translations/ko.json +36 -1
- package/translations/ms.json +36 -1
- 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.
|
|
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": "
|
|
54
|
+
"gitHead": "79bebdf2ce31c5e82134757678be2cc1bb6e4dec"
|
|
55
55
|
}
|
|
@@ -34,7 +34,8 @@ it('★ the settings come from the registered mode', () => {
|
|
|
34
34
|
|
|
35
35
|
expect(settings).toEqual({
|
|
36
36
|
systemPrompt: 'you answer about one space',
|
|
37
|
-
|
|
37
|
+
/* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
|
|
38
|
+
toolCategories: ['twin-ops', 'assistant-workspace'],
|
|
38
39
|
requireToolOnFirstTurn: true,
|
|
39
40
|
model: 'claude-sonnet-5',
|
|
40
41
|
temperature: 0.2,
|
|
@@ -44,15 +44,17 @@ export interface ModeSettings {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* The categories a mode opens: its own, the
|
|
48
|
-
* §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
|
|
47
|
+
* The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
|
|
48
|
+
* takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
|
|
49
49
|
*/
|
|
50
50
|
export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[] }): string[] | undefined {
|
|
51
51
|
const extra = [
|
|
52
|
+
/* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
|
|
53
|
+
'assistant-workspace',
|
|
52
54
|
...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
|
|
53
55
|
...(mode.entryTargets ?? []).map(key => `data-entry:${key}`)
|
|
54
56
|
]
|
|
55
|
-
return
|
|
57
|
+
return [...new Set([...(mode.toolCategories ?? []), ...extra])]
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/** The conversation's settings, from the server's declaration. */
|
|
@@ -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
|
+
})
|
|
@@ -66,8 +66,8 @@ describe('declaring a target', () => {
|
|
|
66
66
|
})
|
|
67
67
|
|
|
68
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'])
|
|
69
|
+
expect(modeToolCategories({ toolCategories: ['office-ops'], entryTargets: ['office.expense-line'] })).toEqual(['office-ops', 'assistant-workspace', 'data-entry:office.expense-line'])
|
|
70
|
+
expect(modeToolCategories({ toolCategories: ['office-ops'] })).toEqual(['office-ops', 'assistant-workspace'])
|
|
71
71
|
})
|
|
72
72
|
})
|
|
73
73
|
|
|
@@ -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
|
}
|