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:
22
teressa-copeland-homes/package-lock.json
generated
22
teressa-copeland-homes/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"next": "16.2.0",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"nodemailer": "^7.0.13",
|
||||
"openai": "^6.32.0",
|
||||
"postgres": "^3.4.8",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -11345,6 +11346,27 @@
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"next": "16.2.0",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"nodemailer": "^7.0.13",
|
||||
"openai": "^6.32.0",
|
||||
"postgres": "^3.4.8",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
|
||||
43
teressa-copeland-homes/src/lib/ai/extract-text.ts
Normal file
43
teressa-copeland-homes/src/lib/ai/extract-text.ts
Normal 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;
|
||||
}
|
||||
168
teressa-copeland-homes/src/lib/ai/field-placement.ts
Normal file
168
teressa-copeland-homes/src/lib/ai/field-placement.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user