parse-bench 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.
- parse_bench/__init__.py +3 -0
- parse_bench/analysis/__init__.py +6 -0
- parse_bench/analysis/aggregation_report.py +582 -0
- parse_bench/analysis/cli.py +472 -0
- parse_bench/analysis/comparison.py +382 -0
- parse_bench/analysis/comparison_core.py +357 -0
- parse_bench/analysis/comparison_report.py +2066 -0
- parse_bench/analysis/detailed_report.py +2254 -0
- parse_bench/analysis/leaderboard_report.py +852 -0
- parse_bench/analysis/metric_definitions.py +771 -0
- parse_bench/cli.py +267 -0
- parse_bench/data/__init__.py +1 -0
- parse_bench/data/cli.py +118 -0
- parse_bench/data/download.py +127 -0
- parse_bench/evaluation/__init__.py +11 -0
- parse_bench/evaluation/cli.py +435 -0
- parse_bench/evaluation/evaluators/__init__.py +17 -0
- parse_bench/evaluation/evaluators/base.py +34 -0
- parse_bench/evaluation/evaluators/extract.py +429 -0
- parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
- parse_bench/evaluation/evaluators/parse.py +1353 -0
- parse_bench/evaluation/evaluators/qa.py +199 -0
- parse_bench/evaluation/layout_adapters/__init__.py +21 -0
- parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
- parse_bench/evaluation/layout_adapters/base.py +105 -0
- parse_bench/evaluation/layout_adapters/registry.py +109 -0
- parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
- parse_bench/evaluation/layout_label_mappers/base.py +66 -0
- parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
- parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
- parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
- parse_bench/evaluation/metric_aggregation.py +56 -0
- parse_bench/evaluation/metrics/__init__.py +5 -0
- parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
- parse_bench/evaluation/metrics/attribution/constants.py +12 -0
- parse_bench/evaluation/metrics/attribution/core.py +1108 -0
- parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
- parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
- parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
- parse_bench/evaluation/metrics/base.py +33 -0
- parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
- parse_bench/evaluation/metrics/extract/__init__.py +29 -0
- parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
- parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
- parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
- parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
- parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
- parse_bench/evaluation/metrics/extract/test_types.py +11 -0
- parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
- parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
- parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
- parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
- parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
- parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
- parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
- parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
- parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
- parse_bench/evaluation/metrics/parse/__init__.py +5 -0
- parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
- parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
- parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
- parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
- parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
- parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
- parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
- parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
- parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
- parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
- parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
- parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
- parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
- parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
- parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
- parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
- parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
- parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
- parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
- parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
- parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
- parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
- parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
- parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
- parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
- parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
- parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
- parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
- parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
- parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
- parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
- parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
- parse_bench/evaluation/metrics/parse/test_types.py +103 -0
- parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
- parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
- parse_bench/evaluation/metrics/parse/utils.py +885 -0
- parse_bench/evaluation/metrics/qa/__init__.py +5 -0
- parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
- parse_bench/evaluation/qa/__init__.py +5 -0
- parse_bench/evaluation/qa/llm_service.py +335 -0
- parse_bench/evaluation/reports/__init__.py +8 -0
- parse_bench/evaluation/reports/csv.py +64 -0
- parse_bench/evaluation/reports/html.py +338 -0
- parse_bench/evaluation/reports/markdown.py +98 -0
- parse_bench/evaluation/reports/rule_csv.py +22 -0
- parse_bench/evaluation/runner.py +1864 -0
- parse_bench/evaluation/stats.py +104 -0
- parse_bench/extensions.py +72 -0
- parse_bench/inference/__init__.py +33 -0
- parse_bench/inference/chunkr_layout_extraction.py +160 -0
- parse_bench/inference/cli.py +484 -0
- parse_bench/inference/layout_extraction.py +422 -0
- parse_bench/inference/pipelines/__init__.py +59 -0
- parse_bench/inference/pipelines/extract.py +39 -0
- parse_bench/inference/pipelines/layout.py +142 -0
- parse_bench/inference/pipelines/parse.py +2603 -0
- parse_bench/inference/pipelines.py +0 -0
- parse_bench/inference/providers/__init__.py +28 -0
- parse_bench/inference/providers/base.py +196 -0
- parse_bench/inference/providers/cancellation.py +137 -0
- parse_bench/inference/providers/extract/__init__.py +22 -0
- parse_bench/inference/providers/extract/citations.py +549 -0
- parse_bench/inference/providers/extract/extend.py +851 -0
- parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
- parse_bench/inference/providers/layoutdet/__init__.py +25 -0
- parse_bench/inference/providers/layoutdet/adapters.py +946 -0
- parse_bench/inference/providers/layoutdet/base.py +203 -0
- parse_bench/inference/providers/layoutdet/chandra.py +449 -0
- parse_bench/inference/providers/layoutdet/docling.py +125 -0
- parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
- parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
- parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
- parse_bench/inference/providers/layoutdet/paddle.py +117 -0
- parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
- parse_bench/inference/providers/layoutdet/surya.py +250 -0
- parse_bench/inference/providers/layoutdet/yolo.py +109 -0
- parse_bench/inference/providers/parse/__init__.py +64 -0
- parse_bench/inference/providers/parse/_docling_common.py +233 -0
- parse_bench/inference/providers/parse/_layout_utils.py +611 -0
- parse_bench/inference/providers/parse/amazon_nova.py +515 -0
- parse_bench/inference/providers/parse/anthropic.py +882 -0
- parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
- parse_bench/inference/providers/parse/chandra2.py +633 -0
- parse_bench/inference/providers/parse/chunkr.py +268 -0
- parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
- parse_bench/inference/providers/parse/datalab.py +370 -0
- parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
- parse_bench/inference/providers/parse/docling.py +281 -0
- parse_bench/inference/providers/parse/docling_serve.py +289 -0
- parse_bench/inference/providers/parse/dots_ocr.py +574 -0
- parse_bench/inference/providers/parse/extend_parse.py +710 -0
- parse_bench/inference/providers/parse/falconocr.py +436 -0
- parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
- parse_bench/inference/providers/parse/gemma4.py +472 -0
- parse_bench/inference/providers/parse/glm_zai.py +229 -0
- parse_bench/inference/providers/parse/google.py +1125 -0
- parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
- parse_bench/inference/providers/parse/google_docai.py +776 -0
- parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
- parse_bench/inference/providers/parse/granite_vision.py +515 -0
- parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
- parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
- parse_bench/inference/providers/parse/landingai.py +452 -0
- parse_bench/inference/providers/parse/liteparse.py +350 -0
- parse_bench/inference/providers/parse/llamaparse.py +677 -0
- parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
- parse_bench/inference/providers/parse/markitdown.py +138 -0
- parse_bench/inference/providers/parse/mineru25.py +405 -0
- parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
- parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
- parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
- parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
- parse_bench/inference/providers/parse/oi_parser.py +222 -0
- parse_bench/inference/providers/parse/openai.py +740 -0
- parse_bench/inference/providers/parse/opendataloader.py +152 -0
- parse_bench/inference/providers/parse/paddleocr.py +624 -0
- parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
- parse_bench/inference/providers/parse/pulse.py +785 -0
- parse_bench/inference/providers/parse/pymupdf.py +207 -0
- parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
- parse_bench/inference/providers/parse/pypdf.py +179 -0
- parse_bench/inference/providers/parse/qwen.py +678 -0
- parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
- parse_bench/inference/providers/parse/reducto.py +546 -0
- parse_bench/inference/providers/parse/surya2.py +372 -0
- parse_bench/inference/providers/parse/tesseract.py +301 -0
- parse_bench/inference/providers/parse/textract.py +694 -0
- parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
- parse_bench/inference/providers/parse/unstructured.py +485 -0
- parse_bench/inference/providers/parse/warp_ingest.py +199 -0
- parse_bench/inference/providers/registry.py +49 -0
- parse_bench/inference/renormalize.py +170 -0
- parse_bench/inference/runner.py +2023 -0
- parse_bench/layout_label_mapping.py +424 -0
- parse_bench/layout_projection.py +179 -0
- parse_bench/pipeline/__init__.py +1 -0
- parse_bench/pipeline/cli.py +549 -0
- parse_bench/schemas/__init__.py +33 -0
- parse_bench/schemas/evaluation.py +93 -0
- parse_bench/schemas/extract_output.py +36 -0
- parse_bench/schemas/layout_detection_output.py +545 -0
- parse_bench/schemas/layout_ontology.py +315 -0
- parse_bench/schemas/metrics.py +69 -0
- parse_bench/schemas/parse_output.py +152 -0
- parse_bench/schemas/pipeline.py +22 -0
- parse_bench/schemas/pipeline_io.py +106 -0
- parse_bench/schemas/product.py +97 -0
- parse_bench/test_cases/__init__.py +25 -0
- parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
- parse_bench/test_cases/extract_field_paths.py +164 -0
- parse_bench/test_cases/layout_attribution_generation.py +287 -0
- parse_bench/test_cases/loader.py +652 -0
- parse_bench/test_cases/parse_rule_schemas.py +1071 -0
- parse_bench/test_cases/rule_filters.py +32 -0
- parse_bench/test_cases/rule_ids.py +107 -0
- parse_bench/test_cases/schema.py +427 -0
- parse_bench/utils/__init__.py +15 -0
- parse_bench/utils/gemini_layout_utils.py +670 -0
- parse_bench/utils/text_aggregation.py +100 -0
- parse_bench-1.0.0.dist-info/METADATA +476 -0
- parse_bench-1.0.0.dist-info/RECORD +227 -0
- parse_bench-1.0.0.dist-info/WHEEL +4 -0
- parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
- parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,2254 @@
|
|
|
1
|
+
"""Detailed HTML report generation for evaluation results.
|
|
2
|
+
|
|
3
|
+
Generates a self-contained, interactive HTML evaluation report with:
|
|
4
|
+
- Summary cards with key metrics
|
|
5
|
+
- Aggregate metrics panel with color-coded score bars
|
|
6
|
+
- Collapsible aggregate stats (latency, cost, tokens)
|
|
7
|
+
- Interactive examples table with metric selector, filters, sort, search, pagination
|
|
8
|
+
- Detail panel with per-example metrics, rule results, PDF viewer, and stats
|
|
9
|
+
|
|
10
|
+
This module provides the fancy interactive report (_evaluation_report_detailed.html).
|
|
11
|
+
It should be run as a separate step after evaluation to explore results in detail.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, cast
|
|
20
|
+
|
|
21
|
+
import bleach
|
|
22
|
+
import markdown2
|
|
23
|
+
|
|
24
|
+
from parse_bench.analysis.metric_definitions import (
|
|
25
|
+
TOOLTIP_CSS,
|
|
26
|
+
TOOLTIP_JS,
|
|
27
|
+
display_name,
|
|
28
|
+
tooltip_dict,
|
|
29
|
+
)
|
|
30
|
+
from parse_bench.schemas.evaluation import EvaluationSummary
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _render_markdown_to_html(md_text: str) -> str:
|
|
34
|
+
"""Render markdown to sanitised HTML, preserving HTML tables with colspan/rowspan."""
|
|
35
|
+
if not md_text:
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
# Extract HTML tables before markdown2 processing (it can mangle colspan/rowspan)
|
|
39
|
+
html_table_pattern = r"<table[^>]*>.*?</table>"
|
|
40
|
+
processed_md = md_text
|
|
41
|
+
table_placeholders: dict[str, str] = {}
|
|
42
|
+
matches = list(re.finditer(html_table_pattern, md_text, re.DOTALL | re.IGNORECASE))
|
|
43
|
+
for i, match in enumerate(reversed(matches)):
|
|
44
|
+
placeholder = f"<!--HTMLTABLE_{len(matches) - 1 - i}-->"
|
|
45
|
+
table_placeholders[placeholder] = match.group(0)
|
|
46
|
+
s, e = match.span()
|
|
47
|
+
processed_md = processed_md[:s] + placeholder + processed_md[e:]
|
|
48
|
+
|
|
49
|
+
rendered = markdown2.markdown(processed_md, extras=["tables", "fenced-code-blocks", "break-on-newline"])
|
|
50
|
+
|
|
51
|
+
# Restore original HTML tables
|
|
52
|
+
for placeholder, table_html in table_placeholders.items():
|
|
53
|
+
rendered = rendered.replace(placeholder, table_html)
|
|
54
|
+
|
|
55
|
+
allowed_tags = bleach.sanitizer.ALLOWED_TAGS | {
|
|
56
|
+
"table",
|
|
57
|
+
"thead",
|
|
58
|
+
"tbody",
|
|
59
|
+
"tr",
|
|
60
|
+
"th",
|
|
61
|
+
"td",
|
|
62
|
+
"caption",
|
|
63
|
+
"h1",
|
|
64
|
+
"h2",
|
|
65
|
+
"h3",
|
|
66
|
+
"h4",
|
|
67
|
+
"h5",
|
|
68
|
+
"h6",
|
|
69
|
+
"p",
|
|
70
|
+
"br",
|
|
71
|
+
"hr",
|
|
72
|
+
"pre",
|
|
73
|
+
"code",
|
|
74
|
+
"img",
|
|
75
|
+
"ul",
|
|
76
|
+
"ol",
|
|
77
|
+
"li",
|
|
78
|
+
"dl",
|
|
79
|
+
"dt",
|
|
80
|
+
"dd",
|
|
81
|
+
"div",
|
|
82
|
+
"span",
|
|
83
|
+
"sup",
|
|
84
|
+
"sub",
|
|
85
|
+
}
|
|
86
|
+
allowed_attrs = {
|
|
87
|
+
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
|
|
88
|
+
"th": ["colspan", "rowspan", "scope"],
|
|
89
|
+
"td": ["colspan", "rowspan"],
|
|
90
|
+
"img": ["src", "alt", "width", "height"],
|
|
91
|
+
"code": ["class"],
|
|
92
|
+
"pre": ["class"],
|
|
93
|
+
}
|
|
94
|
+
return str(bleach.clean(rendered, tags=allowed_tags, attributes=allowed_attrs))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_data_blob(
|
|
98
|
+
summary: EvaluationSummary,
|
|
99
|
+
output_dir: Path | None = None,
|
|
100
|
+
test_cases_dir: Path | None = None,
|
|
101
|
+
pdf_base_url: str = "",
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
"""Build the JSON data blob that powers the client-side rendering."""
|
|
104
|
+
|
|
105
|
+
# --- load predicted/expected output from files ---
|
|
106
|
+
predicted_map: dict[str, str] = {}
|
|
107
|
+
expected_map: dict[str, str] = {}
|
|
108
|
+
job_id_map: dict[str, str] = {}
|
|
109
|
+
parse_job_logs_url_map: dict[str, str] = {}
|
|
110
|
+
parse_job_logs_local_path_map: dict[str, str] = {}
|
|
111
|
+
parse_job_logs_html_path_map: dict[str, str] = {}
|
|
112
|
+
|
|
113
|
+
if output_dir and output_dir.exists():
|
|
114
|
+
for result_file in output_dir.rglob("*.result.json"):
|
|
115
|
+
try:
|
|
116
|
+
data = json.loads(result_file.read_text(encoding="utf-8"))
|
|
117
|
+
test_id = result_file.stem.replace(".result", "")
|
|
118
|
+
output = data.get("output") or {}
|
|
119
|
+
raw_output = data.get("raw_output") or {}
|
|
120
|
+
# Parse output: markdown field
|
|
121
|
+
if isinstance(output, dict) and output.get("markdown"):
|
|
122
|
+
predicted_map[test_id] = output["markdown"]
|
|
123
|
+
# Extract output: extracted_data field
|
|
124
|
+
elif isinstance(output, dict) and output.get("extracted_data"):
|
|
125
|
+
predicted_map[test_id] = json.dumps(output["extracted_data"], indent=2, ensure_ascii=False)
|
|
126
|
+
# Job ID from output (e.g. LlamaParse)
|
|
127
|
+
if isinstance(output, dict) and output.get("job_id"):
|
|
128
|
+
job_id_map[test_id] = output["job_id"]
|
|
129
|
+
if isinstance(raw_output, dict):
|
|
130
|
+
job_logs_url = raw_output.get("job_logs_url")
|
|
131
|
+
if not isinstance(job_logs_url, str) or not job_logs_url:
|
|
132
|
+
job_logs = raw_output.get("job_logs")
|
|
133
|
+
if isinstance(job_logs, dict):
|
|
134
|
+
nested_url = job_logs.get("url")
|
|
135
|
+
if isinstance(nested_url, str) and nested_url:
|
|
136
|
+
job_logs_url = nested_url
|
|
137
|
+
if isinstance(job_logs_url, str) and job_logs_url:
|
|
138
|
+
parse_job_logs_url_map[test_id] = job_logs_url
|
|
139
|
+
|
|
140
|
+
job_logs_local = raw_output.get("job_logs_local_path")
|
|
141
|
+
if isinstance(job_logs_local, str) and job_logs_local:
|
|
142
|
+
parse_job_logs_local_path_map[test_id] = job_logs_local
|
|
143
|
+
|
|
144
|
+
job_logs_html = raw_output.get("job_logs_html_local_path")
|
|
145
|
+
if isinstance(job_logs_html, str) and job_logs_html:
|
|
146
|
+
parse_job_logs_html_path_map[test_id] = job_logs_html
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
if test_cases_dir and test_cases_dir.exists():
|
|
151
|
+
for test_file in test_cases_dir.rglob("*.test.json"):
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(test_file.read_text(encoding="utf-8"))
|
|
154
|
+
test_id = test_file.stem.replace(".test", "")
|
|
155
|
+
if data.get("expected_markdown"):
|
|
156
|
+
expected_map[test_id] = data["expected_markdown"]
|
|
157
|
+
elif data.get("expected_output"):
|
|
158
|
+
expected_map[test_id] = json.dumps(data["expected_output"], indent=2, ensure_ascii=False)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# --- aggregate metrics (group avg/min/max) ---
|
|
163
|
+
# Per-doc table count metrics are bookkeeping, not quality scores --
|
|
164
|
+
# exclude them from the detailed report's aggregate metric panel.
|
|
165
|
+
_hidden_table_count_metrics = {
|
|
166
|
+
"tables_expected",
|
|
167
|
+
"tables_actual",
|
|
168
|
+
"tables_paired",
|
|
169
|
+
"tables_unmatched_expected",
|
|
170
|
+
"tables_unmatched_pred",
|
|
171
|
+
"tables_unparseable_pred",
|
|
172
|
+
}
|
|
173
|
+
metric_groups: dict[str, dict[str, float]] = {}
|
|
174
|
+
for key, value in summary.aggregate_metrics.items():
|
|
175
|
+
for prefix in ("avg_", "min_", "max_"):
|
|
176
|
+
if key.startswith(prefix):
|
|
177
|
+
base = key[len(prefix) :]
|
|
178
|
+
if base in _hidden_table_count_metrics:
|
|
179
|
+
break
|
|
180
|
+
metric_groups.setdefault(base, {})[prefix.rstrip("_")] = value
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
agg_metrics_unsorted = [
|
|
184
|
+
{
|
|
185
|
+
"name": name,
|
|
186
|
+
"displayName": display_name(name),
|
|
187
|
+
"avg": vals.get("avg", 0.0),
|
|
188
|
+
"min": vals.get("min", 0.0),
|
|
189
|
+
"max": vals.get("max", 0.0),
|
|
190
|
+
}
|
|
191
|
+
for name, vals in metric_groups.items()
|
|
192
|
+
]
|
|
193
|
+
agg_metrics = sorted(
|
|
194
|
+
agg_metrics_unsorted,
|
|
195
|
+
key=lambda m: cast(float, m["avg"]),
|
|
196
|
+
reverse=True,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# --- aggregate stats ---
|
|
200
|
+
agg_stats = []
|
|
201
|
+
for stat_name, agg in sorted(summary.aggregate_stats.items()):
|
|
202
|
+
agg_stats.append(
|
|
203
|
+
{
|
|
204
|
+
"name": stat_name,
|
|
205
|
+
"displayName": stat_name.replace("_", " ").title(),
|
|
206
|
+
"unit": agg.get("unit", ""),
|
|
207
|
+
"avg": agg.get("avg", 0),
|
|
208
|
+
"min": agg.get("min", 0),
|
|
209
|
+
"max": agg.get("max", 0),
|
|
210
|
+
"p50": agg.get("p50", 0),
|
|
211
|
+
"p95": agg.get("p95", 0),
|
|
212
|
+
"p99": agg.get("p99", 0),
|
|
213
|
+
"total": agg.get("total", 0),
|
|
214
|
+
"count": agg.get("count", 0),
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# --- metric names lookup ---
|
|
219
|
+
metric_names_map: dict[str, str] = {}
|
|
220
|
+
for base_name in metric_groups:
|
|
221
|
+
metric_names_map[base_name] = display_name(base_name)
|
|
222
|
+
|
|
223
|
+
# --- collect all tags ---
|
|
224
|
+
all_tags: set[str] = set()
|
|
225
|
+
for result in summary.per_example_results:
|
|
226
|
+
all_tags.update(result.tags)
|
|
227
|
+
|
|
228
|
+
# --- per-example data ---
|
|
229
|
+
examples = []
|
|
230
|
+
for result in summary.per_example_results:
|
|
231
|
+
metrics_dict: dict[str, float] = {}
|
|
232
|
+
rule_details: dict[str, dict[str, int]] = {}
|
|
233
|
+
rule_results_map: dict[str, list[dict[str, Any]]] = {}
|
|
234
|
+
metric_details_map: dict[str, list[str]] = {}
|
|
235
|
+
|
|
236
|
+
for mv in result.metrics:
|
|
237
|
+
if mv.metric_name in _hidden_table_count_metrics:
|
|
238
|
+
continue
|
|
239
|
+
metrics_dict[mv.metric_name] = mv.value
|
|
240
|
+
# Add to metric_names_map if not already there
|
|
241
|
+
if mv.metric_name not in metric_names_map:
|
|
242
|
+
metric_names_map[mv.metric_name] = display_name(mv.metric_name)
|
|
243
|
+
|
|
244
|
+
# Collect human-readable detail strings
|
|
245
|
+
if mv.details:
|
|
246
|
+
metric_details_map[mv.metric_name] = mv.details
|
|
247
|
+
|
|
248
|
+
# Extract rule details from metadata
|
|
249
|
+
if "rule_results" in mv.metadata:
|
|
250
|
+
passed = sum(1 for r in mv.metadata["rule_results"] if r.get("passed"))
|
|
251
|
+
total = len(mv.metadata["rule_results"])
|
|
252
|
+
rule_details[mv.metric_name] = {"passed": passed, "total": total}
|
|
253
|
+
rule_results_map[mv.metric_name] = [
|
|
254
|
+
{
|
|
255
|
+
"type": r.get("type", ""),
|
|
256
|
+
"passed": r.get("passed", False),
|
|
257
|
+
"id": r.get("id", ""),
|
|
258
|
+
"message": r.get("message", ""),
|
|
259
|
+
}
|
|
260
|
+
for r in mv.metadata["rule_results"]
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
stats_dict: dict[str, float] = {}
|
|
264
|
+
for s in result.stats:
|
|
265
|
+
stats_dict[s.name] = s.value
|
|
266
|
+
|
|
267
|
+
examples.append(
|
|
268
|
+
{
|
|
269
|
+
"id": result.test_id,
|
|
270
|
+
"success": result.success,
|
|
271
|
+
"error": result.error,
|
|
272
|
+
"tags": result.tags,
|
|
273
|
+
"productType": result.product_type,
|
|
274
|
+
"jobId": (
|
|
275
|
+
result.job_id
|
|
276
|
+
or job_id_map.get(result.test_id)
|
|
277
|
+
or job_id_map.get(result.test_id.rsplit("/", 1)[-1], "")
|
|
278
|
+
),
|
|
279
|
+
"parseJobId": result.parse_job_id or "",
|
|
280
|
+
"parseJobLogsUrl": (
|
|
281
|
+
parse_job_logs_url_map.get(result.test_id)
|
|
282
|
+
or parse_job_logs_url_map.get(result.test_id.rsplit("/", 1)[-1], "")
|
|
283
|
+
),
|
|
284
|
+
"parseJobLogsLocalPath": (
|
|
285
|
+
parse_job_logs_local_path_map.get(result.test_id)
|
|
286
|
+
or parse_job_logs_local_path_map.get(result.test_id.rsplit("/", 1)[-1], "")
|
|
287
|
+
),
|
|
288
|
+
"parseJobLogsHtmlPath": (
|
|
289
|
+
parse_job_logs_html_path_map.get(result.test_id)
|
|
290
|
+
or parse_job_logs_html_path_map.get(result.test_id.rsplit("/", 1)[-1], "")
|
|
291
|
+
),
|
|
292
|
+
"metrics": metrics_dict,
|
|
293
|
+
"stats": stats_dict,
|
|
294
|
+
"ruleDetails": rule_details,
|
|
295
|
+
"ruleResults": rule_results_map,
|
|
296
|
+
"metricDetails": metric_details_map,
|
|
297
|
+
"predictedOutput": (
|
|
298
|
+
predicted_map.get(result.test_id) or predicted_map.get(result.test_id.rsplit("/", 1)[-1], "")
|
|
299
|
+
),
|
|
300
|
+
"expectedOutput": (
|
|
301
|
+
expected_map.get(result.test_id) or expected_map.get(result.test_id.rsplit("/", 1)[-1], "")
|
|
302
|
+
),
|
|
303
|
+
"predictedHtml": _render_markdown_to_html(
|
|
304
|
+
predicted_map.get(result.test_id) or predicted_map.get(result.test_id.rsplit("/", 1)[-1], "")
|
|
305
|
+
),
|
|
306
|
+
"expectedHtml": _render_markdown_to_html(
|
|
307
|
+
expected_map.get(result.test_id) or expected_map.get(result.test_id.rsplit("/", 1)[-1], "")
|
|
308
|
+
),
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
completed_at_str = ""
|
|
313
|
+
if summary.completed_at is not None:
|
|
314
|
+
completed_at_str = summary.completed_at.isoformat()
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"summary": {
|
|
318
|
+
"total": summary.total_examples,
|
|
319
|
+
"successful": summary.successful,
|
|
320
|
+
"failed": summary.failed,
|
|
321
|
+
"skipped": summary.skipped,
|
|
322
|
+
"completedAt": completed_at_str,
|
|
323
|
+
},
|
|
324
|
+
"aggMetrics": agg_metrics,
|
|
325
|
+
"aggStats": agg_stats,
|
|
326
|
+
"metricNames": metric_names_map,
|
|
327
|
+
"metricTooltips": tooltip_dict(),
|
|
328
|
+
"tags": sorted(all_tags),
|
|
329
|
+
"tagMetrics": {tag: dict(metrics.items()) for tag, metrics in summary.tag_metrics.items()},
|
|
330
|
+
"examples": examples,
|
|
331
|
+
"pdfBaseUrl": pdf_base_url,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# HTML template parts
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
_HTML_HEAD = """\
|
|
340
|
+
<!DOCTYPE html>
|
|
341
|
+
<html lang="en">
|
|
342
|
+
<head>
|
|
343
|
+
<meta charset="UTF-8">
|
|
344
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
345
|
+
<title>Evaluation Report</title>
|
|
346
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
347
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
348
|
+
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;0,6..72,700;1,6..72,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
349
|
+
<style>
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
_CSS = (
|
|
353
|
+
"""\
|
|
354
|
+
/* ───── Reset & variables ───── */
|
|
355
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
356
|
+
:root {
|
|
357
|
+
--bg: #f8f7f4;
|
|
358
|
+
--fg: #1c1917;
|
|
359
|
+
--card: #ffffff;
|
|
360
|
+
--border: #e7e5e4;
|
|
361
|
+
--muted: #78716c;
|
|
362
|
+
--muted-light: #a8a29e;
|
|
363
|
+
--cream: #faf9f6;
|
|
364
|
+
--emerald: #059669;
|
|
365
|
+
--emerald-bg: #ecfdf5;
|
|
366
|
+
--amber: #d97706;
|
|
367
|
+
--amber-bg: #fffbeb;
|
|
368
|
+
--red: #dc2626;
|
|
369
|
+
--red-bg: #fef2f2;
|
|
370
|
+
--blue: #2563eb;
|
|
371
|
+
--blue-bg: #eff6ff;
|
|
372
|
+
--font-heading: 'Newsreader', Georgia, serif;
|
|
373
|
+
--font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
374
|
+
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
|
375
|
+
--shadow-sm: 0 1px 2px rgba(28,25,23,0.05);
|
|
376
|
+
--shadow-md: 0 4px 6px -1px rgba(28,25,23,0.07), 0 2px 4px -2px rgba(28,25,23,0.05);
|
|
377
|
+
--shadow-lg: 0 10px 15px -3px rgba(28,25,23,0.08), 0 4px 6px -4px rgba(28,25,23,0.04);
|
|
378
|
+
--radius: 10px;
|
|
379
|
+
--radius-sm: 6px;
|
|
380
|
+
}
|
|
381
|
+
html { font-size: 15px; }
|
|
382
|
+
body {
|
|
383
|
+
font-family: var(--font-body);
|
|
384
|
+
background: var(--bg);
|
|
385
|
+
color: var(--fg);
|
|
386
|
+
line-height: 1.6;
|
|
387
|
+
-webkit-font-smoothing: antialiased;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* ───── Scrollbar ───── */
|
|
391
|
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
392
|
+
::-webkit-scrollbar-track { background: var(--cream); }
|
|
393
|
+
::-webkit-scrollbar-thumb { background: var(--muted-light); border-radius: 4px; }
|
|
394
|
+
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
|
395
|
+
|
|
396
|
+
/* ───── Layout ───── */
|
|
397
|
+
.report-container {
|
|
398
|
+
max-width: 1340px;
|
|
399
|
+
margin: 0 auto;
|
|
400
|
+
padding: 32px 24px 64px;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/* ───── Header ───── */
|
|
404
|
+
.report-header {
|
|
405
|
+
margin-bottom: 36px;
|
|
406
|
+
}
|
|
407
|
+
.report-header h1 {
|
|
408
|
+
font-family: var(--font-heading);
|
|
409
|
+
font-size: 2.2rem;
|
|
410
|
+
font-weight: 700;
|
|
411
|
+
letter-spacing: -0.02em;
|
|
412
|
+
color: var(--fg);
|
|
413
|
+
line-height: 1.2;
|
|
414
|
+
}
|
|
415
|
+
.report-header .subtitle {
|
|
416
|
+
font-size: 0.9rem;
|
|
417
|
+
color: var(--muted);
|
|
418
|
+
margin-top: 6px;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* ───── Summary cards ───── */
|
|
422
|
+
.summary-row {
|
|
423
|
+
display: grid;
|
|
424
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
425
|
+
gap: 14px;
|
|
426
|
+
margin-bottom: 32px;
|
|
427
|
+
}
|
|
428
|
+
.summary-card {
|
|
429
|
+
background: var(--card);
|
|
430
|
+
border: 1px solid var(--border);
|
|
431
|
+
border-radius: var(--radius);
|
|
432
|
+
padding: 18px 20px;
|
|
433
|
+
box-shadow: var(--shadow-sm);
|
|
434
|
+
transition: box-shadow 0.15s;
|
|
435
|
+
}
|
|
436
|
+
.summary-card:hover { box-shadow: var(--shadow-md); }
|
|
437
|
+
.summary-card .label {
|
|
438
|
+
font-size: 0.75rem;
|
|
439
|
+
font-weight: 600;
|
|
440
|
+
text-transform: uppercase;
|
|
441
|
+
letter-spacing: 0.06em;
|
|
442
|
+
color: var(--muted);
|
|
443
|
+
margin-bottom: 4px;
|
|
444
|
+
}
|
|
445
|
+
.summary-card .big-number {
|
|
446
|
+
font-family: var(--font-heading);
|
|
447
|
+
font-size: 2rem;
|
|
448
|
+
font-weight: 700;
|
|
449
|
+
line-height: 1.15;
|
|
450
|
+
}
|
|
451
|
+
.summary-card.card-success .big-number { color: var(--emerald); }
|
|
452
|
+
.summary-card.card-failed .big-number { color: var(--red); }
|
|
453
|
+
.summary-card.card-skipped .big-number { color: var(--muted-light); }
|
|
454
|
+
|
|
455
|
+
/* ───── Section titles ───── */
|
|
456
|
+
.section-title {
|
|
457
|
+
font-family: var(--font-heading);
|
|
458
|
+
font-size: 1.35rem;
|
|
459
|
+
font-weight: 600;
|
|
460
|
+
margin-bottom: 16px;
|
|
461
|
+
padding-bottom: 8px;
|
|
462
|
+
border-bottom: 2px solid var(--border);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* ───── Aggregate metrics ───── */
|
|
466
|
+
.metrics-grid {
|
|
467
|
+
display: grid;
|
|
468
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
469
|
+
gap: 14px;
|
|
470
|
+
margin-bottom: 36px;
|
|
471
|
+
}
|
|
472
|
+
.metric-card {
|
|
473
|
+
background: var(--card);
|
|
474
|
+
border: 1px solid var(--border);
|
|
475
|
+
border-radius: var(--radius);
|
|
476
|
+
padding: 16px 18px;
|
|
477
|
+
box-shadow: var(--shadow-sm);
|
|
478
|
+
}
|
|
479
|
+
.metric-card .metric-label {
|
|
480
|
+
font-size: 0.8rem;
|
|
481
|
+
font-weight: 600;
|
|
482
|
+
color: var(--muted);
|
|
483
|
+
margin-bottom: 8px;
|
|
484
|
+
white-space: nowrap;
|
|
485
|
+
overflow: hidden;
|
|
486
|
+
text-overflow: ellipsis;
|
|
487
|
+
}
|
|
488
|
+
.metric-card .metric-avg {
|
|
489
|
+
font-family: var(--font-heading);
|
|
490
|
+
font-size: 1.6rem;
|
|
491
|
+
font-weight: 700;
|
|
492
|
+
margin-bottom: 8px;
|
|
493
|
+
}
|
|
494
|
+
.metric-card .metric-bar-track {
|
|
495
|
+
height: 6px;
|
|
496
|
+
background: var(--cream);
|
|
497
|
+
border-radius: 3px;
|
|
498
|
+
overflow: hidden;
|
|
499
|
+
margin-bottom: 8px;
|
|
500
|
+
}
|
|
501
|
+
.metric-card .metric-bar-fill {
|
|
502
|
+
height: 100%;
|
|
503
|
+
border-radius: 3px;
|
|
504
|
+
transition: width 0.3s ease;
|
|
505
|
+
}
|
|
506
|
+
.bar-emerald { background: var(--emerald); }
|
|
507
|
+
.bar-amber { background: var(--amber); }
|
|
508
|
+
.bar-red { background: var(--red); }
|
|
509
|
+
.color-emerald { color: var(--emerald); }
|
|
510
|
+
.color-amber { color: var(--amber); }
|
|
511
|
+
.color-red { color: var(--red); }
|
|
512
|
+
.metric-card .metric-range {
|
|
513
|
+
display: flex;
|
|
514
|
+
justify-content: space-between;
|
|
515
|
+
font-size: 0.72rem;
|
|
516
|
+
color: var(--muted-light);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* ───── Aggregate stats (collapsible) ───── */
|
|
520
|
+
.stats-section { margin-bottom: 36px; }
|
|
521
|
+
.stats-toggle {
|
|
522
|
+
display: inline-flex;
|
|
523
|
+
align-items: center;
|
|
524
|
+
gap: 6px;
|
|
525
|
+
background: none;
|
|
526
|
+
border: none;
|
|
527
|
+
cursor: pointer;
|
|
528
|
+
font-family: var(--font-heading);
|
|
529
|
+
font-size: 1.35rem;
|
|
530
|
+
font-weight: 600;
|
|
531
|
+
color: var(--fg);
|
|
532
|
+
padding-bottom: 8px;
|
|
533
|
+
border-bottom: 2px solid var(--border);
|
|
534
|
+
width: 100%;
|
|
535
|
+
text-align: left;
|
|
536
|
+
margin-bottom: 16px;
|
|
537
|
+
}
|
|
538
|
+
.stats-toggle .chevron {
|
|
539
|
+
display: inline-block;
|
|
540
|
+
transition: transform 0.2s;
|
|
541
|
+
font-size: 0.9rem;
|
|
542
|
+
}
|
|
543
|
+
.stats-toggle.open .chevron { transform: rotate(90deg); }
|
|
544
|
+
.stats-body {
|
|
545
|
+
display: none;
|
|
546
|
+
overflow: hidden;
|
|
547
|
+
}
|
|
548
|
+
.stats-body.open { display: block; }
|
|
549
|
+
.stats-grid {
|
|
550
|
+
display: grid;
|
|
551
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
552
|
+
gap: 14px;
|
|
553
|
+
}
|
|
554
|
+
.stat-card {
|
|
555
|
+
background: var(--card);
|
|
556
|
+
border: 1px solid var(--border);
|
|
557
|
+
border-radius: var(--radius);
|
|
558
|
+
padding: 16px 18px;
|
|
559
|
+
box-shadow: var(--shadow-sm);
|
|
560
|
+
}
|
|
561
|
+
.stat-card .stat-label {
|
|
562
|
+
font-size: 0.8rem;
|
|
563
|
+
font-weight: 600;
|
|
564
|
+
color: var(--muted);
|
|
565
|
+
margin-bottom: 6px;
|
|
566
|
+
}
|
|
567
|
+
.stat-card .stat-avg {
|
|
568
|
+
font-family: var(--font-heading);
|
|
569
|
+
font-size: 1.4rem;
|
|
570
|
+
font-weight: 700;
|
|
571
|
+
margin-bottom: 8px;
|
|
572
|
+
}
|
|
573
|
+
.stat-card .stat-detail {
|
|
574
|
+
font-size: 0.72rem;
|
|
575
|
+
color: var(--muted-light);
|
|
576
|
+
line-height: 1.7;
|
|
577
|
+
}
|
|
578
|
+
.stat-card .stat-detail span {
|
|
579
|
+
display: inline-block;
|
|
580
|
+
margin-right: 10px;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* ───── Tag metrics ───── */
|
|
584
|
+
.tag-metrics-section { margin-bottom: 36px; }
|
|
585
|
+
.tag-metrics-grid {
|
|
586
|
+
display: grid;
|
|
587
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
588
|
+
gap: 14px;
|
|
589
|
+
}
|
|
590
|
+
.tag-card {
|
|
591
|
+
background: var(--card);
|
|
592
|
+
border: 1px solid var(--border);
|
|
593
|
+
border-radius: var(--radius);
|
|
594
|
+
padding: 16px 18px;
|
|
595
|
+
box-shadow: var(--shadow-sm);
|
|
596
|
+
}
|
|
597
|
+
.tag-card .tag-name {
|
|
598
|
+
font-weight: 600;
|
|
599
|
+
font-size: 0.9rem;
|
|
600
|
+
margin-bottom: 8px;
|
|
601
|
+
color: var(--fg);
|
|
602
|
+
}
|
|
603
|
+
.tag-card .tag-metric-row {
|
|
604
|
+
display: flex;
|
|
605
|
+
justify-content: space-between;
|
|
606
|
+
font-size: 0.78rem;
|
|
607
|
+
padding: 3px 0;
|
|
608
|
+
border-bottom: 1px solid var(--cream);
|
|
609
|
+
}
|
|
610
|
+
.tag-card .tag-metric-row:last-child { border-bottom: none; }
|
|
611
|
+
.tag-card .tag-metric-name { color: var(--muted); }
|
|
612
|
+
.tag-card .tag-metric-value { font-weight: 600; font-family: var(--font-mono); font-size: 0.75rem; }
|
|
613
|
+
|
|
614
|
+
/* ───── Controls bar ───── */
|
|
615
|
+
.controls-bar {
|
|
616
|
+
display: flex;
|
|
617
|
+
flex-wrap: wrap;
|
|
618
|
+
gap: 12px;
|
|
619
|
+
align-items: center;
|
|
620
|
+
margin-bottom: 16px;
|
|
621
|
+
padding: 14px 18px;
|
|
622
|
+
background: var(--card);
|
|
623
|
+
border: 1px solid var(--border);
|
|
624
|
+
border-radius: var(--radius);
|
|
625
|
+
box-shadow: var(--shadow-sm);
|
|
626
|
+
}
|
|
627
|
+
.controls-bar label {
|
|
628
|
+
font-size: 0.78rem;
|
|
629
|
+
font-weight: 600;
|
|
630
|
+
color: var(--muted);
|
|
631
|
+
text-transform: uppercase;
|
|
632
|
+
letter-spacing: 0.04em;
|
|
633
|
+
}
|
|
634
|
+
.controls-bar select,
|
|
635
|
+
.controls-bar input[type="text"] {
|
|
636
|
+
font-family: var(--font-body);
|
|
637
|
+
font-size: 0.85rem;
|
|
638
|
+
padding: 6px 10px;
|
|
639
|
+
border: 1px solid var(--border);
|
|
640
|
+
border-radius: var(--radius-sm);
|
|
641
|
+
background: var(--cream);
|
|
642
|
+
color: var(--fg);
|
|
643
|
+
outline: none;
|
|
644
|
+
transition: border-color 0.15s;
|
|
645
|
+
}
|
|
646
|
+
.controls-bar select:focus,
|
|
647
|
+
.controls-bar input[type="text"]:focus {
|
|
648
|
+
border-color: var(--blue);
|
|
649
|
+
}
|
|
650
|
+
.controls-bar input[type="text"] { width: 180px; }
|
|
651
|
+
.control-group {
|
|
652
|
+
display: flex;
|
|
653
|
+
align-items: center;
|
|
654
|
+
gap: 6px;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/* Range slider */
|
|
658
|
+
.range-group {
|
|
659
|
+
display: flex;
|
|
660
|
+
align-items: center;
|
|
661
|
+
gap: 8px;
|
|
662
|
+
}
|
|
663
|
+
.range-group input[type="range"] {
|
|
664
|
+
-webkit-appearance: none;
|
|
665
|
+
width: 90px;
|
|
666
|
+
height: 4px;
|
|
667
|
+
background: var(--border);
|
|
668
|
+
border-radius: 2px;
|
|
669
|
+
outline: none;
|
|
670
|
+
}
|
|
671
|
+
.range-group input[type="range"]::-webkit-slider-thumb {
|
|
672
|
+
-webkit-appearance: none;
|
|
673
|
+
width: 14px;
|
|
674
|
+
height: 14px;
|
|
675
|
+
background: var(--blue);
|
|
676
|
+
border-radius: 50%;
|
|
677
|
+
cursor: pointer;
|
|
678
|
+
}
|
|
679
|
+
.range-group .range-label {
|
|
680
|
+
font-family: var(--font-mono);
|
|
681
|
+
font-size: 0.75rem;
|
|
682
|
+
color: var(--muted);
|
|
683
|
+
min-width: 30px;
|
|
684
|
+
text-align: center;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/* Tag pills */
|
|
688
|
+
.tag-filters {
|
|
689
|
+
display: flex;
|
|
690
|
+
flex-wrap: wrap;
|
|
691
|
+
gap: 6px;
|
|
692
|
+
}
|
|
693
|
+
.tag-pill {
|
|
694
|
+
display: inline-block;
|
|
695
|
+
font-size: 0.72rem;
|
|
696
|
+
font-weight: 500;
|
|
697
|
+
padding: 3px 10px;
|
|
698
|
+
border-radius: 999px;
|
|
699
|
+
border: 1px solid var(--border);
|
|
700
|
+
background: var(--cream);
|
|
701
|
+
color: var(--muted);
|
|
702
|
+
cursor: pointer;
|
|
703
|
+
transition: all 0.15s;
|
|
704
|
+
user-select: none;
|
|
705
|
+
}
|
|
706
|
+
.tag-pill:hover { border-color: var(--blue); color: var(--blue); }
|
|
707
|
+
.tag-pill.active {
|
|
708
|
+
background: var(--blue);
|
|
709
|
+
color: #fff;
|
|
710
|
+
border-color: var(--blue);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/* Results count */
|
|
714
|
+
.results-count {
|
|
715
|
+
font-size: 0.78rem;
|
|
716
|
+
color: var(--muted);
|
|
717
|
+
margin-bottom: 8px;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/* ───── Examples table ───── */
|
|
721
|
+
.table-wrap {
|
|
722
|
+
background: var(--card);
|
|
723
|
+
border: 1px solid var(--border);
|
|
724
|
+
border-radius: var(--radius);
|
|
725
|
+
box-shadow: var(--shadow-sm);
|
|
726
|
+
overflow: hidden;
|
|
727
|
+
margin-bottom: 16px;
|
|
728
|
+
}
|
|
729
|
+
.examples-table {
|
|
730
|
+
width: 100%;
|
|
731
|
+
border-collapse: collapse;
|
|
732
|
+
table-layout: fixed;
|
|
733
|
+
}
|
|
734
|
+
.examples-table > thead > tr > th {
|
|
735
|
+
font-size: 0.72rem;
|
|
736
|
+
font-weight: 700;
|
|
737
|
+
text-transform: uppercase;
|
|
738
|
+
letter-spacing: 0.06em;
|
|
739
|
+
color: var(--muted);
|
|
740
|
+
padding: 10px 14px;
|
|
741
|
+
text-align: left;
|
|
742
|
+
border-bottom: 2px solid var(--border);
|
|
743
|
+
background: var(--cream);
|
|
744
|
+
position: sticky;
|
|
745
|
+
top: 0;
|
|
746
|
+
z-index: 2;
|
|
747
|
+
}
|
|
748
|
+
.examples-table > tbody > tr > td {
|
|
749
|
+
padding: 10px 14px;
|
|
750
|
+
border-bottom: 1px solid var(--border);
|
|
751
|
+
font-size: 0.85rem;
|
|
752
|
+
vertical-align: middle;
|
|
753
|
+
}
|
|
754
|
+
.examples-table tbody tr {
|
|
755
|
+
cursor: pointer;
|
|
756
|
+
transition: background 0.1s;
|
|
757
|
+
}
|
|
758
|
+
.examples-table tbody tr:hover { background: var(--cream); }
|
|
759
|
+
.examples-table tbody tr.selected { background: var(--blue-bg); }
|
|
760
|
+
|
|
761
|
+
/* Column widths */
|
|
762
|
+
.col-status { width: 36px; }
|
|
763
|
+
.col-id { width: 40%; }
|
|
764
|
+
.col-score { width: 30%; }
|
|
765
|
+
.col-tags { width: 30%; }
|
|
766
|
+
|
|
767
|
+
/* Status dot */
|
|
768
|
+
.status-dot {
|
|
769
|
+
display: inline-block;
|
|
770
|
+
width: 10px;
|
|
771
|
+
height: 10px;
|
|
772
|
+
border-radius: 50%;
|
|
773
|
+
}
|
|
774
|
+
.status-dot.ok { background: var(--emerald); }
|
|
775
|
+
.status-dot.fail { background: var(--red); }
|
|
776
|
+
|
|
777
|
+
/* Score bar inline */
|
|
778
|
+
.score-cell {
|
|
779
|
+
display: flex;
|
|
780
|
+
align-items: center;
|
|
781
|
+
gap: 8px;
|
|
782
|
+
}
|
|
783
|
+
.score-bar-track {
|
|
784
|
+
flex: 1;
|
|
785
|
+
height: 5px;
|
|
786
|
+
background: var(--cream);
|
|
787
|
+
border-radius: 3px;
|
|
788
|
+
overflow: hidden;
|
|
789
|
+
min-width: 40px;
|
|
790
|
+
}
|
|
791
|
+
.score-bar-fill {
|
|
792
|
+
height: 100%;
|
|
793
|
+
border-radius: 3px;
|
|
794
|
+
transition: width 0.2s;
|
|
795
|
+
}
|
|
796
|
+
.score-value {
|
|
797
|
+
font-family: var(--font-mono);
|
|
798
|
+
font-size: 0.8rem;
|
|
799
|
+
font-weight: 500;
|
|
800
|
+
min-width: 42px;
|
|
801
|
+
text-align: right;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/* Tags in table */
|
|
805
|
+
.tag-badges { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
806
|
+
.tag-badge {
|
|
807
|
+
font-size: 0.65rem;
|
|
808
|
+
padding: 1px 7px;
|
|
809
|
+
border-radius: 999px;
|
|
810
|
+
background: var(--cream);
|
|
811
|
+
border: 1px solid var(--border);
|
|
812
|
+
color: var(--muted);
|
|
813
|
+
white-space: nowrap;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/* ───── Pagination ───── */
|
|
817
|
+
.pagination {
|
|
818
|
+
display: flex;
|
|
819
|
+
align-items: center;
|
|
820
|
+
justify-content: center;
|
|
821
|
+
gap: 6px;
|
|
822
|
+
margin-top: 8px;
|
|
823
|
+
margin-bottom: 36px;
|
|
824
|
+
}
|
|
825
|
+
.pagination button {
|
|
826
|
+
font-family: var(--font-body);
|
|
827
|
+
font-size: 0.8rem;
|
|
828
|
+
padding: 5px 12px;
|
|
829
|
+
border: 1px solid var(--border);
|
|
830
|
+
border-radius: var(--radius-sm);
|
|
831
|
+
background: var(--card);
|
|
832
|
+
color: var(--fg);
|
|
833
|
+
cursor: pointer;
|
|
834
|
+
transition: all 0.12s;
|
|
835
|
+
}
|
|
836
|
+
.pagination button:hover:not(:disabled) {
|
|
837
|
+
border-color: var(--blue);
|
|
838
|
+
color: var(--blue);
|
|
839
|
+
}
|
|
840
|
+
.pagination button:disabled {
|
|
841
|
+
opacity: 0.4;
|
|
842
|
+
cursor: default;
|
|
843
|
+
}
|
|
844
|
+
.pagination button.active {
|
|
845
|
+
background: var(--blue);
|
|
846
|
+
color: #fff;
|
|
847
|
+
border-color: var(--blue);
|
|
848
|
+
}
|
|
849
|
+
.pagination .page-info {
|
|
850
|
+
font-size: 0.78rem;
|
|
851
|
+
color: var(--muted);
|
|
852
|
+
margin: 0 8px;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/* ───── Detail panel ───── */
|
|
856
|
+
.detail-panel {
|
|
857
|
+
background: var(--card);
|
|
858
|
+
border: 1px solid var(--border);
|
|
859
|
+
border-radius: var(--radius);
|
|
860
|
+
box-shadow: var(--shadow-lg);
|
|
861
|
+
margin: -1px 0 0;
|
|
862
|
+
padding: 24px;
|
|
863
|
+
animation: slideDown 0.2s ease;
|
|
864
|
+
}
|
|
865
|
+
@keyframes slideDown {
|
|
866
|
+
from { opacity: 0; transform: translateY(-8px); }
|
|
867
|
+
to { opacity: 1; transform: translateY(0); }
|
|
868
|
+
}
|
|
869
|
+
.detail-panel .detail-header {
|
|
870
|
+
display: flex;
|
|
871
|
+
justify-content: space-between;
|
|
872
|
+
align-items: flex-start;
|
|
873
|
+
margin-bottom: 18px;
|
|
874
|
+
}
|
|
875
|
+
.detail-panel .detail-title {
|
|
876
|
+
font-family: var(--font-heading);
|
|
877
|
+
font-size: 1.2rem;
|
|
878
|
+
font-weight: 600;
|
|
879
|
+
word-break: break-all;
|
|
880
|
+
}
|
|
881
|
+
.detail-close {
|
|
882
|
+
background: none;
|
|
883
|
+
border: 1px solid var(--border);
|
|
884
|
+
border-radius: var(--radius-sm);
|
|
885
|
+
padding: 4px 10px;
|
|
886
|
+
cursor: pointer;
|
|
887
|
+
font-size: 0.8rem;
|
|
888
|
+
color: var(--muted);
|
|
889
|
+
transition: all 0.12s;
|
|
890
|
+
}
|
|
891
|
+
.detail-close:hover { border-color: var(--red); color: var(--red); }
|
|
892
|
+
|
|
893
|
+
.detail-panel .detail-error {
|
|
894
|
+
background: var(--red-bg);
|
|
895
|
+
border-left: 3px solid var(--red);
|
|
896
|
+
padding: 10px 14px;
|
|
897
|
+
border-radius: var(--radius-sm);
|
|
898
|
+
margin-bottom: 16px;
|
|
899
|
+
font-size: 0.85rem;
|
|
900
|
+
color: var(--red);
|
|
901
|
+
font-family: var(--font-mono);
|
|
902
|
+
white-space: pre-wrap;
|
|
903
|
+
word-break: break-all;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/* Detail sub-tables */
|
|
907
|
+
.detail-section { margin-bottom: 18px; }
|
|
908
|
+
.detail-section-title {
|
|
909
|
+
font-size: 0.78rem;
|
|
910
|
+
font-weight: 700;
|
|
911
|
+
text-transform: uppercase;
|
|
912
|
+
letter-spacing: 0.05em;
|
|
913
|
+
color: var(--muted);
|
|
914
|
+
margin-bottom: 8px;
|
|
915
|
+
}
|
|
916
|
+
.detail-table {
|
|
917
|
+
width: 100%;
|
|
918
|
+
border-collapse: collapse;
|
|
919
|
+
font-size: 0.82rem;
|
|
920
|
+
}
|
|
921
|
+
.detail-table th {
|
|
922
|
+
text-align: left;
|
|
923
|
+
padding: 6px 10px;
|
|
924
|
+
background: var(--cream);
|
|
925
|
+
font-weight: 600;
|
|
926
|
+
font-size: 0.72rem;
|
|
927
|
+
text-transform: uppercase;
|
|
928
|
+
letter-spacing: 0.04em;
|
|
929
|
+
color: var(--muted);
|
|
930
|
+
border-bottom: 1px solid var(--border);
|
|
931
|
+
}
|
|
932
|
+
.detail-table td {
|
|
933
|
+
padding: 6px 10px;
|
|
934
|
+
border-bottom: 1px solid var(--border);
|
|
935
|
+
}
|
|
936
|
+
.detail-table td.mono {
|
|
937
|
+
font-family: var(--font-mono);
|
|
938
|
+
font-size: 0.78rem;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/* Rule result badges */
|
|
942
|
+
.rule-pass {
|
|
943
|
+
display: inline-flex;
|
|
944
|
+
align-items: center;
|
|
945
|
+
gap: 4px;
|
|
946
|
+
font-size: 0.75rem;
|
|
947
|
+
padding: 2px 8px;
|
|
948
|
+
border-radius: 999px;
|
|
949
|
+
background: var(--emerald-bg);
|
|
950
|
+
color: var(--emerald);
|
|
951
|
+
font-weight: 500;
|
|
952
|
+
}
|
|
953
|
+
.rule-fail {
|
|
954
|
+
display: inline-flex;
|
|
955
|
+
align-items: center;
|
|
956
|
+
gap: 4px;
|
|
957
|
+
font-size: 0.75rem;
|
|
958
|
+
padding: 2px 8px;
|
|
959
|
+
border-radius: 999px;
|
|
960
|
+
background: var(--red-bg);
|
|
961
|
+
color: var(--red);
|
|
962
|
+
font-weight: 500;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/* PDF viewer area */
|
|
966
|
+
.pdf-viewer-section { margin-bottom: 18px; }
|
|
967
|
+
.pdf-url-bar {
|
|
968
|
+
display: flex;
|
|
969
|
+
gap: 8px;
|
|
970
|
+
align-items: center;
|
|
971
|
+
margin-bottom: 10px;
|
|
972
|
+
}
|
|
973
|
+
.pdf-url-bar label {
|
|
974
|
+
font-size: 0.75rem;
|
|
975
|
+
font-weight: 600;
|
|
976
|
+
color: var(--muted);
|
|
977
|
+
white-space: nowrap;
|
|
978
|
+
}
|
|
979
|
+
.pdf-url-bar input {
|
|
980
|
+
flex: 1;
|
|
981
|
+
font-family: var(--font-mono);
|
|
982
|
+
font-size: 0.78rem;
|
|
983
|
+
padding: 6px 10px;
|
|
984
|
+
border: 1px solid var(--border);
|
|
985
|
+
border-radius: var(--radius-sm);
|
|
986
|
+
background: var(--cream);
|
|
987
|
+
color: var(--fg);
|
|
988
|
+
outline: none;
|
|
989
|
+
}
|
|
990
|
+
.pdf-url-bar input:focus { border-color: var(--blue); }
|
|
991
|
+
.pdf-url-bar button {
|
|
992
|
+
font-family: var(--font-body);
|
|
993
|
+
font-size: 0.78rem;
|
|
994
|
+
padding: 6px 14px;
|
|
995
|
+
border: 1px solid var(--blue);
|
|
996
|
+
border-radius: var(--radius-sm);
|
|
997
|
+
background: var(--blue);
|
|
998
|
+
color: #fff;
|
|
999
|
+
cursor: pointer;
|
|
1000
|
+
}
|
|
1001
|
+
.pdf-canvas-wrap {
|
|
1002
|
+
border: 1px solid var(--border);
|
|
1003
|
+
border-radius: var(--radius-sm);
|
|
1004
|
+
background: #e5e5e5;
|
|
1005
|
+
height: 600px;
|
|
1006
|
+
display: flex;
|
|
1007
|
+
align-items: flex-start;
|
|
1008
|
+
justify-content: center;
|
|
1009
|
+
overflow: auto;
|
|
1010
|
+
position: relative;
|
|
1011
|
+
}
|
|
1012
|
+
.pdf-canvas-wrap canvas {
|
|
1013
|
+
display: block;
|
|
1014
|
+
}
|
|
1015
|
+
.pdf-placeholder {
|
|
1016
|
+
color: var(--muted-light);
|
|
1017
|
+
font-size: 0.85rem;
|
|
1018
|
+
padding: 40px;
|
|
1019
|
+
text-align: center;
|
|
1020
|
+
}
|
|
1021
|
+
.pdf-nav {
|
|
1022
|
+
display: flex;
|
|
1023
|
+
align-items: center;
|
|
1024
|
+
justify-content: center;
|
|
1025
|
+
gap: 10px;
|
|
1026
|
+
margin-top: 8px;
|
|
1027
|
+
font-size: 0.8rem;
|
|
1028
|
+
color: var(--muted);
|
|
1029
|
+
}
|
|
1030
|
+
.pdf-nav button {
|
|
1031
|
+
font-family: var(--font-body);
|
|
1032
|
+
font-size: 0.78rem;
|
|
1033
|
+
padding: 3px 10px;
|
|
1034
|
+
border: 1px solid var(--border);
|
|
1035
|
+
border-radius: var(--radius-sm);
|
|
1036
|
+
background: var(--card);
|
|
1037
|
+
cursor: pointer;
|
|
1038
|
+
}
|
|
1039
|
+
.pdf-nav button:disabled { opacity: 0.4; cursor: default; }
|
|
1040
|
+
|
|
1041
|
+
/* Collapsible detail sections */
|
|
1042
|
+
.detail-collapsible {
|
|
1043
|
+
border: 1px solid var(--border);
|
|
1044
|
+
border-radius: var(--radius-sm);
|
|
1045
|
+
margin-bottom: 14px;
|
|
1046
|
+
overflow: hidden;
|
|
1047
|
+
}
|
|
1048
|
+
.detail-collapsible-toggle {
|
|
1049
|
+
display: flex;
|
|
1050
|
+
align-items: center;
|
|
1051
|
+
gap: 6px;
|
|
1052
|
+
width: 100%;
|
|
1053
|
+
padding: 10px 14px;
|
|
1054
|
+
background: var(--cream);
|
|
1055
|
+
border: none;
|
|
1056
|
+
cursor: pointer;
|
|
1057
|
+
font-family: var(--font-body);
|
|
1058
|
+
font-size: 0.78rem;
|
|
1059
|
+
font-weight: 700;
|
|
1060
|
+
text-transform: uppercase;
|
|
1061
|
+
letter-spacing: 0.05em;
|
|
1062
|
+
color: var(--muted);
|
|
1063
|
+
text-align: left;
|
|
1064
|
+
}
|
|
1065
|
+
.detail-collapsible-toggle:hover { background: var(--border); }
|
|
1066
|
+
.detail-collapsible-toggle .chevron {
|
|
1067
|
+
display: inline-block;
|
|
1068
|
+
transition: transform 0.2s;
|
|
1069
|
+
font-size: 0.7rem;
|
|
1070
|
+
}
|
|
1071
|
+
.detail-collapsible-toggle.open .chevron { transform: rotate(90deg); }
|
|
1072
|
+
.detail-collapsible-body {
|
|
1073
|
+
display: none;
|
|
1074
|
+
padding: 0;
|
|
1075
|
+
}
|
|
1076
|
+
.detail-collapsible-body.open { display: block; }
|
|
1077
|
+
.detail-collapsible-body .detail-table { margin: 0; }
|
|
1078
|
+
|
|
1079
|
+
/* Output panels (side-by-side) */
|
|
1080
|
+
.output-columns {
|
|
1081
|
+
display: grid;
|
|
1082
|
+
grid-template-columns: 1fr 1fr;
|
|
1083
|
+
gap: 16px;
|
|
1084
|
+
margin-bottom: 18px;
|
|
1085
|
+
}
|
|
1086
|
+
@media (max-width: 900px) {
|
|
1087
|
+
.output-columns { grid-template-columns: 1fr; }
|
|
1088
|
+
}
|
|
1089
|
+
.output-panel {
|
|
1090
|
+
border: 1px solid var(--border);
|
|
1091
|
+
border-radius: var(--radius-sm);
|
|
1092
|
+
overflow: hidden;
|
|
1093
|
+
}
|
|
1094
|
+
.output-panel-header {
|
|
1095
|
+
display: flex;
|
|
1096
|
+
align-items: center;
|
|
1097
|
+
justify-content: space-between;
|
|
1098
|
+
font-size: 0.78rem;
|
|
1099
|
+
font-weight: 700;
|
|
1100
|
+
text-transform: uppercase;
|
|
1101
|
+
letter-spacing: 0.05em;
|
|
1102
|
+
color: var(--muted);
|
|
1103
|
+
padding: 10px 14px;
|
|
1104
|
+
background: var(--cream);
|
|
1105
|
+
border-bottom: 1px solid var(--border);
|
|
1106
|
+
}
|
|
1107
|
+
.output-view-toggle {
|
|
1108
|
+
display: inline-flex;
|
|
1109
|
+
gap: 2px;
|
|
1110
|
+
background: var(--border);
|
|
1111
|
+
border-radius: var(--radius-sm);
|
|
1112
|
+
padding: 2px;
|
|
1113
|
+
}
|
|
1114
|
+
.output-view-btn {
|
|
1115
|
+
font-family: var(--font-body);
|
|
1116
|
+
font-size: 0.68rem;
|
|
1117
|
+
font-weight: 600;
|
|
1118
|
+
padding: 3px 10px;
|
|
1119
|
+
border: none;
|
|
1120
|
+
border-radius: 4px;
|
|
1121
|
+
background: transparent;
|
|
1122
|
+
color: var(--muted);
|
|
1123
|
+
cursor: pointer;
|
|
1124
|
+
text-transform: none;
|
|
1125
|
+
letter-spacing: 0;
|
|
1126
|
+
transition: all 0.12s;
|
|
1127
|
+
}
|
|
1128
|
+
.output-view-btn.active {
|
|
1129
|
+
background: var(--card);
|
|
1130
|
+
color: var(--fg);
|
|
1131
|
+
box-shadow: var(--shadow-sm);
|
|
1132
|
+
}
|
|
1133
|
+
.output-copy-btn {
|
|
1134
|
+
font-family: var(--font-body);
|
|
1135
|
+
font-size: 0.68rem;
|
|
1136
|
+
font-weight: 600;
|
|
1137
|
+
padding: 3px 10px;
|
|
1138
|
+
border: 1px solid var(--border);
|
|
1139
|
+
border-radius: 4px;
|
|
1140
|
+
background: var(--card);
|
|
1141
|
+
color: var(--muted);
|
|
1142
|
+
cursor: pointer;
|
|
1143
|
+
margin-left: 6px;
|
|
1144
|
+
transition: all 0.12s;
|
|
1145
|
+
}
|
|
1146
|
+
.output-copy-btn:hover { background: var(--border); color: var(--fg); }
|
|
1147
|
+
.output-copy-btn.copied { background: #d1fae5; color: #065f46; border-color: #6ee7b7; }
|
|
1148
|
+
.output-panel-body {
|
|
1149
|
+
padding: 12px 14px;
|
|
1150
|
+
max-height: 500px;
|
|
1151
|
+
overflow: auto;
|
|
1152
|
+
font-family: var(--font-mono);
|
|
1153
|
+
font-size: 0.78rem;
|
|
1154
|
+
line-height: 1.6;
|
|
1155
|
+
white-space: pre-wrap;
|
|
1156
|
+
word-break: break-word;
|
|
1157
|
+
background: var(--card);
|
|
1158
|
+
}
|
|
1159
|
+
.output-panel-body.rendered-md {
|
|
1160
|
+
font-family: var(--font-body);
|
|
1161
|
+
white-space: normal;
|
|
1162
|
+
}
|
|
1163
|
+
.output-panel-body.rendered-md table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 0.8rem; }
|
|
1164
|
+
.output-panel-body.rendered-md th,
|
|
1165
|
+
.output-panel-body.rendered-md td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
|
|
1166
|
+
.output-panel-body.rendered-md th { background: var(--cream); font-weight: 600; }
|
|
1167
|
+
.output-panel-body.rendered-md pre { background: var(--cream); padding: 10px; border-radius: var(--radius-sm); overflow-x: auto; font-size: 0.78rem; }
|
|
1168
|
+
.output-panel-body.rendered-md code { font-family: var(--font-mono); font-size: 0.85em; background: var(--cream); padding: 1px 4px; border-radius: 3px; }
|
|
1169
|
+
.output-panel-body.rendered-md pre code { background: none; padding: 0; }
|
|
1170
|
+
.output-panel-body.rendered-md h1, .output-panel-body.rendered-md h2, .output-panel-body.rendered-md h3,
|
|
1171
|
+
.output-panel-body.rendered-md h4, .output-panel-body.rendered-md h5, .output-panel-body.rendered-md h6 {
|
|
1172
|
+
margin: 16px 0 8px; font-family: var(--font-heading);
|
|
1173
|
+
}
|
|
1174
|
+
.output-panel-body.rendered-md p { margin: 8px 0; }
|
|
1175
|
+
.output-panel-body.rendered-md ul, .output-panel-body.rendered-md ol { margin: 8px 0; padding-left: 24px; }
|
|
1176
|
+
.output-panel-body.rendered-md blockquote { border-left: 3px solid var(--border); margin: 8px 0; padding: 4px 12px; color: var(--muted); }
|
|
1177
|
+
.output-panel-body.rendered-md img { max-width: 100%; }
|
|
1178
|
+
.output-panel-body.rendered-md hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
|
|
1179
|
+
.output-empty {
|
|
1180
|
+
color: var(--muted-light);
|
|
1181
|
+
font-style: italic;
|
|
1182
|
+
font-family: var(--font-body);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/* Placeholder sections */
|
|
1186
|
+
.placeholder-note {
|
|
1187
|
+
font-size: 0.82rem;
|
|
1188
|
+
color: var(--muted-light);
|
|
1189
|
+
font-style: italic;
|
|
1190
|
+
padding: 12px 0;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/* ───── Responsive ───── */
|
|
1194
|
+
@media (max-width: 768px) {
|
|
1195
|
+
.report-container { padding: 16px 12px; }
|
|
1196
|
+
.controls-bar { flex-direction: column; align-items: stretch; }
|
|
1197
|
+
.controls-bar input[type="text"] { width: 100%; }
|
|
1198
|
+
.col-tags { display: none; }
|
|
1199
|
+
}
|
|
1200
|
+
"""
|
|
1201
|
+
+ TOOLTIP_CSS
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
_JS = (
|
|
1205
|
+
r"""
|
|
1206
|
+
(function() {
|
|
1207
|
+
"use strict";
|
|
1208
|
+
|
|
1209
|
+
// ─── State ───
|
|
1210
|
+
var state = {
|
|
1211
|
+
currentMetric: '',
|
|
1212
|
+
sortMode: 'score_desc', // score_desc, score_asc, alpha
|
|
1213
|
+
searchQuery: '',
|
|
1214
|
+
activeTags: [],
|
|
1215
|
+
rangeMin: 0.0,
|
|
1216
|
+
rangeMax: 1.0,
|
|
1217
|
+
currentPage: 1,
|
|
1218
|
+
perPage: 50,
|
|
1219
|
+
filtered: [],
|
|
1220
|
+
expandedId: null
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
var metricKeys = Object.keys(DATA.metricNames);
|
|
1224
|
+
if (DATA.aggMetrics.length > 0) {
|
|
1225
|
+
state.currentMetric = DATA.aggMetrics[0].name;
|
|
1226
|
+
} else if (metricKeys.length > 0) {
|
|
1227
|
+
state.currentMetric = metricKeys[0];
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ─── Helpers ───
|
|
1231
|
+
function esc(s) {
|
|
1232
|
+
if (s == null) return '';
|
|
1233
|
+
var d = document.createElement('div');
|
|
1234
|
+
d.textContent = String(s);
|
|
1235
|
+
return d.innerHTML;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
"""
|
|
1239
|
+
+ TOOLTIP_JS
|
|
1240
|
+
+ r"""
|
|
1241
|
+
|
|
1242
|
+
// Toggle between raw/rendered markdown view
|
|
1243
|
+
window.toggleOutputView = function(panelId, mode) {
|
|
1244
|
+
var panel = document.getElementById(panelId);
|
|
1245
|
+
if (!panel) return;
|
|
1246
|
+
var rawEl = panel.querySelector('.output-raw');
|
|
1247
|
+
var renderedEl = panel.querySelector('.output-rendered');
|
|
1248
|
+
var btns = panel.querySelectorAll('.output-view-btn');
|
|
1249
|
+
btns.forEach(function(b) { b.classList.toggle('active', b.getAttribute('data-mode') === mode); });
|
|
1250
|
+
if (mode === 'rendered') {
|
|
1251
|
+
rawEl.style.display = 'none';
|
|
1252
|
+
renderedEl.style.display = 'block';
|
|
1253
|
+
} else {
|
|
1254
|
+
rawEl.style.display = 'block';
|
|
1255
|
+
renderedEl.style.display = 'none';
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
// Copy raw output to clipboard
|
|
1260
|
+
window.copyOutput = function(panelId, btn) {
|
|
1261
|
+
var panel = document.getElementById(panelId);
|
|
1262
|
+
if (!panel) return;
|
|
1263
|
+
var rawEl = panel.querySelector('.output-raw');
|
|
1264
|
+
if (!rawEl) return;
|
|
1265
|
+
var text = rawEl.textContent || rawEl.innerText;
|
|
1266
|
+
navigator.clipboard.writeText(text).then(function() {
|
|
1267
|
+
btn.textContent = 'Copied!';
|
|
1268
|
+
btn.classList.add('copied');
|
|
1269
|
+
setTimeout(function() { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
|
|
1270
|
+
});
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
function scoreColor(v) {
|
|
1274
|
+
if (v >= 0.9) return 'emerald';
|
|
1275
|
+
if (v >= 0.7) return 'amber';
|
|
1276
|
+
return 'red';
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function fmt(v, decimals) {
|
|
1280
|
+
if (v == null || isNaN(v)) return '-';
|
|
1281
|
+
return Number(v).toFixed(decimals !== undefined ? decimals : 4);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function fmtStat(v, unit) {
|
|
1285
|
+
if (v == null || isNaN(v)) return '-';
|
|
1286
|
+
var n = Number(v);
|
|
1287
|
+
if (Math.abs(n) >= 1000) return n.toFixed(0) + (unit || '');
|
|
1288
|
+
if (Math.abs(n) >= 1) return n.toFixed(1) + (unit || '');
|
|
1289
|
+
return n.toFixed(3) + (unit || '');
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function debounce(fn, ms) {
|
|
1293
|
+
var timer;
|
|
1294
|
+
return function() {
|
|
1295
|
+
var args = arguments, ctx = this;
|
|
1296
|
+
clearTimeout(timer);
|
|
1297
|
+
timer = setTimeout(function() { fn.apply(ctx, args); }, ms);
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function getExampleScore(ex) {
|
|
1302
|
+
if (!state.currentMetric) return null;
|
|
1303
|
+
var v = ex.metrics[state.currentMetric];
|
|
1304
|
+
return (v !== undefined && v !== null) ? v : null;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// ─── Render summary cards ───
|
|
1308
|
+
function renderSummary() {
|
|
1309
|
+
var el = document.getElementById('summary-cards');
|
|
1310
|
+
var s = DATA.summary;
|
|
1311
|
+
el.innerHTML =
|
|
1312
|
+
'<div class="summary-card"><div class="label">Total Examples</div><div class="big-number">' + s.total + '</div></div>' +
|
|
1313
|
+
'<div class="summary-card card-success"><div class="label">Successful</div><div class="big-number">' + s.successful + '</div></div>' +
|
|
1314
|
+
'<div class="summary-card card-failed"><div class="label">Failed</div><div class="big-number">' + s.failed + '</div></div>' +
|
|
1315
|
+
'<div class="summary-card card-skipped"><div class="label">Skipped</div><div class="big-number">' + s.skipped + '</div></div>';
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// ─── Render aggregate metrics ───
|
|
1319
|
+
function renderAggMetrics() {
|
|
1320
|
+
var el = document.getElementById('agg-metrics');
|
|
1321
|
+
if (!DATA.aggMetrics.length) { el.style.display = 'none'; return; }
|
|
1322
|
+
var html = '<h2 class="section-title">Aggregate Metrics</h2><div class="metrics-grid">';
|
|
1323
|
+
for (var i = 0; i < DATA.aggMetrics.length; i++) {
|
|
1324
|
+
var m = DATA.aggMetrics[i];
|
|
1325
|
+
var c = scoreColor(m.avg);
|
|
1326
|
+
html += '<div class="metric-card">' +
|
|
1327
|
+
'<div class="metric-label">' + esc(m.displayName) + tooltipIcon(m.name) + '</div>' +
|
|
1328
|
+
'<div class="metric-avg color-' + c + '">' + fmt(m.avg) + '</div>' +
|
|
1329
|
+
'<div class="metric-bar-track"><div class="metric-bar-fill bar-' + c + '" style="width:' + (m.avg * 100).toFixed(1) + '%"></div></div>' +
|
|
1330
|
+
'<div class="metric-range"><span>Min: ' + fmt(m.min) + '</span><span>Max: ' + fmt(m.max) + '</span></div>' +
|
|
1331
|
+
'</div>';
|
|
1332
|
+
}
|
|
1333
|
+
html += '</div>';
|
|
1334
|
+
el.innerHTML = html;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// ─── Render aggregate stats ───
|
|
1338
|
+
function renderAggStats() {
|
|
1339
|
+
var el = document.getElementById('agg-stats');
|
|
1340
|
+
if (!DATA.aggStats.length) { el.style.display = 'none'; return; }
|
|
1341
|
+
var html = '<button class="stats-toggle" id="stats-toggle">' +
|
|
1342
|
+
'<span class="chevron">▶</span> Operational Statistics</button>' +
|
|
1343
|
+
'<div class="stats-body" id="stats-body"><div class="stats-grid">';
|
|
1344
|
+
for (var i = 0; i < DATA.aggStats.length; i++) {
|
|
1345
|
+
var s = DATA.aggStats[i];
|
|
1346
|
+
var u = s.unit || '';
|
|
1347
|
+
html += '<div class="stat-card">' +
|
|
1348
|
+
'<div class="stat-label">' + esc(s.displayName) + (u ? ' (' + esc(u) + ')' : '') + '</div>' +
|
|
1349
|
+
'<div class="stat-avg">' + fmtStat(s.avg, u) + '</div>' +
|
|
1350
|
+
'<div class="stat-detail">' +
|
|
1351
|
+
'<span>Min: ' + fmtStat(s.min, u) + '</span>' +
|
|
1352
|
+
'<span>Max: ' + fmtStat(s.max, u) + '</span>' +
|
|
1353
|
+
'<span>P50: ' + fmtStat(s.p50, u) + '</span>' +
|
|
1354
|
+
'<span>P95: ' + fmtStat(s.p95, u) + '</span>' +
|
|
1355
|
+
'<span>P99: ' + fmtStat(s.p99, u) + '</span>' +
|
|
1356
|
+
'<span>Total: ' + fmtStat(s.total, u) + '</span>' +
|
|
1357
|
+
'<span>Count: ' + s.count + '</span>' +
|
|
1358
|
+
'</div></div>';
|
|
1359
|
+
}
|
|
1360
|
+
html += '</div></div>';
|
|
1361
|
+
el.innerHTML = html;
|
|
1362
|
+
|
|
1363
|
+
document.getElementById('stats-toggle').addEventListener('click', function() {
|
|
1364
|
+
this.classList.toggle('open');
|
|
1365
|
+
document.getElementById('stats-body').classList.toggle('open');
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// ─── Render tag metrics ───
|
|
1370
|
+
function renderTagMetrics() {
|
|
1371
|
+
var el = document.getElementById('tag-metrics');
|
|
1372
|
+
var tagKeys = Object.keys(DATA.tagMetrics);
|
|
1373
|
+
if (!tagKeys.length) { el.style.display = 'none'; return; }
|
|
1374
|
+
var html = '<h2 class="section-title">Metrics by Tag</h2><div class="tag-metrics-grid">';
|
|
1375
|
+
tagKeys.sort();
|
|
1376
|
+
for (var t = 0; t < tagKeys.length; t++) {
|
|
1377
|
+
var tag = tagKeys[t];
|
|
1378
|
+
var tm = DATA.tagMetrics[tag];
|
|
1379
|
+
var mKeys = Object.keys(tm).sort();
|
|
1380
|
+
html += '<div class="tag-card"><div class="tag-name">' + esc(tag) + '</div>';
|
|
1381
|
+
for (var mk = 0; mk < mKeys.length; mk++) {
|
|
1382
|
+
var mName = mKeys[mk];
|
|
1383
|
+
// try to find a display name
|
|
1384
|
+
var baseName = mName.replace(/^avg_/, '').replace(/^min_/, '').replace(/^max_/, '');
|
|
1385
|
+
var displayName = DATA.metricNames[baseName] || mName.replace(/_/g, ' ');
|
|
1386
|
+
html += '<div class="tag-metric-row"><span class="tag-metric-name">' + esc(displayName) +
|
|
1387
|
+
'</span><span class="tag-metric-value">' + fmt(tm[mKeys[mk]]) + '</span></div>';
|
|
1388
|
+
}
|
|
1389
|
+
html += '</div>';
|
|
1390
|
+
}
|
|
1391
|
+
html += '</div>';
|
|
1392
|
+
el.innerHTML = html;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// ─── Controls ───
|
|
1396
|
+
function renderControls() {
|
|
1397
|
+
var el = document.getElementById('controls');
|
|
1398
|
+
var html = '';
|
|
1399
|
+
|
|
1400
|
+
// Metric selector
|
|
1401
|
+
html += '<div class="control-group"><label>Metric</label><select id="metric-select">';
|
|
1402
|
+
for (var i = 0; i < metricKeys.length; i++) {
|
|
1403
|
+
var k = metricKeys[i];
|
|
1404
|
+
var sel = (k === state.currentMetric) ? ' selected' : '';
|
|
1405
|
+
html += '<option value="' + esc(k) + '"' + sel + '>' + esc(DATA.metricNames[k]) + '</option>';
|
|
1406
|
+
}
|
|
1407
|
+
html += '</select></div>';
|
|
1408
|
+
|
|
1409
|
+
// Sort
|
|
1410
|
+
html += '<div class="control-group"><label>Sort</label><select id="sort-select">' +
|
|
1411
|
+
'<option value="score_desc"' + (state.sortMode === 'score_desc' ? ' selected' : '') + '>Score ↓</option>' +
|
|
1412
|
+
'<option value="score_asc"' + (state.sortMode === 'score_asc' ? ' selected' : '') + '>Score ↑</option>' +
|
|
1413
|
+
'<option value="alpha"' + (state.sortMode === 'alpha' ? ' selected' : '') + '>Name A-Z</option>' +
|
|
1414
|
+
'</select></div>';
|
|
1415
|
+
|
|
1416
|
+
// Score range
|
|
1417
|
+
html += '<div class="control-group range-group"><label>Score Range</label>' +
|
|
1418
|
+
'<span class="range-label" id="range-min-label">' + state.rangeMin.toFixed(1) + '</span>' +
|
|
1419
|
+
'<input type="range" id="range-min" min="0" max="1" step="0.05" value="' + state.rangeMin + '">' +
|
|
1420
|
+
'<input type="range" id="range-max" min="0" max="1" step="0.05" value="' + state.rangeMax + '">' +
|
|
1421
|
+
'<span class="range-label" id="range-max-label">' + state.rangeMax.toFixed(1) + '</span>' +
|
|
1422
|
+
'</div>';
|
|
1423
|
+
|
|
1424
|
+
// Search
|
|
1425
|
+
html += '<div class="control-group"><label>Search</label>' +
|
|
1426
|
+
'<input type="text" id="search-input" placeholder="Filter by test ID..." value="' + esc(state.searchQuery) + '"></div>';
|
|
1427
|
+
|
|
1428
|
+
el.innerHTML = html;
|
|
1429
|
+
|
|
1430
|
+
// Tag pills
|
|
1431
|
+
var tagEl = document.getElementById('tag-filters');
|
|
1432
|
+
if (DATA.tags.length) {
|
|
1433
|
+
var thtml = '';
|
|
1434
|
+
for (var t = 0; t < DATA.tags.length; t++) {
|
|
1435
|
+
var active = state.activeTags.indexOf(DATA.tags[t]) >= 0 ? ' active' : '';
|
|
1436
|
+
thtml += '<span class="tag-pill' + active + '" data-tag="' + esc(DATA.tags[t]) + '">' + esc(DATA.tags[t]) + '</span>';
|
|
1437
|
+
}
|
|
1438
|
+
tagEl.innerHTML = thtml;
|
|
1439
|
+
tagEl.style.display = '';
|
|
1440
|
+
} else {
|
|
1441
|
+
tagEl.style.display = 'none';
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Event listeners
|
|
1445
|
+
document.getElementById('metric-select').addEventListener('change', function() {
|
|
1446
|
+
state.currentMetric = this.value;
|
|
1447
|
+
state.currentPage = 1;
|
|
1448
|
+
applyFiltersAndRender();
|
|
1449
|
+
});
|
|
1450
|
+
document.getElementById('sort-select').addEventListener('change', function() {
|
|
1451
|
+
state.sortMode = this.value;
|
|
1452
|
+
state.currentPage = 1;
|
|
1453
|
+
applyFiltersAndRender();
|
|
1454
|
+
});
|
|
1455
|
+
document.getElementById('range-min').addEventListener('input', function() {
|
|
1456
|
+
state.rangeMin = parseFloat(this.value);
|
|
1457
|
+
if (state.rangeMin > state.rangeMax) { state.rangeMin = state.rangeMax; this.value = state.rangeMin; }
|
|
1458
|
+
document.getElementById('range-min-label').textContent = state.rangeMin.toFixed(1);
|
|
1459
|
+
});
|
|
1460
|
+
document.getElementById('range-min').addEventListener('change', function() {
|
|
1461
|
+
state.currentPage = 1;
|
|
1462
|
+
applyFiltersAndRender();
|
|
1463
|
+
});
|
|
1464
|
+
document.getElementById('range-max').addEventListener('input', function() {
|
|
1465
|
+
state.rangeMax = parseFloat(this.value);
|
|
1466
|
+
if (state.rangeMax < state.rangeMin) { state.rangeMax = state.rangeMin; this.value = state.rangeMax; }
|
|
1467
|
+
document.getElementById('range-max-label').textContent = state.rangeMax.toFixed(1);
|
|
1468
|
+
});
|
|
1469
|
+
document.getElementById('range-max').addEventListener('change', function() {
|
|
1470
|
+
state.currentPage = 1;
|
|
1471
|
+
applyFiltersAndRender();
|
|
1472
|
+
});
|
|
1473
|
+
document.getElementById('search-input').addEventListener('input', debounce(function() {
|
|
1474
|
+
state.searchQuery = this.value.toLowerCase();
|
|
1475
|
+
state.currentPage = 1;
|
|
1476
|
+
applyFiltersAndRender();
|
|
1477
|
+
}, 300));
|
|
1478
|
+
|
|
1479
|
+
tagEl.addEventListener('click', function(e) {
|
|
1480
|
+
var pill = e.target.closest('.tag-pill');
|
|
1481
|
+
if (!pill) return;
|
|
1482
|
+
var tag = pill.getAttribute('data-tag');
|
|
1483
|
+
var idx = state.activeTags.indexOf(tag);
|
|
1484
|
+
if (idx >= 0) {
|
|
1485
|
+
state.activeTags.splice(idx, 1);
|
|
1486
|
+
pill.classList.remove('active');
|
|
1487
|
+
} else {
|
|
1488
|
+
state.activeTags.push(tag);
|
|
1489
|
+
pill.classList.add('active');
|
|
1490
|
+
}
|
|
1491
|
+
state.currentPage = 1;
|
|
1492
|
+
applyFiltersAndRender();
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// ─── Filter + sort ───
|
|
1497
|
+
function applyFilters() {
|
|
1498
|
+
var results = [];
|
|
1499
|
+
for (var i = 0; i < DATA.examples.length; i++) {
|
|
1500
|
+
var ex = DATA.examples[i];
|
|
1501
|
+
// Search filter
|
|
1502
|
+
if (state.searchQuery && ex.id.toLowerCase().indexOf(state.searchQuery) < 0) continue;
|
|
1503
|
+
// Tag filter
|
|
1504
|
+
if (state.activeTags.length > 0) {
|
|
1505
|
+
var hasTag = false;
|
|
1506
|
+
for (var t = 0; t < state.activeTags.length; t++) {
|
|
1507
|
+
if (ex.tags.indexOf(state.activeTags[t]) >= 0) { hasTag = true; break; }
|
|
1508
|
+
}
|
|
1509
|
+
if (!hasTag) continue;
|
|
1510
|
+
}
|
|
1511
|
+
// Score range filter
|
|
1512
|
+
var score = getExampleScore(ex);
|
|
1513
|
+
if (score !== null) {
|
|
1514
|
+
if (score < state.rangeMin || score > state.rangeMax) continue;
|
|
1515
|
+
}
|
|
1516
|
+
results.push(ex);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Sort
|
|
1520
|
+
results.sort(function(a, b) {
|
|
1521
|
+
if (state.sortMode === 'alpha') {
|
|
1522
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
1523
|
+
}
|
|
1524
|
+
var sa = getExampleScore(a);
|
|
1525
|
+
var sb = getExampleScore(b);
|
|
1526
|
+
// Nulls go last
|
|
1527
|
+
if (sa === null && sb === null) return 0;
|
|
1528
|
+
if (sa === null) return 1;
|
|
1529
|
+
if (sb === null) return -1;
|
|
1530
|
+
if (state.sortMode === 'score_desc') return sb - sa;
|
|
1531
|
+
return sa - sb;
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
state.filtered = results;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function applyFiltersAndRender() {
|
|
1538
|
+
applyFilters();
|
|
1539
|
+
state.expandedId = null;
|
|
1540
|
+
renderResultsCount();
|
|
1541
|
+
renderTable();
|
|
1542
|
+
renderPagination();
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// ─── Results count ───
|
|
1546
|
+
function renderResultsCount() {
|
|
1547
|
+
var el = document.getElementById('results-count');
|
|
1548
|
+
el.textContent = state.filtered.length + ' of ' + DATA.examples.length + ' examples';
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// ─── Table ───
|
|
1552
|
+
function renderTable() {
|
|
1553
|
+
var tbody = document.getElementById('examples-tbody');
|
|
1554
|
+
var frag = document.createDocumentFragment();
|
|
1555
|
+
var start = (state.currentPage - 1) * state.perPage;
|
|
1556
|
+
var end = Math.min(start + state.perPage, state.filtered.length);
|
|
1557
|
+
|
|
1558
|
+
// Clear
|
|
1559
|
+
tbody.innerHTML = '';
|
|
1560
|
+
|
|
1561
|
+
for (var i = start; i < end; i++) {
|
|
1562
|
+
var ex = state.filtered[i];
|
|
1563
|
+
var score = getExampleScore(ex);
|
|
1564
|
+
var tr = document.createElement('tr');
|
|
1565
|
+
tr.setAttribute('data-id', ex.id);
|
|
1566
|
+
if (ex.id === state.expandedId) tr.className = 'selected';
|
|
1567
|
+
|
|
1568
|
+
// Status
|
|
1569
|
+
var td0 = document.createElement('td');
|
|
1570
|
+
td0.innerHTML = '<span class="status-dot ' + (ex.success ? 'ok' : 'fail') + '"></span>';
|
|
1571
|
+
tr.appendChild(td0);
|
|
1572
|
+
|
|
1573
|
+
// Test ID
|
|
1574
|
+
var td1 = document.createElement('td');
|
|
1575
|
+
td1.textContent = ex.id;
|
|
1576
|
+
td1.style.fontFamily = 'var(--font-mono)';
|
|
1577
|
+
td1.style.fontSize = '0.8rem';
|
|
1578
|
+
td1.style.wordBreak = 'break-all';
|
|
1579
|
+
tr.appendChild(td1);
|
|
1580
|
+
|
|
1581
|
+
// Score
|
|
1582
|
+
var td2 = document.createElement('td');
|
|
1583
|
+
if (score !== null) {
|
|
1584
|
+
var c = scoreColor(score);
|
|
1585
|
+
td2.innerHTML = '<div class="score-cell">' +
|
|
1586
|
+
'<div class="score-bar-track"><div class="score-bar-fill bar-' + c + '" style="width:' + (score * 100).toFixed(1) + '%"></div></div>' +
|
|
1587
|
+
'<span class="score-value color-' + c + '">' + fmt(score) + '</span></div>';
|
|
1588
|
+
} else {
|
|
1589
|
+
td2.innerHTML = '<span style="color:var(--muted-light);font-size:0.8rem">-</span>';
|
|
1590
|
+
}
|
|
1591
|
+
tr.appendChild(td2);
|
|
1592
|
+
|
|
1593
|
+
// Tags
|
|
1594
|
+
var td3 = document.createElement('td');
|
|
1595
|
+
td3.className = 'col-tags-cell';
|
|
1596
|
+
if (ex.tags.length) {
|
|
1597
|
+
var badges = '';
|
|
1598
|
+
for (var t = 0; t < ex.tags.length; t++) {
|
|
1599
|
+
badges += '<span class="tag-badge">' + esc(ex.tags[t]) + '</span>';
|
|
1600
|
+
}
|
|
1601
|
+
td3.innerHTML = '<div class="tag-badges">' + badges + '</div>';
|
|
1602
|
+
}
|
|
1603
|
+
tr.appendChild(td3);
|
|
1604
|
+
|
|
1605
|
+
frag.appendChild(tr);
|
|
1606
|
+
|
|
1607
|
+
// Detail panel if expanded
|
|
1608
|
+
if (ex.id === state.expandedId) {
|
|
1609
|
+
var detailTr = document.createElement('tr');
|
|
1610
|
+
var detailTd = document.createElement('td');
|
|
1611
|
+
detailTd.colSpan = 4;
|
|
1612
|
+
detailTd.style.padding = '0';
|
|
1613
|
+
detailTd.innerHTML = buildDetailPanel(ex);
|
|
1614
|
+
detailTr.appendChild(detailTd);
|
|
1615
|
+
frag.appendChild(detailTr);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
tbody.appendChild(frag);
|
|
1620
|
+
|
|
1621
|
+
// Row click handler (delegated)
|
|
1622
|
+
tbody.onclick = function(e) {
|
|
1623
|
+
var tr = e.target.closest('tr[data-id]');
|
|
1624
|
+
if (!tr) return;
|
|
1625
|
+
// Don't toggle if clicking inside detail panel
|
|
1626
|
+
if (e.target.closest('.detail-panel')) return;
|
|
1627
|
+
var id = tr.getAttribute('data-id');
|
|
1628
|
+
if (state.expandedId === id) {
|
|
1629
|
+
state.expandedId = null;
|
|
1630
|
+
} else {
|
|
1631
|
+
state.expandedId = id;
|
|
1632
|
+
}
|
|
1633
|
+
renderTable();
|
|
1634
|
+
initPdfViewerIfNeeded();
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// ─── Detail panel ───
|
|
1639
|
+
function buildDetailPanel(ex) {
|
|
1640
|
+
var html = '<div class="detail-panel">';
|
|
1641
|
+
html += '<div class="detail-header"><div class="detail-title">' + esc(ex.id) + '</div>' +
|
|
1642
|
+
'<button class="detail-close" onclick="event.stopPropagation();closeDetail()">Close</button></div>';
|
|
1643
|
+
|
|
1644
|
+
// Job IDs
|
|
1645
|
+
if (ex.parseJobId || ex.jobId) {
|
|
1646
|
+
html += '<div style="font-size:0.8rem;color:var(--muted);margin-bottom:12px">';
|
|
1647
|
+
if (ex.parseJobId) {
|
|
1648
|
+
html += '<span><strong>Parse Job ID:</strong> <span style="font-family:var(--font-mono);font-size:0.78rem">' + esc(ex.parseJobId) + '</span></span>';
|
|
1649
|
+
}
|
|
1650
|
+
if (ex.jobId && ex.jobId !== ex.parseJobId) {
|
|
1651
|
+
if (ex.parseJobId) html += '<span style="margin:0 10px;color:var(--border)">|</span>';
|
|
1652
|
+
html += '<span><strong>Job ID:</strong> <span style="font-family:var(--font-mono);font-size:0.78rem">' + esc(ex.jobId) + '</span></span>';
|
|
1653
|
+
}
|
|
1654
|
+
html += '</div>';
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
if (ex.parseJobLogsUrl || ex.parseJobLogsLocalPath || ex.parseJobLogsHtmlPath) {
|
|
1658
|
+
html += '<div style="font-size:0.8rem;color:var(--muted);margin:-4px 0 12px 0">';
|
|
1659
|
+
html += '<strong>Parse Job Logs:</strong> ';
|
|
1660
|
+
var links = [];
|
|
1661
|
+
if (ex.parseJobLogsUrl) {
|
|
1662
|
+
links.push('<a href="' + esc(ex.parseJobLogsUrl) + '" target="_blank" rel="noopener noreferrer">jobLogs.json (presigned)</a>');
|
|
1663
|
+
}
|
|
1664
|
+
if (ex.parseJobLogsLocalPath) {
|
|
1665
|
+
links.push('<a href="' + esc(ex.parseJobLogsLocalPath) + '" target="_blank" rel="noopener noreferrer">jobLogs.json (local)</a>');
|
|
1666
|
+
}
|
|
1667
|
+
if (ex.parseJobLogsHtmlPath) {
|
|
1668
|
+
links.push('<a href="' + esc(ex.parseJobLogsHtmlPath) + '" target="_blank" rel="noopener noreferrer">Pretty Viewer</a>');
|
|
1669
|
+
}
|
|
1670
|
+
html += links.join(' <span style="color:var(--border);margin:0 8px;">|</span> ');
|
|
1671
|
+
html += '</div>';
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// Error
|
|
1675
|
+
if (ex.error) {
|
|
1676
|
+
html += '<div class="detail-error">' + esc(ex.error) + '</div>';
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// All metrics (collapsible, with inline sub-collapsible details per metric)
|
|
1680
|
+
var metricEntries = Object.keys(ex.metrics);
|
|
1681
|
+
if (metricEntries.length) {
|
|
1682
|
+
var metricSummary = metricEntries.length + ' metric' + (metricEntries.length > 1 ? 's' : '');
|
|
1683
|
+
html += '<div class="detail-collapsible">' +
|
|
1684
|
+
'<button class="detail-collapsible-toggle" onclick="event.stopPropagation();toggleCollapsible(this)">' +
|
|
1685
|
+
'<span class="chevron">▶</span> Metrics (' + metricSummary + ')</button>' +
|
|
1686
|
+
'<div class="detail-collapsible-body">';
|
|
1687
|
+
metricEntries.sort();
|
|
1688
|
+
for (var i = 0; i < metricEntries.length; i++) {
|
|
1689
|
+
var mName = metricEntries[i];
|
|
1690
|
+
var mVal = ex.metrics[mName];
|
|
1691
|
+
var c = scoreColor(mVal);
|
|
1692
|
+
var ruleStr = '';
|
|
1693
|
+
if (ex.ruleDetails[mName]) {
|
|
1694
|
+
var rd = ex.ruleDetails[mName];
|
|
1695
|
+
ruleStr = ' <span class="mono" style="margin-left:8px;font-size:0.78rem;color:var(--muted)">' + rd.passed + '/' + rd.total + ' rules</span>';
|
|
1696
|
+
}
|
|
1697
|
+
var hasDetails = ex.metricDetails && ex.metricDetails[mName] && ex.metricDetails[mName].length > 0;
|
|
1698
|
+
if (hasDetails) {
|
|
1699
|
+
html += '<div class="detail-collapsible" style="margin:0;border:none;border-bottom:1px solid var(--border)">' +
|
|
1700
|
+
'<button class="detail-collapsible-toggle" style="padding:5px 14px;font-size:0.82rem" onclick="event.stopPropagation();toggleCollapsible(this)">' +
|
|
1701
|
+
'<span class="chevron">▶</span> ' +
|
|
1702
|
+
'<span style="display:inline-flex;align-items:center;min-width:220px">' + esc(DATA.metricNames[mName] || mName) + tooltipIcon(mName) + '</span>' +
|
|
1703
|
+
'<span class="mono color-' + c + '" style="margin-left:8px">' + fmt(mVal) + '</span>' +
|
|
1704
|
+
ruleStr + '</button>' +
|
|
1705
|
+
'<div class="detail-collapsible-body" style="padding:2px 14px 8px 32px;background:var(--bg)">';
|
|
1706
|
+
var lines = ex.metricDetails[mName];
|
|
1707
|
+
// Check if lines use [SECTION:...] markers for sub-collapsibles
|
|
1708
|
+
var hasSections = false;
|
|
1709
|
+
for (var si = 0; si < lines.length; si++) {
|
|
1710
|
+
if (lines[si].indexOf('[SECTION:') === 0) { hasSections = true; break; }
|
|
1711
|
+
}
|
|
1712
|
+
if (hasSections) {
|
|
1713
|
+
var inSection = false;
|
|
1714
|
+
for (var li = 0; li < lines.length; li++) {
|
|
1715
|
+
var ln = lines[li];
|
|
1716
|
+
var secMatch = ln.match(/^\[SECTION:(.+)\]$/);
|
|
1717
|
+
if (secMatch) {
|
|
1718
|
+
if (inSection) html += '</div></div></div>';
|
|
1719
|
+
html += '<div class="detail-collapsible" style="margin:2px 0;border:none;border-bottom:1px solid var(--border)">' +
|
|
1720
|
+
'<button class="detail-collapsible-toggle" style="padding:3px 10px;font-size:0.78rem" onclick="event.stopPropagation();toggleCollapsible(this)">' +
|
|
1721
|
+
'<span class="chevron">▶</span> ' + esc(secMatch[1]) + '</button>' +
|
|
1722
|
+
'<div class="detail-collapsible-body" style="padding:2px 10px 6px 28px;background:var(--bg)">' +
|
|
1723
|
+
'<div style="font-size:0.75rem;line-height:1.5;font-family:var(--font-mono);white-space:pre-wrap;color:var(--text)">';
|
|
1724
|
+
inSection = true;
|
|
1725
|
+
} else {
|
|
1726
|
+
html += esc(ln) + '\n';
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
if (inSection) html += '</div></div></div>';
|
|
1730
|
+
} else {
|
|
1731
|
+
html += '<div style="font-size:0.78rem;line-height:1.6;font-family:var(--font-mono);white-space:pre-wrap;color:var(--text)">';
|
|
1732
|
+
for (var li = 0; li < lines.length; li++) {
|
|
1733
|
+
html += esc(lines[li]) + '\n';
|
|
1734
|
+
}
|
|
1735
|
+
html += '</div>';
|
|
1736
|
+
}
|
|
1737
|
+
html += '</div></div>';
|
|
1738
|
+
} else {
|
|
1739
|
+
html += '<div style="padding:5px 14px;font-size:0.82rem;border-bottom:1px solid var(--border)">' +
|
|
1740
|
+
'<span style="display:inline-block;width:18px"></span>' +
|
|
1741
|
+
'<span style="display:inline-flex;align-items:center;min-width:220px">' + esc(DATA.metricNames[mName] || mName) + tooltipIcon(mName) + '</span>' +
|
|
1742
|
+
'<span class="mono color-' + c + '" style="margin-left:8px">' + fmt(mVal) + '</span>' +
|
|
1743
|
+
ruleStr + '</div>';
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
html += '</div></div>';
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// Rule results (collapsible)
|
|
1750
|
+
var ruleMetrics = Object.keys(ex.ruleResults);
|
|
1751
|
+
if (ruleMetrics.length) {
|
|
1752
|
+
var totalRules = 0;
|
|
1753
|
+
var failedRules = 0;
|
|
1754
|
+
for (var r = 0; r < ruleMetrics.length; r++) {
|
|
1755
|
+
var metricRules = ex.ruleResults[ruleMetrics[r]];
|
|
1756
|
+
totalRules += metricRules.length;
|
|
1757
|
+
for (var fri = 0; fri < metricRules.length; fri++) {
|
|
1758
|
+
if (metricRules[fri].status === 'fail' || metricRules[fri].passed === false) failedRules++;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (totalRules > 0) {
|
|
1762
|
+
// Auto-expand when anything failed: the failures are what the
|
|
1763
|
+
// reader opened the example to see.
|
|
1764
|
+
var ruleOpenClass = failedRules > 0 ? ' open' : '';
|
|
1765
|
+
var ruleSummary = failedRules > 0
|
|
1766
|
+
? '<span class="rule-fail">' + failedRules + ' failed</span> / ' + totalRules + ' rules'
|
|
1767
|
+
: totalRules + ' rules';
|
|
1768
|
+
html += '<div class="detail-collapsible">' +
|
|
1769
|
+
'<button class="detail-collapsible-toggle' + ruleOpenClass + '" onclick="event.stopPropagation();toggleCollapsible(this)">' +
|
|
1770
|
+
'<span class="chevron">▶</span> Rule Results (' + ruleSummary + ')</button>' +
|
|
1771
|
+
'<div class="detail-collapsible-body' + ruleOpenClass + '">';
|
|
1772
|
+
for (var r = 0; r < ruleMetrics.length; r++) {
|
|
1773
|
+
var rmName = ruleMetrics[r];
|
|
1774
|
+
var rules = ex.ruleResults[rmName];
|
|
1775
|
+
if (!rules.length) continue;
|
|
1776
|
+
html += '<div style="padding:8px 14px 4px;font-size:0.75rem;font-weight:600;color:var(--muted)">' + esc(DATA.metricNames[rmName] || rmName) + '</div>';
|
|
1777
|
+
html += '<table class="detail-table"><thead><tr><th>Type</th><th>ID</th><th>Status</th><th>Message</th></tr></thead><tbody>';
|
|
1778
|
+
for (var ri = 0; ri < rules.length; ri++) {
|
|
1779
|
+
var rule = rules[ri];
|
|
1780
|
+
var badge = rule.passed
|
|
1781
|
+
? '<span class="rule-pass">✓ Pass</span>'
|
|
1782
|
+
: '<span class="rule-fail">✗ Fail</span>';
|
|
1783
|
+
html += '<tr><td>' + esc(rule.type) + '</td><td class="mono">' + esc(rule.id) + '</td>' +
|
|
1784
|
+
'<td>' + badge + '</td><td>' + esc(rule.message) + '</td></tr>';
|
|
1785
|
+
}
|
|
1786
|
+
html += '</tbody></table>';
|
|
1787
|
+
}
|
|
1788
|
+
html += '</div></div>';
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// Stats (collapsible)
|
|
1793
|
+
var statEntries = Object.keys(ex.stats);
|
|
1794
|
+
if (statEntries.length) {
|
|
1795
|
+
html += '<div class="detail-collapsible">' +
|
|
1796
|
+
'<button class="detail-collapsible-toggle" onclick="event.stopPropagation();toggleCollapsible(this)">' +
|
|
1797
|
+
'<span class="chevron">▶</span> Operational Stats</button>' +
|
|
1798
|
+
'<div class="detail-collapsible-body">' +
|
|
1799
|
+
'<table class="detail-table"><thead><tr><th>Stat</th><th>Value</th></tr></thead><tbody>';
|
|
1800
|
+
statEntries.sort();
|
|
1801
|
+
for (var si = 0; si < statEntries.length; si++) {
|
|
1802
|
+
var sName = statEntries[si];
|
|
1803
|
+
html += '<tr><td>' + esc(sName.replace(/_/g, ' ')) + '</td><td class="mono">' + fmt(ex.stats[sName], 2) + '</td></tr>';
|
|
1804
|
+
}
|
|
1805
|
+
html += '</tbody></table></div></div>';
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// PDF viewer
|
|
1809
|
+
var productType = ex.productType || 'parse';
|
|
1810
|
+
html += '<div class="detail-section pdf-viewer-section">' +
|
|
1811
|
+
'<div class="detail-section-title">PDF Viewer</div>' +
|
|
1812
|
+
'<div class="pdf-url-bar">' +
|
|
1813
|
+
'<label>Base URL</label>' +
|
|
1814
|
+
'<input type="text" id="pdf-base-url" value="' + esc(getPdfBaseUrl()) + '" placeholder="http://localhost:8080/data">' +
|
|
1815
|
+
'<button onclick="event.stopPropagation();savePdfBaseUrl();loadPdf(\'' + esc(ex.id) + '\',\'' + esc(productType) + '\')">Load PDF</button>' +
|
|
1816
|
+
'</div>' +
|
|
1817
|
+
'<div class="pdf-canvas-wrap" id="pdf-canvas-wrap"><div class="pdf-placeholder">Set base path and click Load PDF</div></div>' +
|
|
1818
|
+
'<div class="pdf-nav" id="pdf-nav" style="display:none">' +
|
|
1819
|
+
'<button id="pdf-prev" onclick="event.stopPropagation();pdfPrev()">Prev</button>' +
|
|
1820
|
+
'<span id="pdf-page-info">-</span>' +
|
|
1821
|
+
'<button id="pdf-next" onclick="event.stopPropagation();pdfNext()">Next</button>' +
|
|
1822
|
+
'<span style="margin-left:12px;border-left:1px solid var(--border);padding-left:12px"></span>' +
|
|
1823
|
+
'<button onclick="event.stopPropagation();pdfZoomOut()">−</button>' +
|
|
1824
|
+
'<span id="pdf-zoom-info">150%</span>' +
|
|
1825
|
+
'<button onclick="event.stopPropagation();pdfZoomIn()">+</button>' +
|
|
1826
|
+
'</div></div>';
|
|
1827
|
+
|
|
1828
|
+
// Predicted / Expected output side-by-side
|
|
1829
|
+
html += '<div class="output-columns">';
|
|
1830
|
+
|
|
1831
|
+
// Predicted output panel
|
|
1832
|
+
var predId = 'output-pred-' + ex.id.replace(/[^a-zA-Z0-9]/g, '_');
|
|
1833
|
+
html += '<div class="output-panel" id="' + predId + '">';
|
|
1834
|
+
html += '<div class="output-panel-header"><span>Predicted Output</span>';
|
|
1835
|
+
if (ex.predictedOutput) {
|
|
1836
|
+
html += '<div class="output-view-toggle">' +
|
|
1837
|
+
'<button class="output-view-btn active" data-mode="rendered" onclick="event.stopPropagation();toggleOutputView(\'' + predId + '\',\'rendered\')">Rendered</button>' +
|
|
1838
|
+
'<button class="output-view-btn" data-mode="raw" onclick="event.stopPropagation();toggleOutputView(\'' + predId + '\',\'raw\')">Raw</button>' +
|
|
1839
|
+
'</div>' +
|
|
1840
|
+
'<button class="output-copy-btn" onclick="event.stopPropagation();copyOutput(\'' + predId + '\',this)">Copy</button>';
|
|
1841
|
+
}
|
|
1842
|
+
html += '</div>';
|
|
1843
|
+
if (ex.predictedOutput) {
|
|
1844
|
+
html += '<div class="output-panel-body output-raw" style="display:none">' + esc(ex.predictedOutput) + '</div>';
|
|
1845
|
+
html += '<div class="output-panel-body rendered-md output-rendered">' + (ex.predictedHtml || esc(ex.predictedOutput)) + '</div>';
|
|
1846
|
+
} else {
|
|
1847
|
+
html += '<div class="output-panel-body"><span class="output-empty">No predicted output available</span></div>';
|
|
1848
|
+
}
|
|
1849
|
+
html += '</div>';
|
|
1850
|
+
|
|
1851
|
+
// Expected output panel
|
|
1852
|
+
var expId = 'output-exp-' + ex.id.replace(/[^a-zA-Z0-9]/g, '_');
|
|
1853
|
+
html += '<div class="output-panel" id="' + expId + '">';
|
|
1854
|
+
html += '<div class="output-panel-header"><span>Expected Output</span>';
|
|
1855
|
+
if (ex.expectedOutput) {
|
|
1856
|
+
html += '<div class="output-view-toggle">' +
|
|
1857
|
+
'<button class="output-view-btn active" data-mode="rendered" onclick="event.stopPropagation();toggleOutputView(\'' + expId + '\',\'rendered\')">Rendered</button>' +
|
|
1858
|
+
'<button class="output-view-btn" data-mode="raw" onclick="event.stopPropagation();toggleOutputView(\'' + expId + '\',\'raw\')">Raw</button>' +
|
|
1859
|
+
'</div>' +
|
|
1860
|
+
'<button class="output-copy-btn" onclick="event.stopPropagation();copyOutput(\'' + expId + '\',this)">Copy</button>';
|
|
1861
|
+
}
|
|
1862
|
+
html += '</div>';
|
|
1863
|
+
if (ex.expectedOutput) {
|
|
1864
|
+
html += '<div class="output-panel-body output-raw" style="display:none">' + esc(ex.expectedOutput) + '</div>';
|
|
1865
|
+
html += '<div class="output-panel-body rendered-md output-rendered">' + (ex.expectedHtml || esc(ex.expectedOutput)) + '</div>';
|
|
1866
|
+
} else {
|
|
1867
|
+
html += '<div class="output-panel-body"><span class="output-empty">No expected output available</span></div>';
|
|
1868
|
+
}
|
|
1869
|
+
html += '</div>';
|
|
1870
|
+
|
|
1871
|
+
html += '</div>';
|
|
1872
|
+
|
|
1873
|
+
html += '</div>';
|
|
1874
|
+
return html;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// Close detail (global)
|
|
1878
|
+
window.closeDetail = function() {
|
|
1879
|
+
state.expandedId = null;
|
|
1880
|
+
renderTable();
|
|
1881
|
+
};
|
|
1882
|
+
|
|
1883
|
+
// Toggle collapsible sections
|
|
1884
|
+
window.toggleCollapsible = function(btn) {
|
|
1885
|
+
btn.classList.toggle('open');
|
|
1886
|
+
var body = btn.nextElementSibling;
|
|
1887
|
+
if (body) body.classList.toggle('open');
|
|
1888
|
+
};
|
|
1889
|
+
|
|
1890
|
+
// ─── PDF viewer ───
|
|
1891
|
+
var pdfState = { doc: null, page: 1, total: 0, scale: 1.5 };
|
|
1892
|
+
|
|
1893
|
+
window.getPdfBaseUrl = function() {
|
|
1894
|
+
try { return DATA.pdfBaseUrl || localStorage.getItem('bench_pdf_base_url') || ''; } catch(e) { return DATA.pdfBaseUrl || ''; }
|
|
1895
|
+
};
|
|
1896
|
+
window.savePdfBaseUrl = function() {
|
|
1897
|
+
var input = document.getElementById('pdf-base-url');
|
|
1898
|
+
if (input) {
|
|
1899
|
+
try { localStorage.setItem('bench_pdf_base_url', input.value); } catch(e) {}
|
|
1900
|
+
}
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
function buildPdfDocumentUrl(baseUrl, testId) {
|
|
1904
|
+
baseUrl = String(baseUrl || '').replace(/\/+$/, '');
|
|
1905
|
+
testId = String(testId || '');
|
|
1906
|
+
if (!baseUrl || !testId) return '';
|
|
1907
|
+
// Strip overlapping path segments between baseUrl and testId to avoid duplication
|
|
1908
|
+
// e.g. baseUrl="http://host/data/tables/v1" + testId="tables/v1/file" -> ".../data/tables/v1/file.pdf"
|
|
1909
|
+
var relPath = testId;
|
|
1910
|
+
var baseParts = baseUrl.replace(/^https?:\/\/[^\/]*/i, '').split('/').filter(Boolean);
|
|
1911
|
+
var idParts = testId.split('/');
|
|
1912
|
+
for (var overlap = Math.min(baseParts.length, idParts.length); overlap > 0; overlap--) {
|
|
1913
|
+
var baseTail = baseParts.slice(baseParts.length - overlap).join('/');
|
|
1914
|
+
var idHead = idParts.slice(0, overlap).join('/');
|
|
1915
|
+
if (baseTail === idHead) {
|
|
1916
|
+
relPath = idParts.slice(overlap).join('/');
|
|
1917
|
+
break;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
// Root-relative bases (e.g. "/files/data") are URLs too: a test id with
|
|
1921
|
+
// spaces or unicode in it must be percent-encoded per segment just like
|
|
1922
|
+
// it is for an absolute http(s) base.
|
|
1923
|
+
if (/^https?:\/\//i.test(baseUrl) || baseUrl.charAt(0) === '/') {
|
|
1924
|
+
return baseUrl + '/' + relPath.split('/').map(function(p) { return encodeURIComponent(p); }).join('/') + '.pdf';
|
|
1925
|
+
}
|
|
1926
|
+
return baseUrl + '/' + relPath + '.pdf';
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
window.loadPdf = function(testId, productType) {
|
|
1930
|
+
var baseUrl = document.getElementById('pdf-base-url').value;
|
|
1931
|
+
var url = buildPdfDocumentUrl(baseUrl, testId);
|
|
1932
|
+
if (!url) return;
|
|
1933
|
+
savePdfBaseUrl();
|
|
1934
|
+
var wrap = document.getElementById('pdf-canvas-wrap');
|
|
1935
|
+
wrap.innerHTML = '<div class="pdf-placeholder">Loading PDF...</div>';
|
|
1936
|
+
document.getElementById('pdf-nav').style.display = 'none';
|
|
1937
|
+
|
|
1938
|
+
if (typeof pdfjsLib === 'undefined') {
|
|
1939
|
+
// Load PDF.js from CDN
|
|
1940
|
+
var script = document.createElement('script');
|
|
1941
|
+
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js';
|
|
1942
|
+
script.onload = function() {
|
|
1943
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';
|
|
1944
|
+
doLoadPdf(url);
|
|
1945
|
+
};
|
|
1946
|
+
script.onerror = function() {
|
|
1947
|
+
wrap.innerHTML = '<div class="pdf-placeholder">Failed to load PDF.js library</div>';
|
|
1948
|
+
};
|
|
1949
|
+
document.head.appendChild(script);
|
|
1950
|
+
} else {
|
|
1951
|
+
doLoadPdf(url);
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
|
|
1955
|
+
function doLoadPdf(url) {
|
|
1956
|
+
var wrap = document.getElementById('pdf-canvas-wrap');
|
|
1957
|
+
pdfjsLib.getDocument(url).promise.then(function(doc) {
|
|
1958
|
+
pdfState.doc = doc;
|
|
1959
|
+
pdfState.total = doc.numPages;
|
|
1960
|
+
pdfState.page = 1;
|
|
1961
|
+
renderPdfPage();
|
|
1962
|
+
document.getElementById('pdf-nav').style.display = 'flex';
|
|
1963
|
+
}).catch(function(err) {
|
|
1964
|
+
wrap.innerHTML = '<div class="pdf-placeholder">Failed to load PDF: ' + esc(String(err)) + '</div>';
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
function renderPdfPage() {
|
|
1969
|
+
if (!pdfState.doc) return;
|
|
1970
|
+
pdfState.doc.getPage(pdfState.page).then(function(page) {
|
|
1971
|
+
var viewport = page.getViewport({ scale: pdfState.scale });
|
|
1972
|
+
var wrap = document.getElementById('pdf-canvas-wrap');
|
|
1973
|
+
wrap.innerHTML = '';
|
|
1974
|
+
var canvas = document.createElement('canvas');
|
|
1975
|
+
canvas.width = viewport.width;
|
|
1976
|
+
canvas.height = viewport.height;
|
|
1977
|
+
wrap.appendChild(canvas);
|
|
1978
|
+
page.render({ canvasContext: canvas.getContext('2d'), viewport: viewport });
|
|
1979
|
+
document.getElementById('pdf-page-info').textContent = pdfState.page + ' / ' + pdfState.total;
|
|
1980
|
+
document.getElementById('pdf-zoom-info').textContent = Math.round(pdfState.scale * 100) + '%';
|
|
1981
|
+
document.getElementById('pdf-prev').disabled = pdfState.page <= 1;
|
|
1982
|
+
document.getElementById('pdf-next').disabled = pdfState.page >= pdfState.total;
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
window.pdfPrev = function() {
|
|
1987
|
+
if (pdfState.page > 1) { pdfState.page--; renderPdfPage(); }
|
|
1988
|
+
};
|
|
1989
|
+
window.pdfNext = function() {
|
|
1990
|
+
if (pdfState.page < pdfState.total) { pdfState.page++; renderPdfPage(); }
|
|
1991
|
+
};
|
|
1992
|
+
window.pdfZoomIn = function() {
|
|
1993
|
+
pdfState.scale = Math.min(pdfState.scale + 0.25, 5.0);
|
|
1994
|
+
renderPdfPage();
|
|
1995
|
+
};
|
|
1996
|
+
window.pdfZoomOut = function() {
|
|
1997
|
+
pdfState.scale = Math.max(pdfState.scale - 0.25, 0.5);
|
|
1998
|
+
renderPdfPage();
|
|
1999
|
+
};
|
|
2000
|
+
|
|
2001
|
+
function initPdfViewerIfNeeded() {
|
|
2002
|
+
// Restore saved base URL into input if present
|
|
2003
|
+
var input = document.getElementById('pdf-base-url');
|
|
2004
|
+
if (input) {
|
|
2005
|
+
var saved = getPdfBaseUrl();
|
|
2006
|
+
if (saved && !input.value) input.value = saved;
|
|
2007
|
+
// Auto-load if we have a base URL and a test is expanded
|
|
2008
|
+
if (input.value && state.expandedId) {
|
|
2009
|
+
var ex = null;
|
|
2010
|
+
for (var i = 0; i < DATA.examples.length; i++) {
|
|
2011
|
+
if (DATA.examples[i].id === state.expandedId) { ex = DATA.examples[i]; break; }
|
|
2012
|
+
}
|
|
2013
|
+
if (ex) {
|
|
2014
|
+
loadPdf(ex.id, ex.productType || 'parse');
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// ─── Pagination ───
|
|
2021
|
+
function totalPages() {
|
|
2022
|
+
return Math.max(1, Math.ceil(state.filtered.length / state.perPage));
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
function renderPagination() {
|
|
2026
|
+
var el = document.getElementById('pagination');
|
|
2027
|
+
var tp = totalPages();
|
|
2028
|
+
if (tp <= 1) { el.innerHTML = ''; return; }
|
|
2029
|
+
|
|
2030
|
+
var html = '';
|
|
2031
|
+
html += '<button ' + (state.currentPage <= 1 ? 'disabled' : '') + ' data-page="' + (state.currentPage - 1) + '">« Prev</button>';
|
|
2032
|
+
|
|
2033
|
+
// Page numbers (show max 7 around current)
|
|
2034
|
+
var startP = Math.max(1, state.currentPage - 3);
|
|
2035
|
+
var endP = Math.min(tp, startP + 6);
|
|
2036
|
+
startP = Math.max(1, endP - 6);
|
|
2037
|
+
|
|
2038
|
+
if (startP > 1) {
|
|
2039
|
+
html += '<button data-page="1">1</button>';
|
|
2040
|
+
if (startP > 2) html += '<span class="page-info">...</span>';
|
|
2041
|
+
}
|
|
2042
|
+
for (var p = startP; p <= endP; p++) {
|
|
2043
|
+
html += '<button data-page="' + p + '"' + (p === state.currentPage ? ' class="active"' : '') + '>' + p + '</button>';
|
|
2044
|
+
}
|
|
2045
|
+
if (endP < tp) {
|
|
2046
|
+
if (endP < tp - 1) html += '<span class="page-info">...</span>';
|
|
2047
|
+
html += '<button data-page="' + tp + '">' + tp + '</button>';
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
html += '<button ' + (state.currentPage >= tp ? 'disabled' : '') + ' data-page="' + (state.currentPage + 1) + '">Next »</button>';
|
|
2051
|
+
html += '<span class="page-info">Page ' + state.currentPage + ' of ' + tp + '</span>';
|
|
2052
|
+
|
|
2053
|
+
el.innerHTML = html;
|
|
2054
|
+
|
|
2055
|
+
el.onclick = function(e) {
|
|
2056
|
+
var btn = e.target.closest('button[data-page]');
|
|
2057
|
+
if (!btn || btn.disabled) return;
|
|
2058
|
+
state.currentPage = parseInt(btn.getAttribute('data-page'));
|
|
2059
|
+
state.expandedId = null;
|
|
2060
|
+
renderTable();
|
|
2061
|
+
renderPagination();
|
|
2062
|
+
// Scroll to table
|
|
2063
|
+
document.getElementById('examples-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// ─── Init ───
|
|
2068
|
+
function init() {
|
|
2069
|
+
renderSummary();
|
|
2070
|
+
renderAggMetrics();
|
|
2071
|
+
renderAggStats();
|
|
2072
|
+
renderTagMetrics();
|
|
2073
|
+
renderControls();
|
|
2074
|
+
applyFilters();
|
|
2075
|
+
renderResultsCount();
|
|
2076
|
+
renderTable();
|
|
2077
|
+
renderPagination();
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
if (document.readyState === 'loading') {
|
|
2081
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
2082
|
+
} else {
|
|
2083
|
+
init();
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
})();
|
|
2087
|
+
"""
|
|
2088
|
+
)
|
|
2089
|
+
|
|
2090
|
+
_HTML_BODY = """\
|
|
2091
|
+
</style>
|
|
2092
|
+
</head>
|
|
2093
|
+
<body>
|
|
2094
|
+
<div class="report-container">
|
|
2095
|
+
<div class="report-header">
|
|
2096
|
+
<h1>Evaluation Report</h1>
|
|
2097
|
+
<div class="subtitle" id="report-subtitle"></div>
|
|
2098
|
+
</div>
|
|
2099
|
+
<div class="summary-row" id="summary-cards"></div>
|
|
2100
|
+
<div id="agg-metrics"></div>
|
|
2101
|
+
<div class="stats-section" id="agg-stats"></div>
|
|
2102
|
+
<div class="tag-metrics-section" id="tag-metrics"></div>
|
|
2103
|
+
<div id="examples-section">
|
|
2104
|
+
<h2 class="section-title">Examples</h2>
|
|
2105
|
+
<div class="controls-bar" id="controls"></div>
|
|
2106
|
+
<div class="tag-filters" id="tag-filters"></div>
|
|
2107
|
+
<div class="results-count" id="results-count"></div>
|
|
2108
|
+
<div class="table-wrap">
|
|
2109
|
+
<table class="examples-table">
|
|
2110
|
+
<thead>
|
|
2111
|
+
<tr>
|
|
2112
|
+
<th class="col-status"></th>
|
|
2113
|
+
<th class="col-id">Test ID</th>
|
|
2114
|
+
<th class="col-score">Score</th>
|
|
2115
|
+
<th class="col-tags">Tags</th>
|
|
2116
|
+
</tr>
|
|
2117
|
+
</thead>
|
|
2118
|
+
<tbody id="examples-tbody"></tbody>
|
|
2119
|
+
</table>
|
|
2120
|
+
</div>
|
|
2121
|
+
<div class="pagination" id="pagination"></div>
|
|
2122
|
+
</div>
|
|
2123
|
+
</div>
|
|
2124
|
+
"""
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
def generate_detailed_html_report(
|
|
2128
|
+
summary: EvaluationSummary,
|
|
2129
|
+
report_dir: Path,
|
|
2130
|
+
output_dir: Path | None = None,
|
|
2131
|
+
test_cases_dir: Path | None = None,
|
|
2132
|
+
pdf_base_url: str | None = None,
|
|
2133
|
+
pipeline_name: str | None = None,
|
|
2134
|
+
group: str | None = None,
|
|
2135
|
+
) -> Path:
|
|
2136
|
+
"""Export evaluation summary to an interactive HTML report.
|
|
2137
|
+
|
|
2138
|
+
Args:
|
|
2139
|
+
summary: Evaluation summary data.
|
|
2140
|
+
report_dir: Directory to write the HTML report.
|
|
2141
|
+
output_dir: Directory containing inference result files (for predicted output).
|
|
2142
|
+
test_cases_dir: Directory containing test case files (for expected output).
|
|
2143
|
+
pdf_base_url: Base URL for PDF files. If not provided but test_cases_dir is set,
|
|
2144
|
+
falls back to the local filesystem path.
|
|
2145
|
+
pipeline_name: Name of the pipeline (e.g., 'llamaparse_agentic').
|
|
2146
|
+
group: Evaluation category/group (e.g., 'text_content').
|
|
2147
|
+
"""
|
|
2148
|
+
html_path = report_dir / "_evaluation_report_detailed.html"
|
|
2149
|
+
|
|
2150
|
+
# Resolve PDF base URL: explicit > relative path from report to PDF directory
|
|
2151
|
+
resolved_pdf_base_url = ""
|
|
2152
|
+
if pdf_base_url:
|
|
2153
|
+
resolved_pdf_base_url = pdf_base_url.rstrip("/")
|
|
2154
|
+
elif test_cases_dir is not None and test_cases_dir.exists():
|
|
2155
|
+
import os
|
|
2156
|
+
|
|
2157
|
+
# JSONL datasets store PDFs under a pdfs/ subdirectory, while sidecar
|
|
2158
|
+
# datasets store them directly alongside test.json files. Use the pdfs/
|
|
2159
|
+
# subdirectory if it exists so that {baseUrl}/{testId}.pdf resolves correctly.
|
|
2160
|
+
pdf_root = test_cases_dir.resolve()
|
|
2161
|
+
if (pdf_root / "pdfs").is_dir():
|
|
2162
|
+
pdf_root = pdf_root / "pdfs"
|
|
2163
|
+
resolved_pdf_base_url = os.path.relpath(pdf_root, report_dir.resolve())
|
|
2164
|
+
|
|
2165
|
+
# Load pipeline metadata if available
|
|
2166
|
+
metadata: dict[str, Any] = {}
|
|
2167
|
+
if output_dir:
|
|
2168
|
+
# Try pipeline output root (one level up from group report dir)
|
|
2169
|
+
for candidate in [output_dir / "_metadata.json", output_dir.parent / "_metadata.json"]:
|
|
2170
|
+
if candidate.exists():
|
|
2171
|
+
try:
|
|
2172
|
+
metadata = json.loads(candidate.read_text(encoding="utf-8"))
|
|
2173
|
+
except Exception:
|
|
2174
|
+
pass
|
|
2175
|
+
break
|
|
2176
|
+
|
|
2177
|
+
# Extract pipeline info
|
|
2178
|
+
pipeline_info = metadata.get("pipeline", {})
|
|
2179
|
+
resolved_pipeline_name = pipeline_name or pipeline_info.get("pipeline_name", "")
|
|
2180
|
+
provider_name = pipeline_info.get("provider_name", "")
|
|
2181
|
+
product_type = pipeline_info.get("product_type", "")
|
|
2182
|
+
pipeline_config = pipeline_info.get("config", {})
|
|
2183
|
+
|
|
2184
|
+
data_blob = _build_data_blob(
|
|
2185
|
+
summary,
|
|
2186
|
+
output_dir=output_dir,
|
|
2187
|
+
test_cases_dir=test_cases_dir,
|
|
2188
|
+
pdf_base_url=resolved_pdf_base_url,
|
|
2189
|
+
)
|
|
2190
|
+
|
|
2191
|
+
# Add run info to data blob
|
|
2192
|
+
data_blob["runInfo"] = {
|
|
2193
|
+
"pipelineName": resolved_pipeline_name,
|
|
2194
|
+
"providerName": provider_name,
|
|
2195
|
+
"productType": product_type,
|
|
2196
|
+
"category": group or "",
|
|
2197
|
+
"config": pipeline_config,
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
# Serialize and escape for safe embedding inside <script>
|
|
2201
|
+
data_json = json.dumps(data_blob, default=str, ensure_ascii=False)
|
|
2202
|
+
# Prevent premature script close
|
|
2203
|
+
data_json = data_json.replace("</script>", "<\\/script>")
|
|
2204
|
+
# Prevent HTML comment issues
|
|
2205
|
+
data_json = data_json.replace("<!--", "<\\!--")
|
|
2206
|
+
|
|
2207
|
+
completed_str = ""
|
|
2208
|
+
if summary.completed_at is not None:
|
|
2209
|
+
completed_str = summary.completed_at.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
2210
|
+
|
|
2211
|
+
# Build full HTML by concatenation (no f-string over JS/CSS to avoid brace issues)
|
|
2212
|
+
parts: list[str] = []
|
|
2213
|
+
parts.append(_HTML_HEAD)
|
|
2214
|
+
parts.append(_CSS)
|
|
2215
|
+
parts.append(_HTML_BODY)
|
|
2216
|
+
|
|
2217
|
+
# Title and subtitle script — show pipeline, provider, category
|
|
2218
|
+
title_parts = []
|
|
2219
|
+
if resolved_pipeline_name:
|
|
2220
|
+
title_parts.append(resolved_pipeline_name.replace("_", " ").title())
|
|
2221
|
+
if group:
|
|
2222
|
+
title_parts.append(group.replace("_", " ").title())
|
|
2223
|
+
title_text = " — ".join(title_parts) if title_parts else "Evaluation Report"
|
|
2224
|
+
|
|
2225
|
+
subtitle_parts = []
|
|
2226
|
+
if provider_name:
|
|
2227
|
+
subtitle_parts.append("Provider: " + provider_name)
|
|
2228
|
+
if product_type:
|
|
2229
|
+
subtitle_parts.append("Product: " + product_type)
|
|
2230
|
+
if completed_str:
|
|
2231
|
+
subtitle_parts.append("Generated: " + completed_str)
|
|
2232
|
+
subtitle_text = (
|
|
2233
|
+
" | ".join(subtitle_parts) if subtitle_parts else ("Generated: " + completed_str if completed_str else "")
|
|
2234
|
+
)
|
|
2235
|
+
|
|
2236
|
+
parts.append("<script>")
|
|
2237
|
+
parts.append('document.querySelector(".report-header h1").textContent = ' + json.dumps(title_text) + ";")
|
|
2238
|
+
parts.append('document.getElementById("report-subtitle").textContent = ' + json.dumps(subtitle_text) + ";")
|
|
2239
|
+
parts.append("</script>\n")
|
|
2240
|
+
|
|
2241
|
+
# Data blob
|
|
2242
|
+
parts.append("<script>\nconst DATA = ")
|
|
2243
|
+
parts.append(data_json)
|
|
2244
|
+
parts.append(";\n</script>\n")
|
|
2245
|
+
|
|
2246
|
+
# Application JS
|
|
2247
|
+
parts.append("<script>\n")
|
|
2248
|
+
parts.append(_JS)
|
|
2249
|
+
parts.append("\n</script>\n")
|
|
2250
|
+
|
|
2251
|
+
parts.append("</body>\n</html>\n")
|
|
2252
|
+
|
|
2253
|
+
html_path.write_text("".join(parts), encoding="utf-8")
|
|
2254
|
+
return html_path
|