Files
red/.planning/phases/13-ai-field-placement-and-pre-fill/13-02-PLAN.md
Chandler Copeland df4676c23c docs(13): create phase 13 AI field placement plan
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>
2026-03-21 16:54:43 -06:00

10 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-ai-field-placement-and-pre-fill 02 execute 2
13-01
teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts
true
AI-01
AI-02
truths artifacts key_links
POST /api/documents/[id]/ai-prepare extracts PDF text, calls GPT-4o-mini, converts coordinates, and writes SignatureFieldData[] to the DB
Route returns { fields, textFillData } on success — client uses this to update FieldPlacer and pre-fill state
Route returns 503 if OPENAI_API_KEY is not configured, 401 if unauthenticated, 404 if document not found, 403 if document is not Draft
textFillData in response is keyed by field UUID (not by label string)
path provides exports
teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts POST handler orchestrating extract → AI → convert → DB write → return
POST
from to via pattern
teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts teressa-copeland-homes/src/lib/ai/extract-text.ts import { extractPdfText } from '@/lib/ai/extract-text' extractPdfText
from to via pattern
teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts teressa-copeland-homes/src/lib/ai/field-placement.ts import { classifyFieldsWithAI } from '@/lib/ai/field-placement' classifyFieldsWithAI
from to via pattern
teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts drizzle documents table db.update(documents).set({ signatureFields: fields }) signatureFields
Create the `POST /api/documents/[id]/ai-prepare` route that orchestrates the full AI placement pipeline: extract PDF text, call GPT-4o-mini, convert AI percentage coordinates to PDF user-space points, and write the resulting fields to the database.

Purpose: This is the server-side orchestration layer. The AI utility modules (Plan 01) provide the logic; this route wires them to the document DB record and returns the result to the client.

Output: src/app/api/documents/[id]/ai-prepare/route.ts

<execution_context> @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/13-ai-field-placement-and-pre-fill/13-RESEARCH.md @.planning/phases/13-ai-field-placement-and-pre-fill/13-01-SUMMARY.md

Read next.config.ts and check for Next.js guide in node_modules/next/dist/docs/ before writing route code.

From teressa-copeland-homes/src/lib/db/schema.ts:

export interface SignatureFieldData {
  id: string;
  page: number;
  x: number; y: number; width: number; height: number;
  type?: SignatureFieldType;
}
export const documents = pgTable("documents", {
  id: text("id").primaryKey(),
  clientId: text("client_id").notNull(),
  status: documentStatusEnum("status").notNull().default("Draft"),
  filePath: text("file_path"),
  signatureFields: jsonb("signature_fields").$type<SignatureFieldData[]>(),
  textFillData: jsonb("text_fill_data").$type<Record<string, string>>(),
  // ...
});
export const clients = pgTable("clients", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  propertyAddress: text("property_address"),
  // ...
});
export const documentsRelations = relations(documents, ({ one }) => ({
  client: one(clients, { fields: [documents.clientId], references: [clients.id] }),
}));

From existing prepare route pattern (src/app/api/documents/[id]/prepare/route.ts):

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents, users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import path from 'node:path';

const UPLOADS_DIR = path.join(process.cwd(), 'uploads');

export async function POST(
  req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
  const { id } = await params;
  const doc = await db.query.documents.findFirst({
    where: eq(documents.id, id),
    with: { client: true },  // use this to get client.name and client.propertyAddress
  });
  if (!doc) return Response.json({ error: 'Not found' }, { status: 404 });
  // ...
}

From Plan 01 (13-01-SUMMARY.md):

// src/lib/ai/extract-text.ts
export async function extractPdfText(filePath: string): Promise<PageText[]>
export interface PageText { page: number; text: string; width: number; height: number; }

// src/lib/ai/field-placement.ts
export async function classifyFieldsWithAI(
  pageTexts: PageText[],
  client: { name: string | null; propertyAddress: string | null } | null
): Promise<{ fields: SignatureFieldData[], textFillData: Record<string, string> }>
Task 1: Create POST /api/documents/[id]/ai-prepare route teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts Create the directory `src/app/api/documents/[id]/ai-prepare/` and the `route.ts` file.

