@things-factory/ai-assistant 10.1.99 → 10.1.101
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/entry-review.ts +19 -4
- package/client/components/workspace-view.ts +6 -1
- package/client/utils/entry-proposal.ts +12 -2
- package/dist-client/components/entry-review.d.ts +1 -1
- package/dist-client/components/entry-review.js +21 -5
- package/dist-client/components/entry-review.js.map +1 -1
- package/dist-client/components/workspace-view.d.ts +1 -1
- package/dist-client/components/workspace-view.js +7 -2
- package/dist-client/components/workspace-view.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/entry-proposal.d.ts +2 -0
- package/dist-client/utils/entry-proposal.js +11 -1
- package/dist-client/utils/entry-proposal.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.js +27 -1
- package/dist-server/service/data-entry/entry-apply.js.map +1 -1
- package/dist-server/service/data-entry/entry-target.d.ts +69 -6
- package/dist-server/service/data-entry/entry-target.js +159 -12
- package/dist-server/service/data-entry/entry-target.js.map +1 -1
- package/dist-server/service/data-entry/entry-tools.d.ts +13 -1
- package/dist-server/service/data-entry/entry-tools.js +27 -10
- package/dist-server/service/data-entry/entry-tools.js.map +1 -1
- package/dist-server/service/data-entry/entry-update.js +3 -3
- package/dist-server/service/data-entry/entry-update.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/data-entry/data-entry-update.test.ts +107 -1
- package/server/service/data-entry/data-entry.test.ts +41 -4
- package/server/service/data-entry/entry-apply.ts +26 -3
- package/server/service/data-entry/entry-target.ts +190 -17
- package/server/service/data-entry/entry-tools.ts +29 -10
- package/server/service/data-entry/entry-update.ts +4 -3
- package/test/entry-proposal.test.ts +11 -0
- package/translations/en.json +9 -1
- package/translations/ja.json +9 -1
- package/translations/ko.json +9 -1
- package/translations/ms.json +9 -1
- package/translations/zh.json +9 -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.101",
|
|
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": "44abc5cac98642b8028b17bf3e019d083539ccab"
|
|
55
55
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and what is asked again before a record is changed.
|
|
4
4
|
*/
|
|
5
5
|
import { clearEntryTargets, declareEntryTarget, changeToolName, findToolName, type EntryTarget } from './entry-target'
|
|
6
|
-
import { entryCategoryOf } from './entry-tools'
|
|
6
|
+
import { entryCategoryOf, proposeEntries } from './entry-tools'
|
|
7
7
|
import { changeRows, findRecords, proposeChanges, resolveChangeRefs, stillAsProposed } from './entry-update'
|
|
8
8
|
import { applyEntryProposal, type EntryApplyDeps } from './entry-apply'
|
|
9
9
|
|
|
@@ -205,3 +205,109 @@ describe('the tool schemas', () => {
|
|
|
205
205
|
for (const spec of entryCategoryOf(expense, () => run).specs as any[]) expect(typesOf(spec.schema)).toEqual([])
|
|
206
206
|
})
|
|
207
207
|
})
|
|
208
|
+
|
|
209
|
+
describe('a field that points at another record', () => {
|
|
210
|
+
const loans: Record<string, any> = { a: { id: 'loan-a', name: '기업은행 운전자금' }, b: { id: 'loan-b', name: '중진공 시설자금' }, c: { id: 'loan-c', name: '기업은행 시설자금' } }
|
|
211
|
+
const lookup = async (document: string, variables: any) => {
|
|
212
|
+
calls.push({ document, variables })
|
|
213
|
+
if (document.includes('loans(')) return { data: { loans: Object.values(loans).filter(one => one.name.includes(variables.text)) } }
|
|
214
|
+
return run(document, variables)
|
|
215
|
+
}
|
|
216
|
+
const repayment: EntryTarget = {
|
|
217
|
+
key: 'office.loan-repayment',
|
|
218
|
+
labelKey: 'x',
|
|
219
|
+
description: 'one repayment.',
|
|
220
|
+
mutation: 'mutation ($r: NewLoanRepayment!) { createLoanRepayment(loanRepayment: $r) { id } }',
|
|
221
|
+
variables: row => ({ r: { ...row.values, loan: { id: (row.values.loan as any).id } } }),
|
|
222
|
+
fields: [
|
|
223
|
+
{ name: 'loan', type: 'reference', required: true, reference: { query: 'query ($text: String) { loans(text: $text) { id name } }', variables: text => ({ text }), recordsOf: (data, text) => data.loans.filter((one: any) => one.name.includes(text)).map((one: any) => ({ id: one.id, label: one.name })) } },
|
|
224
|
+
{ name: 'dueDate', type: 'date', required: true },
|
|
225
|
+
{ name: 'principal', type: 'number' }
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
it('★ one match keeps its id and name; none or several keep the row out, several with their names - the server never picks', async () => {
|
|
230
|
+
const result: any = await proposeEntries(
|
|
231
|
+
repayment,
|
|
232
|
+
{ rows: [{ values: { loan: '중진공', dueDate: '2026-10-25', principal: 1 }, sources: [{ where: 'x' }] }, { values: { loan: '기업은행', dueDate: '2026-10-25' }, sources: [{ where: 'x' }] }, { values: { loan: '신한', dueDate: '2026-10-25' }, sources: [{ where: 'x' }] }] },
|
|
233
|
+
{ state: {} },
|
|
234
|
+
async () => new Map(),
|
|
235
|
+
lookup
|
|
236
|
+
)
|
|
237
|
+
expect(result.rows[0].values.loan).toEqual({ id: 'loan-b', label: '중진공 시설자금' })
|
|
238
|
+
expect(result.rows[0].issues).toEqual([])
|
|
239
|
+
expect(result.rows[1].issues).toEqual([{ field: 'loan', code: 'reference-ambiguous', params: { text: '기업은행', candidates: '기업은행 운전자금, 기업은행 시설자금' }, blocking: true }])
|
|
240
|
+
expect(result.rows[2].issues).toEqual([{ field: 'loan', code: 'reference-not-found', params: { text: '신한' }, blocking: true }])
|
|
241
|
+
expect(result.counts).toMatchObject({ ready: 1, blocked: 2 })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("★ the tool carries the reference query's door too - reading the names is the person's privilege", () => {
|
|
245
|
+
expect((entryCategoryOf(repayment, () => lookup).specs[0] as any).doors).toEqual(['Mutation.createLoanRepayment', 'Query.loans'])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('a declaration whose reference has no query is refused at boot', () => {
|
|
249
|
+
expect(() => declareEntryTarget({ ...repayment, fields: [{ name: 'loan', type: 'reference' }] as any })).toThrow(/reference field 'loan'/)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('the entering passes the resolved id, never the name', async () => {
|
|
253
|
+
declareEntryTarget(repayment)
|
|
254
|
+
const proposed: any = await proposeEntries(repayment, { rows: [{ values: { loan: '중진공', dueDate: '2026-10-25' }, sources: [{ where: 'x' }] }] }, { state: {} }, async () => new Map(), lookup)
|
|
255
|
+
const sent: any[] = []
|
|
256
|
+
const outcome = await applyEntryProposal('r1', {
|
|
257
|
+
messages: [{ id: 'm1', proposals: [{ ...proposed, key: 'r1' }] }],
|
|
258
|
+
answered: false,
|
|
259
|
+
passesDoor: async () => true,
|
|
260
|
+
execute: async (document, variables) => (document.includes('createLoanRepayment') ? (sent.push(variables), { data: { createLoanRepayment: { id: 'x' } } }) : lookup(document, variables)),
|
|
261
|
+
state: {}
|
|
262
|
+
})
|
|
263
|
+
expect(outcome).toMatchObject({ verdict: 'accepted', applied: 1 })
|
|
264
|
+
expect(sent[0].r.loan).toEqual({ id: 'loan-b' })
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('a record with lines', () => {
|
|
269
|
+
const advice: EntryTarget = {
|
|
270
|
+
key: 'warehouse.inbound-advice',
|
|
271
|
+
labelKey: 'x',
|
|
272
|
+
description: 'one inbound advice line.',
|
|
273
|
+
mutation: 'mutation ($advice: InboundAdviceInput!) { receiveInboundAdvice(advice: $advice) { name } }',
|
|
274
|
+
fields: [
|
|
275
|
+
{ name: 'despatchAdvice', type: 'string', required: true },
|
|
276
|
+
{ name: 'epcClass', type: 'string', required: true },
|
|
277
|
+
{ name: 'quantity', type: 'number', required: true }
|
|
278
|
+
],
|
|
279
|
+
group: {
|
|
280
|
+
by: 'despatchAdvice',
|
|
281
|
+
variables: rows => ({ advice: { name: rows[0].values.despatchAdvice, lines: rows.map(row => ({ epcClass: row.values.epcClass, quantity: row.values.quantity })) } }),
|
|
282
|
+
appliedOf: data => ({ id: data?.receiveInboundAdvice?.name })
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const line = (despatchAdvice: string, epcClass: string, quantity: any) => ({ values: { despatchAdvice, epcClass, quantity }, sources: [{ where: 'x' }] })
|
|
286
|
+
|
|
287
|
+
it('★ the lines of one record go in by one call; a record with a line kept out stays out whole', async () => {
|
|
288
|
+
declareEntryTarget(advice)
|
|
289
|
+
const proposed: any = await proposeEntries(advice, { rows: [line('DA-1', 'A', 20), line('DA-1', 'B', 5), line('DA-2', 'C', 'many'), line('DA-2', 'D', 1)] }, { state: {} }, async () => new Map(), run)
|
|
290
|
+
expect(proposed.counts).toMatchObject({ rows: 4, groups: 2, groupsReady: 1 })
|
|
291
|
+
expect(proposed.groupBy).toBe('despatchAdvice')
|
|
292
|
+
const sent: any[] = []
|
|
293
|
+
const outcome = await applyEntryProposal('g1', {
|
|
294
|
+
messages: [{ id: 'm1', proposals: [{ ...proposed, key: 'g1' }] }],
|
|
295
|
+
answered: false,
|
|
296
|
+
passesDoor: async () => true,
|
|
297
|
+
execute: async (_document, variables) => (sent.push(variables), { data: { receiveInboundAdvice: { name: variables.advice.name } } }),
|
|
298
|
+
state: {}
|
|
299
|
+
})
|
|
300
|
+
expect(sent).toEqual([{ advice: { name: 'DA-1', lines: [{ epcClass: 'A', quantity: 20 }, { epcClass: 'B', quantity: 5 }] } }])
|
|
301
|
+
expect(outcome.rows).toEqual([
|
|
302
|
+
{ index: 0, ok: true, id: 'DA-1' },
|
|
303
|
+
{ index: 1, ok: true, id: 'DA-1' },
|
|
304
|
+
{ index: 2, ok: false, error: 'quantity: not-a-number' },
|
|
305
|
+
{ index: 3, ok: false, error: 'group-kept-out (row 3)' }
|
|
306
|
+
])
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('a group by a field that is not declared is refused at boot', () => {
|
|
310
|
+
expect(() => declareEntryTarget({ ...advice, group: { ...advice.group!, by: 'order' } })).toThrow(/group by 'order'/)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Data entry from material (design/ai/data-entry.md) - the rules a row passes, what the tool hands back,
|
|
3
3
|
* and what the server asks again before it writes.
|
|
4
4
|
*/
|
|
5
|
-
import { clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
|
|
5
|
+
import { clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
|
|
6
6
|
import { entryCategoryOf, proposeEntries } from './entry-tools'
|
|
7
7
|
import { applyEntryProposal, entryProposalAnswered, findEntryProposal, type EntryApplyDeps } from './entry-apply'
|
|
8
8
|
import { modeToolCategories } from '../assistant-mode/resolve'
|
|
@@ -237,6 +237,7 @@ describe('checking the rows against the material', () => {
|
|
|
237
237
|
texts
|
|
238
238
|
)
|
|
239
239
|
expect(row.issues).toEqual([{ code: 'source-scattered', blocking: false }])
|
|
240
|
+
expect(row.sources).toEqual([{ attachmentId: 'a1', where: 'row 2' }, { person: true }])
|
|
240
241
|
})
|
|
241
242
|
|
|
242
243
|
it('a photo cannot be read here - the row says it was not checked', () => {
|
|
@@ -251,10 +252,12 @@ describe('checking the rows against the material', () => {
|
|
|
251
252
|
{ values: { amount: 500 }, sources: [{ where: 'x' }] }
|
|
252
253
|
])
|
|
253
254
|
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
|
+
{ field: 'amount', all: 21900, ready: 21400, declared: 21400, declaredFound: true, difference: -500, declaredBy: 'model' }
|
|
255
256
|
])
|
|
256
257
|
expect(entryTotals(rows, expense.fields, { value: 30000, attachmentId: 'a1' }, texts)[0]).toMatchObject({ declared: 30000, declaredFound: false, difference: 8100 })
|
|
257
|
-
|
|
258
|
+
/* No total from the model: the server reads the file's own total line. */
|
|
259
|
+
expect(entryTotals(rows, expense.fields, undefined, texts)).toEqual([{ field: 'amount', all: 21900, ready: 21400, declared: 21400, declaredFound: true, declaredBy: 'file', difference: -500 }])
|
|
260
|
+
expect(entryTotals(rows, expense.fields, undefined, new Map())).toEqual([{ field: 'amount', all: 21900, ready: 21400 }])
|
|
258
261
|
})
|
|
259
262
|
|
|
260
263
|
it('the tool reads the cited files and puts the check and the totals on the card', async () => {
|
|
@@ -274,7 +277,7 @@ describe('checking the rows against the material', () => {
|
|
|
274
277
|
expect(asked[0]).toEqual(['a1', 'a1', 'a1'])
|
|
275
278
|
expect(result.counts).toMatchObject({ verified: 1, unmatched: 1 })
|
|
276
279
|
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 }])
|
|
280
|
+
expect(result.totals).toEqual([{ field: 'amount', all: 21900, ready: 21900, declared: 21400, declaredFound: true, difference: -500, declaredBy: 'model' }])
|
|
278
281
|
expect(result.message).toMatch(/differs from the rows by -500/)
|
|
279
282
|
})
|
|
280
283
|
})
|
|
@@ -318,3 +321,37 @@ describe('the card speaks the person\'s words', () => {
|
|
|
318
321
|
})
|
|
319
322
|
})
|
|
320
323
|
|
|
324
|
+
describe('what the screen says for the model', () => {
|
|
325
|
+
it("★ the model's \"the person's message\" becomes the person flag - the screen words it, the model's English never shows", () => {
|
|
326
|
+
const { rows } = normalizeEntryRows(expense, [{ values: { spentOn: '2026-09-12', amount: 1 }, sources: [{ where: "the person's message" }, { person: true }, { attachmentId: 'a1', where: '3번째 줄' }] }])
|
|
327
|
+
expect(rows[0].sources).toEqual([{ person: true }, { person: true }, { attachmentId: 'a1', where: '3번째 줄' }])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('reads a printed total line - the last amount on a line named 합계 or total', () => {
|
|
331
|
+
expect(printedTotalOf('a,1\n합계,,"50,900"')).toEqual({ value: 50900, line: 2 })
|
|
332
|
+
expect(printedTotalOf('Total amount: 1,234.50 KRW')).toEqual({ value: 1234.5, line: 1 })
|
|
333
|
+
expect(printedTotalOf('nothing like it here, 100')).toBeUndefined()
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe('the model names the total its own way', () => {
|
|
338
|
+
it("★ with one totalled field, the model's total counts whatever field name it wrote - 이용금액 is amount", () => {
|
|
339
|
+
const { rows } = normalizeEntryRows(expense, [{ values: { spentOn: '2026-09-12', amount: 100 }, sources: [{ where: 'x' }] }])
|
|
340
|
+
expect(entryTotals(rows, expense.fields, { field: '이용금액', value: '100' }, new Map())).toEqual([{ field: 'amount', all: 100, ready: 100, declared: 100, difference: 0, declaredBy: 'model' }])
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
describe('a moment', () => {
|
|
345
|
+
const shift: EntryTarget = { ...expense, fields: [{ name: 'startTime', type: 'datetime', required: true }] }
|
|
346
|
+
it('reads the ways a moment is written - without an offset it stays without one', () => {
|
|
347
|
+
const { rows } = normalizeEntryRows(shift, [
|
|
348
|
+
{ values: { startTime: '2026-10-01 08:30' }, sources: [{ where: 'x' }] },
|
|
349
|
+
{ values: { startTime: '2026.10.1 8:05:09' }, sources: [{ where: 'x' }] },
|
|
350
|
+
{ values: { startTime: '2026-10-01T08:30:00+0900' }, sources: [{ where: 'x' }] },
|
|
351
|
+
{ values: { startTime: '2026-10-01 25:00' }, sources: [{ where: 'x' }] }
|
|
352
|
+
])
|
|
353
|
+
expect(rows.map(row => row.values.startTime)).toEqual(['2026-10-01T08:30:00', '2026-10-01T08:05:09', '2026-10-01T08:30:00+09:00', '2026-10-01 25:00'])
|
|
354
|
+
expect(rows[3].issues).toEqual([{ field: 'startTime', code: 'not-a-datetime', blocking: true }])
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { PROPOSAL_OUTCOME_PREFIX, PROPOSAL_VERDICTS } from '../chat-message/proposal-outcome.js'
|
|
14
14
|
import { readRecords, stillAsProposed, type ChangeRow } from './entry-update.js'
|
|
15
|
-
import { changeToolName, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
|
|
15
|
+
import { changeToolName, entryGroups, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
|
|
16
16
|
|
|
17
17
|
export interface StoredProposalMessage {
|
|
18
18
|
id: string
|
|
@@ -85,6 +85,8 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
|
|
|
85
85
|
|
|
86
86
|
/* Read again from the stored values - the same rules, and today's records for the target's checks. */
|
|
87
87
|
let { rows } = normalizeEntryRows(target, (proposal.rows ?? []).map((row: EntryRow) => ({ values: row.values, sources: row.sources })))
|
|
88
|
+
/* A reference that was not settled on the card is asked again - never entered as a name. */
|
|
89
|
+
rows = await resolveReferences(target.fields, rows, deps.execute)
|
|
88
90
|
if (target.check) {
|
|
89
91
|
try {
|
|
90
92
|
rows = withCheckedIssues(rows, await target.check(rows, { state: deps.state }))
|
|
@@ -95,14 +97,35 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
const results: EntryRowResult[] = []
|
|
98
|
-
|
|
100
|
+
if (target.group) {
|
|
101
|
+
/* One call per group; a group with a row kept out stays out whole - an order missing a line is not entered. */
|
|
102
|
+
for (const [, members] of entryGroups(rows, target.group.by)) {
|
|
103
|
+
const blocked = members.find(isBlocked)
|
|
104
|
+
if (blocked) {
|
|
105
|
+
const issue = blocked.issues.find(one => one.blocking)!
|
|
106
|
+
for (const row of members)
|
|
107
|
+
results.push({ index: row.index, ok: false, error: row === blocked ? (issue.field ? `${issue.field}: ${issue.code}` : issue.code) : `group-kept-out (row ${blocked.index + 1})` })
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const answer = await deps.execute(target.mutation, target.group.variables(members))
|
|
112
|
+
const error = answer.errors?.length ? answer.errors.map(one => one.message).join('; ') || 'refused' : undefined
|
|
113
|
+
const applied = error ? undefined : target.group.appliedOf?.(answer.data, members)
|
|
114
|
+
for (const row of members) results.push(error ? { index: row.index, ok: false, error } : { index: row.index, ok: true, ...(applied?.id ? { id: applied.id } : {}), ...(applied?.label ? { label: applied.label } : {}) })
|
|
115
|
+
} catch (error: any) {
|
|
116
|
+
for (const row of members) results.push({ index: row.index, ok: false, error: error?.message || 'refused' })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
results.sort((a, b) => a.index - b.index)
|
|
120
|
+
}
|
|
121
|
+
for (const row of target.group ? [] : rows) {
|
|
99
122
|
if (isBlocked(row)) {
|
|
100
123
|
const issue = row.issues.find(one => one.blocking)!
|
|
101
124
|
results.push({ index: row.index, ok: false, error: issue.field ? `${issue.field}: ${issue.code}` : issue.code })
|
|
102
125
|
continue
|
|
103
126
|
}
|
|
104
127
|
try {
|
|
105
|
-
const answer = await deps.execute(target.mutation, target.variables(row))
|
|
128
|
+
const answer = await deps.execute(target.mutation, target.variables!(row))
|
|
106
129
|
if (answer.errors?.length) {
|
|
107
130
|
results.push({ index: row.index, ok: false, error: answer.errors.map(one => one.message).join('; ') || 'refused' })
|
|
108
131
|
continue
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { parse } from 'graphql'
|
|
16
16
|
|
|
17
|
-
export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'boolean' | 'enum'
|
|
17
|
+
export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'datetime' | 'boolean' | 'enum' | 'reference'
|
|
18
18
|
|
|
19
19
|
export interface EntryField {
|
|
20
20
|
name: string
|
|
@@ -30,13 +30,35 @@ export interface EntryField {
|
|
|
30
30
|
total?: boolean
|
|
31
31
|
/** An enum's values on the card: `<prefix><value in lowercase>` - `text.expense-payment-` shows PERSONAL_CARD as 개인카드. */
|
|
32
32
|
valueKeyPrefix?: string
|
|
33
|
+
/**
|
|
34
|
+
* A `reference` field points at another record - a loan, an item, a location. The model knows it by a name or
|
|
35
|
+
* a code; the server finds it through the application's query, as the person, and keeps its id. One match is
|
|
36
|
+
* taken; none or several keep the row out and say so - the server never picks among several.
|
|
37
|
+
*/
|
|
38
|
+
reference?: EntryReference
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EntryReference {
|
|
42
|
+
query: string
|
|
43
|
+
/** The query's variables for what the model wrote - "기업은행", "A-100". */
|
|
44
|
+
variables(text: string): Record<string, unknown>
|
|
45
|
+
/** The records the answer holds that the text names - the application filters, the server does not guess. */
|
|
46
|
+
recordsOf(data: any, text: string): { id: string; label?: string }[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A resolved reference, as a row keeps it. */
|
|
50
|
+
export interface ReferenceValue {
|
|
51
|
+
id: string
|
|
52
|
+
label?: string
|
|
33
53
|
}
|
|
34
54
|
|
|
35
55
|
/** Where a row came from - which file, and where in it. Shown on the card beside the row. */
|
|
36
56
|
export interface EntrySource {
|
|
37
57
|
attachmentId?: string
|
|
38
|
-
/** "row 3", "the receipt photo"
|
|
58
|
+
/** "row 3", "the receipt photo" - the model's words, in the person's language. */
|
|
39
59
|
where?: string
|
|
60
|
+
/** The value came from what the person said in the conversation - shown in the screen's own words, never the model's. */
|
|
61
|
+
person?: boolean
|
|
40
62
|
}
|
|
41
63
|
|
|
42
64
|
export interface EntryIssue {
|
|
@@ -105,10 +127,20 @@ export interface EntryTarget {
|
|
|
105
127
|
* to a person typing it in.
|
|
106
128
|
*/
|
|
107
129
|
mutation: string
|
|
108
|
-
/** The mutation's variables for one row. */
|
|
109
|
-
variables(row: EntryRow): Record<string, unknown>
|
|
130
|
+
/** The mutation's variables for one row. Absent when the target enters rows by group (below). */
|
|
131
|
+
variables?(row: EntryRow): Record<string, unknown>
|
|
110
132
|
/** What the conversation says of an entered row - its id and a short name. */
|
|
111
133
|
appliedOf?(data: any, row: EntryRow): EntryApplied | undefined
|
|
134
|
+
/**
|
|
135
|
+
* A record with lines - one order, several item lines. Rows that share the value of `by` (the order number) are one
|
|
136
|
+
* record, entered by one call of the mutation with `variables(rows)`. If any row of a group is kept out, the whole
|
|
137
|
+
* group is: an order missing a line must not go in unseen.
|
|
138
|
+
*/
|
|
139
|
+
group?: {
|
|
140
|
+
by: string
|
|
141
|
+
variables(rows: EntryRow[]): Record<string, unknown>
|
|
142
|
+
appliedOf?(data: any, rows: EntryRow[]): EntryApplied | undefined
|
|
143
|
+
}
|
|
112
144
|
/** At most this many rows in one proposal. Default 200. */
|
|
113
145
|
maxRows?: number
|
|
114
146
|
/**
|
|
@@ -135,9 +167,9 @@ export type EntryRecord = { id: string } & Record<string, unknown>
|
|
|
135
167
|
*/
|
|
136
168
|
export interface EntryUpdate {
|
|
137
169
|
/** Reads the records by id, as the person - one they may not see is not found. */
|
|
138
|
-
read: { query: string; variables(ids: string[]): Record<string, unknown>; recordsOf(data: any): EntryRecord[] }
|
|
170
|
+
read: { query: string; variables(ids: string[]): Record<string, unknown>; recordsOf(data: any, ids: string[]): EntryRecord[] }
|
|
139
171
|
/** Finds candidates from what the person said - "last week's 12,400 taxi". */
|
|
140
|
-
find?: { query: string; variables(criteria: Record<string, unknown>): Record<string, unknown>; recordsOf(data: any): EntryRecord[]; criteria: EntryField[] }
|
|
172
|
+
find?: { query: string; variables(criteria: Record<string, unknown>): Record<string, unknown>; recordsOf(data: any, criteria: Record<string, unknown>): EntryRecord[]; criteria: EntryField[] }
|
|
141
173
|
/** The application's own mutation that changes one record. */
|
|
142
174
|
mutation: string
|
|
143
175
|
variables(change: { id: string; record: EntryRecord; values: Record<string, unknown> }): Record<string, unknown>
|
|
@@ -177,6 +209,9 @@ export async function recordEntryEvent(target: EntryTarget, event: EntryEvent, c
|
|
|
177
209
|
|
|
178
210
|
export const DEFAULT_MAX_ROWS = 200
|
|
179
211
|
|
|
212
|
+
/* The model's way of saying a value came from the person - kept only to read older rows and loose words. */
|
|
213
|
+
const PERSON = /person|message|user|사용자|말/i
|
|
214
|
+
|
|
180
215
|
const targets = new Map<string, EntryTarget>()
|
|
181
216
|
|
|
182
217
|
const KEY = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/
|
|
@@ -189,7 +224,10 @@ export function declareEntryTarget(target: EntryTarget): void {
|
|
|
189
224
|
} catch (error: any) {
|
|
190
225
|
throw new Error(`[ai-assistant] entry target '${target.key}': its mutation ${error?.message ?? 'does not parse'}.`)
|
|
191
226
|
}
|
|
192
|
-
if (typeof target.variables !== 'function'
|
|
227
|
+
if (typeof target.variables !== 'function' && typeof target.group?.variables !== 'function')
|
|
228
|
+
throw new Error(`[ai-assistant] entry target '${target.key}' has no variables (per row, or per group).`)
|
|
229
|
+
if (target.group && !target.fields.some(field => field.name === target.group!.by))
|
|
230
|
+
throw new Error(`[ai-assistant] entry target '${target.key}': group by '${target.group.by}' is not a declared field.`)
|
|
193
231
|
if (target.update) {
|
|
194
232
|
for (const [what, document, operation] of [
|
|
195
233
|
['update.read', target.update.read?.query, 'query'],
|
|
@@ -208,6 +246,13 @@ export function declareEntryTarget(target: EntryTarget): void {
|
|
|
208
246
|
if (!target.fields?.length) throw new Error(`[ai-assistant] entry target '${target.key}' declares no fields.`)
|
|
209
247
|
const names = new Set<string>()
|
|
210
248
|
for (const field of target.fields) {
|
|
249
|
+
if (field.type === 'reference') {
|
|
250
|
+
try {
|
|
251
|
+
rootFieldOf(field.reference?.query ?? '', 'query')
|
|
252
|
+
} catch (error: any) {
|
|
253
|
+
throw new Error(`[ai-assistant] entry target '${target.key}': reference field '${field.name}' ${error?.message ?? 'has no query'}.`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
211
256
|
if (names.has(field.name)) throw new Error(`[ai-assistant] entry target '${target.key}' declares '${field.name}' twice.`)
|
|
212
257
|
names.add(field.name)
|
|
213
258
|
if (field.type === 'enum' && !field.values?.length)
|
|
@@ -276,6 +321,22 @@ function toDate(value: unknown): string | undefined {
|
|
|
276
321
|
return `${match[1]}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
277
322
|
}
|
|
278
323
|
|
|
324
|
+
/**
|
|
325
|
+
* A moment as `YYYY-MM-DDTHH:mm:ss`, with the offset when it was written - "2026-10-01 08:30", "2026.10.1 8:30",
|
|
326
|
+
* an ISO string. Without an offset it stays without one: the application reads it in its own time zone, as it
|
|
327
|
+
* reads a time typed into its own screen.
|
|
328
|
+
*/
|
|
329
|
+
function toDateTime(value: unknown): string | undefined {
|
|
330
|
+
if (typeof value !== 'string') return undefined
|
|
331
|
+
const match = value.trim().match(/^(\d{4})[-./](\d{1,2})[-./](\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/)
|
|
332
|
+
if (!match) return undefined
|
|
333
|
+
const day = toDate(`${match[1]}-${match[2]}-${match[3]}`)
|
|
334
|
+
const [hour, minute, second] = [Number(match[4]), Number(match[5]), Number(match[6] ?? 0)]
|
|
335
|
+
if (!day || hour > 23 || minute > 59 || second > 59) return undefined
|
|
336
|
+
const offset = match[7] ? (match[7] === 'Z' ? 'Z' : match[7].replace(/^([+-]\d{2})(\d{2})$/, '$1:$2')) : ''
|
|
337
|
+
return `${day}T${String(hour).padStart(2, '0')}:${match[5]}:${String(second).padStart(2, '0')}${offset}`
|
|
338
|
+
}
|
|
339
|
+
|
|
279
340
|
function toBoolean(value: unknown): boolean | undefined {
|
|
280
341
|
if (typeof value === 'boolean') return value
|
|
281
342
|
if (typeof value !== 'string') return undefined
|
|
@@ -300,12 +361,21 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
|
|
|
300
361
|
const read = toDate(value)
|
|
301
362
|
return read === undefined ? { code: 'not-a-date' } : { read }
|
|
302
363
|
}
|
|
364
|
+
case 'datetime': {
|
|
365
|
+
const read = toDateTime(value)
|
|
366
|
+
return read === undefined ? { code: 'not-a-datetime' } : { read }
|
|
367
|
+
}
|
|
303
368
|
case 'boolean': {
|
|
304
369
|
const read = toBoolean(value)
|
|
305
370
|
return read === undefined ? { code: 'not-a-boolean' } : { read }
|
|
306
371
|
}
|
|
307
372
|
case 'enum':
|
|
308
373
|
return field.values!.includes(String(value).trim()) ? { read: String(value).trim() } : { code: 'not-in-list' }
|
|
374
|
+
case 'reference':
|
|
375
|
+
/* Resolved already ({ id }), or a name to look up ({ text }) - resolveReferences settles the second. */
|
|
376
|
+
if (value && typeof value === 'object' && typeof (value as any).id === 'string') return { read: { id: (value as any).id, ...((value as any).label ? { label: String((value as any).label) } : {}) } }
|
|
377
|
+
if (value && typeof value === 'object' && typeof (value as any).text === 'string') return { read: { text: (value as any).text } }
|
|
378
|
+
return typeof value === 'string' || typeof value === 'number' ? { read: { text: String(value).trim() } } : { code: 'not-a-reference' }
|
|
309
379
|
default:
|
|
310
380
|
return { read: String(value).trim() }
|
|
311
381
|
}
|
|
@@ -315,6 +385,10 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
|
|
|
315
385
|
export function sameFieldValue(field: EntryField, a: unknown, b: unknown): boolean {
|
|
316
386
|
if (empty(a) && empty(b)) return true
|
|
317
387
|
if (empty(a) || empty(b)) return false
|
|
388
|
+
if (field.type === 'reference') {
|
|
389
|
+
const idOf = (v: any) => (v && typeof v === 'object' ? v.id ?? v.text : String(v))
|
|
390
|
+
return idOf(a) === idOf(b)
|
|
391
|
+
}
|
|
318
392
|
const x = readFieldValue(field, a)
|
|
319
393
|
const y = readFieldValue(field, b)
|
|
320
394
|
return x.read !== undefined && y.read !== undefined ? x.read === y.read : String(a) === String(b)
|
|
@@ -357,11 +431,15 @@ export function normalizeEntryRows(target: EntryTarget, input: unknown): { rows:
|
|
|
357
431
|
|
|
358
432
|
const sources: EntrySource[] = Array.isArray(raw?.sources)
|
|
359
433
|
? raw.sources
|
|
360
|
-
.filter((one: any) => one && (one.attachmentId || one.where))
|
|
361
|
-
.map((one: any) =>
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
434
|
+
.filter((one: any) => one && (one.attachmentId || one.where || one.person === true))
|
|
435
|
+
.map((one: any): EntrySource => {
|
|
436
|
+
/* "the person's message" written by the model is the person flag - the screen says it in its own words. */
|
|
437
|
+
if (one.person === true || (!one.attachmentId && typeof one.where === 'string' && PERSON.test(one.where))) return { person: true }
|
|
438
|
+
return {
|
|
439
|
+
...(typeof one.attachmentId === 'string' ? { attachmentId: one.attachmentId } : {}),
|
|
440
|
+
...(typeof one.where === 'string' ? { where: one.where.slice(0, 120) } : {})
|
|
441
|
+
}
|
|
442
|
+
})
|
|
365
443
|
: []
|
|
366
444
|
if (!sources.length) issues.push({ code: 'no-source', blocking: false })
|
|
367
445
|
|
|
@@ -390,7 +468,6 @@ export function isBlocked(row: EntryRow): boolean {
|
|
|
390
468
|
* Names and descriptions are not looked for: the model tidies them, and a tidied name is not a wrong one.
|
|
391
469
|
*/
|
|
392
470
|
|
|
393
|
-
const PERSON = /person|message|user|사용자|말/i
|
|
394
471
|
|
|
395
472
|
/** The ways a number or a date is written in a file. */
|
|
396
473
|
export function writtenForms(type: EntryFieldType, value: unknown): string[] {
|
|
@@ -408,6 +485,12 @@ export function writtenForms(type: EntryFieldType, value: unknown): string[] {
|
|
|
408
485
|
}
|
|
409
486
|
return [...forms].map(form => (negative ? `-${form}` : form))
|
|
410
487
|
}
|
|
488
|
+
if (type === 'datetime') {
|
|
489
|
+
/* A moment is found by its day and its hh:mm - the file writes seconds and zones its own way. */
|
|
490
|
+
const match = typeof value === 'string' ? value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/) : null
|
|
491
|
+
if (!match) return []
|
|
492
|
+
return writtenForms('date', match[1])
|
|
493
|
+
}
|
|
411
494
|
if (type === 'date') {
|
|
412
495
|
const match = typeof value === 'string' ? value.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
|
|
413
496
|
if (!match) return []
|
|
@@ -462,7 +545,7 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
|
|
|
462
545
|
if (at >= 0) return { ...row, evidence: { attachmentId: id, line: at + 1 } }
|
|
463
546
|
}
|
|
464
547
|
|
|
465
|
-
const saidByPerson = row.sources.some(source =>
|
|
548
|
+
const saidByPerson = row.sources.some(source => source.person)
|
|
466
549
|
const issues = [...row.issues]
|
|
467
550
|
for (const one of forms) {
|
|
468
551
|
const anywhere = readable.some(id => texts.get(id)!.split(/\r?\n/).some(line => lineHas(line, one.forms)))
|
|
@@ -473,6 +556,20 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
|
|
|
473
556
|
})
|
|
474
557
|
}
|
|
475
558
|
|
|
559
|
+
/**
|
|
560
|
+
* The total a file prints - a line named 합계 · 총계 · 총액 · total · sum, and the last amount on it. The server
|
|
561
|
+
* reads it itself, so the card can compare even when the model did not say it (2026-10-01: once it did, once not).
|
|
562
|
+
*/
|
|
563
|
+
export function printedTotalOf(text: string): { value: number; line: number } | undefined {
|
|
564
|
+
const lines = text.split(/\r?\n/)
|
|
565
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
566
|
+
if (!/(합\s*계|총\s*계|총\s*액|total|sum)/i.test(lines[i])) continue
|
|
567
|
+
const amounts = [...lines[i].matchAll(/-?\d{1,3}(?:,\d{3})+(?:\.\d+)?|-?\d+(?:\.\d+)?/g)].map(match => Number(match[0].replace(/,/g, '')))
|
|
568
|
+
if (amounts.length) return { value: amounts[amounts.length - 1], line: i + 1 }
|
|
569
|
+
}
|
|
570
|
+
return undefined
|
|
571
|
+
}
|
|
572
|
+
|
|
476
573
|
export interface EntryTotal {
|
|
477
574
|
field: string
|
|
478
575
|
/** The sum over every row, and over the rows that will be entered. */
|
|
@@ -483,6 +580,8 @@ export interface EntryTotal {
|
|
|
483
580
|
declaredFound?: boolean
|
|
484
581
|
/** declared - all. */
|
|
485
582
|
difference?: number
|
|
583
|
+
/** Where the declared total came from - the model's reading, or the server's own from a file's total line. */
|
|
584
|
+
declaredBy?: 'model' | 'file'
|
|
486
585
|
}
|
|
487
586
|
|
|
488
587
|
/** The sums of the fields declared `total`, and how they stand against the total the material prints. */
|
|
@@ -492,13 +591,28 @@ export function entryTotals(
|
|
|
492
591
|
declared: { field?: string; value?: unknown; attachmentId?: string } | undefined,
|
|
493
592
|
texts: Map<string, string>
|
|
494
593
|
): EntryTotal[] {
|
|
495
|
-
|
|
496
|
-
|
|
594
|
+
const totalled = fields.filter(field => field.total && (field.type === 'number' || field.type === 'integer'))
|
|
595
|
+
/* With one totalled field and no total from the model, the server looks for the file's own total line. */
|
|
596
|
+
const printed = !declared && totalled.length === 1 ? [...texts.values()].map(printedTotalOf).find(Boolean) : undefined
|
|
597
|
+
return totalled
|
|
497
598
|
.map(field => {
|
|
498
599
|
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
|
|
499
600
|
const total: EntryTotal = { field: field.name, all: sum(rows), ready: sum(rows.filter(row => !isBlocked(row))) }
|
|
500
|
-
|
|
601
|
+
if (printed) {
|
|
602
|
+
total.declared = printed.value
|
|
603
|
+
total.declaredFound = true
|
|
604
|
+
total.declaredBy = 'file'
|
|
605
|
+
total.difference = Math.round((printed.value - total.all) * 100) / 100
|
|
606
|
+
return total
|
|
607
|
+
}
|
|
608
|
+
/*
|
|
609
|
+
* With one totalled field the model's total is that field's, whatever it named it - on office's second card it
|
|
610
|
+
* wrote the file's column name (이용금액) for amount, and the total was dropped (2026-10-01).
|
|
611
|
+
*/
|
|
612
|
+
const mine = declared && (totalled.length === 1 || (declared.field ?? field.name) === field.name)
|
|
613
|
+
const value = mine ? toNumber(declared!.value) : undefined
|
|
501
614
|
if (value !== undefined) {
|
|
615
|
+
total.declaredBy = 'model'
|
|
502
616
|
total.declared = value
|
|
503
617
|
total.difference = Math.round((value - total.all) * 100) / 100
|
|
504
618
|
const text = declared!.attachmentId ? texts.get(declared!.attachmentId) : undefined
|
|
@@ -508,3 +622,62 @@ export function entryTotals(
|
|
|
508
622
|
})
|
|
509
623
|
}
|
|
510
624
|
|
|
625
|
+
/** The doors of the queries a target's reference fields reach - the person must pass them too. */
|
|
626
|
+
export function referenceDoorsOf(fields: EntryField[]): string[] {
|
|
627
|
+
return [...new Set(fields.filter(field => field.type === 'reference' && field.reference).map(field => rootFieldOf(field.reference!.query, 'query')))]
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Settles each reference a row names by text, through the application's query as the person. One match keeps its
|
|
632
|
+
* id and name; none or several keep the row out - several with their names, so the person can say which.
|
|
633
|
+
*/
|
|
634
|
+
export async function resolveReferences<T extends { values: Record<string, unknown>; issues: EntryIssue[] }>(
|
|
635
|
+
fields: EntryField[],
|
|
636
|
+
rows: T[],
|
|
637
|
+
run: (document: string, variables: Record<string, unknown>) => Promise<{ data?: any; errors?: readonly { message: string }[] }>
|
|
638
|
+
): Promise<T[]> {
|
|
639
|
+
const references = fields.filter(field => field.type === 'reference' && field.reference)
|
|
640
|
+
if (!references.length) return rows
|
|
641
|
+
const asked = new Map<string, { id: string; label?: string }[] | Error>()
|
|
642
|
+
for (const row of rows) {
|
|
643
|
+
for (const field of references) {
|
|
644
|
+
const value: any = row.values[field.name]
|
|
645
|
+
if (!value || typeof value !== 'object' || typeof value.text !== 'string') continue
|
|
646
|
+
const key = `${field.name}\u0000${value.text}`
|
|
647
|
+
if (!asked.has(key)) {
|
|
648
|
+
try {
|
|
649
|
+
const answer = await run(field.reference!.query, field.reference!.variables(value.text))
|
|
650
|
+
asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, value.text) ?? [])
|
|
651
|
+
} catch (error: any) {
|
|
652
|
+
asked.set(key, error instanceof Error ? error : new Error(String(error)))
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const found = asked.get(key)!
|
|
656
|
+
if (found instanceof Error) {
|
|
657
|
+
row.issues.push({ field: field.name, code: 'reference-unreadable', params: { text: value.text }, blocking: true })
|
|
658
|
+
} else if (found.length === 1) {
|
|
659
|
+
row.values[field.name] = { id: found[0].id, ...(found[0].label ? { label: found[0].label } : {}) }
|
|
660
|
+
} else {
|
|
661
|
+
row.issues.push({
|
|
662
|
+
field: field.name,
|
|
663
|
+
code: found.length ? 'reference-ambiguous' : 'reference-not-found',
|
|
664
|
+
params: { text: value.text, ...(found.length ? { candidates: found.slice(0, 5).map(one => one.label || one.id).join(', ') } : {}) },
|
|
665
|
+
blocking: true
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return rows
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The rows by group, in the order groups first appear - `[key, rows]`. A row without the key is a group of its own. */
|
|
674
|
+
export function entryGroups<T extends { index: number; values: Record<string, unknown> }>(rows: T[], by: string): [string, T[]][] {
|
|
675
|
+
const groups = new Map<string, T[]>()
|
|
676
|
+
for (const row of rows) {
|
|
677
|
+
const value = row.values[by]
|
|
678
|
+
const key = value === undefined || value === null || value === '' ? `\u0000row-${row.index}` : String(typeof value === 'object' ? (value as any).id ?? (value as any).text : value)
|
|
679
|
+
groups.set(key, [...(groups.get(key) ?? []), row])
|
|
680
|
+
}
|
|
681
|
+
return [...groups.entries()]
|
|
682
|
+
}
|
|
683
|
+
|