dejanseo commited on
Commit
b83454d
·
verified ·
1 Parent(s): 0ec23a9

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +176 -396
src/streamlit_app.py CHANGED
@@ -1,436 +1,216 @@
1
  # app.py
2
- import html
3
- import json
4
- import re
5
- from dataclasses import asdict, is_dataclass
6
- from typing import Any, Dict, List, Optional, Tuple
7
-
8
- import numpy as np
9
- import streamlit as st
10
  import torch
11
- from transformers import AutoModel, AutoTokenizer
12
-
13
- MODEL_ID = "zilliz/semantic-highlight-bilingual-v1"
14
 
15
- st.set_page_config(page_title="Semantic Highlight (Token-level)", layout="wide")
16
 
17
 
18
  @st.cache_resource(show_spinner=True)
19
- def load_model_and_tokenizer():
20
- # Tokenizer is usually fine as-is
21
- tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
22
-
23
- # HF Spaces sometimes lacks safetensors or has a transformers/safetensors mismatch.
24
- # Force PyTorch loading to avoid metadata=None crashes inside transformers.
25
  model = AutoModel.from_pretrained(
26
- MODEL_ID,
27
  trust_remote_code=True,
28
- use_safetensors=False,
29
  )
30
- model.eval()
31
- return model, tokenizer
32
-
33
-
34
- def to_jsonable(x: Any) -> Any:
35
- if x is None or isinstance(x, (str, int, float, bool)):
36
- return x
37
- if is_dataclass(x):
38
- return asdict(x)
39
- if isinstance(x, dict):
40
- return {str(k): to_jsonable(v) for k, v in x.items()}
41
- if isinstance(x, (list, tuple)):
42
- return [to_jsonable(v) for v in x]
43
- if isinstance(x, np.ndarray):
44
- return x.tolist()
45
- if torch.is_tensor(x):
46
- return x.detach().cpu().tolist()
47
- return str(x)
48
-
49
-
50
- def detect_lang(question: str, context: str) -> str:
51
- s = (question or "") + " " + (context or "")
52
- for ch in s:
53
- if "\u4e00" <= ch <= "\u9fff":
54
- return "zh"
55
- return "en"
56
-
57
-
58
- def split_sentences_with_spans(text: str, lang: str) -> List[Tuple[int, int]]:
59
- text = text or ""
60
- if not text.strip():
61
- return []
62
 
63
- spans: List[Tuple[int, int]] = []
64
- if lang == "zh":
65
- pattern = re.compile(r"[^。!?]*[。!?]?")
66
- else:
67
- pattern = re.compile(r"[^.!?]*[.!?]?")
68
 
69
- for m in pattern.finditer(text):
70
- s, e = m.span()
71
- if s == e:
72
- continue
73
- chunk = text[s:e]
74
- if chunk.strip() == "":
75
- continue
76
- spans.append((s, e))
77
-
78
- if not spans:
79
- spans = [(0, len(text))]
80
- return spans
81
-
82
-
83
- def build_context_mask(tokenizer, enc) -> List[bool]:
84
- ids = enc["input_ids"][0].tolist()
85
- L = len(ids)
86
-
87
- seq_ids: Optional[List[Optional[int]]] = None
88
- try:
89
- if hasattr(enc, "encodings") and enc.encodings and hasattr(enc.encodings[0], "sequence_ids"):
90
- seq_ids = enc.encodings[0].sequence_ids
91
- except Exception:
92
- seq_ids = None
93
-
94
- if seq_ids and len(seq_ids) == L:
95
- return [(sid == 1) for sid in seq_ids]
96
-
97
- sep_id = tokenizer.sep_token_id
98
- context_mask = [False] * L
99
- try:
100
- first_sep = ids.index(sep_id)
101
- for i in range(first_sep + 1, L):
102
- if ids[i] == sep_id:
103
- break
104
- context_mask[i] = True
105
- return context_mask
106
- except Exception:
107
- special = set(tokenizer.all_special_ids)
108
- return [tok_id not in special for tok_id in ids]
109
-
110
-
111
- def infer_token_scores(
112
- model,
113
- tokenizer,
114
- question: str,
115
- context: str,
116
- device: torch.device,
117
- ) -> Dict[str, Any]:
118
- enc = tokenizer(
119
- question,
120
- context,
121
- return_tensors="pt",
122
- return_offsets_mapping=True,
123
- truncation=True,
124
- )
125
 
