File size: 6,766 Bytes
1e1b8a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f7f5a0
 
 
1e1b8a7
 
 
 
 
 
 
 
3f7f5a0
 
1e1b8a7
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"""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)