feat(13-01): implement aiCoordsToPagePdfSpace and AI field utilities

- Install openai 6.32.0 (npm package, listed in dependencies)
- Create src/lib/ai/extract-text.ts — pdfjs-dist legacy build server-side text extraction
  - extractPdfText(filePath) returning PageText[] with page, text, width, height
  - GlobalWorkerOptions.workerSrc = '' for Node.js fake-worker mode
  - Text per page capped at 2000 chars for GPT-4o-mini context limit
- Create src/lib/ai/field-placement.ts — GPT-4o-mini structured output + coord conversion
  - aiCoordsToPagePdfSpace() converts AI top-left pct coords to PDF bottom-left points
  - classifyFieldsWithAI() uses manual json_schema (NOT zodResponseFormat — broken with Zod v4)
  - Standard field sizes: checkbox=24x24pt, others=144x36pt
  - textFillData keyed by field UUID (not label) per Phase 12.1 design
- All 3 unit tests pass (GREEN phase confirmed)
This commit is contained in:
Chandler Copeland
2026-03-21 17:00:34 -06:00
parent f7d74c0523
commit c1e1e5ec49
4 changed files with 234 additions and 0 deletions

View File

@@ -21,6 +21,7 @@
"next": "16.2.0", "next": "16.2.0",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"openai": "^6.32.0",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@@ -11345,6 +11346,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/openai": {
"version": "6.32.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.32.0.tgz",
"integrity": "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",

View File

@@ -28,6 +28,7 @@
"next": "16.2.0", "next": "16.2.0",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"openai": "^6.32.0",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",

View File

@@ -0,0 +1,43 @@
// server-only — never import from client components
// This module uses pdfjs-dist legacy build in Node.js fake-worker mode (no browser worker).
// The client components (PdfViewer.tsx, PreviewModal.tsx) set workerSrc independently.
// @ts-ignore — legacy .mjs build; types re-exported from main pdfjs-dist declaration
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist/legacy/build/pdf.mjs';
import { readFile } from 'node:fs/promises';
// Empty string = no worker thread (fake/synchronous worker) — required for Node.js server context.
// Do NOT use: new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url) — that is browser-only.
GlobalWorkerOptions.workerSrc = '';
export interface PageText {
page: number; // 1-indexed
text: string; // all text items joined with spaces, capped at 2000 chars
width: number; // page width in PDF points (72 DPI)
height: number; // page height in PDF points (72 DPI)
}
export async function extractPdfText(filePath: string): Promise<PageText[]> {
const data = new Uint8Array(await readFile(filePath));
const pdf = await getDocument({ data }).promise;
const pages: PageText[] = [];
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.0 });
const textContent = await page.getTextContent();
const rawText = textContent.items
.filter((item: unknown) => typeof item === 'object' && item !== null && 'str' in item)
.map((item: unknown) => (item as { str: string }).str)
.join(' ');
// Cap text per page at 2000 chars to stay within GPT-4o-mini context limits
const text = rawText.slice(0, 2000);
pages.push({
page: pageNum,
width: viewport.width,
height: viewport.height,
text,
});
}
return pages;
}

View File

