4 plans in 4 sequential waves covering: - Plan 01 (TDD): openai install, extract-text.ts, field-placement.ts, aiCoordsToPagePdfSpace unit test - Plan 02: POST /api/documents/[id]/ai-prepare route with all guards - Plan 03: UI wiring — aiPlacementKey in FieldPlacer, AI Auto-place button in PreparePanel - Plan 04: Unit test gate + human E2E verification checkpoint Satisfies AI-01, AI-02. Completes v1.1 Smart Document Preparation milestone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
256 lines
12 KiB
Markdown
256 lines
12 KiB
Markdown
---
|
||
phase: 13-ai-field-placement-and-pre-fill
|
||
plan: 01
|
||
type: tdd
|
||
wave: 1
|
||
depends_on: []
|
||
files_modified:
|
||
- teressa-copeland-homes/src/lib/ai/extract-text.ts
|
||
- teressa-copeland-homes/src/lib/ai/field-placement.ts
|
||
- teressa-copeland-homes/src/lib/pdf/__tests__/ai-coords.test.ts
|
||
autonomous: true
|
||
requirements:
|
||
- AI-01
|
||
- AI-02
|
||
|
||
must_haves:
|
||
truths:
|
||
- "pdfjs-dist extracts per-page text and page dimensions from a server-side file path"
|
||
- "aiCoordsToPagePdfSpace converts AI top-left percentage coordinates to PDF bottom-left point coordinates with correct Y-axis flip"
|
||
- "GPT-4o-mini structured output call returns a typed AiPlacedField[] array via manual json_schema response_format"
|
||
artifacts:
|
||
- path: "teressa-copeland-homes/src/lib/ai/extract-text.ts"
|
||
provides: "extractPdfText(filePath) returning PageText[] with page, text, width, height"
|
||
exports: ["extractPdfText", "PageText"]
|
||
- path: "teressa-copeland-homes/src/lib/ai/field-placement.ts"
|
||
provides: "aiCoordsToPagePdfSpace() + classifyFieldsWithAI() using GPT-4o-mini structured output"
|
||
exports: ["aiCoordsToPagePdfSpace", "classifyFieldsWithAI", "AiFieldCoords"]
|
||
- path: "teressa-copeland-homes/src/lib/pdf/__tests__/ai-coords.test.ts"
|
||
provides: "Unit tests for aiCoordsToPagePdfSpace with US Letter dimensions"
|
||
contains: "aiCoordsToPagePdfSpace"
|
||
key_links:
|
||
- from: "teressa-copeland-homes/src/lib/ai/field-placement.ts"
|
||
to: "teressa-copeland-homes/src/lib/ai/extract-text.ts"
|
||
via: "import { PageText } from './extract-text'"
|
||
pattern: "from.*extract-text"
|
||
- from: "teressa-copeland-homes/src/lib/ai/field-placement.ts"
|
||
to: "openai SDK"
|
||
via: "import OpenAI from 'openai'"
|
||
pattern: "import OpenAI"
|
||
---
|
||
|
||
<objective>
|
||
Install the openai SDK and create the two server-only AI utility modules plus a unit test for the coordinate conversion function.
|
||
|
||
Purpose: These are the foundation layer for the AI auto-place feature. The route (Plan 02) and the UI button (Plan 03) both depend on these utilities existing and being correct. The coordinate conversion is the most failure-prone part of the feature — the unit test locks in correctness before it is wired into a live route.
|
||
|
||
Output:
|
||
- `src/lib/ai/extract-text.ts` — pdfjs-dist server-side text extraction
|
||
- `src/lib/ai/field-placement.ts` — GPT-4o-mini structured output + aiCoordsToPagePdfSpace utility
|
||
- `src/lib/pdf/__tests__/ai-coords.test.ts` — unit test for aiCoordsToPagePdfSpace (TDD plan: write test first, then implement)
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
|
||
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/PROJECT.md
|
||
@.planning/ROADMAP.md
|
||
@.planning/STATE.md
|
||
@.planning/phases/13-ai-field-placement-and-pre-fill/13-RESEARCH.md
|
||
|
||
Read next.config.ts and check for Next.js guide in node_modules/next/dist/docs/ before writing route code.
|
||
</context>
|
||
|
||
<interfaces>
|
||
<!-- Key types the executor needs. Extracted from codebase. -->
|
||
|
||
From teressa-copeland-homes/src/lib/db/schema.ts:
|
||
```typescript
|
||
export type SignatureFieldType =
|
||
| 'client-signature'
|
||
| 'initials'
|
||
| 'text'
|
||
| 'checkbox'
|
||
| 'date'
|
||
| 'agent-signature'
|
||
| 'agent-initials';
|
||
|
||
export interface SignatureFieldData {
|
||
id: string;
|
||
page: number; // 1-indexed
|
||
x: number; // PDF user space, bottom-left origin, points
|
||
y: number; // PDF user space, bottom-left origin, points
|
||
width: number; // PDF points (default: 144 — 2 inches)
|
||
height: number; // PDF points (default: 36 — 0.5 inches)
|
||
type?: SignatureFieldType;
|
||
}
|
||
```
|
||
|
||
From teressa-copeland-homes/src/lib/pdf/__tests__/prepare-document.test.ts (unit test pattern):
|
||
```typescript
|
||
// Jest + ts-jest (see package.json jest config)
|
||
// Test file pattern: src/lib/pdf/__tests__/*.test.ts
|
||
// Run: cd teressa-copeland-homes && npx jest src/lib/pdf/__tests__/ai-coords.test.ts
|
||
```
|
||
|
||
Existing coordinate formula from FieldPlacer.tsx (lines 289-290) — aiCoordsToPagePdfSpace MUST replicate this:
|
||
```typescript
|
||
const pdfX = (clampedX / renderedW) * pageInfo.originalWidth;
|
||
const pdfY = ((renderedH - (clampedY + fieldHpx)) / renderedH) * pageInfo.originalHeight;
|
||
// Where clampedX/clampedY are screen top-left origin, fieldHpx is field height in pixels
|
||
// 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)
|
||
```
|
||
</interfaces>
|
||
|
||
<tasks>
|
||
|
||
<task type="tdd">
|
||
<name>Task 1: Write failing unit test for aiCoordsToPagePdfSpace</name>
|
||
<files>teressa-copeland-homes/src/lib/pdf/__tests__/ai-coords.test.ts</files>
|
||
<action>
|
||
Create the test file. Import `aiCoordsToPagePdfSpace` from `'../../../lib/ai/field-placement'` (it does not exist yet — the import will fail, making the test RED). Write these test cases covering US Letter (612×792 pts):
|
||
|
||
Test group: "aiCoordsToPagePdfSpace — AI top-left percentage to PDF bottom-left points"
|
||
|
||
Case 1 — text field near top of page:
|
||
- Input: { page:1, fieldType:'text', xPct:10, yPct:5, widthPct:30, heightPct:5, prefillValue:'' }, pageWidth:612, pageHeight:792
|
||
- Expected: x ≈ 61.2, y ≈ 712.8, width ≈ 183.6, height ≈ 39.6
|
||
- Formula: x=612*0.1=61.2; fieldH=792*0.05=39.6; screenY=792*0.05=39.6; y=792-39.6-39.6=712.8
|
||
|
||
Case 2 — checkbox near bottom-right:
|
||
- Input: { page:1, fieldType:'checkbox', xPct:90, yPct:95, widthPct:3, heightPct:3, prefillValue:'' }, pageWidth:612, pageHeight:792
|
||
- Expected: x ≈ 550.8, y ≈ 15.84, width ≈ 18.36, height ≈ 23.76
|
||
- Formula: x=612*0.9=550.8; fieldH=792*0.03=23.76; screenY=792*0.95=752.4; y=792-752.4-23.76=15.84
|
||
|
||
Case 3 — field at exact center:
|
||
- Input: { page:1, fieldType:'client-signature', xPct:50, yPct:50, widthPct:20, heightPct:5, prefillValue:'' }, pageWidth:612, pageHeight:792
|
||
- Expected: x ≈ 306, y ≈ 357.84, width ≈ 122.4, height ≈ 39.6
|
||
- Formula: x=306; fieldH=39.6; screenY=396; y=792-396-39.6=356.4 (use toBeCloseTo(1))
|
||
|
||
Run: `cd teressa-copeland-homes && npx jest src/lib/pdf/__tests__/ai-coords.test.ts --no-coverage`
|
||
Tests must FAIL (RED) because the module does not exist yet. Commit: `test(13-01): add failing aiCoordsToPagePdfSpace unit tests`
|
||
</action>
|
||
<verify>
|
||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx jest src/lib/pdf/__tests__/ai-coords.test.ts --no-coverage 2>&1 | tail -20</automated>
|
||
</verify>
|
||
<done>Test file exists, tests run and fail with "Cannot find module" or similar import error — RED phase confirmed</done>
|
||
</task>
|
||
|
||
<task type="tdd">
|
||
<name>Task 2: Implement extract-text.ts and field-placement.ts (GREEN)</name>
|
||
<files>
|
||
teressa-copeland-homes/src/lib/ai/extract-text.ts
|
||
teressa-copeland-homes/src/lib/ai/field-placement.ts
|
||
</files>
|
||
<action>
|
||
**Step 0: Install openai SDK**
|
||
```bash
|
||
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm install openai
|
||
```
|
||
|
||
**Step 1: Create `src/lib/ai/extract-text.ts`**
|
||
|
||
Use the exact pattern from RESEARCH.md Pattern 1. Key points:
|
||
- Import from `'pdfjs-dist/legacy/build/pdf.mjs'`
|
||
- Set `GlobalWorkerOptions.workerSrc = ''` at module level (Node.js fake-worker mode — NOT browser pattern)
|
||
- Export `PageText` interface: `{ page: number; text: string; width: number; height: number }`
|
||
- Export `extractPdfText(filePath: string): Promise<PageText[]>`
|
||
- Uses `readFile` from `node:fs/promises`, loads PDF bytes as `Uint8Array`
|
||
- Iterates all pages, calls `page.getViewport({ scale: 1.0 })` for dimensions, `getTextContent()` for text
|
||
- Joins text items by filtering `'str' in item` then mapping `.str`, joined with `' '`
|
||
- Cap text per page at 2000 chars with `text.slice(0, 2000)` to stay within GPT-4o-mini context
|
||
- Add comment: `// server-only — never import from client components`
|
||
|
||
**Step 2: Create `src/lib/ai/field-placement.ts`**
|
||
|
||
Exports:
|
||
```typescript
|
||
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;
|
||
}
|
||
|
||
export function aiCoordsToPagePdfSpace(
|
||
coords: AiFieldCoords,
|
||
pageWidth: number,
|
||
pageHeight: number,
|
||
): { x: number; y: number; width: number; height: number }
|
||
```
|
||
|
||
Implement `aiCoordsToPagePdfSpace` using the formula:
|
||
```typescript
|
||
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;
|
||
const y = pageHeight - screenY - fieldHeight; // Y-axis flip: bottom-left origin
|
||
return { x, y, width: fieldWidth, height: fieldHeight };
|
||
```
|
||
|
||
Also export `classifyFieldsWithAI(pageTexts: PageText[], client: { name: string | null; propertyAddress: string | null } | null): Promise<{ fields: SignatureFieldData[], textFillData: Record<string, string> }>`
|
||
|
||
Implementation of `classifyFieldsWithAI`:
|
||
1. Build the FIELD_PLACEMENT_SCHEMA (exact schema from RESEARCH.md Pattern 2 — all fields required, `additionalProperties: false` at every nesting level, `strict: true`)
|
||
2. Build `pagesSummary` string: for each page, format as `Page {n} ({w}x{h}pt):\n{text}\n` — truncate text to 2000 chars per page
|
||
3. Create `const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })`
|
||
4. Check `if (!process.env.OPENAI_API_KEY) throw new Error('OPENAI_API_KEY not configured')`
|
||
5. Call `openai.chat.completions.create` with model `'gpt-4o-mini'`, manual json_schema response_format (NOT zodResponseFormat), system prompt from RESEARCH.md Pattern 2, user message includes client name, property address, pagesSummary
|
||
6. Parse response: `JSON.parse(response.choices[0].message.content!)` typed as `{ fields: AiFieldCoords[] }`
|
||
7. Map each AI field to `SignatureFieldData` using `aiCoordsToPagePdfSpace` with the page's dimensions from `pageTexts.find(p => p.page === field.page)`
|
||
8. Assign `id: crypto.randomUUID()` to each field, set `type: field.fieldType`
|
||
9. Default field sizes: checkbox=24×24pt, all others=144×36pt (ignore AI width/height for consistent sizing — override with standard sizes)
|
||
10. Build `textFillData: Record<string, string>` — for each text field where `prefillValue` is non-empty string, add `{ [field.id]: prefillValue }`
|
||
11. Return `{ fields: SignatureFieldData[], textFillData }`
|
||
|
||
Add comment: `// server-only — never import from client components`
|
||
|
||
**Step 3: Run tests (GREEN)**
|
||
```bash
|
||
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx jest src/lib/pdf/__tests__/ai-coords.test.ts --no-coverage
|
||
```
|
||
Tests must PASS. Commit: `feat(13-01): implement aiCoordsToPagePdfSpace and AI field utilities`
|
||
|
||
Note on TypeScript import: if `'pdfjs-dist/legacy/build/pdf.mjs'` triggers type errors, add `// @ts-ignore` above the import or use the main types: `import type { PDFDocumentProxy, TextItem } from 'pdfjs-dist'`.
|
||
</action>
|
||
<verify>
|
||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx jest src/lib/pdf/__tests__/ai-coords.test.ts --no-coverage</automated>
|
||
</verify>
|
||
<done>All 3+ aiCoordsToPagePdfSpace test cases pass (GREEN). extract-text.ts and field-placement.ts exist with correct exports. openai package is in package.json dependencies.</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<verification>
|
||
- `npx jest src/lib/pdf/__tests__/ai-coords.test.ts --no-coverage` passes all cases
|
||
- `src/lib/ai/extract-text.ts` exports `extractPdfText` and `PageText`
|
||
- `src/lib/ai/field-placement.ts` exports `aiCoordsToPagePdfSpace`, `classifyFieldsWithAI`, `AiFieldCoords`
|
||
- `openai` is listed in `package.json` dependencies (not devDependencies)
|
||
- Neither file is imported from any client component (server-only comment guard present)
|
||
- `GlobalWorkerOptions.workerSrc = ''` set in extract-text.ts (NOT the browser `new URL(...)` pattern)
|
||
- Manual `json_schema` response_format used (NOT `zodResponseFormat`)
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
- Unit test passes: aiCoordsToPagePdfSpace correctly flips Y-axis for all 3 test cases
|
||
- openai SDK installed and importable
|
||
- Both AI utility modules exist, export correct types, have server-only guard comments
|
||
- TypeScript compiles without errors: `npx tsc --noEmit`
|
||
</success_criteria>
|
||
|
||
<output>
|
||
After completion, create `.planning/phases/13-ai-field-placement-and-pre-fill/13-01-SUMMARY.md`
|
||
</output>
|