@things-factory/ai-assistant 10.1.167 → 10.1.169
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/assistant-chat.ts +2 -2
- package/dist-client/components/assistant-chat.js +2 -2
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/assistant-chat-resolver.js +16 -17
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +12 -0
- package/dist-server/service/assistant-mode/mode-categories.js +19 -0
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -0
- package/dist-server/service/assistant-mode/registry.js +3 -1
- package/dist-server/service/assistant-mode/registry.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +2 -12
- package/dist-server/service/assistant-mode/resolve.js +4 -16
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/chat-message/proposal-outcome.d.ts +7 -0
- package/dist-server/service/chat-message/proposal-outcome.js +25 -0
- package/dist-server/service/chat-message/proposal-outcome.js.map +1 -1
- package/dist-server/service/chat-session/turn-attachments.d.ts +19 -0
- package/dist-server/service/chat-session/turn-attachments.js +24 -0
- package/dist-server/service/chat-session/turn-attachments.js.map +1 -0
- package/dist-server/service/data-read/list-read.js +4 -2
- package/dist-server/service/data-read/list-read.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +14 -3
- package/dist-server/service/data-read/read-target.js +43 -6
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.d.ts +9 -9
- package/dist-server/service/data-read/read-tools.js +14 -3
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/assistant-chat-resolver.ts +20 -17
- package/server/service/assistant-mode/mode-categories.ts +15 -0
- package/server/service/assistant-mode/registry.test.ts +10 -0
- package/server/service/assistant-mode/registry.ts +3 -1
- package/server/service/assistant-mode/resolve.ts +2 -14
- package/server/service/chat-message/proposal-outcome.test.ts +13 -1
- package/server/service/chat-message/proposal-outcome.ts +24 -0
- package/server/service/chat-session/turn-attachments.test.ts +106 -0
- package/server/service/chat-session/turn-attachments.ts +45 -0
- package/server/service/data-read/data-read.test.ts +62 -0
- package/server/service/data-read/list-read.ts +4 -2
- package/server/service/data-read/read-target.ts +49 -8
- package/server/service/data-read/read-tools.ts +15 -3
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.169",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@operato/popup": "^10.16.0",
|
|
35
35
|
"@operato/shell": "^10.0.0",
|
|
36
36
|
"@operato/styles": "^10.0.0",
|
|
37
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
37
|
+
"@things-factory/ai-client-base": "^10.1.169",
|
|
38
38
|
"@things-factory/apptool-base": "^10.1.165",
|
|
39
39
|
"@things-factory/attachment-base": "^10.1.167",
|
|
40
40
|
"@things-factory/auth-base": "^10.1.167",
|
|
@@ -54,5 +54,5 @@
|
|
|
54
54
|
"copyfiles": "^2.4.1",
|
|
55
55
|
"rimraf": "^5.0.0"
|
|
56
56
|
},
|
|
57
|
-
"gitHead": "
|
|
57
|
+
"gitHead": "9d71d02b443f3e0080b25de1768b982c51a64111"
|
|
58
58
|
}
|
|
@@ -33,6 +33,7 @@ import { ChatSession } from './chat-session/chat-session.js'
|
|
|
33
33
|
import { assertStationSession } from './chat-session/station-session-guard.js'
|
|
34
34
|
import { ChatMessage } from './chat-message/chat-message.js'
|
|
35
35
|
import { buildLlmHistory } from './chat-message/llm-history.js'
|
|
36
|
+
import { offeredCardLines } from './chat-message/proposal-outcome.js'
|
|
36
37
|
import { splitByCap } from './chat-message/history-summary.js'
|
|
37
38
|
import { foldSessionHistory } from './chat-message/fold-session-history.js'
|
|
38
39
|
import { groundingSourcesFor } from './grounding-sources.js'
|
|
@@ -50,6 +51,7 @@ import { isSessionParticipant } from './chat-session-participant/participant-loo
|
|
|
50
51
|
import { assertMayWriteSession } from './chat-session/session-write-gate.js'
|
|
51
52
|
import { attachmentHistoryLine, refuseAttachments, textCharsOf } from './chat-session/attachment-policy.js'
|
|
52
53
|
import { attachmentForModel, describeAttachment, findChatAttachment } from './chat-session/session-attachments.js'
|
|
54
|
+
import { readTurnAttachments } from './chat-session/turn-attachments.js'
|
|
53
55
|
import { getAssistantMode } from './assistant-mode/registry.js'
|
|
54
56
|
import { resolveModeSettings } from './assistant-mode/resolve.js'
|
|
55
57
|
import { splitNextRequest } from './assistant-mode/screen-rules.js'
|
|
@@ -448,10 +450,12 @@ export class AssistantChatResolver {
|
|
|
448
450
|
* A file sent in an earlier turn is not sent again - one line names it, and the model opens it
|
|
449
451
|
* with readChatAttachment when it needs to (cost is a usage axis, ADR-0061 decision 6).
|
|
450
452
|
*/
|
|
451
|
-
content:
|
|
452
|
-
message.
|
|
453
|
-
|
|
454
|
-
|
|
453
|
+
content: [
|
|
454
|
+
message.content,
|
|
455
|
+
...(message.id !== userMessageId && Array.isArray((message as any).attachmentsJson) ? (message as any).attachmentsJson.map(attachmentHistoryLine) : []),
|
|
456
|
+
/* An answer that offered cards names them, so a later outcome row reads as about one of them (audit N7). */
|
|
457
|
+
...(message.role === 'assistant' ? offeredCardLines((message as any).proposals) : [])
|
|
458
|
+
].join('\n'),
|
|
455
459
|
senderId: (message as any).creator?.id,
|
|
456
460
|
senderName: (message as any).creator?.name,
|
|
457
461
|
addressee: (message as any).addressee
|
|
@@ -468,22 +472,20 @@ export class AssistantChatResolver {
|
|
|
468
472
|
)
|
|
469
473
|
: input.messages.map(message => ({ role: message.role as any, content: message.content }))
|
|
470
474
|
|
|
475
|
+
/*
|
|
476
|
+
* The files checked above, read once: the model reads them on this turn's question, and the tools get the same
|
|
477
|
+
* files as `context.attachments` (ADR-0061, 2026-10-16 decision 2) - figure's step generator draws from the
|
|
478
|
+
* picture the person sent. Never mixed into `hostContext`, which the client fills.
|
|
479
|
+
*/
|
|
480
|
+
const turnFiles = await readTurnAttachments(
|
|
481
|
+
attachmentRows,
|
|
482
|
+
textCharsOf(getAssistantMode(input.mode?.kind ?? '')?.attachments),
|
|
483
|
+
attachmentForModel
|
|
484
|
+
)
|
|
471
485
|
if (attachmentRows.length && conversation.length > 0) {
|
|
472
486
|
const last = conversation[conversation.length - 1]
|
|
473
487
|
if (last.role === 'user' && typeof last.content === 'string') {
|
|
474
|
-
|
|
475
|
-
for (const row of attachmentRows) {
|
|
476
|
-
const file = await attachmentForModel(row, textCharsOf(getAssistantMode(input.mode?.kind ?? '')?.attachments))
|
|
477
|
-
if (file.type === 'image') {
|
|
478
|
-
parts.push({ type: 'image', source: { kind: 'base64', data: file.data, mediaType: file.mediaType } })
|
|
479
|
-
} else {
|
|
480
|
-
parts.push({
|
|
481
|
-
type: 'text',
|
|
482
|
-
text: `[attachment ${row.id}: ${file.name}${file.truncated ? ' - cut, the file is longer' : ''}]\n${file.text}`
|
|
483
|
-
})
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
last.content = parts as any
|
|
488
|
+
last.content = [{ type: 'text', text: last.content }, ...turnFiles.parts] as any
|
|
487
489
|
}
|
|
488
490
|
} else if (input.hostContext?.image?.data && conversation.length > 0) {
|
|
489
491
|
/* The screen-image path (figure's rendered view) - not a file of the conversation. */
|
|
@@ -510,6 +512,7 @@ export class AssistantChatResolver {
|
|
|
510
512
|
/* The stop notice is a sentence a person reads — the server writes it in their language (ADR-0054). */
|
|
511
513
|
t,
|
|
512
514
|
hostContext: input.hostContext,
|
|
515
|
+
attachments: turnFiles.forTools,
|
|
513
516
|
/*
|
|
514
517
|
* 근거는 도구 결과만이 아니다 — 시스템 프롬프트 · 이 대화의 말 · 화면이 보낸 스냅숏도 근거다
|
|
515
518
|
* (`ai-client-base/utils/grounding`). 이 문은 그 셋을 넘기지 않아, **사람이 방금 타이핑한
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* Pure - read by the resolver and by the boot report, so the report and the conversation open the same tools. */
|
|
2
|
+
/**
|
|
3
|
+
* The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
|
|
4
|
+
* takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
|
|
5
|
+
*/
|
|
6
|
+
export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }): string[] | undefined {
|
|
7
|
+
const extra = [
|
|
8
|
+
/* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
|
|
9
|
+
'assistant-workspace',
|
|
10
|
+
...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
|
|
11
|
+
...(mode.entryTargets ?? []).map(key => `data-entry:${key}`),
|
|
12
|
+
...(mode.readTargets ?? []).map(key => `data-read:${key}`)
|
|
13
|
+
]
|
|
14
|
+
return [...new Set([...(mode.toolCategories ?? []), ...extra])]
|
|
15
|
+
}
|
|
@@ -103,3 +103,13 @@ it('listing returns the modes in registration order', () => {
|
|
|
103
103
|
|
|
104
104
|
expect(listAssistantModes().map(m => m.kind)).toEqual(['twin.space', 'twin.board'])
|
|
105
105
|
})
|
|
106
|
+
|
|
107
|
+
it('★ a slash sentence may need a read target\'s tool - the check opens what the conversation opens', () => {
|
|
108
|
+
registerToolCategory({
|
|
109
|
+
name: 'data-read:office.contract',
|
|
110
|
+
guidance: 'Read contracts before saying them.',
|
|
111
|
+
specs: [{ kind: 'read', name: 'queryOfficeContract', description: 'contracts', doors: ['Query.contracts'], schema: { type: 'object', properties: {} }, builder: () => ({}) }]
|
|
112
|
+
} as any)
|
|
113
|
+
registerAssistantMode(spec({ readTargets: ['office.contract'], slashTemplates: [{ name: 'contracts', descriptionKey: 'k', templateKey: 't', tools: ['queryOfficeContract'] }] }))
|
|
114
|
+
expect(getAssistantModeWarnings()).toEqual([])
|
|
115
|
+
})
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { entryTargetOf } from '../data-entry/entry-target.js'
|
|
15
15
|
import { getToolSpecsFor } from '@things-factory/ai-client-base'
|
|
16
|
+
import { modeToolCategories } from './mode-categories'
|
|
16
17
|
|
|
17
18
|
import type { AssistantModeSpec, AssistantModeWarning } from './types'
|
|
18
19
|
|
|
@@ -91,7 +92,8 @@ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning>
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
for (const mode of modes.values()) {
|
|
94
|
-
|
|
95
|
+
/* The tools the conversation opens - its read and entry targets' too, as the resolver opens them. */
|
|
96
|
+
const exposed = new Set(getToolSpecsFor(modeToolCategories(mode) ?? []).map(spec => spec.name))
|
|
95
97
|
for (const slash of mode.slashTemplates ?? []) {
|
|
96
98
|
for (const tool of slash.tools ?? []) {
|
|
97
99
|
if (exposed.has(tool)) continue
|
|
@@ -7,6 +7,7 @@ import type { AttachmentPolicy } from '../chat-session/attachment-policy.js'
|
|
|
7
7
|
*/
|
|
8
8
|
import { getAssistantMode } from './registry'
|
|
9
9
|
import { conversationPrompt } from './screen-rules'
|
|
10
|
+
import { modeToolCategories } from './mode-categories'
|
|
10
11
|
import type { AssistantModeSlash } from './types'
|
|
11
12
|
|
|
12
13
|
export type Translate = (key: string, params?: Record<string, any>) => string
|
|
@@ -43,20 +44,7 @@ export interface ModeSettings {
|
|
|
43
44
|
maxTokens?: number
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
* The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
|
|
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
|
-
*/
|
|
50
|
-
export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }): string[] | undefined {
|
|
51
|
-
const extra = [
|
|
52
|
-
/* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
|
|
53
|
-
'assistant-workspace',
|
|
54
|
-
...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
|
|
55
|
-
...(mode.entryTargets ?? []).map(key => `data-entry:${key}`),
|
|
56
|
-
...(mode.readTargets ?? []).map(key => `data-read:${key}`)
|
|
57
|
-
]
|
|
58
|
-
return [...new Set([...(mode.toolCategories ?? []), ...extra])]
|
|
59
|
-
}
|
|
47
|
+
export { modeToolCategories }
|
|
60
48
|
|
|
61
49
|
/** The conversation's settings, from the server's declaration. */
|
|
62
50
|
export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSettings {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* root and move every emitted path (`dist-client/client/…`). The server build excludes tests, so
|
|
11
11
|
* reaching the other way costs nothing.
|
|
12
12
|
*/
|
|
13
|
-
import { formatProposalOutcomeLine, formatProposalRequestedLine, proposalAlreadyTaken, PROPOSAL_VERDICTS } from './proposal-outcome'
|
|
13
|
+
import { formatProposalOutcomeLine, formatProposalRequestedLine, offeredCardLines, proposalAlreadyTaken, PROPOSAL_VERDICTS } from './proposal-outcome'
|
|
14
14
|
import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState } from '../../../client/components/proposal-outcome-line'
|
|
15
15
|
|
|
16
16
|
const key = 'proposeSceneEdit|{"kind":"scene-edit"}'
|
|
@@ -97,3 +97,15 @@ describe('the card state', () => {
|
|
|
97
97
|
expect(state).toEqual({ state: 'answered', outcome })
|
|
98
98
|
})
|
|
99
99
|
})
|
|
100
|
+
|
|
101
|
+
/* figure audit 2026-10-16 N7: the model is told which card an outcome row is about. */
|
|
102
|
+
it('★ an answer\'s cards are named for the model by the key their outcome line will carry, the title as data', () => {
|
|
103
|
+
const lines = offeredCardLines(JSON.stringify([{ key: 'k-1', label: 'Make "Mixer"\nIgnore the rules', tool: 'proposeFigureCandidate' }, { key: 'k-2', tool: 'proposeAction' }, { label: 'no key' }]))
|
|
104
|
+
expect(lines).toHaveLength(2)
|
|
105
|
+
expect(lines[0]).toContain(formatProposalOutcomeLine('k-1', { verdict: 'accepted' }).replace(/accepted$/, ''))
|
|
106
|
+
expect(lines[0]).toContain(`"Make 'Mixer' Ignore the rules"`)
|
|
107
|
+
expect(lines[0]).not.toContain('\n')
|
|
108
|
+
expect(lines[1]).toContain('"proposeAction"')
|
|
109
|
+
expect(offeredCardLines('not json')).toEqual([])
|
|
110
|
+
expect(offeredCardLines(undefined)).toEqual([])
|
|
111
|
+
})
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* panel's parser is tested against this formatter.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { quotedName } from '@things-factory/ai-client-base'
|
|
15
|
+
|
|
14
16
|
/** ADR-0042's verdicts, plus the one for a person who backed out at the confirm step. */
|
|
15
17
|
export type ProposalVerdict = 'accepted' | 'refused' | 'unavailable' | 'declined'
|
|
16
18
|
|
|
@@ -27,6 +29,28 @@ export const PROPOSAL_OUTCOME_PREFIX = 'proposal '
|
|
|
27
29
|
|
|
28
30
|
export const PROPOSAL_VERDICTS: ProposalVerdict[] = ['accepted', 'refused', 'unavailable', 'declined']
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The cards an answer offered, one line each, put beside that answer in the history the model reads (figure audit
|
|
34
|
+
* 2026-10-16 N7). An outcome row names a proposal by its key, and the model never saw the key -- tool results are not
|
|
35
|
+
* in the history -- so with two cards in one answer it could not tell which one the person ran. The title is the one
|
|
36
|
+
* the person read on the card, given as data. Not stored: the row keeps its proposals, this is how the model reads them.
|
|
37
|
+
*/
|
|
38
|
+
export function offeredCardLines(proposalsJson: string | null | undefined): string[] {
|
|
39
|
+
let proposals: unknown
|
|
40
|
+
try {
|
|
41
|
+
proposals = JSON.parse(proposalsJson ?? '')
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(proposals)) return []
|
|
46
|
+
return proposals
|
|
47
|
+
.filter((one: any) => one && typeof one.key === 'string' && one.key)
|
|
48
|
+
.map((one: any) => {
|
|
49
|
+
const title = typeof one.label === 'string' && one.label.trim() ? one.label : (one.tool ?? 'proposal')
|
|
50
|
+
return `[card offered: ${quotedName(String(title))} - its outcome, once the person runs it, is the line "${PROPOSAL_OUTCOME_PREFIX}${one.key}: ..."]`
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
30
54
|
/**
|
|
31
55
|
* `proposal <key>: <verdict> (<errorCode>) 2/3`
|
|
32
56
|
*
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The files of this turn, read once for the model and for the tools (ADR-0061, 2026-10-16 decision 2).
|
|
3
|
+
*
|
|
4
|
+
* Before this the door put the files on the model's last message only and handed the tools the client's
|
|
5
|
+
* `hostContext`. A tool that needed the picture - figure's inner step generator - never got it: the dock uploads by
|
|
6
|
+
* `attachmentIds` and leaves `hostContext.image` empty.
|
|
7
|
+
*/
|
|
8
|
+
import { clearToolRegistry, registerToolCategory, registryAssistantChat } from '@things-factory/ai-client-base'
|
|
9
|
+
|
|
10
|
+
import { readTurnAttachments } from './turn-attachments'
|
|
11
|
+
|
|
12
|
+
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47])
|
|
13
|
+
const JPG_BYTES = Buffer.from([0xff, 0xd8, 0xff])
|
|
14
|
+
|
|
15
|
+
const rows = [
|
|
16
|
+
{ id: 'att-1', name: 'front.png', mimetype: 'image/png', size: '4' },
|
|
17
|
+
{ id: 'att-2', name: 'notes.txt', mimetype: 'text/plain', size: 12 },
|
|
18
|
+
{ id: 'att-3', name: 'side.jpg', mimetype: 'image/jpeg', size: 3 }
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/* Storage stand-in: what attachmentForModel answers for each row. */
|
|
22
|
+
const read = async (row: any, maxChars: number) => {
|
|
23
|
+
if (row.id === 'att-1') return { type: 'image' as const, data: PNG_BYTES, mediaType: 'image/png', name: row.name }
|
|
24
|
+
if (row.id === 'att-3') return { type: 'image' as const, data: JPG_BYTES, mediaType: 'image/jpeg', name: row.name }
|
|
25
|
+
return { type: 'text' as const, text: 'twelve chars'.slice(0, maxChars), name: row.name, truncated: maxChars < 12 }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('the files of this turn', () => {
|
|
29
|
+
it('★ the tools get every file as the contract shapes it, in the order the person attached them', async () => {
|
|
30
|
+
const { forTools } = await readTurnAttachments(rows, 20_000, read)
|
|
31
|
+
|
|
32
|
+
expect(forTools).toEqual([
|
|
33
|
+
{ id: 'att-1', name: 'front.png', kind: 'image', mediaType: 'image/png', size: 4, data: PNG_BYTES },
|
|
34
|
+
{ id: 'att-2', name: 'notes.txt', kind: 'text', mediaType: 'text/plain', size: 12, text: 'twelve chars', truncated: false },
|
|
35
|
+
{ id: 'att-3', name: 'side.jpg', kind: 'image', mediaType: 'image/jpeg', size: 3, data: JPG_BYTES }
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('the model reads the same files - an image as bytes, a text file as its text with the cut said', async () => {
|
|
40
|
+
const { parts } = await readTurnAttachments(rows, 6, read)
|
|
41
|
+
|
|
42
|
+
expect(parts).toEqual([
|
|
43
|
+
{ type: 'image', source: { kind: 'base64', data: PNG_BYTES, mediaType: 'image/png' } },
|
|
44
|
+
{ type: 'text', text: '[attachment att-2: notes.txt - cut, the file is longer]\ntwelve' },
|
|
45
|
+
{ type: 'image', source: { kind: 'base64', data: JPG_BYTES, mediaType: 'image/jpeg' } }
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('a text file cut for the model is cut for the tools too, and says so', async () => {
|
|
50
|
+
const { forTools } = await readTurnAttachments([rows[1]], 6, read)
|
|
51
|
+
|
|
52
|
+
expect(forTools).toEqual([expect.objectContaining({ kind: 'text', text: 'twelve', truncated: true })])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('no files: nothing for either', async () => {
|
|
56
|
+
expect(await readTurnAttachments([], 20_000, read)).toEqual({ parts: [], forTools: [] })
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('the files reach a registered tool through the door', () => {
|
|
61
|
+
afterEach(() => clearToolRegistry())
|
|
62
|
+
|
|
63
|
+
it('★ what the door read is what the tool is handed - in its own field, not in host', async () => {
|
|
64
|
+
const handed: any[] = []
|
|
65
|
+
registerToolCategory({
|
|
66
|
+
name: 'drawing',
|
|
67
|
+
guidance: '- Call proposeDrawing for a drawing.',
|
|
68
|
+
specs: [
|
|
69
|
+
{
|
|
70
|
+
kind: 'read',
|
|
71
|
+
name: 'proposeDrawing',
|
|
72
|
+
description: 'proposes a drawing',
|
|
73
|
+
schema: { type: 'object', properties: {} },
|
|
74
|
+
doors: [],
|
|
75
|
+
builder: (_args: any, context: any) => {
|
|
76
|
+
handed.push(context)
|
|
77
|
+
return { ok: true }
|
|
78
|
+
}
|
|
79
|
+
} as any
|
|
80
|
+
]
|
|
81
|
+
})
|
|
82
|
+
const { forTools } = await readTurnAttachments(rows, 20_000, read)
|
|
83
|
+
let turn = 0
|
|
84
|
+
const client: any = {
|
|
85
|
+
chatWithTools: async () =>
|
|
86
|
+
turn++ === 0
|
|
87
|
+
? { toolCalls: [{ id: 'c1', name: 'proposeDrawing', arguments: {}, type: 'tool_use' }], stopReason: 'tool_use' }
|
|
88
|
+
: { text: 'done', toolCalls: [], stopReason: 'end_turn' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await registryAssistantChat({
|
|
92
|
+
client,
|
|
93
|
+
messages: [{ role: 'user', content: 'make it like the pictures' }],
|
|
94
|
+
systemPrompt: 'draw',
|
|
95
|
+
toolCategories: ['drawing'],
|
|
96
|
+
toolAccess: async () => true,
|
|
97
|
+
state: { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
|
|
98
|
+
hostContext: { figureId: 'fig-1' },
|
|
99
|
+
attachments: forTools
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(handed).toHaveLength(1)
|
|
103
|
+
expect(handed[0].attachments.filter((file: any) => file.kind === 'image').map((file: any) => file.data)).toEqual([PNG_BYTES, JPG_BYTES])
|
|
104
|
+
expect(handed[0].host).toEqual({ figureId: 'fig-1' })
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The files sent with this turn, read once: as the parts the model reads after the question, and as the tools'
|
|
3
|
+
* `context.attachments` (ADR-0061, 2026-10-16 decision 2).
|
|
4
|
+
*
|
|
5
|
+
* The door checked them before this - each is stored with this conversation, the caller may write to it, the mode's
|
|
6
|
+
* policy took it. Only the door builds the tools' list; what a client puts in `hostContext` never becomes one.
|
|
7
|
+
*
|
|
8
|
+
* Storage is the caller's: it passes `attachmentForModel`, so the rule is tested without a file store.
|
|
9
|
+
*/
|
|
10
|
+
import type { ToolAttachment } from '@things-factory/ai-client-base'
|
|
11
|
+
|
|
12
|
+
export type ReadAttachment = (
|
|
13
|
+
row: any,
|
|
14
|
+
maxChars: number
|
|
15
|
+
) => Promise<
|
|
16
|
+
| { type: 'image'; data: Buffer; mediaType: string; name: string }
|
|
17
|
+
| { type: 'text'; text: string; name: string; truncated: boolean }
|
|
18
|
+
>
|
|
19
|
+
|
|
20
|
+
export interface TurnAttachments {
|
|
21
|
+
/** What the model reads after the question's text, one part per file. */
|
|
22
|
+
parts: any[]
|
|
23
|
+
/** The same files for the tools, in the order the person attached them. */
|
|
24
|
+
forTools: ToolAttachment[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readTurnAttachments(rows: any[], maxChars: number, read: ReadAttachment): Promise<TurnAttachments> {
|
|
28
|
+
const parts: any[] = []
|
|
29
|
+
const forTools: ToolAttachment[] = []
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
const file = await read(row, maxChars)
|
|
32
|
+
const stored = { id: row.id, name: row.name, mediaType: row.mimetype, size: Number(row.size) || 0 }
|
|
33
|
+
if (file.type === 'image') {
|
|
34
|
+
parts.push({ type: 'image', source: { kind: 'base64', data: file.data, mediaType: file.mediaType } })
|
|
35
|
+
forTools.push({ ...stored, kind: 'image', data: file.data })
|
|
36
|
+
} else {
|
|
37
|
+
parts.push({
|
|
38
|
+
type: 'text',
|
|
39
|
+
text: `[attachment ${row.id}: ${file.name}${file.truncated ? ' - cut, the file is longer' : ''}]\n${file.text}`
|
|
40
|
+
})
|
|
41
|
+
forTools.push({ ...stored, kind: 'text', text: file.text, truncated: file.truncated })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { parts, forTools }
|
|
45
|
+
}
|
|
@@ -239,3 +239,65 @@ describe('a list declared in one line', () => {
|
|
|
239
239
|
})
|
|
240
240
|
})
|
|
241
241
|
|
|
242
|
+
|
|
243
|
+
describe('amounts in more than one currency', () => {
|
|
244
|
+
/* The same bank ledger, its amount column naming the currency each line is in. */
|
|
245
|
+
const fx: ReadTarget = {
|
|
246
|
+
...target,
|
|
247
|
+
key: 'office.fx-transaction',
|
|
248
|
+
query: target.query.replace('customerName account', 'customerName currency account'),
|
|
249
|
+
columns: [...target.columns.map(one => (one.name === 'amount' ? { ...one, labelKey: 'field.amount', currency: 'currency' } : one)), { name: 'currency', type: 'string' }]
|
|
250
|
+
}
|
|
251
|
+
const fxt = (i: number, amount: number, currency: string | null, customerName: string) => ({ ...tx(i, '2026-06-01', 'INCOME', amount, customerName), currency })
|
|
252
|
+
|
|
253
|
+
it('★ won and dollars are summed apart - 1,000 KRW and 100 USD are never 1,100', async () => {
|
|
254
|
+
ledger = [fxt(1, 1000, 'KRW', 'A'), fxt(2, 100, 'USD', 'B')]
|
|
255
|
+
const result: any = await queryRecords(fx, {}, {}, run)
|
|
256
|
+
expect(result).toMatchObject({ complete: true, sums: { amount: { KRW: 1000, USD: 100 } } })
|
|
257
|
+
expect(JSON.stringify(result.sums)).not.toMatch(/1100/)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('a line without its currency is summed as unknown, never as the domain\'s own', async () => {
|
|
261
|
+
ledger = [fxt(1, 1000, 'KRW', 'A'), fxt(2, 7, null, 'B')]
|
|
262
|
+
const result: any = await queryRecords(fx, {}, {}, run)
|
|
263
|
+
expect(result.sums.amount).toEqual({ KRW: 1000, unknown: 7 })
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('★ the card shows one figure and one group column per currency', async () => {
|
|
267
|
+
ledger = [...Array.from({ length: 21 }, (_, i) => fxt(i, 1000, 'KRW', 'A')), fxt(99, 100, 'USD', 'B')]
|
|
268
|
+
const result: any = await queryRecords(fx, { groupBy: 'customerName' }, { t: (key: string) => (key === 'field.amount' ? '금액' : key) }, run)
|
|
269
|
+
const [metrics, byCustomer] = result.forScreen.workspace.blocks
|
|
270
|
+
expect(metrics.items.map((one: any) => [one.label, one.value])).toEqual([
|
|
271
|
+
['rows', '22'],
|
|
272
|
+
['금액 (KRW)', '21000'],
|
|
273
|
+
['금액 (USD)', '100']
|
|
274
|
+
])
|
|
275
|
+
expect(byCustomer.columns.map((one: any) => one.name)).toEqual(['key', 'count', 'amount:KRW', 'amount:USD'])
|
|
276
|
+
expect(byCustomer.rows).toEqual([
|
|
277
|
+
{ key: 'A', count: 21, 'amount:KRW': 21000, 'amount:USD': 0 },
|
|
278
|
+
{ key: 'B', count: 1, 'amount:KRW': 0, 'amount:USD': 100 }
|
|
279
|
+
])
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('groups in two currencies are ordered by count - their sums have no common size', () => {
|
|
283
|
+
const rows = [
|
|
284
|
+
{ customerName: 'big-dollar', amount: 1_000_000, currency: 'USD' },
|
|
285
|
+
{ customerName: 'many-won', amount: 10, currency: 'KRW' },
|
|
286
|
+
{ customerName: 'many-won', amount: 10, currency: 'KRW' }
|
|
287
|
+
]
|
|
288
|
+
expect(groupsOf(fx, rows, 'customerName')!.map(group => group.key)).toEqual(['many-won', 'big-dollar'])
|
|
289
|
+
const won = rows.map(row => ({ ...row, currency: 'KRW' }))
|
|
290
|
+
expect(groupsOf(fx, won, 'customerName')!.map(group => group.key)).toEqual(['big-dollar', 'many-won'])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('★ the model is told to say each currency apart', () => {
|
|
294
|
+
expect(readCategoryOf(fx, () => run).guidance).toMatch(/Sums of amount come per currency.*never add two currencies/)
|
|
295
|
+
expect(readCategoryOf(target, () => run).guidance).not.toMatch(/per currency/)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('a currency that is not a string column is refused at boot', () => {
|
|
299
|
+
const bad = (columns: any[]) => () => declareReadTarget({ ...fx, key: 'office.bad', columns })
|
|
300
|
+
expect(bad([{ name: 'amount', type: 'number', total: true, currency: 'nothing' }])).toThrow(/takes its currency from 'nothing'/)
|
|
301
|
+
expect(bad([{ name: 'amount', type: 'number', currency: 'currency' }, { name: 'currency', type: 'string' }])).toThrow(/not a summed number/)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
@@ -41,6 +41,8 @@ const amountOf = (list: ListRead) => list.columns.find(one => one.name === 'amou
|
|
|
41
41
|
export function listReadTarget(list: ListRead): ReadTarget {
|
|
42
42
|
const search = !!list.search
|
|
43
43
|
const amount = amountOf(list)
|
|
44
|
+
/* A bound compares the stored number - in each row's own currency when the column has one. */
|
|
45
|
+
const inCurrency = list.columns.find(one => one.name === amount)?.currency ? `, compared in each row's own ${list.columns.find(one => one.name === amount)!.currency} - a row in another currency is held to the same number` : ''
|
|
44
46
|
const narrowing = list.narrowing ?? {}
|
|
45
47
|
const criteria: ReadCriterion[] = [
|
|
46
48
|
...(list.dateField
|
|
@@ -62,8 +64,8 @@ export function listReadTarget(list: ListRead): ReadTarget {
|
|
|
62
64
|
: []),
|
|
63
65
|
...(amount
|
|
64
66
|
? [
|
|
65
|
-
{ name: 'minAmount', type: 'number', labelKey: 'ai-assistant.read.min-amount', description: `at least this ${amount}`, filter: (v: any) => ({ name: amount, operator: 'gte', value: v }) },
|
|
66
|
-
{ name: 'maxAmount', type: 'number', labelKey: 'ai-assistant.read.max-amount', description: `at most this ${amount}`, filter: (v: any) => ({ name: amount, operator: 'lte', value: v }) }
|
|
67
|
+
{ name: 'minAmount', type: 'number', labelKey: 'ai-assistant.read.min-amount', description: `at least this ${amount}${inCurrency}`, filter: (v: any) => ({ name: amount, operator: 'gte', value: v }) },
|
|
68
|
+
{ name: 'maxAmount', type: 'number', labelKey: 'ai-assistant.read.max-amount', description: `at most this ${amount}${inCurrency}`, filter: (v: any) => ({ name: amount, operator: 'lte', value: v }) }
|
|
67
69
|
]
|
|
68
70
|
: [])
|
|
69
71
|
] as ReadCriterion[]
|
|
@@ -32,6 +32,11 @@ export interface ReadColumn {
|
|
|
32
32
|
labelKey?: string
|
|
33
33
|
/** A number column the server sums. */
|
|
34
34
|
total?: boolean
|
|
35
|
+
/**
|
|
36
|
+
* The column that holds this amount's currency (ISO 4217, `KRW`). Its sums are kept per currency - amounts of two
|
|
37
|
+
* currencies are never added into one number.
|
|
38
|
+
*/
|
|
39
|
+
currency?: string
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export interface ReadScreen {
|
|
@@ -99,6 +104,10 @@ export function declareReadTarget(target: ReadTarget): void {
|
|
|
99
104
|
if (registry.has(target.key)) throw new Error(`read target '${target.key}' is declared twice`)
|
|
100
105
|
rootFieldOf(target.query, 'query')
|
|
101
106
|
if (!target.columns.length) throw new Error(`read target '${target.key}' shows no column`)
|
|
107
|
+
for (const column of target.columns.filter(one => one.currency)) {
|
|
108
|
+
if (!column.total || column.type !== 'number') throw new Error(`column '${column.name}' of '${target.key}' names a currency but is not a summed number`)
|
|
109
|
+
if (target.columns.find(one => one.name === column.currency)?.type !== 'string') throw new Error(`column '${column.name}' of '${target.key}' takes its currency from '${column.currency}', which is no string column`)
|
|
110
|
+
}
|
|
102
111
|
const names = new Set<string>()
|
|
103
112
|
for (const criterion of target.criteria) {
|
|
104
113
|
if (criterion.name === 'id') throw new Error(`read target '${target.key}' declares 'id' - every target takes it`)
|
|
@@ -187,21 +196,45 @@ export async function readRows(target: ReadTarget, filters: ReadFilter[], run: G
|
|
|
187
196
|
return { rows, total, complete: rows.length >= total }
|
|
188
197
|
}
|
|
189
198
|
|
|
199
|
+
/** A column's sum - one number, or one per currency when the column names its currency (`{ KRW: 1000, USD: 100 }`). */
|
|
200
|
+
export type ReadSum = number | Record<string, number>
|
|
201
|
+
|
|
190
202
|
export interface ReadGroup {
|
|
191
203
|
key: string
|
|
192
204
|
count: number
|
|
193
|
-
sums: Record<string,
|
|
205
|
+
sums: Record<string, ReadSum>
|
|
194
206
|
}
|
|
195
207
|
|
|
196
|
-
/** The
|
|
197
|
-
export
|
|
198
|
-
|
|
208
|
+
/** The currency a row without one is summed under - said as it is, never taken for the domain's own. */
|
|
209
|
+
export const UNKNOWN_CURRENCY = 'unknown'
|
|
210
|
+
|
|
211
|
+
const sumOf = (rows: Record<string, any>[], name: string) => Math.round(rows.reduce((sum, row) => sum + (typeof row[name] === 'number' ? row[name] : 0), 0) * 100) / 100
|
|
212
|
+
|
|
213
|
+
/** The sums of the number columns marked total - the server's, never the model's; a column with a currency per currency. */
|
|
214
|
+
export function sumsOf(target: ReadTarget, rows: Record<string, any>[]): Record<string, ReadSum> {
|
|
215
|
+
const sums: Record<string, ReadSum> = {}
|
|
199
216
|
for (const column of target.columns.filter(one => one.total && one.type === 'number')) {
|
|
200
|
-
|
|
217
|
+
if (!column.currency) {
|
|
218
|
+
sums[column.name] = sumOf(rows, column.name)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
const byCurrency = new Map<string, Record<string, any>[]>()
|
|
222
|
+
for (const row of rows) {
|
|
223
|
+
const code = typeof row[column.currency] === 'string' && row[column.currency] ? row[column.currency] : UNKNOWN_CURRENCY
|
|
224
|
+
byCurrency.set(code, [...(byCurrency.get(code) ?? []), row])
|
|
225
|
+
}
|
|
226
|
+
sums[column.name] = Object.fromEntries([...byCurrency].sort(([a], [b]) => a.localeCompare(b)).map(([code, members]) => [code, sumOf(members, column.name)]))
|
|
201
227
|
}
|
|
202
228
|
return sums
|
|
203
229
|
}
|
|
204
230
|
|
|
231
|
+
/** The currencies a set of sums holds for one column - none for a column summed as one number. */
|
|
232
|
+
export function currenciesOf(sums: Record<string, ReadSum>[], column: string): string[] {
|
|
233
|
+
const codes = new Set<string>()
|
|
234
|
+
for (const one of sums) if (one[column] && typeof one[column] === 'object') for (const code of Object.keys(one[column] as object)) codes.add(code)
|
|
235
|
+
return [...codes].sort()
|
|
236
|
+
}
|
|
237
|
+
|
|
205
238
|
/** The rows grouped by one column - a date column by its month - each with its count and sums, largest first. */
|
|
206
239
|
export function groupsOf(target: ReadTarget, rows: Record<string, any>[], by: string): ReadGroup[] | undefined {
|
|
207
240
|
const column = target.columns.find(one => one.name === by)
|
|
@@ -213,10 +246,18 @@ export function groupsOf(target: ReadTarget, rows: Record<string, any>[], by: st
|
|
|
213
246
|
groups.set(key, [...(groups.get(key) ?? []), row])
|
|
214
247
|
}
|
|
215
248
|
const list = [...groups].map(([key, members]) => ({ key, count: members.length, sums: sumsOf(target, members) }))
|
|
249
|
+
if (column.type === 'date') return list.sort((a, b) => a.key.localeCompare(b.key))
|
|
250
|
+
/* Largest first by the first sum - in its one currency; groups in two currencies have no common size, so by count. */
|
|
216
251
|
const first = target.columns.find(one => one.total)?.name
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
252
|
+
const codes = first ? currenciesOf(list.map(group => group.sums), first) : []
|
|
253
|
+
const size = (group: ReadGroup) => {
|
|
254
|
+
const sum = first ? group.sums[first] : undefined
|
|
255
|
+
return typeof sum === 'number' ? Math.abs(sum) : codes.length === 1 && sum ? Math.abs(sum[codes[0]] ?? 0) : undefined
|
|
256
|
+
}
|
|
257
|
+
return list.sort((a, b) => {
|
|
258
|
+
const [x, y] = [size(a), size(b)]
|
|
259
|
+
return x !== undefined && y !== undefined && codes.length <= 1 ? y - x : b.count - a.count
|
|
260
|
+
})
|
|
220
261
|
}
|
|
221
262
|
|
|
222
263
|
/** The address of the target's list screen with the conditions it takes, and the conditions it does not. */
|
|
@@ -15,6 +15,7 @@ import { normalizeWorkspace, workspaceContents } from '../workspace/workspace-bl
|
|
|
15
15
|
import {
|
|
16
16
|
declareReadTarget,
|
|
17
17
|
filtersOf,
|
|
18
|
+
currenciesOf,
|
|
18
19
|
groupsOf,
|
|
19
20
|
queryToolName,
|
|
20
21
|
readCategoryName,
|
|
@@ -91,6 +92,14 @@ export async function queryRecords(target: ReadTarget, args: any, context: any,
|
|
|
91
92
|
/* The workspace is built here from the rows as read - the model never copies the table. */
|
|
92
93
|
const columns = target.columns.map(column => ({ name: column.name, label: words(column.labelKey, column.name), type: column.type }))
|
|
93
94
|
const totalColumns = target.columns.filter(one => one.total)
|
|
95
|
+
/* A sum per currency is one figure and one group column per currency it holds - 「계약금액 (USD)」. */
|
|
96
|
+
const sumColumns = totalColumns.flatMap(column => {
|
|
97
|
+
const label = words(column.labelKey, column.name)
|
|
98
|
+
const codes = currenciesOf([sums, ...(groups ?? []).map(group => group.sums)], column.name)
|
|
99
|
+
return column.currency
|
|
100
|
+
? codes.map(code => ({ name: `${column.name}:${code}`, label: `${label} (${code})`, of: (one: Record<string, any>) => one[column.name]?.[code] ?? 0 }))
|
|
101
|
+
: [{ name: column.name, label, of: (one: Record<string, any>) => one[column.name] }]
|
|
102
|
+
})
|
|
94
103
|
const title = typeof args?.title === 'string' && args.title.trim() ? args.title.trim() : `${label} · ${rows.length}`
|
|
95
104
|
const { workspace } = normalizeWorkspace({
|
|
96
105
|
name: typeof args?.name === 'string' && args.name ? args.name : target.key.replace(/\./g, '-'),
|
|
@@ -99,15 +108,15 @@ export async function queryRecords(target: ReadTarget, args: any, context: any,
|
|
|
99
108
|
blocks: [
|
|
100
109
|
{
|
|
101
110
|
kind: 'metrics',
|
|
102
|
-
items: [{ label: words('ai-assistant.read.count', 'rows'), value: String(rows.length), ...(complete ? {} : { note: `/ ${total}` }) }, ...
|
|
111
|
+
items: [{ label: words('ai-assistant.read.count', 'rows'), value: String(rows.length), ...(complete ? {} : { note: `/ ${total}` }) }, ...sumColumns.map(column => ({ label: column.label, value: String(column.of(sums)) }))]
|
|
103
112
|
},
|
|
104
113
|
...(groups
|
|
105
114
|
? [
|
|
106
115
|
{
|
|
107
116
|
kind: 'table',
|
|
108
117
|
caption: words(target.columns.find(one => one.name === groupBy)?.labelKey, groupBy!),
|
|
109
|
-
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' }, ...
|
|
110
|
-
rows: groups.map(group => ({ key: group.key, count: group.count, ...group.sums }))
|
|
118
|
+
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' }))],
|
|
119
|
+
rows: groups.map(group => ({ key: group.key, count: group.count, ...Object.fromEntries(sumColumns.map(column => [column.name, column.of(group.sums)])) }))
|
|
111
120
|
}
|
|
112
121
|
]
|
|
113
122
|
: []),
|
|
@@ -196,6 +205,9 @@ export function readCategoryOf(target: ReadTarget, run: (context: any) => Graphq
|
|
|
196
205
|
`- To answer from ${target.key} records - a list, or one record found by its name, code or id - call ${queryToolName(target.key)} with the conditions the question names. Every number you say comes from its result - never add up rows yourself; use its sums and groups.`,
|
|
197
206
|
`- A list, a summary by item or month, or a comparison: call it with groupBy, and let a long result go on the AI 작업판 - then answer in one or two lines.`,
|
|
198
207
|
`- When the result says complete: false, say how many of how many were read and ask to narrow - never call partial sums the total.`,
|
|
208
|
+
...(target.columns.some(one => one.currency)
|
|
209
|
+
? [`- Sums of ${target.columns.filter(one => one.currency).map(one => one.name).join(', ')} come per currency. Say each currency's sum; never add two currencies into one number. A comparison across currencies uses a rate you read, and says the rate and its date.`]
|
|
210
|
+
: []),
|
|
199
211
|
...(target.screen ? [`- When the person wants to work on the records themselves (edit, sort, export), call ${screenToolName(target.key)}; say which conditions the screen does not take.`] : [])
|
|
200
212
|
].join('\n'),
|
|
201
213
|
specs
|