From 92c03ea70c8ca305c1f19fb57ff5374b79dbc9af Mon Sep 17 00:00:00 2001 From: Farhan Helmy <59960562+farhan-helmy@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:20:15 +0800 Subject: [PATCH] feat: render benchmark answers as Markdown --- site/app.js | 208 ++++++++++++++++++++++++++++++++++++- site/index.html | 2 +- site/styles.css | 119 +++++++++++++++++++-- site/tests/test_browser.py | 85 ++++++++++++++- 4 files changed, 400 insertions(+), 14 deletions(-) diff --git a/site/app.js b/site/app.js index aa2de3d..f21ab66 100644 --- a/site/app.js +++ b/site/app.js @@ -206,6 +206,192 @@ function jsonBlock(value, emptyMessage) { return pre; } +function safeMarkdownLink(rawHref) { + try { + const url = new URL(rawHref, window.location.href); + return ["http:", "https:", "mailto:"].includes(url.protocol) ? url.href : null; + } catch { + return null; + } +} + +function appendInlineMarkdown(parent, text) { + const tokenPattern = /(?:\\\*|`[^`\n]+`|\*\*[^*\n]+?\*\*|__[^_\n]+?__|(? cell.trim()); +} + +function isMarkdownTableDivider(line) { + return markdownTableCells(line).every((cell) => /^:?-{3,}:?$/.test(cell)); +} + +function isMarkdownBlockStart(lines, index) { + const line = lines[index]; + return ( + /^\s*```/.test(line) + || /^#{1,6}\s+/.test(line) + || /^\s*(?:[-+*]|\d+[.)])\s+/.test(line) + || /^>\s?/.test(line) + || /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line) + || (line.includes("|") && index + 1 < lines.length && isMarkdownTableDivider(lines[index + 1])) + ); +} + +function appendMarkdownLines(parent, lines) { + for (let index = 0; index < lines.length;) { + const line = lines[index]; + if (!line.trim()) { + index += 1; + continue; + } + + const fence = line.match(/^\s*```([^\s`]*)\s*$/); + if (fence) { + const codeLines = []; + index += 1; + while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) { + codeLines.push(lines[index]); + index += 1; + } + if (index < lines.length) index += 1; + const code = element("code", fence[1] ? `language-${fence[1]}` : "", codeLines.join("\n")); + const pre = element("pre", "markdown-code-block"); + pre.append(code); + parent.append(pre); + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + const headingNode = element("h5", `markdown-heading markdown-heading-${heading[1].length}`); + appendInlineMarkdown(headingNode, heading[2]); + parent.append(headingNode); + index += 1; + continue; + } + + if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + parent.append(element("hr")); + index += 1; + continue; + } + + if (line.includes("|") && index + 1 < lines.length && isMarkdownTableDivider(lines[index + 1])) { + const table = element("table", "markdown-table"); + const head = element("thead"); + const headRow = element("tr"); + markdownTableCells(line).forEach((cell) => { + const header = element("th"); + appendInlineMarkdown(header, cell); + headRow.append(header); + }); + head.append(headRow); + table.append(head); + index += 2; + const body = element("tbody"); + while (index < lines.length && lines[index].includes("|") && lines[index].trim()) { + const row = element("tr"); + markdownTableCells(lines[index]).forEach((cell) => { + const data = element("td"); + appendInlineMarkdown(data, cell); + row.append(data); + }); + body.append(row); + index += 1; + } + table.append(body); + const tableWrap = element("div", "markdown-table-wrap"); + tableWrap.append(table); + parent.append(tableWrap); + continue; + } + + const listItem = line.match(/^\s*([-+*]|\d+[.)])\s+(.+)$/); + if (listItem) { + const ordered = /^\d/.test(listItem[1]); + const list = element(ordered ? "ol" : "ul"); + if (ordered) { + const start = Number.parseInt(listItem[1], 10); + if (start !== 1) list.start = start; + } + while (index < lines.length) { + const item = lines[index].match(/^\s*([-+*]|\d+[.)])\s+(.+)$/); + if (!item || /^\d/.test(item[1]) !== ordered) break; + const listNode = element("li"); + appendInlineMarkdown(listNode, item[2]); + list.append(listNode); + index += 1; + } + parent.append(list); + continue; + } + + if (/^>\s?/.test(line)) { + const quoteLines = []; + while (index < lines.length && /^>\s?/.test(lines[index])) { + quoteLines.push(lines[index].replace(/^>\s?/, "")); + index += 1; + } + const quote = element("blockquote"); + appendMarkdownLines(quote, quoteLines); + parent.append(quote); + continue; + } + + const paragraphLines = [line]; + index += 1; + while (index < lines.length && lines[index].trim() && !isMarkdownBlockStart(lines, index)) { + paragraphLines.push(lines[index]); + index += 1; + } + const paragraph = element("p"); + paragraphLines.forEach((paragraphLine, lineIndex) => { + if (lineIndex) paragraph.append(element("br")); + appendInlineMarkdown(paragraph, paragraphLine); + }); + parent.append(paragraph); + } +} + +function renderMarkdown(markdown) { + const fragment = document.createDocumentFragment(); + appendMarkdownLines(fragment, markdown.replace(/\r\n?/g, "\n").split("\n")); + return fragment; +} + function renderAnswerCard(answer, modelById) { const card = element("article", "model-answer"); const header = element("div", "model-answer-header"); @@ -221,12 +407,24 @@ function renderAnswerCard(answer, modelById) { card.append(element("p", "answer-reason", answer.reason || answer.status || "No score detail")); const hasContent = Boolean(answer.content.trim()); - const response = element( - "pre", - `answer-copy${hasContent ? "" : " answer-copy-empty"}`, - hasContent ? answer.content : "No text response.", - ); + const response = element("div", `answer-copy markdown-body${hasContent ? "" : " answer-copy-empty"}`); + if (hasContent) { + response.append(renderMarkdown(answer.content)); + } else { + response.append(element("p", "", "No text response.")); + } card.append(response); + if (hasContent) { + const raw = element("details", "answer-details answer-raw"); + raw.append(element("summary", "", "Raw Markdown")); + const renderRawMarkdown = () => { + if (!raw.open) return; + raw.append(element("pre", "answer-json", answer.content)); + raw.removeEventListener("toggle", renderRawMarkdown); + }; + raw.addEventListener("toggle", renderRawMarkdown); + card.append(raw); + } if (answer.tool_calls.length) { const details = element("details", "answer-details"); details.append(element("summary", "", `Tool calls (${answer.tool_calls.length})`)); diff --git a/site/index.html b/site/index.html index 4e658a8..4e181ec 100644 --- a/site/index.html +++ b/site/index.html @@ -170,7 +170,7 @@

Read every response

Loading all model answers…

-

Answers are shown verbatim; spelling and language choices are not corrected.

+

Answers are rendered from the original Markdown; open Raw Markdown to inspect the exact output.

Loading model answers…

diff --git a/site/styles.css b/site/styles.css index ab84666..8d2aefb 100644 --- a/site/styles.css +++ b/site/styles.css @@ -849,7 +849,7 @@ tbody tr:first-child .rank-badge { .model-answer h4 { margin: 0; - font-size: 13px; + font-size: 15px; } .answer-status { @@ -873,19 +873,18 @@ tbody tr:first-child .rank-badge { } .answer-reason { - margin: 8px 0 16px; + margin: 8px 0 18px; color: var(--faint); - font-size: 10px; + font-size: 11px; } .answer-copy { - max-height: 430px; + max-height: 560px; overflow: auto; margin: 0; color: #dce4df; - white-space: pre-wrap; overflow-wrap: anywhere; - font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 16px/1.72 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .answer-copy-empty { @@ -893,6 +892,114 @@ tbody tr:first-child .rank-badge { font-style: italic; } +.markdown-body > :first-child { margin-top: 0; } +.markdown-body > :last-child { margin-bottom: 0; } + +.markdown-body p, +.markdown-body ul, +.markdown-body ol, +.markdown-body blockquote, +.markdown-body .markdown-table-wrap, +.markdown-body .markdown-code-block { + margin: 0 0 1em; +} + +.markdown-body ul, +.markdown-body ol { + padding-left: 1.45em; +} + +.markdown-body li + li { + margin-top: 0.32em; +} + +.markdown-body strong { + color: #f2f7f4; + font-weight: 760; +} + +.markdown-heading { + margin: 1.25em 0 0.55em; + color: var(--ink); + font-size: 20px; + line-height: 1.3; +} + +.markdown-heading-1, +.markdown-heading-2 { + font-size: 22px; +} + +.markdown-body code { + padding: 0.15em 0.35em; + border: 1px solid var(--line); + border-radius: 5px; + background: rgba(0, 0, 0, 0.24); + font: 0.9em/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.markdown-code-block { + overflow: auto; + padding: 16px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(0, 0, 0, 0.28); + white-space: pre; +} + +.markdown-code-block code { + padding: 0; + border: 0; + background: transparent; +} + +.markdown-body blockquote { + padding: 0.15em 0 0.15em 1em; + color: var(--muted); + border-left: 3px solid var(--mint); +} + +.markdown-body hr { + margin: 1.4em 0; + border: 0; + border-top: 1px solid var(--line); +} + +.markdown-body a { + color: var(--mint); +} + +.markdown-table-wrap { + overflow-x: auto; +} + +.markdown-table { + min-width: 360px; + font-size: 13px; +} + +.markdown-table th, +.markdown-table td { + padding: 9px 10px; + border: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +.markdown-table th { + color: var(--ink); + background: rgba(255, 255, 255, 0.045); +} + +.model-answer .answer-details.answer-raw { + margin-top: 20px; +} + +.answer-raw .answer-json { + max-height: 300px; + font-size: 12px; +} + .model-answer .answer-details { margin-top: 16px; } diff --git a/site/tests/test_browser.py b/site/tests/test_browser.py index e896fb0..d983693 100644 --- a/site/tests/test_browser.py +++ b/site/tests/test_browser.py @@ -1,5 +1,6 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +import json import shutil import subprocess import tempfile @@ -55,6 +56,50 @@ def test_built_dashboard_renders_data_category_and_error_state(self): temporary_path = Path(temporary_directory) output = temporary_path / "public" build.build_site(REPOSITORY_ROOT, output) + answers_path = output / "data" / "answers" / "chatable.json" + answers = json.loads(answers_path.read_text(encoding="utf-8")) + first_answer = answers["cases"][0]["answers"][0] + first_answer["content"] += ( + "\n\n4. Langkah empat\n5. Langkah lima" + "\n\n*Nota:* kekalkan x_1 dan papar \\*asterisk literal\\*." + "\n\n" + "\n\n[Selamat](https://example.com/path) " + "[JavaScript](javascript:window.hostileLinkRan=true) " + "[Data](data:text/html,hostile)" + ) + expected_raw_markdown = first_answer["content"] + answers_path.write_text( + json.dumps(answers, ensure_ascii=False), + encoding="utf-8", + ) + + inspection_script = f""" + + """ handler = lambda *args, **kwargs: QuietHandler( *args, directory=str(output), **kwargs ) @@ -75,16 +120,53 @@ def test_built_dashboard_renders_data_category_and_error_state(self): self.assertIn("Read every response", rendered) self.assertIn("chatable_0", rendered) self.assertIn("Luas bagi sebuah segi tiga dikira dengan formula", rendered) + self.assertEqual( + rendered.count("Luas bagi sebuah segi tiga dikira dengan formula"), + 1, + ) self.assertIn("Luas segitiga dihitung dengan rumus", rendered) + self.assertIn('
', rendered) + self.assertIn("25 unit persegi", rendered) self.assertIn( - '
\n</parameter>',
+                    '
Persamaan Kuadratik
', rendered, ) + self.assertIn( + '
',
+                    rendered,
+                )
+                self.assertIn("Raw Markdown", rendered)
+                self.assertIn('
    ', rendered) + self.assertIn("Nota:", rendered) + self.assertIn("*asterisk literal*", rendered) + self.assertNotIn("asterisk literal", rendered) + self.assertIn("x_1", rendered) + self.assertIn("<img src=x onerror=window.hostileHtmlRan=true>", rendered) + self.assertIn('</parameter>", rendered) self.assertIn("200 of 200 cases", rendered) self.assertIn("Download JSON", rendered) self.assertNotIn("Loading benchmark results", rendered) self.assertNotIn("Loading model answers", rendered) + index = output / "index.html" + index.write_text( + index.read_text(encoding="utf-8").replace( + "", f"{inspection_script}", 1 + ), + encoding="utf-8", + ) + inspected = self.dump_page( + f"{base_url}/?category=multiple&answers=chatable", + temporary_path / "chrome-inspected-answer", + ) + self.assertIn('data-raw-markdown-exact="true"', inspected) + self.assertIn('data-hostile-html-inert="true"', inspected) + self.assertIn('data-unsafe-links-inert="true"', inspected) + (output / "data" / "answers" / "chatable.json").unlink() missing_answers = self.dump_page( f"{base_url}/?answers=chatable", @@ -94,7 +176,6 @@ def test_built_dashboard_renders_data_category_and_error_state(self): self.assertIn("0 of 0 cases", missing_answers) self.assertIn("Unavailable", missing_answers) - index = output / "index.html" index.write_text( index.read_text(encoding="utf-8").replace( './data/comparison.json', './data/missing.json', 1