126
- if "offset_mapping" not in enc:
127
- raise RuntimeError("Tokenizer did not return offset_mapping; cannot build token-level spans.")
128
-
129
- input_ids = enc["input_ids"].to(device)
130
- attention_mask = enc.get("attention_mask", None)
131
- if attention_mask is not None:
132
- attention_mask = attention_mask.to(device)
133
-
134
- offset_mapping = enc["offset_mapping"][0].tolist()
135
- ids_list = enc["input_ids"][0].tolist()
136
- token_texts_all = tokenizer.convert_ids_to_tokens(ids_list)
137
-
138
- context_mask = build_context_mask(tokenizer, enc)
139
-
140
- with torch.no_grad():
141
- out = model(input_ids=input_ids, attention_mask=attention_mask)
142
-
143
- debug_keys = []
144
- if hasattr(out, "keys"):
145
- try:
146
- debug_keys = list(out.keys())
147
- except Exception:
148
- debug_keys = []
149
-
150
- logits = None
151
- if hasattr(out, "logits"):
152
- logits = out.logits
153
- elif isinstance(out, (tuple, list)) and len(out) > 0 and torch.is_tensor(out[0]):
154
- logits = out[0]
155
- elif isinstance(out, dict) and "logits" in out and torch.is_tensor(out["logits"]):
156
- logits = out["logits"]
157
-
158
- if logits is None or not torch.is_tensor(logits):
159
- raise RuntimeError(f"Could not find logits in model output. Output keys: {debug_keys or type(out)}")
160
-
161
- if logits.dim() == 3:
162
- if logits.size(-1) == 1:
163
- token_logits = logits[:, :, 0]
164
- elif logits.size(-1) == 2:
165
- token_logits = logits[:, :, 1]
166
- else:
167
- token_logits = logits.max(dim=-1).values
168
- elif logits.dim() == 2:
169
- token_logits = logits
170
  else:
171
- raise RuntimeError(f"Unexpected logits dim: {logits.dim()}")
 
172
 
173
- token_probs = torch.sigmoid(token_logits)[0].detach().cpu().tolist()
174
 
175
- L_ids = len(ids_list)
176
- L_offsets = len(offset_mapping)
177
- L_mask = len(context_mask)
178
- L_probs = len(token_probs)
179
- L_texts = len(token_texts_all)
180
- L = min(L_ids, L_offsets, L_mask, L_probs, L_texts)
181
 
182
- context_token_scores: List[float] = []
183
- context_token_offsets: List[Tuple[int, int]] = []
184
- context_token_texts: List[str] = []
185
 
186
- for i in range(L):
187
- if not context_mask[i]:
188
- continue
189
- start, end = offset_mapping[i]
190
- if start == 0 and end == 0:
191
  continue
