Files

259 lines
10 KiB
Markdown
Raw Permalink Normal View History

---
phase: 13-ai-field-placement-and-pre-fill
plan: 02
type: execute
wave: 2
depends_on:
- 13-01
files_modified:
- teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts
autonomous: true
requirements:
- AI-01
- AI-02
must_haves:
truths:
- "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)"
artifacts:
- path: "teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts"
provides: "POST handler orchestrating extract → AI → convert → DB write → return"
exports: ["POST"]
key_links:
- from: "teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts"
to: "teressa-copeland-homes/src/lib/ai/extract-text.ts"
via: "import { extractPdfText } from '@/lib/ai/extract-text'"
pattern: "extractPdfText"
- from: "teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts"
to: "teressa-copeland-homes/src/lib/ai/field-placement.ts"
via: "import { classifyFieldsWithAI } from '@/lib/ai/field-placement'"
pattern: "classifyFieldsWithAI"
- from: "teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts"
to: "drizzle documents table"
via: "db.update(documents).set({ signatureFields: fields })"
pattern: "signatureFields"
---
<objective>
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`
</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
@.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.
</context>
<interfaces>
<!-- Key types and patterns from the codebase. Extracted for executor. -->
From teressa-copeland-homes/src/lib/db/schema.ts:
```typescript
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):
```typescript
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):
```typescript
// 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> }>
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Create POST /api/documents/[id]/ai-prepare route</name>
<files>teressa-copeland-homes/src/app/api/documents/[id]/ai-prepare/route.ts</files>
<action>
Create the directory `src/app/api/documents/[id]/ai-prepare/` and the `route.ts` file.
Implement:
```typescript
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.).
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | grep -E "ai-prepare|error" | head -20</automated>
</verify>
<done>
- `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> }`
</done>
</task>
</tasks>
<verification>
- `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)
</verification>
<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>
<output>
After completion, create `.planning/phases/13-ai-field-placement-and-pre-fill/13-02-SUMMARY.md`
</output>