universal-doc-parser 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. universal_doc_parser-1.0.0.dist-info/METADATA +692 -0
  2. universal_doc_parser-1.0.0.dist-info/RECORD +48 -0
  3. universal_doc_parser-1.0.0.dist-info/WHEEL +4 -0
  4. universal_doc_parser-1.0.0.dist-info/licenses/LICENSE +21 -0
  5. universal_parser/__init__.py +28 -0
  6. universal_parser/adaptive/__init__.py +17 -0
  7. universal_parser/adaptive/config_cache.py +127 -0
  8. universal_parser/adaptive/fingerprint.py +133 -0
  9. universal_parser/adaptive/tuner.py +97 -0
  10. universal_parser/core/__init__.py +0 -0
  11. universal_parser/core/engine.py +55 -0
  12. universal_parser/core/router.py +38 -0
  13. universal_parser/core/schema.py +58 -0
  14. universal_parser/core/sniffer.py +125 -0
  15. universal_parser/enrichment/__init__.py +0 -0
  16. universal_parser/enrichment/vlm_enricher.py +0 -0
  17. universal_parser/exports/__init__.py +1 -0
  18. universal_parser/exports/to_chunks.py +108 -0
  19. universal_parser/exports/to_graph.py +114 -0
  20. universal_parser/exports/to_markdown.py +31 -0
  21. universal_parser/extractors/__init__.py +0 -0
  22. universal_parser/extractors/base.py +39 -0
  23. universal_parser/extractors/images/__init__.py +0 -0
  24. universal_parser/extractors/images/scan_extractor.py +79 -0
  25. universal_parser/extractors/mail/__init__.py +0 -0
  26. universal_parser/extractors/mail/mail_extractor.py +206 -0
  27. universal_parser/extractors/office/__init__.py +0 -0
  28. universal_parser/extractors/office/docx_extractor.py +131 -0
  29. universal_parser/extractors/office/legacy_extractor.py +112 -0
  30. universal_parser/extractors/office/pptx_extractor.py +109 -0
  31. universal_parser/extractors/office/xlsx_extractor.py +93 -0
  32. universal_parser/extractors/pdf/__init__.py +0 -0
  33. universal_parser/extractors/pdf/native.py +331 -0
  34. universal_parser/extractors/pdf/tables.py +155 -0
  35. universal_parser/extractors/pdf/visual_onnx.py +0 -0
  36. universal_parser/extractors/structured/__init__.py +0 -0
  37. universal_parser/extractors/structured/csv_extractor.py +86 -0
  38. universal_parser/extractors/structured/json_xml_extractor.py +104 -0
  39. universal_parser/extractors/structured/parquet_extractor.py +59 -0
  40. universal_parser/extractors/web/__init__.py +1 -0
  41. universal_parser/extractors/web/epub_extractor.py +81 -0
  42. universal_parser/extractors/web/html_extractor.py +111 -0
  43. universal_parser/mcp/__init__.py +1 -0
  44. universal_parser/mcp/server.py +61 -0
  45. universal_parser/observability/__init__.py +12 -0
  46. universal_parser/observability/dashboard.py +231 -0
  47. universal_parser/observability/logger.py +0 -0
  48. universal_parser/observability/metrics.py +99 -0
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from pathlib import Path
5
+ from typing import ClassVar
6
+
7
+ import pyarrow.parquet as pq
8
+
9
+ from universal_parser.core.router import register
10
+ from universal_parser.core.schema import Element, TableData
11
+ from universal_parser.core.sniffer import FileType
12
+ from universal_parser.extractors.base import BaseExtractor
13
+
14
+
15
+ @register
16
+ class ParquetExtractor(BaseExtractor):
17
+ """
18
+ Extractor for Apache Parquet files (.parquet).
19
+ Handles:
20
+ - Reads schema headers and row data using pyarrow
21
+ - Converts columnar data into structured TableData & Markdown
22
+ """
23
+
24
+ supported_types: ClassVar[list[FileType]] = [FileType.PARQUET]
25
+
26
+ def stream(self, path: str | Path) -> Iterator[Element]:
27
+ path_obj = Path(path)
28
+
29
+ try:
30
+ parquet_file = pq.ParquetFile(str(path_obj))
31
+ schema = parquet_file.schema.to_arrow_schema()
32
+ headers = schema.names
33
+
34
+ # Read table data
35
+ table = parquet_file.read()
36
+ df_dict = table.to_pydict()
37
+
38
+ num_rows = table.num_rows
39
+ rows = []
40
+ for i in range(num_rows):
41
+ row = [str(df_dict[col][i]).strip() for col in headers]
42
+ rows.append(row)
43
+
44
+ # Generate markdown table representation
45
+ md_header = "| " + " | ".join(headers) + " |"
46
+ md_separator = "| " + " | ".join(["---"] * len(headers)) + " |"
47
+ md_rows = ["| " + " | ".join(r) + " |" for r in rows]
48
+ markdown_repr = "\n".join([md_header, md_separator] + md_rows)
49
+
50
+ yield Element(
51
+ type="table",
52
+ text=path_obj.name,
53
+ data=TableData(headers=headers, rows=rows),
54
+ markdown_repr=markdown_repr,
55
+ confidence=1.0,
56
+ )
57
+
58
+ except Exception: # noqa: BLE001
59
+ return
@@ -0,0 +1 @@
1
+ # Web extractors
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from pathlib import Path
5
+ from typing import ClassVar
6
+
7
+ import ebooklib
8
+ from ebooklib import epub
9
+ from selectolax.parser import HTMLParser
10
+
11
+ from universal_parser.core.router import register
12
+ from universal_parser.core.schema import Element
13
+ from universal_parser.core.sniffer import FileType
14
+ from universal_parser.extractors.base import BaseExtractor
15
+
16
+
17
+ @register
18
+ class EPUBExtractor(BaseExtractor):
19
+ """
20
+ Extractor for EPUB e-books (.epub) using ebooklib & selectolax.
21
+
22
+ Handles:
23
+ - Iterates over document items (chapters/sections)
24
+ - Converts chapter titles to Headings
25
+ - Extracts chapter paragraphs and list items
26
+ """
27
+
28
+ supported_types: ClassVar[list[FileType]] = [FileType.EPUB]
29
+
30
+ def stream(self, path: str | Path) -> Iterator[Element]:
31
+ path_obj = Path(path)
32
+
33
+ try:
34
+ book = epub.read_epub(str(path_obj))
35
+
36
+ for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
37
+ html_content = item.get_content().decode("utf-8", errors="replace")
38
+ if not html_content.strip():
39
+ continue
40
+
41
+ parser = HTMLParser(html_content)
42
+
43
+ # Clean noise tags
44
+ for tag in parser.css("script, style, noscript, svg"):
45
+ tag.decompose()
46
+
47
+ body = parser.body or parser.root
48
+ if body is None:
49
+ continue
50
+
51
+ for node in body.iter():
52
+ tag_name = node.tag.lower() if node.tag else ""
53
+
54
+ # Headings
55
+ if tag_name in ("h1", "h2", "h3", "h4", "h5", "h6"):
56
+ level = int(tag_name[1])
57
+ text = node.text(strip=True)
58
+ if text:
59
+ yield Element(
60
+ type="heading",
61
+ level=level,
62
+ text=text,
63
+ markdown_repr=f"{'#' * level} {text}",
64
+ confidence=1.0,
65
+ )
66
+
67
+ # Paragraphs & Lists
68
+ elif tag_name in ("p", "li"):
69
+ text = node.text(strip=True)
70
+ if text:
71
+ prefix = "- " if tag_name == "li" else ""
72
+ elem_type = "list_item" if tag_name == "li" else "paragraph"
73
+ yield Element(
74
+ type=elem_type,
75
+ text=text,
76
+ markdown_repr=f"{prefix}{text}",
77
+ confidence=1.0,
78
+ )
79
+
80
+ except Exception: # noqa: BLE001
81
+ return
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from pathlib import Path
5
+ from typing import ClassVar
6
+
7
+ from selectolax.parser import HTMLParser
8
+
9
+ from universal_parser.core.router import register
10
+ from universal_parser.core.schema import Element, TableData
11
+ from universal_parser.core.sniffer import FileType
12
+ from universal_parser.extractors.base import BaseExtractor
13
+
14
+
15
+ @register
16
+ class HTMLExtractor(BaseExtractor):
17
+ """
18
+ Ultra-fast HTML/XHTML extractor using selectolax.
19
+
20
+ Handles:
21
+ - Headings (h1 - h6) preserving document tree hierarchy
22
+ - Text paragraphs (<p>, <li>)
23
+ - HTML <table> tags -> TableData + Markdown tables
24
+ - Strips script, style, and navigation junk tags
25
+ """
26
+
27
+ supported_types: ClassVar[list[FileType]] = [FileType.HTML]
28
+
29
+ def stream(self, path: str | Path) -> Iterator[Element]:
30
+ path_obj = Path(path)
31
+
32
+ try:
33
+ with open(path_obj, encoding="utf-8", errors="replace") as f:
34
+ html_content = f.read()
35
+
36
+ if not html_content.strip():
37
+ return
38
+
39
+ parser = HTMLParser(html_content)
40
+
41
+ # Strip noise (script, style, svg)
42
+ for tag in parser.css("script, style, noscript, svg"):
43
+ tag.decompose()
44
+
45
+ body = parser.body or parser.root
46
+ if body is None:
47
+ return
48
+
49
+ for node in body.iter():
50
+ tag_name = node.tag.lower() if node.tag else ""
51
+
52
+ # Headings
53
+ if tag_name in ("h1", "h2", "h3", "h4", "h5", "h6"):
54
+ level = int(tag_name[1])
55
+ text = node.text(strip=True)
56
+ if text:
57
+ yield Element(
58
+ type="heading",
59
+ level=level,
60
+ text=text,
61
+ markdown_repr=f"{'#' * level} {text}",
62
+ confidence=1.0,
63
+ )
64
+
65
+ # Paragraphs and Lists
66
+ elif tag_name in ("p", "li"):
67
+ text = node.text(strip=True)
68
+ if text:
69
+ prefix = "- " if tag_name == "li" else ""
70
+ elem_type = "list_item" if tag_name == "li" else "paragraph"
71
+ yield Element(
72
+ type=elem_type,
73
+ text=text,
74
+ markdown_repr=f"{prefix}{text}",
75
+ confidence=1.0,
76
+ )
77
+
78
+ # Tables
79
+ elif tag_name == "table":
80
+ headers = []
81
+ rows = []
82
+
83
+ for th in node.css("th"):
84
+ h_text = th.text(strip=True)
85
+ if h_text:
86
+ headers.append(h_text)
87
+
88
+ for tr in node.css("tr"):
89
+ tds = [td.text(strip=True) for td in tr.css("td")]
90
+ if any(tds):
91
+ rows.append(tds)
92
+
93
+ if not headers and rows:
94
+ headers = [f"Column_{i + 1}" for i in range(len(rows[0]))]
95
+
96
+ if headers or rows:
97
+ md_header = "| " + " | ".join(headers) + " |"
98
+ md_separator = "| " + " | ".join(["---"] * len(headers)) + " |"
99
+ md_rows = ["| " + " | ".join(r) + " |" for r in rows]
100
+ markdown_repr = "\n".join([md_header, md_separator] + md_rows)
101
+
102
+ yield Element(
103
+ type="table",
104
+ text=path_obj.name,
105
+ data=TableData(headers=headers, rows=rows),
106
+ markdown_repr=markdown_repr,
107
+ confidence=1.0,
108
+ )
109
+
110
+ except Exception: # noqa: BLE001
111
+ return
@@ -0,0 +1 @@
1
+ # MCP package
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from universal_parser.core.engine import parse
9
+ from universal_parser.core.router import FORMAT_REGISTRY
10
+ from universal_parser.exports.to_chunks import to_chunks
11
+ from universal_parser.exports.to_markdown import to_markdown
12
+
13
+ # Initialize FastMCP Server
14
+ mcp = FastMCP(
15
+ "universal-parser",
16
+ instructions="A zero-GPU universal document ingestion engine for RAG and AI agents.",
17
+ )
18
+
19
+
20
+ @mcp.tool()
21
+ def parse_document(file_path: str, output_format: str = "markdown") -> str:
22
+ """
23
+ Parse any local document (PDF, DOCX, XLSX, PPTX, HTML, EPUB, CSV, JSON, XML, Parquet, Email, Image).
24
+ Args:
25
+ file_path: Path to the document.
26
+ output_format: 'markdown', 'chunks', or 'json'.
27
+ """
28
+ path = Path(file_path)
29
+ if not path.exists():
30
+ return f"Error: File not found: {file_path}"
31
+
32
+ try:
33
+ doc = parse(path)
34
+
35
+ if output_format == "markdown":
36
+ return to_markdown(doc)
37
+ elif output_format == "chunks":
38
+ chunks = to_chunks(doc, max_tokens=512)
39
+ return json.dumps([c.__dict__ for c in chunks], indent=2)
40
+ elif output_format == "json":
41
+ return doc.model_dump_json(indent=2)
42
+ else:
43
+ return f"Error: Unknown output format '{output_format}'. Use 'markdown', 'chunks' or 'json'."
44
+
45
+ except Exception as e: # noqa: BLE001
46
+ return f"Error parsing document: {e!s}"
47
+
48
+
49
+ @mcp.tool()
50
+ def list_supported_formats() -> list[str]:
51
+ """List all document file formats supported by Universal Parser."""
52
+ return sorted({ft.name.lower() for ft in FORMAT_REGISTRY})
53
+
54
+
55
+ def run_server() -> None:
56
+ """Run the MCP server over standard I/O (stdio)."""
57
+ mcp.run(transport="studio")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ run_server()
@@ -0,0 +1,12 @@
1
+ from universal_parser.observability.dashboard import (
2
+ export_dashboard,
3
+ generate_dashboard_html,
4
+ )
5
+ from universal_parser.observability.metrics import MetricsCollector, ParseEventMetric
6
+
7
+ __all__ = [
8
+ "MetricsCollector",
9
+ "ParseEventMetric",
10
+ "export_dashboard",
11
+ "generate_dashboard_html",
12
+ ]
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from universal_parser.observability.metrics import MetricsCollector
6
+
7
+
8
+ def generate_dashboard_html(collector: MetricsCollector | None = None) -> str:
9
+ """Generates a self-contained, responsive HTML observability dashboard."""
10
+ col = collector or MetricsCollector()
11
+ summary = col.get_summary()
12
+ events = col.get_events()
13
+
14
+ # Generate format table rows
15
+ format_rows = ""
16
+ for fmt, count in summary["format_breakdown"].items():
17
+ format_rows += f"<tr><td><strong>{fmt.upper()}</strong></td><td>{count}</td></tr>\n"
18
+
19
+ if not format_rows:
20
+ format_rows = "<tr><td colspan='2' style='text-align:center; color:#888;'>No events recorded yet</td></tr>"
21
+
22
+ # Generate recent events table rows
23
+ event_rows = ""
24
+ for e in reversed(events[-20:]):
25
+ anomaly_badge = (
26
+ f"<span class='badge badge-warn'>{len(e.anomalies)} anomalies</span>"
27
+ if e.anomalies
28
+ else "<span class='badge badge-ok'>clean</span>"
29
+ )
30
+ event_rows += f"""
31
+ <tr>
32
+ <td>{e.file_name}</td>
33
+ <td>{e.file_type.upper()}</td>
34
+ <td>{e.page_count}</td>
35
+ <td>{e.element_count}</td>
36
+ <td>{e.duration_ms} ms</td>
37
+ <td>{anomaly_badge}</td>
38
+ </tr>
39
+ """
40
+
41
+ if not event_rows:
42
+ event_rows = "<tr><td colspan='6' style='text-align:center; color:#888;'>No events recorded yet</td></tr>"
43
+
44
+ html = f"""<!DOCTYPE html>
45
+ <html lang="en">
46
+ <head>
47
+ <meta charset="UTF-8">
48
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
49
+ <title>Universal Parser — Observability & Drift Dashboard</title>
50
+ <style>
51
+ :root {{
52
+ --bg-color: #0f172a;
53
+ --card-bg: #1e293b;
54
+ --text-main: #f8fafc;
55
+ --text-muted: #94a3b8;
56
+ --primary: #38bdf8;
57
+ --accent: #818cf8;
58
+ --success: #4ade80;
59
+ --warning: #fbbf24;
60
+ --border: #334155;
61
+ }}
62
+ body {{
63
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
64
+ background-color: var(--bg-color);
65
+ color: var(--text-main);
66
+ margin: 0;
67
+ padding: 24px;
68
+ }}
69
+ .container {{
70
+ max-width: 1200px;
71
+ margin: 0 auto;
72
+ }}
73
+ header {{
74
+ margin-bottom: 32px;
75
+ border-bottom: 1px solid var(--border);
76
+ padding-bottom: 16px;
77
+ }}
78
+ h1 {{
79
+ margin: 0 0 8px 0;
80
+ font-size: 28px;
81
+ color: var(--primary);
82
+ }}
83
+ .subtitle {{
84
+ color: var(--text-muted);
85
+ margin: 0;
86
+ }}
87
+ .grid {{
88
+ display: grid;
89
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
90
+ gap: 16px;
91
+ margin-bottom: 32px;
92
+ }}
93
+ .card {{
94
+ background-color: var(--card-bg);
95
+ border: 1px solid var(--border);
96
+ border-radius: 8px;
97
+ padding: 20px;
98
+ }}
99
+ .card-title {{
100
+ color: var(--text-muted);
101
+ font-size: 13px;
102
+ text-transform: uppercase;
103
+ letter-spacing: 0.5px;
104
+ margin-bottom: 8px;
105
+ }}
106
+ .card-value {{
107
+ font-size: 32px;
108
+ font-weight: bold;
109
+ color: var(--text-main);
110
+ }}
111
+ table {{
112
+ width: 100%;
113
+ border-collapse: collapse;
114
+ margin-top: 12px;
115
+ background-color: var(--card-bg);
116
+ border-radius: 8px;
117
+ overflow: hidden;
118
+ border: 1px solid var(--border);
119
+ }}
120
+ th, td {{
121
+ padding: 12px 16px;
122
+ text-align: left;
123
+ border-bottom: 1px solid var(--border);
124
+ font-size: 14px;
125
+ }}
126
+ th {{
127
+ background-color: rgba(0, 0, 0, 0.2);
128
+ color: var(--primary);
129
+ font-weight: 600;
130
+ }}
131
+ tr:last-child td {{
132
+ border-bottom: none;
133
+ }}
134
+ .badge {{
135
+ padding: 4px 8px;
136
+ border-radius: 4px;
137
+ font-size: 12px;
138
+ font-weight: 600;
139
+ }}
140
+ .badge-ok {{
141
+ background-color: rgba(74, 222, 128, 0.15);
142
+ color: var(--success);
143
+ }}
144
+ .badge-warn {{
145
+ background-color: rgba(251, 191, 36, 0.15);
146
+ color: var(--warning);
147
+ }}
148
+ .layout-two-col {{
149
+ display: grid;
150
+ grid-template-columns: 1fr 2fr;
151
+ gap: 24px;
152
+ }}
153
+ @media (max-width: 768px) {{
154
+ .layout-two-col {{
155
+ grid-template-columns: 1fr;
156
+ }}
157
+ }}
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <div class="container">
162
+ <header>
163
+ <h1>Universal Parser — Observability Dashboard</h1>
164
+ <p class="subtitle">Real-time performance telemetry, anomaly tracking, and multi-format throughput.</p>
165
+ </header>
166
+
167
+ <div class="grid">
168
+ <div class="card">
169
+ <div class="card-title">Total Documents</div>
170
+ <div class="card-value">{summary["total_documents"]}</div>
171
+ </div>
172
+ <div class="card">
173
+ <div class="card-title">Total Pages</div>
174
+ <div class="card-value">{summary["total_pages"]}</div>
175
+ </div>
176
+ <div class="card">
177
+ <div class="card-title">Avg Latency</div>
178
+ <div class="card-value">{summary["avg_duration_ms"]}<span style="font-size:16px; color:var(--text-muted);"> ms</span></div>
179
+ </div>
180
+ <div class="card">
181
+ <div class="card-title">Drift / Anomalies</div>
182
+ <div class="card-value" style="color: {"var(--warning)" if summary["anomaly_count"] > 0 else "var(--success)"};">{summary["anomaly_count"]}</div>
183
+ </div>
184
+ </div>
185
+
186
+ <div class="layout-two-col">
187
+ <div>
188
+ <h2>Formats Handled</h2>
189
+ <table>
190
+ <thead>
191
+ <tr><th>Format</th><th>Documents</th></tr>
192
+ </thead>
193
+ <tbody>
194
+ {format_rows}
195
+ </tbody>
196
+ </table>
197
+ </div>
198
+
199
+ <div>
200
+ <h2>Recent Ingestion Stream</h2>
201
+ <table>
202
+ <thead>
203
+ <tr>
204
+ <th>File</th>
205
+ <th>Type</th>
206
+ <th>Pages</th>
207
+ <th>Elements</th>
208
+ <th>Latency</th>
209
+ <th>Status</th>
210
+ </tr>
211
+ </thead>
212
+ <tbody>
213
+ {event_rows}
214
+ </tbody>
215
+ </table>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ </body>
220
+ </html>
221
+ """
222
+ return html
223
+
224
+
225
+ def export_dashboard(output_path: str | Path, collector: MetricsCollector | None = None) -> Path:
226
+ """Exports the observability dashboard to an HTML file on disk."""
227
+ path = Path(output_path)
228
+ path.parent.mkdir(parents=True, exist_ok=True)
229
+ html = generate_dashboard_html(collector)
230
+ path.write_text(html, encoding="utf-8")
231
+ return path
File without changes
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from threading import Lock
6
+ from typing import Any, Self
7
+
8
+ from universal_parser.core.schema import Document
9
+
10
+
11
+ @dataclass
12
+ class ParseEventMetric:
13
+ """Telemetry record for a single document parse execution."""
14
+
15
+ file_name: str
16
+ file_type: str
17
+ page_count: int
18
+ element_count: int
19
+ duration_ms: float
20
+ timestamp: float = field(default_factory=time.time)
21
+ has_scanned_pages: bool = False
22
+ anomalies: list[str] = field(default_factory=list)
23
+
24
+
25
+ class MetricsCollector:
26
+ """Thread-safe collector for document ingestion telemetry and anomaly signals."""
27
+
28
+ _instance: MetricsCollector | None = None
29
+ _lock = Lock()
30
+
31
+ def __new__(cls) -> Self:
32
+ with cls._lock:
33
+ if cls._instance is None:
34
+ cls._instance = super().__new__(cls)
35
+ cls._instance._events = []
36
+ return cls._instance
37
+
38
+ def record_parse(
39
+ self,
40
+ doc: Document,
41
+ duration_ms: float,
42
+ anomalies: list[str] | None = None,
43
+ ) -> ParseEventMetric:
44
+ """Records telemetry for a parsed document."""
45
+ metric = ParseEventMetric(
46
+ file_name=doc.metadata.file_name,
47
+ file_type=doc.metadata.file_type,
48
+ page_count=doc.metadata.page_count or 1,
49
+ element_count=len(doc.content_tree),
50
+ duration_ms=round(duration_ms, 2),
51
+ has_scanned_pages=doc.metadata.has_scanned_pages,
52
+ anomalies=anomalies or [],
53
+ )
54
+ with self._lock:
55
+ self._events.append(metric)
56
+ return metric
57
+
58
+ def get_events(self) -> list[ParseEventMetric]:
59
+ with self._lock:
60
+ return list(self._events)
61
+
62
+ def get_summary(self) -> dict[str, Any]:
63
+ """Calculates aggregated performance statistics across all parsed documents."""
64
+ with self._lock:
65
+ events = list(self._events)
66
+
67
+ if not events:
68
+ return {
69
+ "total_documents": 0,
70
+ "total_pages": 0,
71
+ "total_elements": 0,
72
+ "avg_duration_ms": 0.0,
73
+ "format_breakdown": {},
74
+ "anomaly_count": 0,
75
+ }
76
+
77
+ total_docs = len(events)
78
+ total_pages = sum(e.page_count for e in events)
79
+ total_elements = sum(e.element_count for e in events)
80
+ avg_duration = round(sum(e.duration_ms for e in events) / total_docs, 2)
81
+
82
+ format_breakdown: dict[str, int] = {}
83
+ anomaly_count = 0
84
+ for e in events:
85
+ format_breakdown[e.file_type] = format_breakdown.get(e.file_type, 0) + 1
86
+ anomaly_count += len(e.anomalies)
87
+
88
+ return {
89
+ "total_documents": total_docs,
90
+ "total_pages": total_pages,
91
+ "total_elements": total_elements,
92
+ "avg_duration_ms": avg_duration,
93
+ "format_breakdown": format_breakdown,
94
+ "anomaly_count": anomaly_count,
95
+ }
96
+
97
+ def clear(self) -> None:
98
+ with self._lock:
99
+ self._events.clear()