Skip to content

Commit 1b3deb4

Browse files
strukalexclaude
andcommitted
ocr.recoverNumericZerosFromCheckboxes: accept cells whose stripped
content parses to the recovery value Self-fix for cells like '$ 0\n:selected:' — Azure DI's layout step recognized the digit AND a stray selection mark in the same cell. The original eligibility rule rejected the cell because the digit broke the "no digits or letters after stripping" check, even though the digit IS the recovery value and the cell still has an overlapping selection mark (the checkbox-as-zero signal we're targeting). cellIsEligibleByContent() now additionally accepts when the stripped content parses to the configured recoveryValue. The mark-overlap gate is unchanged — we still require a real selection-mark polygon inside the cell, so this only widens the content rule, not the trust model. Cells with a 0 in content but no overlapping mark stay rejected (different pattern, out of strict "checkbox-as-zero" scope), and non-zero digit cells (e.g. '$ 5') still fail even with an overlapping mark since the stripped content doesn't equal the recoveryValue. The three new tests cover all three cases. Validated on prod benchmark dfaddb26-… (standalone Python script on the experiment branch): +11 recoveries on top of the 111 from title + A + B = 122 total. The 11 are exactly the cells the diagnostic flagged as having both a digit AND an overlapping selection mark (10 in one sample with '$ 0\n:selected:' on every income row, 1 in another with '$ 0\n:unselected:'). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7ca17b2 commit 1b3deb4

2 files changed

Lines changed: 159 additions & 2 deletions

File tree

apps/temporal/src/activities/ocr-recover-numeric-zeros.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,4 +967,137 @@ describe("recoverNumericZerosFromCheckboxes", () => {
967967
).toBe("positional-anchor");
968968
expect(out.metadata?.applied).toBe(36);
969969
});
970+
971+
// -------------------------------------------------------------------------
972+
// Self-fix #1 — accept cells where stripped content parses to recoveryValue
973+
// -------------------------------------------------------------------------
974+
975+
it("Self-fix #1: recovers '$ 0' cells where Azure saw both the digit AND a selection mark", async () => {
976+
// Cell content looks like '$ 0\n:selected:' — Azure DI's layout step
977+
// recognized both the 0 digit AND a stray selection mark in the same
978+
// cell. Original rule rejected because of the digit; new rule accepts
979+
// since the digit IS the recovery value AND a mark overlaps the cell.
980+
const cells: TableCell[] = [
981+
makeCell(0, 0, "Declare all income", [0, 0, 100, 10], "columnHeader"),
982+
makeCell(1, 1, "Applicant", [50, 10, 70, 20], "columnHeader"),
983+
makeCell(2, 0, "Net Employment Income", [0, 20, 50, 30]),
984+
makeCell(2, 1, "$ 0\n:selected:", [50, 20, 70, 30]),
985+
makeCell(3, 0, "Rental Income", [0, 30, 50, 40]),
986+
makeCell(3, 1, "$ 0.00", [50, 30, 70, 40]),
987+
];
988+
// Both cells have an overlapping selection mark (required by the
989+
// existing eligibility gate, which Self-fix #1 does not relax).
990+
const marks: SelectionMark[] = [
991+
makeMark("selected", [55, 22, 60, 27]),
992+
makeMark("unselected", [55, 32, 60, 37]),
993+
];
994+
const fields: Record<string, Record<string, unknown>> = {
995+
applicant_net_employment_income: { type: "number", confidence: 0.3 },
996+
applicant_rental_income: { type: "number", confidence: 0.5 },
997+
};
998+
const ocrResult = makeOcrResult({ cells, fields, marks });
999+
1000+
const out = await recoverNumericZerosFromCheckboxes({
1001+
ocrResult,
1002+
tables: [
1003+
{
1004+
find: { firstCellTextContains: "Declare all income" },
1005+
columns: [{ prefix: "applicant_", headerEquals: "Applicant" }],
1006+
rows: [
1007+
{
1008+
suffix: "net_employment_income",
1009+
labelEquals: "Net Employment Income",
1010+
},
1011+
{ suffix: "rental_income", labelEquals: "Rental Income" },
1012+
],
1013+
},
1014+
],
1015+
});
1016+
1017+
const recovered = out.ocrResult.documents?.[0]?.fields;
1018+
if (!recovered) throw new Error("missing");
1019+
expect(recovered.applicant_net_employment_income.valueNumber).toBe(0);
1020+
expect(recovered.applicant_rental_income.valueNumber).toBe(0);
1021+
expect(out.metadata?.applied).toBe(2);
1022+
});
1023+
1024+
it("Self-fix #1: a '$ 0' cell with NO overlapping mark is still rejected", async () => {
1025+
// The new rule widens content eligibility but does NOT relax the
1026+
// selection-mark requirement. A '$ 0' cell with zero overlapping marks
1027+
// must remain rejected — that's a "model missed a recognized 0" pattern,
1028+
// not a "checkbox-as-zero" pattern.
1029+
const cells: TableCell[] = [
1030+
makeCell(0, 0, "Declare all income", [0, 0, 100, 10], "columnHeader"),
1031+
makeCell(1, 1, "Applicant", [50, 10, 70, 20], "columnHeader"),
1032+
makeCell(2, 0, "Net Employment Income", [0, 20, 50, 30]),
1033+
makeCell(2, 1, "$ 0", [50, 20, 70, 30]),
1034+
];
1035+
const fields: Record<string, Record<string, unknown>> = {
1036+
applicant_net_employment_income: { type: "number", confidence: 0.5 },
1037+
};
1038+
const ocrResult = makeOcrResult({ cells, fields, marks: [] });
1039+
1040+
const out = await recoverNumericZerosFromCheckboxes({
1041+
ocrResult,
1042+
tables: [
1043+
{
1044+
find: { firstCellTextContains: "Declare all income" },
1045+
columns: [{ prefix: "applicant_", headerEquals: "Applicant" }],
1046+
rows: [
1047+
{
1048+
suffix: "net_employment_income",
1049+
labelEquals: "Net Employment Income",
1050+
},
1051+
],
1052+
},
1053+
],
1054+
});
1055+
1056+
expect(out.metadata?.applied).toBe(0);
1057+
expect(
1058+
(out.metadata?.skippedByReason as Record<string, number>)[
1059+
"no_selection_mark_in_cell"
1060+
],
1061+
).toBe(1);
1062+
});
1063+
1064+
it("Self-fix #1: non-zero digit cells are still rejected even with overlapping mark", async () => {
1065+
// Cell content '$ 5' with a mark overlap: stripped='5' parses to 5, not
1066+
// 0 (the recoveryValue), so eligibility still fails. Critical: this
1067+
// protects against turning real-value cells into 0.
1068+
const cells: TableCell[] = [
1069+
makeCell(0, 0, "Declare all income", [0, 0, 100, 10], "columnHeader"),
1070+
makeCell(1, 1, "Applicant", [50, 10, 70, 20], "columnHeader"),
1071+
makeCell(2, 0, "Net Employment Income", [0, 20, 50, 30]),
1072+
makeCell(2, 1, "$ 5", [50, 20, 70, 30]),
1073+
];
1074+
const marks = [makeMark("unselected", [55, 22, 60, 27])];
1075+
const fields: Record<string, Record<string, unknown>> = {
1076+
applicant_net_employment_income: { type: "number", confidence: 0.5 },
1077+
};
1078+
const ocrResult = makeOcrResult({ cells, fields, marks });
1079+
1080+
const out = await recoverNumericZerosFromCheckboxes({
1081+
ocrResult,
1082+
tables: [
1083+
{
1084+
find: { firstCellTextContains: "Declare all income" },
1085+
columns: [{ prefix: "applicant_", headerEquals: "Applicant" }],
1086+
rows: [
1087+
{
1088+
suffix: "net_employment_income",
1089+
labelEquals: "Net Employment Income",
1090+
},
1091+
],
1092+
},
1093+
],
1094+
});
1095+
1096+
expect(out.metadata?.applied).toBe(0);
1097+
expect(
1098+
(out.metadata?.skippedByReason as Record<string, number>)[
1099+
"cell_has_digits_or_letters"
1100+
],
1101+
).toBe(1);
1102+
});
9701103
});

