From c3e028eade80a8f59b8337a8038f14ceae6cdce0 Mon Sep 17 00:00:00 2001 From: Satwik Kansal Date: Sun, 22 Jun 2025 20:22:02 +0530 Subject: [PATCH] Temporary stash --- mxtoai/agents/email_agent.py | 31 +- mxtoai/request_context.py | 27 +- mxtoai/scripts/email_processor.py | 152 ------ mxtoai/scripts/mdconvert.py | 607 +++++++-------------- mxtoai/scripts/visual_qa.py | 193 +++---- mxtoai/tools/__init__.py | 10 + mxtoai/tools/attachment_processing_tool.py | 378 ++----------- mxtoai/tools/visual_qa_tool.py | 95 ++++ tests/test_visual_qa.py | 264 ++++----- tests/test_visual_qa_tool.py | 193 +++++++ 10 files changed, 789 insertions(+), 1161 deletions(-) delete mode 100644 mxtoai/scripts/email_processor.py create mode 100644 mxtoai/tools/visual_qa_tool.py create mode 100644 tests/test_visual_qa_tool.py diff --git a/mxtoai/agents/email_agent.py b/mxtoai/agents/email_agent.py index d3b330d..6815883 100644 --- a/mxtoai/agents/email_agent.py +++ b/mxtoai/agents/email_agent.py @@ -53,7 +53,7 @@ # Import citation management and web search tools from mxtoai.scripts.report_formatter import ReportFormatter -from mxtoai.scripts.visual_qa import azure_visualizer +from mxtoai.scripts.visual_qa import azure_visualizer_from_content from mxtoai.tools.attachment_processing_tool import AttachmentProcessingTool from mxtoai.tools.citation_aware_visit_tool import CitationAwareVisitTool from mxtoai.tools.deep_research_tool import DeepResearchTool @@ -94,7 +94,12 @@ class EmailAgent: """ def __init__( - self, email_request: EmailRequest, attachment_dir: str = "email_attachments", verbose: bool = False, enable_deep_research: bool = False, attachment_info: list[dict] | None = None + self, + email_request: EmailRequest, + attachment_dir: str = "email_attachments", + verbose: bool = False, + enable_deep_research: bool = False, + attachment_info: list[dict] | None = None, ): """ Initialize the email agent with tools for different operations. @@ -150,7 +155,7 @@ def __init__( self.scheduled_tasks_tool, self.delete_scheduled_tasks_tool, self.references_generator_tool, - azure_visualizer, + azure_visualizer_from_content, ] # Add all available search tools @@ -485,7 +490,9 @@ def _create_task_template( f"Process this email according to the '{handle}' instruction type.\n", email_context, distilled_section, - RESEARCH_GUIDELINES["mandatory"] if deep_research_mandatory and self.enable_deep_research else RESEARCH_GUIDELINES["optional"], + RESEARCH_GUIDELINES["mandatory"] + if deep_research_mandatory and self.enable_deep_research + else RESEARCH_GUIDELINES["optional"], attachment_task, handle_specific_template, output_template, @@ -666,7 +673,11 @@ def _process_agent_result( logger.info(f"Scheduled task created successfully with ID: {tool_output['task_id']}") else: error_msg = tool_output.get("message", "Scheduled task creation failed") - error_type = "Scheduled Task Limit Exceeded" if tool_output.get("error") == "Task limit exceeded" else "Scheduled Task Error" + error_type = ( + "Scheduled Task Limit Exceeded" + if tool_output.get("error") == "Task limit exceeded" + else "Scheduled Task Error" + ) errors_list.append(ProcessingError(message=error_type, details=error_msg)) if tool_output.get("error") == "Task limit exceeded": logger.warning(f"Scheduled task limit exceeded: {error_msg}") @@ -939,7 +950,9 @@ def limited_forward(*args, **kwargs): current_active_count = count_active_tasks_for_user(session, user_email) if current_active_count >= max_calls: - logger.warning(f"Scheduled task limit reached ({max_calls} active tasks per email). User has {current_active_count} active tasks.") + logger.warning( + f"Scheduled task limit reached ({max_calls} active tasks per email). User has {current_active_count} active tasks." + ) return { "success": False, "error": "Task limit exceeded", @@ -954,7 +967,6 @@ def limited_forward(*args, **kwargs): # Call the original method return original_forward(*args, **kwargs) - # Replace the forward method base_tool.forward = limited_forward @@ -974,9 +986,12 @@ def _finalize_response_with_citations(self, content: str) -> str: if self.context.has_citations(): # Check if content already contains a References or Sources section to avoid duplication import re + existing_references_pattern = r"(^|\n)#{1,3}\s*(References|Sources|Bibliography)\s*$" if re.search(existing_references_pattern, content, re.MULTILINE | re.IGNORECASE): - logger.warning("Content already contains a References/Sources section - skipping automatic references to avoid duplication") + logger.warning( + "Content already contains a References/Sources section - skipping automatic references to avoid duplication" + ) return content references_section = self.context.get_references_section() diff --git a/mxtoai/request_context.py b/mxtoai/request_context.py index dc18c97..61bb747 100644 --- a/mxtoai/request_context.py +++ b/mxtoai/request_context.py @@ -64,7 +64,7 @@ def load_attachment(self, filename: str, file_path: str, content_type: str, size "filename": filename, "contentType": content_type, "size": size, - "original_path": file_path + "original_path": file_path, } logger.debug(f"Loaded attachment into memory: {filename} ({size} bytes)") return True @@ -90,7 +90,7 @@ def has_attachment(self, filename: str) -> bool: class CitationManager: - """Per-request citation manager without threading complexity.""" + """Per-request citation manager.""" def __init__(self): self._citations = CitationCollection() @@ -125,7 +125,7 @@ def add_web_source(self, url: str, title: str, description: str | None = None, * url=url, date_accessed=datetime.now(timezone.utc).strftime("%Y-%m-%d"), source_type="web", - description=description + description=description, ) self._citations.add_source(source) @@ -150,7 +150,7 @@ def add_attachment_source(self, filename: str, description: str | None = None) - filename=filename, date_accessed=datetime.now(timezone.utc).strftime("%Y-%m-%d"), source_type="attachment", - description=description or "processed attachment" + description=description or "processed attachment", ) self._citations.add_source(source) @@ -170,7 +170,7 @@ def add_api_source(self, title: str, description: str | None = None) -> str: title=sanitized_title, date_accessed=datetime.now(timezone.utc).strftime("%Y-%m-%d"), source_type="api", - description=description or "API data" + description=description or "API data", ) self._citations.add_source(source) @@ -179,8 +179,7 @@ def add_api_source(self, title: str, description: str | None = None) -> str: def get_citations(self) -> CitationCollection: """Get a copy of the current citations.""" return CitationCollection( - sources=self._citations.sources.copy(), - references_section=self._citations.references_section + sources=self._citations.sources.copy(), references_section=self._citations.references_section ) def generate_references_section(self) -> str: @@ -189,12 +188,7 @@ def generate_references_section(self) -> str: return "" # Categorize sources - source_categories = { - "visited": [], - "search": [], - "attachment": [], - "api": [] - } + source_categories = {"visited": [], "search": [], "attachment": [], "api": []} for source in self._citations.sources: if source.source_type == "web": @@ -213,7 +207,7 @@ def generate_references_section(self) -> str: ("visited", "#### Visited Pages", lambda s: f"{s.id}. [{s.title}]({s.url})"), ("search", "#### Search Results", lambda s: f"{s.id}. [{s.title}]({s.url})"), ("attachment", "#### Attachments", lambda s: f"{s.id}. {s.filename}"), - ("api", "#### Data Sources", lambda s: f"{s.id}. {s.title}") + ("api", "#### Data Sources", lambda s: f"{s.id}. {s.title}"), ] for category, header, formatter in section_configs: @@ -265,10 +259,7 @@ def _load_attachments(self, attachment_info: list[dict]): """Load attachments from disk into memory store.""" for info in attachment_info: success = self.attachment_service.load_attachment( - filename=info["filename"], - file_path=info["path"], - content_type=info["type"], - size=info["size"] + filename=info["filename"], file_path=info["path"], content_type=info["type"], size=info["size"] ) if success: logger.debug(f"Successfully loaded attachment: {info['filename']}") diff --git a/mxtoai/scripts/email_processor.py b/mxtoai/scripts/email_processor.py deleted file mode 100644 index 10e960a..0000000 --- a/mxtoai/scripts/email_processor.py +++ /dev/null @@ -1,152 +0,0 @@ -import os -from email import policy -from email.parser import BytesParser -from pathlib import Path -from typing import Any - - -class EmailProcessor: - """ - Process email content and attachments. - """ - - def __init__(self, temp_dir: str = "email_attachments"): - """ - Initialize the EmailProcessor. - - Args: - temp_dir: Directory to store extracted attachments - - """ - self.temp_dir = temp_dir - os.makedirs(temp_dir, exist_ok=True) - - def process_email_file(self, email_file: str) -> dict[str, Any]: - """ - Process an email file and extract its content and attachments. - - Args: - email_file: Path to the email file (EML format) - - Returns: - Dict containing email metadata, body, and attachment paths - - """ - with open(email_file, "rb") as fp: - msg = BytesParser(policy=policy.default).parse(fp) - - # Extract basic metadata - metadata = { - "subject": msg.get("subject", ""), - "from": msg.get("from", ""), - "to": msg.get("to", ""), - "date": msg.get("date", ""), - } - - body = self._extract_body(msg) - attachments = self._extract_attachments(msg, email_file) - research_instructions = self._extract_research_instructions(body) - - return { - "metadata": metadata, - "body": body, - "attachments": attachments, - "research_instructions": research_instructions, - "attachment_dir": os.path.join(self.temp_dir, Path(email_file).stem) if attachments else None, - } - - def _extract_body(self, msg) -> str: - """ - Extract the body content from the email message. - - Args: - msg: Email message object - - Returns: - Email body as plain text - - """ - body = "" - - # First, try to get the plain text body - if msg.is_multipart(): - for part in msg.iter_parts(): - content_type = part.get_content_type() - if content_type == "text/plain": - body = part.get_content() - break - if content_type == "text/html" and not body: - # Use HTML if plain text isn't available - html_body = part.get_content() - # Simple HTML to text conversion (can be improved) - body = self._html_to_text(html_body) - elif msg.get_content_type() == "text/plain": - body = msg.get_content() - elif msg.get_content_type() == "text/html": - html_body = msg.get_content() - body = self._html_to_text(html_body) - - return body - - def _html_to_text(self, html: str) -> str: - """ - Convert HTML to plain text. - - Args: - html: HTML content - - Returns: - Plain text version of the HTML - - """ - # Simple implementation - can be improved with BeautifulSoup - import re - - text = re.sub(r"<[^>]+>", " ", html) - return re.sub(r"\s+", " ", text).strip() - - def _extract_attachments(self, msg, email_file: str) -> list[str]: - """ - Extract attachments from the email message. - - Args: - msg: Email message object - email_file: Original email file path (used for naming) - - Returns: - List of paths to extracted attachments - - """ - attachments = [] - attachment_dir = os.path.join(self.temp_dir, Path(email_file).stem) - os.makedirs(attachment_dir, exist_ok=True) - - if msg.is_multipart(): - for _, part in enumerate(msg.iter_parts()): - filename = part.get_filename() - if filename: - # Clean the filename - filename = Path(filename).name - filepath = os.path.join(attachment_dir, filename) - with open(filepath, "wb") as fp: - fp.write(part.get_payload(decode=True)) - attachments.append(filepath) - - return attachments - - def _extract_research_instructions(self, body: str) -> str: - """ - Extract research instructions from the email body. - This can be enhanced with NLP to better identify the actual request. - - Args: - body: Email body text - - Returns: - Extracted research instructions - - """ - # For now, we'll use a simple approach: the entire body is the instruction - # In a more sophisticated implementation, this could use NLP to identify - # specific instructions or questions - return body diff --git a/mxtoai/scripts/mdconvert.py b/mxtoai/scripts/mdconvert.py index 868b3af..aa61c69 100644 --- a/mxtoai/scripts/mdconvert.py +++ b/mxtoai/scripts/mdconvert.py @@ -26,7 +26,6 @@ import pptx # File-format detection -import puremagic import pydub import requests import speech_recognition as sr @@ -119,41 +118,25 @@ def __init__(self, title: Union[str, None] = None, text_content: str = ""): class DocumentConverter: """Abstract superclass of all DocumentConverters.""" - def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: + """Convert content from memory. Must be implemented by subclasses.""" raise NotImplementedError class PlainTextConverter(DocumentConverter): """Anything with content type text/plain""" - def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: - # Guess the content type from any file extension that might be around + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: content_type, _ = mimetypes.guess_type("__placeholder" + kwargs.get("file_extension", "")) - # Only accept text files if content_type is None or not content_type.startswith("text/"): return None - # Try to detect if file is binary - try: - with open(local_path, "rb") as file: - # Read first chunk of the file - chunk = file.read(1024) - if b"\0" in chunk: # Binary file detection - return None - - # Try to decode as UTF-8 - try: - chunk.decode("utf-8") - except UnicodeDecodeError: - return None - except Exception: + if b"\0" in content[:1024]: return None - # If we got here, it's safe to read as text try: - with open(local_path, encoding="utf-8") as fh: - text_content = fh.read() + text_content = content.decode("utf-8") return DocumentConverterResult( title=None, text_content=text_content, @@ -165,14 +148,16 @@ def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConvert class HtmlConverter(DocumentConverter): """Anything with content type text/html""" - def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: - # Bail if not html + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() not in [".html", ".htm"]: return None - with open(local_path, encoding="utf-8") as fh: - return self._convert(fh.read()) + try: + html_content = content.decode("utf-8") + return self._convert(html_content) + except UnicodeDecodeError: + return None def _convert(self, html_content: str) -> Union[None, DocumentConverterResult]: """Helper function that converts and HTML string.""" @@ -201,8 +186,7 @@ def _convert(self, html_content: str) -> Union[None, DocumentConverterResult]: class WikipediaConverter(DocumentConverter): """Handle Wikipedia pages separately, focusing only on the main document content.""" - def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: - # Bail if not Wikipedia + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() not in [".html", ".htm"]: return None @@ -210,16 +194,15 @@ def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConvert if not re.search(r"^https?:\/\/[a-zA-Z]{2,3}\.wikipedia.org\/", url): return None - # Parse the file - soup = None - with open(local_path, encoding="utf-8") as fh: - soup = BeautifulSoup(fh.read(), "html.parser") + try: + html_content = content.decode("utf-8") + soup = BeautifulSoup(html_content, "html.parser") + except UnicodeDecodeError: + return None - # Remove javascript and style blocks for script in soup(["script", "style"]): script.extract() - # Print only the main content body_elm = soup.find("div", {"id": "mw-content-text"}) title_elm = soup.find("span", {"class": "mw-page-title-main"}) @@ -246,8 +229,7 @@ def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConvert class YouTubeConverter(DocumentConverter): """Handle YouTube specially, focusing on the video title, description, and transcript.""" - def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: - # Bail if not YouTube + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() not in [".html", ".htm"]: return None @@ -255,12 +237,12 @@ def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConvert if not url.startswith("https://www.youtube.com/watch?"): return None - # Parse the file - soup = None - with open(local_path, encoding="utf-8") as fh: - soup = BeautifulSoup(fh.read(), "html.parser") + try: + html_content = content.decode("utf-8") + soup = BeautifulSoup(html_content, "html.parser") + except UnicodeDecodeError: + return None - # Read the meta tags assert soup.title is not None assert soup.title.string is not None metadata: dict[str, str] = {"title": soup.title.string} @@ -270,7 +252,6 @@ def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConvert metadata[meta[a]] = meta.get("content", "") break - # We can also try to read the full description. This is more prone to breaking, since it reaches into the page implementation try: for script in soup(["script"]): content = script.text @@ -287,7 +268,6 @@ def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConvert except Exception: pass - # Start preparing the page webpage_text = "# YouTube\n" title = self._get(metadata, ["title", "og:title", "name"]) # type: ignore @@ -367,16 +347,25 @@ class PdfConverter(DocumentConverter): Converts PDFs to Markdown. Most style information is ignored, so the results are essentially plain-text. """ - def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: - # Bail if not a PDF + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() != ".pdf": return None - return DocumentConverterResult( - title=None, - text_content=pdfminer.high_level.extract_text(local_path), - ) + try: + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file: + temp_file.write(content) + temp_file_path = temp_file.name + + text_content = pdfminer.high_level.extract_text(temp_file_path) + os.unlink(temp_file_path) + + return DocumentConverterResult( + title=None, + text_content=text_content, + ) + except Exception: + return None class DocxConverter(HtmlConverter): @@ -384,17 +373,19 @@ class DocxConverter(HtmlConverter): Converts DOCX files to Markdown. Style information (e.g.m headings) and tables are preserved where possible. """ - def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: - # Bail if not a DOCX + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() != ".docx": return None - result = None - with open(local_path, "rb") as docx_file: - result = mammoth.convert_to_html(docx_file) + try: + from io import BytesIO + + result = mammoth.convert_to_html(BytesIO(content)) html_content = result.value return self._convert(html_content) + except Exception: + return None class XlsxConverter(HtmlConverter): @@ -402,23 +393,27 @@ class XlsxConverter(HtmlConverter): Converts XLSX files to Markdown, with each sheet presented as a separate Markdown table. """ - def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: - # Bail if not a XLSX + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() not in [".xlsx", ".xls"]: return None - sheets = pd.read_excel(local_path, sheet_name=None) - md_content = "" - for s in sheets: - md_content += f"## {s}\n" - html_content = sheets[s].to_html(index=False) - md_content += self._convert(html_content).text_content.strip() + "\n\n" + try: + from io import BytesIO - return DocumentConverterResult( - title=None, - text_content=md_content.strip(), - ) + sheets = pd.read_excel(BytesIO(content), sheet_name=None) + md_content = "" + for s in sheets: + md_content += f"## {s}\n" + html_content = sheets[s].to_html(index=False) + md_content += self._convert(html_content).text_content.strip() + "\n\n" + + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + except Exception: + return None class PptxConverter(HtmlConverter): @@ -426,70 +421,69 @@ class PptxConverter(HtmlConverter): Converts PPTX files to Markdown. Supports heading, tables and images with alt text. """ - def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: - # Bail if not a PPTX + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() != ".pptx": return None - md_content = "" + try: + from io import BytesIO + + md_content = "" + + presentation = pptx.Presentation(BytesIO(content)) + slide_num = 0 + for slide in presentation.slides: + slide_num += 1 + + md_content += f"\n\n\n" + + title = slide.shapes.title + for shape in slide.shapes: + if self._is_picture(shape): + alt_text = "" + with contextlib.suppress(Exception): + alt_text = shape._element._nvXxPr.cNvPr.attrib.get("descr", "") + + filename = re.sub(r"\W", "", shape.name) + ".jpg" + md_content += "\n![" + (alt_text if alt_text else shape.name) + "](" + filename + ")\n" + + if self._is_table(shape): + html_table = "" + first_row = True + for row in shape.table.rows: + html_table += "" + for cell in row.cells: + if first_row: + html_table += "" + else: + html_table += "" + html_table += "" + first_row = False + html_table += "
" + html.escape(cell.text) + "" + html.escape(cell.text) + "
" + md_content += "\n" + self._convert(html_table).text_content.strip() + "\n" + + elif shape.has_text_frame: + if shape == title: + md_content += "# " + shape.text.lstrip() + "\n" + else: + md_content += shape.text + "\n" - presentation = pptx.Presentation(local_path) - slide_num = 0 - for slide in presentation.slides: - slide_num += 1 - - md_content += f"\n\n\n" - - title = slide.shapes.title - for shape in slide.shapes: - # Pictures - if self._is_picture(shape): - # https://github.com/scanny/python-pptx/pull/512#issuecomment-1713100069 - alt_text = "" - with contextlib.suppress(Exception): - alt_text = shape._element._nvXxPr.cNvPr.attrib.get("descr", "") - - # A placeholder name - filename = re.sub(r"\W", "", shape.name) + ".jpg" - md_content += "\n![" + (alt_text if alt_text else shape.name) + "](" + filename + ")\n" - - # Tables - if self._is_table(shape): - html_table = "" - first_row = True - for row in shape.table.rows: - html_table += "" - for cell in row.cells: - if first_row: - html_table += "" - else: - html_table += "" - html_table += "" - first_row = False - html_table += "
" + html.escape(cell.text) + "" + html.escape(cell.text) + "
" - md_content += "\n" + self._convert(html_table).text_content.strip() + "\n" - - # Text areas - elif shape.has_text_frame: - if shape == title: - md_content += "# " + shape.text.lstrip() + "\n" - else: - md_content += shape.text + "\n" - - md_content = md_content.strip() - - if slide.has_notes_slide: - md_content += "\n\n### Notes:\n" - notes_frame = slide.notes_slide.notes_text_frame - if notes_frame is not None: - md_content += notes_frame.text md_content = md_content.strip() - return DocumentConverterResult( - title=None, - text_content=md_content.strip(), - ) + if slide.has_notes_slide: + md_content += "\n\n### Notes:\n" + notes_frame = slide.notes_slide.notes_text_frame + if notes_frame is not None: + md_content += notes_frame.text + md_content = md_content.strip() + + return DocumentConverterResult( + title=None, + text_content=md_content.strip(), + ) + except Exception: + return None def _is_picture(self, shape): if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE: @@ -518,41 +512,32 @@ def _get_metadata(self, local_path): class WavConverter(MediaConverter): """ - Converts WAV files to markdown via extraction of metadata (if `exiftool` is installed), and speech transcription (if `speech_recognition` is installed). + Converts WAV files to markdown via extraction of metadata and speech transcription. """ - def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: - # Bail if not a XLSX + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() != ".wav": return None md_content = "" - # Add metadata - metadata = self._get_metadata(local_path) - if metadata: - for f in [ - "Title", - "Artist", - "Author", - "Band", - "Album", - "Genre", - "Track", - "DateTimeOriginal", - "CreateDate", - "Duration", - ]: - if f in metadata: - md_content += f"{f}: {metadata[f]}\n" - - # Transcribe try: - transcript = self._transcribe_audio(local_path) - md_content += "\n\n### Audio Transcript:\n" + ("[No speech detected]" if transcript == "" else transcript) + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file: + temp_file.write(content) + temp_file_path = temp_file.name + + try: + transcript = self._transcribe_audio(temp_file_path) + md_content += "\n\n### Audio Transcript:\n" + ( + "[No speech detected]" if transcript == "" else transcript + ) + except Exception: + md_content += "\n\n### Audio Transcript:\nError. Could not transcribe this audio." + + os.unlink(temp_file_path) except Exception: - md_content += "\n\n### Audio Transcript:\nError. Could not transcribe this audio." + return None return DocumentConverterResult( title=None, @@ -568,49 +553,29 @@ def _transcribe_audio(self, local_path) -> str: class Mp3Converter(WavConverter): """ - Converts MP3 and M4A files to markdown via extraction of metadata (if `exiftool` is installed), and speech transcription (if `speech_recognition` AND `pydub` are installed). + Converts MP3 and M4A files to markdown via extraction of metadata and speech transcription. """ - def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: - # Bail if not a MP3 + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() not in [".mp3", ".m4a"]: return None md_content = "" - # Add metadata - metadata = self._get_metadata(local_path) - if metadata: - for f in [ - "Title", - "Artist", - "Author", - "Band", - "Album", - "Genre", - "Track", - "DateTimeOriginal", - "CreateDate", - "Duration", - ]: - if f in metadata: - md_content += f"{f}: {metadata[f]}\n" - - # Transcribe handle, temp_path = tempfile.mkstemp(suffix=".wav") os.close(handle) try: + with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_input: + temp_input.write(content) + temp_input_path = temp_input.name + if extension.lower() == ".mp3": - sound = pydub.AudioSegment.from_mp3(local_path) + sound = pydub.AudioSegment.from_mp3(temp_input_path) else: - sound = pydub.AudioSegment.from_file(local_path, format="m4a") + sound = pydub.AudioSegment.from_file(temp_input_path, format="m4a") sound.export(temp_path, format="wav") - _args = {} - _args.update(kwargs) - _args["file_extension"] = ".wav" - try: transcript = super()._transcribe_audio(temp_path).strip() md_content += "\n\n### Audio Transcript:\n" + ( @@ -619,10 +584,13 @@ def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: except Exception: md_content += "\n\n### Audio Transcript:\nError. Could not transcribe this audio." + os.unlink(temp_input_path) + except Exception: + return None finally: - os.unlink(temp_path) + with contextlib.suppress(OSError): + os.unlink(temp_path) - # Return the result return DocumentConverterResult( title=None, text_content=md_content.strip(), @@ -631,99 +599,75 @@ def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: class ZipConverter(DocumentConverter): """ - Extracts ZIP files to a permanent local directory and returns a listing of extracted files. + Extracts ZIP files and returns a listing of extracted files. """ def __init__(self, extract_dir: str = "downloads"): - """ - Initialize with path to extraction directory. - - Args: - extract_dir: The directory where files will be extracted. Defaults to "downloads" - - """ self.extract_dir = extract_dir - # Create the extraction directory if it doesn't exist os.makedirs(self.extract_dir, exist_ok=True) - def convert(self, local_path: str, **kwargs: Any) -> Union[None, DocumentConverterResult]: - # Bail if not a ZIP file + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") if extension.lower() != ".zip": return None - # Verify it's actually a ZIP file - if not zipfile.is_zipfile(local_path): - return None + try: + from io import BytesIO + + extracted_files = [] - # Extract all files and build list - extracted_files = [] - with zipfile.ZipFile(local_path, "r") as zip_ref: - # Extract all files - zip_ref.extractall(self.extract_dir) - # Get list of all files - for file_path in zip_ref.namelist(): - # Skip directories - if not file_path.endswith("/"): - extracted_files.append(self.extract_dir + "/" + file_path) + with zipfile.ZipFile(BytesIO(content), "r") as zip_ref: + zip_ref.extractall(self.extract_dir) + for file_path in zip_ref.namelist(): + if not file_path.endswith("/"): + extracted_files.append(self.extract_dir + "/" + file_path) - # Sort files for consistent output - extracted_files.sort() + extracted_files.sort() - # Build the markdown content - md_content = "Downloaded the following files:\n" - for file in extracted_files: - md_content += f"* {file}\n" + md_content = "Downloaded the following files:\n" + for file in extracted_files: + md_content += f"* {file}\n" - return DocumentConverterResult(title="Extracted Files", text_content=md_content.strip()) + return DocumentConverterResult(title="Extracted Files", text_content=md_content.strip()) + except Exception: + return None class ImageConverter(MediaConverter): """ - Converts images to markdown via extraction of metadata (if `exiftool` is installed), OCR (if `easyocr` is installed), and description via a multimodal LLM (if an mlm_client is configured). + Converts images to Markdown, with optional vision-language model descriptions. """ - def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]: - # Bail if not a XLSX + def convert_content(self, content: bytes, **kwargs: Any) -> Union[None, DocumentConverterResult]: extension = kwargs.get("file_extension", "") - if extension.lower() not in [".jpg", ".jpeg", ".png"]: + if extension.lower() not in [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"]: return None md_content = "" + filename = kwargs.get("filename", "image" + extension) - # Add metadata - metadata = self._get_metadata(local_path) - if metadata: - for f in [ - "ImageSize", - "Title", - "Caption", - "Description", - "Keywords", - "Artist", - "Author", - "DateTimeOriginal", - "CreateDate", - "GPSPosition", - ]: - if f in metadata: - md_content += f"{f}: {metadata[f]}\n" - - # Try describing the image with GPTV - mlm_client = kwargs.get("mlm_client") - mlm_model = kwargs.get("mlm_model") - if mlm_client is not None and mlm_model is not None: - md_content += ( - "\n# Description:\n" - + self._get_mlm_description( - local_path, extension, mlm_client, mlm_model, prompt=kwargs.get("mlm_prompt") - ).strip() - + "\n" - ) + description = None + client = kwargs.get("mlm_client") + model = kwargs.get("mlm_model") + if client and model: + try: + with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_file: + temp_file.write(content) + temp_file_path = temp_file.name + + description = self._get_mlm_description(temp_file_path, extension, client, model) + os.unlink(temp_file_path) + except Exception: + pass + + if description: + md_content += f"## Image Description\n{description}\n" + + md_content += f"\n![{filename}]({filename})\n" return DocumentConverterResult( - title=None, - text_content=md_content, + title=filename, + text_content=md_content.strip(), ) def _get_mlm_description(self, local_path, extension, client, model, prompt=None): @@ -769,8 +713,7 @@ class UnsupportedFormatException(Exception): class MarkdownConverter: """ - (In preview) An extremely simple text-based document reader, suitable for LLM use. - This reader will convert common file-types or webpages to Markdown. + Converts various file types to Markdown from memory content. """ def __init__( @@ -779,19 +722,11 @@ def __init__( mlm_client: Optional[Any] = None, mlm_model: Optional[Any] = None, ): - if requests_session is None: - self._requests_session = requests.Session() - else: - self._requests_session = requests_session - + self._requests_session = requests_session if requests_session is not None else requests.Session() self._mlm_client = mlm_client self._mlm_model = mlm_model - self._page_converters: list[DocumentConverter] = [] - # Register converters for successful browsing operations - # Later registrations are tried first / take higher priority than earlier registrations - # To this end, the most specific converters should appear below the most generic converters self.register_page_converter(PlainTextConverter()) self.register_page_converter(HtmlConverter()) self.register_page_converter(WikipediaConverter()) @@ -805,167 +740,54 @@ def __init__( self.register_page_converter(ZipConverter()) self.register_page_converter(PdfConverter()) - def convert( - self, source: Union[str, requests.Response], **kwargs: Any - ) -> DocumentConverterResult: # TODO: deal with kwargs - """ - Args: - - source: can be a string representing a path or url, or a requests.response object - - extension: specifies the file extension to use when interpreting the file. If None, infer from source (path, uri, content-type, etc.) - - """ - # Local path or url - if isinstance(source, str): - if source.startswith(("http://", "https://", "file://")): - return self.convert_url(source, **kwargs) - return self.convert_local(source, **kwargs) - # Request response - if isinstance(source, requests.Response): - return self.convert_response(source, **kwargs) - return None - - def convert_local(self, path: str, **kwargs: Any) -> DocumentConverterResult: # TODO: deal with kwargs - # Prepare a list of extensions to try (in order of priority) + def convert_content(self, content: bytes, **kwargs: Any) -> DocumentConverterResult: + """Convert content from memory bytes.""" ext = kwargs.get("file_extension") extensions = [ext] if ext is not None else [] - # Get extension alternatives from the path and puremagic - base, ext = os.path.splitext(path) - self._append_ext(extensions, ext) - self._append_ext(extensions, self._guess_ext_magic(path)) - - # Convert - return self._convert(path, extensions, **kwargs) - - # TODO what should stream's type be? - def convert_stream(self, stream: Any, **kwargs: Any) -> DocumentConverterResult: # TODO: deal with kwargs - # Prepare a list of extensions to try (in order of priority) - ext = kwargs.get("file_extension") - extensions = [ext] if ext is not None else [] + filename = kwargs.get("filename", "") + if filename: + base, file_ext = os.path.splitext(filename) + self._append_ext(extensions, file_ext) - # Save the file locally to a temporary file. It will be deleted before this method exits - handle, temp_path = tempfile.mkstemp() - fh = os.fdopen(handle, "wb") - result = None - try: - # Write to the temporary file - content = stream.read() - if isinstance(content, str): - fh.write(content.encode("utf-8")) - else: - fh.write(content) - fh.close() - - # Use puremagic to check for more extension options - self._append_ext(extensions, self._guess_ext_magic(temp_path)) - - # Convert - result = self._convert(temp_path, extensions, **kwargs) - # Clean up - finally: - with contextlib.suppress(Exception): - fh.close() - os.unlink(temp_path) - - return result - - def convert_url(self, url: str, **kwargs: Any) -> DocumentConverterResult: # TODO: fix kwargs type - # Send a HTTP request to the URL - user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0" - response = self._requests_session.get(url, stream=True, headers={"User-Agent": user_agent}) - response.raise_for_status() - return self.convert_response(response, **kwargs) - - def convert_response( - self, response: requests.Response, **kwargs: Any - ) -> DocumentConverterResult: # TODO fix kwargs type - # Prepare a list of extensions to try (in order of priority) - ext = kwargs.get("file_extension") - extensions = [ext] if ext is not None else [] - - # Guess from the mimetype - content_type = response.headers.get("content-type", "").split(";")[0] - self._append_ext(extensions, mimetypes.guess_extension(content_type)) - - # Read the content disposition if there is one - content_disposition = response.headers.get("content-disposition", "") - m = re.search(r"filename=([^;]+)", content_disposition) - if m: - base, ext = os.path.splitext(m.group(1).strip("\"'")) - self._append_ext(extensions, ext) - - # Read from the extension from the path - base, ext = os.path.splitext(urlparse(response.url).path) - self._append_ext(extensions, ext) - - # Save the file locally to a temporary file. It will be deleted before this method exits - handle, temp_path = tempfile.mkstemp() - fh = os.fdopen(handle, "wb") - result = None - try: - # Download the file - for chunk in response.iter_content(chunk_size=512): - fh.write(chunk) - fh.close() + return self._convert_content(content, extensions, **kwargs) - # Use puremagic to check for more extension options - self._append_ext(extensions, self._guess_ext_magic(temp_path)) - - # Convert - result = self._convert(temp_path, extensions, url=response.url) - except Exception: - pass - - # Clean up - finally: - with contextlib.suppress(Exception): - fh.close() - os.unlink(temp_path) - - return result - - def _convert(self, local_path: str, extensions: list[Union[str, None]], **kwargs) -> DocumentConverterResult: + def _convert_content(self, content: bytes, extensions: list[Union[str, None]], **kwargs) -> DocumentConverterResult: error_trace = "" - for ext in [*extensions, None]: # Try last with no extension + for ext in [*extensions, None]: for converter in self._page_converters: _kwargs = copy.deepcopy(kwargs) - # Overwrite file_extension appropriately if ext is None: if "file_extension" in _kwargs: del _kwargs["file_extension"] else: _kwargs.update({"file_extension": ext}) - # Copy any additional global options if "mlm_client" not in _kwargs and self._mlm_client is not None: _kwargs["mlm_client"] = self._mlm_client if "mlm_model" not in _kwargs and self._mlm_model is not None: _kwargs["mlm_model"] = self._mlm_model - # If we hit an error log it and keep trying res = None try: - res = converter.convert(local_path, **_kwargs) + res = converter.convert_content(content, **_kwargs) except Exception: error_trace = ("\n\n" + traceback.format_exc()).strip() if res is not None: - # Normalize the content res.text_content = "\n".join([line.rstrip() for line in re.split(r"\r?\n", res.text_content)]) res.text_content = re.sub(r"\n{3,}", "\n\n", res.text_content) - - # TODO return res - # If we got this far without success, report any exceptions if len(error_trace) > 0: - msg = f"Could not convert '{local_path}' to Markdown. File type was recognized as {extensions}. While converting the file, the following error was encountered:\n\n{error_trace}" + filename = kwargs.get("filename", "content") + msg = f"Could not convert '{filename}' to Markdown. File type was recognized as {extensions}. While converting the content, the following error was encountered:\n\n{error_trace}" raise FileConversionException(msg) - # Nothing can handle it! - msg = f"Could not convert '{local_path}' to Markdown. The formats {extensions} are not supported." + filename = kwargs.get("filename", "content") + msg = f"Could not convert '{filename}' to Markdown. The formats {extensions} are not supported." raise UnsupportedFormatException(msg) def _append_ext(self, extensions, ext): @@ -975,26 +797,7 @@ def _append_ext(self, extensions, ext): ext = ext.strip() if ext == "": return - # if ext not in extensions: - if True: - extensions.append(ext) - - def _guess_ext_magic(self, path): - """Use puremagic (a Python implementation of libmagic) to guess a file's extension based on the first few bytes.""" - # Use puremagic to guess - try: - guesses = puremagic.magic_file(path) - if len(guesses) > 0: - ext = guesses[0].extension.strip() - if len(ext) > 0: - return ext - except FileNotFoundError: - pass - except IsADirectoryError: - pass - except PermissionError: - pass - return None + extensions.append(ext) def register_page_converter(self, converter: DocumentConverter) -> None: """Register a page text converter.""" diff --git a/mxtoai/scripts/visual_qa.py b/mxtoai/scripts/visual_qa.py index e1f7542..5d14c34 100644 --- a/mxtoai/scripts/visual_qa.py +++ b/mxtoai/scripts/visual_qa.py @@ -1,8 +1,6 @@ import base64 import json -import mimetypes import os -import uuid from io import BytesIO from typing import Optional @@ -21,12 +19,12 @@ logger = get_logger("azure_visualizer") -def process_images_and_text(image_path: str, query: str, client: InferenceClient): +def process_images_and_text_from_content(content: bytes, query: str, client: InferenceClient): """ - Process images and text using the IDEFICS model. + Process images and text using the IDEFICS model from memory content. Args: - image_path: Path to the image file. + content: Image content as bytes. query: The question to ask about the image. client: Inference client for the model. @@ -45,22 +43,14 @@ def process_images_and_text(image_path: str, query: str, client: InferenceClient idefics_processor = AutoProcessor.from_pretrained("HuggingFaceM4/idefics2-8b-chatty") prompt_with_template = idefics_processor.apply_chat_template(messages, add_generation_prompt=True) - # load images from local directory - - # encode images to strings which can be sent to the endpoint - def encode_local_image(image_path): - # load image - image = Image.open(image_path).convert("RGB") - - # Convert the image to a base64 string + def encode_content_image(content: bytes): + image = Image.open(BytesIO(content)).convert("RGB") buffer = BytesIO() - image.save(buffer, format="JPEG") # Use the appropriate format (e.g., JPEG, PNG) + image.save(buffer, format="JPEG") base64_image = base64.b64encode(buffer.getvalue()).decode("utf-8") - - # add string formatting required by the endpoint return f"data:image/jpeg;base64,{base64_image}" - image_string = encode_local_image(image_path) + image_string = encode_content_image(content) prompt_with_images = prompt_with_template.replace("", "![]({}) ").format(image_string) payload = { @@ -74,77 +64,52 @@ def encode_local_image(image_path): return json.loads(client.post(json=payload).decode())[0] -# Function to encode the image -def encode_image(image_path: str) -> str: +def encode_image_from_content(content: bytes) -> str: """ - Encode an image to base64 format. + Encode image content to base64 format. Args: - image_path: The path to the image file. + content: The image content as bytes. Returns: str: The base64 encoded string of the image. """ - if image_path.startswith("http"): - user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0" - request_kwargs = { - "headers": {"User-Agent": user_agent}, - "stream": True, - } - - # Send a HTTP request to the URL - response = requests.get(image_path, **request_kwargs) - response.raise_for_status() - content_type = response.headers.get("content-type", "") - - extension = mimetypes.guess_extension(content_type) - if extension is None: - extension = ".download" - - fname = str(uuid.uuid4()) + extension - download_path = os.path.abspath(os.path.join("downloads", fname)) - - with open(download_path, "wb") as fh: - for chunk in response.iter_content(chunk_size=512): - fh.write(chunk) - - image_path = download_path - - with open(image_path, "rb") as image_file: - return base64.b64encode(image_file.read()).decode("utf-8") - - -headers = {"Content-Type": "application/json", "Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}"} + return base64.b64encode(content).decode("utf-8") -def resize_image(image_path: str) -> str: +def resize_image_from_content(content: bytes) -> bytes: """ - Resize the image to half its original size. + Resize image content to half its original size. Args: - image_path: The path to the image file. + content: The image content as bytes. Returns: - str: The path to the resized image. + bytes: The resized image content as bytes. """ - img = Image.open(image_path) + img = Image.open(BytesIO(content)) width, height = img.size img = img.resize((int(width / 2), int(height / 2))) - directory = os.path.dirname(image_path) - filename = os.path.basename(image_path) - new_image_path = os.path.join(directory, f"resized_{filename}") - img.save(new_image_path) - return new_image_path + buffer = BytesIO() + img.save(buffer, format="JPEG") + return buffer.getvalue() + + +headers = {"Content-Type": "application/json", "Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}"} class VisualQATool(Tool): name = "visualizer" - description = "A tool that can answer questions about attached images." + description = "A tool that can answer questions about attached images from memory content." inputs = { - "image_path": { - "description": "The path to the image on which to answer the question", + "content": { + "description": "The image content as bytes", + "type": "any", + }, + "mime_type": { + "description": "MIME type of the image", "type": "string", }, "question": {"description": "the question to answer", "type": "string", "nullable": True}, @@ -153,12 +118,13 @@ class VisualQATool(Tool): client = InferenceClient("HuggingFaceM4/idefics2-8b-chatty") - def forward(self, image_path: str, question: Optional[str] = None) -> str: + def forward(self, content: bytes, mime_type: str, question: Optional[str] = None) -> str: """ Process the image and return a short caption based on the content. Args: - image_path: The path to the image on which to answer the question. This should be a local path to downloaded image. + content: The image content as bytes. + mime_type: MIME type of the image. question: The question to answer. Returns: @@ -171,11 +137,11 @@ def forward(self, image_path: str, question: Optional[str] = None) -> str: add_note = True question = "Please write a detailed caption for this image." try: - output = process_images_and_text(image_path, question, self.client) + output = process_images_and_text_from_content(content, question, self.client) except Exception as e: if "Payload Too Large" in str(e): - new_image_path = resize_image(image_path) - output = process_images_and_text(new_image_path, question, self.client) + resized_content = resize_image_from_content(content) + output = process_images_and_text_from_content(resized_content, question, self.client) if add_note: output = f"You did not provide a particular question, so here is a detailed caption for the image: {output}" @@ -184,12 +150,13 @@ def forward(self, image_path: str, question: Optional[str] = None) -> str: @tool -def visualizer(image_path: str, question: Optional[str] = None) -> str: +def visualizer_from_content(content: bytes, mime_type: str, question: Optional[str] = None) -> str: """ - A tool that can answer questions about attached images. + A tool that can answer questions about image content. Args: - image_path: The path to the image on which to answer the question. This should be a local path to downloaded image. + content: The image content as bytes. + mime_type: MIME type of the image. question: The question to answer. """ @@ -197,12 +164,11 @@ def visualizer(image_path: str, question: Optional[str] = None) -> str: if not question: add_note = True question = "Please write a detailed caption for this image." - if not isinstance(image_path, str): - msg = "You should provide at least `image_path` string argument to this tool!" - raise Exception(msg) - mime_type, _ = mimetypes.guess_type(image_path) - base64_image = encode_image(image_path) + if not mime_type: + mime_type = "image/jpeg" + + base64_image = encode_image_from_content(content) payload = { "model": "gpt-4o", @@ -231,12 +197,13 @@ def visualizer(image_path: str, question: Optional[str] = None) -> str: @tool -def azure_visualizer(image_path: str, question: Optional[str] = None) -> str: +def azure_visualizer_from_content(content: bytes, mime_type: str, question: Optional[str] = None) -> str: """ - A tool that can answer questions about attached images using Azure OpenAI. + A tool that can answer questions about image content using Azure OpenAI. Args: - image_path: The path to the image on which to answer the question. This should be a local path to downloaded image. + content: The image content as bytes. + mime_type: MIME type of the image. question: The question to answer. """ @@ -246,67 +213,85 @@ def azure_visualizer(image_path: str, question: Optional[str] = None) -> str: question = "Please write a detailed caption for this image." try: - # Get image MIME type - mime_type, _ = mimetypes.guess_type(image_path) if not mime_type: - mime_type = "image/jpeg" # Default to JPEG if can't determine + mime_type = "image/jpeg" - # Encode the image to base64 - base64_image = encode_image(image_path) + base64_image = encode_image_from_content(content) - # Format the content for the Azure OpenAI API - content = [ + content_payload = [ {"type": "text", "text": question}, {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{base64_image}"}}, ] - # Get Azure OpenAI configuration model = f"azure/{os.getenv('MODEL_NAME', 'gpt-4o')}" api_key = os.getenv("MODEL_API_KEY") api_base = os.getenv("MODEL_ENDPOINT") api_version = os.getenv("MODEL_API_VERSION") - logger.info(f"Sending image to Azure OpenAI model: {model}") - # Call Azure OpenAI using litellm + logger.info(f"Sending image content to Azure OpenAI model: {model}") response = completion( model=model, - messages=[{"role": "user", "content": content}], + messages=[{"role": "user", "content": content_payload}], api_key=api_key, api_base=api_base, api_version=api_version, max_tokens=1000, ) - # Extract content from response if hasattr(response, "choices") and response.choices: if hasattr(response.choices[0], "message") and hasattr(response.choices[0].message, "content"): output = response.choices[0].message.content else: - # Fallback if direct access doesn't work output = response.choices[0].get("message", {}).get("content", "") else: - # Handle unusual response format output = str(response) if not output: msg = "Empty response from Azure OpenAI" raise Exception(msg) - # Add note if no question was provided if add_note: output = f"You did not provide a particular question, so here is a detailed caption for the image: {output}" return output except Exception as e: - logger.error(f"Error in azure_visualizer: {e!s}") + logger.error(f"Error in azure_visualizer_from_content: {e!s}") - # Try resizing the image if it might be too large - if "too large" in str(e).lower() or "payload" in str(e).lower(): - try: - new_image_path = resize_image(image_path) - return azure_visualizer(new_image_path, question) - except Exception as resize_error: - logger.error(f"Error resizing image: {resize_error!s}") + try: + resized_content = resize_image_from_content(content) + base64_image = encode_image_from_content(resized_content) + + content_payload = [ + {"type": "text", "text": question}, + {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{base64_image}"}}, + ] + + response = completion( + model=model, + messages=[{"role": "user", "content": content_payload}], + api_key=api_key, + api_base=api_base, + api_version=api_version, + max_tokens=1000, + ) + + if hasattr(response, "choices") and response.choices: + if hasattr(response.choices[0], "message") and hasattr(response.choices[0].message, "content"): + output = response.choices[0].message.content + else: + output = response.choices[0].get("message", {}).get("content", "") + else: + output = str(response) + + if add_note: + output = ( + f"You did not provide a particular question, so here is a detailed caption for the image: {output}" + ) - return f"Error processing image: {e!s}" + return output + + except Exception as retry_error: + logger.error(f"Error in azure_visualizer_from_content retry: {retry_error!s}") + msg = f"Failed to process image: {retry_error!s}" + raise Exception(msg) diff --git a/mxtoai/tools/__init__.py b/mxtoai/tools/__init__.py index d5d51d3..6996abe 100644 --- a/mxtoai/tools/__init__.py +++ b/mxtoai/tools/__init__.py @@ -1,9 +1,14 @@ # Tools package for email processing from mxtoai.tools.attachment_processing_tool import AttachmentProcessingTool +from mxtoai.tools.citation_aware_visit_tool import CitationAwareVisitTool from mxtoai.tools.deep_research_tool import DeepResearchTool from mxtoai.tools.delete_scheduled_tasks_tool import DeleteScheduledTasksTool +from mxtoai.tools.fallback_search_tool import FallbackWebSearchTool from mxtoai.tools.meeting_tool import MeetingTool +from mxtoai.tools.pdf_export_tool import PDFExportTool +from mxtoai.tools.references_generator_tool import ReferencesGeneratorTool from mxtoai.tools.scheduled_tasks_tool import ScheduledTasksTool +from mxtoai.tools.visual_qa_tool import VisualQATool # Web search tools from mxtoai.tools.web_search import BraveSearchTool, DDGSearchTool, GoogleSearchTool @@ -11,10 +16,15 @@ __all__ = [ "AttachmentProcessingTool", "BraveSearchTool", + "CitationAwareVisitTool", "DDGSearchTool", "DeepResearchTool", "DeleteScheduledTasksTool", + "FallbackWebSearchTool", "GoogleSearchTool", "MeetingTool", + "PDFExportTool", + "ReferencesGeneratorTool", "ScheduledTasksTool", + "VisualQATool", ] diff --git a/mxtoai/tools/attachment_processing_tool.py b/mxtoai/tools/attachment_processing_tool.py index 49dd1a2..3c18d75 100644 --- a/mxtoai/tools/attachment_processing_tool.py +++ b/mxtoai/tools/attachment_processing_tool.py @@ -1,384 +1,104 @@ -import json import os -import sys -from pathlib import Path -from typing import Any -from urllib.parse import unquote from smolagents import Tool -from smolagents.models import MessageRole, Model - -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import contextlib - -from scripts.mdconvert import MarkdownConverter from mxtoai._logging import get_logger -from mxtoai.request_context import RequestContext -from mxtoai.schemas import ToolOutputWithCitations +from mxtoai.scripts.mdconvert import MarkdownConverter -# Configure logger -logger = get_logger("attachment_tool") +logger = get_logger(__name__) class AttachmentProcessingTool(Tool): """ - Tool for processing various types of email attachments. - Handles documents using MarkdownConverter. For images, use the azure_visualizer tool directly. - """ - - name = "attachment_processor" - description = """Process and analyze email attachments to extract content and insights. - This tool can handle: - - Documents (PDFs, Office files, text files) - - Audio files (as transcripts) - - HTML files - - Markdown files - - NOTE: For image processing, please use the azure_visualizer tool directly. - This tool will skip image files and indicate they should be processed by azure_visualizer. - - The attachments parameter should be a list of dictionaries, where each dictionary contains: - - filename: Name of the file - - type: MIME type - - path: Full path to the file - - size: File size in bytes + Tool for processing email attachments from memory. """ + name = "attachment_processing" + description = "Process attachments from memory and convert them to text format" inputs = { - "attachments": { - "type": "array", - "description": "List of attachment dictionaries containing file information. Each dictionary must have 'filename', 'type', and 'size' keys. The 'path' key is optional - attachments will be processed from memory when available, falling back to file path if needed.", - "items": { - "type": "object", - "properties": { - "filename": {"type": "string", "description": "Name of the file"}, - "type": {"type": "string", "description": "MIME type or content type of the file"}, - "path": {"type": "string", "description": "Full path to the file (optional - used as fallback if memory content unavailable)"}, - "size": {"type": "integer", "description": "Size of the file in bytes"}, - }, - "required": ["filename", "type", "size"] - }, + "filename": { + "description": "Name of the attachment file to process", + "type": "string", }, - "mode": { + "type": { + "description": "MIME type of the attachment", "type": "string", - "description": "Processing mode: 'basic' for metadata only, 'full' for complete content analysis", - "enum": ["basic", "full"], - "default": "basic", - "nullable": True, + }, + "size": { + "description": "Size of the attachment in bytes", + "type": "integer", }, } - output_type = "object" + output_type = "string" - def __init__(self, context: RequestContext, model: Model | None = None, text_limit: int = 4000): - """ - Initialize the attachment processing tool. - - Args: - context: Request context containing email data and citation manager - model: Optional LLM model for content summarization - text_limit: Maximum text length to include in output - - """ + def __init__(self, context=None): super().__init__() - self.context = context - self.model = model - self.text_limit = text_limit self.converter = MarkdownConverter() + self.context = context - # Configure image extensions that should be handled by azure_visualizer - self.image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".tiff", ".ico"} - - logger.debug(f"AttachmentProcessingTool initialized with text_limit={text_limit}") - - # Set up attachments directory path - self.attachments_dir = Path( - os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "attachments")) - ) - self.attachments_dir.mkdir(parents=True, exist_ok=True) - - def _validate_attachment_path(self, file_path: str) -> Path: + def forward(self, filename: str, type: str, size: int) -> str: """ - Validate and resolve the attachment file path. + Process attachment content from memory and return text representation. Args: - file_path: Path to the attachment file. + filename: Name of the attachment file + type: MIME type of the attachment + size: Size of the attachment in bytes Returns: - Path: The resolved file path. + str: Processed text content or error message """ try: - if not file_path: - msg = "Empty file path provided" - raise ValueError(msg) + if not self.context or not self.context.attachment_service: + return "Error: No attachment service available in request context" - # Clean up the path - file_path = file_path.strip("\"'") + if not self.context.attachment_service.has_attachment(filename): + return f"Error: Attachment '{filename}' not found" - # Try different path variations - paths_to_try = [ - Path(file_path), # Direct path - Path(unquote(file_path)), # URL decoded path - self.attachments_dir / Path(file_path).name, # Relative to attachments dir - ] + content = self.context.attachment_service.get_content(filename) + if content is None: + return f"Error: Could not retrieve content for '{filename}'" - for path in paths_to_try: - if path.is_file(): - return path.resolve() + processed_content = self._process_content_from_memory(content, filename, type) - paths_str = "\n- ".join(str(p) for p in paths_to_try) - msg = f"File not found at any of these locations:\n- {paths_str}" - raise FileNotFoundError(msg) + logger.info(f"Successfully processed attachment '{filename}' from memory") + return f"Processed attachment '{filename}':\n\n{processed_content}" except Exception as e: - logger.error(f"Error validating path {file_path}: {e!s}") - raise + logger.error(f"Error processing attachment '{filename}': {e!s}") + return f"Error processing attachment '{filename}': {e!s}" def _process_content_from_memory(self, content: bytes, filename: str, content_type: str) -> str: """ - Process document content from memory using MarkdownConverter. + Process file content directly from memory bytes. Args: - content: The file content as bytes + content: Raw file content as bytes filename: Name of the file for context - content_type: MIME type of the content + content_type: MIME type of the file Returns: - str: The text content extracted from the document. + str: Processed text content - """ - import tempfile + Raises: + ValueError: If processing fails + """ try: - # For text files, decode directly if content_type.startswith("text/"): try: return content.decode("utf-8") except UnicodeDecodeError: return content.decode("utf-8", errors="ignore") - # For other file types, create a temporary file for the converter - with tempfile.NamedTemporaryFile(suffix=f"_{filename}", delete=False) as temp_file: - temp_file.write(content) - temp_file_path = temp_file.name - - try: - result = self.converter.convert(temp_file_path) - if not result or not hasattr(result, "text_content"): - msg = f"Failed to convert document: {filename}" - raise ValueError(msg) - return result.text_content - finally: - # Clean up temporary file - import os - with contextlib.suppress(OSError): - os.unlink(temp_file_path) - - except Exception as e: - logger.error(f"Error converting document {filename} from memory: {e!s}") - raise - - def _process_document(self, file_path: Path) -> str: - """ - DEPRECATED: This method has been removed for security reasons. - Use _process_content_from_memory instead. - """ - logger.warning(f"_process_document is deprecated and removed for security. File path: {file_path}") - msg = "File path processing is deprecated for security. Use memory-based processing instead." - raise ValueError(msg) - - def forward(self, attachments: list[dict[str, Any]], mode: str = "basic") -> dict[str, Any]: - """ - Process email attachments synchronously with citation tracking. - - Args: - attachments: List of attachment dictionaries containing file information. - mode: Processing mode: 'basic' for metadata only, 'full' for complete content analysis. - - Returns: - str: JSON string of ToolOutputWithCitations containing processed attachments. - - """ - processed_attachments = [] - citation_ids = [] - - logger.info(f"Processing {len(attachments)} attachments in {mode} mode") - - for attachment in attachments: - try: - # Validate required fields - required_fields = ["filename", "type", "size"] - missing_fields = [field for field in required_fields if field not in attachment] - if missing_fields: - msg = f"Missing required fields in attachment: {missing_fields}" - raise ValueError(msg) - - filename = attachment["filename"] - content_type = attachment["type"] - logger.info(f"Processing attachment: {filename}") - - # Add citation for this attachment - citation_id = self.context.add_attachment_citation( - filename, - f"Email attachment ({content_type})" - ) - citation_ids.append(citation_id) - - # Skip image files - they should be handled by azure_visualizer directly - if content_type.startswith("image/"): - processed_attachments.append( - { - **attachment, - "citation_id": citation_id, - "content": { - "text": f"This is an image file that requires visual processing. [#{citation_id}]", - "type": "image", - "requires_visual_qa": True, - }, - } - ) - logger.info(f"Skipped image file: {filename} - use azure_visualizer tool instead") - continue - - # Try to get content from attachment service first - content = None - processing_source = "memory" - - if self.context.attachment_service.has_attachment(filename): - try: - file_content = self.context.attachment_service.get_content(filename) - if file_content: - content = self._process_content_from_memory(file_content, filename, content_type) - logger.debug(f"Processed {filename} from memory store") - else: - logger.warning(f"Attachment {filename} found in service but content is None") - except Exception as e: - logger.warning(f"Failed to process {filename} from memory: {e!s}, falling back to file path") - processing_source = "fallback" - - # Fall back to file path processing if memory processing failed or unavailable - if content is None and "path" in attachment: - logger.warning(f"File path processing is deprecated for security. Skipping {filename}. " - f"Use memory-based processing instead.") - processed_attachments.append({ - **attachment, - "citation_id": citation_id, - "error": "File path processing deprecated for security - use memory-based processing" - }) - continue - - # If we still don't have content, it's an error - if content is None: - error_msg = f"Could not process {filename}: no content available in memory or file path" - logger.error(error_msg) - processed_attachments.append({**attachment, "citation_id": citation_id, "error": error_msg}) - continue - - # If in full mode and model is available, generate a summary - summary = None - if mode == "full" and self.model and len(content) > 4000: - messages = [ - { - "role": MessageRole.SYSTEM, - "content": [ - { - "type": "text", - "text": f"Here is a file:\n### {filename}\n\n{content[: self.text_limit]}", - } - ], - }, - { - "role": MessageRole.USER, - "content": [ - { - "type": "text", - "text": "Please provide a comprehensive summary of this document in 5-7 sentences.", - } - ], - }, - ] - summary = self.model(messages).content - - processed_attachments.append( - { - **attachment, - "citation_id": citation_id, - "processing_source": processing_source, - "content": { - "text": f"{content[: self.text_limit] if len(content) > self.text_limit else content} [#{citation_id}]", - "type": "text", - "summary": summary, - }, - } - ) - logger.info(f"Successfully processed: {filename} (source: {processing_source})") + _, file_extension = os.path.splitext(filename) - except Exception as e: - logger.error(f"Error processing attachment {attachment.get('filename', 'unknown')}: {e!s}") - processed_attachments.append( - {**{k: v for k, v in attachment.items() if k in ["filename", "type", "size"]}, "citation_id": citation_id, "error": str(e)} - ) + result = self.converter.convert_content(content=content, filename=filename, file_extension=file_extension) - # Create structured output with citations - attachment_summary = self._create_attachment_summary(processed_attachments) - content = f"Processed {len(processed_attachments)} attachments:\n\n{attachment_summary}" + return result.text_content - result = ToolOutputWithCitations( - content=content, - metadata={ - "total_attachments": len(attachments), - "processed_successfully": len([a for a in processed_attachments if "error" not in a]), - "failed": len([a for a in processed_attachments if "error" in a]), - "citation_ids": citation_ids, - "attachments": processed_attachments, - } - ) - - return json.dumps(result.model_dump()) - - def _create_attachment_summary(self, attachments: list[dict[str, Any]]) -> str: - """ - Create a summary of processed attachments. - - Args: - attachments: List of processed attachment dictionaries. - - Returns: - str: Summary of processed attachments. - - """ - if not attachments: - return "No attachments processed." - - summary_parts = [] - successful = 0 - failed = 0 - images = 0 - - for att in attachments: - if "error" in att: - failed += 1 - summary_parts.append(f"Failed to process {att['filename']}: {att['error']}") - continue - - content = att.get("content", {}) - if content: - if content.get("type") == "image": - images += 1 - summary_parts.append(f"Image {att['filename']}: Requires visual processing") - elif content.get("type") == "text": - successful += 1 - summary_parts.append(f"Document: {att['filename']}") - if content.get("summary"): - summary_parts.append(f"Summary: {content['summary']}") - else: - text = content.get("text", "") - preview = text[:200] + "..." if len(text) > 200 else text - summary_parts.append(f"Preview: {preview}") - - status = f"Processed {successful} documents, {images} images pending visual processing" - if failed > 0: - status += f", {failed} failed" - - return status + "\n\n" + "\n\n".join(summary_parts) + except Exception as e: + logger.error(f"Error processing content from memory for {filename}: {e!s}") + msg = f"Failed to process content: {e!s}" + raise ValueError(msg) diff --git a/mxtoai/tools/visual_qa_tool.py b/mxtoai/tools/visual_qa_tool.py new file mode 100644 index 0000000..e36856e --- /dev/null +++ b/mxtoai/tools/visual_qa_tool.py @@ -0,0 +1,95 @@ +import mimetypes +from typing import Optional + +from smolagents import Tool + +from mxtoai._logging import get_logger +from mxtoai.scripts.visual_qa import azure_visualizer_from_content, visualizer_from_content + +logger = get_logger(__name__) + + +class VisualQATool(Tool): + """ + Tool for processing image attachments from memory using visual QA. + """ + + name = "visual_qa" + description = "Answer questions about image attachments from memory content" + inputs = { + "filename": { + "description": "Name of the image attachment to analyze", + "type": "string", + }, + "question": { + "description": "Question to ask about the image (optional - will provide caption if not specified)", + "type": "string", + "nullable": True, + }, + "use_azure": { + "description": "Whether to use Azure OpenAI (true) or standard OpenAI (false)", + "type": "boolean", + "nullable": True, + }, + } + output_type = "string" + + def __init__(self, context=None): + super().__init__() + self.context = context + + def forward(self, filename: str, question: Optional[str] = None, use_azure: Optional[bool] = None) -> str: + """ + Analyze an image attachment and answer questions about it. + + Args: + filename: Name of the image attachment + question: Question to ask about the image (optional) + use_azure: Whether to use Azure OpenAI (defaults to True) + + Returns: + str: Analysis result or error message + + """ + try: + if not self.context or not self.context.attachment_service: + return "Error: No attachment service available in request context" + + if not self.context.attachment_service.has_attachment(filename): + return f"Error: Attachment '{filename}' not found" + + content = self.context.attachment_service.get_content(filename) + if content is None: + return f"Error: Could not retrieve content for '{filename}'" + + metadata = self.context.attachment_service.get_metadata(filename) + if not metadata: + return f"Error: Could not retrieve metadata for '{filename}'" + + mime_type = metadata.get("type", "image/jpeg") + + # Check if it's an image file + if not mime_type.startswith("image/"): + guessed_type, _ = mimetypes.guess_type(filename) + if not guessed_type or not guessed_type.startswith("image/"): + return f"Error: '{filename}' is not an image file (type: {mime_type})" + mime_type = guessed_type + + # Use Azure by default + if use_azure is None: + use_azure = True + + # Process the image + if use_azure: + result = azure_visualizer_from_content(content, mime_type, question) + processing_method = "Azure OpenAI" + else: + result = visualizer_from_content(content, mime_type, question) + processing_method = "OpenAI" + + logger.info(f"Successfully processed image '{filename}' using {processing_method}") + return result + + except Exception as e: + logger.error(f"Error processing image '{filename}': {e!s}") + return f"Error processing image '{filename}': {e!s}" diff --git a/tests/test_visual_qa.py b/tests/test_visual_qa.py index 8fbfd71..056e79c 100644 --- a/tests/test_visual_qa.py +++ b/tests/test_visual_qa.py @@ -1,22 +1,22 @@ import base64 import os import tempfile -from unittest.mock import Mock, mock_open, patch +from unittest.mock import Mock, patch import pytest from PIL import Image from mxtoai.scripts.visual_qa import ( VisualQATool, - azure_visualizer, - encode_image, - resize_image, - visualizer, + azure_visualizer_from_content, + encode_image_from_content, + resize_image_from_content, + visualizer_from_content, ) class TestEncodeImage: - """Test the encode_image function.""" + """Test the encode_image_from_content function.""" def test_encode_local_image_file(self): """Test encoding a local image file.""" @@ -28,7 +28,11 @@ def test_encode_local_image_file(self): temp_path = temp_file.name try: - result = encode_image(temp_path) + # Read the file content + with open(temp_path, "rb") as f: + content = f.read() + + result = encode_image_from_content(content) # Should return a base64 string assert isinstance(result, str) @@ -41,79 +45,60 @@ def test_encode_local_image_file(self): finally: os.unlink(temp_path) - @patch("requests.get") - def test_encode_remote_image_url(self, mock_get): - """Test encoding an image from URL.""" - # Mock HTTP response - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.headers = {"content-type": "image/jpeg"} - mock_response.iter_content.return_value = [b"fake_jpeg_data"] - mock_get.return_value = mock_response + def test_encode_image_from_bytes(self): + """Test encoding image from bytes.""" + # Create a simple test image in memory + img = Image.new("RGB", (50, 50), color="green") + import io - # Mock file operations - provide binary data for base64 encoding - with patch("builtins.open", mock_open(read_data=b"fake_jpeg_data")) as mock_file, patch("os.path.abspath") as mock_abspath: - mock_abspath.return_value = "/downloads/test.jpg" + img_bytes = io.BytesIO() + img.save(img_bytes, format="JPEG") + content = img_bytes.getvalue() - result = encode_image("http://example.com/image.jpg") + result = encode_image_from_content(content) - assert isinstance(result, str) - mock_get.assert_called_once() - mock_file.assert_called() - - @patch("requests.get") - def test_encode_remote_image_no_extension(self, mock_get): - """Test encoding remote image when content-type doesn't map to extension.""" - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.headers = {"content-type": "application/octet-stream"} - mock_response.iter_content.return_value = [b"data"] - mock_get.return_value = mock_response + # Should return a base64 string + assert isinstance(result, str) + assert len(result) > 0 - with patch("builtins.open", mock_open(read_data=b"data")): - with patch("os.path.abspath", return_value="/downloads/test.download"): - # Should handle unknown content types - result = encode_image("http://example.com/unknown") - assert isinstance(result, str) + # Should be valid base64 + decoded = base64.b64decode(result) + assert len(decoded) > 0 def test_encode_nonexistent_file(self): - """Test encoding a file that doesn't exist.""" - with pytest.raises(FileNotFoundError): - encode_image("/nonexistent/file.jpg") + """Test encoding with empty content.""" + # encode_image_from_content just does base64 encoding, so empty bytes will return empty string + result = encode_image_from_content(b"") + assert result == "" class TestResizeImage: - """Test the resize_image function.""" + """Test the resize_image_from_content function.""" def test_resize_image_success(self): """Test successful image resizing.""" - # Create a temporary image - with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp_file: - img = Image.new("RGB", (200, 200), color="blue") - img.save(temp_file.name, "JPEG") - temp_path = temp_file.name + # Create a test image in memory + img = Image.new("RGB", (200, 200), color="blue") + import io - resized_path = None - try: - resized_path = resize_image(temp_path) + img_bytes = io.BytesIO() + img.save(img_bytes, format="JPEG") + content = img_bytes.getvalue() - # Check that resized file was created - assert os.path.exists(resized_path) - assert "resized_" in resized_path + resized_content = resize_image_from_content(content) - # Check dimensions are halved - resized_img = Image.open(resized_path) - assert resized_img.size == (100, 100) + # Check that we got bytes back + assert isinstance(resized_content, bytes) + assert len(resized_content) > 0 - finally: - os.unlink(temp_path) - if resized_path and os.path.exists(resized_path): - os.unlink(resized_path) + # Check dimensions are halved + resized_img = Image.open(io.BytesIO(resized_content)) + assert resized_img.size == (100, 100) def test_resize_nonexistent_file(self): - """Test resizing a file that doesn't exist.""" - with pytest.raises(FileNotFoundError): - resize_image("/nonexistent/file.jpg") + """Test resizing with empty content.""" + with pytest.raises(Exception): # PIL will raise UnidentifiedImageError + resize_image_from_content(b"") class TestProcessImagesAndText: @@ -133,29 +118,30 @@ def test_initialization(self): assert tool.name == "visualizer" assert "answer questions about attached images" in tool.description - assert "image_path" in tool.inputs + assert "content" in tool.inputs + assert "mime_type" in tool.inputs assert "question" in tool.inputs assert tool.output_type == "string" assert hasattr(tool, "client") - @patch("mxtoai.scripts.visual_qa.process_images_and_text") + @patch("mxtoai.scripts.visual_qa.process_images_and_text_from_content") def test_forward_with_question(self, mock_process): """Test forward method with a specific question.""" mock_process.return_value = {"generated_text": "This is a test image"} tool = VisualQATool() - result = tool.forward("test_image.jpg", "What do you see?") + result = tool.forward(b"fake_image_content", "image/jpeg", "What do you see?") assert result == {"generated_text": "This is a test image"} - mock_process.assert_called_once_with("test_image.jpg", "What do you see?", tool.client) + mock_process.assert_called_once_with(b"fake_image_content", "What do you see?", tool.client) - @patch("mxtoai.scripts.visual_qa.process_images_and_text") + @patch("mxtoai.scripts.visual_qa.process_images_and_text_from_content") def test_forward_without_question(self, mock_process): """Test forward method without a specific question (auto-caption).""" mock_process.return_value = {"generated_text": "Auto-generated caption"} tool = VisualQATool() - result = tool.forward("test_image.jpg") + result = tool.forward(b"fake_image_content", "image/jpeg") # Should add explanatory note assert "You did not provide a particular question" in result @@ -166,22 +152,19 @@ def test_forward_without_question(self, mock_process): call_args = mock_process.call_args assert "Please write a detailed caption" in call_args[0][1] - @patch("mxtoai.scripts.visual_qa.process_images_and_text") - @patch("mxtoai.scripts.visual_qa.resize_image") + @patch("mxtoai.scripts.visual_qa.process_images_and_text_from_content") + @patch("mxtoai.scripts.visual_qa.resize_image_from_content") def test_forward_payload_too_large_retry(self, mock_resize, mock_process): """Test handling of 'Payload Too Large' error with image resizing.""" # First call fails with payload error, second succeeds - mock_process.side_effect = [ - Exception("Payload Too Large"), - {"generated_text": "Resized image result"} - ] - mock_resize.return_value = "resized_test_image.jpg" + mock_process.side_effect = [Exception("Payload Too Large"), {"generated_text": "Resized image result"}] + mock_resize.return_value = b"resized_image_content" tool = VisualQATool() - result = tool.forward("test_image.jpg", "What's this?") + result = tool.forward(b"fake_image_content", "image/jpeg", "What's this?") assert result == {"generated_text": "Resized image result"} - mock_resize.assert_called_once_with("test_image.jpg") + mock_resize.assert_called_once_with(b"fake_image_content") assert mock_process.call_count == 2 @@ -189,30 +172,24 @@ class TestVisualizerFunction: """Test the visualizer function.""" def test_invalid_image_path_type(self): - """Test visualizer with invalid image_path type.""" - with pytest.raises(Exception, match="You should provide at least `image_path` string argument"): - visualizer(123) # Non-string path + """Test visualizer with invalid content type.""" + with pytest.raises(TypeError): + visualizer_from_content(123, "image/jpeg") # Non-bytes content @patch("requests.post") - @patch("mxtoai.scripts.visual_qa.encode_image") + @patch("mxtoai.scripts.visual_qa.encode_image_from_content") def test_visualizer_success(self, mock_encode, mock_post): """Test successful image analysis with visualizer function.""" mock_encode.return_value = "base64_encoded_image" mock_response = Mock() - mock_response.json.return_value = { - "choices": [{ - "message": { - "content": "This is an image of a cat" - } - }] - } + mock_response.json.return_value = {"choices": [{"message": {"content": "This is an image of a cat"}}]} mock_post.return_value = mock_response - result = visualizer("test_image.jpg", "What animal is this?") + result = visualizer_from_content(b"test_image_content", "image/jpeg", "What animal is this?") assert result == "This is an image of a cat" - mock_encode.assert_called_once_with("test_image.jpg") + mock_encode.assert_called_once_with(b"test_image_content") # Check API call mock_post.assert_called_once() @@ -231,22 +208,16 @@ def test_visualizer_success(self, mock_encode, mock_post): assert content[1]["type"] == "image_url" @patch("requests.post") - @patch("mxtoai.scripts.visual_qa.encode_image") + @patch("mxtoai.scripts.visual_qa.encode_image_from_content") def test_visualizer_without_question(self, mock_encode, mock_post): """Test visualizer function without specific question.""" mock_encode.return_value = "base64_data" mock_response = Mock() - mock_response.json.return_value = { - "choices": [{ - "message": { - "content": "Auto-generated description" - } - }] - } + mock_response.json.return_value = {"choices": [{"message": {"content": "Auto-generated description"}}]} mock_post.return_value = mock_response - result = visualizer("image.jpg") + result = visualizer_from_content(b"image_content", "image/jpeg") # Should add explanatory note assert "You did not provide a particular question" in result @@ -259,7 +230,7 @@ def test_visualizer_without_question(self, mock_encode, mock_post): assert "Please write a detailed caption" in content @patch("requests.post") - @patch("mxtoai.scripts.visual_qa.encode_image") + @patch("mxtoai.scripts.visual_qa.encode_image_from_content") def test_visualizer_api_error(self, mock_encode, mock_post): """Test visualizer function handling API errors.""" mock_encode.return_value = "base64_data" @@ -269,20 +240,23 @@ def test_visualizer_api_error(self, mock_encode, mock_post): mock_post.return_value = mock_response with pytest.raises(Exception, match="Response format unexpected"): - visualizer("image.jpg", "What's this?") + visualizer_from_content(b"image_content", "image/jpeg", "What's this?") class TestAzureVisualizer: """Test the azure_visualizer function.""" @patch("mxtoai.scripts.visual_qa.completion") - @patch("mxtoai.scripts.visual_qa.encode_image") - @patch.dict(os.environ, { - "MODEL_NAME": "gpt-4o", - "MODEL_API_KEY": "test_key", - "MODEL_ENDPOINT": "https://test.openai.azure.com", - "MODEL_API_VERSION": "2024-02-01" - }) + @patch("mxtoai.scripts.visual_qa.encode_image_from_content") + @patch.dict( + os.environ, + { + "MODEL_NAME": "gpt-4o", + "MODEL_API_KEY": "test_key", + "MODEL_ENDPOINT": "https://test.openai.azure.com", + "MODEL_API_VERSION": "2024-02-01", + }, + ) def test_azure_visualizer_success(self, mock_encode, mock_completion): """Test successful Azure OpenAI image analysis.""" mock_encode.return_value = "base64_image_data" @@ -293,10 +267,10 @@ def test_azure_visualizer_success(self, mock_encode, mock_completion): mock_response.choices[0].message.content = "Azure analysis result" mock_completion.return_value = mock_response - result = azure_visualizer("test_image.jpg", "Analyze this image") + result = azure_visualizer_from_content(b"test_image_content", "image/jpeg", "Analyze this image") assert result == "Azure analysis result" - mock_encode.assert_called_once_with("test_image.jpg") + mock_encode.assert_called_once_with(b"test_image_content") # Check litellm completion call mock_completion.assert_called_once() @@ -320,7 +294,7 @@ def test_azure_visualizer_success(self, mock_encode, mock_completion): assert content[1]["type"] == "image_url" @patch("mxtoai.scripts.visual_qa.completion") - @patch("mxtoai.scripts.visual_qa.encode_image") + @patch("mxtoai.scripts.visual_qa.encode_image_from_content") def test_azure_visualizer_without_question(self, mock_encode, mock_completion): """Test Azure visualizer without specific question.""" mock_encode.return_value = "base64_data" @@ -331,14 +305,14 @@ def test_azure_visualizer_without_question(self, mock_encode, mock_completion): mock_response.choices[0].message.content = "Default caption" mock_completion.return_value = mock_response - result = azure_visualizer("image.jpg") + result = azure_visualizer_from_content(b"image_content", "image/jpeg") assert "You did not provide a particular question" in result assert "detailed caption for the image" in result @patch("mxtoai.scripts.visual_qa.completion") - @patch("mxtoai.scripts.visual_qa.encode_image") - @patch("mxtoai.scripts.visual_qa.resize_image") + @patch("mxtoai.scripts.visual_qa.encode_image_from_content") + @patch("mxtoai.scripts.visual_qa.resize_image_from_content") def test_azure_visualizer_image_too_large(self, mock_resize, mock_encode, mock_completion): """Test Azure visualizer handling large image errors.""" mock_encode.return_value = "base64_data" @@ -347,17 +321,17 @@ def test_azure_visualizer_image_too_large(self, mock_resize, mock_encode, mock_c # First call fails with size error, second succeeds after resize mock_completion.side_effect = [ Exception("image too large"), - Mock(choices=[Mock(message=Mock(content="Resized result"))]) + Mock(choices=[Mock(message=Mock(content="Resized result"))]), ] - result = azure_visualizer("large_image.jpg", "What's this?") + result = azure_visualizer_from_content(b"large_image_content", "image/jpeg", "What's this?") # The function successfully retries with resized image and returns the result assert result == "Resized result" - mock_resize.assert_called_once_with("large_image.jpg") + mock_resize.assert_called_once_with(b"large_image_content") @patch("mxtoai.scripts.visual_qa.completion") - @patch("mxtoai.scripts.visual_qa.encode_image") + @patch("mxtoai.scripts.visual_qa.encode_image_from_content") def test_azure_visualizer_empty_response(self, mock_encode, mock_completion): """Test Azure visualizer handling empty response.""" mock_encode.return_value = "base64_data" @@ -368,23 +342,20 @@ def test_azure_visualizer_empty_response(self, mock_encode, mock_completion): mock_response.choices[0].message.content = "" mock_completion.return_value = mock_response - result = azure_visualizer("image.jpg", "What's this?") - - # The function catches the exception and returns an error message - assert "Error processing image: Empty response from Azure OpenAI" in result + with pytest.raises(Exception, match="Failed to process image"): + azure_visualizer_from_content(b"image_content", "image/jpeg", "What's this?") @patch("mxtoai.scripts.visual_qa.completion") - @patch("mxtoai.scripts.visual_qa.encode_image") + @patch("mxtoai.scripts.visual_qa.encode_image_from_content") def test_azure_visualizer_api_error(self, mock_encode, mock_completion): """Test Azure visualizer handling API errors.""" mock_encode.return_value = "base64_data" mock_completion.side_effect = Exception("API connection failed") - result = azure_visualizer("image.jpg", "What's this?") + with pytest.raises(Exception, match="Failed to process image"): + azure_visualizer_from_content(b"image_content", "image/jpeg", "What's this?") - assert "Error processing image: API connection failed" in result - - @patch("mxtoai.scripts.visual_qa.encode_image") + @patch("mxtoai.scripts.visual_qa.encode_image_from_content") def test_azure_visualizer_mime_type_detection(self, mock_encode): """Test MIME type detection for different image formats.""" mock_encode.return_value = "base64_data" @@ -397,15 +368,10 @@ def test_azure_visualizer_mime_type_detection(self, mock_encode): mock_completion.return_value = mock_response # Test different image extensions - test_files = [ - "image.jpg", - "image.png", - "image.gif", - "image.unknown" - ] + test_files = ["image.jpg", "image.png", "image.gif", "image.unknown"] for filename in test_files: - result = azure_visualizer(filename, "Analyze") + result = azure_visualizer_from_content(b"image_content", "image/jpeg", "Analyze") assert result == "Analysis result" # Check MIME type handling @@ -434,30 +400,32 @@ def test_visual_qa_tool_integration(self): temp_path = temp_file.name try: - with patch("mxtoai.scripts.visual_qa.process_images_and_text") as mock_process: + with patch("mxtoai.scripts.visual_qa.process_images_and_text_from_content") as mock_process: mock_process.return_value = {"generated_text": "Integration test result"} - result = tool.forward(temp_path, "What color is this image?") + # Read the file content to pass as bytes + with open(temp_path, "rb") as f: + content = f.read() + + result = tool.forward(content, "image/jpeg", "What color is this image?") # Should return the processed result assert result == {"generated_text": "Integration test result"} - mock_process.assert_called_once_with( - temp_path, - "What color is this image?", - tool.client - ) + mock_process.assert_called_once_with(content, "What color is this image?", tool.client) finally: os.unlink(temp_path) def test_error_handling_chain(self): """Test error handling across different visual processing functions.""" - # Test that errors propagate properly through the processing chain - with pytest.raises(FileNotFoundError): - encode_image("nonexistent_file.jpg") - - with pytest.raises(FileNotFoundError): - resize_image("nonexistent_file.jpg") + # encode_image_from_content just does base64 encoding, won't raise FileNotFoundError + result = encode_image_from_content(b"") + assert result == "" + # resize_image_from_content will raise an image processing error with pytest.raises(Exception): - visualizer(None) # Invalid input type + resize_image_from_content(b"") + + # visualizer_from_content requires proper parameters + with pytest.raises(TypeError): + visualizer_from_content(None, "image/jpeg") # Invalid input type diff --git a/tests/test_visual_qa_tool.py b/tests/test_visual_qa_tool.py new file mode 100644 index 0000000..139e58c --- /dev/null +++ b/tests/test_visual_qa_tool.py @@ -0,0 +1,193 @@ +import io +from unittest.mock import Mock, patch + +from PIL import Image + +from mxtoai.tools.visual_qa_tool import VisualQATool + + +class TestVisualQATool: + """Test the VisualQATool class integration with attachment service.""" + + def test_initialization(self): + """Test VisualQATool initialization.""" + tool = VisualQATool() + + assert tool.name == "visual_qa" + assert "Answer questions about image attachments" in tool.description + assert "filename" in tool.inputs + assert "question" in tool.inputs + assert "use_azure" in tool.inputs + assert tool.output_type == "string" + + def test_no_attachment_service(self): + """Test error when no attachment service is available.""" + tool = VisualQATool() # No context provided + + result = tool.forward("test.jpg", "What is this?") + + assert "Error: No attachment service available" in result + + def test_attachment_not_found(self): + """Test error when attachment is not found.""" + # Mock context with attachment service + mock_context = Mock() + mock_attachment_service = Mock() + mock_attachment_service.has_attachment.return_value = False + mock_context.attachment_service = mock_attachment_service + + tool = VisualQATool(context=mock_context) + + result = tool.forward("nonexistent.jpg", "What is this?") + + assert "Error: Attachment 'nonexistent.jpg' not found" in result + + @patch("mxtoai.tools.visual_qa_tool.azure_visualizer_from_content") + def test_successful_azure_processing(self, mock_azure_visualizer): + """Test successful image processing with Azure OpenAI.""" + mock_azure_visualizer.return_value = "This is a test image showing a red square." + + # Create a simple test image in memory + img = Image.new("RGB", (100, 100), color="red") + img_bytes = io.BytesIO() + img.save(img_bytes, format="JPEG") + content = img_bytes.getvalue() + + # Mock context with attachment service + mock_context = Mock() + mock_attachment_service = Mock() + mock_attachment_service.has_attachment.return_value = True + mock_attachment_service.get_content.return_value = content + mock_attachment_service.get_metadata.return_value = {"type": "image/jpeg", "size": len(content)} + mock_context.attachment_service = mock_attachment_service + + tool = VisualQATool(context=mock_context) + + result = tool.forward("test.jpg", "What color is this?", use_azure=True) + + assert "This is a test image showing a red square." in result + mock_azure_visualizer.assert_called_once_with(content, "image/jpeg", "What color is this?") + + @patch("mxtoai.tools.visual_qa_tool.visualizer_from_content") + def test_successful_openai_processing(self, mock_visualizer): + """Test successful image processing with standard OpenAI.""" + mock_visualizer.return_value = "This is a test image showing a blue circle." + + # Create a simple test image in memory + img = Image.new("RGB", (100, 100), color="blue") + img_bytes = io.BytesIO() + img.save(img_bytes, format="PNG") + content = img_bytes.getvalue() + + # Mock context with attachment service + mock_context = Mock() + mock_attachment_service = Mock() + mock_attachment_service.has_attachment.return_value = True + mock_attachment_service.get_content.return_value = content + mock_attachment_service.get_metadata.return_value = {"type": "image/png", "size": len(content)} + mock_context.attachment_service = mock_attachment_service + + tool = VisualQATool(context=mock_context) + + result = tool.forward("test.png", "What shape is this?", use_azure=False) + + assert "This is a test image showing a blue circle." in result + mock_visualizer.assert_called_once_with(content, "image/png", "What shape is this?") + + def test_non_image_file(self): + """Test error when trying to process non-image file.""" + # Mock context with attachment service returning a text file + mock_context = Mock() + mock_attachment_service = Mock() + mock_attachment_service.has_attachment.return_value = True + mock_attachment_service.get_content.return_value = b"This is text content" + mock_attachment_service.get_metadata.return_value = {"type": "text/plain", "size": 20} + mock_context.attachment_service = mock_attachment_service + + tool = VisualQATool(context=mock_context) + + result = tool.forward("document.txt", "What is this?") + + assert "is not an image file" in result + + def test_mime_type_detection(self): + """Test MIME type detection for files without proper metadata.""" + # Create a simple test image + img = Image.new("RGB", (50, 50), color="green") + img_bytes = io.BytesIO() + img.save(img_bytes, format="JPEG") + content = img_bytes.getvalue() + + # Mock context with attachment service - no type in metadata + mock_context = Mock() + mock_attachment_service = Mock() + mock_attachment_service.has_attachment.return_value = True + mock_attachment_service.get_content.return_value = content + mock_attachment_service.get_metadata.return_value = {"size": len(content)} # No type field + mock_context.attachment_service = mock_attachment_service + + with patch("mxtoai.tools.visual_qa_tool.azure_visualizer_from_content") as mock_azure: + mock_azure.return_value = "Green square detected" + + tool = VisualQATool(context=mock_context) + result = tool.forward("image.jpg", "What color?") + + assert "Green square detected" in result + # Should detect JPEG from filename + mock_azure.assert_called_once_with(content, "image/jpeg", "What color?") + + @patch("mxtoai.tools.visual_qa_tool.azure_visualizer_from_content") + def test_processing_exception_handling(self, mock_azure_visualizer): + """Test error handling when image processing fails.""" + mock_azure_visualizer.side_effect = Exception("API error") + + # Create test image + img = Image.new("RGB", (100, 100), color="red") + img_bytes = io.BytesIO() + img.save(img_bytes, format="JPEG") + content = img_bytes.getvalue() + + # Mock context + mock_context = Mock() + mock_attachment_service = Mock() + mock_attachment_service.has_attachment.return_value = True + mock_attachment_service.get_content.return_value = content + mock_attachment_service.get_metadata.return_value = {"type": "image/jpeg", "size": len(content)} + mock_context.attachment_service = mock_attachment_service + + tool = VisualQATool(context=mock_context) + + result = tool.forward("test.jpg", "What is this?") + + assert "Error processing image 'test.jpg': API error" in result + + def test_content_retrieval_failure(self): + """Test error when content cannot be retrieved.""" + # Mock context with attachment service that can't retrieve content + mock_context = Mock() + mock_attachment_service = Mock() + mock_attachment_service.has_attachment.return_value = True + mock_attachment_service.get_content.return_value = None # Content retrieval fails + mock_context.attachment_service = mock_attachment_service + + tool = VisualQATool(context=mock_context) + + result = tool.forward("test.jpg", "What is this?") + + assert "Error: Could not retrieve content for 'test.jpg'" in result + + def test_metadata_retrieval_failure(self): + """Test error when metadata cannot be retrieved.""" + # Mock context with attachment service that can't retrieve metadata + mock_context = Mock() + mock_attachment_service = Mock() + mock_attachment_service.has_attachment.return_value = True + mock_attachment_service.get_content.return_value = b"image_content" + mock_attachment_service.get_metadata.return_value = None # Metadata retrieval fails + mock_context.attachment_service = mock_attachment_service + + tool = VisualQATool(context=mock_context) + + result = tool.forward("test.jpg", "What is this?") + + assert "Error: Could not retrieve metadata for 'test.jpg'" in result