renwei2024 commited on
Commit
acb85aa
·
1 Parent(s): 178422f

Add tool text_inspector_tool and update requirements.txt

Browse files
Files changed (3) hide show
  1. requirements.txt +11 -0
  2. tools/mdconvert.py +1002 -0
  3. tools/text_inspector_tool.py +124 -0
requirements.txt CHANGED
@@ -1,2 +1,13 @@
1
  gradio
2
  requests
 
 
 
 
 
 
 
 
 
 
 
 
1
  gradio
2
  requests
3
+ smolagents
4
+ mammoth
5
+ markdownify
6
+ pandas
7
+ pdfminer.six
8
+ python-pptx
9
+ puremagic
10
+ pydub
11
+ SpeechRecognition
12
+ beautifulsoup4
13
+ youtube-transcript-api
tools/mdconvert.py ADDED
@@ -0,0 +1,1002 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This is copied from Magentic-one's great repo: https://github.com/microsoft/autogen/blob/v0.4.4/python/packages/autogen-magentic-one/src/autogen_magentic_one/markdown_browser/mdconvert.py
2
+ # Thanks to Microsoft researchers for open-sourcing this!
3
+ # type: ignore
4
+ import base64
5
+ import copy
6
+ import html
7
+ import json
8
+ import mimetypes
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import traceback
16
+ import zipfile
17
+ from typing import Any
18
+ from urllib.parse import parse_qs, quote, unquote, urlparse, urlunparse
19
+
20
+ import mammoth
21
+ import markdownify
22
+ import pandas as pd
23
+ import pdfminer
24
+ import pdfminer.high_level
25
+ import pptx
26
+
27
+ # File-format detection
28
+ import puremagic
29
+ import pydub
30
+ import requests
31
+ import speech_recognition as sr
32
+ from bs4 import BeautifulSoup
33
+ from youtube_transcript_api import YouTubeTranscriptApi
34
+ from youtube_transcript_api.formatters import SRTFormatter
35
+
36
+
37
+ class _CustomMarkdownify(markdownify.MarkdownConverter):
38
+ """
39
+ A custom version of markdownify's MarkdownConverter. Changes include:
40
+
41
+ - Altering the default heading style to use '#', '##', etc.
42
+ - Removing javascript hyperlinks.
43
+ - Truncating images with large data:uri sources.
44
+ - Ensuring URIs are properly escaped, and do not conflict with Markdown syntax
45
+ """
46
+
47
+ def __init__(self, **options: Any):
48
+ options["heading_style"] = options.get("heading_style", markdownify.ATX)
49
+ # Explicitly cast options to the expected type if necessary
50
+ super().__init__(**options)
51
+
52
+ def convert_hn(self, n: int, el: Any, text: str, convert_as_inline: bool) -> str:
53
+ """Same as usual, but be sure to start with a new line"""
54
+ if not convert_as_inline:
55
+ if not re.search(r"^\n", text):
56
+ return "\n" + super().convert_hn(n, el, text, convert_as_inline) # type: ignore
57
+
58
+ return super().convert_hn(n, el, text, convert_as_inline) # type: ignore
59
+
60
+ def convert_a(self, el: Any, text: str, convert_as_inline: bool):
61
+ """Same as usual converter, but removes Javascript links and escapes URIs."""
62
+ prefix, suffix, text = markdownify.chomp(text) # type: ignore
63
+ if not text:
64
+ return ""
65
+ href = el.get("href")
66
+ title = el.get("title")
67
+
68
+ # Escape URIs and skip non-http or file schemes
69
+ if href:
70
+ try:
71
+ parsed_url = urlparse(href) # type: ignore
72
+ if parsed_url.scheme and parsed_url.scheme.lower() not in ["http", "https", "file"]: # type: ignore
73
+ return "%s%s%s" % (prefix, text, suffix)
74
+ href = urlunparse(parsed_url._replace(path=quote(unquote(parsed_url.path)))) # type: ignore
75
+ except ValueError: # It's not clear if this ever gets thrown
76
+ return "%s%s%s" % (prefix, text, suffix)
77
+
78
+ # For the replacement see #29: text nodes underscores are escaped
79
+ if (
80
+ self.options["autolinks"]
81
+ and text.replace(r"\_", "_") == href
82
+ and not title
83
+ and not self.options["default_title"]
84
+ ):
85
+ # Shortcut syntax
86
+ return "<%s>" % href
87
+ if self.options["default_title"] and not title:
88
+ title = href
89
+ title_part = ' "%s"' % title.replace('"', r"\"") if title else ""
90
+ return "%s[%s](%s%s)%s" % (prefix, text, href, title_part, suffix) if href else text
91
+
92
+ def convert_img(self, el: Any, text: str, convert_as_inline: bool) -> str:
93
+ """Same as usual converter, but removes data URIs"""
94
+
95
+ alt = el.attrs.get("alt", None) or ""
96
+ src = el.attrs.get("src", None) or ""
97
+ title = el.attrs.get("title", None) or ""
98
+ title_part = ' "%s"' % title.replace('"', r"\"") if title else ""
99
+ if convert_as_inline and el.parent.name not in self.options["keep_inline_images_in"]:
100
+ return alt
101
+
102
+ # Remove dataURIs
103
+ if src.startswith("data:"):
104
+ src = src.split(",")[0] + "..."
105
+
106
+ return "![%s](%s%s)" % (alt, src, title_part)
107
+
108
+ def convert_soup(self, soup: Any) -> str:
109
+ return super().convert_soup(soup) # type: ignore
110
+
111
+
112
+ class DocumentConverterResult:
113
+ """The result of converting a document to text."""
114
+
115
+ def __init__(self, title: str | None = None, text_content: str = ""):
116
+ self.title: str | None = title
117
+ self.text_content: str = text_content
118
+
119
+
120
+ class DocumentConverter:
121
+ """Abstract superclass of all DocumentConverters."""
122
+
123
+ def convert(self, local_path: str, **kwargs: Any) -> None | DocumentConverterResult:
124
+ raise NotImplementedError()
125
+
126
+
127
+ class PlainTextConverter(DocumentConverter):
128
+ """Anything with content type text/plain"""
129
+
130
+ def convert(self, local_path: str, **kwargs: Any) -> None | DocumentConverterResult:
131
+ # Guess the content type from any file extension that might be around
132
+ content_type, _ = mimetypes.guess_type("__placeholder" + kwargs.get("file_extension", ""))
133
+
134
+ # Only accept text files
135
+ if content_type is None:
136
+ return None
137
+ # elif "text/" not in content_type.lower():
138
+ # return None
139
+
140
+ text_content = ""
141
+ with open(local_path, "rt", encoding="utf-8") as fh:
142
+ text_content = fh.read()
143
+ return DocumentConverterResult(
144
+ title=None,
145
+ text_content=text_content,
146
+ )
147
+
148
+
149
+ class HtmlConverter(DocumentConverter):
150
+ """Anything with content type text/html"""
151
+
152
+ def convert(self, local_path: str, **kwargs: Any) -> None | DocumentConverterResult:
153
+ # Bail if not html
154
+ extension = kwargs.get("file_extension", "")
155
+ if extension.lower() not in [".html", ".htm"]:
156
+ return None
157
+
158
+ result = None
159
+ with open(local_path, "rt", encoding="utf-8") as fh:
160
+ result = self._convert(fh.read())
161
+
162
+ return result
163
+
164
+ def _convert(self, html_content: str) -> None | DocumentConverterResult:
165
+ """Helper function that converts and HTML string."""
166
+
167
+ # Parse the string
168
+ soup = BeautifulSoup(html_content, "html.parser")
169
+
170
+ # Remove javascript and style blocks
171
+ for script in soup(["script", "style"]):
172
+ script.extract()
173
+
174
+ # Print only the main content
175
+ body_elm = soup.find("body")
176
+ webpage_text = ""
177
+ if body_elm:
178
+ webpage_text = _CustomMarkdownify().convert_soup(body_elm)
179
+ else:
180
+ webpage_text = _CustomMarkdownify().convert_soup(soup)
181
+
182
+ assert isinstance(webpage_text, str)
183
+
184
+ return DocumentConverterResult(
185
+ title=None if soup.title is None else soup.title.string, text_content=webpage_text
186
+ )
187
+
188
+
189
+ class WikipediaConverter(DocumentConverter):
190
+ """Handle Wikipedia pages separately, focusing only on the main document content."""
191
+
192
+ def convert(self, local_path: str, **kwargs: Any) -> None | DocumentConverterResult:
193
+ # Bail if not Wikipedia
194
+ extension = kwargs.get("file_extension", "")
195
+ if extension.lower() not in [".html", ".htm"]:
196
+ return None
197
+ url = kwargs.get("url", "")
198
+ if not re.search(r"^https?:\/\/[a-zA-Z]{2,3}\.wikipedia.org\/", url):
199
+ return None
200
+
201
+ # Parse the file
202
+ soup = None
203
+ with open(local_path, "rt", encoding="utf-8") as fh:
204
+ soup = BeautifulSoup(fh.read(), "html.parser")
205
+
206
+ # Remove javascript and style blocks
207
+ for script in soup(["script", "style"]):
208
+ script.extract()
209
+
210
+ # Print only the main content
211
+ body_elm = soup.find("div", {"id": "mw-content-text"})
212
+ title_elm = soup.find("span", {"class": "mw-page-title-main"})
213
+
214
+ webpage_text = ""
215
+ main_title = None if soup.title is None else soup.title.string
216
+
217
+ if body_elm:
218
+ # What's the title
219
+ if title_elm and len(title_elm) > 0:
220
+ main_title = title_elm.string # type: ignore
221
+ assert isinstance(main_title, str)
222
+
223
+ # Convert the page
224
+ webpage_text = f"# {main_title}\n\n" + _CustomMarkdownify().convert_soup(body_elm)
225
+ else:
226
+ webpage_text = _CustomMarkdownify().convert_soup(soup)
227
+
228
+ return DocumentConverterResult(
229
+ title=main_title,
230
+ text_content=webpage_text,
231
+ )
232
+
233
+
234
+ class YouTubeConverter(DocumentConverter):
235
+ """Handle YouTube specially, focusing on the video title, description, and transcript."""
236
+
237
+ def convert(self, local_path: str, **kwargs: Any) -> None | DocumentConverterResult:
238
+ # Bail if not YouTube
239
+ extension = kwargs.get("file_extension", "")
240
+ if extension.lower() not in [".html", ".htm"]:
241
+ return None
242
+ url = kwargs.get("url", "")
243
+ if not url.startswith("https://www.youtube.com/watch?"):
244
+ return None
245
+
246
+ # Parse the file
247
+ soup = None
248
+ with open(local_path, "rt", encoding="utf-8") as fh:
249
+ soup = BeautifulSoup(fh.read(), "html.parser")
250
+
251
+ # Read the meta tags
252
+ assert soup.title is not None and soup.title.string is not None
253
+ metadata: dict[str, str] = {"title": soup.title.string}
254
+ for meta in soup(["meta"]):
255
+ for a in meta.attrs:
256
+ if a in ["itemprop", "property", "name"]:
257
+ metadata[meta[a]] = meta.get("content", "")
258
+ break
259
+
260
+ # We can also try to read the full description. This is more prone to breaking, since it reaches into the page implementation
261
+ try:
262
+ for script in soup(["script"]):
263
+ content = script.text
264
+ if "ytInitialData" in content:
265
+ lines = re.split(r"\r?\n", content)
266
+ obj_start = lines[0].find("{")
267
+ obj_end = lines[0].rfind("}")
268
+ if obj_start >= 0 and obj_end >= 0:
269
+ data = json.loads(lines[0][obj_start : obj_end + 1])
270
+ attrdesc = self._findKey(data, "attributedDescriptionBodyText") # type: ignore
271
+ if attrdesc:
272
+ metadata["description"] = str(attrdesc["content"])
273
+ break
274
+ except Exception:
275
+ pass
276
+
277
+ # Start preparing the page
278
+ webpage_text = "# YouTube\n"
279
+
280
+ title = self._get(metadata, ["title", "og:title", "name"]) # type: ignore
281
+ assert isinstance(title, str)
282
+
283
+ if title:
284
+ webpage_text += f"\n## {title}\n"
285
+
286
+ stats = ""
287
+ views = self._get(metadata, ["interactionCount"]) # type: ignore
288
+ if views:
289
+ stats += f"- **Views:** {views}\n"
290
+
291
+ keywords = self._get(metadata, ["keywords"]) # type: ignore
292
+ if keywords:
293
+ stats += f"- **Keywords:** {keywords}\n"
294
+
295
+ runtime = self._get(metadata, ["duration"]) # type: ignore
296
+ if runtime:
297
+ stats += f"- **Runtime:** {runtime}\n"
298
+
299
+ if len(stats) > 0:
300
+ webpage_text += f"\n### Video Metadata\n{stats}\n"
301
+
302
+ description = self._get(metadata, ["description", "og:description"]) # type: ignore
303
+ if description:
304
+ webpage_text += f"\n### Description\n{description}\n"
305
+
306
+ transcript_text = ""
307
+ parsed_url = urlparse(url) # type: ignore
308
+ params = parse_qs(parsed_url.query) # type: ignore
309
+ if "v" in params:
310
+ assert isinstance(params["v"][0], str)
311
+ video_id = str(params["v"][0])
312
+ try:
313
+ # Must be a single transcript.
314
+ transcript = YouTubeTranscriptApi.get_transcript(video_id) # type: ignore
315
+ # transcript_text = " ".join([part["text"] for part in transcript]) # type: ignore
316
+ # Alternative formatting:
317
+ transcript_text = SRTFormatter().format_transcript(transcript)
318
+ except Exception:
319
+ pass
320
+ if transcript_text:
321
+ webpage_text += f"\n### Transcript\n{transcript_text}\n"
322
+
323
+ title = title if title else soup.title.string
324
+ assert isinstance(title, str)
325
+
326
+ return DocumentConverterResult(
327
+ title=title,
328
+ text_content=webpage_text,
329
+ )
330
+
331
+ def _get(self, metadata: dict[str, str], keys: list[str], default: str | None = None) -> str | None:
332
+ for k in keys:
333
+ if k in metadata:
334
+ return metadata[k]
335
+ return default
336
+
337
+ def _findKey(self, json: Any, key: str) -> str | None: # TODO: Fix json type
338
+ if isinstance(json, list):
339
+ for elm in json:
340
+ ret = self._findKey(elm, key)
341
+ if ret is not None:
342
+ return ret
343
+ elif isinstance(json, dict):
344
+ for k in json:
345
+ if k == key:
346
+ return json[k]
347
+ else:
348
+ ret = self._findKey(json[k], key)
349
+ if ret is not None:
350
+ return ret
351
+ return None
352
+
353
+
354
+ class PdfConverter(DocumentConverter):
355
+ """
356
+ Converts PDFs to Markdown. Most style information is ignored, so the results are essentially plain-text.
357
+ """
358
+
359
+ def convert(self, local_path, **kwargs) -> None | DocumentConverterResult:
360
+ # Bail if not a PDF
361
+ extension = kwargs.get("file_extension", "")
362
+ if extension.lower() != ".pdf":
363
+ return None
364
+
365
+ return DocumentConverterResult(
366
+ title=None,
367
+ text_content=pdfminer.high_level.extract_text(local_path),
368
+ )
369
+
370
+
371
+ class DocxConverter(HtmlConverter):
372
+ """
373
+ Converts DOCX files to Markdown. Style information (e.g.m headings) and tables are preserved where possible.
374
+ """
375
+
376
+ def convert(self, local_path, **kwargs) -> None | DocumentConverterResult:
377
+ # Bail if not a DOCX
378
+ extension = kwargs.get("file_extension", "")
379
+ if extension.lower() != ".docx":
380
+ return None
381
+
382
+ result = None
383
+ with open(local_path, "rb") as docx_file:
384
+ result = mammoth.convert_to_html(docx_file)
385
+ html_content = result.value
386
+ result = self._convert(html_content)
387
+
388
+ return result
389
+
390
+
391
+ class XlsxConverter(HtmlConverter):
392
+ """
393
+ Converts XLSX files to Markdown, with each sheet presented as a separate Markdown table.
394
+ """
395
+
396
+ def convert(self, local_path, **kwargs) -> None | DocumentConverterResult:
397
+ # Bail if not a XLSX
398
+ extension = kwargs.get("file_extension", "")
399
+ if extension.lower() not in [".xlsx", ".xls"]:
400
+ return None
401
+
402
+ sheets = pd.read_excel(local_path, sheet_name=None)
403
+ md_content = ""
404
+ for s in sheets:
405
+ md_content += f"## {s}\n"
406
+ html_content = sheets[s].to_html(index=False)
407
+ md_content += self._convert(html_content).text_content.strip() + "\n\n"
408
+
409
+ return DocumentConverterResult(
410
+ title=None,
411
+ text_content=md_content.strip(),
412
+ )
413
+
414
+
415
+ class PptxConverter(HtmlConverter):
416
+ """
417
+ Converts PPTX files to Markdown. Supports heading, tables and images with alt text.
418
+ """
419
+
420
+ def convert(self, local_path, **kwargs) -> None | DocumentConverterResult:
421
+ # Bail if not a PPTX
422
+ extension = kwargs.get("file_extension", "")
423
+ if extension.lower() != ".pptx":
424
+ return None
425
+
426
+ md_content = ""
427
+
428
+ presentation = pptx.Presentation(local_path)
429
+ slide_num = 0
430
+ for slide in presentation.slides:
431
+ slide_num += 1
432
+
433
+ md_content += f"\n\n<!-- Slide number: {slide_num} -->\n"
434
+
435
+ title = slide.shapes.title
436
+ for shape in slide.shapes:
437
+ # Pictures
438
+ if self._is_picture(shape):
439
+ # https://github.com/scanny/python-pptx/pull/512#issuecomment-1713100069
440
+ alt_text = ""
441
+ try:
442
+ alt_text = shape._element._nvXxPr.cNvPr.attrib.get("descr", "")
443
+ except Exception:
444
+ pass
445
+
446
+ # A placeholder name
447
+ filename = re.sub(r"\W", "", shape.name) + ".jpg"
448
+ md_content += "\n![" + (alt_text if alt_text else shape.name) + "](" + filename + ")\n"
449
+
450
+ # Tables
451
+ if self._is_table(shape):
452
+ html_table = "<html><body><table>"
453
+ first_row = True
454
+ for row in shape.table.rows:
455
+ html_table += "<tr>"
456
+ for cell in row.cells:
457
+ if first_row:
458
+ html_table += "<th>" + html.escape(cell.text) + "</th>"
459
+ else:
460
+ html_table += "<td>" + html.escape(cell.text) + "</td>"
461
+ html_table += "</tr>"
462
+ first_row = False
463
+ html_table += "</table></body></html>"
464
+ md_content += "\n" + self._convert(html_table).text_content.strip() + "\n"
465
+
466
+ # Text areas
467
+ elif shape.has_text_frame:
468
+ if shape == title:
469
+ md_content += "# " + shape.text.lstrip() + "\n"
470
+ else:
471
+ md_content += shape.text + "\n"
472
+
473
+ md_content = md_content.strip()
474
+
475
+ if slide.has_notes_slide:
476
+ md_content += "\n\n### Notes:\n"
477
+ notes_frame = slide.notes_slide.notes_text_frame
478
+ if notes_frame is not None:
479
+ md_content += notes_frame.text
480
+ md_content = md_content.strip()
481
+
482
+ return DocumentConverterResult(
483
+ title=None,
484
+ text_content=md_content.strip(),
485
+ )
486
+
487
+ def _is_picture(self, shape):
488
+ if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE:
489
+ return True
490
+ if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PLACEHOLDER:
491
+ if hasattr(shape, "image"):
492
+ return True
493
+ return False
494
+
495
+ def _is_table(self, shape):
496
+ if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.TABLE:
497
+ return True
498
+ return False
499
+
500
+
501
+ class MediaConverter(DocumentConverter):
502
+ """
503
+ Abstract class for multi-modal media (e.g., images and audio)
504
+ """
505
+
506
+ def _get_metadata(self, local_path):
507
+ exiftool = shutil.which("exiftool")
508
+ if not exiftool:
509
+ return None
510
+ else:
511
+ try:
512
+ result = subprocess.run([exiftool, "-json", local_path], capture_output=True, text=True).stdout
513
+ return json.loads(result)[0]
514
+ except Exception:
515
+ return None
516
+
517
+
518
+ class WavConverter(MediaConverter):
519
+ """
520
+ Converts WAV files to markdown via extraction of metadata (if `exiftool` is installed), and speech transcription (if `speech_recognition` is installed).
521
+ """
522
+
523
+ def convert(self, local_path, **kwargs) -> None | DocumentConverterResult:
524
+ # Bail if not a XLSX
525
+ extension = kwargs.get("file_extension", "")
526
+ if extension.lower() != ".wav":
527
+ return None
528
+
529
+ md_content = ""
530
+
531
+ # Add metadata
532
+ metadata = self._get_metadata(local_path)
533
+ if metadata:
534
+ for f in [
535
+ "Title",
536
+ "Artist",
537
+ "Author",
538
+ "Band",
539
+ "Album",
540
+ "Genre",
541
+ "Track",
542
+ "DateTimeOriginal",
543
+ "CreateDate",
544
+ "Duration",
545
+ ]:
546
+ if f in metadata:
547
+ md_content += f"{f}: {metadata[f]}\n"
548
+
549
+ # Transcribe
550
+ try:
551
+ transcript = self._transcribe_audio(local_path)
552
+ md_content += "\n\n### Audio Transcript:\n" + ("[No speech detected]" if transcript == "" else transcript)
553
+ except Exception:
554
+ md_content += "\n\n### Audio Transcript:\nError. Could not transcribe this audio."
555
+
556
+ return DocumentConverterResult(
557
+ title=None,
558
+ text_content=md_content.strip(),
559
+ )
560
+
561
+ def _transcribe_audio(self, local_path) -> str:
562
+ recognizer = sr.Recognizer()
563
+ with sr.AudioFile(local_path) as source:
564
+ audio = recognizer.record(source)
565
+ return recognizer.recognize_google(audio).strip()
566
+
567
+
568
+ class Mp3Converter(WavConverter):
569
+ """
570
+ 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).
571
+ """
572
+
573
+ def convert(self, local_path, **kwargs) -> None | DocumentConverterResult:
574
+ # Bail if not a MP3
575
+ extension = kwargs.get("file_extension", "")
576
+ if extension.lower() not in [".mp3", ".m4a"]:
577
+ return None
578
+
579
+ md_content = ""
580
+
581
+ # Add metadata
582
+ metadata = self._get_metadata(local_path)
583
+ if metadata:
584
+ for f in [
585
+ "Title",
586
+ "Artist",
587
+ "Author",
588
+ "Band",
589
+ "Album",
590
+ "Genre",
591
+ "Track",
592
+ "DateTimeOriginal",
593
+ "CreateDate",
594
+ "Duration",
595
+ ]:
596
+ if f in metadata:
597
+ md_content += f"{f}: {metadata[f]}\n"
598
+
599
+ # Transcribe
600
+ handle, temp_path = tempfile.mkstemp(suffix=".wav")
601
+ os.close(handle)
602
+ try:
603
+ if extension.lower() == ".mp3":
604
+ sound = pydub.AudioSegment.from_mp3(local_path)
605
+ else:
606
+ sound = pydub.AudioSegment.from_file(local_path, format="m4a")
607
+ sound.export(temp_path, format="wav")
608
+
609
+ _args = dict()
610
+ _args.update(kwargs)
611
+ _args["file_extension"] = ".wav"
612
+
613
+ try:
614
+ transcript = super()._transcribe_audio(temp_path).strip()
615
+ md_content += "\n\n### Audio Transcript:\n" + (
616
+ "[No speech detected]" if transcript == "" else transcript
617
+ )
618
+ except Exception:
619
+ md_content += "\n\n### Audio Transcript:\nError. Could not transcribe this audio."
620
+
621
+ finally:
622
+ os.unlink(temp_path)
623
+
624
+ # Return the result
625
+ return DocumentConverterResult(
626
+ title=None,
627
+ text_content=md_content.strip(),
628
+ )
629
+
630
+
631
+ class ZipConverter(DocumentConverter):
632
+ """
633
+ Extracts ZIP files to a permanent local directory and returns a listing of extracted files.
634
+ """
635
+
636
+ def __init__(self, extract_dir: str = "downloads"):
637
+ """
638
+ Initialize with path to extraction directory.
639
+
640
+ Args:
641
+ extract_dir: The directory where files will be extracted. Defaults to "downloads"
642
+ """
643
+ self.extract_dir = extract_dir
644
+ # Create the extraction directory if it doesn't exist
645
+ os.makedirs(self.extract_dir, exist_ok=True)
646
+
647
+ def convert(self, local_path: str, **kwargs: Any) -> None | DocumentConverterResult:
648
+ # Bail if not a ZIP file
649
+ extension = kwargs.get("file_extension", "")
650
+ if extension.lower() != ".zip":
651
+ return None
652
+
653
+ # Verify it's actually a ZIP file
654
+ if not zipfile.is_zipfile(local_path):
655
+ return None
656
+
657
+ # Extract all files and build list
658
+ extracted_files = []
659
+ with zipfile.ZipFile(local_path, "r") as zip_ref:
660
+ # Extract all files
661
+ zip_ref.extractall(self.extract_dir)
662
+ # Get list of all files
663
+ for file_path in zip_ref.namelist():
664
+ # Skip directories
665
+ if not file_path.endswith("/"):
666
+ extracted_files.append(self.extract_dir + "/" + file_path)
667
+
668
+ # Sort files for consistent output
669
+ extracted_files.sort()
670
+
671
+ # Build the markdown content
672
+ md_content = "Downloaded the following files:\n"
673
+ for file in extracted_files:
674
+ md_content += f"* {file}\n"
675
+
676
+ return DocumentConverterResult(title="Extracted Files", text_content=md_content.strip())
677
+
678
+
679
+ class ImageConverter(MediaConverter):
680
+ """
681
+ 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).
682
+ """
683
+
684
+ def convert(self, local_path, **kwargs) -> None | DocumentConverterResult:
685
+ # Bail if not a XLSX
686
+ extension = kwargs.get("file_extension", "")
687
+ if extension.lower() not in [".jpg", ".jpeg", ".png"]:
688
+ return None
689
+
690
+ md_content = ""
691
+
692
+ # Add metadata
693
+ metadata = self._get_metadata(local_path)
694
+ if metadata:
695
+ for f in [
696
+ "ImageSize",
697
+ "Title",
698
+ "Caption",
699
+ "Description",
700
+ "Keywords",
701
+ "Artist",
702
+ "Author",
703
+ "DateTimeOriginal",
704
+ "CreateDate",
705
+ "GPSPosition",
706
+ ]:
707
+ if f in metadata:
708
+ md_content += f"{f}: {metadata[f]}\n"
709
+
710
+ # Try describing the image with GPTV
711
+ mlm_client = kwargs.get("mlm_client")
712
+ mlm_model = kwargs.get("mlm_model")
713
+ if mlm_client is not None and mlm_model is not None:
714
+ md_content += (
715
+ "\n# Description:\n"
716
+ + self._get_mlm_description(
717
+ local_path, extension, mlm_client, mlm_model, prompt=kwargs.get("mlm_prompt")
718
+ ).strip()
719
+ + "\n"
720
+ )
721
+
722
+ return DocumentConverterResult(
723
+ title=None,
724
+ text_content=md_content,
725
+ )
726
+
727
+ def _get_mlm_description(self, local_path, extension, client, model, prompt=None):
728
+ if prompt is None or prompt.strip() == "":
729
+ prompt = "Write a detailed caption for this image."
730
+
731
+ sys.stderr.write(f"MLM Prompt:\n{prompt}\n")
732
+
733
+ data_uri = ""
734
+ with open(local_path, "rb") as image_file:
735
+ content_type, encoding = mimetypes.guess_type("_dummy" + extension)
736
+ if content_type is None:
737
+ content_type = "image/jpeg"
738
+ image_base64 = base64.b64encode(image_file.read()).decode("utf-8")
739
+ data_uri = f"data:{content_type};base64,{image_base64}"
740
+
741
+ messages = [
742
+ {
743
+ "role": "user",
744
+ "content": [
745
+ {"type": "text", "text": prompt},
746
+ {
747
+ "type": "image_url",
748
+ "image_url": {
749
+ "url": data_uri,
750
+ },
751
+ },
752
+ ],
753
+ }
754
+ ]
755
+
756
+ response = client.chat.completions.create(model=model, messages=messages)
757
+ return response.choices[0].message.content
758
+
759
+
760
+ class FileConversionException(Exception):
761
+ pass
762
+
763
+
764
+ class UnsupportedFormatException(Exception):
765
+ pass
766
+
767
+
768
+ class MarkdownConverter:
769
+ """(In preview) An extremely simple text-based document reader, suitable for LLM use.
770
+ This reader will convert common file-types or webpages to Markdown."""
771
+
772
+ def __init__(
773
+ self,
774
+ requests_session: requests.Session | None = None,
775
+ mlm_client: Any | None = None,
776
+ mlm_model: Any | None = None,
777
+ ):
778
+ if requests_session is None:
779
+ self._requests_session = requests.Session()
780
+ else:
781
+ self._requests_session = requests_session
782
+
783
+ self._mlm_client = mlm_client
784
+ self._mlm_model = mlm_model
785
+
786
+ self._page_converters: list[DocumentConverter] = []
787
+
788
+ # Register converters for successful browsing operations
789
+ # Later registrations are tried first / take higher priority than earlier registrations
790
+ # To this end, the most specific converters should appear below the most generic converters
791
+ self.register_page_converter(PlainTextConverter())
792
+ self.register_page_converter(HtmlConverter())
793
+ self.register_page_converter(WikipediaConverter())
794
+ self.register_page_converter(YouTubeConverter())
795
+ self.register_page_converter(DocxConverter())
796
+ self.register_page_converter(XlsxConverter())
797
+ self.register_page_converter(PptxConverter())
798
+ self.register_page_converter(WavConverter())
799
+ self.register_page_converter(Mp3Converter())
800
+ self.register_page_converter(ImageConverter())
801
+ self.register_page_converter(ZipConverter())
802
+ self.register_page_converter(PdfConverter())
803
+
804
+ def convert(
805
+ self, source: str | requests.Response, **kwargs: Any
806
+ ) -> DocumentConverterResult: # TODO: deal with kwargs
807
+ """
808
+ Args:
809
+ - source: can be a string representing a path or url, or a requests.response object
810
+ - extension: specifies the file extension to use when interpreting the file. If None, infer from source (path, uri, content-type, etc.)
811
+ """
812
+
813
+ # Local path or url
814
+ if isinstance(source, str):
815
+ if source.startswith("http://") or source.startswith("https://") or source.startswith("file://"):
816
+ return self.convert_url(source, **kwargs)
817
+ else:
818
+ return self.convert_local(source, **kwargs)
819
+ # Request response
820
+ elif isinstance(source, requests.Response):
821
+ return self.convert_response(source, **kwargs)
822
+
823
+ def convert_local(self, path: str, **kwargs: Any) -> DocumentConverterResult: # TODO: deal with kwargs
824
+ # Prepare a list of extensions to try (in order of priority)
825
+ ext = kwargs.get("file_extension")
826
+ extensions = [ext] if ext is not None else []
827
+
828
+ # Get extension alternatives from the path and puremagic
829
+ base, ext = os.path.splitext(path)
830
+ self._append_ext(extensions, ext)
831
+ self._append_ext(extensions, self._guess_ext_magic(path))
832
+
833
+ # Convert
834
+ return self._convert(path, extensions, **kwargs)
835
+
836
+ # TODO what should stream's type be?
837
+ def convert_stream(self, stream: Any, **kwargs: Any) -> DocumentConverterResult: # TODO: deal with kwargs
838
+ # Prepare a list of extensions to try (in order of priority)
839
+ ext = kwargs.get("file_extension")
840
+ extensions = [ext] if ext is not None else []
841
+
842
+ # Save the file locally to a temporary file. It will be deleted before this method exits
843
+ handle, temp_path = tempfile.mkstemp()
844
+ fh = os.fdopen(handle, "wb")
845
+ result = None
846
+ try:
847
+ # Write to the temporary file
848
+ content = stream.read()
849
+ if isinstance(content, str):
850
+ fh.write(content.encode("utf-8"))
851
+ else:
852
+ fh.write(content)
853
+ fh.close()
854
+
855
+ # Use puremagic to check for more extension options
856
+ self._append_ext(extensions, self._guess_ext_magic(temp_path))
857
+
858
+ # Convert
859
+ result = self._convert(temp_path, extensions, **kwargs)
860
+ # Clean up
861
+ finally:
862
+ try:
863
+ fh.close()
864
+ except Exception:
865
+ pass
866
+ os.unlink(temp_path)
867
+
868
+ return result
869
+
870
+ def convert_url(self, url: str, **kwargs: Any) -> DocumentConverterResult: # TODO: fix kwargs type
871
+ # Send a HTTP request to the URL
872
+ 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"
873
+ response = self._requests_session.get(url, stream=True, headers={"User-Agent": user_agent})
874
+ response.raise_for_status()
875
+ return self.convert_response(response, **kwargs)
876
+
877
+ def convert_response(
878
+ self, response: requests.Response, **kwargs: Any
879
+ ) -> DocumentConverterResult: # TODO fix kwargs type
880
+ # Prepare a list of extensions to try (in order of priority)
881
+ ext = kwargs.get("file_extension")
882
+ extensions = [ext] if ext is not None else []
883
+
884
+ # Guess from the mimetype
885
+ content_type = response.headers.get("content-type", "").split(";")[0]
886
+ self._append_ext(extensions, mimetypes.guess_extension(content_type))
887
+
888
+ # Read the content disposition if there is one
889
+ content_disposition = response.headers.get("content-disposition", "")
890
+ m = re.search(r"filename=([^;]+)", content_disposition)
891
+ if m:
892
+ base, ext = os.path.splitext(m.group(1).strip("\"'"))
893
+ self._append_ext(extensions, ext)
894
+
895
+ # Read from the extension from the path
896
+ base, ext = os.path.splitext(urlparse(response.url).path)
897
+ self._append_ext(extensions, ext)
898
+
899
+ # Save the file locally to a temporary file. It will be deleted before this method exits
900
+ handle, temp_path = tempfile.mkstemp()
901
+ fh = os.fdopen(handle, "wb")
902
+ result = None
903
+ try:
904
+ # Download the file
905
+ for chunk in response.iter_content(chunk_size=512):
906
+ fh.write(chunk)
907
+ fh.close()
908
+
909
+ # Use puremagic to check for more extension options
910
+ self._append_ext(extensions, self._guess_ext_magic(temp_path))
911
+
912
+ # Convert
913
+ result = self._convert(temp_path, extensions, url=response.url)
914
+ except Exception as e:
915
+ print(f"Error in converting: {e}")
916
+
917
+ # Clean up
918
+ finally:
919
+ try:
920
+ fh.close()
921
+ except Exception:
922
+ pass
923
+ os.unlink(temp_path)
924
+
925
+ return result
926
+
927
+ def _convert(self, local_path: str, extensions: list[str | None], **kwargs) -> DocumentConverterResult:
928
+ error_trace = ""
929
+ for ext in extensions + [None]: # Try last with no extension
930
+ for converter in self._page_converters:
931
+ _kwargs = copy.deepcopy(kwargs)
932
+
933
+ # Overwrite file_extension appropriately
934
+ if ext is None:
935
+ if "file_extension" in _kwargs:
936
+ del _kwargs["file_extension"]
937
+ else:
938
+ _kwargs.update({"file_extension": ext})
939
+
940
+ # Copy any additional global options
941
+ if "mlm_client" not in _kwargs and self._mlm_client is not None:
942
+ _kwargs["mlm_client"] = self._mlm_client
943
+
944
+ if "mlm_model" not in _kwargs and self._mlm_model is not None:
945
+ _kwargs["mlm_model"] = self._mlm_model
946
+
947
+ # If we hit an error log it and keep trying
948
+ try:
949
+ res = converter.convert(local_path, **_kwargs)
950
+ except Exception:
951
+ error_trace = ("\n\n" + traceback.format_exc()).strip()
952
+
953
+ if res is not None:
954
+ # Normalize the content
955
+ res.text_content = "\n".join([line.rstrip() for line in re.split(r"\r?\n", res.text_content)])
956
+ res.text_content = re.sub(r"\n{3,}", "\n\n", res.text_content)
957
+
958
+ # Todo
959
+ return res
960
+
961
+ # If we got this far without success, report any exceptions
962
+ if len(error_trace) > 0:
963
+ raise FileConversionException(
964
+ 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}"
965
+ )
966
+
967
+ # Nothing can handle it!
968
+ raise UnsupportedFormatException(
969
+ f"Could not convert '{local_path}' to Markdown. The formats {extensions} are not supported."
970
+ )
971
+
972
+ def _append_ext(self, extensions, ext):
973
+ """Append a unique non-None, non-empty extension to a list of extensions."""
974
+ if ext is None:
975
+ return
976
+ ext = ext.strip()
977
+ if ext == "":
978
+ return
979
+ # if ext not in extensions:
980
+ if True:
981
+ extensions.append(ext)
982
+
983
+ def _guess_ext_magic(self, path):
984
+ """Use puremagic (a Python implementation of libmagic) to guess a file's extension based on the first few bytes."""
985
+ # Use puremagic to guess
986
+ try:
987
+ guesses = puremagic.magic_file(path)
988
+ if len(guesses) > 0:
989
+ ext = guesses[0].extension.strip()
990
+ if len(ext) > 0:
991
+ return ext
992
+ except FileNotFoundError:
993
+ pass
994
+ except IsADirectoryError:
995
+ pass
996
+ except PermissionError:
997
+ pass
998
+ return None
999
+
1000
+ def register_page_converter(self, converter: DocumentConverter) -> None:
1001
+ """Register a page text converter."""
1002
+ self._page_converters.insert(0, converter)
tools/text_inspector_tool.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from smolagents import Tool
2
+ from smolagents.models import Model
3
+
4
+
5
+ class TextInspectorTool(Tool):
6
+ name = "inspect_file_as_text"
7
+ description = """
8
+ You cannot load files yourself: instead call this tool to read a file as markdown text and ask questions about it.
9
+ This tool handles the following file extensions: [".html", ".htm", ".xlsx", ".pptx", ".wav", ".mp3", ".m4a", ".flac", ".pdf", ".docx"], and all other types of text files. IT DOES NOT HANDLE IMAGES."""
10
+
11
+ inputs = {
12
+ "file_path": {
13
+ "description": "The path to the file you want to read as text. Must be a '.something' file, like '.pdf'. If it is an image, use the visualizer tool instead! DO NOT use this tool for an HTML webpage: use the web_search tool instead!",
14
+ "type": "string",
15
+ },
16
+ "question": {
17
+ "description": "[Optional]: Your question, as a natural language sentence. Provide as much context as possible. Do not pass this parameter if you just want to directly return the content of the file.",
18
+ "type": "string",
19
+ "nullable": True,
20
+ },
21
+ }
22
+ output_type = "string"
23
+
24
+ def __init__(self, model: Model = None, text_limit: int = 100000):
25
+ super().__init__()
26
+ self.model = model
27
+ self.text_limit = text_limit
28
+ from .mdconvert import MarkdownConverter
29
+
30
+ self.md_converter = MarkdownConverter()
31
+
32
+ def forward_initial_exam_mode(self, file_path, question):
33
+ from smolagents.models import MessageRole
34
+
35
+ result = self.md_converter.convert(file_path)
36
+
37
+ if file_path[-4:] in [".png", ".jpg"]:
38
+ raise Exception("Cannot use inspect_file_as_text tool with images: use visualizer instead!")
39
+
40
+ if ".zip" in file_path:
41
+ return result.text_content
42
+
43
+ if not question:
44
+ return result.text_content
45
+
46
+ if len(result.text_content) < 4000:
47
+ return "Document content: " + result.text_content
48
+
49
+ messages = [
50
+ {
51
+ "role": MessageRole.SYSTEM,
52
+ "content": [
53
+ {
54
+ "type": "text",
55
+ "text": "Here is a file:\n### "
56
+ + str(result.title)
57
+ + "\n\n"
58
+ + result.text_content[: self.text_limit],
59
+ }
60
+ ],
61
+ },
62
+ {
63
+ "role": MessageRole.USER,
64
+ "content": [
65
+ {
66
+ "type": "text",
67
+ "text": "Now please write a short, 5 sentence caption for this document, that could help someone asking this question: "
68
+ + question
69
+ + "\n\nDon't answer the question yourself! Just provide useful notes on the document",
70
+ }
71
+ ],
72
+ },
73
+ ]
74
+ return self.model(messages).content
75
+
76
+ def forward(self, file_path, question: str | None = None) -> str:
77
+ from smolagents.models import MessageRole
78
+
79
+ result = self.md_converter.convert(file_path)
80
+
81
+ if file_path[-4:] in [".png", ".jpg"]:
82
+ raise Exception("Cannot use inspect_file_as_text tool with images: use visualizer instead!")
83
+
84
+ if ".zip" in file_path:
85
+ return result.text_content
86
+
87
+ if not question:
88
+ return result.text_content
89
+
90
+ messages = [
91
+ {
92
+ "role": MessageRole.SYSTEM,
93
+ "content": [
94
+ {
95
+ "type": "text",
96
+ "text": "You will have to write a short caption for this file, then answer this question:"
97
+ + question,
98
+ }
99
+ ],
100
+ },
101
+ {
102
+ "role": MessageRole.USER,
103
+ "content": [
104
+ {
105
+ "type": "text",
106
+ "text": "Here is the complete file:\n### "
107
+ + str(result.title)
108
+ + "\n\n"
109
+ + result.text_content[: self.text_limit],
110
+ }
111
+ ],
112
+ },
113
+ {
114
+ "role": MessageRole.USER,
115
+ "content": [
116
+ {
117
+ "type": "text",
118
+ "text": "Now answer the question below. Use these three headings: '1. Short answer', '2. Extremely detailed answer', '3. Additional Context on the document and question asked'."
119
+ + question,
120
+ }
121
+ ],
122
+ },
123
+ ]
124
+ return self.model(messages).content