"""HTML rendering helpers for classifier and detector results."""
from __future__ import annotations
import html
from validation import CONFIG
AiInterval = tuple[int, int, float]
def prettify_label(label: str) -> str:
"""Convert a raw model label into a display label.
Args:
label: Raw label from the model config (e.g. ``"ai"`` or ``"human"``).
Returns:
A capitalized, human-friendly label (``"AI"`` or ``"Human"``).
"""
mapping = {"ai": "AI", "human": "Human", "mixed": "Mixed"}
return mapping.get(label.lower(), label.capitalize())
def build_classifier_card(label: str, p_human: float, p_ai: float) -> str:
"""Render the classifier result as a labeled split probability bar.
Args:
label: Predicted raw label (``"ai"`` or ``"human"``).
p_human: Probability that the text is human-written, in ``[0, 1]``.
p_ai: Probability that the text is AI-generated, in ``[0, 1]``.
Returns:
An HTML string with the predicted label and a green/red split bar.
"""
human_pct = round(p_human * 100)
ai_pct = 100 - human_pct
verdict = prettify_label(label)
verdict_color = CONFIG.ai_color if verdict == "AI" else CONFIG.human_color
return f"""
Predicted: {verdict}
Human · {human_pct}%
AI · {ai_pct}%
"""
def merge_intervals(intervals: list[AiInterval], text_len: int) -> list[AiInterval]:
"""Merge AI intervals into non-overlapping segments keeping the max score.
Overlapping or touching predictions are flattened so that every character
is covered at most once, using the highest score among covering intervals.
Args:
intervals: Raw ``(start, end, score)`` predictions.
text_len: Length of the source text, used to clip the bounds.
Returns:
Sorted, non-overlapping ``(start, end, score)`` segments.
"""
clipped = [
(max(0, start), min(text_len, end), score)
for start, end, score in intervals
if min(text_len, end) > max(0, start)
]
if not clipped:
return []
boundaries = sorted({b for start, end, _ in clipped for b in (start, end)})
segments: list[AiInterval] = []
for left, right in zip(boundaries, boundaries[1:]):
covering = [s for st, en, s in clipped if st <= left and en >= right]
if not covering:
continue
score = max(covering)
if segments and segments[-1][1] == left and segments[-1][2] == score:
prev_start, _, prev_score = segments[-1]
segments[-1] = (prev_start, right, prev_score)
else:
segments.append((left, right, score))
return segments
def score_to_alpha(score: float) -> float:
"""Map a confidence score to a background opacity.
Args:
score: Confidence score in ``[0, 1]``.
Returns:
An opacity in ``[0.15, 1.0]`` so even low scores stay visible.
"""
return round(0.15 + 0.85 * max(0.0, min(1.0, score)), 3)
def build_highlighted_text(text: str, intervals: list[AiInterval]) -> str:
"""Render text with AI spans highlighted by score-scaled red backgrounds.
Args:
text: The source text exactly as passed to the detector.
intervals: Raw ``(start, end, score)`` predictions.
Returns:
An HTML string with AI spans wrapped in colored ```` elements.
"""
segments = merge_intervals(intervals, len(text))
parts: list[str] = []
cursor = 0
for start, end, score in segments:
if start > cursor:
parts.append(html.escape(text[cursor:start]))
alpha = score_to_alpha(score)
chunk = html.escape(text[start:end])
parts.append(
f'{chunk}'
)
cursor = end
if cursor < len(text):
parts.append(html.escape(text[cursor:]))
return "".join(parts)
def build_detector_card(text: str, intervals: list[AiInterval]) -> str:
"""Render the detector result with a header summary and highlighted text.
Args:
text: The source text exactly as passed to the detector.
intervals: Raw ``(start, end, score)`` predictions.
Returns:
An HTML string containing a summary line and the highlighted text.
"""
segments = merge_intervals(intervals, len(text))
if segments:
summary = (
f"{len(segments)} AI-written fragment(s) detected — "
"darker red means higher confidence."
)
summary_class = "gc-summary gc-summary-ai"
else:
summary = "No AI-written fragments detected above the threshold."
summary_class = "gc-summary gc-summary-clean"
highlighted = build_highlighted_text(text, intervals)
return f"""
"""