Implement:

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import path from 'node:path';
import { extractPdfText } from '@/lib/ai/extract-text';
import { classifyFieldsWithAI } from '@/lib/ai/field-placement';

const UPLOADS_DIR = path.join(process.cwd(), 'uploads');

export async function POST(
  req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  // 1. Auth guard — same pattern as prepare/route.ts
  const session = await auth();
  if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });

  // 2. OPENAI_API_KEY guard — fail fast with 503 if not configured
  if (!process.env.OPENAI_API_KEY) {
    return Response.json(
      { error: 'OPENAI_API_KEY not configured. Add it to .env.local.' },
      { status: 503 }
    );
  }

  // 3. Load document with client relation
  const { id } = await params;
  const doc = await db.query.documents.findFirst({
    where: eq(documents.id, id),
    with: { client: true },
  });
  if (!doc) return Response.json({ error: 'Not found' }, { status: 404 });
  if (!doc.filePath) return Response.json({ error: 'Document has no PDF file' }, { status: 422 });

  // 4. Only Draft documents can be AI-prepared (same principle as prepare route)
  if (doc.status !== 'Draft') {
    return Response.json({ error: 'Document is locked — only Draft documents can use AI auto-place' }, { status: 403 });
  }

  // 5. Path traversal guard
  const filePath = path.join(UPLOADS_DIR, doc.filePath);
  if (!filePath.startsWith(UPLOADS_DIR)) {
    return new Response('Forbidden', { status: 403 });
  }

  // 6. Extract text from PDF (server-side, pdfjs-dist legacy build)
  const pageTexts = await extractPdfText(filePath);

  // 7. Classify fields with GPT-4o-mini + coordinate conversion
  const client = (doc as typeof doc & { client: { name: string; propertyAddress: string | null } | null }).client;
  const { fields, textFillData } = await classifyFieldsWithAI(
    pageTexts,
    client ? { name: client.name, propertyAddress: client.propertyAddress ?? null } : null,
  );

  // 8. Write fields to DB (replaces existing signatureFields — agent reviews after)
  //    Do NOT change document status — stays Draft so agent can review and adjust
  const [updated] = await db
    .update(documents)
    .set({ signatureFields: fields })
    .where(eq(documents.id, id))
    .returning();

  // 9. Return fields and textFillData for client state update
  return Response.json({
    fields: updated.signatureFields ?? [],
    textFillData,
  });
}

Key decisions to honor (from STATE.md and RESEARCH.md):

  • Do NOT use zodResponseFormat (broken with Zod v4) — this route delegates to classifyFieldsWithAI which uses manual json_schema
  • Do NOT change document status to anything other than Draft — only the prepare route moves status
  • textFillData is keyed by field UUID (classifyFieldsWithAI handles this — see Plan 01)
  • doc.client is available via Drizzle relation — use with: { client: true } in the query

Error handling: wrap the extractPdfText + classifyFieldsWithAI calls in try/catch, return 500 with the error message if they throw (AI errors, PDF read errors, etc.). cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | grep -E "ai-prepare|error" | head -20 - src/app/api/documents/[id]/ai-prepare/route.ts exists and exports POST - TypeScript compiles without errors for this file - Route structure matches Next.js App Router pattern (params as Promise) - All guards present: auth, OPENAI_API_KEY, not-found, no-filePath, non-Draft - Fields written to DB, document status stays Draft - Returns { fields: SignatureFieldData[], textFillData: Record<string, string> }

- `npx tsc --noEmit` passes with no errors related to ai-prepare route - Route file exists at correct Next.js App Router path - Drizzle query uses `with: { client: true }` to load client profile data - Route does NOT call `extractPdfText` or `classifyFieldsWithAI` directly — only after all guards pass - Document status remains Draft after successful AI placement - `textFillData` in response is keyed by SignatureFieldData UUIDs (not labels)

<success_criteria>

  • POST /api/documents/[id]/ai-prepare returns 401 for unauthenticated requests
  • Returns 503 when OPENAI_API_KEY is absent from environment
  • Returns 403 for non-Draft documents
  • Returns { fields, textFillData } on success, with fields written to DB
  • TypeScript compiles clean </success_criteria>
After completion, create `.planning/phases/13-ai-field-placement-and-pre-fill/13-02-SUMMARY.md`