alhajjaj commited on
Commit
bd51c06
·
verified ·
1 Parent(s): 67327ac

Upload folder using huggingface_hub

Browse files
Files changed (41) hide show
  1. warraqa/__init__.py +20 -0
  2. warraqa/__pycache__/__init__.cpython-313.pyc +0 -0
  3. warraqa/__pycache__/__init__.cpython-314.pyc +0 -0
  4. warraqa/__pycache__/agent.cpython-313.pyc +0 -0
  5. warraqa/__pycache__/agent.cpython-314.pyc +0 -0
  6. warraqa/__pycache__/cli.cpython-313.pyc +0 -0
  7. warraqa/__pycache__/models.cpython-313.pyc +0 -0
  8. warraqa/__pycache__/models.cpython-314.pyc +0 -0
  9. warraqa/__pycache__/utils.cpython-313.pyc +0 -0
  10. warraqa/__pycache__/utils.cpython-314.pyc +0 -0
  11. warraqa/agent.py +801 -0
  12. warraqa/cli.py +150 -0
  13. warraqa/engines/__init__.py +5 -0
  14. warraqa/engines/__pycache__/__init__.cpython-313.pyc +0 -0
  15. warraqa/engines/__pycache__/__init__.cpython-314.pyc +0 -0
  16. warraqa/engines/__pycache__/base.cpython-313.pyc +0 -0
  17. warraqa/engines/__pycache__/base.cpython-314.pyc +0 -0
  18. warraqa/engines/__pycache__/docx_markitdown.cpython-313.pyc +0 -0
  19. warraqa/engines/__pycache__/docx_markitdown.cpython-314.pyc +0 -0
  20. warraqa/engines/__pycache__/docx_pandoc.cpython-313.pyc +0 -0
  21. warraqa/engines/__pycache__/docx_pandoc.cpython-314.pyc +0 -0
  22. warraqa/engines/__pycache__/office_com.cpython-313.pyc +0 -0
  23. warraqa/engines/__pycache__/office_markitdown.cpython-313.pyc +0 -0
  24. warraqa/engines/__pycache__/pdf_marker.cpython-313.pyc +0 -0
  25. warraqa/engines/__pycache__/pdf_marker.cpython-314.pyc +0 -0
  26. warraqa/engines/__pycache__/pdf_pymupdf.cpython-313.pyc +0 -0
  27. warraqa/engines/__pycache__/pdf_pymupdf.cpython-314.pyc +0 -0
  28. warraqa/engines/base.py +141 -0
  29. warraqa/engines/docx_pandoc.py +181 -0
  30. warraqa/engines/office_com.py +194 -0
  31. warraqa/engines/office_markitdown.py +144 -0
  32. warraqa/engines/pdf_marker.py +179 -0
  33. warraqa/engines/pdf_pymupdf.py +123 -0
  34. warraqa/models.py +158 -0
  35. warraqa/scoring/__init__.py +5 -0
  36. warraqa/scoring/__pycache__/__init__.cpython-313.pyc +0 -0
  37. warraqa/scoring/__pycache__/__init__.cpython-314.pyc +0 -0
  38. warraqa/scoring/__pycache__/scorer.cpython-313.pyc +0 -0
  39. warraqa/scoring/__pycache__/scorer.cpython-314.pyc +0 -0
  40. warraqa/scoring/scorer.py +276 -0
  41. warraqa/utils.py +310 -0
