"""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 = """ """ 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"
  • {text}
  • " else: bullet = ( "
  • {text}' ) if include_comment and comment: bullet += ( f' ' f"({html.escape(comment)})" ) bullet += "
  • " items.append(bullet) return ( '" ) 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'
    {rewritten}
    ' 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'

    ' f"⏳ {html.escape(label)}

    " ) content = html.escape(audit.get("content") or "") return ( f'
    {content}
    ' f'

    ' f"⏳ {html.escape(label)}

    " ) 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'{text}' return ( f'{text}' ) 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'
  • {title}
  • ' ) else: links.append(f"
  • {title}
  • ") n = len(links) label = "1 source" if n == 1 else f"{n} sources" return ( f'
    ' f"{label}" f"" f"
    " ) 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'
    ' f"{index}. {html.escape(str(verdict))}
    " ) if detail: block += f'
    {html.escape(detail)}
    ' else: block += '
    No reasoning yet.
    ' 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 '

    Waiting for audit…

    ' status = audit.get("status") if status == "error": err = html.escape(_friendly_audit_error(audit.get("rewritten") or "")) return f'

    {err}

    ' 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'

    ⏳ {html.escape(label)}

    ' if status == "done": return '

    No claims extracted.

    ' return f'

    ⏳ {html.escape(label)}

    ' parts = [ f'
    {_claim_evidence_html(claim, i)}
    ' for i, claim in enumerate(claims, start=1) ] body = "".join(parts) if status in ("running", "pending"): body += f'

    ⏳ {html.escape(label)}

    ' return body def _bullets_cell_html(audit: MessageAudit | None, turn: ChatTurn) -> str: if audit is None: return '

    Audit queued…

    ' status = audit.get("status") if status == "done": body = audit_message_html(audit, colored=True, include_comment=False) return f'
    {body}
    ' if body else ( '

    No bullets.

    ' ) if status in ("running", "pending"): return ( f'
    ' f"{audit_status_html(audit, include_comment=False)}" f"
    " ) if status == "error": err = html.escape(_friendly_audit_error(audit.get("rewritten") or "")) return f'

    {err}

    ' _ = turn return '

    Audit queued…

    ' 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'' f'{original}' f'{bullets}' f'{evidence}' f"" ] 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'

    ⏳ {html.escape(phase_label)}

    ' ) if i == 0: rows.append( f'' f'{original}' f'{claim_html}' f'{evidence_html}' f"" ) else: rows.append( f'' f'{claim_html}' f'{evidence_html}' f"" ) 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("{mathml}' 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)}' 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"(? 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"{_inline_md(c)}" 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"{_inline_md(c)}" for c in padded[: len(header)]) body_rows.append(f"
    {cells}
    ") return ( '
    ' f"{head_cells}" f"{''.join(body_rows)}" "
    " ) 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'\1', out, ) out = re.sub(r"`([^`]+)`", r"\1", out) out = re.sub(r"\*\*([^*]+)\*\*", r"\1", out) out = re.sub(r"(?\1", 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"{code}") 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"{_inline_md(heading.group(2).strip())}" ) 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"
  • {_inline_md(m.group(1))}
  • ") i += 1 blocks.append(f"") 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"
  • {_inline_md(m.group(1))}
  • ") i += 1 blocks.append(f"
      {''.join(items)}
    ") 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"
    {_inline_md(' '.join(quote_lines))}
    " ) 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"

    {_inline_md(' '.join(para))}

    ") 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'
    {role_label}
    ' f'
    {content}
    ' ) 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'
    ' f'

    Send a message to start the conversation.

    ' f"
    " ) if show_audit: legend = ( '

    ' "green = supported · orange = uncertain · red = contradicted" "

    " ) head = ( "" "Original" "Claims" "Reasoning & sources" "" ) else: legend = "" head = ( "" "Conversation" "" ) 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'' f"{original}" f"" ) 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'' f'{original}' f'

    ⏳ Writing…

    ' f'

    ' f"" ) else: rows.append( f'' f"{original}" f"" ) return ( f"{_TABLE_CSS}" f'
    {legend}' f'{head}' f"{''.join(rows)}" f"
    " )