@things-factory/ai-assistant 10.1.112 → 10.1.114

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.
Files changed (49) hide show
  1. package/client/components/assistant-chat.ts +19 -0
  2. package/client/utils/open-page.ts +21 -0
  3. package/dist-client/components/assistant-chat.d.ts +2 -0
  4. package/dist-client/components/assistant-chat.js +19 -0
  5. package/dist-client/components/assistant-chat.js.map +1 -1
  6. package/dist-client/tsconfig.tsbuildinfo +1 -1
  7. package/dist-client/utils/open-page.d.ts +5 -0
  8. package/dist-client/utils/open-page.js +19 -0
  9. package/dist-client/utils/open-page.js.map +1 -0
  10. package/dist-server/service/assistant-mode/resolve.d.ts +1 -0
  11. package/dist-server/service/assistant-mode/resolve.js +2 -1
  12. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  13. package/dist-server/service/assistant-mode/types.d.ts +2 -0
  14. package/dist-server/service/assistant-mode/types.js.map +1 -1
  15. package/dist-server/service/chat-session/attachment-tool-result.d.ts +28 -0
  16. package/dist-server/service/chat-session/attachment-tool-result.js +9 -0
  17. package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -0
  18. package/dist-server/service/chat-session/attachment-tools.js +4 -5
  19. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  20. package/dist-server/service/data-read/list-read.d.ts +36 -0
  21. package/dist-server/service/data-read/list-read.js +64 -0
  22. package/dist-server/service/data-read/list-read.js.map +1 -0
  23. package/dist-server/service/data-read/read-target.d.ts +109 -0
  24. package/dist-server/service/data-read/read-target.js +189 -0
  25. package/dist-server/service/data-read/read-target.js.map +1 -0
  26. package/dist-server/service/data-read/read-tools.d.ts +228 -0
  27. package/dist-server/service/data-read/read-tools.js +197 -0
  28. package/dist-server/service/data-read/read-tools.js.map +1 -0
  29. package/dist-server/service/index.d.ts +3 -0
  30. package/dist-server/service/index.js +7 -1
  31. package/dist-server/service/index.js.map +1 -1
  32. package/dist-server/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +5 -5
  34. package/server/service/assistant-mode/resolve.ts +3 -2
  35. package/server/service/assistant-mode/types.ts +2 -0
  36. package/server/service/chat-session/attachment-tool-result.test.ts +26 -0
  37. package/server/service/chat-session/attachment-tool-result.ts +18 -0
  38. package/server/service/chat-session/attachment-tools.ts +4 -5
  39. package/server/service/data-read/data-read.test.ts +229 -0
  40. package/server/service/data-read/list-read.ts +98 -0
  41. package/server/service/data-read/read-target.ts +230 -0
  42. package/server/service/data-read/read-tools.ts +212 -0
  43. package/server/service/index.ts +4 -0
  44. package/test/open-page.test.ts +31 -0
  45. package/translations/en.json +14 -0
  46. package/translations/ja.json +14 -0
  47. package/translations/ko.json +14 -0
  48. package/translations/ms.json +14 -0
  49. package/translations/zh.json +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.112",
3
+ "version": "10.1.114",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -33,10 +33,10 @@
33
33
  "@operato/popup": "^10.16.0",
34
34
  "@operato/shell": "^10.0.0",
35
35
  "@operato/styles": "^10.0.0",
36
- "@things-factory/ai-client-base": "^10.1.81",
36
+ "@things-factory/ai-client-base": "^10.1.114",
37
37
  "@things-factory/apptool-base": "^10.1.106",
38
- "@things-factory/attachment-base": "^10.1.112",
39
- "@things-factory/auth-base": "^10.1.111",
38
+ "@things-factory/attachment-base": "^10.1.113",
39
+ "@things-factory/auth-base": "^10.1.113",
40
40
  "@things-factory/env": "^10.1.20",
41
41
  "@things-factory/shell": "^10.1.106",
42
42
  "dompurify": "^3.0.0",
@@ -52,5 +52,5 @@
52
52
  "copyfiles": "^2.4.1",
53
53
  "rimraf": "^5.0.0"
54
54
  },
55
- "gitHead": "e300d1b60b98ccb80380afe8df6d2807b1bcff91"
55
+ "gitHead": "0d8f9bc0ff0cb50431f2c76cb166aaf3809d3cf7"
56
56
  }