warraqa/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Warraqa (ورّاقة) — The Document Scribe Agent
3
+
4
+ Named after the professional scribes and paper-makers of the Islamic Golden Age,
5
+ Warraqa is a specialized agent whose sole mission is converting PDF and Word
6
+ documents into clean, accurate Markdown with surgical precision.
7
+
8
+ She reads. She transcribes. She scores her own work.
9
+ """
10
+
11
+ __version__ = "1.0.0"
12
+
13
+ AGENT_NAME = "Warraqa"
14
+ AGENT_NAME_AR = "ورّاقة"
15
+ AGENT_DESCRIPTION = "Document-to-Markdown conversion specialist"
16
+ AGENT_IDENTITY = f"{AGENT_NAME} ({AGENT_NAME_AR}) v{__version__} — {AGENT_DESCRIPTION}"
17
+
18
+ from warraqa.agent import Warraqa # noqa: E402, F401
19
+
20
+ __all__ = ["Warraqa", "AGENT_NAME", "AGENT_IDENTITY", "__version__"]
warraqa/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (863 Bytes). View file
 
warraqa/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (855 Bytes). View file
 
warraqa/__pycache__/agent.cpython-313.pyc ADDED
Binary file (33.5 kB). View file
 
warraqa/__pycache__/agent.cpython-314.pyc ADDED
Binary file (32.2 kB). View file
 
warraqa/__pycache__/cli.cpython-313.pyc ADDED
Binary file (5.95 kB). View file
 
warraqa/__pycache__/models.cpython-313.pyc ADDED
Binary file (8.68 kB). View file
 
warraqa/__pycache__/models.cpython-314.pyc ADDED
Binary file (10.3 kB). View file
 
warraqa/__pycache__/utils.cpython-313.pyc ADDED
Binary file (11.4 kB). View file
 
warraqa/__pycache__/utils.cpython-314.pyc ADDED
Binary file (12.2 kB). View file
 
warraqa/agent.py ADDED
@@ -0,0 +1,801 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Warraqa Agent — Core Orchestrator
3
+
4
+ The brain of the Warraqa agent. Handles file discovery, engine selection,
5
+ conversion with fallback chains, quality scoring, output saving, and
6
+ file movement. Designed for both CLI use and programmatic inter-agent calls.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import time
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from warraqa.engines.base import BaseEngine
18
+ from warraqa.engines.docx_pandoc import PandocEngine
19
+ from warraqa.engines.office_com import OfficeCOMConverter
20
+ from warraqa.engines.office_markitdown import MarkItDownEngine
21
+ from warraqa.engines.pdf_marker import MarkerPDFEngine
22
+ from warraqa.engines.pdf_pymupdf import PyMuPDFEngine
23
+ from warraqa.models import BatchReport, ConversionResult, FileInfo
24
+ from warraqa.scoring.scorer import ConversionScorer
25
+ from warraqa.utils import (
26
+ PDFAnalyzer,
27
+ detect_file_type,
28
+ ensure_directories,
29
+ find_supported_files,
30
+ format_file_size,
31
+ get_mime_type,
32
+ open_folder_picker,
33
+ safe_move,
34
+ sanitize_filename,
35
+ sanitize_markdown,
36
+ setup_logging,
37
+ )
38
+
39
+ logger = logging.getLogger("warraqa")
40
+
41
+ # Default config
42
+ DEFAULT_CONFIG = {
43
+ "mode": "manual",
44
+ "watch": {"input_folder": None, "poll_interval_seconds": 10},
45
+ "output": {
46
+ "base_dir": "./output",
47
+ "md_files": "md_files",
48
+ "processed": "processed",
49
+ "failed": "failed",
50
+ "reports": "reports",
51
+ "scanned_pdfs": "scanned_pdfs",
52
+ },
53
+ "engines": {
54
+ "pdf_primary": "marker",
55
+ "pdf_fallback": "pymupdf",
56
+ "docx_primary": "markitdown",
57
+ "docx_fallback": "pandoc",
58
+ "pptx_primary": "markitdown",
59
+ "pptx_fallback": None,
60
+ },
61
+ "scoring": {"fail_threshold": 40, "warn_threshold": 70},
62
+ "logging": {"level": "INFO", "console": True, "file": None},
63
+ }
64
+
65
+
66
+ class Warraqa:
67
+ """
68
+ The Document Scribe Agent.
69
+
70
+ Converts PDF and Word documents to clean Markdown with quality scoring.
71
+
72
+ Usage:
73
+ agent = Warraqa()
74
+ result = agent.convert_file("path/to/document.pdf")
75
+ results = agent.process_folder("path/to/input/folder")
76
+ """
77
+
78
+ def __init__(self, config_path: str | Path | None = None, native_engine: str = "pymupdf"):
79
+ """
80
+ Initialize Warraqa with configuration.
81
+
82
+ Args:
83
+ config_path: Path to config.yaml. If None, uses defaults and
84
+ looks for config.yaml in the package's parent directory.
85
+ """
86
+ self._config = self._load_config(config_path)
87
+ self._native_engine = native_engine
88
+ self._setup_logging()
89
+ self._output_base = Path(self._config["output"]["base_dir"]).resolve()
90
+ self._init_output_dirs()
91
+ self._engines = self._init_engines()
92
+ self._office_com = OfficeCOMConverter()
93
+ self._scorer = ConversionScorer(
94
+ fail_threshold=self._config["scoring"]["fail_threshold"],
95
+ warn_threshold=self._config["scoring"]["warn_threshold"],
96
+ )
97
+ if self._office_com.is_available():
98
+ logger.debug("Office COM pre-converter available (legacy .doc/.ppt support)")
99
+ else:
100
+ logger.warning(
101
+ "Office COM pre-converter unavailable — install pywin32 + MS Office "
102
+ "to support legacy .doc and .ppt files"
103
+ )
104
+ logger.info("Warraqa (ورّاقة) initialized — The Document Scribe is ready")
105
+
106
+ # --- Public API ---
107
+
108
+ def convert_file(
109
+ self,
110
+ file_path: str | Path,
111
+ *,
112
+ force_ocr: bool = False,
113
+ defer_pdf_fallback: bool = False,
114
+ ) -> ConversionResult:
115
+ """
116
+ Convert a single file to Markdown.
117
+
118
+ This is the primary API for other agents to call.
119
+
120
+ Args:
121
+ file_path: Path to the document file (PDF, DOCX, DOC).
122
+ force_ocr: If True, bypass PDF native-text analysis and force the
123
+ OCR engine (Marker) as primary. Used by the Phase 2 deferred
124
+ OCR pass for files that were already classified scanned or
125
+ that failed PyMuPDF extraction in Phase 1.
126
+ defer_pdf_fallback: If True, when a native-PDF PyMuPDF run
127
+ produces a poor result, return immediately with
128
+ `defer_to_ocr=True` instead of falling back to Marker inline.
129
+ Used by Phase 1 of `process_folder` to keep the fast pass
130
+ fast — the caller is expected to stage the file for Phase 2.
131
+
132
+ Returns:
133
+ ConversionResult with markdown content, score, and metadata.
134
+ """
135
+ file_path = Path(file_path).resolve()
136
+ start_time = time.time()
137
+
138
+ # Build file info
139
+ file_info = self._build_file_info(file_path)
140
+ if file_info is None:
141
+ return ConversionResult(
142
+ source_file=FileInfo(
143
+ path=file_path, file_type="unknown",
144
+ size_bytes=0, mime_type="unknown",
145
+ ),
146
+ error=f"Unsupported or undetectable file type: {file_path.suffix}",
147
+ )
148
+
149
+ logger.info(
150
+ f"Converting: [bold]{file_info.filename}[/bold] "
151
+ f"({file_info.file_type.upper()}, {format_file_size(file_info.size_bytes)})",
152
+ extra={"markup": True},
153
+ )
154
+
155
+ # Pre-convert legacy binary formats (.doc, .ppt) to modern .docx/.pptx
156
+ # via MS Office COM. The original FileInfo is kept so reports, moves,
157
+ # and the .md filename reflect the user's actual file.
158
+ original_file_info = file_info
159
+ working_info, temp_to_cleanup, preconvert_error = self._maybe_preconvert_legacy(file_info)
160
+
161
+ if preconvert_error is not None:
162
+ result = ConversionResult(
163
+ source_file=original_file_info,
164
+ error=preconvert_error,
165
+ engine_used="OfficeCOM",
166
+ )
167
+ else:
168
+ try:
169
+ result = self._convert_with_fallback(
170
+ working_info,
171
+ force_ocr=force_ocr,
172
+ defer_pdf_fallback=defer_pdf_fallback,
173
+ )
174
+ finally:
175
+ if temp_to_cleanup is not None:
176
+ try:
177
+ temp_to_cleanup.unlink(missing_ok=True)
178
+ except Exception as e:
179
+ logger.debug(f"Temp cleanup failed for {temp_to_cleanup}: {e}")
180
+ # Restore original FileInfo so output/reporting reflect the user's file
181
+ result.source_file = original_file_info
182
+
183
+ result.processing_time_seconds = time.time() - start_time
184
+
185
+ # Log result
186
+ grade_color = {"A": "green", "B": "blue", "C": "yellow", "D": "red", "F": "bold red"}
187
+ color = grade_color.get(result.grade, "white")
188
+ if result.success:
189
+ logger.info(
190
+ f"Result: [{color}]{result.grade}[/{color}] "
191
+ f"({result.confidence_score}/100) — "
192
+ f"{result.engine_used} engine — "
193
+ f"{result.processing_time_seconds:.1f}s",
194
+ extra={"markup": True},
195
+ )
196
+ else:
197
+ logger.error(
198
+ f"FAILED: [{color}]{result.grade}[/{color}] "
199
+ f"({result.confidence_score}/100) — {result.error}",
200
+ extra={"markup": True},
201
+ )
202
+
203
+ if result.warnings:
204
+ for w in result.warnings:
205
+ logger.warning(f" ⚠ {w}")
206
+
207
+ return result
208
+
209
+ def process_folder(
210
+ self,
211
+ input_dir: str | Path | None = None,
212
+ save_output: bool = True,
213
+ move_files: bool = True,
214
+ ) -> list[ConversionResult]:
215
+ """
216
+ Process all supported files in a folder.
217
+
218
+ Args:
219
+ input_dir: Path to the input folder. If None, opens a folder picker.
220
+ save_output: Whether to save .md files and reports to output/.
221
+ move_files: Whether to move originals to processed/failed folders.
222
+
223
+ Returns:
224
+ List of ConversionResult objects.
225
+ """
226
+ # Resolve input directory
227
+ if input_dir is None:
228
+ logger.info("Opening folder picker...")
229
+ input_dir = open_folder_picker("Select Input Folder for Warraqa")
230
+ if input_dir is None:
231
+ logger.warning("No folder selected. Aborting.")
232
+ return []
233
+ input_dir = Path(input_dir).resolve()
234
+
235
+ if not input_dir.is_dir():
236
+ logger.error(f"Not a valid directory: {input_dir}")
237
+ return []
238
+
239
+ # Find supported files
240
+ files = find_supported_files(input_dir)
241
+ if not files:
242
+ logger.info(f"No supported files found in: {input_dir}")
243
+ return []
244
+
245
+ logger.info(
246
+ f"Found [bold]{len(files)}[/bold] file(s) in: {input_dir}",
247
+ extra={"markup": True},
248
+ )
249
+
250
+ # --- Pre-pass: triage PDFs into native vs scanned ---
251
+ # Scanned PDFs are deferred to the end (OCR is slow); native PDFs and
252
+ # Word docs run first. Scanned PDFs are physically moved to the staging
253
+ # folder so they're out of the input set during phase 1.
254
+ fast_queue: list[Path] = []
255
+ scanned_queue: list[Path] = []
256
+ for file_path in files:
257
+ if file_path.suffix.lower() == ".pdf" and not PDFAnalyzer.is_native_text(file_path):
258
+ staged_path = safe_move(file_path, self._scanned_dir)
259
+ scanned_queue.append(staged_path)
260
+ else:
261
+ fast_queue.append(file_path)
262
+
263
+ if scanned_queue:
264
+ logger.info(
265
+ f"Triage: [bold]{len(fast_queue)}[/bold] fast file(s), "
266
+ f"[bold]{len(scanned_queue)}[/bold] scanned PDF(s) staged to "
267
+ f"[dim]{self._scanned_dir.name}/[/dim] for deferred OCR",
268
+ extra={"markup": True},
269
+ )
270
+
271
+ report = BatchReport(start_time=datetime.now())
272
+ deferred_in_phase1 = 0
273
+
274
+ def _run(
275
+ queue: list[Path],
276
+ phase_label: str,
277
+ *,
278
+ defer_pdf_fallback: bool = False,
279
+ force_ocr: bool = False,
280
+ ) -> None:
281
+ nonlocal deferred_in_phase1
282
+ for i, file_path in enumerate(queue, 1):
283
+ logger.info(f"\n{'='*60}")
284
+ logger.info(f"{phase_label} — file {i}/{len(queue)}")
285
+ logger.info(f"{'='*60}")
286
+
287
+ result = self.convert_file(
288
+ file_path,
289
+ force_ocr=force_ocr,
290
+ defer_pdf_fallback=defer_pdf_fallback,
291
+ )
292
+
293
+ # If Phase 1 chose to defer this file to OCR, stage it and skip
294
+ # save/move/report — Phase 2 will produce the final result.
295
+ if result.defer_to_ocr:
296
+ staged = safe_move(file_path, self._scanned_dir)
297
+ scanned_queue.append(staged)
298
+ deferred_in_phase1 += 1
299
+ logger.info(
300
+ f" → Staged for Phase 2 OCR: "
301
+ f"[dim]{self._scanned_dir.name}/{staged.name}[/dim]",
302
+ extra={"markup": True},
303
+ )
304
+ continue
305
+
306
+ if save_output and result.markdown_content:
307
+ self._save_output(result)
308
+
309
+ if move_files:
310
+ self._move_original(result)
311
+
312
+ if save_output:
313
+ self._save_report(result)
314
+
315
+ report.results.append(result)
316
+
317
+ # Phase 1: native PDFs + Word docs
318
+ # `defer_pdf_fallback=True` keeps the fast pass fast: any native PDF
319
+ # that PyMuPDF can't extract from is staged to Phase 2 instead of
320
+ # triggering an inline Marker call.
321
+ if fast_queue:
322
+ _run(fast_queue, "Phase 1 (fast)", defer_pdf_fallback=True)
323
+
324
+ # Phase 2: deferred OCR for staged scanned PDFs
325
+ # `force_ocr=True` skips the native-text re-analysis (it was either
326
+ # already classified scanned in triage, or got deferred from Phase 1
327
+ # because PyMuPDF couldn't extract text — both cases want Marker).
328
+ if scanned_queue:
329
+ logger.info(f"\n{'#'*60}")
330
+ logger.info(
331
+ f"Phase 2 (OCR): processing {len(scanned_queue)} scanned PDF(s) "
332
+ f"({deferred_in_phase1} deferred from Phase 1)"
333
+ )
334
+ logger.info(f"{'#'*60}")
335
+ _run(scanned_queue, "Phase 2 (OCR)", force_ocr=True)
336
+
337
+ report.end_time = datetime.now()
338
+
339
+ # Print batch summary
340
+ self._print_batch_summary(report)
341
+
342
+ return report.results
343
+
344
+ def watch_folder(
345
+ self,
346
+ input_dir: str | Path | None = None,
347
+ interval: int | None = None,
348
+ ) -> None:
349
+ """
350
+ Watch mode: continuously monitor a folder for new files.
351
+
352
+ Args:
353
+ input_dir: Folder to watch. Uses config if None.
354
+ interval: Poll interval in seconds. Uses config if None.
355
+ """
356
+ if input_dir is None:
357
+ input_dir = self._config["watch"].get("input_folder")
358
+ if input_dir is None:
359
+ logger.info("No watch folder configured. Opening picker...")
360
+ input_dir = open_folder_picker("Select Folder to Watch")
361
+ if input_dir is None:
362
+ logger.warning("No folder selected. Aborting watch mode.")
363
+ return
364
+
365
+ input_dir = Path(input_dir).resolve()
366
+ if interval is None:
367
+ interval = self._config["watch"].get("poll_interval_seconds", 10)
368
+
369
+ logger.info(
370
+ f"Watch mode: monitoring [bold]{input_dir}[/bold] "
371
+ f"every {interval}s (Ctrl+C to stop)",
372
+ extra={"markup": True},
373
+ )
374
+
375
+ processed_files: set[str] = set()
376
+
377
+ try:
378
+ while True:
379
+ files = find_supported_files(input_dir)
380
+ new_files = [
381
+ f for f in files if str(f) not in processed_files
382
+ ]
383
+
384
+ if new_files:
385
+ logger.info(f"Found {len(new_files)} new file(s)")
386
+ for file_path in new_files:
387
+ result = self.convert_file(file_path)
388
+ if result.markdown_content:
389
+ self._save_output(result)
390
+ self._move_original(result)
391
+ self._save_report(result)
392
+ processed_files.add(str(file_path))
393
+
394
+ time.sleep(interval)
395
+
396
+ except KeyboardInterrupt:
397
+ logger.info("\nWatch mode stopped by user.")
398
+
399
+ # --- Internal Methods ---
400
+
401
+ def _load_config(self, config_path: str | Path | None) -> dict:
402
+ """Load configuration from YAML file, falling back to defaults."""
403
+ config = DEFAULT_CONFIG.copy()
404
+
405
+ if config_path is None:
406
+ # Look for config.yaml relative to this package
407
+ pkg_dir = Path(__file__).parent.parent
408
+ config_path = pkg_dir / "config.yaml"
409
+
410
+ config_path = Path(config_path)
411
+ if config_path.is_file():
412
+ try:
413
+ import yaml
414
+ with open(config_path, encoding="utf-8") as f:
415
+ file_config = yaml.safe_load(f) or {}
416
+ config = self._deep_merge(config, file_config)
417
+ except Exception as e:
418
+ logger.warning(f"Failed to load config from {config_path}: {e}")
419
+
420
+ return config
421
+
422
+ def _deep_merge(self, base: dict, override: dict) -> dict:
423
+ """Deep merge override dict into base dict."""
424
+ result = base.copy()
425
+ for key, value in override.items():
426
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
427
+ result[key] = self._deep_merge(result[key], value)
428
+ else:
429
+ result[key] = value
430
+ return result
431
+
432
+ def _setup_logging(self) -> None:
433
+ """Configure logging from config."""
434
+ log_config = self._config.get("logging", {})
435
+ setup_logging(
436
+ level=log_config.get("level", "INFO"),
437
+ log_file=log_config.get("file"),
438
+ )
439
+
440
+ def _init_output_dirs(self) -> None:
441
+ """Create output directory structure."""
442
+ out = self._config["output"]
443
+ self._md_dir = self._output_base / out["md_files"]
444
+ self._processed_dir = self._output_base / out["processed"]
445
+ self._failed_dir = self._output_base / out["failed"]
446
+ self._reports_dir = self._output_base / out["reports"]
447
+ self._scanned_dir = self._output_base / out.get("scanned_pdfs", "scanned_pdfs")
448
+ ensure_directories(
449
+ self._md_dir, self._processed_dir, self._failed_dir,
450
+ self._reports_dir, self._scanned_dir,
451
+ )
452
+
453
+ def _init_engines(self) -> dict[str, dict[str, BaseEngine | None]]:
454
+ """Initialize conversion engines based on config."""
455
+ engine_config = self._config["engines"]
456
+
457
+ engine_map: dict[str, BaseEngine] = {
458
+ "marker": MarkerPDFEngine(),
459
+ "pymupdf": PyMuPDFEngine(),
460
+ "markitdown": MarkItDownEngine(),
461
+ "pandoc": PandocEngine(),
462
+ }
463
+
464
+ def _resolve(key: str, default: str | None) -> BaseEngine | None:
465
+ name = engine_config.get(key, default)
466
+ return engine_map.get(name) if name else None
467
+
468
+ engines = {
469
+ "pdf": {
470
+ "primary": _resolve("pdf_primary", "marker"),
471
+ "fallback": _resolve("pdf_fallback", "pymupdf"),
472
+ },
473
+ "docx": {
474
+ "primary": _resolve("docx_primary", "markitdown"),
475
+ "fallback": _resolve("docx_fallback", "pandoc"),
476
+ },
477
+ "pptx": {
478
+ "primary": _resolve("pptx_primary", "markitdown"),
479
+ # Pandoc cannot read .pptx — no native fallback by default.
480
+ "fallback": _resolve("pptx_fallback", None),
481
+ },
482
+ }
483
+ # Legacy formats route to their modern counterpart's engines —
484
+ # the OfficeCOMConverter rewrites file_info.file_type upstream,
485
+ # but we keep these aliases as a safety net.
486
+ engines["doc"] = engines["docx"]
487
+ engines["ppt"] = engines["pptx"]
488
+
489
+ # Log engine availability
490
+ for name, engine in engine_map.items():
491
+ status = "✓ available" if engine.is_available() else "✗ not installed"
492
+ logger.debug(f"Engine {name}: {status}")
493
+
494
+ return engines
495
+
496
+ def _build_file_info(self, file_path: Path) -> FileInfo | None:
497
+ """Build FileInfo from a file path. Returns None if unsupported."""
498
+ file_type = detect_file_type(file_path)
499
+ if file_type is None:
500
+ return None
501
+
502
+ return FileInfo(
503
+ path=file_path,
504
+ file_type=file_type,
505
+ size_bytes=file_path.stat().st_size,
506
+ mime_type=get_mime_type(file_path),
507
+ )
508
+
509
+ def _maybe_preconvert_legacy(
510
+ self, file_info: FileInfo
511
+ ) -> tuple[FileInfo, Path | None, str | None]:
512
+ """
513
+ Pre-convert legacy binary Office files (.doc, .ppt) to modern XML
514
+ (.docx, .pptx) via MS Office COM. Returns:
515
+ (working_file_info, temp_path_to_cleanup, error_message)
516
+
517
+ - For non-legacy formats: returns the original FileInfo unchanged.
518
+ - For legacy formats with COM available: returns a FileInfo pointing
519
+ at the temp converted file, plus the temp path for later cleanup.
520
+ - For legacy formats when COM is unavailable or conversion fails:
521
+ returns the original FileInfo plus a descriptive error message.
522
+ The caller should treat this as a hard failure rather than calling
523
+ the engines (MarkItDown and Pandoc both choke on the binary format).
524
+ """
525
+ ftype = file_info.file_type
526
+ if ftype not in ("doc", "ppt"):
527
+ return file_info, None, None
528
+
529
+ if not self._office_com.is_available():
530
+ return file_info, None, (
531
+ f"Legacy {ftype.upper()} format requires MS Office COM "
532
+ f"(pywin32 + Word/PowerPoint) — not available on this system"
533
+ )
534
+
535
+ try:
536
+ if ftype == "doc":
537
+ converted = self._office_com.convert_doc_to_docx(file_info.path)
538
+ new_type, new_mime = "docx", (
539
+ "application/vnd.openxmlformats-officedocument."
540
+ "wordprocessingml.document"
541
+ )
542
+ else: # ppt
543
+ converted = self._office_com.convert_ppt_to_pptx(file_info.path)
544
+ new_type, new_mime = "pptx", (
545
+ "application/vnd.openxmlformats-officedocument."
546
+ "presentationml.presentation"
547
+ )
548
+ except Exception as e:
549
+ return file_info, None, f"Legacy {ftype.upper()} preconvert failed: {e}"
550
+
551
+ working = FileInfo(
552
+ path=converted,
553
+ file_type=new_type,
554
+ size_bytes=converted.stat().st_size,
555
+ mime_type=new_mime,
556
+ filename=file_info.filename, # keep original filename for reporting
557
+ )
558
+ return working, converted, None
559
+
560
+ def _convert_with_fallback(
561
+ self,
562
+ file_info: FileInfo,
563
+ *,
564
+ force_ocr: bool = False,
565
+ defer_pdf_fallback: bool = False,
566
+ ) -> ConversionResult:
567
+ """
568
+ Try primary engine, fall back if failure or very low score.
569
+ Keeps the best result.
570
+
571
+ If `defer_pdf_fallback` and the primary PDF engine is PyMuPDF and it
572
+ produces a poor result, skip the inline Marker fallback and mark the
573
+ result with `defer_to_ocr=True` so the batch orchestrator can stage
574
+ the file for the Phase 2 OCR pass.
575
+ """
576
+ engine_set = self._engines.get(file_info.file_type)
577
+ if not engine_set:
578
+ return ConversionResult(
579
+ source_file=file_info,
580
+ error=f"No engines configured for file type: {file_info.file_type}",
581
+ )
582
+
583
+ primary: BaseEngine | None = engine_set.get("primary")
584
+ fallback: BaseEngine | None = engine_set.get("fallback")
585
+
586
+ # --- Pre-scan Document (Dynamic Engine Selection) ---
587
+ if file_info.file_type == "pdf":
588
+ if force_ocr:
589
+ logger.info("PDF: force_ocr=True — using Marker (OCR) as primary.")
590
+ primary = self._engines["pdf"]["primary"] # Marker
591
+ fallback = self._engines["pdf"]["fallback"] # PyMuPDF
592
+ else:
593
+ is_native = PDFAnalyzer.is_native_text(file_info.path)
594
+ if is_native:
595
+ logger.info(f"PDF is NATIVE TEXT. Using '{self._native_engine}' for speed/quality tradeoff.")
596
+ if self._native_engine == "pymupdf":
597
+ primary = self._engines["pdf"]["fallback"] # PyMuPDF
598
+ fallback = self._engines["pdf"]["primary"] # Marker
599
+ else:
600
+ primary = self._engines["pdf"]["primary"] # Marker
601
+ fallback = self._engines["pdf"]["fallback"] # PyMuPDF
602
+ else:
603
+ logger.info("PDF is SCANNED IMAGE. Forcing 'marker' engine for OCR.")
604
+ primary = self._engines["pdf"]["primary"] # Marker
605
+ fallback = self._engines["pdf"]["fallback"] # PyMuPDF
606
+
607
+
608
+ # Try primary engine
609
+ result = self._try_engine(primary, file_info)
610
+
611
+ fail_threshold = self._config["scoring"]["fail_threshold"]
612
+ primary_poor = result.error or result.confidence_score < fail_threshold
613
+
614
+ # --- Phase 1 deferral: skip inline Marker fallback for poor PyMuPDF ---
615
+ # When called from the batch fast-pass, a poor native-PDF result should
616
+ # not stall Phase 1 by invoking the slow OCR engine. Stage it instead.
617
+ if (
618
+ defer_pdf_fallback
619
+ and file_info.file_type == "pdf"
620
+ and primary is self._engines["pdf"]["fallback"] # primary was PyMuPDF
621
+ and primary_poor
622
+ ):
623
+ logger.info(
624
+ f"PyMuPDF gave a poor result "
625
+ f"(score={result.confidence_score}, words={len(result.markdown_content.split())}). "
626
+ f"Deferring to Phase 2 OCR instead of inline Marker fallback."
627
+ )
628
+ result.defer_to_ocr = True
629
+ return result
630
+
631
+ # If primary failed or scored very poorly, try fallback
632
+ if fallback and primary_poor:
633
+ logger.info(
634
+ f"Primary engine {'failed' if result.error else 'scored poorly'}, "
635
+ f"trying fallback: {fallback.name}"
636
+ )
637
+ fallback_result = self._try_engine(fallback, file_info)
638
+ fallback_result.fallback_used = True
639
+
640
+ # Keep the better result
641
+ if fallback_result.error is None and (
642
+ result.error or fallback_result.confidence_score > result.confidence_score
643
+ ):
644
+ result = fallback_result
645
+
646
+ return result
647
+
648
+ def _try_engine(self, engine: BaseEngine | None, file_info: FileInfo) -> ConversionResult:
649
+ """Attempt conversion with a single engine."""
650
+ if engine is None:
651
+ return ConversionResult(
652
+ source_file=file_info, error="No engine available"
653
+ )
654
+
655
+ if not engine.is_available():
656
+ return ConversionResult(
657
+ source_file=file_info,
658
+ error=f"Engine {engine.name} is not installed",
659
+ engine_used=engine.name,
660
+ )
661
+
662
+ try:
663
+ markdown_text, metadata = engine.convert(file_info.path)
664
+
665
+ # Strip unpaired surrogates so the result can actually be saved
666
+ # to a UTF-8 file. PyMuPDF (and others) occasionally emit them
667
+ # from PDFs with malformed text encodings or custom font cmaps.
668
+ markdown_text, surrogate_count = sanitize_markdown(markdown_text)
669
+
670
+ # Score the result
671
+ score, grade, warnings = self._scorer.score(
672
+ markdown_text, file_info, metadata
673
+ )
674
+
675
+ if surrogate_count > 0:
676
+ warnings.append(
677
+ f"Replaced {surrogate_count} invalid Unicode surrogate(s) from engine output"
678
+ )
679
+ logger.debug(
680
+ f"Sanitized {surrogate_count} surrogate(s) from {engine.name} output"
681
+ )
682
+
683
+ return ConversionResult(
684
+ source_file=file_info,
685
+ markdown_content=markdown_text,
686
+ confidence_score=score,
687
+ grade=grade,
688
+ engine_used=engine.name,
689
+ metadata=metadata,
690
+ warnings=warnings,
691
+ )
692
+
693
+ except Exception as e:
694
+ logger.error(f"Engine {engine.name} failed: {e}")
695
+ return ConversionResult(
696
+ source_file=file_info,
697
+ error=str(e),
698
+ engine_used=engine.name,
699
+ )
700
+
701
+ def _save_output(self, result: ConversionResult) -> None:
702
+ """Save the converted Markdown to output/md_files/."""
703
+ if not result.markdown_content:
704
+ return
705
+
706
+ filename = sanitize_filename(result.source_file.filename) + ".md"
707
+ output_path = self._md_dir / filename
708
+
709
+ # Handle collision
710
+ if output_path.exists():
711
+ stem = output_path.stem
712
+ counter = 1
713
+ while output_path.exists():
714
+ output_path = self._md_dir / f"{stem}_{counter}.md"
715
+ counter += 1
716
+
717
+ # `errors="replace"` is a safety net in case any odd code point slips
718
+ # past sanitize_markdown — one bad PDF should never crash the batch.
719
+ with open(output_path, "w", encoding="utf-8", errors="replace") as f:
720
+ f.write(result.markdown_content)
721
+ result.output_path = output_path
722
+ logger.debug(f"Saved: {output_path.name}")
723
+
724
+ def _move_original(self, result: ConversionResult) -> None:
725
+ """Move the original file to processed/ or failed/."""
726
+ src = result.source_file.path
727
+ if not src.exists():
728
+ logger.warning(f"Source file no longer exists: {src}")
729
+ return
730
+
731
+ dest_dir = self._processed_dir if result.success else self._failed_dir
732
+ safe_move(src, dest_dir)
733
+ logger.debug(f"Moved original to: {dest_dir.name}/")
734
+
735
+ def _save_report(self, result: ConversionResult) -> None:
736
+ """Save a JSON report for this conversion."""
737
+ report_name = sanitize_filename(result.source_file.filename) + ".json"
738
+ report_path = self._reports_dir / report_name
739
+
740
+ if report_path.exists():
741
+ stem = report_path.stem
742
+ counter = 1
743
+ while report_path.exists():
744
+ report_path = self._reports_dir / f"{stem}_{counter}.json"
745
+ counter += 1
746
+
747
+ # `errors="replace"` mirrors _save_output — one weird code point
748
+ # shouldn't ever take down report writing for a whole batch.
749
+ with open(report_path, "w", encoding="utf-8", errors="replace") as f:
750
+ json.dump(result.to_report_dict(), f, indent=2, ensure_ascii=False)
751
+
752
+ def _print_batch_summary(self, report: BatchReport) -> None:
753
+ """Print a beautiful batch summary to console."""
754
+ from rich.table import Table
755
+
756
+ summary = report.summary()
757
+
758
+ logger.info(f"\n{'='*60}")
759
+ logger.info("BATCH CONVERSION COMPLETE")
760
+ logger.info(f"{'='*60}")
761
+
762
+ table = Table(title="Warraqa — Conversion Summary")
763
+ table.add_column("File", style="cyan")
764
+ table.add_column("Type", style="dim")
765
+ table.add_column("Engine", style="blue")
766
+ table.add_column("Score", justify="center")
767
+ table.add_column("Grade", justify="center")
768
+ table.add_column("Time", justify="right", style="dim")
769
+ table.add_column("Status", justify="center")
770
+
771
+ grade_styles = {"A": "bold green", "B": "green", "C": "yellow", "D": "red", "F": "bold red"}
772
+
773
+ for r in report.results:
774
+ grade_style = grade_styles.get(r.grade, "white")
775
+ status = "✅" if r.success else "❌"
776
+ table.add_row(
777
+ r.source_file.filename,
778
+ r.source_file.file_type.upper(),
779
+ r.engine_used or "—",
780
+ str(r.confidence_score),
781
+ f"[{grade_style}]{r.grade}[/{grade_style}]",
782
+ f"{r.processing_time_seconds:.1f}s",
783
+ status,
784
+ )
785
+
786
+ from warraqa.utils import console
787
+ console.print(table)
788
+
789
+ # Stats
790
+ logger.info(
791
+ f"\nTotal: {summary['total_files']} | "
792
+ f"Success: {summary['successful']} | "
793
+ f"Failed: {summary['failed']} | "
794
+ f"Avg Score: {summary['average_score']} | "
795
+ f"Total Time: {summary['total_time_seconds']:.1f}s"
796
+ )
797
+ grades = summary["grade_distribution"]
798
+ logger.info(
799
+ f"Grades: A={grades['A']} B={grades['B']} C={grades['C']} "
800
+ f"D={grades['D']} F={grades['F']}"
801
+ )
warraqa/cli.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Warraqa CLI — Command-Line Entry Point
3
+
4
+ Exposed as the `warraqa` console-script (see pyproject.toml). The repo-root
5
+ `run.py` is a thin shim that imports `main` from here, so `python run.py`
6
+ continues to work for users who clone the repo without installing.
7
+
8
+ Usage:
9
+ warraqa # Manual mode: opens folder picker
10
+ warraqa --folder <path> # Manual mode: process specific folder
11
+ warraqa --file <path> # Convert a single file
12
+ warraqa --watch [--folder <path>] # Watch mode: continuously monitor
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import io
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
23
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
24
+
25
+ from rich.console import Console
26
+ from rich.panel import Panel
27
+ from rich.text import Text
28
+
29
+
30
+ def print_banner() -> None:
31
+ """Print the Warraqa startup banner."""
32
+ console = Console()
33
+ banner = Text()
34
+ banner.append(" ورّاقة ", style="bold magenta")
35
+ banner.append("Warraqa", style="bold white")
36
+ banner.append(" — The Document Scribe\n", style="dim")
37
+ banner.append(" Converting documents to Markdown with precision", style="italic dim")
38
+
39
+ console.print(Panel(
40
+ banner,
41
+ border_style="magenta",
42
+ padding=(1, 2),
43
+ title="[bold]Document Scribe Agent[/bold]",
44
+ subtitle="v1.0.0",
45
+ ))
46
+ console.print()
47
+
48
+
49
+ def main() -> None:
50
+ parser = argparse.ArgumentParser(
51
+ prog="warraqa",
52
+ description="Warraqa (ورّاقة) — The Document Scribe Agent. "
53
+ "Converts PDF, Word, and PowerPoint documents to clean Markdown.",
54
+ )
55
+ parser.add_argument(
56
+ "--file",
57
+ type=str,
58
+ help="Convert a single file to Markdown",
59
+ )
60
+ parser.add_argument(
61
+ "--folder",
62
+ type=str,
63
+ help="Process all supported files in a folder",
64
+ )
65
+ parser.add_argument(
66
+ "--watch",
67
+ action="store_true",
68
+ default=False,
69
+ help="Enable watch mode: continuously monitor for new files",
70
+ )
71
+ parser.add_argument(
72
+ "--interval",
73
+ type=int,
74
+ default=None,
75
+ help="Watch mode poll interval in seconds (default: 10)",
76
+ )
77
+ parser.add_argument(
78
+ "--config",
79
+ type=str,
80
+ default=None,
81
+ help="Path to config.yaml (default: auto-detect)",
82
+ )
83
+ parser.add_argument(
84
+ "--native-engine",
85
+ type=str,
86
+ choices=["pymupdf", "marker"],
87
+ default="pymupdf",
88
+ help="Engine to use for NATIVE text PDFs (default: pymupdf for speed; "
89
+ "'marker' for max quality).",
90
+ )
91
+ parser.add_argument(
92
+ "--no-move",
93
+ action="store_true",
94
+ default=False,
95
+ help="Don't move original files after processing",
96
+ )
97
+ parser.add_argument(
98
+ "--no-save",
99
+ action="store_true",
100
+ default=False,
101
+ help="Don't save output files (dry-run mode)",
102
+ )
103
+
104
+ args = parser.parse_args()
105
+
106
+ print_banner()
107
+
108
+ from warraqa.agent import Warraqa
109
+ agent = Warraqa(config_path=args.config, native_engine=args.native_engine)
110
+
111
+ # --- Single file mode ---
112
+ if args.file:
113
+ file_path = Path(args.file).resolve()
114
+ if not file_path.is_file():
115
+ Console().print(f"[red]Error: File not found: {file_path}[/red]")
116
+ sys.exit(1)
117
+
118
+ result = agent.convert_file(file_path)
119
+
120
+ if not args.no_save and result.markdown_content:
121
+ agent._save_output(result)
122
+ agent._save_report(result)
123
+
124
+ if not args.no_move:
125
+ agent._move_original(result)
126
+
127
+ sys.exit(0 if result.success else 1)
128
+
129
+ # --- Watch mode ---
130
+ if args.watch:
131
+ agent.watch_folder(
132
+ input_dir=args.folder,
133
+ interval=args.interval,
134
+ )
135
+ sys.exit(0)
136
+
137
+ # --- Batch/manual mode (default) ---
138
+ results = agent.process_folder(
139
+ input_dir=args.folder,
140
+ save_output=not args.no_save,
141
+ move_files=not args.no_move,
142
+ )
143
+
144
+ # Exit code: non-zero if any files failed
145
+ failed = sum(1 for r in results if not r.success)
146
+ sys.exit(1 if failed > 0 else 0)
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
warraqa/engines/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Warraqa conversion engines — specialized converters for each document format."""
2
+
3
+ from warraqa.engines.base import BaseEngine # noqa: F401
4
+
5
+ __all__ = ["BaseEngine"]
warraqa/engines/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (347 Bytes). View file
 