@@ -0,0 +1,168 @@
// server-only — never import from client components
// This module calls the OpenAI API (OPENAI_API_KEY env var required) and is Node.js only.
import OpenAI from 'openai';
import type { PageText } from './extract-text';
import type { SignatureFieldData, SignatureFieldType } from '@/lib/db/schema';
export interface AiFieldCoords {
page: number;
fieldType: SignatureFieldType;
xPct: number; // % from left, top-left origin (AI output)
yPct: number; // % from top, top-left origin (AI output)
widthPct: number;
heightPct: number;
prefillValue: string;
}
/**
* Convert AI percentage coordinates (top-left origin) to PDF user-space points (bottom-left origin).
*
* pageWidth/pageHeight in PDF points (from page.getViewport({ scale: 1.0 })).
*
* Formula mirrors FieldPlacer.tsx handleDragEnd (lines 289-291):
* pdfX = (clampedX / renderedW) * pageInfo.originalWidth
* pdfY = ((renderedH - (clampedY + fieldHpx)) / renderedH) * pageInfo.originalHeight
*
* Translated to percentage inputs:
* pdfX = (xPct / 100) * pageWidth
* screenY = (yPct / 100) * pageHeight (top-left origin from AI)
* fieldH = (heightPct / 100) * pageHeight
* pdfY = pageHeight - screenY - fieldH (bottom edge in PDF space)
*/
export function aiCoordsToPagePdfSpace(
coords: AiFieldCoords,
pageWidth: number,
pageHeight: number,
): { x: number; y: number; width: number; height: number } {
const fieldWidth = (coords.widthPct / 100) * pageWidth;
const fieldHeight = (coords.heightPct / 100) * pageHeight;
const screenX = (coords.xPct / 100) * pageWidth;
const screenY = (coords.yPct / 100) * pageHeight; // screen Y from top
const x = screenX;
// PDF y = distance from BOTTOM. screenY is from top, so flip:
// pdfY = pageHeight - screenY - fieldHeight (bottom edge of field)
const y = pageHeight - screenY - fieldHeight;
return { x, y, width: fieldWidth, height: fieldHeight };
}
// Manual JSON schema for GPT-4o-mini structured output.
// NOTE: Do NOT use zodResponseFormat — it is broken with Zod v4 (confirmed issues #1540, #1602, #1709).
// With strict: true, ALL properties must be in required and ALL objects must have additionalProperties: false.
const FIELD_PLACEMENT_SCHEMA = {
type: 'object',
properties: {
fields: {
type: 'array',
items: {
type: 'object',
properties: {
page: { type: 'integer' },
fieldType: { type: 'string', enum: ['text', 'checkbox', 'initials', 'date', 'client-signature', 'agent-signature', 'agent-initials'] },
xPct: { type: 'number' },
yPct: { type: 'number' }, // % from page TOP (AI top-left origin)
widthPct: { type: 'number' },
heightPct: { type: 'number' },
prefillValue: { type: 'string' }, // only for text fields; empty string if none
},
required: ['page', 'fieldType', 'xPct', 'yPct', 'widthPct', 'heightPct', 'prefillValue'],
additionalProperties: false,
},
},
},
required: ['fields'],
additionalProperties: false,
} as const;
/**
* Call GPT-4o-mini to classify and place fields from extracted PDF text.
*
* Returns:
* - fields: SignatureFieldData[] — ready to write to DB; coordinates converted to PDF user-space
* - textFillData: Record<string, string> — keyed by field.id (UUID); only text fields with non-empty prefillValue
*/
export async function classifyFieldsWithAI(
pageTexts: PageText[],
client: { name: string | null; propertyAddress: string | null } | null,
): Promise<{ fields: SignatureFieldData[]; textFillData: Record<string, string> }> {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY not configured');
}
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const clientName = client?.name ?? 'Unknown';
const propertyAddress = client?.propertyAddress ?? 'Unknown';
// Build pages summary — text already capped at 2000 chars per page in extractPdfText
const pagesSummary = pageTexts
.map((p) => `Page ${p.page} (${p.width}x${p.height}pt):\n${p.text}`)
.join('\n\n');
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: `You are a real estate document form field extractor.
Given extracted text from a PDF page (with context about page number and dimensions),
identify where signature, text, checkbox, initials, and date fields should be placed.
Return fields as percentage positions (0-100) from the TOP-LEFT of the page.
Use these field types: text (for typed values), checkbox, initials, date, client-signature, agent-signature, agent-initials.
For text fields that match the client profile, set prefillValue to the known value. Otherwise use empty string.`,
},
{
role: 'user',
content: `Client name: ${clientName}\nProperty address: ${propertyAddress}\n\nPDF pages:\n${pagesSummary}`,
},
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'field_placement',
strict: true,
schema: FIELD_PLACEMENT_SCHEMA,
},
},
});
const raw = JSON.parse(response.choices[0].message.content!) as { fields: AiFieldCoords[] };
// Convert AI coords to PDF user-space and build SignatureFieldData[]
const fields: SignatureFieldData[] = [];
const textFillData: Record<string, string> = {};
for (const aiField of raw.fields) {
const pageInfo = pageTexts.find((p) => p.page === aiField.page);
const pageWidth = pageInfo?.width ?? 612; // fallback: US Letter
const pageHeight = pageInfo?.height ?? 792;
const { x, y } = aiCoordsToPagePdfSpace(aiField, pageWidth, pageHeight);
// Use standard sizes regardless of AI width/height — consistent with FieldPlacer defaults
const isCheckbox = aiField.fieldType === 'checkbox';
const width = isCheckbox ? 24 : 144; // pts: checkbox=24x24, others=144x36
const height = isCheckbox ? 24 : 36;
const id = crypto.randomUUID();
fields.push({
id,
page: aiField.page,
x,
y,
width,
height,
type: aiField.fieldType,
});
// Build textFillData for text fields with a non-empty prefill value (keyed by UUID)
if (aiField.fieldType === 'text' && aiField.prefillValue) {
textFillData[id] = aiField.prefillValue;
}
}
return { fields, textFillData };
}