actual-jev 0.1.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.
@@ -0,0 +1,84 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { parseArgs } from './args.js';
4
+ void test('defaults to interactive and supports every option', () => {
5
+ assert.deepEqual(parseArgs([]), {
6
+ command: 'run',
7
+ mode: 'interactive',
8
+ threshold: 0.9,
9
+ account: undefined,
10
+ from: undefined,
11
+ to: undefined,
12
+ dataDir: undefined,
13
+ help: false,
14
+ });
15
+ assert.deepEqual(parseArgs([
16
+ '--auto',
17
+ '--threshold',
18
+ '0',
19
+ '--account',
20
+ 'Checking',
21
+ '--from',
22
+ '2024-02-29',
23
+ '--to',
24
+ '2024-03-01',
25
+ '--data-dir',
26
+ '/tmp/budget',
27
+ '-h',
28
+ ]), {
29
+ command: 'run',
30
+ mode: 'auto',
31
+ threshold: 0,
32
+ account: 'Checking',
33
+ from: '2024-02-29',
34
+ to: '2024-03-01',
35
+ dataDir: '/tmp/budget',
36
+ help: true,
37
+ });
38
+ assert.equal(parseArgs(['--dry-run', '--threshold', '1']).mode, 'dry-run');
39
+ assert.equal(parseArgs(['--interactive', '--help']).help, true);
40
+ });
41
+ void test('repeated value flags use their last value, but repeated modes are errors', () => {
42
+ assert.equal(parseArgs(['--threshold', '0.2', '--threshold', '1']).threshold, 1);
43
+ assert.equal(parseArgs(['--account', 'first', '--account', 'second']).account, 'second');
44
+ for (const modes of [
45
+ ['--auto', '--auto'],
46
+ ['--interactive', '--dry-run'],
47
+ ]) {
48
+ assert.throws(() => parseArgs(modes), /Choose only one mode/);
49
+ }
50
+ });
51
+ void test('validates values and unknown options even when help is requested', () => {
52
+ for (const flag of ['--threshold', '--max-examples-per-category', '--account', '--from', '--to', '--data-dir']) {
53
+ for (const tail of [[], [''], ['--help']]) {
54
+ assert.throws(() => parseArgs([flag, ...tail]), /requires a value/);
55
+ }
56
+ }
57
+ for (const value of ['NaN', 'Infinity', '-0.1', '1.1']) {
58
+ assert.throws(() => parseArgs(['--threshold', value]), /between 0 and 1/);
59
+ }
60
+ for (const value of ['2025-02-29', '2026-04-31', '2026-13-01', '2026-1-01']) {
61
+ assert.throws(() => parseArgs(['--from', value]), /valid YYYY-MM-DD/);
62
+ }
63
+ assert.throws(() => parseArgs(['--from', '2026-09-02', '--to', '2026-09-01']), /on or before/);
64
+ assert.throws(() => parseArgs(['--help', '--unknown']), /Unknown option/);
65
+ // A short flag is accepted as a string value, matching the original parser.
66
+ assert.equal(parseArgs(['--account', '-h']).account, '-h');
67
+ });
68
+ void test('supports setup, config show, and explicit environment files', () => {
69
+ assert.equal(parseArgs(['setup']).command, 'setup');
70
+ assert.equal(parseArgs(['setup', '--help']).help, true);
71
+ assert.equal(parseArgs(['config', 'show', '--env-file', '/tmp/test.env']).envFile, '/tmp/test.env');
72
+ assert.equal(parseArgs(['--env-file', '/tmp/test.env', '--dry-run']).mode, 'dry-run');
73
+ assert.throws(() => parseArgs(['--env-file']), /requires a value/);
74
+ assert.throws(() => parseArgs(['setup', '--auto']), /setup accepts/);
75
+ assert.throws(() => parseArgs(['config', 'show', '--auto']), /config show accepts/);
76
+ assert.throws(() => parseArgs(['config']), /Unknown option/);
77
+ });
78
+ void test('example limit is a per-run flag, including zero to disable history', () => {
79
+ assert.equal(parseArgs(['--max-examples-per-category', '0']).maxExamplesPerCategory, 0);
80
+ assert.equal(parseArgs(['--max-examples-per-category', '100']).maxExamplesPerCategory, 100);
81
+ for (const value of ['-1', '1.5', '101', 'NaN', 'Infinity']) {
82
+ assert.throws(() => parseArgs(['--max-examples-per-category', value]), /integer between 0 and 100/);
83
+ }
84
+ });
@@ -0,0 +1,8 @@
1
+ import { Separator } from '@inquirer/search';
2
+ import type { CategoryCandidate } from './classifier.js';
3
+ export declare const SKIP_LABEL = "Skip this transaction";
4
+ export declare function categoryChoices(categories: readonly CategoryCandidate[], term?: string): (Separator | {
5
+ name: string;
6
+ value: string | null;
7
+ short: string;
8
+ })[];
@@ -0,0 +1,26 @@
1
+ import { Separator } from '@inquirer/search';
2
+ export const SKIP_LABEL = 'Skip this transaction';
3
+ export function categoryChoices(categories, term) {
4
+ const query = term?.trim().toLocaleLowerCase() ?? '';
5
+ const choices = [
6
+ { name: SKIP_LABEL, value: null, short: 'Skipped' },
7
+ ];
8
+ const groups = new Map();
9
+ for (const category of categories) {
10
+ if (query &&
11
+ !category.name.toLocaleLowerCase().includes(query) &&
12
+ !category.groupName.toLocaleLowerCase().includes(query)) {
13
+ continue;
14
+ }
15
+ const group = groups.get(category.groupName) ?? [];
16
+ group.push(category);
17
+ groups.set(category.groupName, group);
18
+ }
19
+ for (const [groupName, group] of groups) {
20
+ choices.push(new Separator(`── ${groupName} ──`));
21
+ for (const category of group) {
22
+ choices.push({ name: category.name, value: category.id, short: `${groupName} / ${category.name}` });
23
+ }
24
+ }
25
+ return choices;
26
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { Separator } from '@inquirer/search';
4
+ import { categoryChoices } from './choices.js';
5
+ const categories = [
6
+ { id: 'groceries', name: 'Groceries', groupName: 'Food' },
7
+ { id: 'bus', name: 'Bus', groupName: 'Transport' },
8
+ { id: 'restaurants', name: 'Restaurants', groupName: 'Food' },
9
+ ];
10
+ function labels(term) {
11
+ return categoryChoices(categories, term).map((choice) => choice instanceof Separator ? choice.separator : choice.name);
12
+ }
13
+ void test('shows every category under its group with Skip first', () => {
14
+ assert.deepEqual(labels(), [
15
+ 'Skip this transaction',
16
+ '── Food ──',
17
+ 'Groceries',
18
+ 'Restaurants',
19
+ '── Transport ──',
20
+ 'Bus',
21
+ ]);
22
+ const choices = categoryChoices(categories);
23
+ assert.deepEqual(choices.flatMap((choice) => ('value' in choice ? [choice.value] : [])), [null, 'groceries', 'restaurants', 'bus']);
24
+ });
25
+ void test('searches names and group names without empty group headings', () => {
26
+ assert.deepEqual(labels('gRoC'), ['Skip this transaction', '── Food ──', 'Groceries']);
27
+ assert.deepEqual(labels('food'), ['Skip this transaction', '── Food ──', 'Groceries', 'Restaurants']);
28
+ assert.deepEqual(labels('absent'), ['Skip this transaction']);
29
+ });
@@ -0,0 +1,56 @@
1
+ import { choice, type EntryType } from '@typesafe-ai/sdk';
2
+ export interface CategoryCandidate {
3
+ id: string;
4
+ name: string;
5
+ groupName: string;
6
+ isIncome?: boolean;
7
+ note?: string;
8
+ }
9
+ export interface TransactionDetails {
10
+ id?: string;
11
+ payeeName?: string;
12
+ importedPayee?: string;
13
+ notes?: string;
14
+ amount?: number;
15
+ date?: string;
16
+ accountName?: string;
17
+ payeeDefaultCategoryId?: string;
18
+ }
19
+ export interface CategorizedExample extends TransactionDetails {
20
+ categoryId: string;
21
+ }
22
+ export interface RankedCategory extends CategoryCandidate {
23
+ probability: number;
24
+ }
25
+ export interface Classification {
26
+ categoryId: string | null;
27
+ confidence: number;
28
+ candidates: RankedCategory[];
29
+ noMatchProbability: number;
30
+ requiresReview?: boolean;
31
+ }
32
+ export interface JevChoiceClient {
33
+ systemOne(request: {
34
+ state: EntryType;
35
+ model: string;
36
+ questions: {
37
+ category: ReturnType<typeof choice>;
38
+ };
39
+ }): Promise<{
40
+ answers: {
41
+ category: {
42
+ choice: string;
43
+ confidence: number;
44
+ probabilities: Record<string, number>;
45
+ };
46
+ };
47
+ }>;
48
+ }
49
+ export interface ClassifierOptions {
50
+ client?: JevChoiceClient;
51
+ model?: string;
52
+ examples?: readonly CategorizedExample[];
53
+ maxExamplesPerCategory?: number;
54
+ }
55
+ /** Ask Jev to choose among caller-supplied Actual category IDs. Never writes to Actual. */
56
+ export declare function classifyTransaction(transaction: TransactionDetails, categories: readonly CategoryCandidate[], options?: ClassifierOptions): Promise<Classification>;
@@ -0,0 +1,147 @@
1
+ import { choice } from '@typesafe-ai/sdk';
2
+ const NO_MATCH = 'none_of_the_above';
3
+ function exampleLimit(value, name) {
4
+ if (!Number.isSafeInteger(value) || value < 0 || value > 100) {
5
+ throw new RangeError(`${name} must be an integer between 0 and 100`);
6
+ }
7
+ return value;
8
+ }
9
+ function normalized(value) {
10
+ return (value ?? '')
11
+ .toLocaleLowerCase()
12
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
13
+ .trim();
14
+ }
15
+ function words(value) {
16
+ return new Set(normalized(value)
17
+ .split(' ')
18
+ .filter((word) => word.length > 2));
19
+ }
20
+ function relevantExamples(transaction, examples, categoryIds, maxExamplesPerCategory) {
21
+ if (maxExamplesPerCategory === 0)
22
+ return [];
23
+ const payee = normalized(transaction.payeeName);
24
+ const imported = normalized(transaction.importedPayee);
25
+ const terms = words(`${transaction.payeeName ?? ''} ${transaction.importedPayee ?? ''} ${transaction.notes ?? ''}`);
26
+ const ranked = examples.flatMap((example, index) => {
27
+ if (!categoryIds.has(example.categoryId) || (transaction.id && example.id === transaction.id))
28
+ return [];
29
+ const exactPayee = Boolean(payee && payee === normalized(example.payeeName));
30
+ const exactImported = Boolean(imported && imported === normalized(example.importedPayee));
31
+ const overlap = [
32
+ ...words(`${example.payeeName ?? ''} ${example.importedPayee ?? ''} ${example.notes ?? ''}`),
33
+ ].filter((word) => terms.has(word)).length;
34
+ if (!exactPayee && !exactImported && overlap < 2)
35
+ return [];
36
+ const sameDirection = transaction.amount !== undefined &&
37
+ example.amount !== undefined &&
38
+ Math.sign(transaction.amount) === Math.sign(example.amount);
39
+ const score = Number(exactPayee) * 100 + Number(exactImported) * 60 + overlap * 5 + Number(sameDirection);
40
+ return [{ example, score, index }];
41
+ });
42
+ ranked.sort((a, b) => b.score - a.score || (b.example.date ?? '').localeCompare(a.example.date ?? '') || a.index - b.index);
43
+ const counts = new Map();
44
+ const selected = [];
45
+ for (const { example } of ranked) {
46
+ const count = counts.get(example.categoryId) ?? 0;
47
+ if (count >= maxExamplesPerCategory)
48
+ continue;
49
+ selected.push(example);
50
+ counts.set(example.categoryId, count + 1);
51
+ }
52
+ return selected;
53
+ }
54
+ function probability(value, label) {
55
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
56
+ throw new Error(`Jev returned an invalid ${label}`);
57
+ }
58
+ return value;
59
+ }
60
+ /** Ask Jev to choose among caller-supplied Actual category IDs. Never writes to Actual. */
61
+ export async function classifyTransaction(transaction, categories, options = {}) {
62
+ const maxExamplesPerCategory = exampleLimit(options.maxExamplesPerCategory ?? 3, 'maxExamplesPerCategory');
63
+ if (categories.length === 0) {
64
+ return { categoryId: null, confidence: 0, candidates: [], noMatchProbability: 1 };
65
+ }
66
+ if (categories.length > 254) {
67
+ throw new RangeError('Jev Choice supports at most 254 categories plus the no-match option');
68
+ }
69
+ const seen = new Set();
70
+ const criteria = {};
71
+ const byOption = new Map();
72
+ const optionById = new Map();
73
+ categories.forEach((category, index) => {
74
+ if (!category.id || !category.name || !category.groupName || seen.has(category.id)) {
75
+ throw new TypeError('Categories must have unique nonempty IDs, names, and group names');
76
+ }
77
+ seen.add(category.id);
78
+ const option = `category_${index}`;
79
+ const note = category.note?.trim().slice(0, 400);
80
+ criteria[option] =
81
+ `${category.groupName} / ${category.name}${category.isIncome ? ' (income)' : ''}${note ? `. Category note: ${note}` : ''}`;
82
+ byOption.set(option, category);
83
+ optionById.set(category.id, option);
84
+ });
85
+ criteria[NO_MATCH] = 'No listed category reasonably describes this transaction';
86
+ const examples = relevantExamples(transaction, options.examples ?? [], seen, maxExamplesPerCategory);
87
+ const state = {
88
+ transaction: {
89
+ payee: transaction.payeeName ?? null,
90
+ imported_payee: transaction.importedPayee ?? null,
91
+ notes: transaction.notes ?? null,
92
+ amount_minor_units: transaction.amount ?? null,
93
+ date: transaction.date ?? null,
94
+ account: transaction.accountName ?? null,
95
+ },
96
+ payee_default_category: optionById.get(transaction.payeeDefaultCategoryId ?? '') ?? null,
97
+ relevant_examples: examples.map((example) => ({
98
+ payee: example.payeeName ?? null,
99
+ imported_payee: example.importedPayee ?? null,
100
+ notes: example.notes ?? null,
101
+ amount_minor_units: example.amount ?? null,
102
+ category: optionById.get(example.categoryId),
103
+ })),
104
+ };
105
+ const client = options.client;
106
+ if (!client)
107
+ throw new TypeError('A configured Jev client is required');
108
+ const response = await client.systemOne({
109
+ model: options.model ?? 'jev-latest',
110
+ state,
111
+ questions: {
112
+ category: choice('Which available budget category best describes the transaction? Use its details, category notes, and relevant examples. Examples show past choices, not rules. Use none_of_the_above if none fits. Amounts are in minor currency units; negative amounts are expenses.', criteria),
113
+ },
114
+ });
115
+ const answer = response.answers.category;
116
+ const confidence = probability(answer.confidence, 'confidence');
117
+ if (answer.choice !== NO_MATCH && !byOption.has(answer.choice)) {
118
+ throw new Error(`Jev selected an unknown category option: ${answer.choice}`);
119
+ }
120
+ const candidates = [...byOption]
121
+ .map(([option, category]) => ({
122
+ ...category,
123
+ probability: probability(answer.probabilities[option], `probability for ${option}`),
124
+ }))
125
+ .sort((a, b) => b.probability - a.probability);
126
+ const noMatchProbability = probability(answer.probabilities[NO_MATCH], 'no-match probability');
127
+ const selectedId = answer.choice === NO_MATCH ? null : byOption.get(answer.choice).id;
128
+ const payee = normalized(transaction.payeeName);
129
+ const importedPayee = normalized(transaction.importedPayee);
130
+ const matchingPayee = (maxExamplesPerCategory ? (options.examples ?? []) : []).filter((example) => seen.has(example.categoryId) &&
131
+ !(transaction.id && example.id === transaction.id) &&
132
+ ((payee && payee === normalized(example.payeeName)) ||
133
+ (!payee && importedPayee && importedPayee === normalized(example.importedPayee))));
134
+ const historicalCategories = new Set(matchingPayee.map((example) => example.categoryId));
135
+ const requiresReview = Boolean(selectedId &&
136
+ (historicalCategories.size > 1 ||
137
+ (transaction.payeeDefaultCategoryId &&
138
+ transaction.payeeDefaultCategoryId !== selectedId &&
139
+ !historicalCategories.has(selectedId))));
140
+ return {
141
+ categoryId: selectedId,
142
+ confidence,
143
+ candidates,
144
+ noMatchProbability,
145
+ requiresReview,
146
+ };
147
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,257 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { classifyTransaction } from './classifier.js';
4
+ const categories = [
5
+ { id: 'food-id', name: 'Groceries', groupName: 'Food' },
6
+ { id: 'travel-id', name: 'Train', groupName: 'Travel' },
7
+ ];
8
+ function mock(choice, confidence = 0.8) {
9
+ return {
10
+ systemOne(request) {
11
+ assert.equal(request.model, 'jev-latest');
12
+ assert.equal(request.questions.category.criteria.category_0, 'Food / Groceries');
13
+ assert.equal(request.state.transaction.imported_payee, 'Fresh Market');
14
+ return Promise.resolve({
15
+ answers: {
16
+ category: {
17
+ choice,
18
+ confidence,
19
+ probabilities: { category_0: 0.7, category_1: 0.2, none_of_the_above: 0.1 },
20
+ },
21
+ },
22
+ });
23
+ },
24
+ };
25
+ }
26
+ void test('maps Jev options back to category IDs and ranks alternatives', async () => {
27
+ const result = await classifyTransaction({ importedPayee: 'Fresh Market', amount: -2300 }, categories, {
28
+ client: mock('category_0'),
29
+ });
30
+ assert.equal(result.categoryId, 'food-id');
31
+ assert.equal(result.confidence, 0.8);
32
+ assert.deepEqual(result.candidates.map((candidate) => candidate.id), ['food-id', 'travel-id']);
33
+ });
34
+ void test('returns no-match without inventing a category', async () => {
35
+ const result = await classifyTransaction({ importedPayee: 'Fresh Market' }, categories, {
36
+ client: mock('none_of_the_above'),
37
+ });
38
+ assert.equal(result.categoryId, null);
39
+ assert.equal(result.noMatchProbability, 0.1);
40
+ });
41
+ void test('rejects selections that were not offered', async () => {
42
+ await assert.rejects(classifyTransaction({ importedPayee: 'Fresh Market' }, categories, { client: mock('category_999') }), /unknown category option/);
43
+ });
44
+ void test('does not call Jev when no categories are available', async () => {
45
+ const result = await classifyTransaction({}, [], { client: mock('category_0') });
46
+ assert.equal(result.categoryId, null);
47
+ });
48
+ void test('rejects malformed confidences and probabilities', async () => {
49
+ for (const value of [NaN, Infinity, -0.1, 1.1, undefined, '0.5', null]) {
50
+ for (const field of ['confidence', 'category_0', 'none_of_the_above']) {
51
+ const client = {
52
+ async systemOne(request) {
53
+ const response = await mock('category_0').systemOne(request);
54
+ if (field === 'confidence')
55
+ Object.assign(response.answers.category, { confidence: value });
56
+ else
57
+ Object.assign(response.answers.category.probabilities, { [field]: value });
58
+ return response;
59
+ },
60
+ };
61
+ const message = field === 'confidence'
62
+ ? 'confidence'
63
+ : field === 'category_0'
64
+ ? 'probability for category_0'
65
+ : 'no-match probability';
66
+ await assert.rejects(classifyTransaction({ importedPayee: 'Fresh Market' }, categories, { client }), {
67
+ message: `Jev returned an invalid ${message}`,
68
+ });
69
+ }
70
+ }
71
+ });
72
+ void test('validates category IDs, labels, and limits before contacting Jev', async () => {
73
+ const client = {
74
+ systemOne: () => {
75
+ throw new Error('Unexpected request');
76
+ },
77
+ };
78
+ for (const invalid of [
79
+ [categories[0], categories[0]],
80
+ [{ id: '', name: 'Food', groupName: 'Living' }],
81
+ [{ id: 'food', name: '', groupName: 'Living' }],
82
+ [{ id: 'food', name: 'Food', groupName: '' }],
83
+ ]) {
84
+ await assert.rejects(classifyTransaction({}, invalid, { client }), TypeError);
85
+ }
86
+ const tooMany = Array.from({ length: 255 }, (_, i) => ({ id: `${i}`, name: 'Category', groupName: 'Group' }));
87
+ await assert.rejects(classifyTransaction({}, tooMany, { client }), RangeError);
88
+ });
89
+ void test('accepts the category limit, forwards state and model, and ranks by probability', async () => {
90
+ const catalog = Array.from({ length: 254 }, (_, i) => ({
91
+ id: `${i}`,
92
+ name: `Category ${i}`,
93
+ groupName: 'Group',
94
+ isIncome: i === 253,
95
+ }));
96
+ const client = {
97
+ systemOne(request) {
98
+ assert.equal(request.model, 'custom-model');
99
+ assert.deepEqual(request.state, {
100
+ transaction: {
101
+ payee: 'Employer',
102
+ imported_payee: null,
103
+ notes: 'Salary',
104
+ amount_minor_units: 10000,
105
+ date: '2026-09-01',
106
+ account: 'Checking',
107
+ },
108
+ payee_default_category: null,
109
+ relevant_examples: [],
110
+ });
111
+ assert.equal(Object.keys(request.questions.category.criteria).length, 255);
112
+ assert.equal(request.questions.category.criteria.category_253, 'Group / Category 253 (income)');
113
+ return Promise.resolve({
114
+ answers: {
115
+ category: {
116
+ choice: 'category_253',
117
+ confidence: 1,
118
+ probabilities: {
119
+ ...Object.fromEntries(catalog.map((_, i) => [`category_${i}`, i === 253 ? 1 : 0])),
120
+ none_of_the_above: 0,
121
+ },
122
+ },
123
+ },
124
+ });
125
+ },
126
+ };
127
+ const result = await classifyTransaction({ payeeName: 'Employer', notes: 'Salary', amount: 10000, date: '2026-09-01', accountName: 'Checking' }, catalog, { client, model: 'custom-model' });
128
+ assert.equal(result.categoryId, '253');
129
+ assert.equal(result.candidates[0]?.id, '253');
130
+ assert.equal(result.confidence, 1);
131
+ });
132
+ void test('uses contrasting history and notes, but requires review for a mixed-payee history', async () => {
133
+ const catalog = [
134
+ { id: 'groceries', name: 'Groceries', groupName: 'Food', note: 'Food to prepare at home' },
135
+ { id: 'restaurants', name: 'Restaurants', groupName: 'Food', note: 'Prepared meals and deli lunches' },
136
+ ];
137
+ const examples = [
138
+ {
139
+ id: 'old-grocery',
140
+ categoryId: 'groceries',
141
+ payeeName: 'Fresh Market',
142
+ notes: 'Weekly groceries',
143
+ amount: -6200,
144
+ },
145
+ { id: 'old-lunch', categoryId: 'restaurants', payeeName: 'Fresh Market', notes: 'Deli lunch', amount: -1300 },
146
+ { id: 'self', categoryId: 'groceries', payeeName: 'Fresh Market', notes: 'Self' },
147
+ { id: 'hidden', categoryId: 'hidden', payeeName: 'Fresh Market', notes: 'Hidden' },
148
+ ];
149
+ const client = {
150
+ systemOne(request) {
151
+ const description = request.questions.category.criteria.category_1;
152
+ assert.equal(typeof description, 'string');
153
+ assert.match(description, /Prepared meals and deli lunches/);
154
+ const state = request.state;
155
+ assert.equal(state.payee_default_category, 'category_0');
156
+ assert.deepEqual(state.relevant_examples.map((example) => [example.notes, example.category]), [
157
+ ['Deli lunch', 'category_1'],
158
+ ['Weekly groceries', 'category_0'],
159
+ ]);
160
+ return Promise.resolve({
161
+ answers: {
162
+ category: {
163
+ choice: 'category_1',
164
+ confidence: 0.99,
165
+ probabilities: { category_0: 0.01, category_1: 0.99, none_of_the_above: 0 },
166
+ },
167
+ },
168
+ });
169
+ },
170
+ };
171
+ const result = await classifyTransaction({
172
+ id: 'self',
173
+ payeeName: 'Fresh Market',
174
+ notes: 'Lunch sandwich',
175
+ amount: -1450,
176
+ payeeDefaultCategoryId: 'groceries',
177
+ }, catalog, { client, examples });
178
+ assert.equal(result.categoryId, 'restaurants');
179
+ assert.equal(result.requiresReview, true);
180
+ });
181
+ void test('keeps history bounded and ignores irrelevant examples', async () => {
182
+ const examples = Array.from({ length: 30 }, (_, i) => ({
183
+ categoryId: i % 2 ? 'travel-id' : 'food-id',
184
+ payeeName: 'Fresh Market',
185
+ notes: `Visit ${i}`,
186
+ }));
187
+ examples.push({ categoryId: 'travel-id', payeeName: 'Other Store', notes: 'Unrelated' });
188
+ const client = {
189
+ systemOne(request) {
190
+ const state = request.state;
191
+ assert.equal(state.relevant_examples.length, 6);
192
+ assert.ok(state.relevant_examples.every((example) => example.payee === 'Fresh Market'));
193
+ return mock('category_0').systemOne(request);
194
+ },
195
+ };
196
+ await classifyTransaction({ payeeName: 'Fresh Market', importedPayee: 'Fresh Market' }, categories, {
197
+ client,
198
+ examples,
199
+ });
200
+ });
201
+ void test('honors the per-category limit, including zero', async () => {
202
+ const examples = Array.from({ length: 8 }, (_, i) => ({
203
+ id: `example-${i}`,
204
+ categoryId: i % 2 ? 'travel-id' : 'food-id',
205
+ payeeName: 'Fresh Market',
206
+ }));
207
+ const lengths = [];
208
+ const client = {
209
+ systemOne(request) {
210
+ const state = request.state;
211
+ lengths.push(state.relevant_examples.length);
212
+ return Promise.resolve({
213
+ answers: {
214
+ category: {
215
+ choice: 'category_0',
216
+ confidence: 0.8,
217
+ probabilities: { category_0: 0.8, category_1: 0.1, none_of_the_above: 0.1 },
218
+ },
219
+ },
220
+ });
221
+ },
222
+ };
223
+ const transaction = { importedPayee: 'Fresh Market' };
224
+ await classifyTransaction(transaction, categories, { client, examples, maxExamplesPerCategory: 2 });
225
+ await classifyTransaction(transaction, categories, { client, examples, maxExamplesPerCategory: 0 });
226
+ assert.deepEqual(lengths, [4, 0]);
227
+ await assert.rejects(classifyTransaction(transaction, categories, { client, maxExamplesPerCategory: -1 }), RangeError);
228
+ });
229
+ void test('includes relevant examples from all 20 categories without a total cap', async () => {
230
+ const catalog = Array.from({ length: 20 }, (_, index) => ({
231
+ id: `category-id-${index}`,
232
+ name: `Category ${index}`,
233
+ groupName: 'Group',
234
+ }));
235
+ const examples = catalog.map((category) => ({ categoryId: category.id, payeeName: 'Shared Shop' }));
236
+ const client = {
237
+ systemOne(request) {
238
+ const state = request.state;
239
+ assert.equal(state.relevant_examples.length, 20);
240
+ assert.equal(new Set(state.relevant_examples.map((example) => example.category)).size, 20);
241
+ assert.equal(Object.keys(request.questions.category.criteria).length, 21);
242
+ return Promise.resolve({
243
+ answers: {
244
+ category: {
245
+ choice: 'category_0',
246
+ confidence: 1,
247
+ probabilities: {
248
+ ...Object.fromEntries(catalog.map((_, index) => [`category_${index}`, index === 0 ? 1 : 0])),
249
+ none_of_the_above: 0,
250
+ },
251
+ },
252
+ },
253
+ });
254
+ },
255
+ };
256
+ await classifyTransaction({ payeeName: 'Shared Shop' }, catalog, { client, examples });
257
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export { parseArgs } from './args.js';