warraqa/engines/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (339 Bytes). View file
 
warraqa/engines/__pycache__/base.cpython-313.pyc ADDED
Binary file (6.08 kB). View file
 
warraqa/engines/__pycache__/base.cpython-314.pyc ADDED
Binary file (7.38 kB). View file
 
warraqa/engines/__pycache__/docx_markitdown.cpython-313.pyc ADDED
Binary file (5.7 kB). View file
 
warraqa/engines/__pycache__/docx_markitdown.cpython-314.pyc ADDED
Binary file (6.78 kB). View file
 
warraqa/engines/__pycache__/docx_pandoc.cpython-313.pyc ADDED
Binary file (7.37 kB). View file
 
warraqa/engines/__pycache__/docx_pandoc.cpython-314.pyc ADDED
Binary file (8.76 kB). View file
 
warraqa/engines/__pycache__/office_com.cpython-313.pyc ADDED
Binary file (10.4 kB). View file
 
warraqa/engines/__pycache__/office_markitdown.cpython-313.pyc ADDED
Binary file (5.96 kB). View file
 
warraqa/engines/__pycache__/pdf_marker.cpython-313.pyc ADDED
Binary file (7.13 kB). View file
 
warraqa/engines/__pycache__/pdf_marker.cpython-314.pyc ADDED
Binary file (8.23 kB). View file
 
