File size: 9,715 Bytes
c28ca9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb4c5b5
c28ca9d
 
eb4c5b5
38f9159
c28ca9d
 
 
 
 
 
679bf11
c28ca9d
 
 
 
 
679bf11
c28ca9d
 
 
 
 
eb4c5b5
c28ca9d
 
 
 
 
679bf11
 
c28ca9d
 
 
 
 
 
eb4c5b5
c28ca9d
 
 
 
eb4c5b5
c28ca9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb4c5b5
c28ca9d
eb4c5b5
c28ca9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
import os, json, html, traceback
import gradio as gr
import pandas as pd
import spaces
from google import genai
from google.genai import types

import judge_prompts as J
import trust_analysis as ta

# ------------------------------------------------------------------ config
GEMINI_KEY   = os.environ.get("GEMINI_API_KEY")
APP_PASSWORD = os.environ.get("APP_PASSWORD", "")
MODEL        = os.environ.get("JUDGE_MODEL", "gemini-3.1-flash-lite")

MAX_SOURCE_CHARS  = 50000
MAX_SUMMARY_CHARS = 6000
SESSION_LIMIT     = 15

client = genai.Client(api_key=GEMINI_KEY) if GEMINI_KEY else None

DIMENSIONS = {
    "Faithfulness":              ("faithfulness",   "faithfulness_score",        "unsupported_claims", ("span","type","explanation")),
    "Overclaiming":              ("overclaiming",   "overclaiming_score",        "overclaims",         ("span","type","explanation")),
    "Uncertainty preservation":  ("uncertainty",    "uncertainty_score",         "dropped_hedges",     ("rendered_as","source_hedge",None)),
    "Methods & limitations":     ("methods_limits", "methods_limitations_score", None,                 None),
}
ALL_DIMS = list(DIMENSIONS)


# ------------------------------------------------------------------ judge
@spaces.GPU
def _zeroGPU_dummy():
    return True

def parse_json(text):
    obj, _ = json.JSONDecoder().raw_decode(text.strip())
    return obj

def judge(dimension, task, source, output):
    prompt = J.build_prompt(dimension, task, source, output)
    resp = client.models.generate_content(
        model=MODEL, contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=J.SYSTEM_PROMPT, temperature=0,
            response_mime_type="application/json"))
    return parse_json(resp.text)

def screen(output):
    df = ta.screen_validity(pd.DataFrame([{"generated_output": output,
                                           "has_output": bool(str(output).strip())}]))
    row = df.iloc[0]
    return bool(row["is_valid"]), str(row["degeneracy_type"])

# ------------------------------------------------------------------ rendering
def esc(s): return html.escape(str(s))

def score_colour(s):
    try: s = float(s)
    except: return "#888"
    return "#b5523f" if s <= 2 else "#c9a227" if s < 4 else "#2e8f6a"

def highlight(summary, spans):
    out = esc(summary)
    for sp in sorted({s for s in spans if s and len(str(s)) > 4}, key=len, reverse=True):
        out = out.replace(esc(sp), f"<mark style='background:#ffe0b3;color:#000000'>{esc(sp)}</mark>")
    return out.replace("\n", "<br>")

TEXT = "#000000"
MUTED = "#1a1a1a"

def render_scores(results, valid, degen):
    """Middle column: validity banner + one box per scored dimension."""
    parts = []
    if not valid:
        parts.append(f"<div style='padding:10px;border-left:4px solid #b5523f;background:#fbeae6;"
                     f"font-family:system-ui;color:#000000'>"
                     f"<b>Validity screen: DEGENERATE</b> — flagged as <code>{esc(degen)}</code>. "
                     f"Fault-absence dimensions (overclaiming, uncertainty) are not scored for "
                     f"non-functional output; faithfulness is reported as minimal.</div>")
    else:
        parts.append(f"<div style='padding:8px;border-left:4px solid #2e8f6a;background:#eaf6f0;"
                     f"font-family:system-ui;color:#000000'><b>Validity screen: functional output</b></div>")

    all_spans = []
    for label, res in results.items():
        key, sfield, ifield, ikeys = DIMENSIONS[label]
        if "error" in res:
            parts.append(f"<div style='margin-top:12px;color:#000000'><b>{esc(label)}</b>: "
                         f"<span style='color:#b5523f'>could not score ({esc(res['error'])})</span></div>")
            continue
        score = res.get(sfield, "–")
        col = score_colour(score)
        head = (f"<div style='margin-top:14px;padding:8px;border:1px solid #e3e7ee;border-radius:8px;"
                f"font-family:system-ui;background:#ffffff;color:#000000'>"
                f"<span style='font-family:system-ui;font-size:15px;font-weight:700'>{esc(label)}</span> "
                f"<span style='float:right;font-weight:700;color:{col}'>{esc(score)} / 5</span>")
        body = ""
        if label == "Methods & limitations":
            flags = [("Methods", res.get("methods_present")),
                     ("Limitations", res.get("limitations_present")),
                     ("Population", res.get("population_present"))]
            body += f"<div style='margin-top:6px;font-size:13px;color:#000000'>" + " · ".join(
                f"{n}: {'✓' if v else '✗'}" for n, v in flags) + "</div>"
        elif ifield:
            issues = res.get(ifield) or []
            if issues:
                body += f"<ul style='margin:6px 0 0 0;font-size:13px;color:#000000'>"
                for it in issues:
                    if not isinstance(it, dict): continue
                    span = it.get(ikeys[0], "")
                    all_spans.append(span)
                    typ = it.get(ikeys[1], "") if ikeys[1] else ""
                    exp = it.get(ikeys[2], "") if ikeys[2] else ""
                    line = f"<li><i>“{esc(span)}”</i>"
                    if typ: line += f" — <code>{esc(typ)}</code>"
                    if exp: line += f": {esc(exp)}"
                    body += line + "</li>"
                body += "</ul>"
            else:
                body += f"<div style='margin-top:6px;font-size:13px;color:#2e8f6a'>No issues flagged.</div>"
        parts.append(head + body + "</div>")

    html_out = "<div style='font-family:system-ui;color:" + TEXT + "'>" + "".join(parts) + "</div>"
    return html_out, all_spans


