@things-factory/ai-assistant 10.1.88 → 10.1.91
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 +45 -5
- package/client/components/assistant-dock.ts +30 -2
- package/client/components/entry-review.ts +246 -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/assistant-proposals.ts +11 -1
- package/client/utils/entry-proposal.ts +103 -0
- package/client/utils/workspace.ts +89 -0
- package/dist-client/components/assistant-chat.d.ts +3 -0
- package/dist-client/components/assistant-chat.js +46 -5
- 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 +25 -2
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/entry-review.d.ts +9 -0
- package/dist-client/components/entry-review.js +247 -0
- package/dist-client/components/entry-review.js.map +1 -0
- 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/assistant-proposals.d.ts +1 -0
- package/dist-client/utils/assistant-proposals.js +9 -1
- package/dist-client/utils/assistant-proposals.js.map +1 -1
- package/dist-client/utils/entry-proposal.d.ts +55 -0
- package/dist-client/utils/entry-proposal.js +52 -0
- package/dist-client/utils/entry-proposal.js.map +1 -0
- 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/registry.js +13 -0
- package/dist-server/service/assistant-mode/registry.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +11 -0
- package/dist-server/service/assistant-mode/resolve.js +15 -3
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +7 -1
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.d.ts +46 -0
- package/dist-server/service/data-entry/entry-apply.js +102 -0
- package/dist-server/service/data-entry/entry-apply.js.map +1 -0
- package/dist-server/service/data-entry/entry-resolver.d.ts +4 -0
- package/dist-server/service/data-entry/entry-resolver.js +80 -0
- package/dist-server/service/data-entry/entry-resolver.js.map +1 -0
- package/dist-server/service/data-entry/entry-sources.d.ts +1 -0
- package/dist-server/service/data-entry/entry-sources.js +41 -0
- package/dist-server/service/data-entry/entry-sources.js.map +1 -0
- package/dist-server/service/data-entry/entry-target.d.ts +157 -0
- package/dist-server/service/data-entry/entry-target.js +343 -0
- package/dist-server/service/data-entry/entry-target.js.map +1 -0
- package/dist-server/service/data-entry/entry-tools.d.ts +43 -0
- package/dist-server/service/data-entry/entry-tools.js +185 -0
- package/dist-server/service/data-entry/entry-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +6 -1
- package/dist-server/service/index.js +13 -2
- 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/registry.ts +13 -0
- package/server/service/assistant-mode/resolve.test.ts +2 -1
- package/server/service/assistant-mode/resolve.ts +15 -3
- package/server/service/assistant-mode/types.ts +7 -1
- package/server/service/data-entry/data-entry.test.ts +311 -0
- package/server/service/data-entry/entry-apply.ts +134 -0
- package/server/service/data-entry/entry-resolver.ts +74 -0
- package/server/service/data-entry/entry-sources.ts +35 -0
- package/server/service/data-entry/entry-target.ts +442 -0
- package/server/service/data-entry/entry-tools.ts +174 -0
- package/server/service/index.ts +10 -1
- 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 +67 -0
- package/test/open-doors.test.ts +2 -1
- package/test/workspace.test.ts +54 -0
- package/things-factory.config.js +6 -1
- package/translations/en.json +56 -1
- package/translations/ja.json +56 -1
- package/translations/ko.json +56 -1
- package/translations/ms.json +56 -1
- package/translations/zh.json +56 -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.91",
|
|
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": "e9a052f7faaeb5884db1ff8b862b8f05567a31f9"
|
|
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 ?? []) {
|
|
@@ -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,
|
|
@@ -43,6 +43,20 @@ export interface ModeSettings {
|
|
|
43
43
|
maxTokens?: number
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
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
|
+
*/
|
|
50
|
+
export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[] }): string[] | undefined {
|
|
51
|
+
const extra = [
|
|
52
|
+
/* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
|
|
53
|
+
'assistant-workspace',
|
|
54
|
+
...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
|
|
55
|
+
...(mode.entryTargets ?? []).map(key => `data-entry:${key}`)
|
|
56
|
+
]
|
|
57
|
+
return [...new Set([...(mode.toolCategories ?? []), ...extra])]
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
/** The conversation's settings, from the server's declaration. */
|
|
47
61
|
export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSettings {
|
|
48
62
|
const kind = String(ref?.kind ?? '')
|
|
@@ -53,9 +67,7 @@ export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSe
|
|
|
53
67
|
return {
|
|
54
68
|
systemPrompt: mode.systemPrompt,
|
|
55
69
|
/* A mode that takes attachments can open the files sent in earlier turns (design/ai/shared-dock.md §9-4). */
|
|
56
|
-
toolCategories: mode
|
|
57
|
-
? [...(mode.toolCategories ?? []), 'chat-attachments']
|
|
58
|
-
: mode.toolCategories,
|
|
70
|
+
toolCategories: modeToolCategories(mode),
|
|
59
71
|
requireToolOnFirstTurn: mode.requireToolOnFirstTurn,
|
|
60
72
|
model: mode.model,
|
|
61
73
|
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
|
-
|
|
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', 'assistant-workspace', 'data-entry:office.expense-line'])
|
|
70
|
+
expect(modeToolCategories({ toolCategories: ['office-ops'] })).toEqual(['office-ops', 'assistant-workspace'])
|
|
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
|
+
}
|