@okrapdf/cli 0.1.3 → 0.1.5
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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +13 -7
- package/dist/cli.js.map +1 -1
- package/dist/commands/jobs.d.ts.map +1 -1
- package/dist/commands/jobs.js +18 -13
- package/dist/commands/jobs.js.map +1 -1
- package/dist/commands/providers.d.ts +4 -0
- package/dist/commands/providers.d.ts.map +1 -0
- package/dist/commands/providers.js +46 -0
- package/dist/commands/providers.js.map +1 -0
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +26 -17
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/shortcuts.d.ts.map +1 -1
- package/dist/commands/shortcuts.js +40 -17
- package/dist/commands/shortcuts.js.map +1 -1
- package/dist/lib/client.d.ts +4 -0
- package/dist/lib/client.d.ts.map +1 -1
- package/dist/lib/client.js +11 -0
- package/dist/lib/client.js.map +1 -1
- package/dist/lib/config.d.ts +5 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +16 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/scorer.d.ts +305 -0
- package/dist/lib/scorer.d.ts.map +1 -0
- package/dist/lib/scorer.js +796 -0
- package/dist/lib/scorer.js.map +1 -0
- package/dist/lib/scorer.test.d.ts +8 -0
- package/dist/lib/scorer.test.d.ts.map +1 -0
- package/dist/lib/scorer.test.js +974 -0
- package/dist/lib/scorer.test.js.map +1 -0
- package/dist/types.d.ts +14 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for OCR Review Scorer
|
|
3
|
+
*
|
|
4
|
+
* These tests prove the deterministic scoring workflows work correctly
|
|
5
|
+
* for prioritizing pages in large document review.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
8
|
+
import { OcrReviewScorer, createScorer, compareOcrToMarkdown, parsePagesJson, parseTablesJson, formatScoredPages, formatStats, DEFAULT_CONFIG,
|
|
9
|
+
// Financial verification
|
|
10
|
+
getConfidenceTier, categorizeByConfidence, runCheckSums, detectAnomalies, validateCrossQuery, runFinancialVerification, formatVerificationReport, } from './scorer.js';
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Test Fixtures
|
|
13
|
+
// ============================================================================
|
|
14
|
+
function createMockPage(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
page: 1,
|
|
17
|
+
status: 'pending',
|
|
18
|
+
total: 0,
|
|
19
|
+
verified: 0,
|
|
20
|
+
pending: 0,
|
|
21
|
+
flagged: 0,
|
|
22
|
+
rejected: 0,
|
|
23
|
+
avgConfidence: 0.9,
|
|
24
|
+
hasOcr: true,
|
|
25
|
+
ocrLineCount: 50,
|
|
26
|
+
hasCoverageGaps: false,
|
|
27
|
+
uncoveredCount: 0,
|
|
28
|
+
resolution: null,
|
|
29
|
+
classification: null,
|
|
30
|
+
isStale: false,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function createMockTable(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
id: 'table-1',
|
|
37
|
+
page_number: 1,
|
|
38
|
+
markdown: '| Col1 | Col2 |\n|------|------|\n| A | B |',
|
|
39
|
+
verification_status: 'pending',
|
|
40
|
+
confidence: 0.85,
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function createMockOcrBlocks() {
|
|
45
|
+
return [
|
|
46
|
+
{ text: 'Header Text', bbox: { x: 100, y: 50, width: 200, height: 20 }, confidence: 0.95 },
|
|
47
|
+
{ text: 'First paragraph content here.', bbox: { x: 50, y: 100, width: 400, height: 40 }, confidence: 0.88 },
|
|
48
|
+
{ text: 'Table cell 1', bbox: { x: 50, y: 200, width: 100, height: 20 }, confidence: 0.82 },
|
|
49
|
+
{ text: 'Table cell 2', bbox: { x: 150, y: 200, width: 100, height: 20 }, confidence: 0.80 },
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Core Scorer Tests
|
|
54
|
+
// ============================================================================
|
|
55
|
+
describe('OcrReviewScorer', () => {
|
|
56
|
+
let scorer;
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
scorer = new OcrReviewScorer();
|
|
59
|
+
});
|
|
60
|
+
describe('scorePage', () => {
|
|
61
|
+
it('should return zero score for high confidence page with no tables', () => {
|
|
62
|
+
const page = createMockPage({ avgConfidence: 0.95 });
|
|
63
|
+
const result = scorer.scorePage(page);
|
|
64
|
+
expect(result.score).toBeLessThan(10);
|
|
65
|
+
expect(result.tableCount).toBe(0);
|
|
66
|
+
expect(result.flags).not.toContain('has_tables');
|
|
67
|
+
});
|
|
68
|
+
it('should increase score for pages with tables', () => {
|
|
69
|
+
const page = createMockPage({ page: 1 });
|
|
70
|
+
const tables = [
|
|
71
|
+
createMockTable({ page_number: 1 }),
|
|
72
|
+
createMockTable({ page_number: 1, id: 'table-2' }),
|
|
73
|
+
];
|
|
74
|
+
scorer.loadTables(tables);
|
|
75
|
+
const result = scorer.scorePage(page);
|
|
76
|
+
expect(result.score).toBeGreaterThan(0);
|
|
77
|
+
expect(result.tableCount).toBe(2);
|
|
78
|
+
expect(result.breakdown.tableScore).toBe(20); // 2 * 10 default weight
|
|
79
|
+
expect(result.flags).toContain('has_tables');
|
|
80
|
+
});
|
|
81
|
+
it('should increase score for low confidence pages', () => {
|
|
82
|
+
const highConfPage = createMockPage({ avgConfidence: 0.95, page: 1 });
|
|
83
|
+
const lowConfPage = createMockPage({ avgConfidence: 0.5, page: 2 });
|
|
84
|
+
const highResult = scorer.scorePage(highConfPage);
|
|
85
|
+
const lowResult = scorer.scorePage(lowConfPage);
|
|
86
|
+
expect(lowResult.score).toBeGreaterThan(highResult.score);
|
|
87
|
+
expect(lowResult.breakdown.confidenceScore).toBeGreaterThan(highResult.breakdown.confidenceScore);
|
|
88
|
+
expect(lowResult.flags).toContain('low_confidence');
|
|
89
|
+
});
|
|
90
|
+
it('should add penalty for coverage gaps', () => {
|
|
91
|
+
const noGaps = createMockPage({ hasCoverageGaps: false });
|
|
92
|
+
const hasGaps = createMockPage({ hasCoverageGaps: true });
|
|
93
|
+
const noGapsResult = scorer.scorePage(noGaps);
|
|
94
|
+
const hasGapsResult = scorer.scorePage(hasGaps);
|
|
95
|
+
expect(hasGapsResult.score).toBeGreaterThan(noGapsResult.score);
|
|
96
|
+
expect(hasGapsResult.breakdown.coverageScore).toBe(DEFAULT_CONFIG.weights.coverageGap);
|
|
97
|
+
expect(hasGapsResult.flags).toContain('coverage_gaps');
|
|
98
|
+
});
|
|
99
|
+
it('should add penalty for flagged entities', () => {
|
|
100
|
+
const noFlagged = createMockPage({ flagged: 0 });
|
|
101
|
+
const hasFlagged = createMockPage({ flagged: 3 });
|
|
102
|
+
const noFlaggedResult = scorer.scorePage(noFlagged);
|
|
103
|
+
const hasFlaggedResult = scorer.scorePage(hasFlagged);
|
|
104
|
+
expect(hasFlaggedResult.score).toBeGreaterThan(noFlaggedResult.score);
|
|
105
|
+
expect(hasFlaggedResult.breakdown.flaggedScore).toBe(3 * DEFAULT_CONFIG.weights.flaggedEntity);
|
|
106
|
+
expect(hasFlaggedResult.flags).toContain('has_flagged');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('scoreAll', () => {
|
|
110
|
+
it('should sort pages by score descending (highest priority first)', () => {
|
|
111
|
+
const pages = [
|
|
112
|
+
createMockPage({ page: 1, avgConfidence: 0.95 }), // Low priority
|
|
113
|
+
createMockPage({ page: 2, avgConfidence: 0.5 }), // High priority (low confidence)
|
|
114
|
+
createMockPage({ page: 3, avgConfidence: 0.8 }), // Medium priority
|
|
115
|
+
];
|
|
116
|
+
const results = scorer.scoreAll(pages);
|
|
117
|
+
expect(results[0].page).toBe(2); // Lowest confidence = highest priority
|
|
118
|
+
expect(results[1].page).toBe(3);
|
|
119
|
+
expect(results[2].page).toBe(1); // Highest confidence = lowest priority
|
|
120
|
+
});
|
|
121
|
+
it('should correctly combine multiple scoring factors', () => {
|
|
122
|
+
const tables = [
|
|
123
|
+
createMockTable({ page_number: 2 }),
|
|
124
|
+
createMockTable({ page_number: 2, id: 'table-2' }),
|
|
125
|
+
createMockTable({ page_number: 2, id: 'table-3' }),
|
|
126
|
+
];
|
|
127
|
+
scorer.loadTables(tables);
|
|
128
|
+
const pages = [
|
|
129
|
+
createMockPage({ page: 1, avgConfidence: 0.6, hasCoverageGaps: true }), // Low conf + gaps
|
|
130
|
+
createMockPage({ page: 2, avgConfidence: 0.9 }), // 3 tables but high conf
|
|
131
|
+
createMockPage({ page: 3, avgConfidence: 0.7, flagged: 2 }), // Low conf + flagged
|
|
132
|
+
];
|
|
133
|
+
const results = scorer.scoreAll(pages);
|
|
134
|
+
// Page 3 should be highest: low conf + 2 flagged entities
|
|
135
|
+
// Page 1: low conf + gaps
|
|
136
|
+
// Page 2: 3 tables but high confidence
|
|
137
|
+
expect(results[0].page).toBe(3);
|
|
138
|
+
expect(results.map(r => r.page)).toEqual(expect.arrayContaining([1, 2, 3]));
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe('filter', () => {
|
|
142
|
+
it('should filter by status', () => {
|
|
143
|
+
const pages = [
|
|
144
|
+
createMockPage({ page: 1, status: 'pending' }),
|
|
145
|
+
createMockPage({ page: 2, status: 'complete' }),
|
|
146
|
+
createMockPage({ page: 3, status: 'flagged' }),
|
|
147
|
+
];
|
|
148
|
+
const pending = scorer.filter(pages, { status: 'pending' });
|
|
149
|
+
expect(pending.length).toBe(1);
|
|
150
|
+
expect(pending[0].page).toBe(1);
|
|
151
|
+
const multiple = scorer.filter(pages, { status: ['pending', 'flagged'] });
|
|
152
|
+
expect(multiple.length).toBe(2);
|
|
153
|
+
});
|
|
154
|
+
it('should filter by confidence range', () => {
|
|
155
|
+
const pages = [
|
|
156
|
+
createMockPage({ page: 1, avgConfidence: 0.95 }),
|
|
157
|
+
createMockPage({ page: 2, avgConfidence: 0.75 }),
|
|
158
|
+
createMockPage({ page: 3, avgConfidence: 0.5 }),
|
|
159
|
+
];
|
|
160
|
+
const lowConf = scorer.filter(pages, { maxConfidence: 0.8 });
|
|
161
|
+
expect(lowConf.length).toBe(2);
|
|
162
|
+
expect(lowConf.map(p => p.page)).toEqual([2, 3]);
|
|
163
|
+
const highConf = scorer.filter(pages, { minConfidence: 0.9 });
|
|
164
|
+
expect(highConf.length).toBe(1);
|
|
165
|
+
expect(highConf[0].page).toBe(1);
|
|
166
|
+
});
|
|
167
|
+
it('should filter by table count', () => {
|
|
168
|
+
const tables = [
|
|
169
|
+
createMockTable({ page_number: 1 }),
|
|
170
|
+
createMockTable({ page_number: 2, id: 't2' }),
|
|
171
|
+
createMockTable({ page_number: 2, id: 't3' }),
|
|
172
|
+
];
|
|
173
|
+
scorer.loadTables(tables);
|
|
174
|
+
const pages = [
|
|
175
|
+
createMockPage({ page: 1 }),
|
|
176
|
+
createMockPage({ page: 2 }),
|
|
177
|
+
createMockPage({ page: 3 }),
|
|
178
|
+
];
|
|
179
|
+
const withTables = scorer.filter(pages, { minTables: 1 });
|
|
180
|
+
expect(withTables.length).toBe(2);
|
|
181
|
+
const manyTables = scorer.filter(pages, { minTables: 2 });
|
|
182
|
+
expect(manyTables.length).toBe(1);
|
|
183
|
+
expect(manyTables[0].page).toBe(2);
|
|
184
|
+
const noTables = scorer.filter(pages, { maxTables: 0 });
|
|
185
|
+
expect(noTables.length).toBe(1);
|
|
186
|
+
expect(noTables[0].page).toBe(3);
|
|
187
|
+
});
|
|
188
|
+
it('should filter by coverage gaps', () => {
|
|
189
|
+
const pages = [
|
|
190
|
+
createMockPage({ page: 1, hasCoverageGaps: false }),
|
|
191
|
+
createMockPage({ page: 2, hasCoverageGaps: true }),
|
|
192
|
+
];
|
|
193
|
+
const withGaps = scorer.filter(pages, { hasGaps: true });
|
|
194
|
+
expect(withGaps.length).toBe(1);
|
|
195
|
+
expect(withGaps[0].page).toBe(2);
|
|
196
|
+
const noGaps = scorer.filter(pages, { hasGaps: false });
|
|
197
|
+
expect(noGaps.length).toBe(1);
|
|
198
|
+
expect(noGaps[0].page).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
describe('getAutoApprovable', () => {
|
|
202
|
+
it('should identify pages that can be auto-approved', () => {
|
|
203
|
+
const pages = [
|
|
204
|
+
createMockPage({ page: 1, avgConfidence: 0.98, status: 'pending', total: 0 }),
|
|
205
|
+
createMockPage({ page: 2, avgConfidence: 0.85, status: 'pending', total: 0 }), // Low conf
|
|
206
|
+
createMockPage({ page: 3, avgConfidence: 0.98, status: 'complete', total: 0 }), // Already done
|
|
207
|
+
createMockPage({ page: 4, avgConfidence: 0.98, status: 'pending', hasCoverageGaps: true }), // Has gaps
|
|
208
|
+
createMockPage({ page: 5, avgConfidence: 0.98, status: 'pending', flagged: 1 }), // Has flagged
|
|
209
|
+
];
|
|
210
|
+
// Add tables to page 1 to test table threshold
|
|
211
|
+
scorer.loadTables([createMockTable({ page_number: 6 })]);
|
|
212
|
+
pages.push(createMockPage({ page: 6, avgConfidence: 0.98, status: 'pending', total: 0 }));
|
|
213
|
+
const autoApprovable = scorer.getAutoApprovable(pages);
|
|
214
|
+
// Only page 1 should be auto-approvable (page 6 has tables)
|
|
215
|
+
expect(autoApprovable.length).toBe(1);
|
|
216
|
+
expect(autoApprovable[0].page).toBe(1);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
describe('getRequireReview', () => {
|
|
220
|
+
it('should identify pages that need human review', () => {
|
|
221
|
+
const tables = [createMockTable({ page_number: 3 })];
|
|
222
|
+
scorer.loadTables(tables);
|
|
223
|
+
const pages = [
|
|
224
|
+
createMockPage({ page: 1, avgConfidence: 0.98 }), // High conf, no issues
|
|
225
|
+
createMockPage({ page: 2, avgConfidence: 0.5 }), // Low confidence
|
|
226
|
+
createMockPage({ page: 3, avgConfidence: 0.95 }), // Has table
|
|
227
|
+
createMockPage({ page: 4, hasCoverageGaps: true }), // Has gaps
|
|
228
|
+
createMockPage({ page: 5, flagged: 2 }), // Has flagged
|
|
229
|
+
];
|
|
230
|
+
const needsReview = scorer.getRequireReview(pages);
|
|
231
|
+
// Pages 2, 3, 4, 5 should need review
|
|
232
|
+
expect(needsReview.length).toBe(4);
|
|
233
|
+
expect(needsReview.map(p => p.page)).not.toContain(1);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
describe('computeStats', () => {
|
|
237
|
+
it('should compute accurate statistics', () => {
|
|
238
|
+
const tables = [
|
|
239
|
+
createMockTable({ page_number: 1 }),
|
|
240
|
+
createMockTable({ page_number: 2, id: 't2' }),
|
|
241
|
+
createMockTable({ page_number: 2, id: 't3' }),
|
|
242
|
+
];
|
|
243
|
+
scorer.loadTables(tables);
|
|
244
|
+
const pages = [
|
|
245
|
+
createMockPage({ page: 1, status: 'pending', avgConfidence: 0.95 }),
|
|
246
|
+
createMockPage({ page: 2, status: 'pending', avgConfidence: 0.6 }),
|
|
247
|
+
createMockPage({ page: 3, status: 'complete', avgConfidence: 0.9 }),
|
|
248
|
+
createMockPage({ page: 4, status: 'flagged', avgConfidence: 0.4, hasCoverageGaps: true }),
|
|
249
|
+
];
|
|
250
|
+
const stats = scorer.computeStats(pages);
|
|
251
|
+
expect(stats.totalPages).toBe(4);
|
|
252
|
+
expect(stats.pagesWithTables).toBe(2);
|
|
253
|
+
expect(stats.totalTables).toBe(3);
|
|
254
|
+
expect(stats.avgConfidence).toBeCloseTo(0.7125, 2);
|
|
255
|
+
expect(stats.lowConfidencePages).toBe(2); // Pages with conf < 0.7
|
|
256
|
+
expect(stats.pagesWithGaps).toBe(1);
|
|
257
|
+
expect(stats.byStatus).toEqual({
|
|
258
|
+
pending: 2,
|
|
259
|
+
complete: 1,
|
|
260
|
+
flagged: 1,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
// ============================================================================
|
|
266
|
+
// Scoring Strategy Tests
|
|
267
|
+
// ============================================================================
|
|
268
|
+
describe('Scoring Strategies', () => {
|
|
269
|
+
it('should create scorer with direct strategy (ignores tables)', () => {
|
|
270
|
+
const scorer = createScorer('direct');
|
|
271
|
+
const tables = [createMockTable({ page_number: 1 })];
|
|
272
|
+
scorer.loadTables(tables);
|
|
273
|
+
const page = createMockPage({ page: 1, avgConfidence: 0.8 });
|
|
274
|
+
const result = scorer.scorePage(page);
|
|
275
|
+
expect(result.breakdown.tableScore).toBe(0);
|
|
276
|
+
expect(result.breakdown.structureScore).toBe(0);
|
|
277
|
+
});
|
|
278
|
+
it('should create scorer with structure strategy (emphasizes tables)', () => {
|
|
279
|
+
const scorer = createScorer('structure');
|
|
280
|
+
const tables = [createMockTable({ page_number: 1 })];
|
|
281
|
+
scorer.loadTables(tables);
|
|
282
|
+
const page = createMockPage({ page: 1 });
|
|
283
|
+
const result = scorer.scorePage(page);
|
|
284
|
+
// Structure strategy has tableCount weight of 20 vs default 10
|
|
285
|
+
expect(result.breakdown.tableScore).toBe(20);
|
|
286
|
+
});
|
|
287
|
+
it('should create scorer with comparison strategy', () => {
|
|
288
|
+
const scorer = createScorer('comparison');
|
|
289
|
+
const result = scorer.scorePage(createMockPage());
|
|
290
|
+
// Comparison strategy has lower table weight
|
|
291
|
+
expect(result.score).toBeDefined();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
// ============================================================================
|
|
295
|
+
// OCR vs Markdown Comparison Tests
|
|
296
|
+
// ============================================================================
|
|
297
|
+
describe('compareOcrToMarkdown', () => {
|
|
298
|
+
it('should detect minimal difference for matching content', () => {
|
|
299
|
+
const ocrBlocks = [
|
|
300
|
+
{ text: 'Hello World', confidence: 0.95 },
|
|
301
|
+
{ text: 'This is a test.', confidence: 0.90 },
|
|
302
|
+
];
|
|
303
|
+
const markdown = 'Hello World\n\nThis is a test.';
|
|
304
|
+
const result = compareOcrToMarkdown(ocrBlocks, markdown);
|
|
305
|
+
expect(result.deltaPct).toBeLessThan(0.1);
|
|
306
|
+
expect(result.flags).not.toContain('high_char_delta');
|
|
307
|
+
});
|
|
308
|
+
it('should detect significant difference when content differs', () => {
|
|
309
|
+
const ocrBlocks = [
|
|
310
|
+
{ text: 'This is a very long paragraph with lots of content that was captured by OCR.', confidence: 0.85 },
|
|
311
|
+
];
|
|
312
|
+
const markdown = 'Short.';
|
|
313
|
+
const result = compareOcrToMarkdown(ocrBlocks, markdown);
|
|
314
|
+
expect(result.deltaPct).toBeGreaterThan(0.5);
|
|
315
|
+
expect(result.flags).toContain('high_char_delta');
|
|
316
|
+
});
|
|
317
|
+
it('should evaluate spatial integrity', () => {
|
|
318
|
+
const ocrBlocks = [
|
|
319
|
+
{ text: 'Line 1', bbox: { x: 0, y: 0, width: 100, height: 20 }, confidence: 0.9 },
|
|
320
|
+
{ text: 'Line 2', bbox: { x: 0, y: 30, width: 100, height: 20 }, confidence: 0.9 },
|
|
321
|
+
{ text: 'Line 3', bbox: { x: 0, y: 60, width: 100, height: 20 }, confidence: 0.9 },
|
|
322
|
+
];
|
|
323
|
+
const markdown = 'Line 1\n\nLine 2\n\nLine 3';
|
|
324
|
+
const result = compareOcrToMarkdown(ocrBlocks, markdown);
|
|
325
|
+
expect(result.spatialIntegrity).toBeGreaterThan(0.5);
|
|
326
|
+
});
|
|
327
|
+
it('should evaluate table structure preservation', () => {
|
|
328
|
+
const ocrBlocks = [
|
|
329
|
+
{ text: 'Col1', bbox: { x: 50, y: 0, width: 50, height: 20 } },
|
|
330
|
+
{ text: 'Col2', bbox: { x: 150, y: 0, width: 50, height: 20 } },
|
|
331
|
+
{ text: 'A', bbox: { x: 50, y: 30, width: 50, height: 20 } },
|
|
332
|
+
{ text: 'B', bbox: { x: 150, y: 30, width: 50, height: 20 } },
|
|
333
|
+
];
|
|
334
|
+
const markdown = '| Col1 | Col2 |\n|------|------|\n| A | B |';
|
|
335
|
+
const result = compareOcrToMarkdown(ocrBlocks, markdown);
|
|
336
|
+
expect(result.tableStructureScore).toBeGreaterThan(0.5);
|
|
337
|
+
});
|
|
338
|
+
it('should flag missing OCR', () => {
|
|
339
|
+
const ocrBlocks = [];
|
|
340
|
+
const markdown = 'Some content that exists in markdown but not OCR';
|
|
341
|
+
const result = compareOcrToMarkdown(ocrBlocks, markdown);
|
|
342
|
+
expect(result.flags).toContain('missing_ocr');
|
|
343
|
+
});
|
|
344
|
+
it('should flag missing markdown', () => {
|
|
345
|
+
const ocrBlocks = [
|
|
346
|
+
{ text: 'Content in OCR but not in markdown', confidence: 0.9 },
|
|
347
|
+
];
|
|
348
|
+
const markdown = '';
|
|
349
|
+
const result = compareOcrToMarkdown(ocrBlocks, markdown);
|
|
350
|
+
expect(result.flags).toContain('missing_markdown');
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
// ============================================================================
|
|
354
|
+
// JSON Parsing Tests
|
|
355
|
+
// ============================================================================
|
|
356
|
+
describe('JSON Parsing', () => {
|
|
357
|
+
it('should parse pages JSON array', () => {
|
|
358
|
+
const json = JSON.stringify([
|
|
359
|
+
{ page: 1, status: 'pending', avgConfidence: 0.9, total: 0, verified: 0, pending: 0, flagged: 0, rejected: 0, hasOcr: true, ocrLineCount: 50, hasCoverageGaps: false, uncoveredCount: 0, resolution: null, classification: null, isStale: false },
|
|
360
|
+
]);
|
|
361
|
+
const pages = parsePagesJson(json);
|
|
362
|
+
expect(pages.length).toBe(1);
|
|
363
|
+
expect(pages[0].page).toBe(1);
|
|
364
|
+
});
|
|
365
|
+
it('should parse pages JSON object with pages key', () => {
|
|
366
|
+
const json = JSON.stringify({
|
|
367
|
+
pages: [{ page: 1, status: 'pending', avgConfidence: 0.9, total: 0, verified: 0, pending: 0, flagged: 0, rejected: 0, hasOcr: true, ocrLineCount: 50, hasCoverageGaps: false, uncoveredCount: 0, resolution: null, classification: null, isStale: false }],
|
|
368
|
+
});
|
|
369
|
+
const pages = parsePagesJson(json);
|
|
370
|
+
expect(pages.length).toBe(1);
|
|
371
|
+
});
|
|
372
|
+
it('should parse tables JSON array', () => {
|
|
373
|
+
const json = JSON.stringify([
|
|
374
|
+
{ id: 't1', page_number: 1, markdown: '| A |', verification_status: 'pending' },
|
|
375
|
+
]);
|
|
376
|
+
const tables = parseTablesJson(json);
|
|
377
|
+
expect(tables.length).toBe(1);
|
|
378
|
+
expect(tables[0].id).toBe('t1');
|
|
379
|
+
});
|
|
380
|
+
it('should parse tables JSON object with tables key', () => {
|
|
381
|
+
const json = JSON.stringify({
|
|
382
|
+
tables: [{ id: 't1', page_number: 1, markdown: '| A |', verification_status: 'pending' }],
|
|
383
|
+
});
|
|
384
|
+
const tables = parseTablesJson(json);
|
|
385
|
+
expect(tables.length).toBe(1);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
// ============================================================================
|
|
389
|
+
// Output Formatting Tests
|
|
390
|
+
// ============================================================================
|
|
391
|
+
describe('Output Formatting', () => {
|
|
392
|
+
const sampleScored = [
|
|
393
|
+
{
|
|
394
|
+
page: 1,
|
|
395
|
+
score: 45.5,
|
|
396
|
+
breakdown: { tableScore: 20, confidenceScore: 15.5, coverageScore: 0, flaggedScore: 0, structureScore: 10, comparisonScore: 0 },
|
|
397
|
+
tableCount: 2,
|
|
398
|
+
avgConfidence: 0.69,
|
|
399
|
+
status: 'pending',
|
|
400
|
+
flags: ['has_tables', 'low_confidence'],
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
page: 2,
|
|
404
|
+
score: 10,
|
|
405
|
+
breakdown: { tableScore: 0, confidenceScore: 5, coverageScore: 0, flaggedScore: 0, structureScore: 5, comparisonScore: 0 },
|
|
406
|
+
tableCount: 0,
|
|
407
|
+
avgConfidence: 0.9,
|
|
408
|
+
status: 'complete',
|
|
409
|
+
flags: [],
|
|
410
|
+
},
|
|
411
|
+
];
|
|
412
|
+
it('should format as JSON', () => {
|
|
413
|
+
const output = formatScoredPages(sampleScored, 'json');
|
|
414
|
+
const parsed = JSON.parse(output);
|
|
415
|
+
expect(parsed).toBeInstanceOf(Array);
|
|
416
|
+
expect(parsed.length).toBe(2);
|
|
417
|
+
expect(parsed[0].page).toBe(1);
|
|
418
|
+
});
|
|
419
|
+
it('should format as JSONL', () => {
|
|
420
|
+
const output = formatScoredPages(sampleScored, 'jsonl');
|
|
421
|
+
const lines = output.split('\n');
|
|
422
|
+
expect(lines.length).toBe(2);
|
|
423
|
+
expect(JSON.parse(lines[0]).page).toBe(1);
|
|
424
|
+
expect(JSON.parse(lines[1]).page).toBe(2);
|
|
425
|
+
});
|
|
426
|
+
it('should format as CSV', () => {
|
|
427
|
+
const output = formatScoredPages(sampleScored, 'csv');
|
|
428
|
+
const lines = output.split('\n');
|
|
429
|
+
expect(lines[0]).toBe('page,score,tableCount,avgConfidence,status,flags');
|
|
430
|
+
expect(lines[1]).toContain('1');
|
|
431
|
+
expect(lines[1]).toContain('45.50');
|
|
432
|
+
});
|
|
433
|
+
it('should format as table', () => {
|
|
434
|
+
const output = formatScoredPages(sampleScored, 'table');
|
|
435
|
+
expect(output).toContain('Page');
|
|
436
|
+
expect(output).toContain('Score');
|
|
437
|
+
expect(output).toContain('Tables');
|
|
438
|
+
expect(output).toContain('has_tables');
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
describe('Stats Formatting', () => {
|
|
442
|
+
it('should format stats as JSON', () => {
|
|
443
|
+
const stats = {
|
|
444
|
+
totalPages: 100,
|
|
445
|
+
pagesWithTables: 20,
|
|
446
|
+
totalTables: 45,
|
|
447
|
+
avgConfidence: 0.85,
|
|
448
|
+
lowConfidencePages: 10,
|
|
449
|
+
pagesWithGaps: 5,
|
|
450
|
+
byStatus: { pending: 50, complete: 40, flagged: 10 },
|
|
451
|
+
byPriority: { high: 5, medium: 15, low: 80 },
|
|
452
|
+
estimatedReviewTime: { pages: 20, minutes: 25 },
|
|
453
|
+
};
|
|
454
|
+
const output = formatStats(stats, 'json');
|
|
455
|
+
const parsed = JSON.parse(output);
|
|
456
|
+
expect(parsed.totalPages).toBe(100);
|
|
457
|
+
expect(parsed.byPriority.high).toBe(5);
|
|
458
|
+
});
|
|
459
|
+
it('should format stats as table', () => {
|
|
460
|
+
const stats = {
|
|
461
|
+
totalPages: 100,
|
|
462
|
+
pagesWithTables: 20,
|
|
463
|
+
totalTables: 45,
|
|
464
|
+
avgConfidence: 0.85,
|
|
465
|
+
lowConfidencePages: 10,
|
|
466
|
+
pagesWithGaps: 5,
|
|
467
|
+
byStatus: { pending: 50, complete: 40, flagged: 10 },
|
|
468
|
+
byPriority: { high: 5, medium: 15, low: 80 },
|
|
469
|
+
estimatedReviewTime: { pages: 20, minutes: 25 },
|
|
470
|
+
};
|
|
471
|
+
const output = formatStats(stats, 'table');
|
|
472
|
+
expect(output).toContain('Total pages: 100');
|
|
473
|
+
expect(output).toContain('Pages with tables: 20');
|
|
474
|
+
expect(output).toContain('High (score >= 50): 5 pages');
|
|
475
|
+
expect(output).toContain('Estimated review: 20 pages');
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
// ============================================================================
|
|
479
|
+
// Workflow Integration Tests
|
|
480
|
+
// ============================================================================
|
|
481
|
+
describe('Workflow Integration', () => {
|
|
482
|
+
it('should complete full table-priority workflow', () => {
|
|
483
|
+
// Simulate a 50-page document with varying characteristics
|
|
484
|
+
const pages = [];
|
|
485
|
+
const tables = [];
|
|
486
|
+
for (let i = 1; i <= 50; i++) {
|
|
487
|
+
const confidence = 0.5 + Math.random() * 0.5; // 0.5 - 1.0
|
|
488
|
+
const hasTables = i % 5 === 0; // Every 5th page has tables
|
|
489
|
+
const hasGaps = i % 10 === 0; // Every 10th page has gaps
|
|
490
|
+
pages.push(createMockPage({
|
|
491
|
+
page: i,
|
|
492
|
+
avgConfidence: confidence,
|
|
493
|
+
hasCoverageGaps: hasGaps,
|
|
494
|
+
status: i <= 40 ? 'pending' : 'complete',
|
|
495
|
+
}));
|
|
496
|
+
if (hasTables) {
|
|
497
|
+
tables.push(createMockTable({ page_number: i, id: `table-${i}` }));
|
|
498
|
+
if (i % 15 === 0) {
|
|
499
|
+
// Some pages have multiple tables
|
|
500
|
+
tables.push(createMockTable({ page_number: i, id: `table-${i}-2` }));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const scorer = new OcrReviewScorer();
|
|
505
|
+
scorer.loadTables(tables);
|
|
506
|
+
// Step 1: Get statistics
|
|
507
|
+
const stats = scorer.computeStats(pages);
|
|
508
|
+
expect(stats.totalPages).toBe(50);
|
|
509
|
+
expect(stats.pagesWithTables).toBe(10); // 50/5 = 10 pages with tables
|
|
510
|
+
// Step 2: Get auto-approvable pages (high confidence, no tables, no issues)
|
|
511
|
+
const autoApprovable = scorer.getAutoApprovable(pages);
|
|
512
|
+
expect(autoApprovable.length).toBeGreaterThan(0);
|
|
513
|
+
// Step 3: Get pages requiring review
|
|
514
|
+
const needsReview = scorer.getRequireReview(pages);
|
|
515
|
+
expect(needsReview.length).toBeGreaterThan(0);
|
|
516
|
+
// Step 4: Score all and verify priority ordering
|
|
517
|
+
const scored = scorer.scoreAll(pages);
|
|
518
|
+
expect(scored[0].score).toBeGreaterThanOrEqual(scored[scored.length - 1].score);
|
|
519
|
+
// Step 5: Filter for table-heavy pages
|
|
520
|
+
const tablePages = scorer.filter(pages, { minTables: 1 });
|
|
521
|
+
expect(tablePages.length).toBe(10);
|
|
522
|
+
// Verify the highest priority pages have tables or low confidence
|
|
523
|
+
const topPriority = scored.slice(0, 5);
|
|
524
|
+
for (const page of topPriority) {
|
|
525
|
+
const hasIssue = page.tableCount > 0 ||
|
|
526
|
+
page.avgConfidence < 0.7 ||
|
|
527
|
+
page.flags.includes('coverage_gaps');
|
|
528
|
+
expect(hasIssue).toBe(true);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
it('should complete OCR comparison workflow', () => {
|
|
532
|
+
const ocrBlocks = createMockOcrBlocks();
|
|
533
|
+
const markdown = `# Header Text
|
|
534
|
+
|
|
535
|
+
First paragraph content here.
|
|
536
|
+
|
|
537
|
+
| Table cell 1 | Table cell 2 |
|
|
538
|
+
|--------------|--------------|`;
|
|
539
|
+
const comparison = compareOcrToMarkdown(ocrBlocks, markdown);
|
|
540
|
+
// Should detect reasonable similarity
|
|
541
|
+
expect(comparison.deltaPct).toBeLessThan(0.5);
|
|
542
|
+
expect(comparison.spatialIntegrity).toBeGreaterThan(0);
|
|
543
|
+
expect(comparison.tableStructureScore).toBeGreaterThan(0.5);
|
|
544
|
+
});
|
|
545
|
+
it('should handle empty document gracefully', () => {
|
|
546
|
+
const scorer = new OcrReviewScorer();
|
|
547
|
+
scorer.loadTables([]);
|
|
548
|
+
const pages = [];
|
|
549
|
+
const stats = scorer.computeStats(pages);
|
|
550
|
+
expect(stats.totalPages).toBe(0);
|
|
551
|
+
expect(stats.avgConfidence).toBe(0);
|
|
552
|
+
expect(stats.byPriority).toEqual({ high: 0, medium: 0, low: 0 });
|
|
553
|
+
});
|
|
554
|
+
it('should handle all pages with tables', () => {
|
|
555
|
+
const pages = [
|
|
556
|
+
createMockPage({ page: 1 }),
|
|
557
|
+
createMockPage({ page: 2 }),
|
|
558
|
+
createMockPage({ page: 3 }),
|
|
559
|
+
];
|
|
560
|
+
const tables = [
|
|
561
|
+
createMockTable({ page_number: 1 }),
|
|
562
|
+
createMockTable({ page_number: 2 }),
|
|
563
|
+
createMockTable({ page_number: 3 }),
|
|
564
|
+
];
|
|
565
|
+
const scorer = new OcrReviewScorer();
|
|
566
|
+
scorer.loadTables(tables);
|
|
567
|
+
const autoApprovable = scorer.getAutoApprovable(pages);
|
|
568
|
+
expect(autoApprovable.length).toBe(0); // All have tables
|
|
569
|
+
const needsReview = scorer.getRequireReview(pages);
|
|
570
|
+
expect(needsReview.length).toBe(3);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
// ============================================================================
|
|
574
|
+
// Custom Configuration Tests
|
|
575
|
+
// ============================================================================
|
|
576
|
+
describe('Custom Configuration', () => {
|
|
577
|
+
it('should apply custom weights', () => {
|
|
578
|
+
const scorer = new OcrReviewScorer({
|
|
579
|
+
weights: {
|
|
580
|
+
tableCount: 100, // Much higher table weight
|
|
581
|
+
inverseConfidence: 10,
|
|
582
|
+
coverageGap: 5,
|
|
583
|
+
flaggedEntity: 5,
|
|
584
|
+
structurePenalty: 5,
|
|
585
|
+
comparisonDelta: 5,
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
const tables = [createMockTable({ page_number: 1 })];
|
|
589
|
+
scorer.loadTables(tables);
|
|
590
|
+
const page = createMockPage({ page: 1 });
|
|
591
|
+
const result = scorer.scorePage(page);
|
|
592
|
+
expect(result.breakdown.tableScore).toBe(100);
|
|
593
|
+
});
|
|
594
|
+
it('should apply custom thresholds', () => {
|
|
595
|
+
const scorer = new OcrReviewScorer({
|
|
596
|
+
thresholds: {
|
|
597
|
+
autoApproveConfidence: 0.99, // Very strict
|
|
598
|
+
requireReviewConfidence: 0.9,
|
|
599
|
+
maxTablesForAutoApprove: 0,
|
|
600
|
+
minOcrLinesForContent: 5,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
const pages = [
|
|
604
|
+
createMockPage({ page: 1, avgConfidence: 0.98, status: 'pending' }),
|
|
605
|
+
createMockPage({ page: 2, avgConfidence: 0.995, status: 'pending' }),
|
|
606
|
+
];
|
|
607
|
+
const autoApprovable = scorer.getAutoApprovable(pages);
|
|
608
|
+
expect(autoApprovable.length).toBe(1);
|
|
609
|
+
expect(autoApprovable[0].page).toBe(2); // Only 0.995 passes 0.99 threshold
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
// ============================================================================
|
|
613
|
+
// Financial Verification Tests
|
|
614
|
+
// ============================================================================
|
|
615
|
+
describe('Financial Verification', () => {
|
|
616
|
+
describe('getConfidenceTier', () => {
|
|
617
|
+
it('should categorize high confidence as auto_approve', () => {
|
|
618
|
+
expect(getConfidenceTier(0.96)).toBe('auto_approve');
|
|
619
|
+
expect(getConfidenceTier(0.99)).toBe('auto_approve');
|
|
620
|
+
expect(getConfidenceTier(1.0)).toBe('auto_approve');
|
|
621
|
+
});
|
|
622
|
+
it('should categorize medium confidence as spot_check', () => {
|
|
623
|
+
expect(getConfidenceTier(0.70)).toBe('spot_check');
|
|
624
|
+
expect(getConfidenceTier(0.85)).toBe('spot_check');
|
|
625
|
+
expect(getConfidenceTier(0.94)).toBe('spot_check');
|
|
626
|
+
});
|
|
627
|
+
it('should categorize low confidence as manual_review', () => {
|
|
628
|
+
expect(getConfidenceTier(0.69)).toBe('manual_review');
|
|
629
|
+
expect(getConfidenceTier(0.50)).toBe('manual_review');
|
|
630
|
+
expect(getConfidenceTier(0.0)).toBe('manual_review');
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
describe('categorizeByConfidence', () => {
|
|
634
|
+
it('should categorize pages into tiers', () => {
|
|
635
|
+
const pages = [
|
|
636
|
+
createMockPage({ page: 1, avgConfidence: 0.98 }),
|
|
637
|
+
createMockPage({ page: 2, avgConfidence: 0.85 }),
|
|
638
|
+
createMockPage({ page: 3, avgConfidence: 0.60 }),
|
|
639
|
+
createMockPage({ page: 4, avgConfidence: 0.75 }),
|
|
640
|
+
createMockPage({ page: 5, avgConfidence: 0.96 }),
|
|
641
|
+
];
|
|
642
|
+
const categorized = categorizeByConfidence(pages);
|
|
643
|
+
expect(categorized.auto_approve.length).toBe(2); // pages 1, 5
|
|
644
|
+
expect(categorized.spot_check.length).toBe(2); // pages 2, 4
|
|
645
|
+
expect(categorized.manual_review.length).toBe(1); // page 3
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
describe('runCheckSums', () => {
|
|
649
|
+
it('should validate balance sheet identity', () => {
|
|
650
|
+
// Valid balance sheet: Assets = Liabilities + Equity
|
|
651
|
+
const validValues = {
|
|
652
|
+
total_assets: 1000000,
|
|
653
|
+
total_liabilities: 600000,
|
|
654
|
+
equity: 400000,
|
|
655
|
+
};
|
|
656
|
+
const results = runCheckSums(validValues);
|
|
657
|
+
const balanceSheet = results.find(r => r.rule.name === 'Balance Sheet Identity');
|
|
658
|
+
expect(balanceSheet).toBeDefined();
|
|
659
|
+
expect(balanceSheet.result.passed).toBe(true);
|
|
660
|
+
});
|
|
661
|
+
it('should detect balance sheet imbalance', () => {
|
|
662
|
+
// Invalid: Assets don't equal Liabilities + Equity
|
|
663
|
+
const invalidValues = {
|
|
664
|
+
total_assets: 1000000,
|
|
665
|
+
total_liabilities: 600000,
|
|
666
|
+
equity: 300000, // Should be 400000
|
|
667
|
+
};
|
|
668
|
+
const results = runCheckSums(invalidValues);
|
|
669
|
+
const balanceSheet = results.find(r => r.rule.name === 'Balance Sheet Identity');
|
|
670
|
+
expect(balanceSheet).toBeDefined();
|
|
671
|
+
expect(balanceSheet.result.passed).toBe(false);
|
|
672
|
+
expect(balanceSheet.result.tolerancePct).toBeGreaterThan(1);
|
|
673
|
+
});
|
|
674
|
+
it('should validate net income calculation', () => {
|
|
675
|
+
const values = {
|
|
676
|
+
total_revenue: 5000000,
|
|
677
|
+
total_expenses: 4200000,
|
|
678
|
+
net_income: 800000,
|
|
679
|
+
};
|
|
680
|
+
const results = runCheckSums(values);
|
|
681
|
+
const netIncomeCheck = results.find(r => r.rule.name === 'Net Income Check');
|
|
682
|
+
expect(netIncomeCheck).toBeDefined();
|
|
683
|
+
expect(netIncomeCheck.result.passed).toBe(true);
|
|
684
|
+
});
|
|
685
|
+
it('should allow small tolerance for rounding', () => {
|
|
686
|
+
// 0.5% rounding difference should pass
|
|
687
|
+
const values = {
|
|
688
|
+
total_assets: 1000000,
|
|
689
|
+
total_liabilities: 600000,
|
|
690
|
+
equity: 405000, // 0.5% over
|
|
691
|
+
};
|
|
692
|
+
const results = runCheckSums(values);
|
|
693
|
+
const balanceSheet = results.find(r => r.rule.name === 'Balance Sheet Identity');
|
|
694
|
+
expect(balanceSheet.result.passed).toBe(true);
|
|
695
|
+
});
|
|
696
|
+
it('should skip rules with insufficient data', () => {
|
|
697
|
+
const values = {
|
|
698
|
+
total_assets: 1000000, // Only one field
|
|
699
|
+
};
|
|
700
|
+
const results = runCheckSums(values);
|
|
701
|
+
// Should not validate balance sheet without liabilities/equity
|
|
702
|
+
expect(results.length).toBe(0);
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
describe('detectAnomalies', () => {
|
|
706
|
+
it('should detect decimal point error', () => {
|
|
707
|
+
const historical = [
|
|
708
|
+
{ revenue: 5000000 },
|
|
709
|
+
{ revenue: 5200000 },
|
|
710
|
+
{ revenue: 4800000 },
|
|
711
|
+
];
|
|
712
|
+
const current = {
|
|
713
|
+
revenue: 50000, // Missing two zeros - 100x error
|
|
714
|
+
};
|
|
715
|
+
const anomalies = detectAnomalies(current, historical);
|
|
716
|
+
expect(anomalies.length).toBe(1);
|
|
717
|
+
expect(anomalies[0].field).toBe('revenue');
|
|
718
|
+
expect(anomalies[0].possibleCause).toContain('decimal point error');
|
|
719
|
+
});
|
|
720
|
+
it('should not flag normal variance', () => {
|
|
721
|
+
const historical = [
|
|
722
|
+
{ revenue: 5000000 },
|
|
723
|
+
{ revenue: 5200000 },
|
|
724
|
+
{ revenue: 4800000 },
|
|
725
|
+
];
|
|
726
|
+
const current = {
|
|
727
|
+
revenue: 5100000, // Normal year-over-year change
|
|
728
|
+
};
|
|
729
|
+
const anomalies = detectAnomalies(current, historical);
|
|
730
|
+
expect(anomalies.length).toBe(0);
|
|
731
|
+
});
|
|
732
|
+
it('should handle insufficient historical data', () => {
|
|
733
|
+
const historical = [{ revenue: 5000000 }]; // Only one data point
|
|
734
|
+
const current = { revenue: 50000 };
|
|
735
|
+
const anomalies = detectAnomalies(current, historical);
|
|
736
|
+
expect(anomalies.length).toBe(0); // Can't detect anomalies without enough history
|
|
737
|
+
});
|
|
738
|
+
it('should detect significant outliers', () => {
|
|
739
|
+
const historical = [
|
|
740
|
+
{ profit: 100000 },
|
|
741
|
+
{ profit: 120000 },
|
|
742
|
+
{ profit: 110000 },
|
|
743
|
+
{ profit: 105000 },
|
|
744
|
+
];
|
|
745
|
+
const current = {
|
|
746
|
+
profit: 1000000, // 10x higher than historical
|
|
747
|
+
};
|
|
748
|
+
const anomalies = detectAnomalies(current, historical);
|
|
749
|
+
expect(anomalies.length).toBe(1);
|
|
750
|
+
expect(anomalies[0].zScore).toBeGreaterThan(3);
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
describe('validateCrossQuery', () => {
|
|
754
|
+
it('should validate total assets breakdown', () => {
|
|
755
|
+
const values = {
|
|
756
|
+
total_assets: 1000000,
|
|
757
|
+
current_assets: 400000,
|
|
758
|
+
non_current_assets: 600000,
|
|
759
|
+
};
|
|
760
|
+
const results = validateCrossQuery(values);
|
|
761
|
+
const assetsCheck = results.find(r => r.rule.name === 'Total Assets Components');
|
|
762
|
+
expect(assetsCheck).toBeDefined();
|
|
763
|
+
expect(assetsCheck.passed).toBe(true);
|
|
764
|
+
});
|
|
765
|
+
it('should detect missing component in total', () => {
|
|
766
|
+
const values = {
|
|
767
|
+
total_assets: 1000000,
|
|
768
|
+
current_assets: 400000,
|
|
769
|
+
// non_current_assets missing but total doesn't match
|
|
770
|
+
};
|
|
771
|
+
const results = validateCrossQuery(values);
|
|
772
|
+
const assetsCheck = results.find(r => r.rule.name === 'Total Assets Components');
|
|
773
|
+
expect(assetsCheck).toBeDefined();
|
|
774
|
+
expect(assetsCheck.passed).toBe(false);
|
|
775
|
+
expect(assetsCheck.differencePercent).toBeGreaterThan(2);
|
|
776
|
+
});
|
|
777
|
+
it('should allow tolerance for incomplete components', () => {
|
|
778
|
+
const values = {
|
|
779
|
+
total_revenue: 1000000,
|
|
780
|
+
product_revenue: 800000,
|
|
781
|
+
service_revenue: 180000,
|
|
782
|
+
// other_revenue = 20000 is missing but within tolerance
|
|
783
|
+
};
|
|
784
|
+
const results = validateCrossQuery(values);
|
|
785
|
+
const revenueCheck = results.find(r => r.rule.name === 'Total Revenue Components');
|
|
786
|
+
expect(revenueCheck).toBeDefined();
|
|
787
|
+
expect(revenueCheck.passed).toBe(true); // Within 2% tolerance
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
describe('runFinancialVerification', () => {
|
|
791
|
+
it('should pass for valid financial data with high confidence', () => {
|
|
792
|
+
const values = {
|
|
793
|
+
total_assets: 1000000,
|
|
794
|
+
total_liabilities: 600000,
|
|
795
|
+
equity: 400000,
|
|
796
|
+
};
|
|
797
|
+
const report = runFinancialVerification(values, 0.98);
|
|
798
|
+
expect(report.overallStatus).toBe('pass');
|
|
799
|
+
expect(report.confidenceTier).toBe('auto_approve');
|
|
800
|
+
expect(report.issues.length).toBe(0);
|
|
801
|
+
});
|
|
802
|
+
it('should fail for low confidence', () => {
|
|
803
|
+
const values = {
|
|
804
|
+
total_assets: 1000000,
|
|
805
|
+
total_liabilities: 600000,
|
|
806
|
+
equity: 400000,
|
|
807
|
+
};
|
|
808
|
+
const report = runFinancialVerification(values, 0.50);
|
|
809
|
+
expect(report.overallStatus).toBe('fail');
|
|
810
|
+
expect(report.confidenceTier).toBe('manual_review');
|
|
811
|
+
expect(report.issues.some(i => i.includes('Low OCR confidence'))).toBe(true);
|
|
812
|
+
});
|
|
813
|
+
it('should fail for check-sum violations', () => {
|
|
814
|
+
const values = {
|
|
815
|
+
total_assets: 1000000,
|
|
816
|
+
total_liabilities: 600000,
|
|
817
|
+
equity: 200000, // Wrong!
|
|
818
|
+
};
|
|
819
|
+
const report = runFinancialVerification(values, 0.98);
|
|
820
|
+
expect(report.overallStatus).toBe('fail');
|
|
821
|
+
expect(report.issues.some(i => i.includes('Balance Sheet Identity failed'))).toBe(true);
|
|
822
|
+
});
|
|
823
|
+
it('should fail for anomalies with historical data', () => {
|
|
824
|
+
const values = {
|
|
825
|
+
revenue: 50000, // Should be ~5 million
|
|
826
|
+
};
|
|
827
|
+
const historical = [
|
|
828
|
+
{ revenue: 5000000 },
|
|
829
|
+
{ revenue: 5200000 },
|
|
830
|
+
{ revenue: 4800000 },
|
|
831
|
+
];
|
|
832
|
+
const report = runFinancialVerification(values, 0.98, historical);
|
|
833
|
+
expect(report.overallStatus).toBe('fail');
|
|
834
|
+
expect(report.anomalies.length).toBe(1);
|
|
835
|
+
});
|
|
836
|
+
it('should provide recommendations for issues', () => {
|
|
837
|
+
const values = {
|
|
838
|
+
total_assets: 1000000,
|
|
839
|
+
total_liabilities: 600000,
|
|
840
|
+
equity: 200000,
|
|
841
|
+
};
|
|
842
|
+
const report = runFinancialVerification(values, 0.75);
|
|
843
|
+
expect(report.recommendations.length).toBeGreaterThan(0);
|
|
844
|
+
expect(report.recommendations.some(r => r.includes('spot-check'))).toBe(true);
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
describe('formatVerificationReport', () => {
|
|
848
|
+
it('should format passing report', () => {
|
|
849
|
+
const values = {
|
|
850
|
+
total_assets: 1000000,
|
|
851
|
+
total_liabilities: 600000,
|
|
852
|
+
equity: 400000,
|
|
853
|
+
};
|
|
854
|
+
const report = runFinancialVerification(values, 0.98);
|
|
855
|
+
const formatted = formatVerificationReport(report);
|
|
856
|
+
expect(formatted).toContain('Overall Status: PASS');
|
|
857
|
+
expect(formatted).toContain('AUTO APPROVE');
|
|
858
|
+
});
|
|
859
|
+
it('should format failing report with details', () => {
|
|
860
|
+
const values = {
|
|
861
|
+
total_assets: 1000000,
|
|
862
|
+
total_liabilities: 600000,
|
|
863
|
+
equity: 200000,
|
|
864
|
+
};
|
|
865
|
+
const report = runFinancialVerification(values, 0.50);
|
|
866
|
+
const formatted = formatVerificationReport(report);
|
|
867
|
+
expect(formatted).toContain('Overall Status: FAIL');
|
|
868
|
+
expect(formatted).toContain('Check-Sum Validations');
|
|
869
|
+
expect(formatted).toContain('Issues:');
|
|
870
|
+
expect(formatted).toContain('Recommendations:');
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
// ============================================================================
|
|
875
|
+
// End-to-End Financial Workflow Test
|
|
876
|
+
// ============================================================================
|
|
877
|
+
describe('Financial Document Review Workflow', () => {
|
|
878
|
+
it('should complete full financial verification workflow', () => {
|
|
879
|
+
// Simulate extracting data from a financial report
|
|
880
|
+
// Step 1: OCR extracts pages with confidence scores
|
|
881
|
+
const pages = [
|
|
882
|
+
createMockPage({ page: 1, avgConfidence: 0.98, total: 2 }), // Cover page
|
|
883
|
+
createMockPage({ page: 2, avgConfidence: 0.92, total: 5 }), // Balance sheet
|
|
884
|
+
createMockPage({ page: 3, avgConfidence: 0.95, total: 4 }), // Income statement
|
|
885
|
+
createMockPage({ page: 4, avgConfidence: 0.65, total: 8 }), // Notes (low quality)
|
|
886
|
+
createMockPage({ page: 5, avgConfidence: 0.88, total: 3 }), // Cash flow
|
|
887
|
+
];
|
|
888
|
+
const tables = [
|
|
889
|
+
createMockTable({ page_number: 2, id: 'balance-sheet-1' }),
|
|
890
|
+
createMockTable({ page_number: 2, id: 'balance-sheet-2' }),
|
|
891
|
+
createMockTable({ page_number: 3, id: 'income-statement' }),
|
|
892
|
+
createMockTable({ page_number: 5, id: 'cash-flow' }),
|
|
893
|
+
];
|
|
894
|
+
// Step 2: Categorize by confidence tier
|
|
895
|
+
const categorized = categorizeByConfidence(pages);
|
|
896
|
+
expect(categorized.auto_approve.length).toBe(2); // Pages 1 (98%), 3 (95%)
|
|
897
|
+
expect(categorized.spot_check.length).toBe(2); // Pages 2, 5
|
|
898
|
+
expect(categorized.manual_review.length).toBe(1); // Page 4 (65%)
|
|
899
|
+
// Step 3: Score pages for review priority
|
|
900
|
+
const scorer = new OcrReviewScorer();
|
|
901
|
+
scorer.loadTables(tables);
|
|
902
|
+
const scored = scorer.scoreAll(pages);
|
|
903
|
+
// Highest priority pages should include those with low confidence or tables
|
|
904
|
+
// Page 4 has lowest confidence (0.65), pages 2,3,5 have tables
|
|
905
|
+
const topPriorityPages = scored.slice(0, 3).map(s => s.page);
|
|
906
|
+
expect(topPriorityPages).toContain(4); // Low confidence page
|
|
907
|
+
expect(topPriorityPages).toContain(2); // Has 2 tables
|
|
908
|
+
// Step 4: Simulate extracted financial values
|
|
909
|
+
const extractedValues = {
|
|
910
|
+
total_assets: 10500000,
|
|
911
|
+
current_assets: 3500000,
|
|
912
|
+
non_current_assets: 7000000,
|
|
913
|
+
total_liabilities: 6200000,
|
|
914
|
+
current_liabilities: 2100000,
|
|
915
|
+
non_current_liabilities: 4100000,
|
|
916
|
+
equity: 4300000,
|
|
917
|
+
working_capital: 1400000, // current_assets - current_liabilities
|
|
918
|
+
total_revenue: 25000000,
|
|
919
|
+
total_expenses: 22500000,
|
|
920
|
+
net_income: 2500000,
|
|
921
|
+
};
|
|
922
|
+
// Step 5: Run financial verification
|
|
923
|
+
const report = runFinancialVerification(extractedValues, 0.96);
|
|
924
|
+
// Should pass - data is consistent and confidence is high enough
|
|
925
|
+
expect(report.overallStatus).toBe('pass');
|
|
926
|
+
expect(report.checkSums.every(c => c.result.passed)).toBe(true);
|
|
927
|
+
// Step 6: Run cross-query validation
|
|
928
|
+
const crossResults = validateCrossQuery(extractedValues);
|
|
929
|
+
expect(crossResults.every(r => r.passed)).toBe(true);
|
|
930
|
+
// Step 7: Test anomaly detection with historical data
|
|
931
|
+
const historicalData = [
|
|
932
|
+
{ total_revenue: 24000000, net_income: 2400000 },
|
|
933
|
+
{ total_revenue: 23000000, net_income: 2300000 },
|
|
934
|
+
{ total_revenue: 22000000, net_income: 2200000 },
|
|
935
|
+
];
|
|
936
|
+
const anomalies = detectAnomalies(extractedValues, historicalData);
|
|
937
|
+
expect(anomalies.length).toBe(0); // No anomalies - growth is normal
|
|
938
|
+
// Step 8: Format report for analyst
|
|
939
|
+
const formatted = formatVerificationReport(report);
|
|
940
|
+
expect(formatted).toContain('PASS');
|
|
941
|
+
});
|
|
942
|
+
it('should catch common OCR errors in financial data', () => {
|
|
943
|
+
// Simulate OCR errors that commonly occur
|
|
944
|
+
// Error 1: Decimal point shift
|
|
945
|
+
const historicalRevenue = [
|
|
946
|
+
{ revenue: 5000000 },
|
|
947
|
+
{ revenue: 5200000 },
|
|
948
|
+
{ revenue: 4800000 },
|
|
949
|
+
];
|
|
950
|
+
const decimalError = { revenue: 50000 }; // Missing 2 zeros
|
|
951
|
+
const anomalies = detectAnomalies(decimalError, historicalRevenue);
|
|
952
|
+
expect(anomalies.length).toBe(1);
|
|
953
|
+
expect(anomalies[0].possibleCause).toContain('decimal point error');
|
|
954
|
+
// Error 2: Balance sheet doesn't balance
|
|
955
|
+
const unbalanced = {
|
|
956
|
+
total_assets: 1000000,
|
|
957
|
+
total_liabilities: 500000,
|
|
958
|
+
equity: 400000, // Should be 500000
|
|
959
|
+
};
|
|
960
|
+
const checksumResults = runCheckSums(unbalanced);
|
|
961
|
+
const balanceCheck = checksumResults.find(r => r.rule.name === 'Balance Sheet Identity');
|
|
962
|
+
expect(balanceCheck.result.passed).toBe(false);
|
|
963
|
+
// Error 3: Components don't sum to total
|
|
964
|
+
const missingComponent = {
|
|
965
|
+
total_assets: 1000000,
|
|
966
|
+
current_assets: 300000,
|
|
967
|
+
// Missing non_current_assets that should be 700000
|
|
968
|
+
};
|
|
969
|
+
const crossResults = validateCrossQuery(missingComponent);
|
|
970
|
+
const assetsCheck = crossResults.find(r => r.rule.name === 'Total Assets Components');
|
|
971
|
+
expect(assetsCheck.passed).toBe(false);
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
//# sourceMappingURL=scorer.test.js.map
|