File size: 5,365 Bytes
7c51531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
154
155
156
157
158
159
160
161
"""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"""
<div class="gc-card">
  <div class="gc-verdict">
    Predicted: <span style="color:{verdict_color}">{verdict}</span>
  </div>
  <div class="gc-bar">
    <div class="gc-bar-human" style="width:{human_pct}%"></div>
    <div class="gc-bar-ai" style="width:{ai_pct}%"></div>
  </div>
  <div class="gc-bar-legend">
    <span class="gc-legend-human">Human · {human_pct}%</span>
    <span class="gc-legend-ai">AI · {ai_pct}%</span>
  </div>
</div>
"""


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 ``<span>`` 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'<span class="gc-ai-span" '
            f'style="background-color: rgba(229, 83, 60, {alpha})" '
            f'title="AI score: {score:.2f}">{chunk}</span>'
        )
        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"""
<div class="gc-card">
  <div class="{summary_class}">{summary}</div>
  <div class="gc-text">{highlighted}</div>
</div>
"""