From 2184c4dcdaba04595a775fbc0e8ebcfd0b1954d1 Mon Sep 17 00:00:00 2001 From: smslc Date: Tue, 14 Jul 2026 00:55:02 +0200 Subject: [PATCH] feat: add implementation for bounty #1 --- bounty-1/DESIGN.md | 140 ++++++++++++++++++++++++ bounty-1/README.md | 125 +++++++++++++++++++++ bounty-1/lib/prisma.ts | 8 ++ bounty-1/schema.prisma | 39 +++++++ bounty-1/services/contentExtractor.ts | 117 ++++++++++++++++++++ bounty-1/services/gcsDownloader.ts | 45 ++++++++ bounty-1/services/sqsConsumer.ts | 151 ++++++++++++++++++++++++++ bounty-1/services/summarizer.ts | 98 +++++++++++++++++ bounty-1/types.ts | 53 +++++++++ 9 files changed, 776 insertions(+) create mode 100644 bounty-1/DESIGN.md create mode 100644 bounty-1/README.md create mode 100644 bounty-1/lib/prisma.ts create mode 100644 bounty-1/schema.prisma create mode 100644 bounty-1/services/contentExtractor.ts create mode 100644 bounty-1/services/gcsDownloader.ts create mode 100644 bounty-1/services/sqsConsumer.ts create mode 100644 bounty-1/services/summarizer.ts create mode 100644 bounty-1/types.ts diff --git a/bounty-1/DESIGN.md b/bounty-1/DESIGN.md new file mode 100644 index 00000000..12800dda --- /dev/null +++ b/bounty-1/DESIGN.md @@ -0,0 +1,140 @@ +# Attachment Summarizer — Architecture Design + +## Overview + +A Node.js background service that processes email attachment events from an SQS queue, +downloads the attachments from Google Cloud Storage, extracts text content from PDF, DOCX, +TXT, and image files, and generates natural-language summaries using a self-hosted +open-source LLM (Llama 3 / Mistral via Ollama). + +## System Flow + +``` +Email System + │ + │ (S3/GCS event notification → SQS) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ SQS Queue │ +│ AttachmentEvent { token, gcsUri } │ +└─────────────────────────────────────────────────────────┘ + │ + │ Poll (long-poll, batch size 10) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ sqsConsumer.ts │ +│ - Receives SQS messages │ +│ - Parses AttachmentEvent │ +│ - Passes to orchestrator │ +│ - Deletes message on success / DLQ on failure │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ gcsDownloader.ts │ +│ - Authenticates via GCP service account (ADC) │ +│ - Downloads file to local temp storage │ +│ - Returns file path + content-type │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ contentExtractor.ts │ +│ - Routes on MIME type │ +│ ├─ application/pdf → pdf-parse │ +│ ├─ application/vnd.openxmlformats-officedocument │ +│ │ .wordprocessingml.document → mammoth │ +│ ├─ text/plain → fs.readFile │ +│ └─ image/* → tesseract OCR │ +│ - Returns ExtractionResult { text, metadata } │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ summarizer.ts │ +│ - Calls self-hosted LLM (Ollama API) │ +│ - Constructs prompt with extracted text │ +│ - Returns Summary { id, summary, createdAt } │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ PostgreSQL via Prisma │ +│ - Stores Attachment, ExtractionResult, Summary │ +│ - Enables idempotency dedup by SQS message ID │ +└─────────────────────────────────────────────────────────┘ +``` + +## Component Responsibilities + +### 1. SQS Consumer (`sqsConsumer.ts`) +- Long-polls SQS in an infinite loop (configurable interval). +- Parses `AttachmentEvent` from message body. +- Runs the full pipeline: download → extract → summarize → persist. +- On success: deletes the message from SQS. +- On failure after retries: sends to DLQ (configured at queue level). +- Uses `@aws-sdk/client-sqs`. + +### 2. GCS Downloader (`gcsDownloader.ts`) +- Parses `gs://bucket/path` URIs. +- Streams files to a temp directory using `@google-cloud/storage`. +- Cleans up temp files after processing. +- Returns local path and detected MIME type. + +### 3. Content Extractor (`contentExtractor.ts`) +- Routes by MIME type to the appropriate extraction engine. +- PDF: `pdf-parse` (PDF.js-based, extracts text). +- DOCX: `mammoth` (converts DOCX to raw text). +- TXT: Direct `fs.readFile` + UTF-8 decode. +- Images (JPEG, PNG, TIFF): `tesseract.js` OCR. +- Returns a normalized `ExtractionResult`. + +### 4. Summarizer (`summarizer.ts`) +- Constructs a prompt with the extracted text and a summarization instruction. +- Calls Ollama REST API (`http://localhost:11434/api/generate`). +- Model: `llama3.1:8b` or `mistral:7b` (configurable). +- Configures max tokens, temperature, prompt template. +- Parses and returns the generated summary text. + +### 5. Database (Prisma + PostgreSQL) +- Tracks each attachment's processing state. +- Avoids duplicate processing via SQS message dedup ID. +- Stores raw extracted text and the final summary. + +## Data Flow (Sequence) + +``` +SQS ──► sqsConsumer ──► gcsDownloader ──► contentExtractor ──► summarizer ──► Prisma + │ │ │ │ │ │ + │ parse event download file extract text call LLM persist + │ delete msg cleanup temp return text return ack +``` + +## Error Handling & Retry + +| Layer | Strategy | +|-------|----------| +| SQS | Visibility timeout (30s); message goes to DLQ after 3 receives | +| GCS | Retry with exponential backoff (3 attempts) | +| Extraction | Catch per-file errors; skip unsupported types gracefully | +| LLM | Retry on 5xx / timeout (2 attempts); fallback to truncated summary | + +## Security Considerations + +- GCS access via Workload Identity Federation or service account key (ADC). +- SQS access via IAM role with least-privilege policy. +- LLM runs on localhost — no network exposure. +- Temp files cleaned immediately after extraction. +- No secrets in code — all via environment variables. + +## Deployment + +- Docker container deployed to Cloud Run (or ECS Fargate). +- Single-process, no HTTP server needed (background worker). +- Configure `SQS_QUEUE_URL`, `GOOGLE_APPLICATION_CREDENTIALS`, `DATABASE_URL`, `OLLAMA_BASE_URL`. + +## Scaling + +- Horizontally scalable: each instance polls the same SQS queue. +- SQS visibility timeout must exceed max processing time per message. +- Use SQS batch size of 10 for throughput. diff --git a/bounty-1/README.md b/bounty-1/README.md new file mode 100644 index 00000000..9b182280 --- /dev/null +++ b/bounty-1/README.md @@ -0,0 +1,125 @@ +# Attachment Summarizer + +Consumes email attachment events from AWS SQS, downloads files from Google Cloud Storage, extracts text (PDF, DOCX, TXT, images), and generates summaries via a self-hosted open-source LLM (Ollama). + +## Prerequisites + +- Node.js 20+ +- PostgreSQL 15+ +- AWS SQS queue + IAM credentials +- Google Cloud Storage bucket + service account +- Ollama with a model pulled (`llama3.1:8b` or `mistral:7b`) + +## Quick Start + +```bash +# Install dependencies +npm install + +# Generate Prisma client +npx prisma generate + +# Run migrations +npx prisma migrate deploy + +# Start the consumer +node -r dotenv/config dist/index.js +``` + +## Environment Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `SQS_QUEUE_URL` | yes | — | SQS queue URL | +| `AWS_REGION` | no | `us-east-1` | AWS region | +| `GOOGLE_APPLICATION_CREDENTIALS` | no | — | Path to GCP service account JSON | +| `DATABASE_URL` | yes | — | PostgreSQL connection string | +| `OLLAMA_BASE_URL` | no | `http://localhost:11434` | Ollama API base URL | +| `LLM_MODEL` | no | `llama3.1:8b` | Ollama model name | +| `MAX_INPUT_TOKENS` | no | `3072` | Max tokens to send to LLM | +| `MAX_OUTPUT_TOKENS` | no | `512` | Max tokens in generated summary | + +## SQS Message Format + +Send messages with this JSON body: + +```json +{ + "attachmentId": "uuid-or-id", + "gcsUri": "gs://bucket-name/path/to/file.pdf", + "fileName": "invoice.pdf", + "mimeType": "application/pdf", + "receivedAt": "2026-07-14T12:00:00Z" +} +``` + +## Architecture + +See [DESIGN.md](./DESIGN.md) for full architecture details. + +### Directory Layout + +``` +├── types.ts # TypeScript type definitions +├── services/ +│ ├── sqsConsumer.ts # SQS long-poll consumer +│ ├── gcsDownloader.ts # GCS file downloader +│ ├── contentExtractor.ts # PDF/DOCX/TXT/image extraction +│ └── summarizer.ts # Ollama LLM summarization +├── schema.prisma # Prisma schema (PostgreSQL) +├── DESIGN.md # Architecture document +└── README.md # This file +``` + +## Docker + +```dockerfile +FROM node:20-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr tesseract-ocr-eng poppler-utils && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY dist/ ./dist/ +COPY prisma/ ./prisma/ +RUN npx prisma generate +CMD ["node", "dist/index.js"] +``` + +## Deployment (Cloud Run) + +```bash +gcloud builds submit --tag gcr.io/$PROJECT_ID/attachment-summarizer +gcloud run deploy attachment-summarizer \ + --image gcr.io/$PROJECT_ID/attachment-summarizer \ + --platform managed \ + --region us-central1 \ + --set-env-vars="SQS_QUEUE_URL=...,DATABASE_URL=...,OLLAMA_BASE_URL=..." +``` + +## Development + +```bash +cp .env.example .env +npm run dev # tsx watch src/index.ts +npm run build # tsc +npm run lint # biome check +``` + +## Supported File Types + +| Type | Extension | Engine | +|---|---|---| +| PDF | `.pdf` | `pdf-parse` | +| DOCX | `.docx` | `mammoth` | +| TXT | `.txt`, `.csv`, `.log`, `.md` | `fs.readFile` | +| JPEG | `.jpg`, `.jpeg` | `tesseract.js` | +| PNG | `.png` | `tesseract.js` | +| TIFF | `.tif`, `.tiff` | `tesseract.js` | + +## Monitoring + +- Logs: structured JSON via `pino` (recommended). +- Metrics: expose Prometheus gauge for queue depth and processing latency. +- Alerts: DLQ message count > 0 should trigger an alert. diff --git a/bounty-1/lib/prisma.ts b/bounty-1/lib/prisma.ts new file mode 100644 index 00000000..5b8268a6 --- /dev/null +++ b/bounty-1/lib/prisma.ts @@ -0,0 +1,8 @@ +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient({ + log: + process.env.NODE_ENV === 'development' + ? ['query', 'warn', 'error'] + : ['warn', 'error'], +}); diff --git a/bounty-1/schema.prisma b/bounty-1/schema.prisma new file mode 100644 index 00000000..34e17a29 --- /dev/null +++ b/bounty-1/schema.prisma @@ -0,0 +1,39 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum AttachmentStatus { + PENDING + PROCESSING + COMPLETED + FAILED +} + +model Attachment { + id String @id @default(uuid()) + attachmentId String @unique + fileName String + mimeType String + gcsUri String + sqsMessageId String? + status AttachmentStatus @default(PENDING) + + extractedText String? + summaryText String? + summaryModel String? + + errorMessage String? + retryCount Int @default(0) + + createdAt DateTime @default(now()) + processedAt DateTime? + + @@index([attachmentId]) + @@index([status]) + @@index([createdAt]) +} diff --git a/bounty-1/services/contentExtractor.ts b/bounty-1/services/contentExtractor.ts new file mode 100644 index 00000000..0174e9c6 --- /dev/null +++ b/bounty-1/services/contentExtractor.ts @@ -0,0 +1,117 @@ +import { readFile } from 'fs/promises'; +import path from 'path'; +import { AttachmentEvent, ExtractionResult, SupportedFileType } from '../types'; + +let pdfParse: typeof import('pdf-parse') | null = null; +async function getPdfParse() { + if (!pdfParse) { + pdfParse = (await import('pdf-parse')).default; + } + return pdfParse; +} + +let mammoth: typeof import('mammoth') | null = null; +async function getMammoth() { + if (!mammoth) { + mammoth = await import('mammoth'); + } + return mammoth; +} + +let Tesseract: typeof import('tesseract.js') | null = null; +async function getTesseract() { + if (!Tesseract) { + Tesseract = await import('tesseract.js'); + } + return Tesseract; +} + +async function extractPdf(filePath: string): Promise { + const parser = await getPdfParse(); + const dataBuffer = await readFile(filePath); + const data = await parser(dataBuffer); + return data.text; +} + +async function extractDocx(filePath: string): Promise { + const m = await getMammoth(); + const buffer = await readFile(filePath); + const result = await m.extractRawText({ buffer }); + return result.value; +} + +async function extractTxt(filePath: string): Promise { + return await readFile(filePath, 'utf-8'); +} + +async function extractImage(filePath: string): Promise { + const t = await getTesseract(); + const result = await t.recognize(filePath, 'eng'); + return result.data.text; +} + +function detectMimeType(filePath: string): SupportedFileType { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case '.pdf': + return SupportedFileType.PDF; + case '.docx': + return SupportedFileType.DOCX; + case '.txt': + case '.csv': + case '.log': + case '.md': + return SupportedFileType.TXT; + case '.jpg': + case '.jpeg': + return SupportedFileType.JPEG; + case '.png': + return SupportedFileType.PNG; + case '.tif': + case '.tiff': + return SupportedFileType.TIFF; + default: + throw new Error(`Unsupported file extension: ${ext}`); + } +} + +export async function extractContent( + filePath: string, + event?: AttachmentEvent, +): Promise { + const mimeType = event?.mimeType ?? detectMimeType(filePath); + + let text: string; + switch (mimeType) { + case SupportedFileType.PDF: + text = await extractPdf(filePath); + break; + case SupportedFileType.DOCX: + text = await extractDocx(filePath); + break; + case SupportedFileType.TXT: + text = await extractTxt(filePath); + break; + case SupportedFileType.JPEG: + case SupportedFileType.PNG: + case SupportedFileType.TIFF: + text = await extractImage(filePath); + break; + default: + throw new Error(`Unsupported MIME type: ${mimeType}`); + } + + const charCount = text.length; + const wordCount = text + .split(/\s+/) + .filter(Boolean).length; + + return { + attachmentId: event?.attachmentId ?? 'unknown', + text, + mimeType, + wordCount, + charCount, + extractedAt: new Date().toISOString(), + }; +} diff --git a/bounty-1/services/gcsDownloader.ts b/bounty-1/services/gcsDownloader.ts new file mode 100644 index 00000000..2bf33d7e --- /dev/null +++ b/bounty-1/services/gcsDownloader.ts @@ -0,0 +1,45 @@ +import { Storage } from '@google-cloud/storage'; +import { createWriteStream, mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const storage = new Storage(); + +interface GcsUriComponents { + bucket: string; + path: string; +} + +function parseGcsUri(uri: string): GcsUriComponents { + const match = uri.match(/^gs:\/\/([^/]+)\/(.+)$/); + if (!match) { + throw new Error(`Invalid GCS URI: ${uri}`); + } + return { bucket: match[1], path: match[2] }; +} + +export async function downloadFromGcs(gcsUri: string): Promise { + const { bucket, path } = parseGcsUri(gcsUri); + const bucketRef = storage.bucket(bucket); + const fileRef = bucketRef.file(path); + + const [exists] = await fileRef.exists(); + if (!exists) { + throw new Error(`File not found in GCS: ${gcsUri}`); + } + + const tempDir = mkdtempSync(join(tmpdir(), 'attachments-')); + const localPath = join(tempDir, path.split('/').pop() ?? 'download'); + + await new Promise((resolve, reject) => { + const writeStream = createWriteStream(localPath); + fileRef + .createReadStream() + .on('error', reject) + .pipe(writeStream) + .on('error', reject) + .on('finish', resolve); + }); + + return localPath; +} diff --git a/bounty-1/services/sqsConsumer.ts b/bounty-1/services/sqsConsumer.ts new file mode 100644 index 00000000..1abde817 --- /dev/null +++ b/bounty-1/services/sqsConsumer.ts @@ -0,0 +1,151 @@ +import { + SQSClient, + ReceiveMessageCommand, + DeleteMessageCommand, + ChangeMessageVisibilityCommand, + Message, +} from '@aws-sdk/client-sqs'; +import { AttachmentEvent, ProcessingResult, SqsMessage } from '../types'; +import { downloadFromGcs } from './gcsDownloader'; +import { extractContent } from './contentExtractor'; +import { summarize } from './summarizer'; +import { prisma } from '../lib/prisma'; +import fs from 'fs/promises'; + +const SQS_QUEUE_URL = process.env.SQS_QUEUE_URL!; +const MAX_RETRIES = 3; +const VISIBILITY_TIMEOUT = 120; + +const sqs = new SQSClient({ + region: process.env.AWS_REGION ?? 'us-east-1', +}); + +function parseSqsMessage(msg: Message): SqsMessage { + if (!msg.MessageId || !msg.ReceiptHandle || !msg.Body) { + throw new Error('Invalid SQS message: missing required fields'); + } + return { + messageId: msg.MessageId, + receiptHandle: msg.ReceiptHandle, + body: msg.Body, + attributes: msg.Attributes ?? {}, + }; +} + +function parseAttachmentEvent(sqsMsg: SqsMessage): AttachmentEvent { + const parsed: Record = JSON.parse(sqsMsg.body); + if ( + typeof parsed.attachmentId !== 'string' || + typeof parsed.gcsUri !== 'string' || + typeof parsed.fileName !== 'string' || + typeof parsed.mimeType !== 'string' || + typeof parsed.receivedAt !== 'string' + ) { + throw new Error('Invalid AttachmentEvent: missing or wrong-typed fields'); + } + return { + messageId: sqsMsg.messageId, + attachmentId: parsed.attachmentId, + gcsUri: parsed.gcsUri, + fileName: parsed.fileName, + mimeType: parsed.mimeType as AttachmentEvent['mimeType'], + receivedAt: parsed.receivedAt, + }; +} + +async function processEvent(event: AttachmentEvent): Promise { + const filePath = await downloadFromGcs(event.gcsUri); + try { + const extraction = await extractContent(filePath, event); + const summary = await summarize(extraction); + await prisma.attachment.upsert({ + where: { attachmentId: event.attachmentId }, + update: { + status: 'COMPLETED', + extractedText: extraction.text, + summaryText: summary.summary, + summaryModel: summary.model, + processedAt: new Date(), + }, + create: { + attachmentId: event.attachmentId, + fileName: event.fileName, + mimeType: event.mimeType, + gcsUri: event.gcsUri, + sqsMessageId: event.messageId, + status: 'COMPLETED', + extractedText: extraction.text, + summaryText: summary.summary, + summaryModel: summary.model, + processedAt: new Date(), + }, + }); + return { attachment: event, extraction, summary }; + } finally { + await fs.promises.unlink(filePath).catch(() => {}); + } +} + +export async function pollQueue(): Promise { + const command = new ReceiveMessageCommand({ + QueueUrl: SQS_QUEUE_URL, + MaxNumberOfMessages: 10, + WaitTimeSeconds: 20, + VisibilityTimeout: VISIBILITY_TIMEOUT, + }); + + const response = await sqs.send(command); + if (!response.Messages?.length) return; + + const results = await Promise.allSettled( + response.Messages.map(async (msg) => { + let attempts = 0; + const sqsMsg = parseSqsMessage(msg); + const event = parseAttachmentEvent(sqsMsg); + + while (attempts < MAX_RETRIES) { + try { + await processEvent(event); + await sqs.send( + new DeleteMessageCommand({ + QueueUrl: SQS_QUEUE_URL, + ReceiptHandle: sqsMsg.receiptHandle, + }), + ); + return { messageId: sqsMsg.messageId, status: 'ok' }; + } catch (err) { + attempts++; + if (attempts >= MAX_RETRIES) { + throw err; + } + await sqs.send( + new ChangeMessageVisibilityCommand({ + QueueUrl: SQS_QUEUE_URL, + ReceiptHandle: sqsMsg.receiptHandle, + VisibilityTimeout: VISIBILITY_TIMEOUT * attempts, + }), + ); + } + } + return { messageId: sqsMsg.messageId, status: 'error' }; + }), + ); + + for (const result of results) { + if (result.status === 'rejected') { + console.error('Fatal error processing batch:', result.reason); + } + } +} + +export async function startConsumer(): Promise { + console.log('SQS consumer starting, polling:', SQS_QUEUE_URL); + while (true) { + try { + await pollQueue(); + } catch (err) { + console.error('Poll error, will retry in 10s:', err); + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + } +} diff --git a/bounty-1/services/summarizer.ts b/bounty-1/services/summarizer.ts new file mode 100644 index 00000000..07c5be82 --- /dev/null +++ b/bounty-1/services/summarizer.ts @@ -0,0 +1,98 @@ +import { ExtractionResult, Summary } from '../types'; +import { randomUUID } from 'crypto'; + +const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'; +const LLM_MODEL = process.env.LLM_MODEL ?? 'llama3.1:8b'; +const MAX_INPUT_TOKENS = Number(process.env.MAX_INPUT_TOKENS) || 3072; +const MAX_OUTPUT_TOKENS = Number(process.env.MAX_OUTPUT_TOKENS) || 512; + +function truncateText(text: string, maxTokens: number): string { + const words = text.split(/\s+/); + if (words.length <= maxTokens) return text; + return words.slice(0, maxTokens).join(' ') + '...'; +} + +function buildPrompt(extractedText: string, fileName: string): string { + return [ + 'You are an expert assistant that summarizes email attachments.', + '', + 'Write a concise natural-language summary of the following document content.', + 'Include the key points, topics, and any actionable items.', + 'Use plain English, 3-5 sentences.', + '', + `File: ${fileName}`, + '', + '--- DOCUMENT CONTENT ---', + extractedText, + '', + '--- SUMMARY ---', + ].join('\n'); +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +async function callOllama( + prompt: string, + signal?: AbortSignal, +): Promise { + const response = await fetch( + `${OLLAMA_BASE_URL}/api/generate`, + { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: LLM_MODEL, + prompt, + stream: false, + options: { + temperature: 0.3, + top_p: 0.9, + num_predict: MAX_OUTPUT_TOKENS, + }, + }), + }, + ); + + if (!response.ok) { + throw new Error( + `Ollama API error: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + response: string; + eval_count?: number; + }; + return (data.response ?? '').trim(); +} + +export async function summarize( + extraction: ExtractionResult, +): Promise { + const truncated = truncateText(extraction.text, MAX_INPUT_TOKENS); + const promptTokens = estimateTokens(truncated); + const prompt = buildPrompt(truncated, extraction.attachmentId); + + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), 60_000); + + try { + const summaryText = await callOllama(prompt, abortController.signal); + const completionTokens = estimateTokens(summaryText); + + return { + id: randomUUID(), + attachmentId: extraction.attachmentId, + summary: summaryText, + model: LLM_MODEL, + promptTokens, + completionTokens, + createdAt: new Date().toISOString(), + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/bounty-1/types.ts b/bounty-1/types.ts new file mode 100644 index 00000000..0d4eeb6d --- /dev/null +++ b/bounty-1/types.ts @@ -0,0 +1,53 @@ +export const SupportedFileType = { + PDF: 'application/pdf', + DOCX: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + TXT: 'text/plain', + JPEG: 'image/jpeg', + PNG: 'image/png', + TIFF: 'image/tiff', +} as const; + +export type SupportedFileType = + (typeof SupportedFileType)[keyof typeof SupportedFileType]; + +export interface AttachmentEvent { + messageId: string; + attachmentId: string; + gcsUri: string; + fileName: string; + mimeType: SupportedFileType; + receivedAt: string; +} + +export interface ExtractionResult { + attachmentId: string; + text: string; + mimeType: SupportedFileType; + pageCount?: number; + wordCount: number; + charCount: number; + extractedAt: string; +} + +export interface Summary { + id: string; + attachmentId: string; + summary: string; + model: string; + promptTokens: number; + completionTokens: number; + createdAt: string; +} + +export interface ProcessingResult { + attachment: AttachmentEvent; + extraction: ExtractionResult; + summary: Summary; +} + +export interface SqsMessage { + messageId: string; + receiptHandle: string; + body: string; + attributes: Record; +}