warraqa/engines/__pycache__/pdf_pymupdf.cpython-313.pyc ADDED
Binary file (4.78 kB). View file
 
warraqa/engines/__pycache__/pdf_pymupdf.cpython-314.pyc ADDED
Binary file (5.71 kB). View file
 
warraqa/engines/base.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base Engine Interface
3
+
4
+ Abstract base class that all Warraqa conversion engines must implement.
5
+ This ensures a consistent API across PDF and Word engines, enabling
6
+ clean fallback chains and inter-engine substitution.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from abc import ABC, abstractmethod
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger("warraqa.engines")
17
+
18
+
19
+ class BaseEngine(ABC):
20
+ """Abstract base class for all document conversion engines."""
21
+
22
+ @property
23
+ @abstractmethod
24
+ def name(self) -> str:
25
+ """Human-readable engine name (e.g., 'Marker', 'PyMuPDF', 'MarkItDown')."""
26
+ ...
27
+
28
+ @property
29
+ @abstractmethod
30
+ def supported_types(self) -> list[str]:
31
+ """List of file types this engine supports (e.g., ['pdf'], ['docx', 'doc'])."""
32
+ ...
33
+
34
+ @abstractmethod
35
+ def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
36
+ """
37
+ Convert a document file to Markdown.
38
+
39
+ Args:
40
+ file_path: Path to the input document.
41
+
42
+ Returns:
43
+ A tuple of:
44
+ - markdown_text: The converted Markdown content as a string.
45
+ - metadata: Dictionary with engine-specific metadata, such as:
46
+ - page_count: int
47
+ - word_count: int
48
+ - heading_count: int
49
+ - table_count: int
50
+ - images_detected: int
51
+ - ocr_pages: int (for PDFs)
52
+ - equations_detected: int (for PDFs)
53
+
54
+ Raises:
55
+ FileNotFoundError: If the input file does not exist.
56
+ ValueError: If the file type is not supported.
57
+ RuntimeError: If the conversion fails.
58
+ """
59
+ ...
60
+
61
+ def supports(self, file_type: str) -> bool:
62
+ """Check if this engine supports the given file type."""
63
+ return file_type.lower() in self.supported_types
64
+
65
+ def is_available(self) -> bool:
66
+ """
67
+ Check if this engine's dependencies are installed and ready.
68
+
69
+ Override this method in subclasses that have optional dependencies.
70
+ """
71
+ return True
72
+
73
+ def _validate_input(self, file_path: Path) -> None:
74
+ """Common input validation for all engines."""
75
+ if not file_path.exists():
76
+ raise FileNotFoundError(f"File not found: {file_path}")
77
+ if not file_path.is_file():
78
+ raise ValueError(f"Not a file: {file_path}")
79
+ if file_path.stat().st_size == 0:
80
+ raise ValueError(f"File is empty: {file_path}")
81
+
82
+ def _count_markdown_elements(self, markdown: str) -> dict[str, int]:
83
+ """
84
+ Count structural Markdown elements in the output.
85
+
86
+ Useful for engines that don't provide their own metadata about
87
+ the structure of the converted document.
88
+ """
89
+ lines = markdown.split("\n")
90
+ counts = {
91
+ "heading_count": 0,
92
+ "list_item_count": 0,
93
+ "table_row_count": 0,
94
+ "code_block_count": 0,
95
+ "link_count": 0,
96
+ "image_placeholder_count": 0,
97
+ "word_count": len(markdown.split()),
98
+ }
99
+
100
+ in_code_block = False
101
+ for line in lines:
102
+ stripped = line.strip()
103
+
104
+ # Code blocks
105
+ if stripped.startswith("```"):
106
+ in_code_block = not in_code_block
107
+ if not in_code_block:
108
+ counts["code_block_count"] += 1
109
+ continue
110
+
111
+ if in_code_block:
112
+ continue
113
+
114
+ # Headings
115
+ if stripped.startswith("#"):
116
+ counts["heading_count"] += 1
117
+
118
+ # List items
119
+ if stripped.startswith(("- ", "* ", "+ ")) or (
120
+ len(stripped) > 2 and stripped[0].isdigit() and ". " in stripped[:5]
121
+ ):
122
+ counts["list_item_count"] += 1
123
+
124
+ # Table rows
125
+ if stripped.startswith("|") and stripped.endswith("|"):
126
+ # Skip separator rows like |---|---|
127
+ if not all(c in "|-: " for c in stripped):
128
+ counts["table_row_count"] += 1
129
+
130
+ # Links
131
+ counts["link_count"] += stripped.count("](")
132
+
133
+ # Image placeholders
134
+ if stripped.startswith("!["):
135
+ counts["image_placeholder_count"] += 1
136
+
137
+ return counts
138
+
139
+ def __repr__(self) -> str:
140
+ available = "✓" if self.is_available() else "✗"
141
+ return f"<{self.name} Engine [{available}] types={self.supported_types}>"
warraqa/engines/docx_pandoc.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pandoc Word Engine
3
+
4
+ Fallback Word/DOCX conversion engine using Pandoc via subprocess.
5
+ Pandoc is the gold-standard universal document converter, providing
6
+ the most reliable handling of complex formatting edge cases.
7
+
8
+ Requires Pandoc to be installed on the system.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import shutil
15
+ import subprocess
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from warraqa.engines.base import BaseEngine
20
+
21
+ logger = logging.getLogger("warraqa.engines.pandoc")
22
+
23
+
24
+ class PandocEngine(BaseEngine):
25
+ """Word/DOCX to Markdown converter using Pandoc (subprocess)."""
26
+
27
+ def __init__(self, pandoc_path: str | None = None):
28
+ """
29
+ Args:
30
+ pandoc_path: Explicit path to the pandoc executable.
31
+ If None, uses system PATH.
32
+ """
33
+ self._pandoc_path = pandoc_path
34
+
35
+ @property
36
+ def name(self) -> str:
37
+ return "Pandoc"
38
+
39
+ @property
40
+ def supported_types(self) -> list[str]:
41
+ return ["docx", "doc"]
42
+
43
+ def _get_pandoc_cmd(self) -> str:
44
+ """Get the pandoc command path."""
45
+ if self._pandoc_path:
46
+ return self._pandoc_path
47
+ return "pandoc"
48
+
49
+ def is_available(self) -> bool:
50
+ """Check if Pandoc is installed and accessible."""
51
+ cmd = self._get_pandoc_cmd()
52
+
53
+ # Check explicit path
54
+ if self._pandoc_path:
55
+ return Path(self._pandoc_path).is_file()
56
+
57
+ # Check system PATH
58
+ if shutil.which(cmd):
59
+ return True
60
+
61
+ return False
62
+
63
+ def _get_pandoc_version(self) -> str | None:
64
+ """Get the installed Pandoc version."""
65
+ try:
66
+ result = subprocess.run(
67
+ [self._get_pandoc_cmd(), "--version"],
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=10,
71
+ )
72
+ if result.returncode == 0:
73
+ first_line = result.stdout.strip().split("\n")[0]
74
+ return first_line
75
+ except Exception:
76
+ pass
77
+ return None
78
+
79
+ def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
80
+ """
81
+ Convert a Word document to Markdown using Pandoc.
82
+
83
+ Runs Pandoc as a subprocess:
84
+ pandoc input.docx -t markdown --wrap=none
85
+
86
+ Args:
87
+ file_path: Path to the Word document.
88
+
89
+ Returns:
90
+ (markdown_text, metadata) tuple.
91
+ """
92
+ self._validate_input(file_path)
93
+
94
+ if not self.is_available():
95
+ raise RuntimeError(
96
+ "Pandoc is not installed or not found on PATH. "
97
+ "Install from: https://pandoc.org/installing.html"
98
+ )
99
+
100
+ logger.info(f"[Pandoc] Converting: {file_path.name}")
101
+
102
+ try:
103
+ cmd = [
104
+ self._get_pandoc_cmd(),
105
+ str(file_path),
106
+ "-t", "markdown",
107
+ "--wrap=none", # Don't wrap long lines
108
+ "--extract-media=-", # Don't extract media files
109
+ ]
110
+
111
+ result = subprocess.run(
112
+ cmd,
113
+ capture_output=True,
114
+ text=True,
115
+ timeout=120, # 2-minute timeout for very large documents
116
+ encoding="utf-8",
117
+ )
118
+
119
+ if result.returncode != 0:
120
+ error_msg = result.stderr.strip() if result.stderr else "Unknown error"
121
+ raise RuntimeError(f"Pandoc exited with code {result.returncode}: {error_msg}")
122
+
123
+ markdown_text = result.stdout
124
+
125
+ if not markdown_text.strip():
126
+ logger.warning(f"[Pandoc] Empty output for {file_path.name}")
127
+
128
+ # Post-process: replace image references with alt-text placeholders
129
+ markdown_text = self._replace_images_with_placeholders(markdown_text)
130
+
131
+ # Build metadata
132
+ metadata: dict[str, Any] = {
133
+ "page_count": 0,
134
+ "images_detected": 0,
135
+ "tables_detected": 0,
136
+ "pandoc_version": self._get_pandoc_version(),
137
+ }
138
+
139
+ # Count structural elements
140
+ element_counts = self._count_markdown_elements(markdown_text)
141
+ metadata.update(element_counts)
142
+
143
+ metadata["tables_detected"] = markdown_text.count("|---|") + markdown_text.count("| ---")
144
+
145
+ logger.info(
146
+ f"[Pandoc] Success: {file_path.name} — "
147
+ f"{metadata['word_count']} words, "
148
+ f"{metadata['heading_count']} headings"
149
+ )
150
+
151
+ return markdown_text, metadata
152
+
153
+ except subprocess.TimeoutExpired as e:
154
+ logger.error(f"[Pandoc] Timeout for {file_path.name} (>120s)")
155
+ raise RuntimeError("Pandoc conversion timed out (>120 seconds)") from e
156
+ except Exception as e:
157
+ if isinstance(e, RuntimeError):
158
+ raise
159
+ logger.error(f"[Pandoc] Conversion failed for {file_path.name}: {e}")
160
+ raise RuntimeError(f"Pandoc conversion failed: {e}") from e
161
+
162
+ def _replace_images_with_placeholders(self, markdown: str) -> str:
163
+ """Replace any image references with alt-text placeholders."""
164
+ import re
165
+
166
+ image_count = 0
167
+
168
+ def _replace_match(match: re.Match) -> str:
169
+ nonlocal image_count
170
+ image_count += 1
171
+ alt_text = match.group(1).strip()
172
+ if alt_text and alt_text.lower() not in ("image", "img", "figure", ""):
173
+ return f"![Image: {alt_text}]()"
174
+ return f"![Image: embedded image #{image_count} in document]()"
175
+
176
+ markdown = re.sub(
177
+ r"!\[([^\]]*)\]\([^\)]+\)",
178
+ _replace_match,
179
+ markdown,
180
+ )
181
+ return markdown
warraqa/engines/office_com.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Office COM Pre-Converter
3
+
4
+ Uses Microsoft Word and PowerPoint via pywin32 COM automation to convert
5
+ legacy binary Office formats (.doc, .ppt) into their modern XML
6
+ equivalents (.docx, .pptx). Required because MarkItDown and Pandoc cannot
7
+ read the 1990s-era binary formats directly.
8
+
9
+ Windows + Microsoft Office required. The Office applications are launched
10
+ invisible and kept alive for the duration of the Warraqa session — opening
11
+ Word for every file would be very slow in a large batch.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import atexit
17
+ import logging
18
+ import os
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+ logger = logging.getLogger("warraqa.engines.office_com")
23
+
24
+ # Word file format constants (https://docs.microsoft.com/office/vba/api/word.wdsaveformat)
25
+ WD_FORMAT_XML_DOCUMENT = 12 # .docx
26
+
27
+ # PowerPoint file format constants (https://docs.microsoft.com/office/vba/api/powerpoint.ppsaveasfiletype)
28
+ PP_SAVE_AS_OPEN_XML_PRESENTATION = 24 # .pptx
29
+
30
+
31
+ class OfficeCOMConverter:
32
+ """Legacy → modern Office file pre-converter via MS Office COM."""
33
+
34
+ def __init__(self) -> None:
35
+ self._word = None
36
+ self._powerpoint = None
37
+ self._com_initialized = False
38
+ self._available = self._check_available()
39
+ if self._available:
40
+ atexit.register(self.close)
41
+
42
+ @staticmethod
43
+ def _check_available() -> bool:
44
+ """Verify pywin32 is importable. Does not start any COM app."""
45
+ try:
46
+ import win32com.client # noqa: F401
47
+ return True
48
+ except ImportError:
49
+ return False
50
+
51
+ def is_available(self) -> bool:
52
+ return self._available
53
+
54
+ # --- Public conversion API ---
55
+
56
+ def convert_doc_to_docx(self, src: Path) -> Path:
57
+ """Convert a binary .doc to a .docx in a temp file. Returns the temp path."""
58
+ if not src.is_file():
59
+ raise FileNotFoundError(src)
60
+ word = self._ensure_word()
61
+ out = self._make_temp_path(src.stem, ".docx")
62
+
63
+ logger.info(f"[OfficeCOM] Converting .doc → .docx: {src.name}")
64
+ doc = None
65
+ try:
66
+ doc = word.Documents.Open(
67
+ str(src.resolve()),
68
+ ReadOnly=True,
69
+ ConfirmConversions=False,
70
+ AddToRecentFiles=False,
71
+ )
72
+ doc.SaveAs2(str(out.resolve()), FileFormat=WD_FORMAT_XML_DOCUMENT)
73
+ except Exception as e:
74
+ raise RuntimeError(f"Word COM failed on {src.name}: {e}") from e
75
+ finally:
76
+ if doc is not None:
77
+ try:
78
+ doc.Close(SaveChanges=False)
79
+ except Exception:
80
+ pass
81
+
82
+ if not out.is_file() or out.stat().st_size == 0:
83
+ raise RuntimeError(f"Word produced no output for {src.name}")
84
+ return out
85
+
86
+ def convert_ppt_to_pptx(self, src: Path) -> Path:
87
+ """Convert a binary .ppt to a .pptx in a temp file. Returns the temp path."""
88
+ if not src.is_file():
89
+ raise FileNotFoundError(src)
90
+ ppt = self._ensure_powerpoint()
91
+ out = self._make_temp_path(src.stem, ".pptx")
92
+
93
+ logger.info(f"[OfficeCOM] Converting .ppt → .pptx: {src.name}")
94
+ pres = None
95
+ try:
96
+ pres = ppt.Presentations.Open(
97
+ str(src.resolve()),
98
+ ReadOnly=True,
99
+ WithWindow=False,
100
+ )
101
+ pres.SaveAs(
102
+ str(out.resolve()),
103
+ PP_SAVE_AS_OPEN_XML_PRESENTATION,
104
+ )
105
+ except Exception as e:
106
+ raise RuntimeError(f"PowerPoint COM failed on {src.name}: {e}") from e
107
+ finally:
108
+ if pres is not None:
109
+ try:
110
+ pres.Close()
111
+ except Exception:
112
+ pass
113
+
114
+ if not out.is_file() or out.stat().st_size == 0:
115
+ raise RuntimeError(f"PowerPoint produced no output for {src.name}")
116
+ return out
117
+
118
+ def close(self) -> None:
119
+ """Shutdown Word and PowerPoint. Safe to call multiple times."""
120
+ if self._word is not None:
121
+ try:
122
+ self._word.Quit()
123
+ logger.debug("[OfficeCOM] Word closed")
124
+ except Exception:
125
+ pass
126
+ self._word = None
127
+ if self._powerpoint is not None:
128
+ try:
129
+ self._powerpoint.Quit()
130
+ logger.debug("[OfficeCOM] PowerPoint closed")
131
+ except Exception:
132
+ pass
133
+ self._powerpoint = None
134
+ if self._com_initialized:
135
+ try:
136
+ import pythoncom
137
+ pythoncom.CoUninitialize()
138
+ except Exception:
139
+ pass
140
+ self._com_initialized = False
141
+
142
+ # --- Internals ---
143
+
144
+ def _ensure_com_init(self) -> None:
145
+ if self._com_initialized:
146
+ return
147
+ import pythoncom
148
+ pythoncom.CoInitialize()
149
+ self._com_initialized = True
150
+
151
+ def _ensure_word(self):
152
+ if self._word is not None:
153
+ return self._word
154
+ if not self._available:
155
+ raise RuntimeError("pywin32 is not installed — cannot use Office COM")
156
+ self._ensure_com_init()
157
+ import win32com.client
158
+ try:
159
+ self._word = win32com.client.Dispatch("Word.Application")
160
+ self._word.Visible = False
161
+ self._word.DisplayAlerts = 0 # wdAlertsNone
162
+ logger.info(f"[OfficeCOM] Word {self._word.Version} initialized")
163
+ except Exception as e:
164
+ raise RuntimeError(
165
+ f"Failed to start Word via COM (is Microsoft Word installed?): {e}"
166
+ ) from e
167
+ return self._word
168
+
169
+ def _ensure_powerpoint(self):
170
+ if self._powerpoint is not None:
171
+ return self._powerpoint
172
+ if not self._available:
173
+ raise RuntimeError("pywin32 is not installed — cannot use Office COM")
174
+ self._ensure_com_init()
175
+ import win32com.client
176
+ try:
177
+ self._powerpoint = win32com.client.Dispatch("PowerPoint.Application")
178
+ # PowerPoint's Visible property is read-only in newer versions
179
+ # when there's no UI thread; just leave default.
180
+ logger.info(
181
+ f"[OfficeCOM] PowerPoint {self._powerpoint.Version} initialized"
182
+ )
183
+ except Exception as e:
184
+ raise RuntimeError(
185
+ f"Failed to start PowerPoint via COM (is PowerPoint installed?): {e}"
186
+ ) from e
187
+ return self._powerpoint
188
+
189
+ @staticmethod
190
+ def _make_temp_path(stem: str, suffix: str) -> Path:
191
+ """Generate a unique temp file path. Caller is responsible for cleanup."""
192
+ safe_stem = "".join(c if c.isalnum() or c in "_-" else "_" for c in stem)[:60]
193
+ token = os.urandom(6).hex()
194
+ return Path(tempfile.gettempdir()) / f"warraqa_{token}_{safe_stem}{suffix}"
warraqa/engines/office_markitdown.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MarkItDown Office Engine
3
+
4
+ Primary Office document conversion engine using Microsoft's MarkItDown
5
+ library. Handles modern XML-based Office formats: .docx and .pptx. Legacy
6
+ .doc and .ppt are pre-converted to the modern format by the
7
+ OfficeCOMConverter before this engine runs.
8
+
9
+ Provides clean preservation of headings, lists, tables, links, slides,
10
+ speaker notes, and embedded content.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from warraqa.engines.base import BaseEngine
20
+
21
+ logger = logging.getLogger("warraqa.engines.markitdown")
22
+
23
+
24
+ class MarkItDownEngine(BaseEngine):
25
+ """Office (DOCX/PPTX) to Markdown converter using Microsoft's MarkItDown."""
26
+
27
+ def __init__(self):
28
+ self._converter = None
29
+
30
+ @property
31
+ def name(self) -> str:
32
+ return "MarkItDown"
33
+
34
+ @property
35
+ def supported_types(self) -> list[str]:
36
+ # Modern formats only. Legacy .doc / .ppt are normalized upstream
37
+ # by the OfficeCOMConverter into .docx / .pptx before reaching here.
38
+ return ["docx", "pptx"]
39
+
40
+ def is_available(self) -> bool:
41
+ """Check if markitdown is installed."""
42
+ try:
43
+ from markitdown import MarkItDown # noqa: F401
44
+ return True
45
+ except ImportError:
46
+ return False
47
+
48
+ def _ensure_initialized(self) -> None:
49
+ """Lazy-initialize the MarkItDown converter."""
50
+ if self._converter is not None:
51
+ return
52
+
53
+ if not self.is_available():
54
+ raise RuntimeError(
55
+ "MarkItDown is not installed. Run: pip install 'markitdown[all]'"
56
+ )
57
+
58
+ from markitdown import MarkItDown
59
+ self._converter = MarkItDown()
60
+ logger.debug("MarkItDown engine initialized")
61
+
62
+ def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
63
+ """
64
+ Convert an Office document (.docx or .pptx) to Markdown using
65
+ MarkItDown.
66
+
67
+ Legacy .doc / .ppt files should be pre-converted to their modern
68
+ equivalents by the OfficeCOMConverter before reaching this engine
69
+ — MarkItDown's bundled converters do not parse the binary formats.
70
+
71
+ Args:
72
+ file_path: Path to the Office document.
73
+
74
+ Returns:
75
+ (markdown_text, metadata) tuple.
76
+ """
77
+ self._validate_input(file_path)
78
+ self._ensure_initialized()
79
+
80
+ logger.info(f"[MarkItDown] Converting: {file_path.name}")
81
+
82
+ try:
83
+ # Perform the conversion
84
+ result = self._converter.convert(str(file_path))
85
+
86
+ # MarkItDown returns result.text_content with the markdown
87
+ markdown_text = result.text_content if result.text_content else ""
88
+
89
+ if not markdown_text.strip():
90
+ logger.warning(
91
+ f"[MarkItDown] Empty output for {file_path.name}"
92
+ )
93
+
94
+ # Post-process: replace image references with alt-text placeholders
95
+ markdown_text = self._replace_images_with_placeholders(markdown_text)
96
+
97
+ # Build metadata
98
+ metadata: dict[str, Any] = {
99
+ "page_count": 0, # Word docs don't have fixed page counts
100
+ "images_detected": 0,
101
+ "tables_detected": 0,
102
+ }
103
+
104
+ # Count structural elements
105
+ element_counts = self._count_markdown_elements(markdown_text)
106
+ metadata.update(element_counts)
107
+
108
+ # Estimate table count from markdown
109
+ metadata["tables_detected"] = markdown_text.count("|---|") + markdown_text.count("| ---")
110
+
111
+ logger.info(
112
+ f"[MarkItDown] Success: {file_path.name} — "
113
+ f"{metadata['word_count']} words, "
114
+ f"{metadata['heading_count']} headings"
115
+ )
116
+
117
+ return markdown_text, metadata
118
+
119
+ except Exception as e:
120
+ logger.error(
121
+ f"[MarkItDown] Conversion failed for {file_path.name}: {e}"
122
+ )
123
+ raise RuntimeError(f"MarkItDown conversion failed: {e}") from e
124
+
125
+ def _replace_images_with_placeholders(self, markdown: str) -> str:
126
+ """Replace any image references with alt-text placeholders."""
127
+ import re
128
+
129
+ image_count = 0
130
+
131
+ def _replace_match(match: re.Match) -> str:
132
+ nonlocal image_count
133
+ image_count += 1
134
+ alt_text = match.group(1).strip()
135
+ if alt_text and alt_text.lower() not in ("image", "img", "figure", ""):
136
+ return f"![Image: {alt_text}]()"
137
+ return f"![Image: embedded image #{image_count} in document]()"
138
+
139
+ markdown = re.sub(
140
+ r"!\[([^\]]*)\]\([^\)]+\)",
141
+ _replace_match,
142
+ markdown,
143
+ )
144
+ return markdown
warraqa/engines/pdf_marker.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Marker PDF Engine
3
+
4
+ Primary PDF conversion engine using the Marker library — a deep learning
5
+ pipeline that handles complex layouts, tables, equations, multi-column
6
+ formats, headers/footers, and image detection.
7
+
8
+ Marker's purpose-built ML models outperform general-purpose vision LLMs
9
+ for structural document extraction.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from warraqa.engines.base import BaseEngine
19
+
20
+ logger = logging.getLogger("warraqa.engines.marker")
21
+
22
+
23
+ class MarkerPDFEngine(BaseEngine):
24
+ """High-fidelity PDF to Markdown converter using Marker."""
25
+
26
+ def __init__(self):
27
+ self._converter = None
28
+ self._initialized = False
29
+
30
+ @property
31
+ def name(self) -> str:
32
+ return "Marker"
33
+
34
+ @property
35
+ def supported_types(self) -> list[str]:
36
+ return ["pdf"]
37
+
38
+ def is_available(self) -> bool:
39
+ """Check if marker-pdf is installed."""
40
+ try:
41
+ import marker # noqa: F401
42
+ return True
43
+ except ImportError:
44
+ return False
45
+
46
+ def _ensure_initialized(self) -> None:
47
+ """
48
+ Lazy-initialize the Marker converter.
49
+
50
+ Marker loads several large ML models into memory on first use.
51
+ We do this lazily to avoid slow startup when the engine isn't needed,
52
+ but cache the converter so models are loaded only once.
53
+ """
54
+ if self._initialized:
55
+ return
56
+
57
+ if not self.is_available():
58
+ raise RuntimeError(
59
+ "Marker is not installed. Run: pip install marker-pdf"
60
+ )
61
+
62
+ try:
63
+ from marker.converters.pdf import PdfConverter
64
+ from marker.models import create_model_dict
65
+
66
+ # Initialize Marker ML models (downloads if first run)
67
+ logger.info("Loading Marker ML models (this may take a few minutes on first run)...")
68
+ models = create_model_dict()
69
+
70
+ self._converter = PdfConverter(artifact_dict=models)
71
+ self._initialized = True
72
+ logger.info("Marker engine initialized (models loaded)")
73
+ except Exception as e:
74
+ logger.error(f"Failed to initialize Marker engine: {e}")
75
+ raise RuntimeError(f"Marker initialization failed: {e}") from e
76
+
77
+ def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
78
+ """
79
+ Convert a PDF file to Markdown using Marker.
80
+
81
+ Args:
82
+ file_path: Path to the PDF file.
83
+
84
+ Returns:
85
+ (markdown_text, metadata) tuple.
86
+ """
87
+ self._validate_input(file_path)
88
+ self._ensure_initialized()
89
+
90
+ logger.info(f"[Marker] Converting: {file_path.name}")
91
+
92
+ try:
93
+ # Run the conversion
94
+ rendered = self._converter(str(file_path))
95
+
96
+ # Extract the markdown text from the rendered output
97
+ markdown_text = rendered.markdown
98
+
99
+ import fitz
100
+ try:
101
+ with fitz.open(str(file_path)) as doc:
102
+ page_count = len(doc)
103
+ except Exception:
104
+ page_count = 0
105
+
106
+ # Build metadata from conversion results
107
+ metadata: dict[str, Any] = {
108
+ "page_count": page_count,
109
+ "images_detected": 0,
110
+ "tables_detected": 0,
111
+ "equations_detected": 0,
112
+ }
113
+
114
+ # Count images from rendered output
115
+ if hasattr(rendered, 'images') and rendered.images:
116
+ metadata["images_detected"] = len(rendered.images)
117
+
118
+ # Post-process: replace image references with alt-text placeholders
119
+ markdown_text = self._replace_images_with_placeholders(
120
+ markdown_text, metadata
121
+ )
122
+
123
+ # Count structural elements from the markdown itself
124
+ element_counts = self._count_markdown_elements(markdown_text)
125
+ metadata.update(element_counts)
126
+
127
+ # Count tables and equations from the markdown
128
+ metadata["tables_detected"] = markdown_text.count("|---|") + markdown_text.count("| ---")
129
+ metadata["equations_detected"] = (
130
+ markdown_text.count("$$") // 2 + markdown_text.count("\\(")
131
+ )
132
+
133
+ logger.info(
134
+ f"[Marker] Success: {file_path.name} — "
135
+ f"{metadata['word_count']} words, "
136
+ f"{metadata['page_count']} pages"
137
+ )
138
+
139
+ return markdown_text, metadata
140
+
141
+ except Exception as e:
142
+ logger.error(f"[Marker] Conversion failed for {file_path.name}: {e}")
143
+ raise RuntimeError(f"Marker conversion failed: {e}") from e
144
+
145
+ def _replace_images_with_placeholders(
146
+ self, markdown: str, metadata: dict
147
+ ) -> str:
148
+ """
149
+ Replace image references with descriptive alt-text placeholders.
150
+
151
+ Converts patterns like:
152
+ ![](image_path.png)
153
+ ![some alt text](image_path.png)
154
+ To:
155
+ ![Image: figure/diagram detected on this page]()
156
+ """
157
+ import re
158
+
159
+ image_count = 0
160
+
161
+ def _replace_match(match: re.Match) -> str:
162
+ nonlocal image_count
163
+ image_count += 1
164
+ alt_text = match.group(1).strip()
165
+ if alt_text and alt_text.lower() not in ("image", "img", "figure"):
166
+ # Keep meaningful alt text
167
+ return f"![Image: {alt_text}]()"
168
+ return f"![Image: figure/diagram #{image_count} detected in document]()"
169
+
170
+ # Match markdown image syntax: ![alt text](path)
171
+ markdown = re.sub(
172
+ r"!\[([^\]]*)\]\([^\)]+\)",
173
+ _replace_match,
174
+ markdown,
175
+ )
176
+
177
+ metadata["images_detected"] = max(metadata.get("images_detected", 0), image_count)
178
+
179
+ return markdown
warraqa/engines/pdf_pymupdf.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PyMuPDF4LLM PDF Engine
3
+
4
+ Fallback PDF conversion engine — fast, lightweight, CPU-only.
5
+ Uses PyMuPDF's LLM-optimized markdown export for text-heavy documents.
6
+ No ML model loading means instant startup and minimal resource usage.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from warraqa.engines.base import BaseEngine
16
+
17
+ logger = logging.getLogger("warraqa.engines.pymupdf")
18
+
19
+
20
+ class PyMuPDFEngine(BaseEngine):
21
+ """Fast, lightweight PDF to Markdown converter using PyMuPDF4LLM."""
22
+
23
+ @property
24
+ def name(self) -> str:
25
+ return "PyMuPDF"
26
+
27
+ @property
28
+ def supported_types(self) -> list[str]:
29
+ return ["pdf"]
30
+
31
+ def is_available(self) -> bool:
32
+ """Check if pymupdf4llm is installed."""
33
+ try:
34
+ import pymupdf4llm # noqa: F401
35
+ return True
36
+ except ImportError:
37
+ return False
38
+
39
+ def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
40
+ """
41
+ Convert a PDF file to Markdown using PyMuPDF4LLM.
42
+
43
+ This is a fast, single-function conversion that works best on
44
+ text-heavy, simple-layout PDFs. It does not use ML models.
45
+
46
+ Args:
47
+ file_path: Path to the PDF file.
48
+
49
+ Returns:
50
+ (markdown_text, metadata) tuple.
51
+ """
52
+ self._validate_input(file_path)
53
+
54
+ if not self.is_available():
55
+ raise RuntimeError(
56
+ "PyMuPDF4LLM is not installed. Run: pip install pymupdf4llm"
57
+ )
58
+
59
+ logger.info(f"[PyMuPDF] Converting: {file_path.name}")
60
+
61
+ try:
62
+ import pymupdf # fitz
63
+ import pymupdf4llm
64
+
65
+ # Get page count first
66
+ doc = pymupdf.open(str(file_path))
67
+ page_count = len(doc)
68
+ doc.close()
69
+
70
+ # Convert to markdown — single function call
71
+ markdown_text = pymupdf4llm.to_markdown(
72
+ str(file_path),
73
+ write_images=False, # We use alt-text placeholders, not image files
74
+ show_progress=False,
75
+ )
76
+
77
+ # Post-process: replace any image references with placeholders
78
+ markdown_text = self._replace_images_with_placeholders(markdown_text)
79
+
80
+ # Build metadata
81
+ metadata: dict[str, Any] = {
82
+ "page_count": page_count,
83
+ "images_detected": 0,
84
+ "tables_detected": 0,
85
+ "equations_detected": 0,
86
+ }
87
+
88
+ # Count structural elements
89
+ element_counts = self._count_markdown_elements(markdown_text)
90
+ metadata.update(element_counts)
91
+
92
+ logger.info(
93
+ f"[PyMuPDF] Success: {file_path.name} — "
94
+ f"{metadata['word_count']} words, "
95
+ f"{page_count} pages"
96
+ )
97
+
98
+ return markdown_text, metadata
99
+
100
+ except Exception as e:
101
+ logger.error(f"[PyMuPDF] Conversion failed for {file_path.name}: {e}")
102
+ raise RuntimeError(f"PyMuPDF conversion failed: {e}") from e
103
+
104
+ def _replace_images_with_placeholders(self, markdown: str) -> str:
105
+ """Replace any image references with alt-text placeholders."""
106
+ import re
107
+
108
+ image_count = 0
109
+
110
+ def _replace_match(match: re.Match) -> str:
111
+ nonlocal image_count
112
+ image_count += 1
113
+ alt_text = match.group(1).strip()
114
+ if alt_text and alt_text.lower() not in ("image", "img", "figure", ""):
115
+ return f"![Image: {alt_text}]()"
116
+ return f"![Image: figure/diagram #{image_count} detected in document]()"
117
+
118
+ markdown = re.sub(
119
+ r"!\[([^\]]*)\]\([^\)]+\)",
120
+ _replace_match,
121
+ markdown,
122
+ )
123
+ return markdown
warraqa/models.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Warraqa Data Models
3
+
4
+ Core data structures used throughout the Warraqa agent for representing
5
+ file information, conversion results, and batch reports.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ @dataclass
17
+ class FileInfo:
18
+ """Metadata about an input file to be converted."""
19
+
20
+ path: Path
21
+ file_type: str # "pdf", "docx", "doc"
22
+ size_bytes: int
23
+ mime_type: str
24
+ filename: str = ""
25
+
26
+ def __post_init__(self):
27
+ self.path = Path(self.path)
28
+ if not self.filename:
29
+ self.filename = self.path.name
30
+
31
+
32
+ @dataclass
33
+ class ConversionResult:
34
+ """Complete result of a single file conversion."""
35
+
36
+ # Source information
37
+ source_file: FileInfo
38
+
39
+ # Output
40
+ output_path: Path | None = None
41
+ markdown_content: str = ""
42
+
43
+ # Quality assessment
44
+ confidence_score: int = 0
45
+ grade: str = "F"
46
+
47
+ # Engine details
48
+ engine_used: str = ""
49
+ fallback_used: bool = False
50
+ defer_to_ocr: bool = False # Phase-1 batch flag: poor PyMuPDF result, defer to Phase 2 OCR
51
+
52
+ # Performance
53
+ processing_time_seconds: float = 0.0
54
+
55
+ # Issues
56
+ warnings: list[str] = field(default_factory=list)
57
+ error: str | None = None
58
+
59
+ # Metadata from the engine
60
+ metadata: dict[str, Any] = field(default_factory=dict)
61
+
62
+ # Timestamp
63
+ timestamp: datetime = field(default_factory=datetime.now)
64
+
65
+ @property
66
+ def success(self) -> bool:
67
+ """Whether the conversion was successful (not failed)."""
68
+ return self.error is None and self.confidence_score >= 40
69
+
70
+ @property
71
+ def moved_to(self) -> str:
72
+ """Where the original file was moved to."""
73
+ return "processed" if self.success else "failed"
74
+
75
+ def to_report_dict(self) -> dict[str, Any]:
76
+ """Serialize to a JSON-compatible dictionary for reporting."""
77
+ return {
78
+ "agent": "Warraqa v1.0.0",
79
+ "timestamp": self.timestamp.isoformat(),
80
+ "source": {
81
+ "filename": self.source_file.filename,
82
+ "path": str(self.source_file.path),
83
+ "type": self.source_file.file_type,
84
+ "size_bytes": self.source_file.size_bytes,
85
+ "mime_type": self.source_file.mime_type,
86
+ },
87
+ "result": {
88
+ "output_path": str(self.output_path) if self.output_path else None,
89
+ "engine_used": self.engine_used,
90
+ "fallback_used": self.fallback_used,
91
+ "confidence_score": self.confidence_score,
92
+ "grade": self.grade,
93
+ "processing_time_seconds": round(self.processing_time_seconds, 2),
94
+ "warnings": self.warnings,
95
+ "error": self.error,
96
+ "metadata": self.metadata,
97
+ },
98
+ "moved_to": self.moved_to,
99
+ }
100
+
101
+
102
+ @dataclass
103
+ class BatchReport:
104
+ """Summary of a batch conversion run."""
105
+
106
+ results: list[ConversionResult] = field(default_factory=list)
107
+ start_time: datetime = field(default_factory=datetime.now)
108
+ end_time: datetime | None = None
109
+
110
+ @property
111
+ def total_files(self) -> int:
112
+ return len(self.results)
113
+
114
+ @property
115
+ def successful(self) -> int:
116
+ return sum(1 for r in self.results if r.success)
117
+
118
+ @property
119
+ def failed(self) -> int:
120
+ return sum(1 for r in self.results if not r.success)
121
+
122
+ @property
123
+ def average_score(self) -> float:
124
+ if not self.results:
125
+ return 0.0
126
+ return sum(r.confidence_score for r in self.results) / len(self.results)
127
+
128
+ @property
129
+ def average_time(self) -> float:
130
+ if not self.results:
131
+ return 0.0
132
+ return sum(r.processing_time_seconds for r in self.results) / len(self.results)
133
+
134
+ @property
135
+ def total_time_seconds(self) -> float:
136
+ if self.end_time and self.start_time:
137
+ return (self.end_time - self.start_time).total_seconds()
138
+ return sum(r.processing_time_seconds for r in self.results)
139
+
140
+ @property
141
+ def grade_distribution(self) -> dict[str, int]:
142
+ dist: dict[str, int] = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0}
143
+ for r in self.results:
144
+ if r.grade in dist:
145
+ dist[r.grade] += 1
146
+ return dist
147
+
148
+ def summary(self) -> dict[str, Any]:
149
+ """Generate a summary dictionary for the batch."""
150
+ return {
151
+ "total_files": self.total_files,
152
+ "successful": self.successful,
153
+ "failed": self.failed,
154
+ "average_score": round(self.average_score, 1),
155
+ "average_time_seconds": round(self.average_time, 2),
156
+ "total_time_seconds": round(self.total_time_seconds, 2),
157
+ "grade_distribution": self.grade_distribution,
158
+ }
warraqa/scoring/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Warraqa quality scoring — multi-dimensional conversion quality assessment."""
2
+
3
+ from warraqa.scoring.scorer import ConversionScorer # noqa: F401
4
+
5
+ __all__ = ["ConversionScorer"]
warraqa/scoring/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (353 Bytes). View file
 
warraqa/scoring/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (345 Bytes). View file
 
warraqa/scoring/__pycache__/scorer.cpython-313.pyc ADDED
Binary file (14.7 kB). View file
 
warraqa/scoring/__pycache__/scorer.cpython-314.pyc ADDED
Binary file (16.3 kB). View file
 
warraqa/scoring/scorer.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Warraqa Conversion Scorer
3
+
4
+ Multi-dimensional quality assessment engine that evaluates conversions
5
+ across 5 weighted dimensions: completeness, structure, encoding, density, readability.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ import unicodedata
13
+ from collections import Counter
14
+ from typing import Any
15
+
16
+ from warraqa.models import FileInfo
17
+
18
+ logger = logging.getLogger("warraqa.scoring")
19
+
20
+ EXPECTED_WORDS_PER_KB = {"pdf": 40.0, "docx": 80.0, "doc": 30.0}
21
+
22
+ GARBLED_PATTERNS = [
23
+ r"[□■◆◇►◄▲▼●○★☆♠♣♥♦]{3,}",
24
+ r"[\ufffd]{2,}",
25
+ r"[^\x00-\x7F]{10,}",
26
+ r"(?:[\x80-\x9f]){3,}",
27
+ ]
28
+
29
+ GRADE_MAP = [(90, "A"), (75, "B"), (60, "C"), (40, "D"), (0, "F")]
30
+
31
+
32
+ class ConversionScorer:
33
+ """Scores document-to-Markdown conversions on a 0-100 scale with A-F grades."""
34
+
35
+ def __init__(self, fail_threshold: int = 40, warn_threshold: int = 70):
36
+ self.fail_threshold = fail_threshold
37
+ self.warn_threshold = warn_threshold
38
+
39
+ def score(
40
+ self, markdown: str, source_info: FileInfo, metadata: dict[str, Any] | None = None
41
+ ) -> tuple[int, str, list[str]]:
42
+ """Score a conversion. Returns (score, grade, warnings)."""
43
+ if metadata is None:
44
+ metadata = {}
45
+ warnings: list[str] = []
46
+
47
+ if not markdown or not markdown.strip():
48
+ warnings.append("Conversion produced empty output")
49
+ return 0, "F", warnings
50
+
51
+ completeness = self._score_text_completeness(markdown, source_info, metadata, warnings)
52
+ structure = self._score_structure_integrity(markdown, metadata, warnings)
53
+ encoding = self._score_encoding_quality(markdown, warnings)
54
+ density = self._score_content_density(markdown, warnings)
55
+ readability = self._score_readability(markdown, warnings)
56
+
57
+ composite = (
58
+ completeness * 0.30 + structure * 0.25 + encoding * 0.20
59
+ + density * 0.15 + readability * 0.10
60
+ )
61
+ final_score = max(0, min(100, round(composite)))
62
+ grade = self._score_to_grade(final_score)
63
+
64
+ if final_score < self.fail_threshold:
65
+ warnings.insert(0, f"Score {final_score} below fail threshold ({self.fail_threshold})")
66
+ elif final_score < self.warn_threshold:
67
+ warnings.insert(0, f"Score {final_score} below warning threshold ({self.warn_threshold})")
68
+
69
+ logger.debug(
70
+ f"Scoring: Completeness={completeness:.0f} Structure={structure:.0f} "
71
+ f"Encoding={encoding:.0f} Density={density:.0f} Readability={readability:.0f} "
72
+ f"→ {final_score} ({grade})"
73
+ )
74
+ return final_score, grade, warnings
75
+
76
+ def _score_text_completeness(
77
+ self, markdown: str, source_info: FileInfo, metadata: dict, warnings: list[str]
78
+ ) -> float:
79
+ """Text Completeness (30%): word count vs expected density for file size."""
80
+ word_count = len(markdown.split())
81
+ if word_count < 10:
82
+ warnings.append(f"Extremely short output: only {word_count} words")
83
+ return 5.0
84
+ if word_count < 50:
85
+ warnings.append(f"Very short output: only {word_count} words")
86
+ return 20.0
87
+
88
+ file_size_kb = max(source_info.size_bytes / 1024, 0.001)
89
+ expected_density = EXPECTED_WORDS_PER_KB.get(source_info.file_type, 40.0)
90
+ expected_words = max(file_size_kb * expected_density, 1)
91
+ ratio = word_count / expected_words
92
+
93
+ if ratio >= 0.8:
94
+ return 100.0
95
+ elif ratio >= 0.5:
96
+ return 70.0 + (ratio - 0.5) * 100.0
97
+ elif ratio >= 0.2:
98
+ return 30.0 + (ratio - 0.2) * 133.3
99
+ elif ratio >= 0.05:
100
+ warnings.append(
101
+ f"Word count ({word_count}) low for file size ({file_size_kb:.0f} KB)"
102
+ )
103
+ return 10.0 + (ratio - 0.05) * 133.3
104
+ else:
105
+ warnings.append(
106
+ f"Word count ({word_count}) very low for file size ({file_size_kb:.0f} KB)"
107
+ )
108
+ return ratio * 200.0
109
+
110
+ def _score_structure_integrity(
111
+ self, markdown: str, metadata: dict, warnings: list[str]
112
+ ) -> float:
113
+ """Structure Integrity (25%): presence of headings, lists, tables, formatting."""
114
+ lines = markdown.split("\n")
115
+ total_lines = len(lines)
116
+ if total_lines == 0:
117
+ return 0.0
118
+
119
+ heading_count = sum(1 for ln in lines if ln.strip().startswith("#"))
120
+ list_count = sum(
121
+ 1 for ln in lines
122
+ if ln.strip().startswith(("- ", "* ", "+ "))
123
+ or (len(ln.strip()) > 2 and ln.strip()[0].isdigit() and ". " in ln.strip()[:5])
124
+ )
125
+ table_rows = sum(
126
+ 1 for ln in lines
127
+ if ln.strip().startswith("|") and ln.strip().endswith("|")
128
+ )
129
+ bold_italic = len(re.findall(r"\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|_[^_]+_", markdown))
130
+ code_blocks = markdown.count("```")
131
+
132
+ structure_elements = heading_count + list_count + table_rows + bold_italic + code_blocks
133
+ word_count = len(markdown.split())
134
+
135
+ if word_count < 50:
136
+ return 80.0 if structure_elements > 0 else 50.0
137
+
138
+ structure_ratio = (structure_elements / word_count) * 100
139
+ if structure_ratio >= 2.0:
140
+ score = 100.0
141
+ elif structure_ratio >= 1.0:
142
+ score = 80.0
143
+ elif structure_ratio >= 0.5:
144
+ score = 65.0
145
+ elif structure_ratio >= 0.1:
146
+ score = 45.0
147
+ else:
148
+ score = 25.0
149
+ if word_count > 200:
150
+ warnings.append("Output is a wall of plain text with minimal formatting")
151
+
152
+ if heading_count >= 3:
153
+ score = min(100.0, score + 10.0)
154
+ elif heading_count >= 1:
155
+ score = min(100.0, score + 5.0)
156
+ return score
157
+
158
+ def _score_encoding_quality(self, markdown: str, warnings: list[str]) -> float:
159
+ """Encoding Quality (20%): garbled chars, mojibake, broken Unicode."""
160
+ total_chars = len(markdown)
161
+ if total_chars == 0:
162
+ return 0.0
163
+
164
+ issues = 0
165
+ for pattern in GARBLED_PATTERNS:
166
+ issues += len(re.findall(pattern, markdown))
167
+
168
+ replacement_chars = markdown.count("\ufffd")
169
+ if replacement_chars > 0:
170
+ issues += replacement_chars
171
+ warnings.append(f"Found {replacement_chars} Unicode replacement characters")
172
+
173
+ non_printable = sum(
174
+ 1 for c in markdown
175
+ if unicodedata.category(c).startswith("C") and c not in "\n\r\t"
176
+ )
177
+ if non_printable > total_chars * 0.01:
178
+ issues += non_printable
179
+ warnings.append(f"Found {non_printable} non-printable characters")
180
+
181
+ alnum_count = sum(1 for c in markdown if c.isalnum() or c.isspace())
182
+ alnum_ratio = alnum_count / total_chars
183
+ if alnum_ratio < 0.5:
184
+ issues += int((0.5 - alnum_ratio) * 100)
185
+ warnings.append(f"Low alphanumeric ratio ({alnum_ratio:.1%})")
186
+
187
+ issue_ratio = issues / total_chars
188
+ if issue_ratio == 0:
189
+ return 100.0
190
+ elif issue_ratio < 0.001:
191
+ return 95.0
192
+ elif issue_ratio < 0.005:
193
+ return 80.0
194
+ elif issue_ratio < 0.01:
195
+ return 65.0
196
+ elif issue_ratio < 0.05:
197
+ return 40.0
198
+ else:
199
+ return 15.0
200
+
201
+ def _score_content_density(self, markdown: str, warnings: list[str]) -> float:
202
+ """Content Density (15%): meaningful text vs whitespace and noise."""
203
+ lines = markdown.split("\n")
204
+ total_lines = len(lines)
205
+ if total_lines == 0:
206
+ return 0.0
207
+
208
+ blank_lines = sum(1 for ln in lines if not ln.strip())
209
+ blank_ratio = blank_lines / total_lines
210
+ short_lines = sum(1 for ln in lines if 0 < len(ln.strip()) < 3)
211
+ short_ratio = short_lines / total_lines
212
+
213
+ line_counts = Counter(ln.strip() for ln in lines if ln.strip())
214
+ most_common_count = line_counts.most_common(1)[0][1] if line_counts else 0
215
+ repetition_ratio = most_common_count / total_lines if total_lines > 0 else 0
216
+
217
+ score = 100.0
218
+ if blank_ratio > 0.5:
219
+ score -= (blank_ratio - 0.5) * 100
220
+ warnings.append(f"Excessive blank lines: {blank_ratio:.0%} of output is empty")
221
+ elif blank_ratio > 0.3:
222
+ score -= (blank_ratio - 0.3) * 50
223
+
224
+ if short_ratio > 0.3:
225
+ score -= (short_ratio - 0.3) * 80
226
+ warnings.append(f"Many very short lines ({short_ratio:.0%}) — possible OCR artifacts")
227
+
228
+ if repetition_ratio > 0.1 and most_common_count > 5:
229
+ score -= repetition_ratio * 60
230
+ warnings.append(f"Repetitive content: one line appears {most_common_count} times")
231
+
232
+ return max(0.0, min(100.0, score))
233
+
234
+ def _score_readability(self, markdown: str, warnings: list[str]) -> float:
235
+ """Readability (10%): line length, paragraph structure, whitespace balance."""
236
+ lines = markdown.split("\n")
237
+ non_empty = [ln for ln in lines if ln.strip()]
238
+ if not non_empty:
239
+ return 0.0
240
+
241
+ avg_len = sum(len(ln) for ln in non_empty) / len(non_empty)
242
+ paragraphs, in_para = 0, False
243
+ for line in lines:
244
+ if line.strip():
245
+ if not in_para:
246
+ paragraphs += 1
247
+ in_para = True
248
+ else:
249
+ in_para = False
250
+
251
+ score = 100.0
252
+ total_words = len(markdown.split())
253
+
254
+ if avg_len > 500:
255
+ score -= 30
256
+ warnings.append(f"Very long average line length ({avg_len:.0f} chars)")
257
+ elif avg_len > 300:
258
+ score -= 15
259
+
260
+ if paragraphs < 2 and total_words > 200:
261
+ score -= 20
262
+ warnings.append("Document appears to be a single block of text")
263
+ elif paragraphs < 3 and total_words > 500:
264
+ score -= 10
265
+
266
+ if paragraphs >= 5 and total_words > 200:
267
+ score = min(100.0, score + 10)
268
+
269
+ return max(0.0, min(100.0, score))
270
+
271
+ @staticmethod
272
+ def _score_to_grade(score: int) -> str:
273
+ for threshold, grade in GRADE_MAP:
274
+ if score >= threshold:
275
+ return grade
276
+ return "F"
warraqa/utils.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Warraqa Utilities
3
+
4
+ File detection, folder picker dialog, path helpers, and logging configuration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import mimetypes
11
+ import re
12
+ import shutil
13
+ import tkinter as tk
14
+ from pathlib import Path
15
+ from tkinter import filedialog
16
+
17
+ from rich.console import Console
18
+ from rich.logging import RichHandler
19
+
20
+ logger = logging.getLogger("warraqa")
21
+ console = Console()
22
+
23
+ # Supported file extensions and their type mapping
24
+ EXTENSION_TYPE_MAP: dict[str, str] = {
25
+ ".pdf": "pdf",
26
+ ".docx": "docx",
27
+ ".doc": "doc",
28
+ ".pptx": "pptx",
29
+ ".ppt": "ppt",
30
+ }
31
+
32
+ # MIME type fallback mapping
33
+ MIME_TYPE_MAP: dict[str, str] = {
34
+ "application/pdf": "pdf",
35
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
36
+ "application/msword": "doc",
37
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
38
+ "application/vnd.ms-powerpoint": "ppt",
39
+ }
40
+
41
+
42
+ def detect_file_type(path: Path) -> str | None:
43
+ """
44
+ Detect file type using extension first, then MIME type as fallback.
45
+
46
+ Returns:
47
+ File type string ("pdf", "docx", "doc") or None if unsupported.
48
+ """
49
+ # Try extension first (most reliable for common formats)
50
+ ext = path.suffix.lower()
51
+ if ext in EXTENSION_TYPE_MAP:
52
+ return EXTENSION_TYPE_MAP[ext]
53
+
54
+ # Fallback to MIME type detection
55
+ mime_type, _ = mimetypes.guess_type(str(path))
56
+ if mime_type and mime_type in MIME_TYPE_MAP:
57
+ return MIME_TYPE_MAP[mime_type]
58
+
59
+ # Try python-magic if available
60
+ try:
61
+ import magic # type: ignore
62
+
63
+ mime_type = magic.from_file(str(path), mime=True)
64
+ if mime_type in MIME_TYPE_MAP:
65
+ return MIME_TYPE_MAP[mime_type]
66
+ except ImportError:
67
+ pass
68
+ except Exception as e:
69
+ logger.debug(f"python-magic detection failed for {path}: {e}")
70
+
71
+ return None
72
+
73
+
74
+ def get_mime_type(path: Path) -> str:
75
+ """Get the MIME type of a file."""
76
+ # Try python-magic first for accuracy
77
+ try:
78
+ import magic # type: ignore
79
+
80
+ return magic.from_file(str(path), mime=True)
81
+ except (ImportError, Exception):
82
+ pass
83
+
84
+ # Fallback to mimetypes module
85
+ mime_type, _ = mimetypes.guess_type(str(path))
86
+ return mime_type or "application/octet-stream"
87
+
88
+
89
+ def open_folder_picker(title: str = "Select Input Folder") -> Path | None:
90
+ """
91
+ Open a native folder picker dialog.
92
+
93
+ Returns:
94
+ Selected folder path, or None if cancelled.
95
+ """
96
+ root = tk.Tk()
97
+ root.withdraw() # Hide the root window
98
+ root.attributes("-topmost", True) # Bring dialog to front
99
+
100
+ folder_path = filedialog.askdirectory(title=title)
101
+
102
+ root.destroy()
103
+
104
+ if folder_path:
105
+ return Path(folder_path)
106
+ return None
107
+
108
+
109
+ def ensure_directories(*dirs: Path | str) -> None:
110
+ """Create directories if they don't exist."""
111
+ for d in dirs:
112
+ Path(d).mkdir(parents=True, exist_ok=True)
113
+
114
+
115
+ def safe_move(src: Path, dest_dir: Path) -> Path:
116
+ """
117
+ Move a file to a destination directory, handling name collisions.
118
+
119
+ If a file with the same name already exists in dest_dir, appends
120
+ _1, _2, etc. to the filename.
121
+
122
+ Returns:
123
+ The final destination path.
124
+ """
125
+ dest_dir.mkdir(parents=True, exist_ok=True)
126
+ dest = dest_dir / src.name
127
+
128
+ if dest.exists():
129
+ stem = src.stem
130
+ suffix = src.suffix
131
+ counter = 1
132
+ while dest.exists():
133
+ dest = dest_dir / f"{stem}_{counter}{suffix}"
134
+ counter += 1
135
+
136
+ shutil.move(str(src), str(dest))
137
+ logger.debug(f"Moved: {src.name} → {dest}")
138
+ return dest
139
+
140
+
141
+ def sanitize_filename(name: str) -> str:
142
+ """
143
+ Clean a filename for use as a markdown output filename.
144
+
145
+ Removes special characters, replaces spaces with underscores,
146
+ and ensures the name is filesystem-safe.
147
+ """
148
+ # Remove file extension if present
149
+ name = Path(name).stem
150
+
151
+ # Replace common separators with underscores
152
+ name = re.sub(r"[\s\-]+", "_", name)
153
+
154
+ # Remove characters that are problematic on filesystems
155
+ name = re.sub(r"[^\w\.\-]", "", name)
156
+
157
+ # Collapse multiple underscores
158
+ name = re.sub(r"_+", "_", name)
159
+
160
+ # Strip leading/trailing underscores
161
+ name = name.strip("_")
162
+
163
+ # Ensure we have something
164
+ if not name:
165
+ name = "untitled"
166
+
167
+ return name
168
+
169
+
170
+ def find_supported_files(directory: Path) -> list[Path]:
171
+ """
172
+ Find all supported files in a directory (non-recursive).
173
+
174
+ Returns:
175
+ List of paths to supported files, sorted alphabetically.
176
+ """
177
+ supported = []
178
+ if not directory.is_dir():
179
+ logger.warning(f"Not a directory: {directory}")
180
+ return supported
181
+
182
+ for file_path in sorted(directory.iterdir()):
183
+ if file_path.is_file() and file_path.suffix.lower() in EXTENSION_TYPE_MAP:
184
+ supported.append(file_path)
185
+
186
+ return supported
187
+
188
+
189
+ def setup_logging(level: str = "INFO", log_file: str | None = None) -> None:
190
+ """
191
+ Configure structured logging with rich console output.
192
+
193
+ Args:
194
+ level: Logging level (DEBUG, INFO, WARNING, ERROR)
195
+ log_file: Optional path to a log file
196
+ """
197
+ log_level = getattr(logging, level.upper(), logging.INFO)
198
+
199
+ # Configure root warraqa logger
200
+ warraqa_logger = logging.getLogger("warraqa")
201
+ warraqa_logger.setLevel(log_level)
202
+
203
+ # Clear existing handlers
204
+ warraqa_logger.handlers.clear()
205
+
206
+ # Rich console handler
207
+ rich_handler = RichHandler(
208
+ console=console,
209
+ show_time=True,
210
+ show_path=False,
211
+ markup=True,
212
+ rich_tracebacks=True,
213
+ )
214
+ rich_handler.setLevel(log_level)
215
+ rich_handler.setFormatter(logging.Formatter("%(message)s"))
216
+ warraqa_logger.addHandler(rich_handler)
217
+
218
+ # File handler (if specified)
219
+ if log_file:
220
+ log_path = Path(log_file)
221
+ log_path.parent.mkdir(parents=True, exist_ok=True)
222
+ file_handler = logging.FileHandler(str(log_path), encoding="utf-8")
223
+ file_handler.setLevel(log_level)
224
+ file_handler.setFormatter(
225
+ logging.Formatter(
226
+ "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
227
+ datefmt="%Y-%m-%d %H:%M:%S",
228
+ )
229
+ )
230
+ warraqa_logger.addHandler(file_handler)
231
+
232
+ warraqa_logger.debug("Logging initialized at level %s", level)
233
+
234
+
235
+ def format_file_size(size_bytes: int) -> str:
236
+ """Format a file size in bytes to a human-readable string."""
237
+ for unit in ("B", "KB", "MB", "GB"):
238
+ if size_bytes < 1024:
239
+ return f"{size_bytes:.1f} {unit}"
240
+ size_bytes /= 1024 # type: ignore
241
+ return f"{size_bytes:.1f} TB"
242
+
243
+
244
+ # Unpaired-surrogate range — invalid in well-formed Unicode; appear in engine
245
+ # output when a PDF has malformed text encodings or custom font cmaps that
246
+ # don't round-trip cleanly. Python's UTF-8 encoder refuses them, which
247
+ # crashes `Path.write_text` and breaks the whole batch.
248
+ _SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
249
+
250
+
251
+ def sanitize_markdown(text: str) -> tuple[str, int]:
252
+ """
253
+ Replace lone Unicode surrogates with U+FFFD (replacement char).
254
+
255
+ Returns a (cleaned_text, replacement_count) tuple so callers can warn
256
+ about how much was sanitized.
257
+ """
258
+ if not text:
259
+ return text, 0
260
+ count = 0
261
+
262
+ def _sub(_match: re.Match) -> str:
263
+ nonlocal count
264
+ count += 1
265
+ return "�"
266
+
267
+ cleaned = _SURROGATE_RE.sub(_sub, text)
268
+ return cleaned, count
269
+
270
+ class PDFAnalyzer:
271
+ """Analyzes PDFs to determine structural properties before conversion."""
272
+
273
+ @staticmethod
274
+ def is_native_text(file_path: Path, sample_pages: int = 5, threshold: int = 100) -> bool:
275
+ """
276
+ Determines if a PDF is natively digital (contains embedded text)
277
+ or if it is a scanned image.
278
+ """
279
+ try:
280
+ import fitz
281
+ total_chars = 0
282
+ pages_checked = 0
283
+
284
+ with fitz.open(str(file_path)) as doc:
285
+ pages_to_check = min(sample_pages, len(doc))
286
+
287
+ if pages_to_check == 0:
288
+ return False
289
+
290
+ for i in range(pages_to_check):
291
+ page = doc[i]
292
+ text = page.get_text()
293
+ total_chars += len(text.strip())
294
+ pages_checked += 1
295
+
296
+ if pages_checked == 0:
297
+ return False
298
+
299
+ avg_chars_per_page = total_chars / pages_checked
300
+
301
+ if avg_chars_per_page > threshold:
302
+ logger.debug(f"Document classified as NATIVE TEXT (avg {avg_chars_per_page:.0f} chars/page).")
303
+ return True
304
+ else:
305
+ logger.debug(f"Document classified as SCANNED IMAGE (avg {avg_chars_per_page:.0f} chars/page).")
306
+ return False
307
+
308
+ except Exception as e:
309
+ logger.warning(f"Failed to analyze PDF {file_path.name}: {e}. Defaulting to SCANNED.")
310
+ return False