@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.221",
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": "90e211d7a2c99216e017bd19a62e1703b20f2710"
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
- /** One row as the columns say - a number column as a number, a missing value as null. */
311
- export function rowOf(target: Pick<ReadTarget, 'columns'>, item: any): Record<string, string | number | null> {
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(target: ReadTarget, filters: ReadFilter[], run: GraphqlRunner, search?: string): Promise<{ rows: Record<string, any>[]; total: number; complete: boolean }> {
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
+ })