Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ export function ReviewClient({
reviewStatus,
extractionStatus,
detectedPageCount,
unreadRotatedContent,
entries: initialEntries,
returnLogbookId,
canEdit,
Expand All @@ -525,6 +526,7 @@ export function ReviewClient({
reviewStatus: ReviewStatus;
extractionStatus: ExtractionStatus;
detectedPageCount: number | null;
unreadRotatedContent: boolean;
entries: ReviewEntry[];
returnLogbookId: string | null;
canEdit: boolean;
Expand Down Expand Up @@ -651,6 +653,20 @@ export function ReviewClient({
</p>
)}

{/* A missed entry has nothing to review against — no low-confidence field
to catch the eye — so the only recoverable outcome is saying so. */}
{unreadRotatedContent && (
<p
className="mt-2 rounded-md border border-annun-amber/40 px-3 py-2 text-xs text-annun-amber"
style={{ background: "var(--amb-bg)" }}
>
There is sideways or rotated content on this page (often a sticker
affixed at 90°) that couldn&apos;t be read in full. Compare against the
paper — if an entry is missing, add it with{" "}
<strong>Add an entry the extractor missed</strong> below.
</p>
)}

<button
onClick={() => setShowRaw((s) => !s)}
className="mt-3 text-xs text-dim underline hover:text-ink"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default async function ReviewPage({
const { data: page } = await supabase
.from("page")
.select(
"id, aircraft_id, logbook_id, page_sequence, storage_path, ocr_text, review_status, extraction_status, detected_page_count",
"id, aircraft_id, logbook_id, page_sequence, storage_path, ocr_text, review_status, extraction_status, detected_page_count, unread_rotated_content",
)
.eq("id", pageId)
.single();
Expand Down Expand Up @@ -201,6 +201,7 @@ export default async function ReviewPage({
reviewStatus={page.review_status}
extractionStatus={page.extraction_status}
detectedPageCount={page.detected_page_count}
unreadRotatedContent={page.unread_rotated_content ?? false}
entries={reviewEntries}
returnLogbookId={returnLogbookId ?? null}
canEdit={canEdit}
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/app/help/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,17 @@ const SECTIONS: Section[] = [
showing where that value was read from, so you can confirm it without hunting the whole
page (snippets appear once a page is extracted under the current model; re-extract older
pages to get them). The full page image sits alongside the entries too. Editing an entry
marks it confirmed. You can <strong>re-extract</strong> a page (e.g. if a multi-page
marks it confirmed.
</p>
<p className="mt-3">
<strong>Stickers at odd angles.</strong> Shops stick labels wherever there&apos;s room, so
one page can carry an upright sticker and another rotated 90°. Each sticker is normally its
own entry, and a page is read in every orientation. If rotated content is spotted but
can&apos;t be read in full, the page gets a second look automatically — and if that still
doesn&apos;t resolve it, <strong>Review shows an amber warning on that page</strong> asking
you to compare against the paper. That matters more than a wrong value does: a low
confidence score flags a field you can see, but an entry that was never extracted has
nothing to flag. Use <strong>Add an entry the extractor missed</strong> to key it in. You can <strong>re-extract</strong> a page (e.g. if a multi-page
entry wasn&apos;t linked) right from the review screen — it replaces that page&apos;s
entries. The <strong>Logbook pages</strong> view (in the left nav) lists every captured
scan grouped by logbook with its <strong>Needs review / Processing</strong> status, and can
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lib/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export type Page = {
extraction_status: ExtractionStatus;
extraction_error: string | null;
detected_page_count: number | null;
/**
* Rotated content was visible but not fully read, even after the follow-up
* pass (0052). Review warns on it — a missed entry has nothing to review
* against, so a visible flag is the only recoverable outcome.
*/
unread_rotated_content: boolean;
extracted_at: string | null;
created_at: string;
updated_at: string;
Expand Down
86 changes: 86 additions & 0 deletions apps/web/src/lib/extraction/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,92 @@ export async function extractFromImage(
? Math.round(parsed.detected_page_count)
: 1,
raw_text: typeof parsed.raw_text === "string" ? parsed.raw_text : "",
unread_rotated_content: parsed.unread_rotated_content === true,
entries: Array.isArray(parsed.entries) ? parsed.entries.filter(isEntry) : [],
};
}

/**
* Should the page KEEP its "rotated content unread" warning after the retry?
*
* Clear it only on evidence: the retry must have actually returned something AND
* no longer report anything outstanding. Getting this backwards would suppress
* the warning on exactly the pages that need it — a silent miss is the failure
* mode this whole change exists to remove — so it errs toward keeping it.
*/
export function stillUnreadAfterRetry(second: {
unread_rotated_content: boolean;
entries: unknown[];
}): boolean {
return second.unread_rotated_content || second.entries.length === 0;
}

/** Prompt for the follow-up pass. Deliberately narrow: ONLY the rotated content. */
const ROTATED_PASS_PROMPT = `Your previous pass over this page reported rotated or sideways content that was not fully read.

Read it now. Rotate the page mentally as needed — content may run bottom-to-top, top-to-bottom along an edge, upside down, or at an angle.

Return ONLY entries from that rotated content. Do NOT return entries you already read upright — they are captured, and repeating them creates duplicates the owner has to clean up. If, on a second look, there is genuinely nothing rotated that you can read, return an empty entries array and set unread_rotated_content appropriately.`;

/**
* Second, targeted pass for a page whose first pass flagged rotated content.
*
* Runs on the SAME image with a narrower prompt rather than rotating the bytes:
* a vision model reads rotated text when it is told to look, and re-encoding
* would mean importing sharp into app code — which `package.json` deliberately
* avoids (it is pinned for a CVE precisely because user-uploaded images reach
* it via image optimization, and nothing in the app imports it).
*
* Only runs when the model asked for it, so the extra call lands on the few
* pages that need it rather than on every page — extraction is bounded by a
* per-user daily cap and a global dollar ceiling.
*/
export async function extractRotatedFromImage(
imageBase64: string,
mediaType: ImageMediaType,
): Promise<ExtractionResult> {
const client = getAnthropic();

const response = await client.messages.create({
model: EXTRACTION_MODEL,
max_tokens: 16000,
system: EXTRACTION_SYSTEM_PROMPT,
thinking: { type: "adaptive" },
output_config: {
effort: "medium",
format: { type: "json_schema", schema: EXTRACTION_JSON_SCHEMA },
},
messages: [
{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: mediaType, data: imageBase64 } },
{ type: "text", text: ROTATED_PASS_PROMPT },
],
},
],
});

if (response.stop_reason === "refusal" || response.stop_reason === "max_tokens") {
// Never fatal: the first pass's entries are already good. Report "still
// unread" so the page keeps its warning.
return { detected_page_count: 1, raw_text: "", unread_rotated_content: true, entries: [] };
}

const text = response.content
.filter((b): b is Extract<typeof b, { type: "text" }> => b.type === "text")
.map((b) => b.text)
.join("");

try {
const parsed = JSON.parse(text) as ExtractionResult;
return {
detected_page_count: 1,
raw_text: typeof parsed.raw_text === "string" ? parsed.raw_text : "",
unread_rotated_content: parsed.unread_rotated_content === true,
entries: Array.isArray(parsed.entries) ? parsed.entries.filter(isEntry) : [],
};
} catch {
return { detected_page_count: 1, raw_text: "", unread_rotated_content: true, entries: [] };
}
}
28 changes: 27 additions & 1 deletion apps/web/src/lib/extraction/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/database.types";
import { extractFromImage } from "./extract";
import { extractFromImage, extractRotatedFromImage, stillUnreadAfterRetry } from "./extract";
import { safeIsoDate } from "./date";
import {
ENTRY_FIELDS,
Expand Down Expand Up @@ -117,6 +117,31 @@ export async function extractPage(

const result = await extractFromImage(base64, "image/jpeg");

// Shops affix stickers wherever there's room, so a page can carry an upright
// sticker and a 90°-rotated one. Reported from the field: only the upright
// one came back. When the first pass says it saw rotated content it couldn't
// read, spend one more call on it — targeted at that content only.
//
// Best-effort: the first pass's entries are already saved-worthy, so a
// failure here must never cost them. `rotatedStillUnread` drives the warning
// the review screen shows, because a missed entry has nothing to review
// against — the only recoverable outcome is telling the owner to look.
let rotatedStillUnread = result.unread_rotated_content;
if (result.unread_rotated_content) {
try {
const second = await extractRotatedFromImage(base64, "image/jpeg");
result.entries.push(...second.entries);
// Cleared only if the follow-up actually read something and no longer
// reports anything outstanding.
rotatedStillUnread = stillUnreadAfterRetry(second);
} catch (e) {
// Constant format string, values as arguments: an interpolated template
// passed to a format function lets an injected specifier forge the log
// line (semgrep javascript.lang.security.audit.unsafe-formatstring).
console.error("[extract] rotated second pass failed for page %s: %s", page.id, (e as Error).message);
}
}

// The model can emit calendar-invalid dates (e.g. "1987-11-31"); coerce each
// to a Postgres-safe date up front so one bad date can't fail the whole page.
for (const e of result.entries) e.entry_date = safeIsoDate(e.entry_date);
Expand Down Expand Up @@ -167,6 +192,7 @@ export async function extractPage(
ocr_text: result.raw_text || null,
extraction_confidence: minConfidence,
detected_page_count: result.detected_page_count,
unread_rotated_content: rotatedStillUnread,
extraction_status: "extracted",
extraction_error: null,
extracted_at: new Date().toISOString(),
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/lib/extraction/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ export type ExtractionResult = {
// Phase-1 full-text search has something to index even before classic OCR is
// added as a routing pre-pass.
raw_text: string;
/**
* The model can see rotated/sideways content it did NOT fully read (a sticker
* affixed at 90°, say). Set by the first pass to request a second, targeted
* one — and persisted on the page so review can warn even if that pass also
* comes up short. A missed entry has nothing to review against, so the point
* is to turn a silent omission into a visible flag.
*/
unread_rotated_content: boolean;
entries: ExtractedEntry[];
};

Expand Down Expand Up @@ -149,9 +157,14 @@ export const EXTRACTION_JSON_SCHEMA = {
description: "Number of distinct logbook pages visible in this image (1, or 2 for a two-page spread).",
},
raw_text: { type: "string", description: "Full plain-text transcription of everything legible on the page." },
unread_rotated_content: {
type: "boolean",
description:
"True if rotated/sideways content (e.g. a sticker at 90°) is visible but was NOT fully read in this pass. Triggers a follow-up pass; false when every orientation has been read.",
},
entries: { type: "array", items: ENTRY_SCHEMA },
},
required: ["detected_page_count", "raw_text", "entries"],
required: ["detected_page_count", "raw_text", "unread_rotated_content", "entries"],
};

export const EXTRACTION_SYSTEM_PROMPT = `You extract structured data from photographed or scanned aircraft maintenance logbook pages (airframe, engine, or propeller logs).
Expand All @@ -163,7 +176,9 @@ Rules:
- Pages mix PRINTED and HANDWRITTEN content, often within a single entry: typed/stamped work descriptions, printed inspection or 337/8130 stickers, and pre-printed AD/SB reference numbers alongside handwritten dates, hobbs/tach, signatures, and notes. Extract both kinds and merge them into the correct fields — do not ignore typed text or handwritten annotations on the same entry.
- Some pages are not maintenance entries at all (cover pages, aircraft/engine/prop general-information pages, blank pages). Return an empty entries array for those; still fill raw_text with whatever is printed.
- A single image often contains TWO facing logbook pages (a spread). Report detected_page_count accordingly and return entries from both.
- One logbook page may contain multiple dated entries — return one object per entry, in top-to-bottom order.
- One logbook page may contain multiple dated entries — return one object per entry. Order them by reading position (top-to-bottom for upright content), but ORDER NEVER JUSTIFIES OMITTING ONE: an entry that doesn't fit the normal flow still gets its own object.
- ORIENTATION. Shops affix adhesive stickers wherever there is room, so a single page often carries SEVERAL stickers at DIFFERENT orientations: some upright, others ROTATED 90° (reading bottom-to-top or top-to-bottom along the page edge), occasionally upside down or at an angle. Read the page in every orientation before you answer. **Each sticker is normally its OWN separate entry** — a rotated sticker beside an upright one is a second entry, not decoration on the first, and must not be skipped because it doesn't match the page's dominant text direction. This is a known real-world miss: pages with one upright and one vertical sticker have come back with only the upright one extracted.
- If you can see rotated or sideways content that you could NOT fully read in this pass, set unread_rotated_content=true. Setting it is not a failure — it tells the owner (and a follow-up pass) that something is there. Set it false only when you are satisfied you have read every orientation on the page.
- For every entry, set confidence (0 to 1) for how sure you are overall, AND fill field_confidence with a separate 0-to-1 score for EVERY field — including fields you set to null (a field that is confidently absent scores high; an illegible one scores low). Be conservative: a smudged or ambiguous value should score low so the owner checks it.
- Also fill field_boxes: for each field, give the bounding box of where that value appears on the image as the array [x, y, w, h] in fractions of the FULL image — x,y is the top-left corner and w,h the size, all between 0 and 1 (e.g. a hobbs reading in the upper-right might be [0.72, 0.08, 0.14, 0.05]). If a field is absent or you cannot locate it, use [0, 0, 0, 0]. Boxes may be approximate; they only help the owner find the value on the page.
- Numbers like hobbs/tach: transcribe digits exactly as written; if a digit is ambiguous, score that field low rather than guessing.
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/lib/oauth/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,13 @@ export async function logDenied(
// shape of the header is enough to separate "sent nothing", "sent a
// malformed header" and "sent a token we rejected".
const code = err instanceof ApiError ? err.code : "server_error";
console.error(`[api/v1] denied ${path} — ${code} (no identifiable client)`);
// Constant format string, values as arguments. `path` comes from the
// request URL, so it is attacker-influenced: interpolating it into the
// format string itself would let a crafted request forge log lines.
console.error("[api/v1] denied %s — %s (no identifiable client)", path, code);
return;
}
console.error(`[api/v1] denied ${path}${row.error} (client ${row.client_id})`);
console.error("[api/v1] denied %s%s (client %s)", path, row.error, row.client_id);
await createServiceClient().from("oauth_access_log").insert(row);
} catch (logErr) {
console.error("[api/v1] failed to record a denial:", (logErr as Error).message);
Expand Down
56 changes: 56 additions & 0 deletions apps/web/test/extraction-rotated.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { stillUnreadAfterRetry } from "../src/lib/extraction/extract";
import { EXTRACTION_JSON_SCHEMA, EXTRACTION_SYSTEM_PROMPT } from "../src/lib/extraction/schema";

// From the field: a page carried an upright sticker and one rotated 90°, and
// only the upright one was extracted. The rotated entry wasn't wrong, it was
// ABSENT — and an absent entry has no low-confidence field to catch the eye, so
// the owner had no way to know. These guard the two halves of the fix: telling
// the model to look, and keeping the warning when it still can't.

test("the warning is cleared only when the retry actually read something", () => {
// Retry found entries and reports nothing outstanding → safe to clear.
assert.equal(
stillUnreadAfterRetry({ unread_rotated_content: false, entries: [{}] }),
false,
);
});

test("the warning SURVIVES a retry that returned nothing", () => {
// The dangerous case: an empty retry must not be read as "all clear". If this
// inverted, the page would look fully extracted while an entry was missing —
// exactly the silent miss being fixed.
assert.equal(
stillUnreadAfterRetry({ unread_rotated_content: false, entries: [] }),
true,
"an empty retry is not evidence the page is clean",
);
});

test("the warning survives a retry that still reports rotated content", () => {
assert.equal(stillUnreadAfterRetry({ unread_rotated_content: true, entries: [{}] }), true);
assert.equal(stillUnreadAfterRetry({ unread_rotated_content: true, entries: [] }), true);
});

test("the schema REQUIRES the flag, so the model can't quietly omit it", () => {
const props = EXTRACTION_JSON_SCHEMA.properties as Record<string, unknown>;
assert.ok("unread_rotated_content" in props, "flag must be in the page schema");
assert.ok(
(EXTRACTION_JSON_SCHEMA.required as string[]).includes("unread_rotated_content"),
"flag must be required — an optional flag would default to a silent false",
);
});

test("the prompt tells the model stickers are separate entries at any orientation", () => {
const p = EXTRACTION_SYSTEM_PROMPT.toLowerCase();
// The original prompt mentioned stickers only as content to MERGE into one
// entry, and ordered entries "top-to-bottom" — which has no slot for a
// sideways sticker. Both had to change.
assert.ok(p.includes("rotated 90"), "must name the actual failure: 90° rotation");
assert.ok(p.includes("own"), "must say a sticker is its own entry");
assert.ok(
p.includes("unread_rotated_content"),
"must tell the model how to report content it could not read",
);
});
Loading
Loading