apps/temporal/src/activities/ocr-recover-numeric-zeros.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,26 @@ function cellHasSelectionMark(
255255
return false;
256256
}
257257

258+
/**
259+
* A cell is eligible if, after stripping the configured tokens and collapsing
260+
* whitespace, ANY of these holds:
261+
* - the remaining string is empty (the only thing in the cell was a
262+
* currency/selection-mark marker — classic checkbox-as-zero pattern);
263+
* - the remaining string contains no digits and no letters (e.g. lone
264+
* punctuation left over after stripping);
265+
* - the remaining string parses to the configured `recoveryValue` (e.g.
266+
* `"0"`, `"0.00"`, `"0,00"` when recoveryValue=0). This covers cells
267+
* like `"$ 0\n:selected:"` where Azure DI's layout step recognized the
268+
* digit AND a stray selection mark in the same cell — the custom model
269+
* emitted null but the digit is provably present.
270+
*
271+
* The selection-mark-overlap gate is applied separately by the caller, so we
272+
* still require a real checkbox glyph in the cell.
273+
*/
258274
function cellIsEligibleByContent(
259275
content: string,
260276
stripTokens: string[],
277+
recoveryValue?: number,
261278
): boolean {
262279
let stripped = content;
263280
for (const token of stripTokens) {
@@ -267,7 +284,12 @@ function cellIsEligibleByContent(
267284
}
268285
stripped = stripped.replace(/\s+/g, "");
269286
if (stripped.length === 0) return true;
270-
return !/[A-Za-z0-9]/.test(stripped);
287+
if (!/[A-Za-z0-9]/.test(stripped)) return true;
288+
if (typeof recoveryValue === "number") {
289+
const parsed = Number(stripped.replace(",", "."));
290+
if (!Number.isNaN(parsed) && parsed === recoveryValue) return true;
291+
}
292+
return false;
271293
}
272294

273295
function fieldIsEmpty(field: AzureDocumentFieldValue | undefined): boolean {
@@ -842,7 +864,9 @@ export async function recoverNumericZerosFromCheckboxes(
842864
skipped.push({ fieldKey, reason: "cell_not_found" });
843865
continue;
844866
}
845-
if (!cellIsEligibleByContent(cell.content, stripTokens)) {
867+
if (
868+
!cellIsEligibleByContent(cell.content, stripTokens, recoveryValue)
869+
) {
846870
skipped.push({ fieldKey, reason: "cell_has_digits_or_letters" });
847871
continue;
848872
}

0 commit comments

Comments
 (0)