Spaces:
Paused
Paused
File size: 7,979 Bytes
fa8d021 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | import datetime
import logging
from io import BytesIO
from typing import Any, Protocol
from fastapi import UploadFile
from nanoid import generate as generate_nanoid
from sqlalchemy import Integer, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import schemas
from src.config import settings
from src.exceptions import (
FileProcessingError,
UnsupportedFileTypeError,
ValidationException,
)
from src.schemas import Message
logger = logging.getLogger(__name__)
class FileProcessor(Protocol):
async def extract_text(self, content: bytes) -> str: ...
def supports_file_type(self, content_type: str) -> bool: ...
class PDFProcessor:
def supports_file_type(self, content_type: str) -> bool:
return content_type == "application/pdf"
async def extract_text(self, content: bytes) -> str:
import pdfplumber
with pdfplumber.open(BytesIO(content)) as pdf_reader:
text_parts: list[str] = []
for page_num, page in enumerate(pdf_reader.pages):
text = page.extract_text()
if text and text.strip():
text_parts.append(f"[Page {page_num + 1}]\n{text}")
return "\n\n".join(text_parts)
class TextProcessor:
def supports_file_type(self, content_type: str) -> bool:
return content_type.startswith("text/")
async def extract_text(self, content: bytes) -> str:
# Try different encodings
for encoding in ["utf-8", "utf-16", "latin-1"]:
try:
return content.decode(encoding)
except UnicodeDecodeError:
continue
raise ValueError("Could not decode text file")
class JSONProcessor:
def supports_file_type(self, content_type: str) -> bool:
return content_type == "application/json"
async def extract_text(self, content: bytes) -> str:
import json
try:
decoded_content = content.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValidationException("JSON uploads must be UTF-8 encoded") from exc
if not decoded_content.strip():
return ""
try:
data = json.loads(decoded_content)
except json.JSONDecodeError as exc:
raise ValidationException("Uploaded JSON is invalid") from exc
# Convert JSON to readable text format
return json.dumps(data, ensure_ascii=False)
class FileProcessingService:
def __init__(self):
self.processors: list[FileProcessor] = [
PDFProcessor(),
TextProcessor(),
JSONProcessor(),
# Add more processors as needed
]
async def extract_text_from_upload(self, file: UploadFile) -> str:
"""Extract text from uploaded file without saving to disk."""
content = await file.read()
# Reset file position in case it's needed again
await file.seek(0)
processor = self._get_processor(file.content_type or "")
if not processor:
raise UnsupportedFileTypeError(
f"Unsupported file type: {file.content_type}. Supported types: {[p.__class__.__name__ for p in self.processors]}"
)
return await processor.extract_text(content)
def _get_processor(self, content_type: str) -> FileProcessor | None:
for processor in self.processors:
if processor.supports_file_type(content_type):
return processor
return None
def split_text_into_chunks(text: str, max_chars: int = 49500) -> list[str]:
"""Split text into chunks that fit within message limits."""
if len(text) <= max_chars:
return [text]
chunks: list[str] = []
current_pos = 0
while current_pos < len(text):
# Try to break at paragraph boundaries first
end_pos = current_pos + max_chars
if end_pos >= len(text):
chunks.append(text[current_pos:])
break
# Look for good break points (paragraph, sentence, word)
break_pos = end_pos
for delimiter in ["\n\n", "\n", ". ", " "]:
last_delimiter = text.rfind(delimiter, current_pos, end_pos)
if last_delimiter > current_pos:
break_pos = last_delimiter + len(delimiter)
break
chunks.append(text[current_pos:break_pos])
current_pos = break_pos
return chunks
async def get_file_messages(
db: AsyncSession,
workspace_name: str,
file_id: str,
session_name: str | None = None,
) -> list[Message]:
"""Get all messages for a specific document, ordered by chunk_index."""
from sqlalchemy import and_, func
from src.models import Message
query = select(Message).where(
and_(
Message.workspace_name == workspace_name,
func.jsonb_extract_path_text(Message.internal_metadata, "file_id")
== file_id,
)
)
if session_name:
query = query.where(Message.session_name == session_name)
# Order by chunk_index
query = query.order_by(
func.jsonb_extract_path_text(Message.internal_metadata, "chunk_index").cast(
Integer
)
)
result = await db.execute(query)
return list(result.scalars().all())
async def process_file_uploads_for_messages(
file: UploadFile,
peer_id: str,
max_chars: int = settings.MAX_MESSAGE_SIZE,
metadata: dict[str, Any] | None = None,
configuration: schemas.MessageConfiguration | None = None,
created_at: datetime.datetime | None = None,
) -> list[dict[str, Any]]:
"""
Process an uploaded file and prepare message creation data.
This function extracts text from a file, splits it into chunks, and prepares
the data needed to create messages.
Args:
file: Uploaded file to process
peer_id: ID of the peer creating the messages
max_chars: Maximum characters per message chunk
metadata: Optional metadata to associate with all messages created from this file
configuration: Optional configuration to associate with all messages created from this file
created_at: Optional created_at timestamp to use for all messages created from this file
Returns:
List of dictionaries containing message_create and file_metadata
Raises:
HTTPException: If file processing fails
"""
file_processor = FileProcessingService()
all_message_data: list[dict[str, Any]] = []
# Process the uploaded file
extracted_text = await file_processor.extract_text_from_upload(file)
# Split into chunks and create messages
chunks = split_text_into_chunks(extracted_text, max_chars=max_chars)
file_id = generate_nanoid()
for i, chunk in enumerate(chunks):
# Build message content properly handling empty files
message_content = chunk or ""
# Create message with optional metadata, configuration, and created_at
message_create = schemas.MessageCreate(
content=message_content,
peer_id=peer_id,
metadata=metadata,
configuration=configuration,
created_at=created_at,
)
# Store file metadata separately to add to internal_metadata later
file_metadata = {
"file_id": file_id,
"filename": file.filename,
"chunk_index": i,
"total_chunks": len(chunks),
"original_file_size": file.size,
"content_type": file.content_type,
"chunk_character_range": [
i * max_chars,
min((i + 1) * max_chars, len(extracted_text)),
],
}
all_message_data.append(
{
"message_create": message_create,
"file_metadata": file_metadata,
}
)
if not all_message_data:
raise FileProcessingError()
return all_message_data
|