Spaces:
Running
Running
| """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 & 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>" | |
| ) | |