fix(collection-ai): re-read sideways invoice photos turned upright
A sideways Bestobell invoice was read as doc 10045064 / a bank-details line; turned 270 deg the same photo reads correctly (10245084, PO202610010MRE, rubber insertion @ R365). When no printed PO number resolves, retry the photo at 270 and 90 deg and keep the first read whose PO resolves (within the tick's time budget). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f8294b4e1d
commit
79d54ce3fd
2 changed files with 95 additions and 7 deletions
|
|
@ -435,10 +435,16 @@ function makeDeps(over: Partial<CollectionAiDeps> = {}) {
|
|||
const bytes = Buffer.from(contents[filename] ?? `bytes of ${filename}`);
|
||||
return { base64: bytes.toString("base64"), contentType: "image/jpeg", bytes };
|
||||
});
|
||||
// Rotation stub: marks the bytes so a readDocument mock can tell which turn it got.
|
||||
const rotateImage = vi.fn(async (bytes: Buffer, angle: number) => {
|
||||
const rotated = Buffer.from(`rot${angle}:${bytes.toString()}`);
|
||||
return { base64: rotated.toString("base64"), contentType: "image/jpeg" };
|
||||
});
|
||||
const deps = {
|
||||
readDocument,
|
||||
pairLines,
|
||||
fetchFile,
|
||||
rotateImage,
|
||||
now: () => NOW,
|
||||
...over,
|
||||
} as CollectionAiDeps & {
|
||||
|
|
@ -875,6 +881,48 @@ describe("checkCollectionCard", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("re-reads a sideways photo turned upright when no printed PO number resolves", async () => {
|
||||
seedNss();
|
||||
const readDocument = vi.fn(async (img: { base64: string }) =>
|
||||
Buffer.from(img.base64, "base64").toString().startsWith("rot270:")
|
||||
? nssRead({ document_no: "10245084", po_refs: [PO_CEM] })
|
||||
: nssRead({ document_no: "10045064", po_refs: ["PO202610010019CEM/207"] }),
|
||||
);
|
||||
const { deps } = makeDeps({ readDocument });
|
||||
|
||||
const r = await checkCollectionCard(PO_CEM, { deps });
|
||||
|
||||
expect(readDocument).toHaveBeenCalledTimes(2);
|
||||
const read = reads()[0];
|
||||
expect(read.document_no).toBe("10245084");
|
||||
expect(read.po_refs).toEqual([PO_CEM]);
|
||||
expect(read.notes as string[]).toContain("Read with the photo turned 270°.");
|
||||
expect(r.files_read).toBe(1);
|
||||
});
|
||||
|
||||
it("reads an upright photo once when its PO number resolves", async () => {
|
||||
seedNss();
|
||||
const { deps } = makeDeps();
|
||||
|
||||
await checkCollectionCard(PO_CEM, { deps });
|
||||
|
||||
expect(deps.readDocument).toHaveBeenCalledTimes(1);
|
||||
expect(deps.rotateImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the original read when no turn resolves a PO number either", async () => {
|
||||
seedNss();
|
||||
const readDocument = vi.fn(async () => nssRead({ po_refs: ["ORDER 99887"] }));
|
||||
const { deps } = makeDeps({ readDocument });
|
||||
|
||||
await checkCollectionCard(PO_CEM, { deps });
|
||||
|
||||
expect(readDocument).toHaveBeenCalledTimes(3);
|
||||
const read = reads()[0];
|
||||
expect(read.po_refs).toEqual(["ORDER 99887"]);
|
||||
expect((read.notes as string[]).some((n) => n.startsWith("Read with the photo turned"))).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the card's PO when no printed PO reference resolves, and says so", async () => {
|
||||
seedNss();
|
||||
const { deps } = makeDeps({
|
||||
|
|
@ -1042,7 +1090,8 @@ describe("runCollectionAiTick", () => {
|
|||
|
||||
const r = await runCollectionAiTick(deps);
|
||||
|
||||
expect(deps.readDocument).toHaveBeenCalledTimes(TICK_MAX_FILES);
|
||||
// Files, not AI calls: a photo whose PO doesn't resolve is also re-read turned.
|
||||
expect(deps.fetchFile).toHaveBeenCalledTimes(TICK_MAX_FILES);
|
||||
expect(r.summary?.files_read).toBe(TICK_MAX_FILES);
|
||||
expect(deps.fetchFile.mock.calls[0][1]).toBe("p12.jpg");
|
||||
expect(deps.fetchFile.mock.calls.map((c) => c[1])).not.toContain("p1.jpg");
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export const TICK_LEASE_MS = 10 * 60_000;
|
|||
|
||||
export interface CollectionAiDeps {
|
||||
readDocument?: typeof readDocumentImage;
|
||||
/** Turns an image by `angle` degrees clockwise (default: sharp). */
|
||||
rotateImage?: (bytes: Buffer, angle: number) => Promise<{ base64: string; contentType: string }>;
|
||||
pairLines?: typeof pairDocumentLines;
|
||||
fetchFile?: (
|
||||
rec: { collection: string; id: string },
|
||||
|
|
@ -386,6 +388,23 @@ async function defaultFetchFile(
|
|||
return { base64: bytes.toString("base64"), contentType, bytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drivers often photograph landscape invoices sideways, and the AI misreads
|
||||
* sideways text badly (wrong numbers, bank details taken for line items).
|
||||
* When a read names no PO we know, the photo is re-read at these turns and
|
||||
* the first read whose PO number resolves wins.
|
||||
*/
|
||||
const RETRY_ROTATIONS = [270, 90] as const;
|
||||
|
||||
async function defaultRotateImage(
|
||||
bytes: Buffer,
|
||||
angle: number,
|
||||
): Promise<{ base64: string; contentType: string }> {
|
||||
const { default: sharp } = await import("sharp");
|
||||
const out = await sharp(bytes).rotate(angle).jpeg({ quality: 90 }).toBuffer();
|
||||
return { base64: out.toString("base64"), contentType: "image/jpeg" };
|
||||
}
|
||||
|
||||
type ReadFields = Omit<CollectionDocRead, "id">;
|
||||
|
||||
function emptyRead(f: FileRef, now: Date): ReadFields {
|
||||
|
|
@ -503,6 +522,7 @@ async function checkCard(
|
|||
const readDocument = deps.readDocument ?? readDocumentImage;
|
||||
const pairLines = deps.pairLines ?? pairDocumentLines;
|
||||
const fetchFile = deps.fetchFile ?? defaultFetchFile;
|
||||
const rotateImage = deps.rotateImage ?? defaultRotateImage;
|
||||
const nowFn = deps.now ?? (() => new Date());
|
||||
const maxFiles = opts.maxFiles ?? TICK_MAX_FILES;
|
||||
const actorName = opts.actorName ? `${ACTOR_NAME} (run by ${opts.actorName})` : ACTOR_NAME;
|
||||
|
|
@ -548,6 +568,29 @@ async function checkCard(
|
|||
/** Hashes whose AI read failed in THIS run: copies aren't re-sent. */
|
||||
const failedByHash = new Map<string, string>();
|
||||
const queued: Array<{ file: FileRef; fields: ReadFields; replaceId: string | null }> = [];
|
||||
let poIndex: PoIndex | null = null;
|
||||
const getPoIndex = async () => (poIndex ??= await loadPoIndex(pb));
|
||||
|
||||
/** Read a photo; if no PO number it names resolves, retry it turned. */
|
||||
const readOriented = async (fetched: { base64: string; contentType: string; bytes: Buffer }) => {
|
||||
const first = await readDocument({ base64: fetched.base64, contentType: fetched.contentType });
|
||||
if (first.doc_type === "other") return first;
|
||||
if (resolveRefs(await getPoIndex(), first.po_refs).length > 0) return first;
|
||||
for (const angle of RETRY_ROTATIONS) {
|
||||
if (opts.deadline !== undefined && nowFn().getTime() >= opts.deadline) break;
|
||||
let turned: DocumentReadResult;
|
||||
try {
|
||||
turned = await readDocument(await rotateImage(fetched.bytes, angle));
|
||||
} catch (e) {
|
||||
if (e instanceof AiDisabledError) throw e;
|
||||
continue;
|
||||
}
|
||||
if (resolveRefs(await getPoIndex(), turned.po_refs).length > 0) {
|
||||
return { ...turned, notes: [...turned.notes, `Read with the photo turned ${angle}°.`] };
|
||||
}
|
||||
}
|
||||
return first;
|
||||
};
|
||||
|
||||
const readOneFile = async (file: FileRef): Promise<void> => {
|
||||
const existing = readsByFile.get(fileKey(file));
|
||||
|
|
@ -599,7 +642,7 @@ async function checkCard(
|
|||
|
||||
let fields: ReadFields;
|
||||
try {
|
||||
const read = await readDocument({ base64: fetched.base64, contentType: fetched.contentType });
|
||||
const read = await readOriented(fetched);
|
||||
fields = fromResult(file, hash, read, now);
|
||||
result.files_read += 1;
|
||||
} catch (e) {
|
||||
|
|
@ -658,7 +701,6 @@ async function checkCard(
|
|||
if (docs.length === 0) return result;
|
||||
|
||||
// 4. Pair each document with the lines of every PO it names.
|
||||
let poIndex: PoIndex | null = null;
|
||||
const candidates = new Map<string, Candidate[]>();
|
||||
const lineDocs = new Map<string, CollectionDocRead[]>();
|
||||
const linesById = new Map(cardLines.map((l) => [l.id, l]));
|
||||
|
|
@ -668,10 +710,7 @@ async function checkCard(
|
|||
for (const doc of docs) {
|
||||
try {
|
||||
let fromRefs: PoRef[] = [];
|
||||
if (doc.po_refs.length > 0) {
|
||||
poIndex ??= await loadPoIndex(pb);
|
||||
fromRefs = resolveRefs(poIndex, doc.po_refs);
|
||||
}
|
||||
if (doc.po_refs.length > 0) fromRefs = resolveRefs(await getPoIndex(), doc.po_refs);
|
||||
// The findings speak about the POs the document names (or the card's
|
||||
// PO when none resolved) ...
|
||||
const fellBackToPo: string | null = fromRefs.length === 0 ? poNumber : null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue