259 lines
10 KiB
Markdown
259 lines
10 KiB
Markdown
|
|
---
|
||
|
|
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>
|