192
- if end <= start:
193
- continue
194
- context_token_scores.append(float(token_probs[i]))
195
- context_token_offsets.append((int(start), int(end)))
196
- context_token_texts.append(str(token_texts_all[i]))
197
-
198
- return {
199
- "token_scores": context_token_scores,
200
- "token_offsets": context_token_offsets,
201
- "token_texts": context_token_texts,
202
- "debug": {
203
- "output_keys": debug_keys,
204
- "logits_shape": list(logits.shape),
205
- "lens": {
206
- "input_ids": L_ids,
207
- "offset_mapping": L_offsets,
208
- "context_mask": L_mask,
209
- "token_probs": L_probs,
210
- "token_texts": L_texts,
211
- "aligned_L_used": L,
212
- },
213
- "num_ctx_tokens_kept": len(context_token_scores),
214
- },
215
- }
216
-
217
-
218
- def spans_from_token_scores(
219
- token_offsets: List[Tuple[int, int]],
220
- token_scores: List[float],
221
- threshold: float,
222
- merge_gap_chars: int = 1,
223
- ) -> List[Tuple[int, int, float]]:
224
- assert len(token_offsets) == len(token_scores)
225
-
226
- raw: List[Tuple[int, int, float]] = []
227
- cur_start = None
228
- cur_end = None
229
- cur_scores: List[float] = []
230
-
231
- for (s, e), score in zip(token_offsets, token_scores):
232
- if score >= threshold and e > s:
233
- if cur_start is None:
234
- cur_start, cur_end = s, e
235
- cur_scores = [score]
236
- else:
237
- if s <= (cur_end + merge_gap_chars):
238
- cur_end = max(cur_end, e)
239
- cur_scores.append(score)
240
- else:
241
- raw.append((cur_start, cur_end, float(sum(cur_scores) / max(1, len(cur_scores)))))
242
- cur_start, cur_end = s, e
243
- cur_scores = [score]
244
- else:
245
- if cur_start is not None:
246
- raw.append((cur_start, cur_end, float(sum(cur_scores) / max(1, len(cur_scores)))))
247
- cur_start = None
248
- cur_end = None
249
- cur_scores = []
250
-
251
- if cur_start is not None:
252
- raw.append((cur_start, cur_end, float(sum(cur_scores) / max(1, len(cur_scores)))))
253
-
254
- if not raw:
255
- return []
256
-
257
- raw.sort(key=lambda x: (x[0], x[1]))
258
- merged: List[Tuple[int, int, List[float]]] = [(raw[0][0], raw[0][1], [raw[0][2]])]
259
-
260
- for s, e, avg in raw[1:]:
261
- ps, pe, pavgs = merged[-1]
262
- if s <= pe + merge_gap_chars:
263
- merged[-1] = (ps, max(pe, e), pavgs + [avg])
264
- else:
265
- merged.append((s, e, [avg]))
266
-
267
- return [(s, e, float(sum(avgs) / len(avgs))) for s, e, avgs in merged]
268
-
269
-
270
- def render_highlighted_html(text: str, spans: List[Tuple[int, int, float]]) -> str:
271
- text = text or ""
272
- if not spans:
273
- return f"<div class='context-box'>{html.escape(text)}</div>"
274
-
275
- spans = [(max(0, s), min(len(text), e), sc) for s, e, sc in spans if e > s]
276
- spans.sort(key=lambda x: x[0])
277
-
278
- pieces: List[str] = []
279
- cur = 0
280
- for s, e, sc in spans:
281
- if s > cur:
282
- pieces.append(html.escape(text[cur:s]))
283
- frag = html.escape(text[s:e])
284
- pieces.append(f"<mark class='hl' title='score={sc:.3f}'>{frag}</mark>")
285
- cur = e
286
- if cur < len(text):
287
- pieces.append(html.escape(text[cur:]))
288
 
 
289
  style = """
290
  <style>
291
- .context-box {
 
 
 
 
 
292
  white-space: pre-wrap;
293
  font-family: ui-monospace, Menlo, Monaco, "Courier New", monospace;
294
- font-size: 0.95rem;
295
- line-height: 1.55;
296
- }
297
- mark.hl {
298
- background-color: rgba(255, 215, 0, 0.35);
299
- padding: 0.05em 0.15em;
300
- border-radius: 0.2em;
301
- }
302
  </style>
303
  """
304
- return style + f"<div class='context-box'>{''.join(pieces)}</div>"
305
-
306
-
307
- def sentence_scores_from_tokens(
308
- sentence_spans: List[Tuple[int, int]],
309
- token_offsets: List[Tuple[int, int]],
310
- token_scores: List[float],
311
- ) -> List[float]:
312
- if not sentence_spans:
313
- return []
314
- if not token_offsets:
315
- return [0.0 for _ in sentence_spans]
316
-
317
- mids = [((s + e) / 2.0) for s, e in token_offsets]
318
- sent_scores: List[List[float]] = [[] for _ in sentence_spans]
319
-
320
- for mid, score in zip(mids, token_scores):
321
- for i, (ss, se) in enumerate(sentence_spans):
322
- if ss <= mid < se:
323
- sent_scores[i].append(score)
324
- break
325
-
326
- out: List[float] = []
327
- for scores in sent_scores:
328
- out.append(float(sum(scores) / len(scores)) if scores else 0.0)
329
- return out
330
 
