| import { limitWords, mapLanguageCodeToPostgresConfig } from "@midday/documents"; |
| import { DocumentClassifier } from "@midday/documents/classifier"; |
| import { triggerJob } from "@midday/job-client"; |
| import { createClient } from "@midday/supabase/job"; |
| import type { Job } from "bullmq"; |
| import type { ClassifyImagePayload } from "../../schemas/documents"; |
| import { getDb } from "../../utils/db"; |
| import { updateDocumentWithRetry } from "../../utils/document-update"; |
| import { NonRetryableError } from "../../utils/error-classification"; |
| import { resizeImage } from "../../utils/image-processing"; |
| import { TIMEOUTS, withTimeout } from "../../utils/timeout"; |
| import { BaseProcessor } from "../base"; |
|
|
| |
| |
| |
| interface ImageClassificationResult { |
| title: string | null; |
| summary: string | null; |
| content: string | null; |
| date: string | null; |
| language: string | null; |
| tags: string[] | null; |
| } |
|
|
| |
| |
| |
| |
| |
| export class ClassifyImageProcessor extends BaseProcessor<ClassifyImagePayload> { |
| async process(job: Job<ClassifyImagePayload>): Promise<void> { |
| const { teamId, fileName } = job.data; |
| const supabase = createClient(); |
| const db = getDb(); |
|
|
| |
| |
| const pathTokens = fileName.split("/"); |
|
|
| this.logger.info("Classifying image", { |
| fileName, |
| teamId, |
| }); |
|
|
| |
| const { data: fileData } = await withTimeout( |
| supabase.storage.from("vault").download(fileName), |
| TIMEOUTS.FILE_DOWNLOAD, |
| `File download timed out after ${TIMEOUTS.FILE_DOWNLOAD}ms`, |
| ); |
|
|
| if (!fileData) { |
| throw new NonRetryableError("File not found", undefined, "validation"); |
| } |
|
|
| const rawImageContent = await fileData.arrayBuffer(); |
|
|
| |
| |
| const { buffer: imageContent } = await resizeImage( |
| rawImageContent, |
| fileData.type || "image/jpeg", |
| this.logger, |
| ); |
|
|
| |
| let classificationResult: ImageClassificationResult | null = null; |
| let classificationFailed = false; |
|
|
| try { |
| const classifier = new DocumentClassifier(); |
| |
| const arrayBuffer = new Uint8Array(imageContent).buffer; |
| classificationResult = await withTimeout( |
| classifier.classifyImage({ content: arrayBuffer }), |
| TIMEOUTS.AI_CLASSIFICATION, |
| `Image classification timed out after ${TIMEOUTS.AI_CLASSIFICATION}ms`, |
| ); |
| } catch (error) { |
| |
| classificationFailed = true; |
| this.logger.warn( |
| "AI image classification failed, completing with fallback", |
| { |
| fileName, |
| teamId, |
| error: error instanceof Error ? error.message : "Unknown error", |
| errorType: error instanceof Error ? error.name : "Unknown", |
| }, |
| ); |
| } |
|
|
| |
| let finalTitle: string | null = null; |
|
|
| if ( |
| classificationResult?.title && |
| classificationResult.title.trim().length > 0 |
| ) { |
| finalTitle = classificationResult.title; |
| } else if (classificationResult && !classificationFailed) { |
| |
| this.logger.warn( |
| "Image classification returned null or empty title - generating fallback", |
| { |
| fileName, |
| pathTokens, |
| teamId, |
| hasSummary: !!classificationResult.summary, |
| hasDate: !!classificationResult.date, |
| hasContent: !!classificationResult.content, |
| }, |
| ); |
|
|
| |
| const fileNameWithoutExt = |
| fileName |
| .split("/") |
| .pop() |
| ?.replace(/\.[^/.]+$/, "") || "Image"; |
| const datePart = classificationResult.date |
| ? ` - ${classificationResult.date}` |
| : ""; |
| const summaryPart = classificationResult.summary |
| ? ` - ${classificationResult.summary.substring(0, 50)}${classificationResult.summary.length > 50 ? "..." : ""}` |
| : ""; |
|
|
| |
| const contentSample = ( |
| classificationResult.content || |
| classificationResult.summary || |
| "" |
| ).toLowerCase(); |
| let inferredType = "Image"; |
| if (contentSample.includes("receipt")) { |
| inferredType = "Receipt"; |
| } else if ( |
| contentSample.includes("invoice") || |
| contentSample.includes("inv") |
| ) { |
| inferredType = "Invoice"; |
| } else if (contentSample.includes("logo")) { |
| inferredType = "Logo"; |
| } else if (contentSample.includes("photo")) { |
| inferredType = "Photo"; |
| } |
|
|
| finalTitle = `${inferredType}${summaryPart || ` - ${fileNameWithoutExt}`}${datePart}`; |
|
|
| this.logger.info("Generated fallback title for image", { |
| fileName, |
| generatedTitle: finalTitle, |
| }); |
| } |
| |
|
|
| |
| |
| const updatedDocs = await updateDocumentWithRetry( |
| db, |
| { |
| pathTokens, |
| teamId, |
| title: finalTitle ?? undefined, |
| summary: classificationResult?.summary ?? undefined, |
| content: classificationResult?.content |
| ? limitWords(classificationResult.content, 10000) |
| : undefined, |
| date: classificationResult?.date ?? undefined, |
| language: mapLanguageCodeToPostgresConfig( |
| classificationResult?.language, |
| ), |
| |
| processingStatus: "completed", |
| }, |
| this.logger, |
| ); |
|
|
| if (!updatedDocs || updatedDocs.length === 0) { |
| this.logger.error("Document not found for image classification update", { |
| fileName, |
| pathTokens, |
| teamId, |
| }); |
| throw new Error(`Document with path ${fileName} not found`); |
| } |
|
|
| const data = updatedDocs[0]; |
| if (!data || !data.id) { |
| throw new Error( |
| `Document update returned invalid data for path ${fileName}`, |
| ); |
| } |
|
|
| |
| if (classificationResult?.tags && classificationResult.tags.length > 0) { |
| this.logger.info("Triggering document tag embedding", { |
| documentId: data.id, |
| tagsCount: classificationResult.tags.length, |
| }); |
|
|
| await triggerJob( |
| "embed-document-tags", |
| { documentId: data.id, tags: classificationResult.tags, teamId }, |
| "documents", |
| { jobId: `embed-tags_${teamId}_${data.id}` }, |
| ); |
| } else { |
| this.logger.info("Image processing completed", { |
| documentId: data.id, |
| classificationFailed, |
| hasTitle: !!finalTitle, |
| }); |
| } |
| } |
| } |
|
|