diff --git a/apps/web/src/app/aircraft/[id]/pages/[pageId]/review/ReviewClient.tsx b/apps/web/src/app/aircraft/[id]/pages/[pageId]/review/ReviewClient.tsx
index 5e68ecb..8ec4b63 100644
--- a/apps/web/src/app/aircraft/[id]/pages/[pageId]/review/ReviewClient.tsx
+++ b/apps/web/src/app/aircraft/[id]/pages/[pageId]/review/ReviewClient.tsx
@@ -512,6 +512,7 @@ export function ReviewClient({
reviewStatus,
extractionStatus,
detectedPageCount,
+ unreadRotatedContent,
entries: initialEntries,
returnLogbookId,
canEdit,
@@ -525,6 +526,7 @@ export function ReviewClient({
reviewStatus: ReviewStatus;
extractionStatus: ExtractionStatus;
detectedPageCount: number | null;
+ unreadRotatedContent: boolean;
entries: ReviewEntry[];
returnLogbookId: string | null;
canEdit: boolean;
@@ -651,6 +653,20 @@ export function ReviewClient({
)}
+ {/* 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 && (
+
+ There is sideways or rotated content on this page (often a sticker
+ affixed at 90°) that couldn't be read in full. Compare against the
+ paper — if an entry is missing, add it with{" "}
+ Add an entry the extractor missed below.
+
+ )}
+
setShowRaw((s) => !s)}
className="mt-3 text-xs text-dim underline hover:text-ink"
diff --git a/apps/web/src/app/aircraft/[id]/pages/[pageId]/review/page.tsx b/apps/web/src/app/aircraft/[id]/pages/[pageId]/review/page.tsx
index 10fe732..88ea5d0 100644
--- a/apps/web/src/app/aircraft/[id]/pages/[pageId]/review/page.tsx
+++ b/apps/web/src/app/aircraft/[id]/pages/[pageId]/review/page.tsx
@@ -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();
@@ -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}
diff --git a/apps/web/src/app/help/page.tsx b/apps/web/src/app/help/page.tsx
index 9ce4691..c14997b 100644
--- a/apps/web/src/app/help/page.tsx
+++ b/apps/web/src/app/help/page.tsx
@@ -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 re-extract a page (e.g. if a multi-page
+ marks it confirmed.
+
+
+ Stickers at odd angles. Shops stick labels wherever there'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't be read in full, the page gets a second look automatically — and if that still
+ doesn't resolve it, Review shows an amber warning on that page 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 Add an entry the extractor missed to key it in. You can re-extract a page (e.g. if a multi-page
entry wasn't linked) right from the review screen — it replaces that page's
entries. The Logbook pages view (in the left nav) lists every captured
scan grouped by logbook with its Needs review / Processing status, and can
diff --git a/apps/web/src/lib/database.types.ts b/apps/web/src/lib/database.types.ts
index be27e88..8bf3797 100644
--- a/apps/web/src/lib/database.types.ts
+++ b/apps/web/src/lib/database.types.ts
@@ -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;
diff --git a/apps/web/src/lib/extraction/extract.ts b/apps/web/src/lib/extraction/extract.ts
index 71811d4..3924e2e 100644
--- a/apps/web/src/lib/extraction/extract.ts
+++ b/apps/web/src/lib/extraction/extract.ts
@@ -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 {
+ 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 => 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: [] };
+ }
+}
diff --git a/apps/web/src/lib/extraction/pipeline.ts b/apps/web/src/lib/extraction/pipeline.ts
index ab23996..725df3f 100644
--- a/apps/web/src/lib/extraction/pipeline.ts
+++ b/apps/web/src/lib/extraction/pipeline.ts
@@ -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,
@@ -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);
@@ -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(),
diff --git a/apps/web/src/lib/extraction/schema.ts b/apps/web/src/lib/extraction/schema.ts
index c1b1305..ac07c8b 100644
--- a/apps/web/src/lib/extraction/schema.ts
+++ b/apps/web/src/lib/extraction/schema.ts
@@ -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[];
};
@@ -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).
@@ -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.
diff --git a/apps/web/src/lib/oauth/resource.ts b/apps/web/src/lib/oauth/resource.ts
index ec9f63c..565f1d4 100644
--- a/apps/web/src/lib/oauth/resource.ts
+++ b/apps/web/src/lib/oauth/resource.ts
@@ -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);
diff --git a/apps/web/test/extraction-rotated.test.ts b/apps/web/test/extraction-rotated.test.ts
new file mode 100644
index 0000000..c536a7f
--- /dev/null
+++ b/apps/web/test/extraction-rotated.test.ts
@@ -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;
+ 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",
+ );
+});
diff --git a/supabase/migrations/0052_page_rotated_content.sql b/supabase/migrations/0052_page_rotated_content.sql
new file mode 100644
index 0000000..eb8a7db
--- /dev/null
+++ b/supabase/migrations/0052_page_rotated_content.sql
@@ -0,0 +1,22 @@
+-- Flag a page whose rotated content we could not fully read.
+--
+-- Reported from the field: shops affix stickers wherever there is room, so a
+-- page can carry an upright sticker AND one rotated 90°. Extraction returned
+-- only the upright one. The rotated entry was not wrong — it was ABSENT, and an
+-- absent entry has nothing to review against, so the owner had no way to know.
+--
+-- The extractor now reports when it can see rotated content it did not fully
+-- read, which triggers a second targeted pass. This column persists the state
+-- AFTER that pass, so review can still say "look here" when even the retry came
+-- up short. Silent omission → visible flag.
+
+alter table page
+ add column if not exists unread_rotated_content boolean not null default false;
+
+comment on column page.unread_rotated_content is
+ 'Rotated/sideways content was visible but not fully read, even after the follow-up pass. Review shows a "check this page" warning. Default false: pages extracted before 0052 were never assessed, and false means "nothing to flag", not "verified clean".';
+
+-- Pages needing a look are rare, so index only those.
+create index if not exists page_unread_rotated_idx
+ on page (aircraft_id)
+ where unread_rotated_content;