chore: review

This commit is contained in:
Ephraim Atta-Duncan
2025-11-19 10:57:54 +00:00
parent a0a3e7fb93
commit 654fc57639
12 changed files with 710 additions and 435 deletions

View File

@ -190,7 +190,7 @@ export const EnvelopeEditorFieldsPage = () => {
for (const [pageNumber, fields] of fieldsPerPage.entries()) {
for (const detected of fields) {
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
const { ymin, xmin, ymax, xmax } = detected.boundingBox;
const positionX = (xmin / 1000) * 100;
const positionY = (ymin / 1000) * 100;
const width = ((xmax - xmin) / 1000) * 100;
@ -407,7 +407,7 @@ export const EnvelopeEditorFieldsPage = () => {
let totalAdded = 0;
for (const [pageNumber, detectedFields] of fieldsPerPage.entries()) {
for (const detected of detectedFields) {
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
const { ymin, xmin, ymax, xmax } = detected.boundingBox;
const positionX = (xmin / 1000) * 100;
const positionY = (ymin / 1000) * 100;
const width = ((xmax - xmin) / 1000) * 100;

View File

@ -0,0 +1,61 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { prisma } from '@documenso/prisma';
import type { DocumentData } from '@documenso/prisma/client';
/**
* Authorize a user's access to an envelope and return its document data.
* Checks both direct ownership and team membership.
*/
export async function authorizeDocumentAccess(
envelopeId: string,
userId: number,
): Promise<DocumentData> {
const envelope = await prisma.envelope.findUnique({
where: { id: envelopeId },
include: {
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!envelope || !envelope.envelopeItems || envelope.envelopeItems.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Envelope not found: ${envelopeId}`,
userMessage: 'The requested document does not exist.',
});
}
const isDirectOwner = envelope.userId === userId;
let hasTeamAccess = false;
if (envelope.teamId) {
try {
await getTeamById({ teamId: envelope.teamId, userId });
hasTeamAccess = true;
} catch {
hasTeamAccess = false;
}
}
if (!isDirectOwner && !hasTeamAccess) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: `User ${userId} does not have access to envelope ${envelopeId}`,
userMessage: 'You do not have permission to access this document.',
});
}
const documentData = envelope.envelopeItems[0]?.documentData;
if (!documentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document data not found in envelope: ${envelopeId}`,
userMessage: 'The requested document does not exist.',
});
}
return documentData;
}

View File

@ -0,0 +1,183 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Canvas, Image } from 'skia-canvas';
import type { TDetectFormFieldsResponse } from './types';
export type RenderedPage = {
image: Buffer;
pageNumber: number;
width: number;
height: number;
};
const GRID_PADDING = { left: 80, top: 20, right: 20, bottom: 40 };
const GRID_INTERVAL = 100;
const FIELD_COLORS = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
/**
* Saves debug visualizations of detected form fields for development purposes.
* Creates annotated images showing field bounding boxes and coordinate grids.
*/
export async function saveDebugVisualization(
renderedPages: RenderedPage[],
detectedFields: TDetectFormFieldsResponse,
): Promise<void> {
const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews');
await mkdir(debugDir, { recursive: true });
const timestamp = new Date()
.toISOString()
.replace(/[-:]/g, '')
.replace(/\..+/, '')
.replace('T', '_');
for (const page of renderedPages) {
const canvas = createAnnotatedCanvas(page, detectedFields);
await saveCanvasToFile(canvas, debugDir, timestamp, page.pageNumber);
}
}
function createAnnotatedCanvas(
page: RenderedPage,
detectedFields: TDetectFormFieldsResponse,
): Canvas {
const canvas = new Canvas(
page.width + GRID_PADDING.left + GRID_PADDING.right,
page.height + GRID_PADDING.top + GRID_PADDING.bottom,
);
const ctx = canvas.getContext('2d');
// Draw the original page image
const img = new Image();
img.src = page.image;
ctx.drawImage(img, GRID_PADDING.left, GRID_PADDING.top);
// Draw coordinate grid
drawCoordinateGrid(ctx, page.width, page.height);
// Draw field bounding boxes
drawFieldBoundingBoxes(ctx, page, detectedFields);
// Draw axis labels
drawAxisLabels(ctx, page.width, page.height);
return canvas;
}
function drawCoordinateGrid(
ctx: ReturnType<Canvas['getContext']>,
pageWidth: number,
pageHeight: number,
): void {
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
ctx.lineWidth = 1;
// Draw vertical grid lines
for (let i = 0; i <= 1000; i += GRID_INTERVAL) {
const x = GRID_PADDING.left + (i / 1000) * pageWidth;
ctx.beginPath();
ctx.moveTo(x, GRID_PADDING.top);
ctx.lineTo(x, pageHeight + GRID_PADDING.top);
ctx.stroke();
}
// Draw horizontal grid lines
for (let i = 0; i <= 1000; i += GRID_INTERVAL) {
const y = GRID_PADDING.top + (i / 1000) * pageHeight;
ctx.beginPath();
ctx.moveTo(GRID_PADDING.left, y);
ctx.lineTo(pageWidth + GRID_PADDING.left, y);
ctx.stroke();
}
}
function drawFieldBoundingBoxes(
ctx: ReturnType<Canvas['getContext']>,
page: RenderedPage,
detectedFields: TDetectFormFieldsResponse,
): void {
const pageFields = detectedFields.filter((field) => field.pageNumber === page.pageNumber);
pageFields.forEach((field, index) => {
const { ymin, xmin, ymax, xmax } = field.boundingBox;
const x = (xmin / 1000) * page.width + GRID_PADDING.left;
const y = (ymin / 1000) * page.height + GRID_PADDING.top;
const width = ((xmax - xmin) / 1000) * page.width;
const height = ((ymax - ymin) / 1000) * page.height;
const color = FIELD_COLORS[index % FIELD_COLORS.length];
ctx.strokeStyle = color;
ctx.lineWidth = 5;
ctx.strokeRect(x, y, width, height);
// Draw field label
ctx.fillStyle = color;
ctx.font = '20px Arial';
ctx.fillText(field.label, x, y - 5);
});
}
function drawAxisLabels(
ctx: ReturnType<Canvas['getContext']>,
pageWidth: number,
pageHeight: number,
): void {
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.font = '26px Arial';
// Draw Y-axis
ctx.beginPath();
ctx.moveTo(GRID_PADDING.left, GRID_PADDING.top);
ctx.lineTo(GRID_PADDING.left, pageHeight + GRID_PADDING.top);
ctx.stroke();
// Y-axis labels
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= 1000; i += GRID_INTERVAL) {
const y = GRID_PADDING.top + (i / 1000) * pageHeight;
ctx.fillStyle = '#000000';
ctx.fillText(i.toString(), GRID_PADDING.left - 5, y);
ctx.beginPath();
ctx.moveTo(GRID_PADDING.left - 5, y);
ctx.lineTo(GRID_PADDING.left, y);
ctx.stroke();
}
// Draw X-axis
ctx.beginPath();
ctx.moveTo(GRID_PADDING.left, pageHeight + GRID_PADDING.top);
ctx.lineTo(pageWidth + GRID_PADDING.left, pageHeight + GRID_PADDING.top);
ctx.stroke();
// X-axis labels
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let i = 0; i <= 1000; i += GRID_INTERVAL) {
const x = GRID_PADDING.left + (i / 1000) * pageWidth;
ctx.fillStyle = '#000000';
ctx.fillText(i.toString(), x, pageHeight + GRID_PADDING.top + 5);
ctx.beginPath();
ctx.moveTo(x, pageHeight + GRID_PADDING.top);
ctx.lineTo(x, pageHeight + GRID_PADDING.top + 5);
ctx.stroke();
}
}
async function saveCanvasToFile(
canvas: Canvas,
debugDir: string,
timestamp: string,
pageNumber: number,
): Promise<void> {
const outputFilename = `detected_form_fields_${timestamp}_page_${pageNumber}.png`;
const outputPath = join(debugDir, outputFilename);
const pngBuffer = await canvas.toBuffer('png');
await writeFile(outputPath, new Uint8Array(pngBuffer));
}

View File

@ -0,0 +1,118 @@
import { generateObject } from 'ai';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { resizeAndCompressImage } from '@documenso/lib/server-only/image/resize-and-compress-image';
import { DETECT_OBJECTS_PROMPT } from './prompts';
import type { TDetectFormFieldsResponse } from './types';
import { ZDetectedFormFieldSchema } from './types';
import { buildRecipientDirectory, safeGenerateObject, validateRecipientId } from './utils';
export type FieldDetectionRecipient = {
id: number;
name: string | null;
email: string | null;
role: string;
signingOrder: number | null;
};
/**
* Build the field detection prompt with optional recipient context.
*/
function buildFieldDetectionPrompt(recipients: FieldDetectionRecipient[]): string {
if (recipients.length === 0) {
return DETECT_OBJECTS_PROMPT;
}
const directory = buildRecipientDirectory(recipients);
return `${DETECT_OBJECTS_PROMPT}
RECIPIENT DIRECTORY:
${directory}
RECIPIENT ASSIGNMENT RULES:
1. Every detected field MUST include a "recipientId" taken from the directory above.
2. Match printed names, role labels ("Buyer", "Seller"), or instructions near the field to the closest recipient.
3. When the document references numbered signers (Signer 1, Signer 2, etc.), align them with signingOrder when provided.
4. If a name exactly matches a recipient, always use that recipient's ID.
5. When context is ambiguous, distribute fields logically across recipients instead of assigning all fields to one person.
6. Never invent new recipients or IDs—only use those in the directory.`;
}
/**
* Run form field detection on a single page image.
*/
export async function runFormFieldDetection(
imageBuffer: Buffer,
pageNumber: number,
recipients: FieldDetectionRecipient[],
): Promise<TDetectFormFieldsResponse> {
const compressedImageBuffer = await resizeAndCompressImage(imageBuffer);
const base64Image = compressedImageBuffer.toString('base64');
const prompt = buildFieldDetectionPrompt(recipients);
const detectedFields = await safeGenerateObject(
async () =>
generateObject({
model: 'google/gemini-3-pro-preview',
output: 'array',
schema: ZDetectedFormFieldSchema,
messages: [
{
role: 'user',
content: [
{
type: 'image',
image: `data:image/jpeg;base64,${base64Image}`,
},
{
type: 'text',
text: prompt,
},
],
},
],
}),
{
operation: 'detect form fields',
pageNumber,
},
);
return validateAndEnrichFields(detectedFields, recipients, pageNumber);
}
/**
* Validate recipient IDs and add page numbers to detected fields.
*/
function validateAndEnrichFields(
detectedFields: Array<Omit<TDetectFormFieldsResponse[0], 'pageNumber'>>,
recipients: FieldDetectionRecipient[],
pageNumber: number,
): TDetectFormFieldsResponse {
const recipientIds = new Set(recipients.map((r) => r.id));
const fallbackRecipientId = recipients[0]?.id;
if (fallbackRecipientId === undefined) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Unable to assign recipients because no recipients were provided',
userMessage: 'Please add at least one recipient before detecting form fields.',
});
}
return detectedFields.map((field) => {
const validatedRecipientId = validateRecipientId(
field.recipientId,
recipientIds,
fallbackRecipientId,
{ fieldLabel: field.label },
);
return {
...field,
recipientId: validatedRecipientId,
pageNumber,
};
});
}