@@ -47,12 +47,13 @@ export interface ModeSettings {
47
47
  * The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
48
48
  * takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
49
49
  */
50
- export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[] }): string[] | undefined {
50
+ export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }): string[] | undefined {
51
51
  const extra = [
52
52
  /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
53
53
  'assistant-workspace',
54
54
  ...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
55
- ...(mode.entryTargets ?? []).map(key => `data-entry:${key}`)
55
+ ...(mode.entryTargets ?? []).map(key => `data-entry:${key}`),
56
+ ...(mode.readTargets ?? []).map(key => `data-read:${key}`)
56
57
  ]
57
58
  return [...new Set([...(mode.toolCategories ?? []), ...extra])]
58
59
  }
@@ -105,6 +105,8 @@ export interface AssistantModeSpec {
105
105
  * with `registerEntryTarget`. Each opens that target's propose tool; its door still decides who gets it.
106
106
  */
107
107
  entryTargets?: string[]
108
+ /** The lists of the application the conversation may read - read targets by key (design/ai/read-targets.md). */
109
+ readTargets?: string[]
108
110
  /** i18n key for the line under an empty conversation. */
109
111
  footerNoticeKey?: string
110
112
  /** i18n key for the line shown once the host reports a proposal staged. */
@@ -0,0 +1,26 @@
1
+ /*
2
+ * readChatAttachment's answer (design/ai/shared-dock.md §9-4) - a picture of an earlier turn comes back as the picture.
3
+ */
4
+ /* The source, not the built package - the two land together and the test must see the change before a build. */
5
+ import { takeToolImages } from '../../../../ai-client-base/server/service/agentic-loop/agentic-loop'
6
+
7
+ import { attachmentToolResult } from './attachment-tool-result'
8
+
9
+ describe('a file opened again in a later turn', () => {
10
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 9])
11
+
12
+ it('★ a picture comes back as the picture - not as a request to send it again', () => {
13
+ const answer: any = attachmentToolResult('a1', { type: 'image', data: png, mediaType: 'image/png', name: '영수증.png' })
14
+ expect(answer.images).toEqual([{ data: png, mediaType: 'image/png' }])
15
+ expect(answer.note).not.toMatch(/send it again/)
16
+ /* What the loop makes of it: the picture as an image part, the rest as the JSON the model reads. */
17
+ const { value, images } = takeToolImages(answer)
18
+ expect(images).toHaveLength(1)
19
+ expect(value).toEqual({ attachmentId: 'a1', name: '영수증.png', type: 'image', note: answer.note, imagesAttached: 1 })
20
+ })
21
+
22
+ it('a text file comes back as its text, and a cut one says so', () => {
23
+ expect(attachmentToolResult('t1', { type: 'text', text: 'a,b', name: 'x.csv', truncated: false })).toEqual({ attachmentId: 't1', name: 'x.csv', type: 'text', text: 'a,b' })
24
+ expect(attachmentToolResult('t1', { type: 'text', text: 'a', name: 'x.csv', truncated: true })).toMatchObject({ note: 'the file is longer; this is its beginning.' })
25
+ })
26
+ })
@@ -0,0 +1,18 @@
1
+ /*
2
+ * What readChatAttachment answers for a file of the conversation (design/ai/shared-dock.md §9-4).
3
+ *
4
+ * A text file comes back as its text. A picture comes back as the picture: the reserved `images` field is taken out
5
+ * of the JSON by the loop (ai-client-base takeToolImages) and sent as an image part of the same turn - so a receipt
6
+ * sent three turns ago can be read again without asking the person to send it again.
7
+ *
8
+ * Pure, so it is tested without storage or the shell.
9
+ */
10
+ export type FileForModel =
11
+ | { type: 'image'; data: Buffer; mediaType: string; name: string }
12
+ | { type: 'text'; text: string; name: string; truncated: boolean }
13
+
14
+ export function attachmentToolResult(attachmentId: string, file: FileForModel) {
15
+ return file.type === 'image'
16
+ ? { attachmentId, name: file.name, type: 'image', images: [{ data: file.data, mediaType: file.mediaType }], note: 'the picture is attached right after this result - look at it.' }
17
+ : { attachmentId, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }
18
+ }
@@ -2,7 +2,7 @@
2
2
  * readChatAttachment - the model opens a file sent in an earlier turn (design/ai/shared-dock.md §9-4).
