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.
Files changed (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. 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">&#9654;</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 &#x2193;</option>' +
1412
+ '<option value="score_asc"' + (state.sortMode === 'score_asc' ? ' selected' : '') + '>Score &#x2191;</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">&#9654;</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">&#9654;</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">&#9654;</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">&#9654;</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">&#10003; Pass</span>'
1782
+ : '<span class="rule-fail">&#10007; 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">&#9654;</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) + '">&laquo; 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 &raquo;</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