Skip to content
Draft
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ This file records what changes **in the product** – process and session state

## [Unreleased]

### Added
- The review page shows the original PDF page beside the values, with the place the AI read a value from
highlighted; selecting another value (mouse or keyboard) moves the highlight. Uncertain, missing and
unverified values say why in plain language (e.g. "Kalenderwoche ohne Datum"). The page is rendered in the
browser with pdf.js from the protected document download – no third-party request (#74, ADR-0002). The
review sample now comes with its position list as a PDF.

### Changed
- Request list leads with the next work decision: customer, need for review (the same count as the detail
page) and the next action ("Prüfen", "Duplikat entscheiden", "Fehler ansehen", "Export läuft"); attempts, last
Expand Down
52 changes: 52 additions & 0 deletions docs/decisions/ADR-0002-pdf-page-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR-0002 · Original PDF page in the review screen: pdf.js in the browser

- **Status:** Accepted – decided by the orchestrator on 2026-09-28 in #74
- **Date:** 2026-09-28
- **Context issue:** #74 (showcase review 2026-09-27, P1 item 2; epic #19)
- **Relates to:** ADR-0001 D5 (originals only through the tenant-checked route), D8 (grounding, stored bounding boxes)

## Context

The review screen shows a value's source as text in reading order with the cited segment marked
(#8, #25). For PDFs the AI service already stores the cited segment's page and bounding box
(`PdfLocator.bbox`, PDF points, origin top-left); #8 left the rendered view as "decision needed".
The showcase review asked for the original page beside the values so a visitor sees within a minute
that every value can be checked against the original.

Constraints: originals are read only through `GET /api/documents/:id` (tenant-checked, private
bucket, no public URLs); no third-party requests from the review screen; the AI service stays
stateless and never gets storage credentials (D8); the web app runs on Vercel Hobby (D11).

## Options

| Option | + | − |
|---|---|---|
| **A · pdf.js (`pdfjs-dist`) in the browser** | renders the exact original from the existing route; bbox → overlay is a percentage of the page; no server cost; Apache-2.0; widely used, maintained by Mozilla | new dependency (~35 MB unpacked in `node_modules`, the page loads it lazily); a worker file must be served by the app |
| B · server-side page image (pdfium in the AI service or a Node renderer) | no PDF code in the browser | the AI service would need the original bytes again per view or storage access (breaks D8 statelessness) or the web runtime needs a native renderer; extra CPU per page view; images must be cached and tenant-scoped |
| C · the browser's own PDF viewer (`<iframe>` / `#page=`) | no dependency | no reliable way to highlight a region; the route answers `content-disposition: attachment`; viewer differs per browser |

## Decision

**Option A.** The review page loads `pdfjs-dist` lazily in a client component, fetches the original
through `GET /api/documents/:id` (same origin, the session cookie – no new route, no public URL),
renders the cited page to a canvas and lays the stored bounding box over it as a percentage of the
page size. The pdf.js worker is served from the app's own build output (`new URL(…, import.meta.url)`),
never from a CDN. XFA forms stay off; pdf.js 6 evaluates no code from a PDF (no `eval` or
`new Function` in the build) and its scripting sandbox is never loaded. The text source view stays below the page: it is the
accessible equivalent of the canvas, and the only view for mail, XLSX, DOCX and PDFs inside an
Outlook message (the route serves the `.msg`, not the attachment).

Verified: docling's boxes are in the displayed page's space – for a page with `/Rotate 90` they are
already rotated – so the default pdf.js viewport (which applies `/Rotate`) needs no extra rotation.

## Consequences

- One new runtime dependency, pinned; `pnpm audit` covers it in `verify`.
- The review page's JavaScript grows only when a PDF source is shown (dynamic import).
- A PDF the renderer cannot open falls back to the text view with a note – the review never depends on it.

## Re-evaluate when

- a customer needs PDFs inside `.msg` attachments rendered (needs an attachment download route), or
- a strict Content-Security-Policy is introduced (worker and canvas rules), or
- `pdfjs-dist` has an unpatched advisory that `pnpm audit` reports.
1 change: 1 addition & 0 deletions docs/decisions/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ A dated amendment decided by the orchestrator is appended to the decision it cha
| ADR | Date | Title | Status |
|---|---|---|---|
| [ADR-0001](ADR-0001-pilot-architecture.md) | 2026-09-22 | Pilot architecture baseline (D1–D11) | Accepted; amended 2026-09-24 (D11: Supabase instead of Neon + R2) |
| [ADR-0002](ADR-0002-pdf-page-view.md) | 2026-09-28 | Original PDF page in the review screen: pdf.js in the browser | Accepted (orchestrator, #74) |
4 changes: 2 additions & 2 deletions docs/technical/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ Every new file belongs to one of these modules – otherwise add the module here
| `documents` | `src/features/documents/` | document records, storage references, hashes | internal | confidential | tenant context | built: records, SHA-256, storage keys |
| `extraction` | `src/features/extraction/` | AI-service client, persists runs/fields/evidence | internal | confidential + personal | tenant context, contract validation | built: AI-service client (timeout, error classes), field merge, runs/segments/fields |
| `requests` | `src/features/requests/` | request aggregate, status machine | internal | confidential | tenant context | built: repository, status machine, processing state, list filters (status, possible duplicate) |
| `review` | `src/features/review/` | review UI, corrections, approve/reject | authenticated UI | confidential + personal | session, role check, audit | built: review page (fields + status badges + source view for mail, PDF incl. OCR label, XLSX cells, DOCX paragraphs/tables, attachments; line items as a table with per-field status and audited corrections), corrections with history, approve (→ export job) / reject with reason, duplicate decision (confirm or reject as duplicate) |
| `review` | `src/features/review/` | review UI, corrections, approve/reject | authenticated UI | confidential + personal | session, role check, audit | built: review page (fields + status badges + plain-language reason for uncertain/missing/unverified values + source view for mail, PDF incl. OCR label, XLSX cells, DOCX paragraphs/tables, attachments; a PDF document itself also as the rendered original page with the cited box highlighted – pdf.js in the browser, loaded from the document route, ADR-0002; line items as a table with per-field status and audited corrections), corrections with history, approve (→ export job) / reject with reason, duplicate decision (confirm or reject as duplicate) |
| `export` | `src/features/export/` | ERP port + REST adapter, idempotency | outbound HTTP | confidential | idempotency key, unique export, timeout | built: REST adapter (timeout, error classes, contract validation), export handler under row lock, `drainExports()`, `request_exports` |
| `erp-mock` | `src/features/erp-mock/` | simulated ERP REST API | route behind flag | synthetic | disabled unless `ERP_MOCK_ENABLED` | built: idempotent receiver (replay → same reference, 409 on a different body), fault injection, bounded in-memory store, route `/api/erp-mock/v1/quote-requests` |
| `samples` | `src/features/samples/`, entrypoints `src/seed-samples.ts` (`pnpm seed:samples`) and `src/samples-record.ts` (`pnpm samples:record`) | prepared showcase cases: synthetic mails with a recorded AI answer, seeded through intake → processing → approval | operator scripts only | synthetic | no model call when seeding (recording replayed inline, no queued processing job); requests marked `source = 'sample'` (CHECK, migration 0019) | built: two samples per demo company – one in review (uncertain + missing value), one approved with its export queued (#71) |
| `samples` | `src/features/samples/`, entrypoints `src/seed-samples.ts` (`pnpm seed:samples`) and `src/samples-record.ts` (`pnpm samples:record`) | prepared showcase cases: synthetic requests (mail, the review sample with a PDF position list) with a recorded AI answer per file, seeded through intake → processing → approval | operator scripts only | synthetic | no model call when seeding (recording replayed inline, no queued processing job); requests marked `source = 'sample'` (CHECK, migration 0019) | built: two samples per demo company – one in review (uncertain + missing value), one approved with its export queued (#71) |
| `identity` | `src/features/identity/` | Better Auth, users, companies, roles | public login route | personal (staff) | rate limit, invite-only | built: Better Auth (invite-only, organization + admin plugins), `authorize()`, audited invite, user management (`/users`: roles, deactivate/reactivate, last-admin rule), seed |
| `tenancy` | `src/features/tenancy/` | `withTenant()`, RLS policies | internal | – | forced RLS, `app_rw` without BYPASSRLS | built: `withTenant()`, forced RLS on `app.*`, guard test (every `app` table: `company_id`, forced RLS, only company policies; allow-list empty) |
| `audit` | `src/features/audit/` | append-only audit events | internal | personal (staff) | INSERT/SELECT only | partial: `recordAudit()` (append-only enforced by grants) |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"better-auth": "1.7.5",
"drizzle-orm": "0.45.3",
"next": "16.3.6",
"pdfjs-dist": "6.3.289",
"pg": "8.23.0",
"pg-boss": "12.33.6",
"pino": "10.3.1",
Expand Down
129 changes: 129 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src/app/requests/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { requestRowView } from "../row-view";
import { SAMPLE_EXPLANATION, SAMPLE_LABEL } from "../sample-label";
import { StatusPill } from "../status-pill";
import { DONE_MESSAGES, ERROR_MESSAGES, messageFor } from "./messages";
import { reasonText } from "./reason-label";
import { displayValue } from "./value-label";
import { approveAction, confirmNotDuplicateAction, correctFieldAction, rejectAction, rejectAsDuplicateAction } from "./actions";
import { DocumentList, needsAttention, Source, STATUS_LABEL, StatusBadge } from "./review-parts";
Expand Down Expand Up @@ -180,6 +181,7 @@ export default async function RequestPage({
<td className="nowrap">
<StatusBadge status={field.reviewStatus} />
{field.corrected && <div className="field-hint">erkannt: {STATUS_LABEL[field.status]}</div>}
{reasonText(field) && <div className="field-hint field-reason">{reasonText(field)}</div>}
</td>
<td className="source-col">{field.source ? <Link href={fieldHref(field)}>Quelle anzeigen</Link> : <span className="muted">–</span>}</td>
{inReview && (
Expand Down Expand Up @@ -276,6 +278,11 @@ export default async function RequestPage({
<strong data-testid={selected.itemIndex === null ? undefined : "selected-item-value"}>{displayValue(selected) ?? "–"}</strong>
<StatusBadge status={selected.reviewStatus} />
</div>
{reasonText(selected) && (
<p className="field-reason" data-testid="selected-reason">
{reasonText(selected)}
</p>
)}
{selected.corrected && (
<p className="field-hint">
erkannt: {displayValue({ key: selected.key, value: selected.extractedValue }) ?? "–"} ({STATUS_LABEL[selected.status]})
Expand Down
25 changes: 25 additions & 0 deletions src/app/requests/[id]/pdf-overlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { overlayOf } from "./pdf-overlay";

// The highlight on the rendered page (#74): the stored box as a share of the page, so it scales with the canvas.
const a4 = { width: 595, height: 842 };

describe("overlayOf", () => {
it("places the box as percentages of the page, with a small margin around the text", () => {
expect(overlayOf({ l: 56, t: 278.82, r: 205.49, b: 288.07 }, a4)).toEqual({ left: 9.08, top: 32.88, width: 25.8, height: 1.57 });
});

it("keeps a box at the page edge inside the page", () => {
expect(overlayOf({ l: 0, t: 0, r: 595, b: 20 }, a4)).toEqual({ left: 0, top: 0, width: 100, height: 2.61 });
});

it("uses the displayed page – a rotated page is simply wider than high", () => {
expect(overlayOf({ l: 783.1, t: 56, r: 796.05, b: 239.6 }, { width: 842, height: 595 })).toEqual({ left: 92.77, top: 9.08, width: 2.01, height: 31.53 });
});

it("has no overlay for a box outside the page or a page without size", () => {
expect(overlayOf({ l: 700, t: 10, r: 750, b: 20 }, a4)).toBeNull();
expect(overlayOf({ l: 10, t: 900, r: 50, b: 920 }, a4)).toBeNull();
expect(overlayOf({ l: 10, t: 10, r: 50, b: 20 }, { width: 0, height: 842 })).toBeNull();
});
});
29 changes: 29 additions & 0 deletions src/app/requests/[id]/pdf-overlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { PageRegion } from "@/features/review";

// The highlight on the rendered PDF page (#74): the stored box as a share of the page, so it scales with
// the canvas. docling's boxes are in the displayed page's space – already rotated for `/Rotate` – which is
// also pdf.js's default viewport (ADR-0002): no rotation here.
export interface Overlay {
/** Percent of the page width or height. */
left: number;
top: number;
width: number;
height: number;
}

/** Margin around the text in PDF points, so the highlight does not touch the glyphs. */
const MARGIN = 2;

const clamp = (value: number, max: number) => Math.min(Math.max(value, 0), max);
const percent = (value: number, of: number) => Math.round((value / of) * 10_000) / 100;

/** Null for a page without size or a box outside the page. */
export function overlayOf(bbox: PageRegion["bbox"], page: { width: number; height: number }): Overlay | null {
if (!(page.width > 0 && page.height > 0)) return null;
const left = clamp(bbox.l - MARGIN, page.width);
const right = clamp(bbox.r + MARGIN, page.width);
const top = clamp(bbox.t - MARGIN, page.height);
const bottom = clamp(bbox.b + MARGIN, page.height);
if (right <= left || bottom <= top) return null;
return { left: percent(left, page.width), top: percent(top, page.height), width: percent(right - left, page.width), height: percent(bottom - top, page.height) };
}
Loading
Loading