3
3
  *
4
4
  * A file reaches the model in full only in the turn it was sent; later turns carry one line naming it.
5
- * When the model needs it again it asks here. The caller must be able to read the conversation the file
5
+ * When the model needs it again it asks here - a text file comes back as its text, a picture as the picture itself. The caller must be able to read the conversation the file
6
6
  * belongs to - the same gate as reading its messages (ADR-0073 decision 1) - so a file id from elsewhere
7
7
  * opens nothing.
8
8
  *
@@ -18,6 +18,7 @@ import { isSessionParticipant } from '../chat-session-participant/participant-lo
18
18
  import { attachmentForModel, CHAT_ATTACHMENT_REF } from './session-attachments.js'
19
19
  import { textCharsOf } from './attachment-policy.js'
20
20
  import { getAssistantMode } from '../assistant-mode/registry.js'
21
+ import { attachmentToolResult } from './attachment-tool-result.js'
21
22
 
22
23
  export const CHAT_ATTACHMENT_CATEGORY = 'chat-attachments'
23
24
 
@@ -27,7 +28,7 @@ const readChatAttachment: ToolSpec = {
27
28
  doors: ['Query.chatMessages'],
28
29
  description:
29
30
  'Open a file that was sent earlier in this conversation. History names such files as "[attachment <id>: <name> ...]"; ' +
30
- 'pass that id. An image comes back as a description request, a text file as its text (cut when long - the answer says so).',
31
+ 'pass that id. An image comes back as the picture itself, a text file as its text (cut when long - the answer says so).',
31
32
  schema: {
32
33
  type: 'object',
33
34
  properties: { attachmentId: { type: 'string', description: 'The id from the "[attachment <id>: ...]" line.' } },
@@ -49,9 +50,7 @@ const readChatAttachment: ToolSpec = {
49
50
  }
50
51
 
51
52
  const file = await attachmentForModel(row, textCharsOf(getAssistantMode(session.kind || '')?.attachments))
52
- return file.type === 'image'
53
- ? { attachmentId: id, name: file.name, type: 'image', note: 'images are shown to you only in the turn they were sent; ask the person to send it again to look at it.' }
54
- : { attachmentId: id, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }
53
+ return attachmentToolResult(id, file)
55
54
  }
56
55
  } as ToolSpec
57
56
 
