theanswer / render.py
Norod78's picture
table layout + disclaimer bidi
3f7f5a0 verified
Raw History Blame Contribute Delete
6.77 kB
"""Turn untrusted API JSON (via view_model) into RTL HTML. Escape first; only our tags are added.
Layout follows the 7881 debug playground: status, answer, claims table (with a verified
column), episode triage table, warnings. Styling follows the 2026-09-28 Space redesign.
"""
from __future__ import annotations
import html
import re
from urllib.parse import urlsplit
from textproc import DisplayOpts, highlight_terms, strip_fffd, trim_at_word_boundary
from view_model import EpisodeView, ViewData
_BULLET = re.compile(r"^\s*[-*\u2022]\s+(.*)$")
_NUMBERED = re.compile(r"^\s*\d+\.\s+(.*)$")
_BOLD = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
_ITALIC = re.compile(
r"(?<!\*)\*(?!\s|\*)(.+?)(?<!\s|\*)\*(?!\*)"
r"|(?<![\w\u0590-\u05FF])_(?!_)(.+?)(?<!_)_(?![\w\u0590-\u05FF])"
)
def _e(text: object) -> str:
return html.escape(str(text or ""), quote=True)
def render_notice(message: str, css_class: str = "notice") -> str:
return f'<div class="{css_class}" dir="rtl" role="status">{_e(message)}</div>'
def _inline(escaped_line: str) -> str:
def bold(match: re.Match[str]) -> str:
return f"<strong>{match.group(1) if match.group(1) is not None else match.group(2)}</strong>"
def italic(match: re.Match[str]) -> str:
return f"<em>{match.group(1) if match.group(1) is not None else match.group(2)}</em>"
return _ITALIC.sub(italic, _BOLD.sub(bold, escaped_line))
def markdown_to_safe_html(text: str) -> str:
"""Escape HTML, then a small Markdown subset. Never emits links or raw tags."""
lines = html.escape(strip_fffd(text).replace("\r\n", "\n").replace("\r", "\n"), quote=True).split("\n")
blocks: list[str] = []
i = 0
while i < len(lines):
if not lines[i].strip():
i += 1
continue
for pattern, tag in ((_BULLET, "ul"), (_NUMBERED, "ol")):
if pattern.match(lines[i]):
items = []
while i < len(lines) and pattern.match(lines[i]):
items.append(f"<li>{_inline(pattern.match(lines[i]).group(1))}</li>") # type: ignore[union-attr]
i += 1
blocks.append(f"<{tag}>{''.join(items)}</{tag}>")
break
else:
para = []
while i < len(lines) and lines[i].strip() and not (_BULLET.match(lines[i]) or _NUMBERED.match(lines[i])):
para.append(_inline(lines[i]))
i += 1
blocks.append("<p>" + "<br>".join(para) + "</p>")
return "".join(blocks)
def is_http_url(url: str) -> bool:
candidate = (url or "").strip()
if not candidate or any(ch.isspace() or ord(ch) < 32 for ch in candidate):
return False
try:
parts = urlsplit(candidate)
except ValueError:
return False
return parts.scheme in ("http", "https") and bool(parts.netloc)
def episode_link(ep: EpisodeView, view: dict) -> str:
title = strip_fffd(ep.title).strip()
if not title or is_http_url(title):
title = str(view["missing_title"])
inner = _e(title)
if is_http_url(ep.url):
return (f'<a class="episode-link" href="{_e(ep.url.strip())}" target="_blank" '
f'rel="noopener noreferrer">{inner}</a>')
return f'<span class="episode-name">{inner}</span>'
def _date(ep: EpisodeView) -> str:
date = strip_fffd(ep.date).strip()
return "" if is_http_url(date) else f'<span class="src-date" dir="ltr">{_e(date)}</span>'
def _cell(label: str, content: str, css: str = "") -> str:
cls = f' class="{css}"' if css else ""
return f'<td data-label="{_e(label)}"{cls}>{content}</td>'
def _table(css: str, heading: str, headers: list[str], rows: list[str]) -> str:
head = "".join(f"<th>{_e(h)}</th>" for h in headers)
return (f'<section class="{css}"><h2>{_e(heading)}</h2><div class="table-wrap"><table>'
f'<thead><tr>{head}</tr></thead><tbody>{"".join(rows)}</tbody></table></div></section>')
def render_result(data: ViewData, question: str, view: dict, opts: DisplayOpts) -> str:
status_text = view["status_labels"].get(data.status, data.status)
parts = ['<div class="result" dir="rtl">',
f'<div class="status status-{_e(data.status)}" role="status">'
f'<span class="status-label">{_e(view["label_status"])}</span> {_e(status_text)}</div>']
answer_html = markdown_to_safe_html(data.answer_markdown or "") or f"<p>{_e(view['msg_refusal'])}</p>"
parts.append(f'<article class="answer-card"><h2>{_e(view["label_answer"])}</h2>'
f'<div class="answer-body">{answer_html}</div></article>')
if data.claims:
h = view["claims_headers"]
rows = []
for c in data.claims:
quote = trim_at_word_boundary(strip_fffd(c.quote), opts.max_quote_chars, opts.ellipsis)
quote_html = f'<q class="quote-text">{highlight_terms(quote, question, opts)}</q>'
if c.verified:
check = f'<span class="badge ok" title="{_e(view["verified_title"])}">✓ {_e(view["verified_badge"])}</span>'
else:
check = f'<span class="badge unverified" title="{_e(view["unverified_title"])}">{_e(view["unverified_badge"])}</span>'
rows.append("<tr" + ("" if c.verified else ' class="row-unverified"') + ">"
+ _cell(h[0], _e(c.text), "claim-text")
+ _cell(h[1], quote_html, "claim-quote")
+ _cell(h[2], check, "claim-verified")
+ _cell(h[3], episode_link(c.episode, view), "col-episode")
+ _cell(h[4], _date(c.episode), "col-date")
+ _cell(h[5], f'<span dir="ltr">{_e(c.timestamp)}</span>', "col-time")
+ "</tr>")
parts.append(_table("claims", view["label_claims"], h, rows))
if data.triage:
h = view["triage_headers"]
rows = []
for t in data.triage:
label = view["triage_labels"].get(t.label, t.label)
rows.append("<tr>" + _cell(h[0], episode_link(t.episode, view), "col-episode") + _cell(h[1], _date(t.episode), "col-date")
+ _cell(h[2], f'<span class="chip chip-{_e(t.label)}">{_e(label)}</span>', "col-label")
+ _cell(h[3], _e(t.reason), "triage-reason") + "</tr>")
parts.append(_table("triage", view["label_triage"], h, rows))
if data.warnings:
items = "".join(f'<li dir="auto">{_e(w)}</li>' for w in data.warnings)
parts.append(f'<section class="warnings"><h2>{_e(view["label_warnings"])}</h2>'
f'<p class="warn-note">{_e(view["warnings_note"])}</p><ul>{items}</ul></section>')
parts.append("</div>")
return "".join(parts)