@things-factory/ai-assistant 10.1.221 → 10.1.222
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/dist-server/service/data-read/read-target.d.ts +25 -3
- package/dist-server/service/data-read/read-target.js +50 -5
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.d.ts +18 -0
- package/dist-server/service/data-read/read-tools.js +11 -4
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/data-read/read-target.ts +64 -5
- package/server/service/data-read/read-tools.ts +13 -4
- package/server/service/data-read/read-words.test.ts +92 -0
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.222",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"copyfiles": "^2.4.1",
|
|
56
56
|
"rimraf": "^5.0.0"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "afdd6a9b1c8b440e39d4fec48bfbad1a0c02fccc"
|
|
59
59
|
}
|
|
@@ -46,6 +46,12 @@ export interface ReadColumn {
|
|
|
46
46
|
* currencies are never added into one number.
|
|
47
47
|
*/
|
|
48
48
|
currency?: string
|
|
49
|
+
/**
|
|
50
|
+
* The i18n prefix that names this column's codes - `<words><code>` (`announcement.category.` reads `HR` as
|
|
51
|
+
* `announcement.category.HR`). The rows keep the code, so a filter or a card's match means the same in every
|
|
52
|
+
* language; the model is given the words (`legendOf`) and a table or chart shows them (`wordOf`).
|
|
53
|
+
*/
|
|
54
|
+
words?: string
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export interface ReadScreen {
|
|
@@ -307,8 +313,55 @@ export function filtersOf(target: ReadTarget, criteria: Record<string, unknown>)
|
|
|
307
313
|
|
|
308
314
|
export const valueAt = (row: any, path: string) => path.split('.').reduce((at, part) => (at === null || at === undefined ? undefined : at[part]), row)
|
|
309
315
|
|
|
310
|
-
/**
|
|
311
|
-
|
|
316
|
+
/**
|
|
317
|
+
* A date column's moment as people read it - `YYYY-MM-DD HH:mm` in the domain's time zone. A day (`YYYY-MM-DD`) or
|
|
318
|
+
* month stays as it is; anything that is not a moment is left alone. Still ordered as text, so a card's comparison
|
|
319
|
+
* with a day keeps working.
|
|
320
|
+
*/
|
|
321
|
+
export function localDateTime(value: string, timeZone?: string): string {
|
|
322
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) return value
|
|
323
|
+
const moment = new Date(value)
|
|
324
|
+
if (Number.isNaN(moment.getTime())) return value
|
|
325
|
+
try {
|
|
326
|
+
const parts = Object.fromEntries(
|
|
327
|
+
new Intl.DateTimeFormat('en-CA', { timeZone: timeZone || undefined, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
328
|
+
.formatToParts(moment)
|
|
329
|
+
.map(part => [part.type, part.value])
|
|
330
|
+
)
|
|
331
|
+
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
|
|
332
|
+
} catch {
|
|
333
|
+
return value
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** How the reader's words are asked - `(key, fallback)`, the fallback when the key has no word. */
|
|
338
|
+
export type SayWord = (key: string | undefined, fallback: string) => string
|
|
339
|
+
|
|
340
|
+
/** The word a coded value is read as, in the reader's language - the value itself when its column has no words. */
|
|
341
|
+
export function wordOf(column: Pick<ReadColumn, 'words'> | undefined, value: unknown, say: SayWord): unknown {
|
|
342
|
+
if (!column?.words || value === null || value === undefined || value === '') return value
|
|
343
|
+
return say(`${column.words}${value}`, String(value))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The words of the coded values the rows hold, column by column - what the model reads the codes by. */
|
|
347
|
+
export function legendOf(columns: readonly Pick<ReadColumn, 'name' | 'words'>[], rows: Record<string, any>[], say: SayWord): Record<string, Record<string, string>> {
|
|
348
|
+
const legend: Record<string, Record<string, string>> = {}
|
|
349
|
+
for (const column of columns) {
|
|
350
|
+
if (!column.words) continue
|
|
351
|
+
for (const row of rows) {
|
|
352
|
+
const code = row[column.name]
|
|
353
|
+
if (code === null || code === undefined || code === '') continue
|
|
354
|
+
;(legend[column.name] ??= {})[String(code)] = String(wordOf(column, code, say))
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return legend
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** What shapes a row besides its columns - the domain's time zone, for a moment. */
|
|
361
|
+
export type RowOptions = { timeZone?: string }
|
|
362
|
+
|
|
363
|
+
/** One row as the columns say - a number column as a number, a moment in the domain's time zone, a missing value as null. */
|
|
364
|
+
export function rowOf(target: Pick<ReadTarget, 'columns'>, item: any, options: RowOptions = {}): Record<string, string | number | null> {
|
|
312
365
|
return Object.fromEntries([
|
|
313
366
|
/* The id names the record to the model - for one record's detail, a change, or its screen. */
|
|
314
367
|
...(item?.id ? [['id', String(item.id)] as [string, string]] : []),
|
|
@@ -319,7 +372,7 @@ export function rowOf(target: Pick<ReadTarget, 'columns'>, item: any): Record<st
|
|
|
319
372
|
const number = Number(raw)
|
|
320
373
|
return [column.name, Number.isFinite(number) ? number : null]
|
|
321
374
|
}
|
|
322
|
-
return [column.name, String(raw)]
|
|
375
|
+
return [column.name, column.type === 'date' ? localDateTime(String(raw), options.timeZone) : String(raw)]
|
|
323
376
|
})
|
|
324
377
|
])
|
|
325
378
|
}
|
|
@@ -328,7 +381,13 @@ export function rowOf(target: Pick<ReadTarget, 'columns'>, item: any): Record<st
|
|
|
328
381
|
* Reads the rows through the application's query, page by page, up to MAX_READ_ROWS. `complete` is false when the
|
|
329
382
|
* list holds more - a sum of the rows read is then not the sum of the list, and is never said as one.
|
|
330
383
|
*/
|
|
331
|
-
export async function readRows(
|
|
384
|
+
export async function readRows(
|
|
385
|
+
target: ReadTarget,
|
|
386
|
+
filters: ReadFilter[],
|
|
387
|
+
run: GraphqlRunner,
|
|
388
|
+
search?: string,
|
|
389
|
+
options: RowOptions = {}
|
|
390
|
+
): Promise<{ rows: Record<string, any>[]; total: number; complete: boolean }> {
|
|
332
391
|
const rows: Record<string, any>[] = []
|
|
333
392
|
let total = 0
|
|
334
393
|
for (let page = 1; rows.length < MAX_READ_ROWS; page++) {
|
|
@@ -342,7 +401,7 @@ export async function readRows(target: ReadTarget, filters: ReadFilter[], run: G
|
|
|
342
401
|
const read = target.rowsOf(answer.data)
|
|
343
402
|
total = Number(read?.total) || 0
|
|
344
403
|
const items = read?.rows ?? []
|
|
345
|
-
rows.push(...items.slice(0, MAX_READ_ROWS - rows.length).map(item => rowOf(target, item)))
|
|
404
|
+
rows.push(...items.slice(0, MAX_READ_ROWS - rows.length).map(item => rowOf(target, item, options)))
|
|
346
405
|
if (items.length < READ_PAGE || rows.length >= total) break
|
|
347
406
|
}
|
|
348
407
|
return { rows, total, complete: rows.length >= total }
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type DetailArg,
|
|
23
23
|
type DetailRead,
|
|
24
24
|
groupsOf,
|
|
25
|
+
legendOf,
|
|
25
26
|
queryToolName,
|
|
26
27
|
readCategoryName,
|
|
27
28
|
readCriteria,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
screenAddress,
|
|
31
32
|
screenToolName,
|
|
32
33
|
sumsOf,
|
|
34
|
+
wordOf,
|
|
33
35
|
type ReadCriterion,
|
|
34
36
|
type ReadTarget
|
|
35
37
|
} from './read-target.js'
|
|
@@ -62,7 +64,9 @@ export async function queryRecords(target: ReadTarget, args: any, context: any,
|
|
|
62
64
|
}
|
|
63
65
|
let read
|
|
64
66
|
try {
|
|
65
|
-
read = await readRows(target, filtersOf(target, criteria), run, typeof criteria.text === 'string' && target.search ? criteria.text : undefined
|
|
67
|
+
read = await readRows(target, filtersOf(target, criteria), run, typeof criteria.text === 'string' && target.search ? criteria.text : undefined, {
|
|
68
|
+
timeZone: context?.state?.domain?.timezone
|
|
69
|
+
})
|
|
66
70
|
} catch (error: any) {
|
|
67
71
|
return { rejected: true, error: 'query-failed', message: `the list could not be read: ${error?.message ?? error}` }
|
|
68
72
|
}
|
|
@@ -72,6 +76,8 @@ export async function queryRecords(target: ReadTarget, args: any, context: any,
|
|
|
72
76
|
const groups = groupBy ? groupsOf(target, rows, groupBy) : undefined
|
|
73
77
|
const words = wordsOf(context)
|
|
74
78
|
const label = words(target.labelKey, target.key)
|
|
79
|
+
/* The rows keep their codes - a condition names them; the person is told the words (ADR-0163, 2026-10-26). */
|
|
80
|
+
const legend = legendOf(target.columns, rows, words)
|
|
75
81
|
|
|
76
82
|
const said = {
|
|
77
83
|
target: target.key,
|
|
@@ -82,7 +88,8 @@ export async function queryRecords(target: ReadTarget, args: any, context: any,
|
|
|
82
88
|
sums,
|
|
83
89
|
...(groupBy ? (groups ? { groupBy, groups: groups.slice(0, 50), ...(groups.length > 50 ? { moreGroups: groups.length - 50 } : {}) } : { groupBy, groupRefused: 'no such column' }) : {}),
|
|
84
90
|
rows: rows.slice(0, ROWS_FOR_MODEL),
|
|
85
|
-
...(rows.length > ROWS_FOR_MODEL ? { rowsNotShown: rows.length - ROWS_FOR_MODEL } : {})
|
|
91
|
+
...(rows.length > ROWS_FOR_MODEL ? { rowsNotShown: rows.length - ROWS_FOR_MODEL } : {}),
|
|
92
|
+
...(Object.keys(legend).length ? { legend, legendNote: 'a coded value is said by its word in legend, never by its code' } : {})
|
|
86
93
|
}
|
|
87
94
|
const incomplete = complete
|
|
88
95
|
? ''
|
|
@@ -97,6 +104,8 @@ export async function queryRecords(target: ReadTarget, args: any, context: any,
|
|
|
97
104
|
|
|
98
105
|
/* The workspace is built here from the rows as read - the model never copies the table. */
|
|
99
106
|
const columns = target.columns.map(column => ({ name: column.name, label: words(column.labelKey, column.name), type: column.type }))
|
|
107
|
+
const shownRows = rows.map(row => Object.fromEntries(Object.entries(row).map(([name, value]) => [name, wordOf(target.columns.find(one => one.name === name), value, words)])))
|
|
108
|
+
const groupColumn = target.columns.find(one => one.name === groupBy)
|
|
100
109
|
const totalColumns = target.columns.filter(one => one.total)
|
|
101
110
|
/* A sum per currency is one figure and one group column per currency it holds - 「계약금액 (USD)」. */
|
|
102
111
|
const sumColumns = totalColumns.flatMap(column => {
|
|
@@ -122,11 +131,11 @@ export async function queryRecords(target: ReadTarget, args: any, context: any,
|
|
|
122
131
|
kind: 'table',
|
|
123
132
|
caption: words(target.columns.find(one => one.name === groupBy)?.labelKey, groupBy!),
|
|
124
133
|
columns: [{ name: 'key', label: words(target.columns.find(one => one.name === groupBy)?.labelKey, groupBy!), type: 'string' }, { name: 'count', label: words('ai-assistant.read.count', 'rows'), type: 'number' }, ...sumColumns.map(column => ({ name: column.name, label: column.label, type: 'number' }))],
|
|
125
|
-
rows: groups.map(group => ({ key: group.key, count: group.count, ...Object.fromEntries(sumColumns.map(column => [column.name, column.of(group.sums)])) }))
|
|
134
|
+
rows: groups.map(group => ({ key: wordOf(groupColumn, group.key, words), count: group.count, ...Object.fromEntries(sumColumns.map(column => [column.name, column.of(group.sums)])) }))
|
|
126
135
|
}
|
|
127
136
|
]
|
|
128
137
|
: []),
|
|
129
|
-
{ kind: 'table', caption: label, columns, rows }
|
|
138
|
+
{ kind: 'table', caption: label, columns, rows: shownRows }
|
|
130
139
|
]
|
|
131
140
|
})
|
|
132
141
|
return {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What people read off a list (ADR-0163, 2026-10-26: answers and cards showed EVENT, unread and 2026-10-26T09:09:03Z):
|
|
3
|
+
* a moment in the domain's time zone, and a coded value by its word - the rows keep the code, so a condition means the
|
|
4
|
+
* same in every language.
|
|
5
|
+
*/
|
|
6
|
+
import { listReadTarget } from './list-read'
|
|
7
|
+
import { clearReadTargets, legendOf, localDateTime, rowOf, wordOf } from './read-target'
|
|
8
|
+
import { queryRecords } from './read-tools'
|
|
9
|
+
|
|
10
|
+
const WORDS: Record<string, string> = { 'notice.kind.HR': '인사', 'notice.kind.EVENT': '행사', 'notice.state.unread': '안 읽음' }
|
|
11
|
+
const say = (key: string | undefined, fallback: string) => (key && WORDS[key]) || fallback
|
|
12
|
+
|
|
13
|
+
describe('a moment', () => {
|
|
14
|
+
it('★ is said in the domain\'s time zone, to the minute', () => {
|
|
15
|
+
expect(localDateTime('2026-10-26T09:09:03.235Z', 'Asia/Seoul')).toBe('2026-10-26 18:09')
|
|
16
|
+
/* Past midnight there, it is the next day. */
|
|
17
|
+
expect(localDateTime('2026-10-26T20:00:00Z', 'Asia/Seoul')).toBe('2026-10-27 05:00')
|
|
18
|
+
expect(localDateTime('2026-10-26T20:00:00Z', 'UTC')).toBe('2026-10-26 20:00')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('★ a day, a month or anything else stays as it is', () => {
|
|
22
|
+
expect(localDateTime('2026-10-26', 'Asia/Seoul')).toBe('2026-10-26')
|
|
23
|
+
expect(localDateTime('2026-10', 'Asia/Seoul')).toBe('2026-10')
|
|
24
|
+
expect(localDateTime('2026-10-26Tnot', 'Asia/Seoul')).toBe('2026-10-26Tnot')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('★ a row reads its date columns so, and leaves the other columns alone', () => {
|
|
28
|
+
const columns = [
|
|
29
|
+
{ name: 'at', type: 'date' as const },
|
|
30
|
+
{ name: 'note', type: 'string' as const }
|
|
31
|
+
]
|
|
32
|
+
expect(rowOf({ columns }, { id: 'a', at: '2026-10-26T09:09:03.235Z', note: '2026-10-26T09:09:03.235Z' }, { timeZone: 'Asia/Seoul' })).toEqual({
|
|
33
|
+
id: 'a',
|
|
34
|
+
at: '2026-10-26 18:09',
|
|
35
|
+
note: '2026-10-26T09:09:03.235Z'
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('a coded value', () => {
|
|
41
|
+
const kind = { name: 'kind', words: 'notice.kind.' }
|
|
42
|
+
it('★ is read by its word, the code itself when there is none', () => {
|
|
43
|
+
expect(wordOf(kind, 'HR', say)).toBe('인사')
|
|
44
|
+
expect(wordOf(kind, 'SYSTEM', say)).toBe('SYSTEM')
|
|
45
|
+
expect(wordOf({ name: 'title' }, 'HR', say)).toBe('HR')
|
|
46
|
+
expect(wordOf(kind, null, say)).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('★ the legend names the codes the rows hold, column by column', () => {
|
|
50
|
+
expect(legendOf([kind, { name: 'title' }], [{ kind: 'HR', title: 'a' }, { kind: 'EVENT', title: 'b' }, { kind: null }], say)).toEqual({ kind: { HR: '인사', EVENT: '행사' } })
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('the read tool', () => {
|
|
55
|
+
const target = listReadTarget({
|
|
56
|
+
key: 'test.notices',
|
|
57
|
+
title: 'title.notices',
|
|
58
|
+
name: 'notices',
|
|
59
|
+
listQuery: 'notices',
|
|
60
|
+
selection: 'id title kind state publishedAt',
|
|
61
|
+
columns: [
|
|
62
|
+
{ name: 'title', type: 'string' },
|
|
63
|
+
{ name: 'kind', type: 'string', words: 'notice.kind.' },
|
|
64
|
+
{ name: 'state', type: 'string', words: 'notice.state.' },
|
|
65
|
+
{ name: 'publishedAt', type: 'date' }
|
|
66
|
+
],
|
|
67
|
+
status: { field: 'state', values: ['unread', 'read'] }
|
|
68
|
+
})
|
|
69
|
+
const items = Array.from({ length: 3 }, (_, i) => ({ id: `n${i}`, title: `t${i}`, kind: i ? 'EVENT' : 'HR', state: 'unread', publishedAt: '2026-10-26T09:09:03.235Z' }))
|
|
70
|
+
const calls: any[] = []
|
|
71
|
+
const run = async (_document: string, variables: any) => (calls.push(variables), { data: { notices: { items, total: items.length } } })
|
|
72
|
+
/* Not this machine's zone - a read that forgot the domain's zone would read the machine's instead. */
|
|
73
|
+
const context = { t: (key: string) => WORDS[key] ?? key, state: { domain: { timezone: 'UTC' } } }
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
clearReadTargets()
|
|
77
|
+
calls.length = 0
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('★ gives the model the codes, their words and the moments in the domain\'s time - and its condition names the code', async () => {
|
|
81
|
+
const result: any = await queryRecords(target, { status: 'unread' }, context, run)
|
|
82
|
+
expect(calls[0].filters).toEqual([{ name: 'state', operator: 'eq', value: 'unread' }])
|
|
83
|
+
expect(result.rows[0]).toMatchObject({ kind: 'HR', state: 'unread', publishedAt: '2026-10-26 09:09' })
|
|
84
|
+
expect(result.legend).toEqual({ kind: { HR: '인사', EVENT: '행사' }, state: { unread: '안 읽음' } })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('★ the 작업판 table shows the words', async () => {
|
|
88
|
+
const result: any = await queryRecords(target, { show: 'workspace' }, context, run)
|
|
89
|
+
const table = result.forScreen.workspace.blocks.find((one: any) => one.kind === 'table')
|
|
90
|
+
expect(table.rows[0]).toMatchObject({ kind: '인사', state: '안 읽음', publishedAt: '2026-10-26 09:09' })
|
|
91
|
+
})
|
|
92
|
+
})
|