def render_summary(summary, all_spans):
    hi = highlight(summary, all_spans)
    return (f"<div style='font-family:system-ui;color:#000000'>"
           f"<div style='padding:10px;background:#fbfaf7;border:1px solid #ece7dc;border-radius:8px;"
           f"font-size:14px;line-height:1.6;color:#000000'>{hi}</div></div>")

# ------------------------------------------------------------------ main callback
def _err(msg, remaining):
    return f"<b style='color:#b5523f'>{esc(msg)}</b>", "", remaining

def evaluate(source, summary, task, dims, remaining):
    if client is None:
        return _err("Server not configured: GEMINI_API_KEY secret is missing.", remaining)
    if remaining <= 0:
        return _err("Session evaluation limit reached. Reload the page to reset.", remaining)
    source, summary = (source or "").strip(), (summary or "").strip()
    if not source or not summary:
        return _err("Please provide both a source text and an AI summary.", remaining)
    if len(source) > MAX_SOURCE_CHARS:
        return _err(f"Source too long ({len(source)} chars; max {MAX_SOURCE_CHARS}). Please trim.", remaining)
    if len(summary) > MAX_SUMMARY_CHARS:
        return _err(f"Summary too long ({len(summary)} chars; max {MAX_SUMMARY_CHARS}). Please trim.", remaining)
    if not dims:
        return _err("Select at least one dimension to run.", remaining)

    task_key = "structured" if task.startswith("Structured") else "lay"
    valid, degen = screen(summary)

    results = {}
    for label in dims:
        key, sfield, *_ = DIMENSIONS[label]
        # gate fault-absence dims on validity
        if not valid and key in ("overclaiming", "uncertainty"):
            continue
        if not valid and key in ("faithfulness", "methods_limits"):
            results[label] = {sfield: 1}
            continue
        try:
            results[label] = judge(key, task_key, source, summary)
        except Exception as e:
            results[label] = {"error": str(e)[:120]}

    scores_html, all_spans = render_scores(
        {k: v for k, v in results.items() if k in DIMENSIONS}, valid, degen)
    summary_html = render_summary(summary, all_spans)
    return scores_html, summary_html, remaining - 1

# ------------------------------------------------------------------ UI
CSS = ".gradio-container{max-width:1450px!important}"
with gr.Blocks(title="Trust Evaluation Toolkit") as demo:
    gr.Markdown("## Biomedical Summary Trustworthiness Toolkit\n"
                "Scores an AI-generated summary against a validated multi-dimensional trust rubric. "
                "Paste the source article and the AI summary, choose the register and dimensions, and run.")
    remaining = gr.State(SESSION_LIMIT)
    with gr.Row():
        with gr.Column(scale=4):
            source = gr.Textbox(label="Source text (the article)", lines=12, max_lines=20)
            summary = gr.Textbox(label="AI-generated summary", lines=8, max_lines=14)
            task = gr.Radio(["Structured summary", "Lay summary"], value="Structured summary",
                            label="Register")
            dims = gr.CheckboxGroup(ALL_DIMS, value=ALL_DIMS, label="Trust dimensions (LLM-judge)")
            btn = gr.Button("Evaluate", variant="primary")
        with gr.Column(scale=3):
            gr.Markdown("**Scores**")
            scores_out = gr.HTML()
        with gr.Column(scale=3):
            gr.Markdown("**Summary with flagged spans**")
            summary_out = gr.HTML()
    btn.click(evaluate, [source, summary, task, dims, remaining], [scores_out, summary_out, remaining])
    gr.Markdown("<small>Scores are produced by an LLM judge validated against human ratings; they are "
                "comparative, not absolute ground truth. Access is rate-limited per session.</small>")

def check_auth(username, password):
    return bool(APP_PASSWORD) and password == APP_PASSWORD

demo.launch(
    auth=check_auth,
    auth_message="Enter the shared access password.",
    css=CSS,
)