@@ -0,0 +1,229 @@
1
+ /*
2
+ * AI 조회 (design/ai/read-targets.md) - the conditions become the application's filters, the rows are read through
3
+ * its query page by page, the server counts and sums, a long result goes on the workspace as read, and the screen
4
+ * card says which conditions it does not take.
5
+ */
6
+ import { modeToolCategories } from '../assistant-mode/resolve'
7
+ import { clearReadTargets, declareReadTarget, groupsOf, MAX_READ_ROWS, queryToolName, readCategoryName, screenAddress, screenToolName, type ReadTarget } from './read-target'
8
+ import { openScreen, queryRecords, readCategoryOf } from './read-tools'
9
+
10
+ /* The application, as a fake: a bank ledger behind a list query that pages and filters like ListParam. */
11
+ let ledger: any[]
12
+ const calls: any[] = []
13
+ const run = async (_document: string, variables: any) => {
14
+ calls.push(variables)
15
+ const keep = (row: any) =>
16
+ variables.filters.every((f: any) =>
17
+ f.operator === 'gte' ? row[f.name] >= f.value : f.operator === 'lte' ? row[f.name] <= f.value : f.operator === 'eq' ? row[f.name] === f.value : String(row[f.name] ?? '').includes(f.value)
18
+ )
19
+ const all = ledger.filter(keep)
20
+ const { page, limit } = variables.pagination
21
+ return { data: { cashTransactions: { items: all.slice((page - 1) * limit, page * limit), total: all.length } } }
22
+ }
23
+
24
+ const target: ReadTarget = {
25
+ key: 'office.cash-transaction',
26
+ labelKey: 'label.office-read-cash-transaction',
27
+ description: 'bank transactions.',
28
+ query: 'query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) { cashTransactions(filters: $filters, pagination: $pagination, sortings: $sortings) { items { id transactionDate type amount customerName account { name } } total } }',
29
+ criteria: [
30
+ { name: 'from', type: 'date', filter: v => ({ name: 'transactionDate', operator: 'gte', value: v }) },
31
+ { name: 'to', type: 'date', filter: v => ({ name: 'transactionDate', operator: 'lte', value: v }) },
32
+ { name: 'flow', type: 'enum', values: ['INCOME', 'EXPENSE'], filter: v => ({ name: 'type', operator: 'eq', value: v }) },
33
+ { name: 'text', type: 'string', filter: v => ({ name: 'customerName', operator: 'search', value: v }) }
34
+ ],
35
+ columns: [
36
+ { name: 'transactionDate', type: 'date' },
37
+ { name: 'account', path: 'account.name', type: 'string' },
38
+ { name: 'type', type: 'string' },
39
+ { name: 'amount', type: 'number', total: true },
40
+ { name: 'customerName', type: 'string' }
41
+ ],
42
+ rowsOf: data => ({ rows: data.cashTransactions.items, total: data.cashTransactions.total }),
43
+ screen: { route: 'cash-transaction-list', takes: ['flow', 'text'], params: c => ({ flow: c.flow as string, search: c.text as string }) }
44
+ }
45
+
46
+ const tx = (i: number, date: string, type: string, amount: number, customerName: string) => ({ id: `t${i}`, transactionDate: date, type, amount, customerName, account: { name: '우리은행' } })
47
+
48
+ beforeEach(() => {
49
+ clearReadTargets()
50
+ calls.length = 0
51
+ ledger = [
52
+ tx(1, '2026-04-03', 'EXPENSE', 1_000_000, '외주A'),
53
+ tx(2, '2026-05-10', 'EXPENSE', 250_000, '카카오T'),
54
+ tx(3, '2026-05-20', 'INCOME', 9_000_000, '고객사'),
55
+ tx(4, '2026-09-30', 'EXPENSE', 2_000_000, '외주A'),
56
+ tx(5, '2026-10-01', 'EXPENSE', 70_000, '스타벅스')
57
+ ]
58
+ })
59
+
60
+ describe('reading a list', () => {
61
+ it('★ the conditions become the application\'s filters - the model never names the query\'s columns', async () => {
62
+ const result: any = await queryRecords(target, { from: '2026-04-01', to: '2026-09-30', flow: 'EXPENSE' }, {}, run)
63
+ expect(calls[0].filters).toEqual([
64
+ { name: 'transactionDate', operator: 'gte', value: '2026-04-01' },
65
+ { name: 'transactionDate', operator: 'lte', value: '2026-09-30' },
66
+ { name: 'type', operator: 'eq', value: 'EXPENSE' }
67
+ ])
68
+ expect(result).toMatchObject({ total: 3, read: 3, complete: true, sums: { amount: 3_250_000 } })
69
+ expect(result.rows[0]).toEqual({ id: 't1', transactionDate: '2026-04-03', account: '우리은행', type: 'EXPENSE', amount: 1_000_000, customerName: '외주A' })
70
+ expect(result.proposed).toBeUndefined()
71
+ })
72
+
73
+ it('★ the server groups and sums - by a column largest first, by a date column per month', async () => {
74
+ const byName: any = await queryRecords(target, { flow: 'EXPENSE', groupBy: 'customerName' }, {}, run)
75
+ expect(byName.groups[0]).toEqual({ key: '외주A', count: 2, sums: { amount: 3_000_000 } })
76
+ const byMonth: any = await queryRecords(target, { flow: 'EXPENSE', groupBy: 'transactionDate' }, {}, run)
77
+ expect(byMonth.groups.map((group: any) => group.key)).toEqual(['2026-04', '2026-05', '2026-09', '2026-10'])
78
+ expect(groupsOf(target, [], 'nothing')).toBeUndefined()
79
+ })
80
+
81
+ it('a condition that does not fit its type is refused, not guessed at', async () => {
82
+ const result: any = await queryRecords(target, { from: '지난달', flow: 'OUT' }, {}, run)
83
+ expect(result).toMatchObject({ rejected: true, error: 'condition-unreadable' })
84
+ expect(result.refused.map((one: any) => one.name)).toEqual(['from', 'flow'])
85
+ expect(calls).toHaveLength(0)
86
+ })
87
+
88
+ it('★ a long result goes on the workspace as read - the model gets 20 rows, the card every row', async () => {
89
+ ledger = Array.from({ length: 450 }, (_, i) => tx(i, `2026-06-${String((i % 28) + 1).padStart(2, '0')}`, 'EXPENSE', 1000 + i, `거래처${i % 7}`))
90
+ const result: any = await queryRecords(target, { flow: 'EXPENSE', groupBy: 'customerName' }, { t: (key: string) => (key === 'label.office-read-cash-transaction' ? '은행 입출금' : key) }, run)
91
+ expect(calls.map(one => one.pagination.page)).toEqual([1, 2, 3])
92
+ expect(result).toMatchObject({ proposed: true, kind: 'workspace', total: 450, read: 450, complete: true, rowsNotShown: 430 })
93
+ expect(result.rows).toHaveLength(20)
94
+ const tables = result.forScreen.workspace.blocks.filter((block: any) => block.kind === 'table')
95
+ expect(tables.map((table: any) => table.rows.length)).toEqual([7, 450])
96
+ expect(result.forScreen.workspace.title).toBe('은행 입출금 · 450')
97
+ expect(result.forScreen.workspace.name).toBe('office-cash-transaction')
98
+ })
99
+
100
+ it('★ more than the read limit is said - the sums are of the rows read, never called the total', async () => {
101
+ ledger = Array.from({ length: MAX_READ_ROWS + 30 }, (_, i) => tx(i, '2026-06-01', 'EXPENSE', 1, 'x'))
102
+ const result: any = await queryRecords(target, {}, {}, run)
103
+ expect(result).toMatchObject({ total: MAX_READ_ROWS + 30, read: MAX_READ_ROWS, complete: false, sums: { amount: MAX_READ_ROWS } })
104
+ expect(result.message).toMatch(/Only 1000 of 1030 rows were read/)
105
+ })
106
+
107
+ it('a query the person may not run is said as it failed', async () => {
108
+ const result: any = await queryRecords(target, {}, {}, async () => ({ errors: [{ message: 'unauthorized! office:query privilege required' }] }))
109
+ expect(result).toMatchObject({ rejected: true, error: 'query-failed' })
110
+ })
111
+ })
112
+
113
+ describe('the screen card', () => {
114
+ it('★ the address carries what the screen takes, and the card names what it does not', () => {
115
+ expect(screenAddress(target, { flow: 'EXPENSE', from: '2026-04-01', text: '외주' })).toEqual({
116
+ href: 'cash-transaction-list?flow=EXPENSE&search=%EC%99%B8%EC%A3%BC',
117
+ applied: ['flow', 'text'],
118
+ notApplied: ['from']
119
+ })
120
+ const card: any = openScreen(target, { flow: 'EXPENSE', from: '2026-04-01' }, {})
121
+ expect(card).toMatchObject({ proposed: true, kind: 'open-page', href: 'cash-transaction-list?flow=EXPENSE', notApplied: ['from'] })
122
+ expect(card.message).toMatch(/does not take from/)
123
+ })
124
+ })
125
+
126
+ describe('declaring and offering', () => {
127
+ it('★ the tools are locked by the list query\'s door, and a mode opens them by key', () => {
128
+ const category = readCategoryOf(target, () => run)
129
+ expect(category.name).toBe(readCategoryName(target.key))
130
+ expect(category.specs.map(spec => spec.name)).toEqual([queryToolName(target.key), screenToolName(target.key)])
131
+ expect(category.specs.map(spec => (spec as any).doors)).toEqual([['Query.cashTransactions'], ['Query.cashTransactions']])
132
+ expect(modeToolCategories({ readTargets: [target.key] })).toContain('data-read:office.cash-transaction')
133
+ })
134
+
135
+ it('a declaration that would mislead is refused at boot', () => {
136
+ declareReadTarget(target)
137
+ expect(() => declareReadTarget(target)).toThrow(/declared twice/)
138
+ expect(() => declareReadTarget({ ...target, key: 'office.x', screen: { ...target.screen!, takes: ['amount'] } })).toThrow(/takes 'amount', which is no condition/)
139
+ expect(() => declareReadTarget({ ...target, key: 'office.y', columns: [] })).toThrow(/shows no column/)
140
+ })
141
+
142
+ it('every condition has one schema type - Gemini refuses a list of types', () => {
143
+ const [query] = readCategoryOf(target, () => run).specs as any[]
144
+ for (const [name, schema] of Object.entries<any>(query.schema.properties)) expect(typeof schema.type).toBe('string')
145
+ })
146
+ })
147
+
148
+ describe('one record', () => {
149
+ it('★ one record by its id - every target takes it, and its every column comes back', async () => {
150
+ const result: any = await queryRecords(target, { id: 't3' }, {}, run)
151
+ expect(calls[0].filters).toEqual([{ name: 'id', operator: 'eq', value: 't3' }])
152
+ expect(result.record).toEqual({ id: 't3', transactionDate: '2026-05-20', account: '우리은행', type: 'INCOME', amount: 9_000_000, customerName: '고객사' })
153
+ expect(result.message).toMatch(/one record/)
154
+ })
155
+
156
+ it('a list answer names each row by its id - the next question can ask for one', async () => {
157
+ const result: any = await queryRecords(target, { flow: 'INCOME', text: '고객' }, {}, run)
158
+ expect(result.record?.id).toBe('t3')
159
+ const list: any = await queryRecords(target, { flow: 'EXPENSE' }, {}, run)
160
+ expect(list.rows.every((row: any) => typeof row.id === 'string')).toBe(true)
161
+ })
162
+
163
+ it('a target may not declare id itself', () => {
164
+ expect(() => declareReadTarget({ ...target, key: 'office.z', criteria: [{ name: 'id', type: 'string', filter: v => ({ name: 'id', operator: 'eq', value: v }) }] })).toThrow(/every target takes it/)
165
+ })
166
+ })
167
+
168
+ describe('the text condition', () => {
169
+ it('★ a target that takes ListParam search sends the words as search - the resolver finds them in its own fields', async () => {
170
+ const searching: ReadTarget = { ...target, key: 'office.searching', criteria: target.criteria.filter(one => one.name !== 'text'), search: { description: 'the memo and the counterparty' } }
171
+ const seen: any[] = []
172
+ await queryRecords(searching, { text: '외주', flow: 'EXPENSE' }, {}, async (_d, variables) => (seen.push(variables), { data: { cashTransactions: { items: [], total: 0 } } }))
173
+ expect(seen[0].search).toBe('외주')
174
+ expect(seen[0].filters).toEqual([{ name: 'type', operator: 'eq', value: 'EXPENSE' }])
175
+ const [query] = readCategoryOf(searching, () => run).specs as any[]
176
+ expect(query.schema.properties.text.description).toMatch(/the memo and the counterparty/)
177
+ expect(() => declareReadTarget({ ...target, key: 'office.both', search: { description: 'x' } })).toThrow(/search is the text condition/)
178
+ })
179
+ })
180
+
181
+ describe('a list declared in one line', () => {
182
+ const { listReadTarget } = require('./list-read')
183
+ const line = {
184
+ key: 'office.cash-transaction',
185
+ title: 'menu.cash-transaction-list',
186
+ name: '자금 입출금 관리',
187
+ listQuery: 'cashTransactions',
188
+ selection: 'id transactionDate type amount balance customerName',
189
+ columns: [
190
+ { name: 'transactionDate', type: 'date' },
191
+ { name: 'type', type: 'string' },
192
+ { name: 'amount', type: 'number', total: true },
193
+ { name: 'balance', type: 'number' }
194
+ ],
195
+ dateField: 'transactionDate',
196
+ narrowing: { year: 'year', month: 'month', flow: 'type' },
197
+ search: 'memo and counterparty',
198
+ route: 'cash-transaction-list',
199
+ screenTakes: ['year', 'month', 'flow', 'text']
200
+ }
201
+
202
+ it('★ becomes a target with the conditions its fields allow - and only those', async () => {
203
+ const made: ReadTarget = listReadTarget(line)
204
+ declareReadTarget(made)
205
+ expect(made.criteria.map(one => one.name)).toEqual(['from', 'to', 'year', 'month', 'flow', 'minAmount', 'maxAmount'])
206
+ expect(made.query).toContain('cashTransactions(filters: $filters, pagination: $pagination, sortings: $sortings, search: $search) { items { id transactionDate type amount balance customerName } total }')
207
+ const bare: ReadTarget = listReadTarget({ ...line, key: 'office.bare', narrowing: undefined, dateField: undefined, search: undefined, columns: [{ name: 'name', type: 'string' }] })
208
+ expect(bare.criteria).toEqual([])
209
+ expect(bare.query).not.toContain('$search')
210
+ expect(bare.screen!.takes).toEqual([])
211
+ })
212
+
213
+ it('★ the to day takes the whole day of a date-time, and the screen gets only what it reads', () => {
214
+ const made: ReadTarget = listReadTarget(line)
215
+ const to = made.criteria.find(one => one.name === 'to')!
216
+ expect(to.filter('2026-09-30')).toEqual({ name: 'transactionDate', operator: 'lte', value: '2026-09-30T23:59:59.999' })
217
+ expect(made.screen!.params({ year: 2026, month: 9, flow: 'EXPENSE', text: '외주', from: '2026-01-01' })).toEqual({ year: 2026, month: 9, flow: 'EXPENSE', search: '외주' })
218
+ expect(made.screen!.params({ month: 9 })).toEqual({})
219
+ const plain: ReadTarget = listReadTarget({ ...line, key: 'plant.x', screenTakes: undefined })
220
+ expect(plain.screen!.takes).toEqual([])
221
+ expect(plain.screen!.params({ year: 2026 })).toEqual({})
222
+ })
223
+
224
+ it('a minimum or maximum amount means the amount column, else the first summed one', () => {
225
+ const made: ReadTarget = listReadTarget({ ...line, key: 'office.claims', columns: [{ name: 'totalAmount', type: 'number', total: true }] })
226
+ expect(made.criteria.find(one => one.name === 'minAmount')!.filter(5)).toEqual({ name: 'totalAmount', operator: 'gte', value: 5 })
227
+ })
228
+ })
229
+
@@ -0,0 +1,98 @@
1
+ /*
2
+ * A list declared in one line (operato-application/design/ai/read-targets.md §3) - the shape most list screens share:
3
+ * a ListParam query, the fields it reads, a date to range over, a status, an amount, search words, and a screen.
4
+ * Each application writes its lists as these lines; this turns one into a read target with the conditions it takes.
5
+ */
6
+ import type { ReadColumn, ReadCriterion, ReadTarget } from './read-target.js'
7
+
8
+ export interface ListRead {
9
+ /** `<app>.<record>`. */
10
+ key: string
11
+ /** i18n key of the screen's title - the card's name. */
12
+ title: string
13
+ /** The screen's name as the person knows it - the model picks the list by it. */
14
+ name: string
15
+ /** The ListParam query (`cashTransactions`). */
16
+ listQuery: string
17
+ /** The fields of each item it reads - the screen's own selection where there is one. */
18
+ selection: string
19
+ columns: ReadColumn[]
20
+ /** A date (or date-time) field the `from` / `to` conditions range over. */
21
+ dateField?: string
22
+ /** Integer year and month fields, and an in/out field (INCOME | EXPENSE) - the `year`, `month`, `flow` conditions. */
23
+ narrowing?: { year?: string; month?: string; flow?: string }
24
+ /** A field holding the period as YYYY-MM - the `period` condition. */
25
+ monthField?: string
26
+ /** A status field and its values - the `status` condition. */
27
+ status?: { field: string; values: string[] }
28
+ /** The resolver's searchables, said for the model - the list takes ListParam search (the `text` condition). */
29
+ search?: string
30
+ /** The screen's route. Left out, the list has no screen card. */
31
+ route?: string
32
+ /** The conditions the screen reads from its address - `year`, `month`, `flow` and `text` (as `search`). Empty: it opens as it is. */
33
+ screenTakes?: ('year' | 'month' | 'flow' | 'text')[]
34
+ }
35
+
36
+ /** The money column a minimum or maximum amount means - `amount` when there is one, else the first summed column. */
37
+ const amountOf = (list: ListRead) => list.columns.find(one => one.name === 'amount' && one.type === 'number')?.name ?? list.columns.find(one => one.total)?.name
38
+
39
+ export function listReadTarget(list: ListRead): ReadTarget {
40
+ const search = !!list.search
41
+ const amount = amountOf(list)
42
+ const narrowing = list.narrowing ?? {}
43
+ const criteria: ReadCriterion[] = [
44
+ ...(list.dateField
45
+ ? [
46
+ { name: 'from', type: 'date', labelKey: 'ai-assistant.read.from', description: `the first ${list.dateField}, inclusive`, filter: (v: any) => ({ name: list.dateField!, operator: 'gte', value: v }) },
47
+ { name: 'to', type: 'date', labelKey: 'ai-assistant.read.to', description: `the last ${list.dateField}, inclusive`, filter: (v: any) => ({ name: list.dateField!, operator: 'lte', value: `${v}${/T/.test(String(v)) ? '' : 'T23:59:59.999'}` }) }
48
+ ]
49
+ : []),
50
+ ...(narrowing.year ? [{ name: 'year', type: 'integer', labelKey: 'ai-assistant.read.year', description: 'a year (2026)', filter: (v: any) => ({ name: narrowing.year!, operator: 'eq', value: v }) }] : []),
51
+ ...(narrowing.month ? [{ name: 'month', type: 'integer', labelKey: 'ai-assistant.read.month', description: '1-12, with a year', filter: (v: any) => ({ name: narrowing.month!, operator: 'eq', value: v }) }] : []),
52
+ ...(narrowing.flow
53
+ ? [{ name: 'flow', type: 'enum', values: ['INCOME', 'EXPENSE'], labelKey: 'ai-assistant.read.flow', description: 'money in or out', filter: (v: any) => ({ name: narrowing.flow!, operator: 'eq', value: v }) }]
54
+ : []),
55
+ ...(list.monthField
56
+ ? [{ name: 'period', type: 'string', labelKey: 'ai-assistant.read.period', description: 'a month as YYYY-MM', filter: (v: any) => ({ name: list.monthField!, operator: 'eq', value: v }) }]
57
+ : []),
58
+ ...(list.status
59
+ ? [{ name: 'status', type: 'enum', values: list.status.values, labelKey: 'ai-assistant.read.status', filter: (v: any) => ({ name: list.status!.field, operator: 'eq', value: v }) }]
60
+ : []),
61
+ ...(amount
62
+ ? [
63
+ { name: 'minAmount', type: 'number', labelKey: 'ai-assistant.read.min-amount', description: `at least this ${amount}`, filter: (v: any) => ({ name: amount, operator: 'gte', value: v }) },
64
+ { name: 'maxAmount', type: 'number', labelKey: 'ai-assistant.read.max-amount', description: `at most this ${amount}`, filter: (v: any) => ({ name: amount, operator: 'lte', value: v }) }
65
+ ]
66
+ : [])
67
+ ] as ReadCriterion[]
68
+
69
+ const takes = (list.screenTakes ?? []).filter(name => (name === 'text' ? search : !!narrowing[name]))
70
+ return {
71
+ key: list.key,
72
+ labelKey: list.title,
73
+ description: `the rows of the list 「${list.name}」 (${list.columns.map(one => one.name).join(', ')}).`,
74
+ query:
75
+ `query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]${search ? ', $search: String' : ''}) ` +
76
+ `{ ${list.listQuery}(filters: $filters, pagination: $pagination, sortings: $sortings${search ? ', search: $search' : ''}) { items { ${list.selection} } total } }`,
77
+ criteria,
78
+ columns: list.columns,
79
+ rowsOf: data => ({ rows: data?.[list.listQuery]?.items ?? [], total: data?.[list.listQuery]?.total ?? 0 }),
80
+ ...(search ? { search: { description: `the ${list.search}` } } : {}),
81
+ ...(list.dateField ? { sortings: [{ name: list.dateField, desc: true }] } : {}),
82
+ ...(list.route
83
+ ? {
84
+ screen: {
85
+ route: list.route,
86
+ labelKey: list.title,
87
+ takes,
88
+ params: (c: Record<string, any>) => ({
89
+ ...(takes.includes('year') ? { year: c.year } : {}),
90
+ ...(takes.includes('month') && c.year ? { month: c.month } : {}),
91
+ ...(takes.includes('flow') ? { flow: c.flow } : {}),
92
+ ...(takes.includes('text') ? { search: c.text } : {})
93
+ })
94
+ }
95
+ }
96
+ : {})
97
+ }
98
+ }