331
 
332
  def main():
333
- st.title("Semantic Highlight (Token-level)")
334
- st.caption(f"Model: {MODEL_ID} (bypassing `process()` to get token scores)")
335
 
336
  with st.sidebar:
337
  st.header("Settings")
338
- lang_choice = st.selectbox("Language", ["auto", "en", "zh"], index=0)
339
- token_threshold = st.slider("Token relevance threshold", 0.0, 1.0, 0.50, 0.01)
340
- merge_gap = st.number_input("Merge gap (chars)", min_value=0, max_value=10, value=1, step=1)
341
- show_sentence_table = st.checkbox("Show sentence scores table", value=True)
342
- debug = st.checkbox("Debug (show shapes/keys/lens)", value=True)
343
-
344
- default_q = "What are the key claims about Dan Petrovic?"
345
- default_ctx = (
346
- "Dan Petrovic is the managing director of DEJAN a well-known Australian AI SEO with worldwide recognition "
347
- "for industry-defining innovation and thought leadership. He works with major global brands on strategic "
348
- "product, service and brand visibility in AI search. Dan holds two Google awards for his work in discovery "
349
- "of Google’s APIs exposing internal systems and algorithms."
350
- )
351
-
352
- col1, col2 = st.columns(2)
353
- with col1:
354
- question = st.text_input("Query / Question", value=default_q)
355
- context = st.text_area("Context / Document", value=default_ctx, height=280)
356
-
357
- with col2:
358
- st.subheader("Run")
359
- run = st.button("Highlight", type="primary")
360
-
361
- if not run:
362
- return
363
-
364
- if not question.strip():
365
- st.error("Query is empty.")
366
- return
367
- if not context.strip():
368
- st.error("Context is empty.")
369
- return
370
-
371
- lang = detect_lang(question, context) if lang_choice == "auto" else lang_choice
372
-
373
- try:
374
- model, tokenizer = load_model_and_tokenizer()
375
- except Exception as e:
376
- st.error("Model failed to load in this environment.")
377
- st.code(str(e), language="text")
378
- st.info("On HF Spaces: add safetensors to requirements.txt and pin transformers>=4.41.0. This app also forces use_safetensors=False.")
379
- return
380
-
381
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
382
- model.to(device)
383
-
384
- with st.spinner("Scoring tokens..."):
385
- token_pack = infer_token_scores(
386
- model=model,
387
- tokenizer=tokenizer,
388
- question=question,
389
- context=context,
390
- device=device,
391
  )
392
 
393
- token_scores = token_pack["token_scores"]
394
- token_offsets = token_pack["token_offsets"]
 
 
 
 
395
 
396
- spans = spans_from_token_scores(
397
- token_offsets=token_offsets,
398
- token_scores=token_scores,
399
- threshold=float(token_threshold),
400
- merge_gap_chars=int(merge_gap),
 
 
 
 
401
  )
402
 
403
- st.subheader("Context with token-level highlights")
404
- st.markdown(render_highlighted_html(context, spans), unsafe_allow_html=True)
405
-
406
- st.subheader("Metrics")
407
- m1, m2, m3 = st.columns(3)
408
- with m1:
409
- st.metric("Highlighted spans", value=len(spans))
410
- with m2:
411
- st.metric("Context tokens kept", value=len(token_scores))
412
- with m3:
413
- st.metric("Language", value=lang)
414
-
415
- if show_sentence_table:
416
- st.subheader("Sentence scores (derived from token scores)")
417
- sentence_spans = split_sentences_with_spans(context, lang=lang)
418
- sent_scores = sentence_scores_from_tokens(sentence_spans, token_offsets, token_scores)
419
-
420
- rows = []
421
- for i, ((s, e), sc) in enumerate(zip(sentence_spans, sent_scores), start=1):
422
- rows.append(
423
- {
424
- "Sentence #": i,
425
- "Score": float(sc),
426
- "Sentence": (context[s:e] or "").strip(),
427
- }
428
- )
429
- st.dataframe(rows, use_container_width=True)
430
 
