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.
- universal_doc_parser-1.0.0.dist-info/METADATA +692 -0
- universal_doc_parser-1.0.0.dist-info/RECORD +48 -0
- universal_doc_parser-1.0.0.dist-info/WHEEL +4 -0
- universal_doc_parser-1.0.0.dist-info/licenses/LICENSE +21 -0
- universal_parser/__init__.py +28 -0
- universal_parser/adaptive/__init__.py +17 -0
- universal_parser/adaptive/config_cache.py +127 -0
- universal_parser/adaptive/fingerprint.py +133 -0
- universal_parser/adaptive/tuner.py +97 -0
- universal_parser/core/__init__.py +0 -0
- universal_parser/core/engine.py +55 -0
- universal_parser/core/router.py +38 -0
- universal_parser/core/schema.py +58 -0
- universal_parser/core/sniffer.py +125 -0
- universal_parser/enrichment/__init__.py +0 -0
- universal_parser/enrichment/vlm_enricher.py +0 -0
- universal_parser/exports/__init__.py +1 -0
- universal_parser/exports/to_chunks.py +108 -0
- universal_parser/exports/to_graph.py +114 -0
- universal_parser/exports/to_markdown.py +31 -0
- universal_parser/extractors/__init__.py +0 -0
- universal_parser/extractors/base.py +39 -0
- universal_parser/extractors/images/__init__.py +0 -0
- universal_parser/extractors/images/scan_extractor.py +79 -0
- universal_parser/extractors/mail/__init__.py +0 -0
- universal_parser/extractors/mail/mail_extractor.py +206 -0
- universal_parser/extractors/office/__init__.py +0 -0
- universal_parser/extractors/office/docx_extractor.py +131 -0
- universal_parser/extractors/office/legacy_extractor.py +112 -0
- universal_parser/extractors/office/pptx_extractor.py +109 -0
- universal_parser/extractors/office/xlsx_extractor.py +93 -0
- universal_parser/extractors/pdf/__init__.py +0 -0
- universal_parser/extractors/pdf/native.py +331 -0
- universal_parser/extractors/pdf/tables.py +155 -0
- universal_parser/extractors/pdf/visual_onnx.py +0 -0
- universal_parser/extractors/structured/__init__.py +0 -0
- universal_parser/extractors/structured/csv_extractor.py +86 -0
- universal_parser/extractors/structured/json_xml_extractor.py +104 -0
- universal_parser/extractors/structured/parquet_extractor.py +59 -0
- universal_parser/extractors/web/__init__.py +1 -0
- universal_parser/extractors/web/epub_extractor.py +81 -0
- universal_parser/extractors/web/html_extractor.py +111 -0
- universal_parser/mcp/__init__.py +1 -0
- universal_parser/mcp/server.py +61 -0
- universal_parser/observability/__init__.py +12 -0
- universal_parser/observability/dashboard.py +231 -0
- universal_parser/observability/logger.py +0 -0
- 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()
|