@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
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
|
|
11
11
|
|
|
12
|
+
import { findRecords, proposeChanges, changeableFields, type GraphqlRunner } from './entry-update.js'
|
|
12
13
|
import {
|
|
14
|
+
changeToolName,
|
|
15
|
+
findToolName,
|
|
16
|
+
rootFieldOf,
|
|
13
17
|
declareEntryTarget,
|
|
14
18
|
entryTotals,
|
|
15
19
|
verifyRowsAgainstSources,
|
|
@@ -99,7 +103,71 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
|
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
|
|
106
|
+
/**
|
|
107
|
+
* The application's API as the person who asked - the tool's own request state, no transaction handed on.
|
|
108
|
+
* Loaded late so the rules are tested without the shell.
|
|
109
|
+
*/
|
|
110
|
+
export const runAsCaller = (context: any): GraphqlRunner => async (document, variables) => {
|
|
111
|
+
const [{ GraphqlLocalClient }, { default: gql }] = await Promise.all([import('@things-factory/shell'), import('graphql-tag')])
|
|
112
|
+
const { tx: _tx, afterCommit: _after, ...state } = context?.state ?? {}
|
|
113
|
+
const operation = /^\s*mutation\b/.test(document) ? 'mutate' : 'query'
|
|
114
|
+
const result: any =
|
|
115
|
+
operation === 'mutate'
|
|
116
|
+
? await GraphqlLocalClient.client.mutate({ mutation: gql(document), variables, context: { state, t: context?.t } })
|
|
117
|
+
: await GraphqlLocalClient.client.query({ query: gql(document), variables, context: { state, t: context?.t }, fetchPolicy: 'no-cache' })
|
|
118
|
+
return { data: result.data, errors: result.errors }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The two tools of changing records - finding candidates, and the 수정안 (design/ai/data-entry.md §5-1). */
|
|
122
|
+
function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner): ToolSpec[] {
|
|
123
|
+
const update = target.update
|
|
124
|
+
if (!update) return []
|
|
125
|
+
const readDoor = rootFieldOf(update.read.query, 'query')
|
|
126
|
+
const specs: ToolSpec[] = [
|
|
127
|
+
{
|
|
128
|
+
kind: 'read',
|
|
129
|
+
name: changeToolName(target.key),
|
|
130
|
+
/* The person must be able to read the records and change them by hand. */
|
|
131
|
+
doors: [rootFieldOf(update.mutation, 'mutation'), readDoor],
|
|
132
|
+
description:
|
|
133
|
+
`Propose changes to existing records: ${target.description} ` +
|
|
134
|
+
'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
|
|
135
|
+
'The server reads each record, and the card shows what it holds now and what it becomes; nothing changes until the person presses it.',
|
|
136
|
+
schema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
changes: {
|
|
140
|
+
type: 'array',
|
|
141
|
+
items: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
ref: { type: 'string', description: '"current", "selected", or a record id from a find tool - never a guessed id.' },
|
|
145
|
+
values: { type: 'object', description: 'Only the fields that change, with their new values.', properties: Object.fromEntries(changeableFields(target).map(field => [field.name, fieldSchema(field)])) }
|
|
146
|
+
},
|
|
147
|
+
required: ['ref', 'values']
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
note: { type: 'string', description: 'One line for the card.' }
|
|
151
|
+
},
|
|
152
|
+
required: ['changes']
|
|
153
|
+
},
|
|
154
|
+
builder: (args: any, context: any) => proposeChanges(target, args, context, run(context))
|
|
155
|
+
} as ToolSpec
|
|
156
|
+
]
|
|
157
|
+
if (update.find) {
|
|
158
|
+
specs.push({
|
|
159
|
+
kind: 'read',
|
|
160
|
+
name: findToolName(target.key),
|
|
161
|
+
doors: [rootFieldOf(update.find.query, 'query')],
|
|
162
|
+
description: `Find existing records to change from what the person described: ${target.description} Returns candidates with their ids.`,
|
|
163
|
+
schema: { type: 'object', properties: Object.fromEntries(update.find.criteria.map(field => [field.name, fieldSchema(field)])) },
|
|
164
|
+
builder: (args: any, context: any) => findRecords(target, args, run(context))
|
|
165
|
+
} as ToolSpec)
|
|
166
|
+
}
|
|
167
|
+
return specs
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function entryCategoryOf(target: EntryTarget, run: (context: any) => GraphqlRunner = runAsCaller): ToolCategory {
|
|
103
171
|
const name = entryToolName(target.key)
|
|
104
172
|
const spec = {
|
|
105
173
|
kind: 'read',
|
|
@@ -157,9 +225,15 @@ export function entryCategoryOf(target: EntryTarget): ToolCategory {
|
|
|
157
225
|
'- Give each row its sources: which attachment and where in it. A value the person gave or corrected in the conversation adds the source where: "the person\'s message". The server looks for each row\'s numbers and dates in the cited file and marks what it does not find.',
|
|
158
226
|
'- When the material prints a total, pass it as declaredTotal exactly as printed - do not add it up yourself.',
|
|
159
227
|
`- ${name} only proposes. Nothing is entered until the person presses the button on the card - never say the records were entered.`,
|
|
160
|
-
`- When the person corrects or drops rows, call ${name} again with the whole corrected set. The newest card replaces the earlier ones; an earlier card can no longer be entered
|
|
228
|
+
`- When the person corrects or drops rows, call ${name} again with the whole corrected set. The newest card replaces the earlier ones; an earlier card can no longer be entered.`,
|
|
229
|
+
...(target.update
|
|
230
|
+
? [
|
|
231
|
+
`- To change records that exist, call ${changeToolName(target.key)}. Use ref "current" for the record on screen and "selected" for the rows selected there.${target.update.find ? ` Otherwise call ${findToolName(target.key)} first with what the person said; if more than one record matches, list them and ask which - never pick one yourself.` : ' Otherwise ask the person to open or select the record.'}`,
|
|
232
|
+
`- ${changeToolName(target.key)} only proposes; the card shows each record's value now and after. Never say a record was changed.`
|
|
233
|
+
]
|
|
234
|
+
: [])
|
|
161
235
|
].join('\n'),
|
|
162
|
-
specs: [spec]
|
|
236
|
+
specs: [spec, ...changeSpecs(target, run)]
|
|
163
237
|
}
|
|
164
238
|
}
|
|
165
239
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Changing records that exist - the 수정안 (operato-application/design/ai/data-entry.md §5-1).
|
|
3
|
+
*
|
|
4
|
+
* The most expensive mistake is changing the wrong record, so which record is meant is settled here, by the
|
|
5
|
+
* server, never by the model's memory. A record is picked by the screen (the one being looked at, the rows
|
|
6
|
+
* selected) or by an id the find tool handed back; whichever way, it is read again through the application's
|
|
7
|
+
* read query as the person, and a record they cannot see is not found. The card shows what is there now and
|
|
8
|
+
* what it becomes; pressing reads it once more, and a field that moved in between keeps its row out.
|
|
9
|
+
*
|
|
10
|
+
* Pure but for the GraphQL runner, which comes in as an argument - the rules are tested without a server.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
changeToolName,
|
|
14
|
+
findToolName,
|
|
15
|
+
readFieldValue,
|
|
16
|
+
sameFieldValue,
|
|
17
|
+
type EntryField,
|
|
18
|
+
type EntryIssue,
|
|
19
|
+
type EntryRecord,
|
|
20
|
+
type EntryTarget
|
|
21
|
+
} from './entry-target.js'
|
|
22
|
+
|
|
23
|
+
/** Runs one of the application's queries or mutations as the person. */
|
|
24
|
+
export type GraphqlRunner = (document: string, variables: Record<string, unknown>) => Promise<{ data?: any; errors?: readonly { message: string }[] }>
|
|
25
|
+
|
|
26
|
+
/** How the record of a change was picked - said on the card, so the person sees the ground of it. */
|
|
27
|
+
export type ChangeVia = 'current' | 'selected' | 'id'
|
|
28
|
+
|
|
29
|
+
export interface ChangeRow {
|
|
30
|
+
index: number
|
|
31
|
+
id: string
|
|
32
|
+
via: ChangeVia
|
|
33
|
+
label?: string
|
|
34
|
+
/** What the record holds now, for the fields that change - read by the server. */
|
|
35
|
+
before: Record<string, unknown>
|
|
36
|
+
values: Record<string, unknown>
|
|
37
|
+
issues: EntryIssue[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const MAX_CHANGES = 100
|
|
41
|
+
|
|
42
|
+
const blockedChange = (row: { issues: EntryIssue[] }) => row.issues.some(issue => issue.blocking)
|
|
43
|
+
|
|
44
|
+
/** The fields a target lets change. */
|
|
45
|
+
export function changeableFields(target: EntryTarget): EntryField[] {
|
|
46
|
+
const names = target.update?.fields
|
|
47
|
+
return names ? target.fields.filter(field => names.includes(field.name)) : target.fields
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The records the changes name. `current` is the record on screen, `selected` every row selected there -
|
|
52
|
+
* both from the surface's host context, which is **not trusted**: an id from it is only a candidate, read
|
|
53
|
+
* through the application before anything is shown. Any other ref is an id, from the find tool.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveChangeRefs(
|
|
56
|
+
targetKey: string,
|
|
57
|
+
changes: unknown,
|
|
58
|
+
host: Record<string, any> | undefined
|
|
59
|
+
): { picked: { id: string; via: ChangeVia; values: Record<string, unknown> }[]; unresolved: string[] } {
|
|
60
|
+
const picked: { id: string; via: ChangeVia; values: Record<string, unknown> }[] = []
|
|
61
|
+
const unresolved: string[] = []
|
|
62
|
+
const mine = (one: any) => !one?.target || one.target === targetKey
|
|
63
|
+
for (const change of Array.isArray(changes) ? changes : []) {
|
|
64
|
+
const values = change?.values && typeof change.values === 'object' ? change.values : {}
|
|
65
|
+
const ref = typeof change?.ref === 'string' ? change.ref.trim() : ''
|
|
66
|
+
if (ref === 'current') {
|
|
67
|
+
const id = mine(host?.record) ? host?.record?.id : undefined
|
|
68
|
+
if (typeof id === 'string' && id) picked.push({ id, via: 'current', values })
|
|
69
|
+
else unresolved.push('current')
|
|
70
|
+
} else if (ref === 'selected') {
|
|
71
|
+
const ids = mine(host?.selection) && Array.isArray(host?.selection?.ids) ? host!.selection.ids.filter((id: unknown) => typeof id === 'string' && id) : []
|
|
72
|
+
if (ids.length) for (const id of ids) picked.push({ id, via: 'selected', values })
|
|
73
|
+
else unresolved.push('selected')
|
|
74
|
+
} else if (ref) {
|
|
75
|
+
picked.push({ id: ref, via: 'id', values })
|
|
76
|
+
} else {
|
|
77
|
+
unresolved.push('no-ref')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { picked: picked.slice(0, MAX_CHANGES), unresolved }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Reads the records by id through the application's read query, as the person. */
|
|
84
|
+
export async function readRecords(target: EntryTarget, ids: string[], run: GraphqlRunner): Promise<Map<string, EntryRecord>> {
|
|
85
|
+
const update = target.update!
|
|
86
|
+
const answer = await run(update.read.query, update.read.variables([...new Set(ids)]))
|
|
87
|
+
if (answer.errors?.length) throw new Error(answer.errors.map(one => one.message).join('; '))
|
|
88
|
+
return new Map((update.read.recordsOf(answer.data) ?? []).filter(record => record && typeof record.id === 'string').map(record => [record.id, record]))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The change rows against what the records hold now. */
|
|
92
|
+
export function changeRows(target: EntryTarget, picked: { id: string; via: ChangeVia; values: Record<string, unknown> }[], records: Map<string, EntryRecord>): ChangeRow[] {
|
|
93
|
+
const fields = new Map(changeableFields(target).map(field => [field.name, field]))
|
|
94
|
+
return picked.map((one, index): ChangeRow => {
|
|
95
|
+
const record = records.get(one.id)
|
|
96
|
+
const issues: EntryIssue[] = []
|
|
97
|
+
const values: Record<string, unknown> = {}
|
|
98
|
+
const before: Record<string, unknown> = {}
|
|
99
|
+
for (const [name, value] of Object.entries(one.values)) {
|
|
100
|
+
const field = fields.get(name)
|
|
101
|
+
if (!field) {
|
|
102
|
+
issues.push({ field: name, code: 'not-changeable', blocking: false })
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
const { read, code } = value === null || value === '' ? { read: null, code: undefined } : readFieldValue(field, value)
|
|
106
|
+
if (read === undefined) {
|
|
107
|
+
values[name] = value
|
|
108
|
+
issues.push({ field: name, code: code!, params: code === 'not-in-list' ? { values: field.values } : undefined, blocking: true })
|
|
109
|
+
} else {
|
|
110
|
+
if (read === null && field.required) issues.push({ field: name, code: 'required', blocking: true })
|
|
111
|
+
values[name] = read
|
|
112
|
+
}
|
|
113
|
+
if (record) before[name] = record[name] ?? null
|
|
114
|
+
}
|
|
115
|
+
if (!record) issues.push({ code: 'record-not-found', blocking: true })
|
|
116
|
+
else if (Object.keys(values).length && Object.keys(values).every(name => sameFieldValue(fields.get(name)!, before[name], values[name])))
|
|
117
|
+
issues.push({ code: 'no-change', blocking: true })
|
|
118
|
+
if (!Object.keys(values).length && !issues.some(issue => issue.blocking)) issues.push({ code: 'nothing-to-change', blocking: true })
|
|
119
|
+
return { index, id: one.id, via: one.via, ...(record && target.update?.labelOf ? { label: target.update.labelOf(record) } : {}), before, values, issues }
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What the card needs besides the rows - the fields that change, in the target's order. */
|
|
124
|
+
function changedColumns(target: EntryTarget, rows: ChangeRow[]) {
|
|
125
|
+
const used = new Set(rows.flatMap(row => Object.keys(row.values)))
|
|
126
|
+
return changeableFields(target)
|
|
127
|
+
.filter(field => used.has(field.name))
|
|
128
|
+
.map(field => ({ name: field.name, type: field.type, ...(field.labelKey ? { labelKey: field.labelKey } : {}) }))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function proposeChanges(target: EntryTarget, args: any, context: any, run: GraphqlRunner) {
|
|
132
|
+
const { picked, unresolved } = resolveChangeRefs(target.key, args?.changes, context?.host)
|
|
133
|
+
if (!picked.length) {
|
|
134
|
+
return {
|
|
135
|
+
rejected: true,
|
|
136
|
+
error: unresolved.length ? `unresolved-${unresolved[0]}` : 'no-changes',
|
|
137
|
+
message:
|
|
138
|
+
unresolved[0] === 'current' || unresolved[0] === 'selected'
|
|
139
|
+
? `the screen names no ${unresolved[0] === 'current' ? 'record' : 'selected rows'} of this kind. Ask the person which record, or call ${findToolName(target.key)}.`
|
|
140
|
+
: `name each record: ref "current", "selected", or an id from ${findToolName(target.key)}. Never guess an id.`
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let records: Map<string, EntryRecord>
|
|
144
|
+
try {
|
|
145
|
+
records = await readRecords(target, picked.map(one => one.id), run)
|
|
146
|
+
} catch (error: any) {
|
|
147
|
+
return { rejected: true, error: 'read-failed', message: `could not read the records: ${error?.message ?? error}` }
|
|
148
|
+
}
|
|
149
|
+
const rows = changeRows(target, picked, records)
|
|
150
|
+
const blocked = rows.filter(blockedChange).length
|
|
151
|
+
return {
|
|
152
|
+
proposed: true,
|
|
153
|
+
kind: 'entry',
|
|
154
|
+
op: 'update',
|
|
155
|
+
target: target.key,
|
|
156
|
+
labelKey: target.labelKey,
|
|
157
|
+
label: `${rows.length} ${target.key} changes`,
|
|
158
|
+
icon: 'edit_note',
|
|
159
|
+
columns: changedColumns(target, rows),
|
|
160
|
+
rows,
|
|
161
|
+
counts: { rows: rows.length, ready: rows.length - blocked, blocked, cut: 0 },
|
|
162
|
+
...(typeof args?.note === 'string' && args.note.trim() ? { reason: args.note.trim().slice(0, 300) } : {}),
|
|
163
|
+
message:
|
|
164
|
+
`proposed ${rows.length} changes (${rows.length - blocked} ready, ${blocked} blocked). Nothing changes until the person presses the card. ` +
|
|
165
|
+
'Tell them which records are not found or would not change.'
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const MAX_CANDIDATES = 20
|
|
170
|
+
|
|
171
|
+
/** Candidates for what the person described - the model lists them and asks when there is more than one. */
|
|
172
|
+
export async function findRecords(target: EntryTarget, args: any, run: GraphqlRunner) {
|
|
173
|
+
const find = target.update?.find
|
|
174
|
+
if (!find) return { rejected: true, error: 'no-find', message: 'this record cannot be searched.' }
|
|
175
|
+
const criteria: Record<string, unknown> = {}
|
|
176
|
+
for (const field of find.criteria) {
|
|
177
|
+
const value = args?.[field.name]
|
|
178
|
+
if (value === undefined || value === null || value === '') continue
|
|
179
|
+
const { read } = readFieldValue(field, value)
|
|
180
|
+
if (read !== undefined) criteria[field.name] = read
|
|
181
|
+
}
|
|
182
|
+
const answer = await run(find.query, find.variables(criteria))
|
|
183
|
+
if (answer.errors?.length) return { rejected: true, error: 'find-failed', message: answer.errors.map(one => one.message).join('; ') }
|
|
184
|
+
const found = (find.recordsOf(answer.data) ?? []).filter(record => record && typeof record.id === 'string')
|
|
185
|
+
return {
|
|
186
|
+
total: found.length,
|
|
187
|
+
candidates: found.slice(0, MAX_CANDIDATES).map(record => ({ id: record.id, ...(target.update?.labelOf ? { label: target.update.labelOf(record) } : {}), record })),
|
|
188
|
+
message:
|
|
189
|
+
found.length === 1
|
|
190
|
+
? `one record matches; pass its id to ${changeToolName(target.key)}.`
|
|
191
|
+
: found.length
|
|
192
|
+
? `${found.length} records match${found.length > MAX_CANDIDATES ? ` (first ${MAX_CANDIDATES} shown)` : ''}. List them to the person and ask which - never pick one yourself.`
|
|
193
|
+
: 'no record matches. Tell the person and ask for more to go on.'
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The rows of a pressed 수정안, against what the records hold now. A row whose field moved since the card was
|
|
199
|
+
* made is kept out as `changed-since` - it is not overwritten unseen.
|
|
200
|
+
*/
|
|
201
|
+
export function stillAsProposed(target: EntryTarget, rows: ChangeRow[], now: Map<string, EntryRecord>): (ChangeRow & { now?: EntryRecord })[] {
|
|
202
|
+
const fields = new Map(target.fields.map(field => [field.name, field]))
|
|
203
|
+
return rows.map(row => {
|
|
204
|
+
const record = now.get(row.id)
|
|
205
|
+
if (!record) return { ...row, issues: [...row.issues, { code: 'record-not-found', blocking: true }] }
|
|
206
|
+
const moved = Object.keys(row.values).find(name => fields.get(name) && !sameFieldValue(fields.get(name)!, record[name] ?? null, row.before[name]))
|
|
207
|
+
return moved ? { ...row, now: record, issues: [...row.issues, { field: moved, code: 'changed-since', blocking: true }] } : { ...row, now: record }
|
|
208
|
+
})
|
|
209
|
+
}
|
package/server/service/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import './chat-session/attachment-tools.js'
|
|
|
11
11
|
import './chat-session/session-purge.js'
|
|
12
12
|
import './chat-session/attachment-route.js'
|
|
13
13
|
import { EntryProposalResolver } from './data-entry/entry-resolver.js'
|
|
14
|
+
import './workspace/workspace-tools.js'
|
|
14
15
|
|
|
15
16
|
export * from './types.js'
|
|
16
17
|
export * from './chat-session-resolver.js'
|
|
@@ -27,6 +28,8 @@ export * from './tool-access.js'
|
|
|
27
28
|
/* Data entry from material - an app registers what it can enter; a mode names it (design/ai/data-entry.md). */
|
|
28
29
|
export * from './data-entry/entry-target.js'
|
|
29
30
|
export { registerEntryTarget, entryCategoryOf } from './data-entry/entry-tools.js'
|
|
31
|
+
/* The AI 작업판 - large results on a page of their own (design/ai/workspace.md). */
|
|
32
|
+
export * from './workspace/workspace-blocks.js'
|
|
30
33
|
/* 무엇이 근거인가 — 도구 결과 밖의 셋(프롬프트 · 대화 · 화면 스냅숏). */
|
|
31
34
|
export * from './grounding-sources.js'
|
|
32
35
|
/* 앵커 순수 로직 — 호스트가 앵커 종류를 참조한다(리졸버는 데코레이터 때문에 무겁다). */
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The AI 작업판 - what a workspace is made of (operato-application/design/ai/workspace.md §3).
|
|
3
|
+
*
|
|
4
|
+
* The model does not write HTML. A workspace is a list of blocks of known kinds; the screen draws each, so
|
|
5
|
+
* theme, language and accessibility are the framework's. A block that does not fit is not dropped: it is
|
|
6
|
+
* kept as a fact in its place ("a block that cannot be drawn"), so the person sees that something was meant
|
|
7
|
+
* to be there. Pure - tested without a server.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const WORKSPACE_BLOCK_KINDS = ['heading', 'text', 'metrics', 'table', 'chart', 'steps'] as const
|
|
11
|
+
export type WorkspaceBlockKind = (typeof WORKSPACE_BLOCK_KINDS)[number]
|
|
12
|
+
|
|
13
|
+
export const MAX_BLOCKS = 40
|
|
14
|
+
export const MAX_TABLE_ROWS = 1000
|
|
15
|
+
export const MAX_SERIES = 8
|
|
16
|
+
const MAX_TEXT = 20_000
|
|
17
|
+
|
|
18
|
+
export interface WorkspaceBlock {
|
|
19
|
+
kind: WorkspaceBlockKind | 'unreadable'
|
|
20
|
+
[key: string]: unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Workspace {
|
|
24
|
+
name: string
|
|
25
|
+
title: string
|
|
26
|
+
summary?: string
|
|
27
|
+
blocks: WorkspaceBlock[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const text = (value: unknown, max = 500): string | undefined => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined)
|
|
31
|
+
const scalar = (value: unknown): string | number | boolean | null =>
|
|
32
|
+
value === null || value === undefined ? null : typeof value === 'number' || typeof value === 'boolean' ? value : String(value).slice(0, 500)
|
|
33
|
+
|
|
34
|
+
/** A short key of the workspace - the same name in one conversation is the same workspace, a new version. */
|
|
35
|
+
export function workspaceName(name: unknown, title: string): string {
|
|
36
|
+
const given = typeof name === 'string' ? name.trim().toLowerCase() : ''
|
|
37
|
+
if (/^[a-z0-9][a-z0-9-]{0,63}$/.test(given)) return given
|
|
38
|
+
const slug = (given || title)
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z0-9가-힣]+/g, '-')
|
|
41
|
+
.replace(/^-+|-+$/g, '')
|
|
42
|
+
.slice(0, 64)
|
|
43
|
+
return slug || 'workspace'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function unreadable(kind: unknown, reason: string): WorkspaceBlock {
|
|
47
|
+
return { kind: 'unreadable', was: typeof kind === 'string' ? kind.slice(0, 40) : null, reason }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function block(raw: any): WorkspaceBlock {
|
|
51
|
+
const kind = raw?.kind
|
|
52
|
+
switch (kind) {
|
|
53
|
+
case 'heading': {
|
|
54
|
+
const value = text(raw.text, 200)
|
|
55
|
+
if (!value) return unreadable(kind, 'no-text')
|
|
56
|
+
const level = [1, 2, 3].includes(raw.level) ? raw.level : 2
|
|
57
|
+
return { kind, text: value, level }
|
|
58
|
+
}
|
|
59
|
+
case 'text': {
|
|
60
|
+
const value = text(raw.markdown ?? raw.text, MAX_TEXT)
|
|
61
|
+
return value ? { kind, markdown: value } : unreadable(kind, 'no-text')
|
|
62
|
+
}
|
|
63
|
+
case 'metrics': {
|
|
64
|
+
const items = (Array.isArray(raw.items) ? raw.items : [])
|
|
65
|
+
.filter((one: any) => text(one?.label) && one?.value !== undefined && one?.value !== null)
|
|
66
|
+
.slice(0, 12)
|
|
67
|
+
.map((one: any) => ({ label: text(one.label, 80), value: scalar(one.value), ...(text(one.unit, 20) ? { unit: text(one.unit, 20) } : {}), ...(text(one.note, 200) ? { note: text(one.note, 200) } : {}) }))
|
|
68
|
+
return items.length ? { kind, items } : unreadable(kind, 'no-items')
|
|
69
|
+
}
|
|
70
|
+
case 'table': {
|
|
71
|
+
const columns = (Array.isArray(raw.columns) ? raw.columns : [])
|
|
72
|
+
.filter((one: any) => text(one?.name, 80))
|
|
73
|
+
.slice(0, 30)
|
|
74
|
+
.map((one: any) => ({ name: text(one.name, 80)!, ...(text(one.label, 80) ? { label: text(one.label, 80) } : {}), type: ['number', 'date', 'string'].includes(one.type) ? one.type : 'string' }))
|
|
75
|
+
if (!columns.length) return unreadable(kind, 'no-columns')
|
|
76
|
+
const given = Array.isArray(raw.rows) ? raw.rows : []
|
|
77
|
+
/* A row is an object by column name, or an array in column order - models write both. */
|
|
78
|
+
const rows = given.slice(0, MAX_TABLE_ROWS).map((row: any) =>
|
|
79
|
+
Object.fromEntries(columns.map((column: any, i: number) => [column.name, scalar(Array.isArray(row) ? row[i] : row?.[column.name])]))
|
|
80
|
+
)
|
|
81
|
+
return { kind, columns, rows, ...(text(raw.caption, 300) ? { caption: text(raw.caption, 300) } : {}), ...(given.length > MAX_TABLE_ROWS ? { cut: given.length - MAX_TABLE_ROWS } : {}) }
|
|
82
|
+
}
|
|
83
|
+
case 'chart': {
|
|
84
|
+
const x = (Array.isArray(raw.x) ? raw.x : []).slice(0, 200).map((one: unknown) => String(one ?? '').slice(0, 60))
|
|
85
|
+
const series = (Array.isArray(raw.series) ? raw.series : [])
|
|
86
|
+
.filter((one: any) => text(one?.name, 60) && Array.isArray(one?.values))
|
|
87
|
+
.map((one: any) => ({ name: text(one.name, 60)!, values: x.map((_: unknown, i: number) => (typeof one.values[i] === 'number' && Number.isFinite(one.values[i]) ? one.values[i] : null)) }))
|
|
88
|
+
if (!x.length || !series.length) return unreadable(kind, 'no-series')
|
|
89
|
+
/* Never a ninth hue: past eight, the first seven stay and the rest fold into one "other". */
|
|
90
|
+
const shown =
|
|
91
|
+
series.length > MAX_SERIES
|
|
92
|
+
? [
|
|
93
|
+
...series.slice(0, MAX_SERIES - 1),
|
|
94
|
+
{ name: 'other', other: true, values: x.map((_: unknown, i: number) => series.slice(MAX_SERIES - 1).reduce((acc: number, one: any) => acc + (one.values[i] ?? 0), 0)) }
|
|
95
|
+
]
|
|
96
|
+
: series
|
|
97
|
+
return { kind, chart: raw.chart === 'line' || raw.type === 'line' || raw.shape === 'line' ? 'line' : 'bar', x, series: shown, ...(text(raw.unit, 20) ? { unit: text(raw.unit, 20) } : {}), ...(text(raw.caption, 300) ? { caption: text(raw.caption, 300) } : {}) }
|
|
98
|
+
}
|
|
99
|
+
case 'steps': {
|
|
100
|
+
const items = (Array.isArray(raw.items) ? raw.items : [])
|
|
101
|
+
.filter((one: any) => text(one?.label, 200))
|
|
102
|
+
.slice(0, 50)
|
|
103
|
+
.map((one: any) => ({ label: text(one.label, 200), state: ['done', 'doing', 'todo', 'failed'].includes(one.state) ? one.state : 'todo', ...(text(one.note, 300) ? { note: text(one.note, 300) } : {}) }))
|
|
104
|
+
return items.length ? { kind, items } : unreadable(kind, 'no-items')
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
return unreadable(kind, 'unknown-kind')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The workspace as the screen reads it. */
|
|
112
|
+
export function normalizeWorkspace(args: any): { workspace?: Workspace; error?: string } {
|
|
113
|
+
const title = text(args?.title, 200)
|
|
114
|
+
if (!title) return { error: 'no-title' }
|
|
115
|
+
const given = Array.isArray(args?.blocks) ? args.blocks : []
|
|
116
|
+
if (!given.length) return { error: 'no-blocks' }
|
|
117
|
+
const blocks = given.slice(0, MAX_BLOCKS).map(block)
|
|
118
|
+
if (given.length > MAX_BLOCKS) blocks.push({ kind: 'unreadable', was: null, reason: 'too-many-blocks', cut: given.length - MAX_BLOCKS })
|
|
119
|
+
return { workspace: { name: workspaceName(args?.name, title), title, ...(text(args?.summary, 500) ? { summary: text(args.summary, 500) } : {}), blocks } }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One line of what is in it - for the card in the conversation and for the model. */
|
|
123
|
+
export function workspaceContents(workspace: Workspace): Record<string, number> {
|
|
124
|
+
const counts: Record<string, number> = {}
|
|
125
|
+
for (const one of workspace.blocks) if (['table', 'chart', 'metrics', 'steps'].includes(one.kind)) counts[one.kind] = (counts[one.kind] ?? 0) + 1
|
|
126
|
+
return counts
|
|
127
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* showInWorkspace - the model puts a large result on the AI 작업판 (operato-application/design/ai/workspace.md §5).
|
|
3
|
+
*
|
|
4
|
+
* The result rides on the answer as a proposal of kind 'workspace', so the conversation's gates hold for it:
|
|
5
|
+
* who may read the conversation reads the workspace, and a value behind a door the reader cannot pass hides
|
|
6
|
+
* it with the answer (ADR-0073). The tool itself reaches no door - what it carries came from the turn's other
|
|
7
|
+
* tools, whose doors the answer row collects.
|
|
8
|
+
*/
|
|
9
|
+
import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
|
|
10
|
+
|
|
11
|
+
import { normalizeWorkspace, workspaceContents, WORKSPACE_BLOCK_KINDS } from './workspace-blocks.js'
|
|
12
|
+
|
|
13
|
+
export const WORKSPACE_CATEGORY = 'assistant-workspace'
|
|
14
|
+
|
|
15
|
+
export function showInWorkspace(args: any) {
|
|
16
|
+
const { workspace, error } = normalizeWorkspace(args)
|
|
17
|
+
if (!workspace) return { rejected: true, error, message: 'a workspace needs a title and at least one block.' }
|
|
18
|
+
const contents = workspaceContents(workspace)
|
|
19
|
+
return {
|
|
20
|
+
proposed: true,
|
|
21
|
+
kind: 'workspace',
|
|
22
|
+
icon: 'dashboard',
|
|
23
|
+
label: workspace.title,
|
|
24
|
+
workspace,
|
|
25
|
+
contents,
|
|
26
|
+
message: `put "${workspace.title}" on the workspace (${workspace.blocks.length} blocks). The person opens it from the card - say in one line what it holds; do not repeat its tables in the answer.`
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const spec = {
|
|
31
|
+
kind: 'read',
|
|
32
|
+
name: 'showInWorkspace',
|
|
33
|
+
doors: [],
|
|
34
|
+
description:
|
|
35
|
+
'Put a large result on the AI workspace - a page the person opens from a card, beside the conversation. Blocks: ' +
|
|
36
|
+
'heading{text,level}, text{markdown}, metrics{items:[{label,value,unit,note}]}, table{columns:[{name,label,type:number|date|string}],rows,caption}, ' +
|
|
37
|
+
'chart{chart:bar|line, x:[labels], series:[{name,values:[numbers]}], unit, caption}, steps{items:[{label,state:done|doing|todo|failed,note}]}.',
|
|
38
|
+
schema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
name: { type: 'string', description: 'A short key, lowercase with dashes ("contracts-by-customer"). The same name again makes a new version of the same workspace.' },
|
|
42
|
+
title: { type: 'string' },
|
|
43
|
+
summary: { type: 'string', description: 'One line under the title.' },
|
|
44
|
+
blocks: {
|
|
45
|
+
type: 'array',
|
|
46
|
+
items: { type: 'object', properties: { kind: { type: 'string', enum: [...WORKSPACE_BLOCK_KINDS] } }, required: ['kind'] }
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
required: ['name', 'title', 'blocks']
|
|
50
|
+
},
|
|
51
|
+
builder: (args: any) => showInWorkspace(args)
|
|
52
|
+
} as ToolSpec
|
|
53
|
+
|
|
54
|
+
export const workspaceCategory: ToolCategory = {
|
|
55
|
+
name: WORKSPACE_CATEGORY,
|
|
56
|
+
description: 'the AI workspace - a page for results too large for the conversation',
|
|
57
|
+
guidance: [
|
|
58
|
+
'- When a result is more than a short table (over 20 rows, or two or more tables or charts), or the person asks for a page, call showInWorkspace. Keep the answer short: the point, and that it is on the workspace.',
|
|
59
|
+
'- Every number in a block comes from a tool result in this conversation - never computed or guessed by you.',
|
|
60
|
+
'- To change a workspace, call showInWorkspace again with the same name and the whole new content; it becomes a new version.',
|
|
61
|
+
'- Never say something is on the workspace unless you called showInWorkspace in this turn.'
|
|
62
|
+
].join('\n'),
|
|
63
|
+
specs: [spec]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
registerToolCategory(workspaceCategory)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The AI 작업판 (design/ai/workspace.md) - what a block may be, what happens to one that is not, and what the
|
|
3
|
+
* tool hands back.
|
|
4
|
+
*/
|
|
5
|
+
import { normalizeWorkspace, workspaceName, MAX_SERIES } from './workspace-blocks'
|
|
6
|
+
import { showInWorkspace, workspaceCategory } from './workspace-tools'
|
|
7
|
+
|
|
8
|
+
describe('the workspace', () => {
|
|
9
|
+
it('★ is blocks of known kinds - a block that does not fit stays in its place as a fact, never dropped silently', () => {
|
|
10
|
+
const { workspace } = normalizeWorkspace({
|
|
11
|
+
name: 'contracts',
|
|
12
|
+
title: '2026 contracts',
|
|
13
|
+
blocks: [{ kind: 'heading', text: 'By customer' }, { kind: 'html', html: '<script>x</script>' }, { kind: 'table', rows: [] }]
|
|
14
|
+
})
|
|
15
|
+
expect(workspace!.blocks).toEqual([
|
|
16
|
+
{ kind: 'heading', text: 'By customer', level: 2 },
|
|
17
|
+
{ kind: 'unreadable', was: 'html', reason: 'unknown-kind' },
|
|
18
|
+
{ kind: 'unreadable', was: 'table', reason: 'no-columns' }
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('reads a table row as an object by column or an array in column order, and cuts at the limit saying how many', () => {
|
|
23
|
+
const rows = [{ customer: 'A', amount: 10 }, ['B', 20], ...Array.from({ length: 1000 }, () => ({ customer: 'x', amount: 1 }))]
|
|
24
|
+
const table: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'table', columns: [{ name: 'customer' }, { name: 'amount', type: 'number' }], rows }] }).workspace!.blocks[0]
|
|
25
|
+
expect(table.rows.slice(0, 2)).toEqual([{ customer: 'A', amount: 10 }, { customer: 'B', amount: 20 }])
|
|
26
|
+
expect(table.rows).toHaveLength(1000)
|
|
27
|
+
expect(table.cut).toBe(2)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('★ a chart never gets a ninth colour - past eight series the rest fold into "other"', () => {
|
|
31
|
+
const series = Array.from({ length: 10 }, (_, i) => ({ name: `s${i}`, values: [1, 2] }))
|
|
32
|
+
const chart: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'chart', x: ['Jan', 'Feb'], series }] }).workspace!.blocks[0]
|
|
33
|
+
expect(chart.series).toHaveLength(MAX_SERIES)
|
|
34
|
+
expect(chart.series[MAX_SERIES - 1]).toEqual({ name: 'other', other: true, values: [3, 6] })
|
|
35
|
+
expect(chart.chart).toBe('bar')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('a value that is not a number is a gap in the chart, never a zero', () => {
|
|
39
|
+
const chart: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'chart', chart: 'line', x: ['a', 'b', 'c'], series: [{ name: 'n', values: [1, 'x', 3] }] }] }).workspace!.blocks[0]
|
|
40
|
+
expect(chart.series[0].values).toEqual([1, null, 3])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('the same name is the same workspace - a name that is not a key is made one from it', () => {
|
|
44
|
+
expect(workspaceName('contracts-by-customer', 'x')).toBe('contracts-by-customer')
|
|
45
|
+
expect(workspaceName(undefined, '2026 Contracts / 고객사')).toBe('2026-contracts-고객사')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('★ the tool hands back a proposal of kind workspace, so the answer row carries it - and the conversation gates it', () => {
|
|
49
|
+
const result: any = showInWorkspace({ name: 'c', title: 'Contracts', blocks: [{ kind: 'table', columns: [{ name: 'a' }], rows: [[1]] }, { kind: 'metrics', items: [{ label: 'n', value: 26 }] }] })
|
|
50
|
+
expect(result).toMatchObject({ proposed: true, kind: 'workspace', label: 'Contracts', contents: { table: 1, metrics: 1 } })
|
|
51
|
+
expect((workspaceCategory.specs[0] as any).doors).toEqual([])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('no title or no blocks is refused, with what it needs', () => {
|
|
55
|
+
expect(showInWorkspace({ blocks: [{ kind: 'text', markdown: 'x' }] })).toMatchObject({ rejected: true, error: 'no-title' })
|
|
56
|
+
expect(showInWorkspace({ title: 'x', blocks: [] })).toMatchObject({ rejected: true, error: 'no-blocks' })
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -7,6 +7,7 @@ import { join } from 'path'
|
|
|
7
7
|
|
|
8
8
|
import { runProposal, setEntryProposalHandler } from '../client/utils/assistant-proposals'
|
|
9
9
|
import {
|
|
10
|
+
entryGroupOf,
|
|
10
11
|
entryOutcomeNote,
|
|
11
12
|
entryProposalHandler,
|
|
12
13
|
entryReadyCount,
|
|
@@ -31,8 +32,8 @@ describe('the entry card', () => {
|
|
|
31
32
|
|
|
32
33
|
it('★ the newest card per record is the current one - an older one is replaced', () => {
|
|
33
34
|
const latest = latestEntryKeys([[card('k1')], [{ key: 'x', tool: 'proposeAction' }], [card('k2'), card('k3', 'office.leave')]])
|
|
34
|
-
expect(latest.get('office.expense-line')).toBe('k2')
|
|
35
|
-
expect(latest.get('office.leave')).toBe('k3')
|
|
35
|
+
expect(latest.get('office.expense-line:create')).toBe('k2')
|
|
36
|
+
expect(latest.get('office.leave:create')).toBe('k3')
|
|
36
37
|
})
|
|
37
38
|
|
|
38
39
|
it('leaves in the conversation what went in and each row that did not, by its place', () => {
|
|
@@ -64,4 +65,16 @@ describe('the entry card', () => {
|
|
|
64
65
|
expect(chat).toContain("'ai-assistant.entry.enter'")
|
|
65
66
|
expect(chat).toContain("'ai-assistant.entry.replaced'")
|
|
66
67
|
})
|
|
68
|
+
|
|
69
|
+
it('★ a 입력안 and a 수정안 of one record are counted apart - a newer 수정안 replaces only the older 수정안', () => {
|
|
70
|
+
const latest = latestEntryKeys([[card('e1')], [{ ...card('u1'), op: 'update' }], [card('e2')]])
|
|
71
|
+
expect(latest.get(entryGroupOf(card('x')))).toBe('e2')
|
|
72
|
+
expect(latest.get(entryGroupOf({ ...card('x'), op: 'update' }))).toBe('u1')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('a 수정안 says in the conversation what was changed', () => {
|
|
76
|
+
expect(entryOutcomeNote({ verdict: 'accepted', applied: 1, missed: 1, op: 'update', rows: [{ index: 0, ok: true }, { index: 1, ok: false, error: 'amount: changed-since' }] }, t)).toBe(
|
|
77
|
+
'changed 1 records.\nrow 2: amount: changed-since'
|
|
78
|
+
)
|
|
79
|
+
})
|
|
67
80
|
})
|