plasalid 0.5.7 → 0.6.0
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/README.md +9 -9
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +8 -9
- package/dist/ai/agent.js +21 -20
- package/dist/ai/errors.d.ts +16 -0
- package/dist/ai/errors.js +47 -0
- package/dist/ai/personas.d.ts +1 -1
- package/dist/ai/personas.js +69 -66
- package/dist/ai/prompt-sections.d.ts +4 -5
- package/dist/ai/prompt-sections.js +11 -11
- package/dist/ai/providers/anthropic.js +10 -4
- package/dist/ai/providers/openai.js +70 -56
- package/dist/ai/redactor.js +77 -51
- package/dist/ai/system-prompt.d.ts +2 -3
- package/dist/ai/system-prompt.js +5 -5
- package/dist/ai/tools/common.js +13 -5
- package/dist/ai/tools/index.js +15 -15
- package/dist/ai/tools/ingest.d.ts +2 -2
- package/dist/ai/tools/ingest.js +210 -87
- package/dist/ai/tools/merchants.js +27 -12
- package/dist/ai/tools/read.js +36 -20
- package/dist/ai/tools/record.js +79 -19
- package/dist/ai/tools/resolve.d.ts +2 -0
- package/dist/ai/tools/resolve.js +195 -0
- package/dist/ai/tools/types.d.ts +5 -7
- package/dist/cli/commands/accounts.js +2 -2
- package/dist/cli/commands/record.js +4 -2
- package/dist/cli/commands/resolve.d.ts +2 -0
- package/dist/cli/commands/resolve.js +13 -0
- package/dist/cli/commands/scan.js +18 -22
- package/dist/cli/commands/status.js +4 -2
- package/dist/cli/index.js +9 -9
- package/dist/cli/ink/hooks/useFooterText.js +1 -1
- package/dist/cli/ink/hooks/useTextInput.js +60 -69
- package/dist/cli/ink/scan_dashboard.d.ts +2 -2
- package/dist/cli/ink/scan_dashboard.js +3 -3
- package/dist/cli/setup.js +6 -3
- package/dist/cli/ux.js +1 -1
- package/dist/db/queries/account-balance.d.ts +140 -0
- package/dist/db/queries/account-balance.js +355 -0
- package/dist/db/queries/account_balance.d.ts +0 -1
- package/dist/db/queries/account_balance.js +0 -10
- package/dist/db/queries/action-log.d.ts +29 -0
- package/dist/db/queries/action-log.js +27 -0
- package/dist/db/queries/action_log.d.ts +1 -1
- package/dist/db/queries/concerns.d.ts +10 -0
- package/dist/db/queries/concerns.js +21 -0
- package/dist/db/queries/transactions.d.ts +3 -22
- package/dist/db/queries/transactions.js +4 -5
- package/dist/db/queries/unknowns.d.ts +62 -0
- package/dist/db/queries/unknowns.js +114 -0
- package/dist/db/schema.js +3 -3
- package/dist/resolver/pipeline.d.ts +16 -0
- package/dist/resolver/pipeline.js +38 -0
- package/dist/resolver/prompts.d.ts +8 -0
- package/dist/resolver/prompts.js +26 -0
- package/dist/scanner/account-mutex.d.ts +1 -0
- package/dist/scanner/account-mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +10 -10
- package/dist/scanner/buffer.js +15 -15
- package/dist/scanner/concurrency.d.ts +10 -7
- package/dist/scanner/concurrency.js +3 -16
- package/dist/scanner/decrypt-queue.d.ts +57 -0
- package/dist/scanner/decrypt-queue.js +114 -0
- package/dist/scanner/decrypt_queue.js +56 -38
- package/dist/scanner/detectors/correlations.d.ts +2 -0
- package/dist/scanner/detectors/correlations.js +51 -0
- package/dist/scanner/detectors/duplicates.d.ts +2 -0
- package/dist/scanner/detectors/duplicates.js +75 -0
- package/dist/scanner/detectors/index.d.ts +18 -0
- package/dist/scanner/detectors/index.js +39 -0
- package/dist/scanner/detectors/recurrences.d.ts +2 -0
- package/dist/scanner/detectors/recurrences.js +49 -0
- package/dist/scanner/detectors/similar_accounts.d.ts +2 -0
- package/dist/scanner/detectors/similar_accounts.js +64 -0
- package/dist/scanner/detectors/similarities.d.ts +2 -0
- package/dist/scanner/detectors/similarities.js +73 -0
- package/dist/scanner/detectors/types.d.ts +16 -0
- package/dist/scanner/detectors/types.js +1 -0
- package/dist/scanner/inspectors/correlations.d.ts +2 -0
- package/dist/scanner/inspectors/correlations.js +47 -0
- package/dist/scanner/inspectors/duplicates.d.ts +2 -0
- package/dist/scanner/inspectors/duplicates.js +75 -0
- package/dist/scanner/inspectors/index.d.ts +19 -0
- package/dist/scanner/inspectors/index.js +39 -0
- package/dist/scanner/inspectors/recurrences.d.ts +2 -0
- package/dist/scanner/inspectors/recurrences.js +49 -0
- package/dist/scanner/inspectors/similarities.d.ts +2 -0
- package/dist/scanner/inspectors/similarities.js +73 -0
- package/dist/scanner/inspectors/types.d.ts +16 -0
- package/dist/scanner/inspectors/types.js +1 -0
- package/dist/scanner/pdf-unlock.js +3 -1
- package/dist/scanner/pipeline.d.ts +6 -4
- package/dist/scanner/pipeline.js +63 -102
- package/dist/scanner/prompts.js +2 -2
- package/package.json +2 -1
package/dist/ai/tools/ingest.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import { createAccount, updateAccountMetadata, findAccountById, } from "../../db/queries/
|
|
1
|
+
import { createAccount, updateAccountMetadata, findAccountById, } from "../../db/queries/account-balance.js";
|
|
2
2
|
import { validateTransaction, insertTransactionRows, recordTransaction, } from "../../db/queries/transactions.js";
|
|
3
|
-
import { appendAction } from "../../db/queries/
|
|
4
|
-
import {
|
|
5
|
-
import { runExclusive as runAccountExclusive } from "../../scanner/
|
|
3
|
+
import { appendAction } from "../../db/queries/action-log.js";
|
|
4
|
+
import { getUnknownTarget, recordUnknown, resolveUnknown, } from "../../db/queries/unknowns.js";
|
|
5
|
+
import { runExclusive as runAccountExclusive } from "../../scanner/account-mutex.js";
|
|
6
6
|
import { sanitizeForPrompt } from "../sanitize.js";
|
|
7
7
|
import { ACCOUNT_TYPE_DESCRIPTIONS } from "../../accounts/taxonomy.js";
|
|
8
8
|
const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
|
|
9
9
|
/**
|
|
10
10
|
* Account + transaction write primitives
|
|
11
11
|
*
|
|
12
|
-
* Shared by scan,
|
|
12
|
+
* Shared by scan, resolve, and record. Each tool branches once on
|
|
13
13
|
* `ctx.correlationId`: when set (record path), the data write and the
|
|
14
14
|
* action_log insert run inside a single transaction so the audit row is
|
|
15
|
-
* atomic with the change. Without it (scan /
|
|
15
|
+
* atomic with the change. Without it (scan / resolve), the write goes through
|
|
16
16
|
* the existing path unchanged.
|
|
17
17
|
*/
|
|
18
18
|
const ACCOUNT_DEFS = [
|
|
@@ -22,20 +22,52 @@ const ACCOUNT_DEFS = [
|
|
|
22
22
|
input_schema: {
|
|
23
23
|
type: "object",
|
|
24
24
|
properties: {
|
|
25
|
-
id: {
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
id: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Stable colon-path identifier, lowercase. e.g. 'expense:food:groceries' or 'asset:kbank-savings-1234'.",
|
|
28
|
+
},
|
|
29
|
+
name: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Human-readable name. e.g. 'Groceries' or 'KBank Savings ••1234'.",
|
|
32
|
+
},
|
|
33
|
+
type: {
|
|
34
|
+
type: "string",
|
|
35
|
+
enum: ACCOUNT_TYPES,
|
|
36
|
+
description: "Account type. Must match the parent's type.",
|
|
37
|
+
},
|
|
28
38
|
parent_id: {
|
|
29
39
|
type: ["string", "null"],
|
|
30
40
|
description: "Parent account id (the prefix before the final ':' segment). Pass null only when creating one of the five top-level type roots — and then id must equal type. Examples: id='expense:food:groceries' → parent_id='expense:food'. id='expense:food' → parent_id='expense'. id='expense' → parent_id=null.",
|
|
31
41
|
},
|
|
32
|
-
subtype: {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
subtype: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "e.g. 'bank', 'credit_card', 'salary'.",
|
|
45
|
+
},
|
|
46
|
+
bank_name: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Thai institution code from the taxonomy (e.g. KBANK, SCB, KTC).",
|
|
49
|
+
},
|
|
50
|
+
account_number_masked: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "Last 4 digits only, e.g. '••1234'.",
|
|
53
|
+
},
|
|
54
|
+
currency: {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "ISO 4217 code. Defaults to 'THB'.",
|
|
57
|
+
default: "THB",
|
|
58
|
+
},
|
|
59
|
+
due_day: {
|
|
60
|
+
type: "number",
|
|
61
|
+
description: "Credit-card due day of month (liabilities only).",
|
|
62
|
+
},
|
|
63
|
+
statement_day: {
|
|
64
|
+
type: "number",
|
|
65
|
+
description: "Statement-cut day of month.",
|
|
66
|
+
},
|
|
67
|
+
metadata: {
|
|
68
|
+
type: "object",
|
|
69
|
+
description: "Free-form extra fields (e.g. {points_program: 'KTC Forever'}).",
|
|
70
|
+
},
|
|
39
71
|
},
|
|
40
72
|
required: ["id", "name", "type", "parent_id"],
|
|
41
73
|
},
|
|
@@ -52,7 +84,10 @@ const ACCOUNT_DEFS = [
|
|
|
52
84
|
points_balance: { type: "number" },
|
|
53
85
|
account_number_masked: { type: "string" },
|
|
54
86
|
bank_name: { type: "string" },
|
|
55
|
-
metadata: {
|
|
87
|
+
metadata: {
|
|
88
|
+
type: "object",
|
|
89
|
+
description: "Merged into existing metadata_json.",
|
|
90
|
+
},
|
|
56
91
|
},
|
|
57
92
|
required: ["account_id"],
|
|
58
93
|
},
|
|
@@ -63,9 +98,18 @@ const ACCOUNT_DEFS = [
|
|
|
63
98
|
input_schema: {
|
|
64
99
|
type: "object",
|
|
65
100
|
properties: {
|
|
66
|
-
date: {
|
|
67
|
-
|
|
68
|
-
|
|
101
|
+
date: {
|
|
102
|
+
type: "string",
|
|
103
|
+
description: "ISO Gregorian date (YYYY-MM-DD).",
|
|
104
|
+
},
|
|
105
|
+
description: {
|
|
106
|
+
type: "string",
|
|
107
|
+
description: "Short human-readable description.",
|
|
108
|
+
},
|
|
109
|
+
source_page: {
|
|
110
|
+
type: "number",
|
|
111
|
+
description: "Page number in the source PDF, if known.",
|
|
112
|
+
},
|
|
69
113
|
raw_descriptor: {
|
|
70
114
|
type: "string",
|
|
71
115
|
description: "The exact statement line (the raw merchant descriptor) when posting from a PDF — preserved for alias matching and later review. Omit for manual entries and transfers.",
|
|
@@ -74,9 +118,18 @@ const ACCOUNT_DEFS = [
|
|
|
74
118
|
type: "object",
|
|
75
119
|
description: "Counter-party block. Omit for transfers between own accounts and pure metadata movements. When set during a scan, Plasalid upserts the merchant by canonical_name and (optionally) records the raw descriptor as an alias for future matches. Set default_account_id to teach the cache when categorization is confident.",
|
|
76
120
|
properties: {
|
|
77
|
-
canonical_name: {
|
|
78
|
-
|
|
79
|
-
|
|
121
|
+
canonical_name: {
|
|
122
|
+
type: "string",
|
|
123
|
+
description: "Normalized merchant name, Title Case. e.g. 'Starbucks', 'Amazon', 'Spotify'.",
|
|
124
|
+
},
|
|
125
|
+
alias: {
|
|
126
|
+
type: "string",
|
|
127
|
+
description: "The raw descriptor exactly as it appears on the statement. Plasalid normalizes and stores it so future statements skip the LLM.",
|
|
128
|
+
},
|
|
129
|
+
default_account_id: {
|
|
130
|
+
type: "string",
|
|
131
|
+
description: "Optional learned cache: 'this merchant's expense category is X'. Set when categorization is confident.",
|
|
132
|
+
},
|
|
80
133
|
},
|
|
81
134
|
required: ["canonical_name"],
|
|
82
135
|
},
|
|
@@ -90,11 +143,27 @@ const ACCOUNT_DEFS = [
|
|
|
90
143
|
items: {
|
|
91
144
|
type: "object",
|
|
92
145
|
properties: {
|
|
93
|
-
account_id: {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
account_id: {
|
|
147
|
+
type: "string",
|
|
148
|
+
description: "Existing account id from list_accounts or create_account.",
|
|
149
|
+
},
|
|
150
|
+
debit: {
|
|
151
|
+
type: "number",
|
|
152
|
+
description: "Debit amount in this posting's currency. Use 0 if this posting is a credit.",
|
|
153
|
+
},
|
|
154
|
+
credit: {
|
|
155
|
+
type: "number",
|
|
156
|
+
description: "Credit amount in this posting's currency. Use 0 if this posting is a debit.",
|
|
157
|
+
},
|
|
158
|
+
currency: {
|
|
159
|
+
type: "string",
|
|
160
|
+
description: "ISO 4217 currency code for this posting (e.g. THB, USD, EUR). Defaults to THB.",
|
|
161
|
+
default: "THB",
|
|
162
|
+
},
|
|
163
|
+
memo: {
|
|
164
|
+
type: "string",
|
|
165
|
+
description: "Optional per-posting memo.",
|
|
166
|
+
},
|
|
98
167
|
},
|
|
99
168
|
required: ["account_id"],
|
|
100
169
|
},
|
|
@@ -112,8 +181,6 @@ const ACCOUNT_LABELS = {
|
|
|
112
181
|
async function accountExecute(db, name, input, ctx) {
|
|
113
182
|
switch (name) {
|
|
114
183
|
case "create_account": {
|
|
115
|
-
if (ctx?.dryRun)
|
|
116
|
-
return `Would create account ${input.id}.`;
|
|
117
184
|
if (!ACCOUNT_TYPES.includes(input.type)) {
|
|
118
185
|
return `Invalid type "${input.type}". Allowed: ${ACCOUNT_TYPES.join(", ")}.`;
|
|
119
186
|
}
|
|
@@ -163,8 +230,6 @@ async function accountExecute(db, name, input, ctx) {
|
|
|
163
230
|
});
|
|
164
231
|
}
|
|
165
232
|
case "update_account_metadata": {
|
|
166
|
-
if (ctx?.dryRun)
|
|
167
|
-
return `Would update metadata for ${input.account_id}: ${JSON.stringify(input)}`;
|
|
168
233
|
return await runAccountExclusive(() => {
|
|
169
234
|
try {
|
|
170
235
|
let changed = false;
|
|
@@ -199,7 +264,9 @@ async function accountExecute(db, name, input, ctx) {
|
|
|
199
264
|
else {
|
|
200
265
|
apply();
|
|
201
266
|
}
|
|
202
|
-
return changed
|
|
267
|
+
return changed
|
|
268
|
+
? `Updated ${input.account_id}.`
|
|
269
|
+
: "Nothing to update.";
|
|
203
270
|
}
|
|
204
271
|
catch (err) {
|
|
205
272
|
if (String(err.message).includes("not found")) {
|
|
@@ -212,8 +279,6 @@ async function accountExecute(db, name, input, ctx) {
|
|
|
212
279
|
case "record_transaction": {
|
|
213
280
|
if (!ctx)
|
|
214
281
|
return "record_transaction is only available inside an agent session.";
|
|
215
|
-
if (ctx.dryRun)
|
|
216
|
-
return `Would post transaction "${input.description}" on ${input.date}.`;
|
|
217
282
|
const txInput = {
|
|
218
283
|
date: input.date,
|
|
219
284
|
description: input.description,
|
|
@@ -278,62 +343,67 @@ export const accountIngestTools = {
|
|
|
278
343
|
execute: accountExecute,
|
|
279
344
|
};
|
|
280
345
|
/**
|
|
281
|
-
* Scan-only
|
|
346
|
+
* Scan-only unknowns
|
|
282
347
|
*
|
|
283
|
-
* `
|
|
348
|
+
* `note_unknown` records a clarification mid-scan without ever prompting the
|
|
284
349
|
* user — only scan needs this. Record uses `clarify` (transient prompt, no
|
|
285
|
-
*
|
|
350
|
+
* unknowns-table residue); resolve uses `ask_user` (prompts and resolves).
|
|
286
351
|
*/
|
|
287
|
-
const
|
|
352
|
+
const UNKNOWN_DEFS = [
|
|
288
353
|
{
|
|
289
|
-
name: "
|
|
290
|
-
description: "Record a clarification request without pausing the run. Use during scan when a row is ambiguous (post your best-guess transaction first, then call this with the transaction's id), when a row is unparseable (skip the transaction, call this with no transaction_id), or when you have a
|
|
354
|
+
name: "note_unknown",
|
|
355
|
+
description: "Record a clarification request without pausing the run. Use during scan when a row is ambiguous (post your best-guess transaction first, then call this with the transaction's id), when a row is unparseable (skip the transaction, call this with no transaction_id), or when you have a unknown about an account itself (pass account_id). Use kind='uncategorized_expense' when posting an expense to expense:uncategorized so resolve can group these. The resolver picks these up later with the full picture.",
|
|
291
356
|
input_schema: {
|
|
292
357
|
type: "object",
|
|
293
358
|
properties: {
|
|
294
359
|
prompt: {
|
|
295
360
|
type: "string",
|
|
296
|
-
description: "The question or
|
|
361
|
+
description: "The question or unknown in a complete sentence, with date, ฿-formatted amount, and human account names. Never reference internal ids.",
|
|
297
362
|
},
|
|
298
363
|
kind: {
|
|
299
364
|
type: "string",
|
|
300
|
-
description: "Optional category for the
|
|
365
|
+
description: "Optional category for the unknown. Use 'uncategorized_expense' when the posting landed in expense:uncategorized; the resolver batches these into one cleanup pass.",
|
|
301
366
|
},
|
|
302
367
|
options: {
|
|
303
368
|
type: "array",
|
|
304
|
-
description: "Optional list of candidate answers the
|
|
369
|
+
description: "Optional list of candidate answers the resolver can offer the user.",
|
|
305
370
|
items: { type: "string" },
|
|
306
371
|
},
|
|
307
372
|
transaction_id: {
|
|
308
373
|
type: "string",
|
|
309
|
-
description: "Id of the transaction this
|
|
374
|
+
description: "Id of the transaction this unknown relates to (returned by record_transaction). Omit for file-level unknowns about an unparseable row.",
|
|
310
375
|
},
|
|
311
376
|
account_id: {
|
|
312
377
|
type: "string",
|
|
313
|
-
description: "Id of the account this
|
|
378
|
+
description: "Id of the account this unknown relates to. Set when the statement's bank name, currency, statement_day, due_day, or other metadata disagrees with the stored account, or when you suspect a new account you're about to create duplicates an existing one. Can be combined with transaction_id.",
|
|
314
379
|
},
|
|
315
380
|
},
|
|
316
381
|
required: ["prompt"],
|
|
317
382
|
},
|
|
318
383
|
},
|
|
319
384
|
];
|
|
320
|
-
const
|
|
321
|
-
|
|
385
|
+
const UNKNOWN_LABELS = {
|
|
386
|
+
note_unknown: "Noting unknown",
|
|
322
387
|
};
|
|
323
|
-
async function
|
|
324
|
-
if (name !== "
|
|
388
|
+
async function unknownExecute(db, name, input, ctx) {
|
|
389
|
+
if (name !== "note_unknown")
|
|
325
390
|
return undefined;
|
|
326
391
|
if (!ctx)
|
|
327
|
-
return "
|
|
392
|
+
return "note_unknown is only available inside an agent session.";
|
|
328
393
|
const target = {
|
|
329
394
|
transaction_id: input.transaction_id ?? null,
|
|
330
395
|
account_id: input.account_id ?? null,
|
|
331
396
|
};
|
|
332
397
|
if (ctx.buffer) {
|
|
333
|
-
ctx.buffer.
|
|
334
|
-
|
|
398
|
+
ctx.buffer.appendUnknown({
|
|
399
|
+
...target,
|
|
400
|
+
kind: input.kind ?? null,
|
|
401
|
+
prompt: input.prompt,
|
|
402
|
+
options: input.options,
|
|
403
|
+
});
|
|
404
|
+
return `Unknown noted (buffered). Continue with the next row.`;
|
|
335
405
|
}
|
|
336
|
-
const id =
|
|
406
|
+
const id = recordUnknown(db, {
|
|
337
407
|
file_id: ctx.fileId ?? null,
|
|
338
408
|
transaction_id: target.transaction_id,
|
|
339
409
|
account_id: target.account_id,
|
|
@@ -341,28 +411,31 @@ async function concernExecute(db, name, input, ctx) {
|
|
|
341
411
|
prompt: input.prompt,
|
|
342
412
|
options: input.options,
|
|
343
413
|
});
|
|
344
|
-
return `
|
|
414
|
+
return `Unknown noted (${id}). Continue with the next row.`;
|
|
345
415
|
}
|
|
346
|
-
export const
|
|
347
|
-
DEFS:
|
|
348
|
-
LABELS:
|
|
349
|
-
execute:
|
|
416
|
+
export const scanUnknownTools = {
|
|
417
|
+
DEFS: UNKNOWN_DEFS,
|
|
418
|
+
LABELS: UNKNOWN_LABELS,
|
|
419
|
+
execute: unknownExecute,
|
|
350
420
|
};
|
|
351
421
|
/**
|
|
352
|
-
*
|
|
422
|
+
* Resolve-only tool definitions
|
|
353
423
|
*
|
|
354
424
|
* `ask_user` is the only interactive primitive. Scan never reaches it (the
|
|
355
425
|
* scan profile doesn't include this module), so we don't need a "scan, please
|
|
356
426
|
* don't use this" guard.
|
|
357
427
|
*/
|
|
358
|
-
const
|
|
428
|
+
const RESOLVE_DEFS = [
|
|
359
429
|
{
|
|
360
430
|
name: "ask_user",
|
|
361
|
-
description: "Ask the user a clarifying question when you cannot confidently proceed. The pipeline pauses and prompts the user interactively. Available during `plasalid
|
|
431
|
+
description: "Ask the user a clarifying question when you cannot confidently proceed. The pipeline pauses and prompts the user interactively. Available during `plasalid resolve`. Not exposed during `plasalid scan` — use `note_unknown` instead. Pass `transaction_id` / `account_id` to attach the question to the same target as a scan-noted unknown. Pass `unknown_id` to resolve an existing open unknown in place (recommended when re-posing a scan-noted unknown to the user). Pass `related_unknown_ids` to apply the user's single answer to a whole group of sibling unknowns at once.",
|
|
362
432
|
input_schema: {
|
|
363
433
|
type: "object",
|
|
364
434
|
properties: {
|
|
365
|
-
prompt: {
|
|
435
|
+
prompt: {
|
|
436
|
+
type: "string",
|
|
437
|
+
description: "The question to ask in plain language.",
|
|
438
|
+
},
|
|
366
439
|
options: {
|
|
367
440
|
type: "array",
|
|
368
441
|
description: "Optional list of candidate answers.",
|
|
@@ -370,28 +443,37 @@ const REVIEW_DEFS = [
|
|
|
370
443
|
},
|
|
371
444
|
transaction_id: {
|
|
372
445
|
type: "string",
|
|
373
|
-
description: "Optional: transaction this question is about. Used to clear the transaction's
|
|
446
|
+
description: "Optional: transaction this question is about. Used to clear the transaction's has_unknown flag once all its unknowns close.",
|
|
374
447
|
},
|
|
375
448
|
account_id: {
|
|
376
449
|
type: "string",
|
|
377
|
-
description: "Optional: account this question is about. Used to clear the account's
|
|
450
|
+
description: "Optional: account this question is about. Used to clear the account's has_unknown flag once all its unknowns close.",
|
|
378
451
|
},
|
|
379
|
-
|
|
452
|
+
unknown_id: {
|
|
380
453
|
type: "string",
|
|
381
|
-
description: "Optional: id of an existing open
|
|
454
|
+
description: "Optional: id of an existing open unknown. If supplied, the user's answer resolves that row in place instead of creating a new one.",
|
|
382
455
|
},
|
|
383
|
-
|
|
456
|
+
related_unknown_ids: {
|
|
384
457
|
type: "array",
|
|
385
458
|
items: { type: "string" },
|
|
386
|
-
description: "Optional: ids of additional open
|
|
459
|
+
description: "Optional: ids of additional open unknowns that share the same answer as `unknown_id`. The user is prompted once; every listed unknown (plus the primary) is marked resolved with the same answer. Use this for grouping duplicate questions — e.g., 12 Lazada rows that all categorize the same way — so the user isn't asked the same thing twelve times.",
|
|
387
460
|
},
|
|
388
461
|
facts: {
|
|
389
462
|
type: "object",
|
|
390
463
|
description: "Optional structured highlights rendered as a single colored header line above the question. Provide whichever fields apply; the prompter colorizes each by category (amount=yellow, date=cyan, merchant=green, accounts=magenta). Keep the `prompt` text short — the facts header carries the context.",
|
|
391
464
|
properties: {
|
|
392
|
-
amount: {
|
|
393
|
-
|
|
394
|
-
|
|
465
|
+
amount: {
|
|
466
|
+
type: "string",
|
|
467
|
+
description: "฿-formatted amount, e.g. '฿1,200.00'.",
|
|
468
|
+
},
|
|
469
|
+
date: {
|
|
470
|
+
type: "string",
|
|
471
|
+
description: "ISO date or short range, e.g. '2026-04-15' or '2026-02-15 to 2026-05-15'.",
|
|
472
|
+
},
|
|
473
|
+
merchant: {
|
|
474
|
+
type: "string",
|
|
475
|
+
description: "Counterparty / merchant name, e.g. 'LAZADA TH', 'Spotify'.",
|
|
476
|
+
},
|
|
395
477
|
accounts: {
|
|
396
478
|
type: "array",
|
|
397
479
|
items: { type: "string" },
|
|
@@ -403,23 +485,42 @@ const REVIEW_DEFS = [
|
|
|
403
485
|
required: ["prompt"],
|
|
404
486
|
},
|
|
405
487
|
},
|
|
488
|
+
{
|
|
489
|
+
name: "close_unknown",
|
|
490
|
+
description: "Close an open unknown by writing its answer to the row WITHOUT prompting the user. Use after applying a mutation that a memory rule, heuristic, or small-amount auto-skip already implied. Pass `related_unknown_ids` to close a sibling group in one call. The actual mutation (update_posting / record_recurrence / merge_accounts / etc.) must be done BEFORE this call — close_unknown only records the answer for audit.",
|
|
491
|
+
input_schema: {
|
|
492
|
+
type: "object",
|
|
493
|
+
properties: {
|
|
494
|
+
unknown_id: { type: "string" },
|
|
495
|
+
answer: {
|
|
496
|
+
type: "string",
|
|
497
|
+
description: "The implied answer to record.",
|
|
498
|
+
},
|
|
499
|
+
related_unknown_ids: { type: "array", items: { type: "string" } },
|
|
500
|
+
},
|
|
501
|
+
required: ["unknown_id", "answer"],
|
|
502
|
+
},
|
|
503
|
+
},
|
|
406
504
|
];
|
|
407
|
-
const
|
|
505
|
+
const RESOLVE_LABELS = {
|
|
408
506
|
ask_user: "Asking for clarification",
|
|
507
|
+
close_unknown: "Closing unknown",
|
|
409
508
|
};
|
|
410
|
-
async function
|
|
509
|
+
async function resolveExecute(db, name, input, ctx) {
|
|
510
|
+
if (name === "close_unknown")
|
|
511
|
+
return closeUnknown(db, input);
|
|
411
512
|
if (name !== "ask_user")
|
|
412
513
|
return undefined;
|
|
413
514
|
if (!ctx)
|
|
414
515
|
return "ask_user is only available inside an agent session.";
|
|
415
516
|
let id;
|
|
416
|
-
if (input.
|
|
417
|
-
id = String(input.
|
|
418
|
-
if (!
|
|
419
|
-
return `
|
|
517
|
+
if (input.unknown_id) {
|
|
518
|
+
id = String(input.unknown_id);
|
|
519
|
+
if (!getUnknownTarget(db, id))
|
|
520
|
+
return `Unknown ${id} not found.`;
|
|
420
521
|
}
|
|
421
522
|
else {
|
|
422
|
-
id =
|
|
523
|
+
id = recordUnknown(db, {
|
|
423
524
|
file_id: ctx.fileId ?? null,
|
|
424
525
|
transaction_id: input.transaction_id ?? null,
|
|
425
526
|
account_id: input.account_id ?? null,
|
|
@@ -429,22 +530,44 @@ async function reviewExecute(db, name, input, ctx) {
|
|
|
429
530
|
}
|
|
430
531
|
if (ctx.interactive && ctx.promptUser) {
|
|
431
532
|
const answer = await ctx.promptUser(input.prompt, input.options, input.facts);
|
|
432
|
-
|
|
433
|
-
const siblings = Array.isArray(input.
|
|
533
|
+
resolveUnknown(db, id, answer);
|
|
534
|
+
const siblings = Array.isArray(input.related_unknown_ids)
|
|
535
|
+
? input.related_unknown_ids
|
|
536
|
+
: [];
|
|
434
537
|
let propagated = 0;
|
|
435
538
|
for (const sibId of siblings) {
|
|
436
539
|
if (sibId === id)
|
|
437
540
|
continue;
|
|
438
|
-
if (
|
|
541
|
+
if (resolveUnknown(db, String(sibId), answer))
|
|
439
542
|
propagated++;
|
|
440
543
|
}
|
|
441
544
|
const totalResolved = 1 + propagated;
|
|
442
|
-
return `User answered: ${sanitizeForPrompt(answer)}${totalResolved > 1 ? ` (applied to ${totalResolved}
|
|
545
|
+
return `User answered: ${sanitizeForPrompt(answer)}${totalResolved > 1 ? ` (applied to ${totalResolved} unknown${totalResolved === 1 ? "" : "s"})` : ""}`;
|
|
443
546
|
}
|
|
444
547
|
return `Question recorded for later (${id}). Awaiting user input — do not act on assumptions about this answer.`;
|
|
445
548
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
549
|
+
function closeUnknown(db, input) {
|
|
550
|
+
const primary = String(input.unknown_id ?? "");
|
|
551
|
+
const answer = String(input.answer ?? "");
|
|
552
|
+
if (!primary || !answer)
|
|
553
|
+
return "close_unknown requires unknown_id and answer.";
|
|
554
|
+
if (!getUnknownTarget(db, primary))
|
|
555
|
+
return `Unknown ${primary} not found.`;
|
|
556
|
+
resolveUnknown(db, primary, answer);
|
|
557
|
+
let count = 1;
|
|
558
|
+
const siblings = Array.isArray(input.related_unknown_ids)
|
|
559
|
+
? input.related_unknown_ids
|
|
560
|
+
: [];
|
|
561
|
+
for (const sibId of siblings) {
|
|
562
|
+
if (sibId === primary)
|
|
563
|
+
continue;
|
|
564
|
+
if (resolveUnknown(db, String(sibId), answer))
|
|
565
|
+
count++;
|
|
566
|
+
}
|
|
567
|
+
return `Closed ${count} unknown${count === 1 ? "" : "s"}.`;
|
|
568
|
+
}
|
|
569
|
+
export const resolveIngestTools = {
|
|
570
|
+
DEFS: RESOLVE_DEFS,
|
|
571
|
+
LABELS: RESOLVE_LABELS,
|
|
572
|
+
execute: resolveExecute,
|
|
450
573
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { upsertMerchant, findMerchantByAlias, findMerchantById, setMerchantDefaultAccount, } from "../../db/queries/merchants.js";
|
|
2
|
-
import { appendAction } from "../../db/queries/
|
|
2
|
+
import { appendAction } from "../../db/queries/action-log.js";
|
|
3
3
|
import { sanitizeForPrompt } from "../sanitize.js";
|
|
4
4
|
/**
|
|
5
5
|
* Merchant tools
|
|
@@ -17,9 +17,18 @@ const DEFS = [
|
|
|
17
17
|
input_schema: {
|
|
18
18
|
type: "object",
|
|
19
19
|
properties: {
|
|
20
|
-
canonical_name: {
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
canonical_name: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Title-cased merchant name, e.g. 'Starbucks', 'Amazon'.",
|
|
23
|
+
},
|
|
24
|
+
alias: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Optional raw descriptor (as seen on a statement). Plasalid normalizes and dedups it.",
|
|
27
|
+
},
|
|
28
|
+
default_account_id: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Optional learned cache: the merchant's default expense account.",
|
|
31
|
+
},
|
|
23
32
|
},
|
|
24
33
|
required: ["canonical_name"],
|
|
25
34
|
},
|
|
@@ -30,7 +39,10 @@ const DEFS = [
|
|
|
30
39
|
input_schema: {
|
|
31
40
|
type: "object",
|
|
32
41
|
properties: {
|
|
33
|
-
descriptor: {
|
|
42
|
+
descriptor: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "The raw statement line or merchant string to look up.",
|
|
45
|
+
},
|
|
34
46
|
},
|
|
35
47
|
required: ["descriptor"],
|
|
36
48
|
},
|
|
@@ -56,8 +68,6 @@ const LABELS = {
|
|
|
56
68
|
async function execute(db, name, input, ctx) {
|
|
57
69
|
switch (name) {
|
|
58
70
|
case "find_or_create_merchant": {
|
|
59
|
-
if (ctx?.dryRun)
|
|
60
|
-
return `Would upsert merchant "${input.canonical_name}".`;
|
|
61
71
|
const existing = db
|
|
62
72
|
.prepare(`SELECT id FROM merchants WHERE canonical_name = ?`)
|
|
63
73
|
.get(input.canonical_name);
|
|
@@ -73,22 +83,27 @@ async function execute(db, name, input, ctx) {
|
|
|
73
83
|
user_input: ctx.userInput ?? null,
|
|
74
84
|
action_type: "create_merchant",
|
|
75
85
|
target_id: merchant.id,
|
|
76
|
-
payload: {
|
|
86
|
+
payload: {
|
|
87
|
+
canonical_name: merchant.canonical_name,
|
|
88
|
+
default_account_id: merchant.default_account_id,
|
|
89
|
+
},
|
|
77
90
|
});
|
|
78
91
|
}
|
|
79
|
-
const defaultStr = merchant.default_account_id
|
|
92
|
+
const defaultStr = merchant.default_account_id
|
|
93
|
+
? ` (default → ${merchant.default_account_id})`
|
|
94
|
+
: "";
|
|
80
95
|
return `Merchant ${merchant.id}: ${sanitizeForPrompt(merchant.canonical_name)}${defaultStr}.`;
|
|
81
96
|
}
|
|
82
97
|
case "find_merchant_by_descriptor": {
|
|
83
98
|
const hit = findMerchantByAlias(db, String(input.descriptor ?? ""));
|
|
84
99
|
if (!hit)
|
|
85
100
|
return `No merchant matched descriptor "${sanitizeForPrompt(String(input.descriptor ?? ""))}".`;
|
|
86
|
-
const defaultStr = hit.default_account_id
|
|
101
|
+
const defaultStr = hit.default_account_id
|
|
102
|
+
? ` (default → ${hit.default_account_id})`
|
|
103
|
+
: "";
|
|
87
104
|
return `Merchant ${hit.merchant.id}: ${sanitizeForPrompt(hit.merchant.canonical_name)}${defaultStr}.`;
|
|
88
105
|
}
|
|
89
106
|
case "set_merchant_default_account": {
|
|
90
|
-
if (ctx?.dryRun)
|
|
91
|
-
return `Would set ${input.merchant_id}'s default to ${input.account_id}.`;
|
|
92
107
|
const m = findMerchantById(db, input.merchant_id);
|
|
93
108
|
if (!m)
|
|
94
109
|
return `Merchant ${input.merchant_id} not found.`;
|