@things-factory/ai-assistant 10.1.88 → 10.1.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/assistant-chat.ts +19 -5
- package/client/components/assistant-dock.ts +21 -1
- package/client/components/entry-review.ts +246 -0
- package/client/utils/assistant-proposals.ts +11 -1
- package/client/utils/entry-proposal.ts +103 -0
- package/dist-client/components/assistant-chat.d.ts +1 -0
- package/dist-client/components/assistant-chat.js +19 -5
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.js +16 -1
- 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/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-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 +13 -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 +4 -1
- package/dist-server/service/index.js +10 -2
- package/dist-server/service/index.js.map +1 -1
- 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.ts +13 -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 +7 -1
- package/test/entry-proposal.test.ts +67 -0
- package/test/open-doors.test.ts +2 -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
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Data entry from material - what an application declares so the assistant can enter its records
|
|
3
|
+
* (operato-application/design/ai/data-entry.md).
|
|
4
|
+
*
|
|
5
|
+
* A person hands the conversation what the records are made of - a bank export, a photo of a receipt,
|
|
6
|
+
* lines pasted from a mail - and the assistant turns it into rows of one of the application's records.
|
|
7
|
+
* Nothing is written by the model. It proposes the rows; the card shows them with what was wrong in
|
|
8
|
+
* each and where each came from; a correction is said in the conversation and the model proposes again;
|
|
9
|
+
* the person presses the button; the server enters each row **through the application's own GraphQL
|
|
10
|
+
* mutation**, run with the person's request state - the same door, directives and checks as entering by hand.
|
|
11
|
+
* There is no second way into the record.
|
|
12
|
+
*
|
|
13
|
+
* This file is pure - no framework, no database - so the rules a row passes are tested without a server.
|
|
14
|
+
*/
|
|
15
|
+
import { parse } from 'graphql'
|
|
16
|
+
|
|
17
|
+
export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'boolean' | 'enum'
|
|
18
|
+
|
|
19
|
+
export interface EntryField {
|
|
20
|
+
name: string
|
|
21
|
+
type: EntryFieldType
|
|
22
|
+
required?: boolean
|
|
23
|
+
/** The allowed values of an `enum` field. */
|
|
24
|
+
values?: string[]
|
|
25
|
+
/** What the model reads about the field - English, like every tool description. */
|
|
26
|
+
description?: string
|
|
27
|
+
/** i18n key of the column heading on the card. Absent: the field name. */
|
|
28
|
+
labelKey?: string
|
|
29
|
+
/** A number field the card sums - and compares with the total the material prints (§ totals). */
|
|
30
|
+
total?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where a row came from - which file, and where in it. Shown on the card beside the row. */
|
|
34
|
+
export interface EntrySource {
|
|
35
|
+
attachmentId?: string
|
|
36
|
+
/** "row 3", "the receipt photo", "the person's message" - the model's words. */
|
|
37
|
+
where?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EntryIssue {
|
|
41
|
+
/** The field it is about. Absent: the row as a whole. */
|
|
42
|
+
field?: string
|
|
43
|
+
/** A code the screen translates: `ai-assistant.entry.issue.<code>`, or the target's own `messageKey`. */
|
|
44
|
+
code: string
|
|
45
|
+
messageKey?: string
|
|
46
|
+
params?: Record<string, unknown>
|
|
47
|
+
/** A blocking issue keeps the row out when the person presses the button. */
|
|
48
|
+
blocking: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface EntryRowInput {
|
|
52
|
+
values?: Record<string, unknown>
|
|
53
|
+
sources?: EntrySource[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface EntryRow {
|
|
57
|
+
index: number
|
|
58
|
+
values: Record<string, unknown>
|
|
59
|
+
sources: EntrySource[]
|
|
60
|
+
issues: EntryIssue[]
|
|
61
|
+
/** The line of a text source where this row's numbers and dates were all found - the server's check, not the model's word. */
|
|
62
|
+
evidence?: { attachmentId: string; line: number }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EntryContext {
|
|
66
|
+
/** The request state - domain, user, and when writing, `tx` and `afterCommit`. */
|
|
67
|
+
state: any
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface EntryApplied {
|
|
71
|
+
/** The created record's id, when there is one. */
|
|
72
|
+
id?: string
|
|
73
|
+
/** A short name for the conversation line - "2026-09-12 · 택시 · ₩12,400". */
|
|
74
|
+
label?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What happened, for an application that writes down every assistant call (office's AI audit). */
|
|
78
|
+
export interface EntryEvent {
|
|
79
|
+
phase: 'propose' | 'apply'
|
|
80
|
+
tool: string
|
|
81
|
+
rows: number
|
|
82
|
+
ok: boolean
|
|
83
|
+
applied?: number
|
|
84
|
+
missed?: number
|
|
85
|
+
error?: string
|
|
86
|
+
/** propose: the rows as the model gave them; apply: the proposal key and each row's result. */
|
|
87
|
+
detail?: unknown
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface EntryTarget {
|
|
91
|
+
/** `<app>.<record>` - `office.expense-line`. */
|
|
92
|
+
key: string
|
|
93
|
+
/** i18n key of the record's name on the card. */
|
|
94
|
+
labelKey: string
|
|
95
|
+
/** What one row is, for the model. */
|
|
96
|
+
description: string
|
|
97
|
+
fields: EntryField[]
|
|
98
|
+
/**
|
|
99
|
+
* The application's own mutation that enters one record, as a GraphQL document with one root field -
|
|
100
|
+
* `mutation ($line: NewExpenseLine!) { addExpenseLine(line: $line) { id } }`. Its root field is the door:
|
|
101
|
+
* the propose tool is offered only to someone who passes it, and each row is entered by running it with
|
|
102
|
+
* the person's request state, so `@privilege`, `@transaction` and the door's own checks apply as they do
|
|
103
|
+
* to a person typing it in.
|
|
104
|
+
*/
|
|
105
|
+
mutation: string
|
|
106
|
+
/** The mutation's variables for one row. */
|
|
107
|
+
variables(row: EntryRow): Record<string, unknown>
|
|
108
|
+
/** What the conversation says of an entered row - its id and a short name. */
|
|
109
|
+
appliedOf?(data: any, row: EntryRow): EntryApplied | undefined
|
|
110
|
+
/** At most this many rows in one proposal. Default 200. */
|
|
111
|
+
maxRows?: number
|
|
112
|
+
/**
|
|
113
|
+
* Read-only checks the application owns - a duplicate of what is already entered, a code that does not
|
|
114
|
+
* exist. Asked when the rows are proposed and again when they are entered: the records may have moved.
|
|
115
|
+
* Returns one list per row, in order.
|
|
116
|
+
*/
|
|
117
|
+
check?(rows: EntryRow[], context: EntryContext): EntryIssue[][] | Promise<EntryIssue[][]>
|
|
118
|
+
/** Told of every proposal and every entering - an application that audits its assistant writes it down. A throw is ignored. */
|
|
119
|
+
record?(event: EntryEvent, context: EntryContext): void | Promise<void>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `Mutation.<root field>` of the target's mutation - the one declaration both the tool's gate and the
|
|
124
|
+
* entering read. A document that is not one mutation with one root field is refused at boot.
|
|
125
|
+
*/
|
|
126
|
+
export function entryDoorOf(mutation: string): string {
|
|
127
|
+
const doc = parse(mutation)
|
|
128
|
+
const operations = doc.definitions.filter((one: any) => one.kind === 'OperationDefinition') as any[]
|
|
129
|
+
if (operations.length !== 1 || operations[0].operation !== 'mutation') throw new Error('is not one mutation')
|
|
130
|
+
const fields = operations[0].selectionSet.selections.filter((one: any) => one.kind === 'Field')
|
|
131
|
+
if (fields.length !== 1) throw new Error('must have exactly one root field')
|
|
132
|
+
return `Mutation.${fields[0].name.value}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Tells the target, and never lets its record keeping stop the work. */
|
|
136
|
+
export async function recordEntryEvent(target: EntryTarget, event: EntryEvent, context: EntryContext): Promise<void> {
|
|
137
|
+
if (!target.record) return
|
|
138
|
+
try {
|
|
139
|
+
await target.record(event, context)
|
|
140
|
+
} catch {
|
|
141
|
+
/* an audit that failed to write is the application's to report */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const DEFAULT_MAX_ROWS = 200
|
|
146
|
+
|
|
147
|
+
const targets = new Map<string, EntryTarget>()
|
|
148
|
+
|
|
149
|
+
const KEY = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/
|
|
150
|
+
|
|
151
|
+
/** Validates and keeps a target - `registerEntryTarget` (entry-tools.ts) also opens its tool. A malformed declaration is refused at boot, not in a conversation. */
|
|
152
|
+
export function declareEntryTarget(target: EntryTarget): void {
|
|
153
|
+
if (!KEY.test(target?.key ?? '')) throw new Error(`[ai-assistant] entry target key '${target?.key}' is not '<app>.<record>'.`)
|
|
154
|
+
try {
|
|
155
|
+
entryDoorOf(target.mutation ?? '')
|
|
156
|
+
} catch (error: any) {
|
|
157
|
+
throw new Error(`[ai-assistant] entry target '${target.key}': its mutation ${error?.message ?? 'does not parse'}.`)
|
|
158
|
+
}
|
|
159
|
+
if (typeof target.variables !== 'function') throw new Error(`[ai-assistant] entry target '${target.key}' has no variables.`)
|
|
160
|
+
if (!target.fields?.length) throw new Error(`[ai-assistant] entry target '${target.key}' declares no fields.`)
|
|
161
|
+
const names = new Set<string>()
|
|
162
|
+
for (const field of target.fields) {
|
|
163
|
+
if (names.has(field.name)) throw new Error(`[ai-assistant] entry target '${target.key}' declares '${field.name}' twice.`)
|
|
164
|
+
names.add(field.name)
|
|
165
|
+
if (field.type === 'enum' && !field.values?.length)
|
|
166
|
+
throw new Error(`[ai-assistant] entry target '${target.key}': enum field '${field.name}' lists no values.`)
|
|
167
|
+
}
|
|
168
|
+
targets.set(target.key, target)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function entryTargetOf(key: string | undefined): EntryTarget | undefined {
|
|
172
|
+
return key ? targets.get(key) : undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function entryTargets(): EntryTarget[] {
|
|
176
|
+
return [...targets.values()]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** For tests. */
|
|
180
|
+
export function clearEntryTargets(): void {
|
|
181
|
+
targets.clear()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `office.expense-line` → `proposeOfficeExpenseLineEntries` - one tool per target, so each has its own door. */
|
|
185
|
+
export function entryToolName(key: string): string {
|
|
186
|
+
const pascal = key
|
|
187
|
+
.split(/[.-]/)
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.map(part => part[0].toUpperCase() + part.slice(1))
|
|
190
|
+
.join('')
|
|
191
|
+
return `propose${pascal}Entries`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function entryCategoryName(key: string): string {
|
|
195
|
+
return `data-entry:${key}`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const empty = (value: unknown) => value === undefined || value === null || (typeof value === 'string' && !value.trim())
|
|
199
|
+
|
|
200
|
+
/** "12,400" · "₩12,400" · "12 400" → 12400. Only the separators people write; anything else is not a number. */
|
|
201
|
+
function toNumber(value: unknown): number | undefined {
|
|
202
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
|
|
203
|
+
if (typeof value !== 'string') return undefined
|
|
204
|
+
const cleaned = value.trim().replace(/^[₩$€¥£]\s*/, '').replace(/[,\s]/g, '').replace(/원$/, '')
|
|
205
|
+
if (!/^-?\d+(\.\d+)?$/.test(cleaned)) return undefined
|
|
206
|
+
return Number(cleaned)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A calendar date as `YYYY-MM-DD` - "2026.9.12", "2026/09/12" too. A day that does not exist is not a date. */
|
|
210
|
+
function toDate(value: unknown): string | undefined {
|
|
211
|
+
if (typeof value !== 'string') return undefined
|
|
212
|
+
const match = value.trim().match(/^(\d{4})[-./](\d{1,2})[-./](\d{1,2})\.?$/)
|
|
213
|
+
if (!match) return undefined
|
|
214
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
215
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
216
|
+
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return undefined
|
|
217
|
+
return `${match[1]}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function toBoolean(value: unknown): boolean | undefined {
|
|
221
|
+
if (typeof value === 'boolean') return value
|
|
222
|
+
if (typeof value !== 'string') return undefined
|
|
223
|
+
const text = value.trim().toLowerCase()
|
|
224
|
+
if (['true', 'yes', 'y', '1'].includes(text)) return true
|
|
225
|
+
if (['false', 'no', 'n', '0'].includes(text)) return false
|
|
226
|
+
return undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The rows as the target reads them - each value in its field's type, and what was wrong said as an issue.
|
|
231
|
+
*
|
|
232
|
+
* A value that does not fit is **kept as it was written and marked**, never dropped or guessed: the card
|
|
233
|
+
* shows what the material said, and the person corrects it in the conversation.
|
|
234
|
+
*/
|
|
235
|
+
export function normalizeEntryRows(target: EntryTarget, input: unknown): { rows: EntryRow[]; cut: number } {
|
|
236
|
+
const list = Array.isArray(input) ? input : []
|
|
237
|
+
const max = target.maxRows ?? DEFAULT_MAX_ROWS
|
|
238
|
+
const fields = new Map(target.fields.map(field => [field.name, field]))
|
|
239
|
+
|
|
240
|
+
const rows = list.slice(0, max).map((raw: any, index): EntryRow => {
|
|
241
|
+
const given = raw && typeof raw.values === 'object' && raw.values ? raw.values : {}
|
|
242
|
+
const values: Record<string, unknown> = {}
|
|
243
|
+
const issues: EntryIssue[] = []
|
|
244
|
+
|
|
245
|
+
for (const name of Object.keys(given)) {
|
|
246
|
+
if (!fields.has(name)) issues.push({ field: name, code: 'unknown-field', blocking: false })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const field of target.fields) {
|
|
250
|
+
const value = given[field.name]
|
|
251
|
+
if (empty(value)) {
|
|
252
|
+
if (field.required) issues.push({ field: field.name, code: 'required', blocking: true })
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
let read: unknown
|
|
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
|
+
}
|
|
282
|
+
if (read === undefined) {
|
|
283
|
+
values[field.name] = value
|
|
284
|
+
issues.push({ field: field.name, code, params: code === 'not-in-list' ? { values: field.values } : undefined, blocking: true })
|
|
285
|
+
} else {
|
|
286
|
+
values[field.name] = read
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const sources: EntrySource[] = Array.isArray(raw?.sources)
|
|
291
|
+
? raw.sources
|
|
292
|
+
.filter((one: any) => one && (one.attachmentId || one.where))
|
|
293
|
+
.map((one: any) => ({
|
|
294
|
+
...(typeof one.attachmentId === 'string' ? { attachmentId: one.attachmentId } : {}),
|
|
295
|
+
...(typeof one.where === 'string' ? { where: one.where.slice(0, 120) } : {})
|
|
296
|
+
}))
|
|
297
|
+
: []
|
|
298
|
+
if (!sources.length) issues.push({ code: 'no-source', blocking: false })
|
|
299
|
+
|
|
300
|
+
return { index, values, sources, issues }
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
return { rows, cut: Math.max(0, list.length - max) }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Adds the target's own findings to each row. A check that throws marks nothing and is reported by the caller. */
|
|
307
|
+
export function withCheckedIssues(rows: EntryRow[], found: EntryIssue[][] | undefined): EntryRow[] {
|
|
308
|
+
if (!found) return rows
|
|
309
|
+
return rows.map((row, i) => (found[i]?.length ? { ...row, issues: [...row.issues, ...found[i]] } : row))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function isBlocked(row: EntryRow): boolean {
|
|
313
|
+
return row.issues.some(issue => issue.blocking)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ── Checking the rows against the material (design/ai/data-entry.md §4-1) ─────────────────────────────
|
|
317
|
+
*
|
|
318
|
+
* A row cites where it came from; the model's word is not proof. For a text source the server looks: the
|
|
319
|
+
* row's numbers and dates must stand together on one line of the cited file. Found - the row carries that
|
|
320
|
+
* line as evidence. A value the file does not have is marked, unless the row also cites the person's
|
|
321
|
+
* message (a correction said in the conversation). A photo cannot be read here - the row says so.
|
|
322
|
+
* Names and descriptions are not looked for: the model tidies them, and a tidied name is not a wrong one.
|
|
323
|
+
*/
|
|
324
|
+
|
|
325
|
+
const PERSON = /person|message|user|사용자|말/i
|
|
326
|
+
|
|
327
|
+
/** The ways a number or a date is written in a file. */
|
|
328
|
+
export function writtenForms(type: EntryFieldType, value: unknown): string[] {
|
|
329
|
+
if (type === 'number' || type === 'integer') {
|
|
330
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return []
|
|
331
|
+
const negative = value < 0
|
|
332
|
+
const abs = Math.abs(value)
|
|
333
|
+
const fixed = Number.isInteger(abs) ? String(abs) : abs.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
|
|
334
|
+
const [int, frac] = fixed.split('.')
|
|
335
|
+
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
336
|
+
const forms = new Set<string>()
|
|
337
|
+
for (const whole of [int, grouped]) {
|
|
338
|
+
forms.add(frac ? `${whole}.${frac}` : whole)
|
|
339
|
+
if (!frac) forms.add(`${whole}.00`)
|
|
340
|
+
}
|
|
341
|
+
return [...forms].map(form => (negative ? `-${form}` : form))
|
|
342
|
+
}
|
|
343
|
+
if (type === 'date') {
|
|
344
|
+
const match = typeof value === 'string' ? value.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
|
|
345
|
+
if (!match) return []
|
|
346
|
+
const [, y, m, d] = match
|
|
347
|
+
const mm = String(Number(m))
|
|
348
|
+
const dd = String(Number(d))
|
|
349
|
+
return [`${y}-${m}-${d}`, `${y}.${m}.${d}`, `${y}/${m}/${d}`, `${y}${m}${d}`, `${y}.${mm}.${dd}`, `${y}/${mm}/${dd}`, `${y}-${mm}-${dd}`, `${y.slice(2)}.${m}.${d}`, `${y.slice(2)}/${m}/${d}`]
|
|
350
|
+
}
|
|
351
|
+
return []
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A form found as a whole number - "12,400" is not found in "112,400", "12,4001", "12,400.50" or "12,400,000";
|
|
356
|
+
* "2026-09-12" not in "2026-09-120". A CSV's own commas are not part of the number.
|
|
357
|
+
*/
|
|
358
|
+
export function lineHas(line: string, forms: string[]): boolean {
|
|
359
|
+
const digit = (c: string | undefined) => !!c && c >= '0' && c <= '9'
|
|
360
|
+
return forms.some(form => {
|
|
361
|
+
for (let at = line.indexOf(form); at >= 0; at = line.indexOf(form, at + 1)) {
|
|
362
|
+
const before = line[at - 1]
|
|
363
|
+
const after = line[at + form.length]
|
|
364
|
+
/* A comma is also the column separator of a CSV: only a comma and three digits more continue the number. */
|
|
365
|
+
if (digit(before) || (before === '.' && digit(line[at - 2]))) continue
|
|
366
|
+
if (digit(after) || (after === '.' && digit(line[at + form.length + 1]))) continue
|
|
367
|
+
if (after === ',' && /^,\d{3}(?!\d)/.test(line.slice(at + form.length))) continue
|
|
368
|
+
return true
|
|
369
|
+
}
|
|
370
|
+
return false
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Checks each row against the text of the files it cites. `texts` holds what could be read, by attachment id;
|
|
376
|
+
* an id that is not there was an image, or could not be opened.
|
|
377
|
+
*/
|
|
378
|
+
export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[], texts: Map<string, string>): EntryRow[] {
|
|
379
|
+
const checked = fields.filter(field => ['number', 'integer', 'date'].includes(field.type))
|
|
380
|
+
return rows.map(row => {
|
|
381
|
+
const files = row.sources.map(source => source.attachmentId).filter((id): id is string => !!id)
|
|
382
|
+
if (!files.length || !checked.length) return row
|
|
383
|
+
const readable = files.filter(id => texts.has(id))
|
|
384
|
+
if (!readable.length) return { ...row, issues: [...row.issues, { code: 'source-not-checked', blocking: false }] }
|
|
385
|
+
|
|
386
|
+
const forms = checked
|
|
387
|
+
.map(field => ({ field, forms: writtenForms(field.type, row.values[field.name]) }))
|
|
388
|
+
.filter(one => one.forms.length)
|
|
389
|
+
if (!forms.length) return row
|
|
390
|
+
|
|
391
|
+
for (const id of readable) {
|
|
392
|
+
const lines = texts.get(id)!.split(/\r?\n/)
|
|
393
|
+
const at = lines.findIndex(line => forms.every(one => lineHas(line, one.forms)))
|
|
394
|
+
if (at >= 0) return { ...row, evidence: { attachmentId: id, line: at + 1 } }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const saidByPerson = row.sources.some(source => !source.attachmentId && PERSON.test(source.where ?? ''))
|
|
398
|
+
const issues = [...row.issues]
|
|
399
|
+
for (const one of forms) {
|
|
400
|
+
const anywhere = readable.some(id => texts.get(id)!.split(/\r?\n/).some(line => lineHas(line, one.forms)))
|
|
401
|
+
if (!anywhere && !saidByPerson) issues.push({ field: one.field.name, code: 'not-in-source', blocking: false })
|
|
402
|
+
}
|
|
403
|
+
if (issues.length === row.issues.length) issues.push({ code: 'source-scattered', blocking: false })
|
|
404
|
+
return { ...row, issues }
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export interface EntryTotal {
|
|
409
|
+
field: string
|
|
410
|
+
/** The sum over every row, and over the rows that will be entered. */
|
|
411
|
+
all: number
|
|
412
|
+
ready: number
|
|
413
|
+
/** The total the material prints, as the model read it - and whether the server found it in the file. */
|
|
414
|
+
declared?: number
|
|
415
|
+
declaredFound?: boolean
|
|
416
|
+
/** declared - all. */
|
|
417
|
+
difference?: number
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The sums of the fields declared `total`, and how they stand against the total the material prints. */
|
|
421
|
+
export function entryTotals(
|
|
422
|
+
rows: EntryRow[],
|
|
423
|
+
fields: EntryField[],
|
|
424
|
+
declared: { field?: string; value?: unknown; attachmentId?: string } | undefined,
|
|
425
|
+
texts: Map<string, string>
|
|
426
|
+
): EntryTotal[] {
|
|
427
|
+
return fields
|
|
428
|
+
.filter(field => field.total && (field.type === 'number' || field.type === 'integer'))
|
|
429
|
+
.map(field => {
|
|
430
|
+
const sum = (list: EntryRow[]) => Math.round(list.reduce((acc, row) => acc + (typeof row.values[field.name] === 'number' ? (row.values[field.name] as number) : 0), 0) * 100) / 100
|
|
431
|
+
const total: EntryTotal = { field: field.name, all: sum(rows), ready: sum(rows.filter(row => !isBlocked(row))) }
|
|
432
|
+
const value = declared && (declared.field ?? field.name) === field.name ? toNumber(declared.value) : undefined
|
|
433
|
+
if (value !== undefined) {
|
|
434
|
+
total.declared = value
|
|
435
|
+
total.difference = Math.round((value - total.all) * 100) / 100
|
|
436
|
+
const text = declared!.attachmentId ? texts.get(declared!.attachmentId) : undefined
|
|
437
|
+
if (text !== undefined) total.declaredFound = text.split(/\r?\n/).some(line => lineHas(line, writtenForms(field.type, value)))
|
|
438
|
+
}
|
|
439
|
+
return total
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The propose tool of each entry target (operato-application/design/ai/data-entry.md §3).
|
|
3
|
+
*
|
|
4
|
+
* One tool per target, so each carries its own door: the tool is offered only to a person who could enter
|
|
5
|
+
* those records by hand, and its card cannot be made for someone who could not press it.
|
|
6
|
+
*
|
|
7
|
+
* The tool writes nothing. It reads the rows the model made from the material, puts each value in its
|
|
8
|
+
* field's type, asks the target's own checks, and hands back a proposal - the card.
|
|
9
|
+
*/
|
|
10
|
+
import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
declareEntryTarget,
|
|
14
|
+
entryTotals,
|
|
15
|
+
verifyRowsAgainstSources,
|
|
16
|
+
entryCategoryName,
|
|
17
|
+
entryDoorOf,
|
|
18
|
+
entryToolName,
|
|
19
|
+
isBlocked,
|
|
20
|
+
normalizeEntryRows,
|
|
21
|
+
recordEntryEvent,
|
|
22
|
+
withCheckedIssues,
|
|
23
|
+
type EntryField,
|
|
24
|
+
type EntryRow,
|
|
25
|
+
type EntryTarget
|
|
26
|
+
} from './entry-target.js'
|
|
27
|
+
|
|
28
|
+
function fieldSchema(field: EntryField): any {
|
|
29
|
+
const type = field.type === 'number' || field.type === 'integer' ? ['number', 'string'] : field.type === 'boolean' ? ['boolean', 'string'] : 'string'
|
|
30
|
+
const said = [
|
|
31
|
+
field.description,
|
|
32
|
+
field.required ? 'required' : 'optional',
|
|
33
|
+
field.type === 'date' ? 'YYYY-MM-DD' : undefined,
|
|
34
|
+
field.type === 'enum' ? `one of: ${field.values!.join(', ')}` : undefined
|
|
35
|
+
]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join('. ')
|
|
38
|
+
return { type, description: said }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the card needs besides the rows - the columns, in the target's order. */
|
|
42
|
+
function columnsOf(target: EntryTarget) {
|
|
43
|
+
return target.fields.map(field => ({ name: field.name, type: field.type, required: !!field.required, ...(field.labelKey ? { labelKey: field.labelKey } : {}) }))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SourceTextLoader = (attachmentIds: string[], context: any) => Promise<Map<string, string>>
|
|
47
|
+
|
|
48
|
+
const readSources: SourceTextLoader = async (ids, context) => (await import('./entry-sources.js')).loadSourceTexts(ids, context)
|
|
49
|
+
|
|
50
|
+
export async function proposeEntries(target: EntryTarget, args: any, context: any, loadTexts: SourceTextLoader = readSources) {
|
|
51
|
+
const tool = entryToolName(target.key)
|
|
52
|
+
const told = (event: Omit<Parameters<typeof recordEntryEvent>[1], 'phase' | 'tool'>) => recordEntryEvent(target, { phase: 'propose', tool, ...event }, { state: context?.state })
|
|
53
|
+
const { rows: normalized, cut } = normalizeEntryRows(target, args?.rows)
|
|
54
|
+
if (!normalized.length) {
|
|
55
|
+
await told({ rows: 0, ok: false, error: 'no-rows', detail: args })
|
|
56
|
+
return { rejected: true, error: 'no-rows', message: 'no rows were given. Read the material and pass every record as a row.' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* The server looks in the cited files itself - the model's citation is not proof (design/ai/data-entry.md §4-1). */
|
|
60
|
+
const declared = args?.declaredTotal && typeof args.declaredTotal === 'object' ? args.declaredTotal : undefined
|
|
61
|
+
const cited = [...normalized.flatMap(row => row.sources.map(source => source.attachmentId)), declared?.attachmentId].filter((id): id is string => typeof id === 'string' && !!id)
|
|
62
|
+
const texts = cited.length ? await loadTexts(cited, context).catch(() => new Map<string, string>()) : new Map<string, string>()
|
|
63
|
+
const read = verifyRowsAgainstSources(normalized, target.fields, texts)
|
|
64
|
+
|
|
65
|
+
let rows: EntryRow[] = read
|
|
66
|
+
let checkFailed: string | undefined
|
|
67
|
+
if (target.check) {
|
|
68
|
+
try {
|
|
69
|
+
rows = withCheckedIssues(read, await target.check(read, { state: context?.state }))
|
|
70
|
+
} catch (error: any) {
|
|
71
|
+
checkFailed = error?.message || 'check-failed'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const blocked = rows.filter(isBlocked).length
|
|
76
|
+
const totals = entryTotals(rows, target.fields, declared, texts)
|
|
77
|
+
const verified = rows.filter(row => row.evidence).length
|
|
78
|
+
const unmatched = rows.filter(row => row.issues.some(issue => issue.code === 'not-in-source')).length
|
|
79
|
+
await told({ rows: rows.length, ok: true, detail: args })
|
|
80
|
+
return {
|
|
81
|
+
proposed: true,
|
|
82
|
+
kind: 'entry',
|
|
83
|
+
target: target.key,
|
|
84
|
+
labelKey: target.labelKey,
|
|
85
|
+
label: `${rows.length} ${target.key} rows`,
|
|
86
|
+
icon: 'playlist_add',
|
|
87
|
+
columns: columnsOf(target),
|
|
88
|
+
rows,
|
|
89
|
+
counts: { rows: rows.length, ready: rows.length - blocked, blocked, cut, verified, unmatched },
|
|
90
|
+
...(totals.length ? { totals } : {}),
|
|
91
|
+
...(typeof args?.note === 'string' && args.note.trim() ? { reason: args.note.trim().slice(0, 300) } : {}),
|
|
92
|
+
...(checkFailed ? { checkFailed } : {}),
|
|
93
|
+
/* For the model - the card is the answer; it says what the person must still decide. */
|
|
94
|
+
message:
|
|
95
|
+
`proposed ${rows.length} rows (${rows.length - blocked} ready, ${blocked} blocked${cut ? `, ${cut} cut over the limit` : ''}; ` +
|
|
96
|
+
`${verified} found line by line in their files, ${unmatched} with a value the file does not have). ` +
|
|
97
|
+
totals.filter(total => total.difference).map(total => `${total.field}: the material's total differs from the rows by ${total.difference}. `).join('') +
|
|
98
|
+
'nothing is written until the person presses the button on the card. Tell them which rows are blocked or do not match their file, and why, and ask for what is missing.'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function entryCategoryOf(target: EntryTarget): ToolCategory {
|
|
103
|
+
const name = entryToolName(target.key)
|
|
104
|
+
const spec = {
|
|
105
|
+
kind: 'read',
|
|
106
|
+
name,
|
|
107
|
+
/* The person must be able to enter these records themselves - the tool is not a second way in. */
|
|
108
|
+
doors: [entryDoorOf(target.mutation)],
|
|
109
|
+
description:
|
|
110
|
+
`Propose records to enter: ${target.description} ` +
|
|
111
|
+
'Pass every record found in the material as one row. The rows are shown to the person as a card; nothing is written until they press it.',
|
|
112
|
+
schema: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
rows: {
|
|
116
|
+
type: 'array',
|
|
117
|
+
description: `At most ${target.maxRows ?? 200} rows.`,
|
|
118
|
+
items: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
values: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: Object.fromEntries(target.fields.map(field => [field.name, fieldSchema(field)]))
|
|
124
|
+
},
|
|
125
|
+
sources: {
|
|
126
|
+
type: 'array',
|
|
127
|
+
description: 'Where this row came from: the attachment id and where in it ("row 3", "the receipt photo"), or where: "the person\'s message".',
|
|
128
|
+
items: { type: 'object', properties: { attachmentId: { type: 'string' }, where: { type: 'string' } } }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
required: ['values', 'sources']
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
note: { type: 'string', description: 'One line for the card - what this set is ("September card statement").' },
|
|
135
|
+
declaredTotal: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
description: 'The total the material itself prints, when it prints one - the card compares it with the sum of the rows. Never compute it yourself.',
|
|
138
|
+
properties: {
|
|
139
|
+
field: { type: 'string', description: `Which field it totals${target.fields.filter(field => field.total).length ? ` (${target.fields.filter(field => field.total).map(field => field.name).join(', ')})` : ''}.` },
|
|
140
|
+
value: { type: ['number', 'string'] },
|
|
141
|
+
attachmentId: { type: 'string' },
|
|
142
|
+
where: { type: 'string' }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
required: ['rows']
|
|
147
|
+
},
|
|
148
|
+
builder: (args: any, context: any) => proposeEntries(target, args, context)
|
|
149
|
+
} as ToolSpec
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
name: entryCategoryName(target.key),
|
|
153
|
+
description: `data entry: ${target.key}`,
|
|
154
|
+
guidance: [
|
|
155
|
+
`- When the person gives material to enter as records (files, photos, pasted text), read all of it first - call readChatAttachment for a file sent in an earlier turn - then call ${name} once with every record as a row.`,
|
|
156
|
+
'- Never invent a value. Leave a field out when the material does not say it; the card marks it, and you ask the person for it.',
|
|
157
|
+
'- 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
|
+
'- When the material prints a total, pass it as declaredTotal exactly as printed - do not add it up yourself.',
|
|
159
|
+
`- ${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.`
|
|
161
|
+
].join('\n'),
|
|
162
|
+
specs: [spec]
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What an application calls at boot to let the assistant enter one of its records.
|
|
168
|
+
*
|
|
169
|
+
* A mode opens it by naming the key in `entryTargets` (the tool is not in every conversation of the app).
|
|
170
|
+
*/
|
|
171
|
+
export function registerEntryTarget(target: EntryTarget): void {
|
|
172
|
+
declareEntryTarget(target)
|
|
173
|
+
registerToolCategory(entryCategoryOf(target))
|
|
174
|
+
}
|
package/server/service/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { entities as ChatSessionParticipantEntities } from './chat-session-parti
|
|
|
10
10
|
import './chat-session/attachment-tools.js'
|
|
11
11
|
import './chat-session/session-purge.js'
|
|
12
12
|
import './chat-session/attachment-route.js'
|
|
13
|
+
import { EntryProposalResolver } from './data-entry/entry-resolver.js'
|
|
13
14
|
|
|
14
15
|
export * from './types.js'
|
|
15
16
|
export * from './chat-session-resolver.js'
|
|
@@ -23,6 +24,9 @@ export * from './assistant-capability-resolver.js'
|
|
|
23
24
|
* 실린다. 시험은 경로로 가져온다: `…/service/testing/conversation-script.js`.
|
|
24
25
|
*/
|
|
25
26
|
export * from './tool-access.js'
|
|
27
|
+
/* Data entry from material - an app registers what it can enter; a mode names it (design/ai/data-entry.md). */
|
|
28
|
+
export * from './data-entry/entry-target.js'
|
|
29
|
+
export { registerEntryTarget, entryCategoryOf } from './data-entry/entry-tools.js'
|
|
26
30
|
/* 무엇이 근거인가 — 도구 결과 밖의 셋(프롬프트 · 대화 · 화면 스냅숏). */
|
|
27
31
|
export * from './grounding-sources.js'
|
|
28
32
|
/* 앵커 순수 로직 — 호스트가 앵커 종류를 참조한다(리졸버는 데코레이터 때문에 무겁다). */
|
|
@@ -63,6 +67,8 @@ export const schema = {
|
|
|
63
67
|
AssistantCapabilityResolver,
|
|
64
68
|
ChatMessageSubscription,
|
|
65
69
|
/* 내가 참여한 모든 세션의 활동 — 창을 닫아 둔 표면이 배지·목록을 갱신할 수 있게. */
|
|
66
|
-
MySessionActivitySubscription
|
|
70
|
+
MySessionActivitySubscription,
|
|
71
|
+
/* 사람이 누른 입력 제안을 쓰는 자리 — 행마다 앱의 코드로, 그 사람의 권한으로. */
|
|
72
|
+
EntryProposalResolver
|
|
67
73
|
]
|
|
68
74
|
}
|