Anti-Hallucination / ui /render.py
MHRDYN7's picture
Deploy Anti-Hallucination Chat to personal Space
f3793b5 verified
Raw
History Blame Contribute Delete
25.9 kB
"""Color-coded rewritten bullets and turn-table HTML for Streamlit."""
from __future__ import annotations
import html
import re
from agent.state import ChatTurn, Claim, MessageAudit
_COLORS = {
"supported": ("#d1fae5", "#065f46"),
"uncertain": ("#ffedd5", "#9a3412"),
"contradicted": ("#fee2e2", "#991b1b"),
"non_claim": (None, None),
}
_PHASE_LABELS = {
"queued": "Queued…",
"extracting": "Extracting bullets…",
"searching": "Searching the web…",
"judging": "Judging claims…",
"running": "Auditing…",
}
_TABLE_CSS = """
<style>
.ht-wrap {
--ht-border: #d8dee6;
--ht-head: #f3f5f8;
--ht-user: #e8f1fb;
--ht-assistant: #f4f6f8;
--ht-muted: #6b7280;
--ht-text: #1f2937;
font-family: "Source Sans 3", "Segoe UI", sans-serif;
color: var(--ht-text);
margin: 0.25rem 0 1rem;
}
.ht-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border: 1px solid var(--ht-border);
border-radius: 10px;
overflow: hidden;
background: #fff;
table-layout: fixed;
}
.ht-table th,
.ht-table td {
vertical-align: top;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--ht-border);
border-right: 1px solid var(--ht-border);
word-wrap: break-word;
overflow-wrap: anywhere;
}
.ht-table th:last-child,
.ht-table td:last-child { border-right: none; }
.ht-table tr:last-child td { border-bottom: none; }
.ht-table thead th {
background: var(--ht-head);
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 650;
color: #374151;
}
.ht-role {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--ht-muted);
margin-bottom: 0.35rem;
}
.ht-msg {
line-height: 1.45;
font-size: 0.95rem;
}
.ht-msg p {
margin: 0.3em 0;
}
.ht-msg h1,
.ht-msg h2,
.ht-msg h3,
.ht-msg h4,
.ht-msg h5,
.ht-msg h6 {
margin: 0.45em 0 0.2em;
line-height: 1.25;
font-weight: 700;
}
.ht-msg h1 { font-size: 1.12rem; }
.ht-msg h2 { font-size: 1.06rem; }
.ht-msg h3 { font-size: 1.0rem; }
.ht-msg h4, .ht-msg h5, .ht-msg h6 { font-size: 0.95rem; }
.ht-msg ul,
.ht-msg ol {
margin: 0.25em 0 0.25em 1.15em;
padding: 0;
}
.ht-msg li {
margin: 0.12em 0;
}
.ht-msg li > p {
margin: 0.12em 0;
}
.ht-msg pre,
.ht-msg code {
font-family: "IBM Plex Mono", "Consolas", monospace;
font-size: 0.88em;
}
.ht-msg pre {
margin: 0.35em 0;
padding: 0.5em 0.65em;
background: #eef1f5;
border-radius: 6px;
overflow-x: auto;
white-space: pre-wrap;
}
.ht-msg blockquote {
margin: 0.3em 0;
padding: 0.15em 0 0.15em 0.75em;
border-left: 3px solid #c5ced9;
color: #4b5563;
}
.ht-msg table.ht-md-table {
width: 100%;
border-collapse: collapse;
margin: 0.4em 0;
font-size: 0.88rem;
background: #fff;
}
.ht-msg table.ht-md-table th,
.ht-msg table.ht-md-table td {
border: 1px solid #d1d5db;
padding: 0.35em 0.5em;
text-align: left;
vertical-align: top;
}
.ht-msg table.ht-md-table th {
background: #eef2f6;
font-weight: 650;
}
.ht-msg math {
font-size: 1.05em;
}
.ht-msg .ht-math-block {
display: block;
margin: 0.45em 0;
overflow-x: auto;
text-align: center;
}
.ht-msg .ht-math-fallback {
font-family: "IBM Plex Mono", "Consolas", monospace;
font-size: 0.88em;
background: #eef1f5;
padding: 0.05em 0.25em;
border-radius: 4px;
}
.ht-msg > :first-child { margin-top: 0 !important; }
.ht-msg > :last-child { margin-bottom: 0 !important; }
.ht-row-user td.ht-original { background: var(--ht-user); }
.ht-row-assistant td.ht-original { background: var(--ht-assistant); }
.ht-row-user td:first-child { background: var(--ht-user); }
.ht-row-assistant td:first-child { background: var(--ht-assistant); }
.ht-subrow td.ht-claim,
.ht-subrow td.ht-evidence {
border-bottom: 1px solid #e8ecf1;
padding-top: 0.55rem;
padding-bottom: 0.55rem;
}
.ht-subrow-last td.ht-claim,
.ht-subrow-last td.ht-evidence,
.ht-subrow-last td.ht-original {
border-bottom: 1px solid var(--ht-border);
}
.ht-table tr.ht-subrow-last:last-child td {
border-bottom: none;
}
.ht-claim-text {
display: inline;
padding: 0.1em 0.25em;
border-radius: 0.25em;
line-height: 1.55;
font-size: 0.93rem;
}
.ht-bullets ul {
margin: 0.15em 0 0.15em 1.05em;
padding: 0;
line-height: 1.65;
}
.ht-bullets li { margin: 0.15em 0; }
.ht-phase {
color: var(--ht-muted);
font-size: 0.85em;
margin: 0.35em 0 0;
}
.ht-empty {
color: #9ca3af;
font-size: 0.88em;
font-style: italic;
}
.ht-ev-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
margin-bottom: 0.2rem;
}
.ht-ev-reason {
font-size: 0.9rem;
line-height: 1.45;
margin: 0.15rem 0 0.25rem;
}
.ht-sources {
margin: 0.15rem 0 0;
font-size: 0.85rem;
}
.ht-sources summary {
cursor: pointer;
color: #4b5563;
font-size: 0.82rem;
user-select: none;
list-style: none;
}
.ht-sources summary::-webkit-details-marker { display: none; }
.ht-sources summary::before {
content: "▸ ";
color: var(--ht-muted);
}
.ht-sources[open] summary::before { content: "▾ "; }
.ht-sources ul {
margin: 0.35rem 0 0 0.9rem;
padding: 0;
line-height: 1.45;
}
.ht-sources a { color: #1d4ed8; text-decoration: none; }
.ht-sources a:hover { text-decoration: underline; }
.ht-legend {
color: var(--ht-muted);
font-size: 0.85rem;
margin: 0 0 0.6rem;
}
</style>
"""
def claims_to_html(
claims: list[Claim],
*,
colored: bool = True,
include_comment: bool = True,
) -> str:
if not claims:
return ""
items: list[str] = []
for claim in claims:
verdict = claim.get("verdict", "uncertain") if colored else "non_claim"
bg, fg = _COLORS.get(verdict, _COLORS["uncertain"])
text = html.escape(claim.get("text", ""))
comment = claim.get("comment") or ""
reason = claim.get("reason") or comment
tip = html.escape(f"{verdict} · {reason}" if colored else text)
if bg is None:
bullet = f"<li>{text}</li>"
else:
bullet = (
"<li><span "
f'style="background:{bg};color:{fg};padding:0.1em 0.25em;'
'border-radius:0.25em;" '
f'title="{tip}">{text}</span>'
)
if include_comment and comment:
bullet += (
f' <em style="color:#6b7280;font-size:0.9em;">'
f"({html.escape(comment)})</em>"
)
bullet += "</li>"
items.append(bullet)
return (
'<ul style="margin:0.25em 0 0.25em 1.1em;line-height:1.6;">'
+ "".join(items)
+ "</ul>"
)
def audit_message_html(
audit: MessageAudit,
*,
colored: bool = True,
include_comment: bool = True,
) -> str:
claims = audit.get("claims") or []
if claims:
return claims_to_html(
claims, colored=colored, include_comment=include_comment
)
rewritten = html.escape(audit.get("rewritten") or audit.get("content") or "")
return f'<div style="white-space:pre-wrap;">{rewritten}</div>'
def audit_status_html(
audit: MessageAudit,
*,
include_comment: bool = True,
) -> str:
"""Render in-progress audit with preliminary green/orange colors."""
phase = audit.get("phase") or audit.get("status") or "running"
label = _PHASE_LABELS.get(str(phase), "Auditing…")
claims = audit.get("claims") or []
rewritten = (audit.get("rewritten") or "").strip()
if claims or rewritten:
# Show preliminary extract colors (green = common knowledge, orange = checking).
body = audit_message_html(
audit, colored=True, include_comment=include_comment
)
return (
f"{body}"
f'<p style="color:#6b7280;font-size:0.85em;margin:0.35em 0 0;">'
f"⏳ {html.escape(label)}</p>"
)
content = html.escape(audit.get("content") or "")
return (
f'<div style="white-space:pre-wrap;">{content}</div>'
f'<p style="color:#6b7280;font-size:0.85em;margin:0.35em 0 0;">'
f"⏳ {html.escape(label)}</p>"
)
def _verdict_color(verdict: str) -> str:
colors = {
"supported": "#065f46",
"uncertain": "#9a3412",
"contradicted": "#991b1b",
"non_claim": "#6b7280",
}
return colors.get(verdict, colors["uncertain"])
def _friendly_audit_error(raw: str) -> str:
text = (raw or "").strip()
low = text.lower()
if not text:
return "Audit unavailable"
if any(
tok in low
for tok in ("503", "unavailable", "429", "resource exhausted", "timeout")
):
return "Audit unavailable"
if len(text) > 120 or text.startswith("{"):
return "Audit unavailable"
return text
def _claim_bullet_html(claim: Claim, *, colored: bool = True) -> str:
verdict = claim.get("verdict", "uncertain") if colored else "non_claim"
bg, fg = _COLORS.get(verdict, _COLORS["uncertain"])
text = html.escape(claim.get("text", ""))
reason = claim.get("reason") or claim.get("comment") or ""
tip = html.escape(f"{verdict} · {reason}" if colored else text)
if bg is None:
return f'<span class="ht-claim-text">{text}</span>'
return (
f'<span class="ht-claim-text" '
f'style="background:{bg};color:{fg};" title="{tip}">{text}</span>'
)
def _sources_details_html(citations: list) -> str:
if not citations:
return ""
links: list[str] = []
for cite in citations:
title = html.escape(cite.get("title") or "source")
uri = cite.get("uri") or ""
if uri:
safe_uri = html.escape(uri, quote=True)
links.append(
f'<li><a href="{safe_uri}" target="_blank" '
f'rel="noopener noreferrer">{title}</a></li>'
)
else:
links.append(f"<li>{title}</li>")
n = len(links)
label = "1 source" if n == 1 else f"{n} sources"
return (
f'<details class="ht-sources">'
f"<summary>{label}</summary>"
f"<ul>{''.join(links)}</ul>"
f"</details>"
)
def _claim_evidence_html(claim: Claim, index: int) -> str:
verdict = claim.get("verdict", "uncertain")
comment = (claim.get("comment") or "").strip()
reason = (claim.get("reason") or "").strip()
detail = reason or comment
color = _verdict_color(str(verdict))
block = (
f'<div class="ht-ev-label" style="color:{color};">'
f"{index}. {html.escape(str(verdict))}</div>"
)
if detail:
block += f'<div class="ht-ev-reason">{html.escape(detail)}</div>'
else:
block += '<div class="ht-ev-reason ht-empty">No reasoning yet.</div>'
block += _sources_details_html(claim.get("citations") or [])
return block
def evidence_cell_html(audit: MessageAudit | None) -> str:
"""Reasoning + sources for one turn (legacy single-cell helper)."""
if audit is None:
return '<p class="ht-empty">Waiting for audit…</p>'
status = audit.get("status")
if status == "error":
err = html.escape(_friendly_audit_error(audit.get("rewritten") or ""))
return f'<p class="ht-empty">{err}</p>'
claims = audit.get("claims") or []
phase = audit.get("phase") or status or "running"
label = _PHASE_LABELS.get(str(phase), "Auditing…")
if not claims:
if status in ("running", "pending"):
return f'<p class="ht-phase">⏳ {html.escape(label)}</p>'
if status == "done":
return '<p class="ht-empty">No claims extracted.</p>'
return f'<p class="ht-phase">⏳ {html.escape(label)}</p>'
parts = [
f'<div style="margin:0 0 0.65rem;">{_claim_evidence_html(claim, i)}</div>'
for i, claim in enumerate(claims, start=1)
]
body = "".join(parts)
if status in ("running", "pending"):
body += f'<p class="ht-phase">⏳ {html.escape(label)}</p>'
return body
def _bullets_cell_html(audit: MessageAudit | None, turn: ChatTurn) -> str:
if audit is None:
return '<p class="ht-empty">Audit queued…</p>'
status = audit.get("status")
if status == "done":
body = audit_message_html(audit, colored=True, include_comment=False)
return f'<div class="ht-bullets">{body}</div>' if body else (
'<p class="ht-empty">No bullets.</p>'
)
if status in ("running", "pending"):
return (
f'<div class="ht-bullets">'
f"{audit_status_html(audit, include_comment=False)}"
f"</div>"
)
if status == "error":
err = html.escape(_friendly_audit_error(audit.get("rewritten") or ""))
return f'<p class="ht-empty">{err}</p>'
_ = turn
return '<p class="ht-empty">Audit queued…</p>'
def _turn_audit_rows(
turn: ChatTurn, audit: MessageAudit | None, row_class: str
) -> list[str]:
"""Original rowspan + one thin subrow per claim (cols 2–3 aligned)."""
original = _original_cell_html(turn)
claims = list((audit or {}).get("claims") or []) if audit else []
status = (audit or {}).get("status") if audit else None
phase = (audit or {}).get("phase") or status or "running"
phase_label = _PHASE_LABELS.get(str(phase), "Auditing…")
if not claims:
bullets = _bullets_cell_html(audit, turn)
evidence = evidence_cell_html(audit)
return [
f'<tr class="{row_class} ht-subrow-last">'
f'<td class="ht-original">{original}</td>'
f'<td class="ht-claim">{bullets}</td>'
f'<td class="ht-evidence">{evidence}</td>'
f"</tr>"
]
n = len(claims)
rows: list[str] = []
for i, claim in enumerate(claims):
is_last = i == n - 1
sub_class = "ht-subrow-last" if is_last else "ht-subrow"
claim_html = _claim_bullet_html(claim, colored=True)
evidence_html = _claim_evidence_html(claim, i + 1)
if is_last and status in ("running", "pending"):
evidence_html += (
f'<p class="ht-phase">⏳ {html.escape(phase_label)}</p>'
)
if i == 0:
rows.append(
f'<tr class="{row_class} {sub_class}">'
f'<td class="ht-original" rowspan="{n}">{original}</td>'
f'<td class="ht-claim">{claim_html}</td>'
f'<td class="ht-evidence">{evidence_html}</td>'
f"</tr>"
)
else:
rows.append(
f'<tr class="{row_class} {sub_class}">'
f'<td class="ht-claim">{claim_html}</td>'
f'<td class="ht-evidence">{evidence_html}</td>'
f"</tr>"
)
return rows
def _latex_to_mathml(latex: str, *, display: bool) -> str:
"""Convert TeX to MathML; fall back to monospace on parse errors."""
body = (latex or "").strip()
if not body:
return ""
try:
from latex2mathml.converter import convert
mathml = convert(body)
# latex2mathml defaults to inline; force block when needed.
if display:
mathml = mathml.replace('display="inline"', 'display="block"', 1)
if 'display="' not in mathml:
mathml = mathml.replace("<math ", '<math display="block" ', 1)
return f'<div class="ht-math-block">{mathml}</div>'
return mathml
except Exception: # noqa: BLE001
cls = "ht-math-block ht-math-fallback" if display else "ht-math-fallback"
tag = "div" if display else "code"
return f'<{tag} class="{cls}">{html.escape(body)}</{tag}>'
def _extract_math_segments(text: str) -> tuple[str, list[str]]:
"""Replace TeX segments with placeholders; return (text, html_segments)."""
segments: list[str] = []
def _store(html_snip: str) -> str:
idx = len(segments)
segments.append(html_snip)
return f"@@HTMATH{idx}@@"
# Order matters: block forms before inline.
patterns: list[tuple[re.Pattern[str], bool]] = [
(re.compile(r"\$\$(.+?)\$\$", re.DOTALL), True),
(re.compile(r"\\\[(.+?)\\\]", re.DOTALL), True),
(re.compile(r"(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)", re.DOTALL), False),
(re.compile(r"\\\((.+?)\\\)", re.DOTALL), False),
]
out = text
for pattern, display in patterns:
def _repl(match: re.Match[str], *, _display: bool = display) -> str:
return _store(_latex_to_mathml(match.group(1), display=_display))
out = pattern.sub(_repl, out)
return out, segments
def _restore_math_segments(text: str, segments: list[str]) -> str:
out = text
for i, snip in enumerate(segments):
out = out.replace(f"@@HTMATH{i}@@", snip)
return out
def _split_table_row(line: str) -> list[str]:
raw = line.strip().strip("|")
return [cell.strip() for cell in raw.split("|")]
def _is_table_separator(line: str) -> bool:
cells = _split_table_row(line)
if not cells:
return False
return all(re.fullmatch(r":?-{3,}:?", c.replace(" ", "")) for c in cells)
def _is_table_row(line: str) -> bool:
stripped = line.strip()
return stripped.startswith("|") and stripped.count("|") >= 2
def _table_html(header: list[str], rows: list[list[str]]) -> str:
head_cells = "".join(f"<th>{_inline_md(c)}</th>" for c in header)
body_rows: list[str] = []
for row in rows:
# Pad/truncate to header width for ragged markdown tables.
padded = list(row) + [""] * max(0, len(header) - len(row))
cells = "".join(f"<td>{_inline_md(c)}</td>" for c in padded[: len(header)])
body_rows.append(f"<tr>{cells}</tr>")
return (
'<table class="ht-md-table">'
f"<thead><tr>{head_cells}</tr></thead>"
f"<tbody>{''.join(body_rows)}</tbody>"
"</table>"
)
def _inline_md(text: str) -> str:
"""Escape text, render inline TeX, then apply safe inline markdown."""
protected, segments = _extract_math_segments(text or "")
out = html.escape(protected)
# Keep math placeholders intact through escaping (only @ letters/digits).
out = re.sub(
r"\[([^\]]+)\]\((https?://[^)\s]+)\)",
r'<a href="\2" target="_blank" rel="noopener noreferrer">\1</a>',
out,
)
out = re.sub(r"`([^`]+)`", r"<code>\1</code>", out)
out = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", out)
out = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", out)
return _restore_math_segments(out, segments)
def _md_to_html(text: str) -> str:
"""Compact markdown → HTML, including GFM tables and TeX math."""
raw = (text or "").replace("\r\n", "\n").strip()
if not raw:
return ""
# Extract display/inline math first so table/paragraph logic won't split TeX.
protected, math_segments = _extract_math_segments(raw)
lines = protected.split("\n")
blocks: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
# Fenced code block (no math restore inside — keep placeholders literal
# only if user put @@ in code; math already extracted from whole text).
if stripped.startswith("```"):
lang = stripped[3:].strip()
i += 1
code_lines: list[str] = []
while i < len(lines) and not lines[i].strip().startswith("```"):
code_lines.append(lines[i])
i += 1
if i < len(lines):
i += 1
code = html.escape("\n".join(code_lines))
code = _restore_math_segments(code, math_segments)
# If math was extracted from inside a fence, show raw escaped TeX-ish
# placeholders restored as MathML — acceptable for rare cases.
lang_attr = f' data-lang="{html.escape(lang)}"' if lang else ""
blocks.append(f"<pre{lang_attr}><code>{code}</code></pre>")
continue
# GFM table
if (
_is_table_row(stripped)
and i + 1 < len(lines)
and _is_table_separator(lines[i + 1].strip())
):
header = _split_table_row(stripped)
i += 2
body: list[list[str]] = []
while i < len(lines) and _is_table_row(lines[i].strip()):
body.append(_split_table_row(lines[i].strip()))
i += 1
blocks.append(_table_html(header, body))
continue
# ATX headings
heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if heading:
level = len(heading.group(1))
blocks.append(
f"<h{level}>{_inline_md(heading.group(2).strip())}</h{level}>"
)
i += 1
continue
# Unordered list
if re.match(r"^[-*+]\s+", stripped):
items: list[str] = []
while i < len(lines):
item = lines[i].strip()
m = re.match(r"^[-*+]\s+(.*)$", item)
if not m:
break
items.append(f"<li>{_inline_md(m.group(1))}</li>")
i += 1
blocks.append(f"<ul>{''.join(items)}</ul>")
continue
# Ordered list
if re.match(r"^\d+\.\s+", stripped):
items = []
while i < len(lines):
item = lines[i].strip()
m = re.match(r"^\d+\.\s+(.*)$", item)
if not m:
break
items.append(f"<li>{_inline_md(m.group(1))}</li>")
i += 1
blocks.append(f"<ol>{''.join(items)}</ol>")
continue
# Blockquote
if stripped.startswith(">"):
quote_lines: list[str] = []
while i < len(lines) and lines[i].strip().startswith(">"):
quote_lines.append(re.sub(r"^>\s?", "", lines[i].strip()))
i += 1
blocks.append(
f"<blockquote>{_inline_md(' '.join(quote_lines))}</blockquote>"
)
continue
# Paragraph: gather until blank line or a new block starter
para: list[str] = [stripped]
i += 1
while i < len(lines):
nxt = lines[i].strip()
if not nxt:
break
if (
nxt.startswith("```")
or re.match(r"#{1,6}\s+", nxt)
or re.match(r"^[-*+]\s+", nxt)
or re.match(r"^\d+\.\s+", nxt)
or nxt.startswith(">")
or (
_is_table_row(nxt)
and i + 1 < len(lines)
and _is_table_separator(lines[i + 1].strip())
)
):
break
para.append(nxt)
i += 1
blocks.append(f"<p>{_inline_md(' '.join(para))}</p>")
return _restore_math_segments("".join(blocks), math_segments)
def _original_cell_html(turn: ChatTurn) -> str:
role = turn.get("role", "assistant")
role_label = "You" if role == "user" else "Assistant"
content = _md_to_html(turn.get("content") or "")
return (
f'<div class="ht-role">{role_label}</div>'
f'<div class="ht-msg">{content}</div>'
)
def conversation_table_html(
turns: list[ChatTurn],
audits_by_id: dict[str, MessageAudit],
*,
show_audit: bool,
streaming_assistant: str | None = None,
) -> str:
"""One row per chat turn; expand to 3 columns after hallucination check."""
if not turns and not streaming_assistant:
return (
f"{_TABLE_CSS}"
f'<div class="ht-wrap">'
f'<p class="ht-empty">Send a message to start the conversation.</p>'
f"</div>"
)
if show_audit:
legend = (
'<p class="ht-legend">'
"green = supported · orange = uncertain · red = contradicted"
"</p>"
)
head = (
"<thead><tr>"
"<th style='width:34%'>Original</th>"
"<th style='width:33%'>Claims</th>"
"<th style='width:33%'>Reasoning &amp; sources</th>"
"</tr></thead>"
)
else:
legend = ""
head = (
"<thead><tr>"
"<th>Conversation</th>"
"</tr></thead>"
)
rows: list[str] = []
for turn in turns:
role = turn.get("role", "assistant")
row_class = "ht-row-user" if role == "user" else "ht-row-assistant"
if show_audit:
audit = audits_by_id.get(turn["id"])
rows.extend(_turn_audit_rows(turn, audit, row_class))
else:
original = _original_cell_html(turn)
rows.append(
f'<tr class="{row_class}">'
f"<td>{original}</td>"
f"</tr>"
)
if streaming_assistant is not None:
stream_turn: ChatTurn = {
"id": "__streaming__",
"role": "assistant",
"content": streaming_assistant,
}
original = _original_cell_html(stream_turn)
if show_audit:
rows.append(
f'<tr class="ht-row-assistant ht-subrow-last">'
f'<td class="ht-original">{original}</td>'
f'<td class="ht-claim"><p class="ht-phase">⏳ Writing…</p></td>'
f'<td class="ht-evidence"><p class="ht-empty">—</p></td>'
f"</tr>"
)
else:
rows.append(
f'<tr class="ht-row-assistant">'
f"<td>{original}</td>"
f"</tr>"
)
return (
f"{_TABLE_CSS}"
f'<div class="ht-wrap">{legend}'
f'<table class="ht-table">{head}'
f"<tbody>{''.join(rows)}</tbody>"
f"</table></div>"
)