431
- if debug:
432
- st.subheader("Debug")
433
- st.code(json.dumps(to_jsonable(token_pack["debug"]), indent=2, ensure_ascii=False), language="json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
 
435
 
436
  if __name__ == "__main__":
 
1
  # app.py
 
 
 
 
 
 
 
 
2
  import torch
3
+ import streamlit as st
4
+ from transformers import AutoModel
 
5
 
6
+ st.set_page_config(page_title="Semantic Highlight Bilingual Demo", layout="wide")
7
 
8
 
9
  @st.cache_resource(show_spinner=True)
10
+ def load_model():
 
 
 
 
 
11
  model = AutoModel.from_pretrained(
12
+ "zilliz/semantic-highlight-bilingual-v1",
13
  trust_remote_code=True,
 
14
  )
15
+ return model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
 
 
 
 
 
17
 
18
+ def split_sentences(text: str):
19
+ text = text.strip()
20
+ if not text:
21
+ return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
+ # Very simple heuristic: use Chinese period if present, else English period.
24
+ if "。" in text:
25
+ parts = [s.strip() for s in text.split("。") if s.strip()]
26
+ # Add back "" to each sentence for nicer display.
27
+ sentences = [s + "" for s in parts]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  else:
29
+ parts = [s.strip() for s in text.split(".") if s.strip()]
30
+ sentences = [s + "." for s in parts]
31
 
32
+ return sentences
33
 
 
 
 
 
 
 
34
 
35
+ def highlight_context(context: str, highlighted_sentences):
36
+ if not context or not highlighted_sentences:
37
+ return context
38
 
39
+ # Simple HTML highlighting by sentence replacement
40
+ highlighted_html = context
41
+ for sent in highlighted_sentences:
42
+ sent_clean = sent.strip()
43
+ if not sent_clean:
44
  continue
45
+ # Avoid double-wrapping: only replace plain text, not already highlighted
46
+ replacement = f'<span class="hl-sentence">{sent_clean}</span>'
47
+ highlighted_html = highlighted_html.replace(sent_clean, replacement)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ # Basic styling
50
  style = """
51
  <style>
52
+ .hl-sentence {
53
+ background-color: rgba(255, 215, 0, 0.35);
54
+ padding: 2px 3px;
55
+ border-radius: 3px;
56
+ }
57
+ .context-box {
58
  white-space: pre-wrap;
59
  font-family: ui-monospace, Menlo, Monaco, "Courier New", monospace;
60
+ font-size: 0.9rem;
61
+ line-height: 1.5;
62
+ }
 
 
 
 
 
63
  </style>
64
  """
65
+ return style + f'<div class="context-box">{highlighted_html}</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
 
68
  def main():
69
+ st.title("Semantic Highlight Bilingual Demo")
70
+ st.caption("Model: zilliz/semantic-highlight-bilingual-v1")
71
 
72
  with st.sidebar:
73
  st.header("Settings")
74
+ threshold = st.slider(
75
+ "Relevance threshold",
76
+ min_value=0.0,
77
+ max_value=1.0,
78
+ value=0.5,
79
+ step=0.01,
80
+ help="Lower values highlight more sentences; higher values highlight fewer.",
81
+ )
82
+ language = st.selectbox(
83
+ "Language",
84
+ options=["auto", "en", "zh"],
85
+ index=0,
86
+ help="Let the model auto-detect, or force English (en) / Chinese (zh).",
87
+ )
88
+ return_sentence_metrics = st.checkbox(
89
+ "Return per-sentence probabilities",
90
+ value=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  )
92
 
93
+ st.markdown("---")
94
+ st.info(
95
+ "1. Enter a query.\n"
96
+ "2. Paste a document as context.\n"
97
+ "3. Click **Run Semantic Highlight**."
98
+ )
99
 
100
+ default_question = "What are the symptoms of dehydration?"
101
+ default_context = (
102
+ "Dehydration occurs when your body loses more fluid than you take in.\n"
103
+ "Common signs include feeling thirsty and having a dry mouth.\n"
104
+ "The human body is composed of about 60% water.\n"
105
+ "Dark yellow urine and infrequent urination are warning signs.\n"
106
+ "Water is essential for many bodily functions.\n"
107
+ "Dizziness, fatigue, and headaches can indicate severe dehydration.\n"
108
+ "Drinking enough water daily is often recommended."
109
  )
110
 
111
+ col_left, col_right = st.columns(2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
+ with col_left:
114
+ question = st.text_input(
115
+ "Query / Question",
116
+ value=default_question,
117
+ )
118
+ context = st.text_area(
119
+ "Context / Document",
120
+ value=default_context,
121
+ height=260,
122
+ )
123
+
124
+ with col_right:
125
+ st.subheader("Controls")
126
+ run = st.button("Run Semantic Highlight", type="primary")
127
+
128
+ if run:
129
+ if not question.strip():
130
+ st.error("Please enter a query/question.")
131
+ return
132
+ if not context.strip():
133
+ st.error("Please enter some context text.")
134
+ return
135
+
136
+ with st.spinner("Loading model and running inference..."):
137
+ model = load_model()
138
+ kwargs = {
139
+ "question": question,
140
+ "context": context,
141
+ "threshold": threshold,
142
+ "return_sentence_metrics": return_sentence_metrics,
143
+ }
144
+ if language != "auto":
145
+ kwargs["language"] = language
146
+
147
+ with torch.no_grad():
148
+ result = model.process(**kwargs)
149
+
150
+ highlighted_sentences = result.get("highlighted_sentences", [])
151
+ compression_rate = result.get("compression_rate", None)
152
+ sentence_probs = result.get("sentence_probabilities", None)
153
+
154
+ st.subheader("Results")
155
+
156
+ # Metrics row
157
+ metric_cols = st.columns(3)
158
+ with metric_cols[0]:
159
+ st.metric(
160
+ "Highlighted sentences",
161
+ value=len(highlighted_sentences),
162
+ )
163
+ with metric_cols[1]:
164
+ if compression_rate is not None:
165
+ st.metric(
166
+ "Compression rate",
167
+ value=f"{compression_rate * 100:.1f}%",
168
+ help="Approximate percentage of text removed.",
169
+ )
170
+ with metric_cols[2]:
171
+ st.metric(
172
+ "Threshold used",
173
+ value=f"{threshold:.2f}",
174
+ )
175
+
176
+ # Highlighted sentence list
177
+ st.markdown("### Highlighted Sentences")
178
+ if highlighted_sentences:
179
+ for i, sent in enumerate(highlighted_sentences, start=1):
180
+ st.markdown(f"**{i}.** {sent}")
181
+ else:
182
+ st.write("No sentences passed the current threshold.")
183
+
184
+ # Full context with inline highlights
185
+ st.markdown("### Context with Highlights")
186
+ highlighted_html = highlight_context(context, highlighted_sentences)
187
+ st.markdown(highlighted_html, unsafe_allow_html=True)
188
+
189
+ # Sentence probabilities table (if available)
190
+ if return_sentence_metrics and sentence_probs is not None:
191
+ st.markdown("### Sentence Probabilities")
192
+
193
+ sentences = split_sentences(context)
194
+ # Align lengths if possible; otherwise just show probabilities
195
+ if len(sentences) == len(sentence_probs):
196
+ import pandas as pd
197
+
198
+ data = {
199
+ "Sentence #": list(range(1, len(sentences) + 1)),
200
+ "Sentence": sentences,
201
+ "Probability": sentence_probs,
202
+ }
203
+ df = pd.DataFrame(data)
204
+ st.dataframe(
205
+ df,
206
+ use_container_width=True,
207
+ )
208
+ else:
209
+ st.write(
210
+ "Count of split sentences does not match model probabilities; "
211
+ "showing raw probability list."
212
+ )
213
+ st.write(sentence_probs)
214
 
215
 
216
  if __name__ == "__main__":