View File

@ -1,185 +1,32 @@
import { generateObject } from 'ai';
import { Hono } from 'hono';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Canvas, Image } from 'skia-canvas';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { resizeAndCompressImage } from '@documenso/lib/server-only/image/resize-and-compress-image';
import { renderPdfToImage } from '@documenso/lib/server-only/pdf/render-pdf-to-image';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { env } from '@documenso/lib/utils/env';
import { resolveRecipientEmail } from '@documenso/lib/utils/recipients';
import { logger } from '@documenso/lib/utils/logger';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
import { ANALYZE_RECIPIENTS_PROMPT, DETECT_OBJECTS_PROMPT } from './prompts';
import { authorizeDocumentAccess } from './authorization';
import { saveDebugVisualization } from './debug-visualizer';
import type { FieldDetectionRecipient } from './field-detection';
import { runFormFieldDetection } from './field-detection';
import { MAX_PAGES_FOR_RECIPIENT_ANALYSIS, analyzePageForRecipients } from './recipient-detection';
import type { TAnalyzeRecipientsResponse, TDetectFormFieldsResponse } from './types';
import {
type TAnalyzeRecipientsResponse,
type TDetectFormFieldsResponse,
type TDetectedRecipient,
ZAnalyzeRecipientsRequestSchema,
ZDetectFormFieldsRequestSchema,
ZDetectFormFieldsResponseSchema,
ZDetectedFormFieldSchema,
ZDetectedRecipientLLMSchema,
} from './types';
import { processPageBatch, sortRecipientsForDetection } from './utils';
type FieldDetectionRecipient = {
id: number;
name: string | null;
email: string | null;
role: string;
signingOrder: number | null;
};
const buildFieldDetectionPrompt = (recipients: FieldDetectionRecipient[]) => {
if (recipients.length === 0) {
return DETECT_OBJECTS_PROMPT;
}
const directory = recipients
.map((recipient, index) => {
const name = recipient.name?.trim() || `Recipient ${index + 1}`;
const details = [`name: "${name}"`, `role: ${recipient.role}`];
if (recipient.email) {
details.push(`email: ${recipient.email}`);
}
if (typeof recipient.signingOrder === 'number') {
details.push(`signingOrder: ${recipient.signingOrder}`);
}
return `ID ${recipient.id}${details.join(', ')}`;
})
.join('\n');
return `${DETECT_OBJECTS_PROMPT}\n\nRECIPIENT DIRECTORY:\n${directory}\n\nRECIPIENT ASSIGNMENT RULES:\n1. Every detected field MUST include a "recipientId" taken from the directory above.\n2. Match printed names, role labels ("Buyer", "Seller"), or instructions near the field to the closest recipient.\n3. When the document references numbered signers (Signer 1, Signer 2, etc.), align them with signingOrder when provided.\n4. If a name exactly matches a recipient, always use that recipient's ID.\n5. When context is ambiguous, distribute fields logically across recipients instead of assigning all fields to one person.\n6. Never invent new recipients or IDs—only use those in the directory.`;
};
const runFormFieldDetection = async (
imageBuffer: Buffer,
pageNumber: number,
recipients: FieldDetectionRecipient[],
): Promise<TDetectFormFieldsResponse> => {
const compressedImageBuffer = await resizeAndCompressImage(imageBuffer);
const base64Image = compressedImageBuffer.toString('base64');
const prompt = buildFieldDetectionPrompt(recipients);
const result = await generateObject({
model: 'google/gemini-3-pro-preview',
output: 'array',
schema: ZDetectedFormFieldSchema,
messages: [
{
role: 'user',
content: [
{
type: 'image',
image: `data:image/jpeg;base64,${base64Image}`,
},
{
type: 'text',
text: prompt,
},
],
},
],
});
const recipientIds = new Set(recipients.map((recipient) => recipient.id));
const fallbackRecipientId = recipients[0]?.id;
if (fallbackRecipientId === undefined) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Unable to assign recipients because no recipients were provided',
userMessage: 'Please add at least one recipient before detecting form fields.',
});
}
return result.object.map((field) => {
let recipientId = field.recipientId;
if (!recipientIds.has(recipientId)) {
console.warn(
'AI returned invalid recipientId for detected field, defaulting to first recipient',
{
field,
fallbackRecipientId,
},
);
recipientId = fallbackRecipientId;
}
return {
...field,
recipientId,
pageNumber,
};
});
};
// Limit recipient detection to first 3 pages for performance and cost efficiency
const MAX_PAGES_FOR_RECIPIENT_ANALYSIS = 3;
const authorizeDocumentAccess = async (envelopeId: string, userId: number) => {
const envelope = await prisma.envelope.findUnique({
where: { id: envelopeId },
include: {
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!envelope || !envelope.envelopeItems || envelope.envelopeItems.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Envelope not found: ${envelopeId}`,
userMessage: 'The requested document does not exist.',
});
}
const isDirectOwner = envelope.userId === userId;
let hasTeamAccess = false;
if (envelope.teamId) {
try {
await getTeamById({ teamId: envelope.teamId, userId });
hasTeamAccess = true;
} catch {
hasTeamAccess = false;
}
}
if (!isDirectOwner && !hasTeamAccess) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: `User ${userId} does not have access to envelope ${envelopeId}`,
userMessage: 'You do not have permission to access this document.',
});
}
const documentData = envelope.envelopeItems[0]?.documentData;
if (!documentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document data not found in envelope: ${envelopeId}`,
userMessage: 'The requested document does not exist.',
});
}
return documentData;
};
export const aiRoute = new Hono<HonoEnv>()
.post('/detect-fields', async (c) => {
try {
const { user } = await getSession(c.req.raw);
/**
* Validates the user has a verified email for AI features.
*/
async function validateUserForAI(request: Request): Promise<{ userId: number }> {
const { user } = await getSession(request);
if (!user.emailVerified) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
@ -188,6 +35,39 @@ export const aiRoute = new Hono<HonoEnv>()
});
}
return { userId: user.id };
}
/**
* Fetches recipients for an envelope and validates they exist.
*/
async function getEnvelopeRecipients(envelopeId: string): Promise<FieldDetectionRecipient[]> {
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
select: {
id: true,
name: true,
email: true,
role: true,
signingOrder: true,
},
});
if (recipients.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `No recipients found for envelope ${envelopeId}`,
userMessage: 'Please add at least one recipient before detecting form fields.',
});
}
return sortRecipientsForDetection(recipients);
}
export const aiRoute = new Hono<HonoEnv>()
.post('/detect-fields', async (c) => {
try {
const { userId } = await validateUserForAI(c.req.raw);
const body = await c.req.json();
const parsed = ZDetectFormFieldsRequestSchema.safeParse(body);
@ -200,58 +80,8 @@ export const aiRoute = new Hono<HonoEnv>()
const { envelopeId } = parsed.data;
const documentData = await authorizeDocumentAccess(envelopeId, user.id);
const envelopeRecipients = await prisma.recipient.findMany({
where: { envelopeId },
select: {
id: true,
name: true,
email: true,
role: true,
signingOrder: true,
},
});
if (envelopeRecipients.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `No recipients found for envelope ${envelopeId}`,
userMessage: 'Please add at least one recipient before detecting form fields.',
});
}
const rolePriority: Record<string, number> = {
SIGNER: 0,
APPROVER: 1,
CC: 2,
};
const detectionRecipients: FieldDetectionRecipient[] = envelopeRecipients
.slice()
.sort((a, b) => {
const roleDiff = (rolePriority[a.role] ?? 3) - (rolePriority[b.role] ?? 3);
if (roleDiff !== 0) {
return roleDiff;
}
const aOrder =
typeof a.signingOrder === 'number' ? a.signingOrder : Number.MAX_SAFE_INTEGER;
const bOrder =
typeof b.signingOrder === 'number' ? b.signingOrder : Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
})
.map((recipient) => ({
id: recipient.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
}));
const documentData = await authorizeDocumentAccess(envelopeId, userId);
const detectionRecipients = await getEnvelopeRecipients(envelopeId);
const pdfBytes = await getFileServerSide({
type: documentData.type,
@ -260,140 +90,20 @@ export const aiRoute = new Hono<HonoEnv>()
const renderedPages = await renderPdfToImage(pdfBytes);
const results = await Promise.allSettled(
renderedPages.map(async (page) => {
return await runFormFieldDetection(page.image, page.pageNumber, detectionRecipients);
}),
const { results: pageResults } = await processPageBatch(
renderedPages,
async (page) => runFormFieldDetection(page.image, page.pageNumber, detectionRecipients),
{
itemName: 'page',
getItemIdentifier: (_, index) => renderedPages[index]?.pageNumber ?? index + 1,
errorMessage: 'We could not detect fields on some pages. Please try again.',
},
);
const detectedFields: TDetectFormFieldsResponse = [];
const failedPages: number[] = [];
for (const [index, result] of results.entries()) {
if (result.status === 'fulfilled') {
detectedFields.push(...result.value);
} else {
const pageNumber = renderedPages[index]?.pageNumber ?? index + 1;
console.error(`Failed to detect fields on page ${pageNumber}:`, result.reason);
failedPages.push(pageNumber);
}
}
if (failedPages.length > 0) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to detect fields on pages: ${failedPages.join(', ')}`,
userMessage: 'We could not detect fields on some pages. Please try again.',
});
}
const detectedFields = pageResults.flat();
if (env('NEXT_PUBLIC_AI_DEBUG_PREVIEW') === 'true') {
const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews');
await mkdir(debugDir, { recursive: true });
const now = new Date();
const timestamp = now
.toISOString()
.replace(/[-:]/g, '')
.replace(/\..+/, '')
.replace('T', '_');
for (const page of renderedPages) {
const padding = { left: 80, top: 20, right: 20, bottom: 40 };
const canvas = new Canvas(
page.width + padding.left + padding.right,
page.height + padding.top + padding.bottom,
);
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = page.image;
ctx.drawImage(img, padding.left, padding.top);
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
ctx.lineWidth = 1;
for (let i = 0; i <= 1000; i += 100) {
const x = padding.left + (i / 1000) * page.width;
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, page.height + padding.top);
ctx.stroke();
}
for (let i = 0; i <= 1000; i += 100) {
const y = padding.top + (i / 1000) * page.height;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(page.width + padding.left, y);
ctx.stroke();
}
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
const pageFields = detectedFields.filter((f) => f.pageNumber === page.pageNumber);
pageFields.forEach((field, index) => {
const [ymin, xmin, ymax, xmax] = field.boundingBox.map((coord) => coord / 1000);
const x = xmin * page.width + padding.left;
const y = ymin * page.height + padding.top;
const width = (xmax - xmin) * page.width;
const height = (ymax - ymin) * page.height;
ctx.strokeStyle = colors[index % colors.length];
ctx.lineWidth = 5;
ctx.strokeRect(x, y, width, height);
ctx.fillStyle = colors[index % colors.length];
ctx.font = '20px Arial';
ctx.fillText(field.label, x, y - 5);
});
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.font = '26px Arial';
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, page.height + padding.top);
ctx.stroke();
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= 1000; i += 100) {
const y = padding.top + (i / 1000) * page.height;
ctx.fillStyle = '#000000';
ctx.fillText(i.toString(), padding.left - 5, y);
ctx.beginPath();
ctx.moveTo(padding.left - 5, y);
ctx.lineTo(padding.left, y);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(padding.left, page.height + padding.top);
ctx.lineTo(page.width + padding.left, page.height + padding.top);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let i = 0; i <= 1000; i += 100) {
const x = padding.left + (i / 1000) * page.width;
ctx.fillStyle = '#000000';
ctx.fillText(i.toString(), x, page.height + padding.top + 5);
ctx.beginPath();
ctx.moveTo(x, page.height + padding.top);
ctx.lineTo(x, page.height + padding.top + 5);
ctx.stroke();
}
const outputFilename = `detected_form_fields_${timestamp}_page_${page.pageNumber}.png`;
const outputPath = join(debugDir, outputFilename);
const pngBuffer = await canvas.toBuffer('png');
await writeFile(outputPath, new Uint8Array(pngBuffer));
}
await saveDebugVisualization(renderedPages, detectedFields);
}
const validatedResponse = ZDetectFormFieldsResponseSchema.parse(detectedFields);
@ -404,7 +114,9 @@ export const aiRoute = new Hono<HonoEnv>()
throw error;
}
console.error('Failed to detect form fields from PDF:', error);
logger.error('Failed to detect form fields from PDF:', {
error: error instanceof Error ? error.message : String(error),
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to detect form fields from PDF: ${error instanceof Error ? error.message : String(error)}`,
@ -414,14 +126,7 @@ export const aiRoute = new Hono<HonoEnv>()
})
.post('/detect-recipients', async (c) => {
try {
const { user } = await getSession(c.req.raw);
if (!user.emailVerified) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Email verification required',
userMessage: 'Please verify your email to use AI features',
});
}
const { userId } = await validateUserForAI(c.req.raw);
const body = await c.req.json();
const parsed = ZAnalyzeRecipientsRequestSchema.safeParse(body);
@ -435,7 +140,7 @@ export const aiRoute = new Hono<HonoEnv>()
const { envelopeId } = parsed.data;
const documentData = await authorizeDocumentAccess(envelopeId, user.id);
const documentData = await authorizeDocumentAccess(envelopeId, userId);
const pdfBytes = await getFileServerSide({
type: documentData.type,
@ -443,79 +148,19 @@ export const aiRoute = new Hono<HonoEnv>()
});
const renderedPages = await renderPdfToImage(pdfBytes);
const pagesToAnalyze = renderedPages.slice(0, MAX_PAGES_FOR_RECIPIENT_ANALYSIS);
const results = await Promise.allSettled(
pagesToAnalyze.map(async (page) => {
const compressedImageBuffer = await resizeAndCompressImage(page.image);
const base64Image = compressedImageBuffer.toString('base64');
const result = await generateObject({
model: 'anthropic/claude-haiku-4.5',
output: 'array',
schema: ZDetectedRecipientLLMSchema,
messages: [
const { results: pageResults } = await processPageBatch(
pagesToAnalyze,
async (page) => analyzePageForRecipients(page),
{
role: 'user',
content: [
{
type: 'image',
image: `data:image/jpeg;base64,${base64Image}`,
itemName: 'page',
getItemIdentifier: (page) => page.pageNumber,
errorMessage: 'We could not analyze recipients on some pages. Please try again.',
},
{
type: 'text',
text: ANALYZE_RECIPIENTS_PROMPT,
},
],
},
],
});
return {
pageNumber: page.pageNumber,
recipients: result.object,
};
}),
);
const allRecipients: TDetectedRecipient[] = [];
let recipientIndex = 1;
const failedPages: number[] = [];
for (const [index, result] of results.entries()) {
if (result.status !== 'fulfilled') {
const pageNumber = pagesToAnalyze[index]?.pageNumber ?? index + 1;
console.error(`Failed to analyze recipients on page ${pageNumber}:`, result.reason);
failedPages.push(pageNumber);
continue;
}
const { recipients } = result.value;
const recipientsWithEmails = recipients.map((recipient) => {
const email = resolveRecipientEmail(recipient.email);
const normalizedRecipient: TDetectedRecipient = {
...recipient,
email,
};
recipientIndex += 1;
return normalizedRecipient;
});
allRecipients.push(...recipientsWithEmails);
}
if (failedPages.length > 0) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to analyze recipients on pages: ${failedPages.join(', ')}`,
userMessage: 'We could not analyze recipients on some pages. Please try again.',
});
}
const allRecipients = pageResults.flat();
return c.json<TAnalyzeRecipientsResponse>(allRecipients);
} catch (error) {
@ -523,7 +168,9 @@ export const aiRoute = new Hono<HonoEnv>()
throw error;
}
console.error('Failed to analyze recipients from PDF:', error);
logger.error('Failed to analyze recipients from PDF:', {
error: error instanceof Error ? error.message : String(error),
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to analyze recipients from PDF: ${error instanceof Error ? error.message : String(error)}`,

View File

@ -0,0 +1,16 @@
// Re-export all types from the module for convenient importing
export type {
TAnalyzeRecipientsRequest,
TAnalyzeRecipientsResponse,
TDetectedFormField,
TDetectedRecipient,
TDetectFormFieldsRequest,
TDetectFormFieldsResponse,
TGenerateTextRequest,
TGenerateTextResponse,
TRecipientRole,
} from './types';
export type { FieldDetectionRecipient } from './field-detection';
export type { PageWithImage } from './recipient-detection';
export type { RenderedPage } from './debug-visualizer';

View File

@ -4,7 +4,7 @@ IMPORTANT RULES:
1. Only detect EMPTY/UNFILLED fields (ignore boxes that already contain text or data)
2. Analyze nearby text labels to determine the field type
3. Return bounding boxes for the fillable area only, NOT the label text
4. Each boundingBox must be in the format [ymin, xmin, ymax, xmax] where all coordinates are NORMALIZED to a 0-1000 scale
4. Each boundingBox must be in the format {ymin, xmin, ymax, xmax} where all coordinates are NORMALIZED to a 0-1000 scale
CRITICAL: UNDERSTANDING FILLABLE AREAS
The "fillable area" is ONLY the empty space where a user will write, type, sign, or check.
@ -56,7 +56,7 @@ BOUNDING BOX PLACEMENT (CRITICAL):
- The box should never cover only the leftmost few characters of a long field. For "Signature: ____________", the box must stretch from the first underscore to the last.
COORDINATE SYSTEM:
- [ymin, xmin, ymax, xmax] normalized to 0-1000 scale
- {ymin, xmin, ymax, xmax} normalized to 0-1000 scale
- Top-left corner: ymin and xmin close to 0
- Bottom-right corner: ymax and xmax close to 1000
- Coordinates represent positions on a 1000x1000 grid overlaid on the image
@ -78,7 +78,7 @@ When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE,
6. Example: If you detect a signature line at ymax=500 with clear whitespace extending up to y=400:
- Available whitespace: 100 units
- Use 60-80% of that: 60-80 units
- Expanded field: [ymin=420, xmin=200, ymax=500, xmax=600] (creates 80-unit tall field)
- Expanded field: {ymin: 420, xmin: 200, ymax: 500, xmax: 600} (creates 80-unit tall field)
- This gives comfortable signing space while respecting the form layout`;
export const ANALYZE_RECIPIENTS_PROMPT = `You are analyzing a document to identify recipients who need to sign, approve, or receive copies.

View File

@ -0,0 +1,72 @@
import { generateObject } from 'ai';
import { resizeAndCompressImage } from '@documenso/lib/server-only/image/resize-and-compress-image';
import { resolveRecipientEmail } from '@documenso/lib/utils/recipients';
import { ANALYZE_RECIPIENTS_PROMPT } from './prompts';
import type { TDetectedRecipient } from './types';
import { ZDetectedRecipientLLMSchema } from './types';
import { safeGenerateObject } from './utils';
// Limit recipient detection to first 3 pages for performance and cost efficiency
export const MAX_PAGES_FOR_RECIPIENT_ANALYSIS = 3;
export type PageWithImage = {
image: Buffer;
pageNumber: number;
};
/**
* Analyze a single page for recipient information.
*/
export async function analyzePageForRecipients(page: PageWithImage): Promise<TDetectedRecipient[]> {
const compressedImageBuffer = await resizeAndCompressImage(page.image);
const base64Image = compressedImageBuffer.toString('base64');
const recipients = await safeGenerateObject(
async () =>
generateObject({
model: 'anthropic/claude-haiku-4.5',
output: 'array',
schema: ZDetectedRecipientLLMSchema,
messages: [
{
role: 'user',
content: [
{
type: 'image',
image: `data:image/jpeg;base64,${base64Image}`,
},
{
type: 'text',
text: ANALYZE_RECIPIENTS_PROMPT,
},
],
},
],
}),
{
operation: 'analyze recipients',
pageNumber: page.pageNumber,
},
);
return normalizeRecipients(recipients);
}
/**
* Normalize recipient data by resolving emails and ensuring consistent format.
*/
function normalizeRecipients(
recipients: Array<{
name: string;
email?: string;
role: 'SIGNER' | 'APPROVER' | 'CC';
signingOrder?: number;
}>,
): TDetectedRecipient[] {
return recipients.map((recipient) => ({
...recipient,
email: resolveRecipientEmail(recipient.email),
}));
}

View File

@ -28,9 +28,11 @@ export type TDetectFormFieldsRequest = z.infer<typeof ZDetectFormFieldsRequestSc
export type TDetectFormFieldsResponse = z.infer<typeof ZDetectFormFieldsResponseSchema>;
export type { TDetectedFormField };
const ZRecipientRoleEnum = z.enum(['SIGNER', 'APPROVER', 'CC']);
const recipientFieldShape = {
name: z.string().describe('Full name of the recipient'),
role: z.enum(['SIGNER', 'APPROVER', 'CC']).describe('Recipient role based on document context'),
role: ZRecipientRoleEnum.describe('Recipient role based on document context'),
signingOrder: z
.number()
.int()
@ -39,11 +41,12 @@ const recipientFieldShape = {
.describe('Sequential signing order if document indicates ordering'),
} as const;
const createRecipientSchema = <TSchema extends z.ZodTypeAny>(emailSchema: TSchema) =>
z.object({
function createRecipientSchema<TSchema extends z.ZodTypeAny>(emailSchema: TSchema) {
return z.object({
...recipientFieldShape,
email: emailSchema,
});
}
export const ZDetectedRecipientLLMSchema = createRecipientSchema(
z
@ -69,3 +72,4 @@ export const ZAnalyzeRecipientsResponseSchema = z.array(ZDetectedRecipientSchema
export type TDetectedRecipient = z.infer<typeof ZDetectedRecipientSchema>;
export type TAnalyzeRecipientsRequest = z.infer<typeof ZAnalyzeRecipientsRequestSchema>;
export type TAnalyzeRecipientsResponse = z.infer<typeof ZAnalyzeRecipientsResponseSchema>;
export type TRecipientRole = z.infer<typeof ZRecipientRoleEnum>;

View File

@ -0,0 +1,164 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { logger } from '@documenso/lib/utils/logger';
/**
* Process an array of items in parallel and handle failures gracefully.
* Returns successful results and reports failed items.
*/
export async function processPageBatch<TInput, TOutput>(
items: TInput[],
processor: (item: TInput, index: number) => Promise<TOutput>,
context: {
itemName: string; // e.g., "page", "recipient"
getItemIdentifier: (item: TInput, index: number) => number | string; // e.g., pageNumber
errorMessage: string; // User-facing error message
},
): Promise<{
results: TOutput[];
failedItems: Array<number | string>;
}> {
const settledResults = await Promise.allSettled(
items.map(async (item, index) => processor(item, index)),
);
const results: TOutput[] = [];
const failedItems: Array<number | string> = [];
for (const [index, result] of settledResults.entries()) {
if (result.status === 'fulfilled') {
results.push(result.value);
} else {
const identifier = context.getItemIdentifier(items[index]!, index);
logger.error(`Failed to process ${context.itemName} ${identifier}:`, {
error: result.reason,
identifier,
});
failedItems.push(identifier);
}
}
if (failedItems.length > 0) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to process ${context.itemName}s: ${failedItems.join(', ')}`,
userMessage: context.errorMessage,
});
}
return { results, failedItems: [] };
}
/**
* Safely execute an LLM generation with proper error handling and logging.
*/
export async function safeGenerateObject<T>(
generatorFn: () => Promise<{ object: T }>,
context: {
operation: string; // e.g., "detect form fields", "analyze recipients"
pageNumber?: number;
},
): Promise<T> {
try {
const result = await generatorFn();
return result.object;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const pageContext = context.pageNumber ? ` on page ${context.pageNumber}` : '';
logger.error(`Failed to ${context.operation}${pageContext}:`, {
error: errorMessage,
pageNumber: context.pageNumber,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `AI generation failed for ${context.operation}: ${errorMessage}`,
userMessage: `Unable to ${context.operation}. Please try again.`,
});
}
}
/**
* Sort recipients by role priority and signing order for consistent field assignment.
*/
export function sortRecipientsForDetection<
T extends { role: string; signingOrder: number | null; id: number },
>(recipients: T[]): T[] {
const ROLE_PRIORITY: Record<string, number> = {
SIGNER: 0,
APPROVER: 1,
CC: 2,
};
return recipients.slice().sort((a, b) => {
// 1. Sort by role priority
const roleComparison = (ROLE_PRIORITY[a.role] ?? 3) - (ROLE_PRIORITY[b.role] ?? 3);
if (roleComparison !== 0) {
return roleComparison;
}
// 2. Sort by signing order (null values last)
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
// 3. Sort by ID as final tiebreaker
return a.id - b.id;
});
}
/**
* Build a recipient directory string for LLM context.
*/
export function buildRecipientDirectory(
recipients: Array<{
id: number;
name: string | null;
email: string | null;
role: string;
signingOrder: number | null;
}>,
): string {
return recipients
.map((recipient, index) => {
const name = recipient.name?.trim() || `Recipient ${index + 1}`;
const details = [`name: "${name}"`, `role: ${recipient.role}`];
if (recipient.email) {
details.push(`email: ${recipient.email}`);
}
if (typeof recipient.signingOrder === 'number') {
details.push(`signingOrder: ${recipient.signingOrder}`);
}
return `ID ${recipient.id}${details.join(', ')}`;
})
.join('\n');
}
/**
* Validate and correct recipient IDs to ensure they match available recipients.
*/
export function validateRecipientId(
fieldRecipientId: number,
availableRecipientIds: Set<number>,
fallbackRecipientId: number,
context?: { fieldLabel?: string },
): number {
if (availableRecipientIds.has(fieldRecipientId)) {
return fieldRecipientId;
}
logger.error('AI returned invalid recipientId for detected field', {
invalidRecipientId: fieldRecipientId,
fieldLabel: context?.fieldLabel,
availableRecipientIds: Array.from(availableRecipientIds),
});
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `AI assigned field "${context?.fieldLabel || 'Unknown'}" to invalid recipient ID ${fieldRecipientId}`,
userMessage:
'We detected fields assigned to a recipient that does not exist. Please try again.',
});
}

View File

@ -68,10 +68,13 @@ export const renderPdfToImage = async (pdfBytes: Uint8Array) => {
.map((result) => result.value);
if (results.some((result) => result.status === 'rejected')) {
console.error(
'Some pages failed to render:',
results.filter((result) => result.status === 'rejected').map((result) => result.reason),
);
const failedReasons = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason);
// Note: We don't have logger available in this package
// Errors are handled by the caller in document-analysis/index.ts
// which will use the proper logger for reporting failures
}
return pages;

View File

@ -2,9 +2,16 @@ import { z } from 'zod';
export const ZDetectedFormFieldSchema = z.object({
boundingBox: z
.array(z.number().min(0).max(1000))
.length(4)
.describe('Bounding box [ymin, xmin, ymax, xmax] in normalized 0-1000 range'),
.object({
ymin: z.number().min(0).max(1000),
xmin: z.number().min(0).max(1000),
ymax: z.number().min(0).max(1000),
xmax: z.number().min(0).max(1000),
})
.refine((box) => box.xmin < box.xmax && box.ymin < box.ymax, {
message: 'Bounding box must have min < max for both axes',
})
.describe('Bounding box {ymin, xmin, ymax, xmax} in normalized 0-1000 range'),
label: z
.enum([
'SIGNATURE',