r0mant1c Codex commited on
Commit
ba4739e
·
1 Parent(s): 529c18a

Improve upload flow and report UI

Browse files

Co-authored-by: Codex <chatgpt-codex-connector[bot]@users.noreply.github.com>

Files changed (1) hide show
  1. app.py +1567 -75
app.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
5
- from pathlib import Path
6
  from typing import Any
7
 
8
  import gradio as gr
@@ -30,20 +30,34 @@ def extract_lab_values(
30
  model: str,
31
  api_key_override: str,
32
  max_pages: int,
33
- ) -> tuple[list[list[Any]], str, str, str]:
34
  if not uploaded_file:
35
- return [], "Upload a lab document first.", "{}", ""
 
 
 
 
 
 
 
36
 
37
  extractor = OpenBMBExtractor(
38
  api_url=api_url,
39
  model=model,
40
- api_key=api_key_override.strip() or None,
41
  )
42
 
43
  try:
44
  result = extractor.extract(uploaded_file, max_pages=int(max_pages))
45
  except Exception as error:
46
- return [], f"Extraction failed: {error}", "{}", ""
 
 
 
 
 
 
 
47
 
48
  table_rows = [[test.get(header) for header in TABLE_HEADERS] for test in result.tests]
49
  structured_json = json.dumps(
@@ -52,11 +66,18 @@ def extract_lab_values(
52
  ensure_ascii=False,
53
  )
54
 
55
- status = f"Extracted {len(result.tests)} lab values."
56
  if result.notes:
57
- status += " Notes: " + " ".join(result.notes[:3])
58
 
59
- return table_rows, status, structured_json, result.raw_response
 
 
 
 
 
 
 
60
 
61
 
62
  def api_status() -> str:
@@ -65,56 +86,1491 @@ def api_status() -> str:
65
  model = os.getenv("OPENBMB_MODEL") or DEFAULT_MODEL
66
 
67
  if configured:
68
- return f"OpenBMB API key detected. Default model: `{model}`. Endpoint: `{api_url}`."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  return (
70
- "OpenBMB API key is not configured. Set `OPENBMB_API_KEY` locally or add it as a "
71
- "Hugging Face Space secret before running extraction."
72
  )
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  CUSTOM_CSS = """
76
  :root {
77
- --bte-ink: #16202a;
78
- --bte-muted: #5b6673;
79
- --bte-line: #dce4eb;
80
- --bte-blue: #2364aa;
81
- --bte-green: #1f8a70;
82
- --bte-paper: #f8fbfd;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
 
85
  .gradio-container {
86
- max-width: 1180px !important;
 
 
 
87
  color: var(--bte-ink);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  }
89
 
90
  .bte-shell {
91
  border: 1px solid var(--bte-line);
92
- border-radius: 8px;
93
  padding: 18px;
94
- background: var(--bte-paper);
 
95
  }
96
 
97
- .bte-title h1 {
98
- font-size: 32px !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  line-height: 1.12 !important;
100
  margin-bottom: 8px !important;
 
 
101
  }
102
 
103
  .bte-title p {
104
  color: var(--bte-muted);
105
- font-size: 15px;
106
- max-width: 780px;
 
 
 
 
 
107
  }
108
 
109
- .bte-status {
110
- border-left: 4px solid var(--bte-green);
111
- padding: 10px 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  background: #ffffff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  }
114
 
115
  button.primary {
116
- background: var(--bte-blue) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  """
119
 
120
 
@@ -122,65 +1578,101 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
122
  gr.HTML(
123
  """
124
  <section class="bte-title">
 
125
  <h1>Blood Test Explainer</h1>
126
- <p>Upload a lab report and extract the raw markers, values, units, ranges, and flags into a structured table. This first version performs extraction only.</p>
127
  </section>
128
  """
129
  )
130
 
131
- with gr.Row(equal_height=True):
132
  with gr.Column(scale=4, min_width=320):
133
- with gr.Group(elem_classes=["bte-shell"]):
134
- uploaded = gr.File(
135
- label="Medical test document",
136
- file_count="single",
137
- file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp", ".tif", ".tiff", ".txt", ".csv"],
138
- type="filepath",
139
- )
140
- run_button = gr.Button("Extract lab values", variant="primary")
141
-
142
- with gr.Column(scale=3, min_width=300):
143
- with gr.Group(elem_classes=["bte-shell"]):
144
- gr.Markdown("### Extraction backend")
145
- api_url = gr.Textbox(
146
- label="OpenBMB chat completions URL",
147
- value=os.getenv("OPENBMB_API_URL") or DEFAULT_API_URL,
148
- interactive=True,
149
- )
150
- model = gr.Textbox(
151
- label="Model",
152
- value=os.getenv("OPENBMB_MODEL") or DEFAULT_MODEL,
153
- interactive=True,
154
- )
155
- api_key_override = gr.Textbox(
156
- label="OpenBMB API key override",
157
- type="password",
158
- placeholder="Optional. Paste exact token here for local testing.",
159
- interactive=True,
160
- )
161
- max_pages = gr.Slider(1, 8, value=3, step=1, label="PDF pages to inspect")
162
- gr.Markdown(api_status(), elem_classes=["bte-status"])
163
-
164
- status = gr.Markdown("Ready.")
165
- results = gr.Dataframe(
166
- headers=TABLE_HEADERS,
167
- datatype=["str", "str", "str", "str", "str", "number", "str"],
168
- row_count=(0, "dynamic"),
169
- column_count=(len(TABLE_HEADERS), "fixed"),
170
- label="Extracted values",
171
- wrap=True,
172
- )
173
 
174
- with gr.Accordion("Structured JSON", open=True):
175
- structured_json = gr.Code(language="json", label="Normalized extraction")
176
 
177
- with gr.Accordion("Raw model response", open=False):
178
- raw_response = gr.Textbox(label="Raw response", lines=12)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
  run_button.click(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  extract_lab_values,
182
  inputs=[uploaded, api_url, model, api_key_override, max_pages],
183
- outputs=[results, status, structured_json, raw_response],
 
 
184
  )
185
 
186
 
 
2
 
3
  import json
4
  import os
5
+ from html import escape
6
  from typing import Any
7
 
8
  import gradio as gr
 
30
  model: str,
31
  api_key_override: str,
32
  max_pages: int,
33
+ ) -> tuple[list[list[Any]], str, str, str, str, Any]:
34
  if not uploaded_file:
35
+ return (
36
+ [],
37
+ _status_html("Waiting for a document", "Upload a lab report to begin extraction."),
38
+ "{}",
39
+ "",
40
+ empty_report_html("No document uploaded", "Choose a file first, then run extraction again."),
41
+ gr.update(visible=True),
42
+ )
43
 
44
  extractor = OpenBMBExtractor(
45
  api_url=api_url,
46
  model=model,
47
+ api_key=(api_key_override or "").strip() or None,
48
  )
49
 
50
  try:
51
  result = extractor.extract(uploaded_file, max_pages=int(max_pages))
52
  except Exception as error:
53
+ return (
54
+ [],
55
+ _status_html("Extraction failed", str(error), tone="danger"),
56
+ "{}",
57
+ "",
58
+ empty_report_html("Extraction failed", "The model response could not be converted into a report."),
59
+ gr.update(visible=True),
60
+ )
61
 
62
  table_rows = [[test.get(header) for header in TABLE_HEADERS] for test in result.tests]
63
  structured_json = json.dumps(
 
66
  ensure_ascii=False,
67
  )
68
 
69
+ status_text = f"Extracted {len(result.tests)} lab values."
70
  if result.notes:
71
+ status_text += " Notes: " + " ".join(result.notes[:3])
72
 
73
+ return (
74
+ table_rows,
75
+ _status_html("Extraction complete", status_text),
76
+ structured_json,
77
+ result.raw_response,
78
+ report_html(result.tests, result.notes),
79
+ gr.update(visible=True),
80
+ )
81
 
82
 
83
  def api_status() -> str:
 
86
  model = os.getenv("OPENBMB_MODEL") or DEFAULT_MODEL
87
 
88
  if configured:
89
+ return f"<strong>OpenBMB key detected.</strong><span>{escape(model)} · {escape(api_url)}</span>"
90
+ return (
91
+ "<strong>OpenBMB key missing.</strong><span>Set <code>OPENBMB_API_KEY</code> locally or as a Space secret.</span>"
92
+ )
93
+
94
+
95
+ def _status_html(title: str, detail: str, tone: str = "success") -> str:
96
+ return f"""
97
+ <div class="bte-run-status bte-run-status--{escape(tone)}">
98
+ <strong>{escape(title)}</strong>
99
+ <span>{escape(detail)}</span>
100
+ </div>
101
+ """
102
+
103
+
104
+ def show_processing() -> tuple[str, Any, str]:
105
+ return (
106
+ _status_html("Reading document", "Extracting markers, values, units, ranges, and confidence signals.", tone="loading"),
107
+ gr.update(visible=True),
108
+ loading_report_html(),
109
+ )
110
+
111
+
112
+ def upload_state(uploaded_file: str | None) -> tuple[Any, Any]:
113
+ if not uploaded_file:
114
+ return (
115
+ gr.update(visible=True),
116
+ gr.update(visible=False, value=selected_document_html()),
117
+ )
118
+
119
+ filename = os.path.basename(uploaded_file)
120
  return (
121
+ gr.update(visible=False),
122
+ gr.update(visible=True, value=selected_document_html(filename)),
123
  )
124
 
125
 
126
+ def selected_document_html(filename: str | None = None) -> str:
127
+ if not filename:
128
+ filename = "Document ready"
129
+ return f"""
130
+ <section class="bte-selected-document">
131
+ <div class="bte-selected-icon" aria-hidden="true">
132
+ <span></span>
133
+ </div>
134
+ <div>
135
+ <p class="bte-kicker">Document loaded</p>
136
+ <h3>{escape(filename)}</h3>
137
+ <p>Ready to extract markers, values, units, ranges, and confidence signals.</p>
138
+ </div>
139
+ </section>
140
+ """
141
+
142
+
143
+ def empty_report_html(
144
+ title: str = "Report preview",
145
+ detail: str = "Extracted lab markers will render here as an interactive medical document.",
146
+ ) -> str:
147
+ return f"""
148
+ <section class="bte-report bte-report--empty">
149
+ <div>
150
+ <p class="bte-kicker">Interactive document draft</p>
151
+ <h2>{escape(title)}</h2>
152
+ <p>{escape(detail)}</p>
153
+ </div>
154
+ </section>
155
+ """
156
+
157
+
158
+ def loading_report_html() -> str:
159
+ return """
160
+ <section class="bte-report bte-loading-report">
161
+ <div class="bte-loader-orbit" aria-hidden="true">
162
+ <span></span>
163
+ <i></i>
164
+ </div>
165
+ <div class="bte-loading-copy">
166
+ <p class="bte-kicker">Extraction in progress</p>
167
+ <h2>Reading your test results</h2>
168
+ <p>The model is locating markers, values, units, reference ranges, and status flags. The full report will appear here when extraction is complete.</p>
169
+ </div>
170
+ <div class="bte-loading-stack" aria-hidden="true">
171
+ <div><span></span><strong></strong></div>
172
+ <div><span></span><strong></strong></div>
173
+ <div><span></span><strong></strong></div>
174
+ </div>
175
+ </section>
176
+ """
177
+
178
+
179
+ def formation_animation_html() -> str:
180
+ return """
181
+ <section class="bte-formation" aria-label="Responsive document formation animation">
182
+ <div class="bte-formation-stage">
183
+ <div class="bte-source-doc">
184
+ <div class="bte-doc-top">
185
+ <span></span>
186
+ <span></span>
187
+ <span></span>
188
+ </div>
189
+ <div class="bte-doc-line bte-doc-line--wide"></div>
190
+ <div class="bte-doc-line"></div>
191
+ <div class="bte-doc-line bte-doc-line--short"></div>
192
+ <div class="bte-doc-table">
193
+ <span></span><span></span><span></span>
194
+ <span></span><span></span><span></span>
195
+ <span></span><span></span><span></span>
196
+ </div>
197
+ <div class="bte-scan-band"></div>
198
+ </div>
199
+
200
+ <div class="bte-flow">
201
+ <span></span>
202
+ <i></i>
203
+ <b></b>
204
+ </div>
205
+
206
+ <div class="bte-smart-report">
207
+ <div class="bte-report-window">
208
+ <div class="bte-report-header">
209
+ <strong>12 markers</strong>
210
+ <small>ready to review</small>
211
+ </div>
212
+ <div class="bte-mini-card bte-mini-card--green">
213
+ <span>Hemoglobin</span>
214
+ <strong>Normal</strong>
215
+ </div>
216
+ <div class="bte-mini-card bte-mini-card--amber">
217
+ <span>Vitamin D</span>
218
+ <strong>Low</strong>
219
+ </div>
220
+ <div class="bte-mini-chart">
221
+ <span style="height: 34%"></span>
222
+ <span style="height: 56%"></span>
223
+ <span style="height: 42%"></span>
224
+ <span style="height: 74%"></span>
225
+ <span style="height: 61%"></span>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ </section>
231
+ """
232
+
233
+
234
+ def report_html(tests: list[dict[str, Any]], notes: list[str]) -> str:
235
+ total = len(tests)
236
+ high = _count_status(tests, "high")
237
+ low = _count_status(tests, "low")
238
+ abnormal = _count_status(tests, "abnormal")
239
+ normal = _count_status(tests, "normal")
240
+ needs_review = high + low + abnormal
241
+
242
+ cards = "\n".join(_marker_card(test) for test in tests) or _empty_marker_card()
243
+ notes_html = "".join(f"<li>{escape(note)}</li>" for note in notes[:6])
244
+ notes_block = f"<ul>{notes_html}</ul>" if notes_html else "<p>No extraction notes returned.</p>"
245
+
246
+ return f"""
247
+ <section class="bte-report">
248
+ <header class="bte-report-hero">
249
+ <div>
250
+ <p class="bte-kicker">Lab extraction report</p>
251
+ <h2>{total} markers found</h2>
252
+ <p>Review the extracted values before using them for interpretation. This draft is an extraction view only.</p>
253
+ </div>
254
+ <div class="bte-score">
255
+ <span>{needs_review}</span>
256
+ <small>need review</small>
257
+ </div>
258
+ </header>
259
+
260
+ <div class="bte-metrics">
261
+ {_metric("Total", total, "All extracted markers")}
262
+ {_metric("Review", needs_review, "High, low, or abnormal")}
263
+ {_metric("Normal", normal, "Marked normal")}
264
+ {_metric("Unknown", max(total - needs_review - normal, 0), "Needs confirmation")}
265
+ </div>
266
+
267
+ <div class="bte-status-strip">
268
+ {_pill("High", high, "high")}
269
+ {_pill("Low", low, "low")}
270
+ {_pill("Abnormal", abnormal, "abnormal")}
271
+ {_pill("Normal", normal, "normal")}
272
+ </div>
273
+
274
+ <div class="bte-report-grid">
275
+ <section class="bte-marker-list">
276
+ {cards}
277
+ </section>
278
+ <aside class="bte-report-aside">
279
+ <h3>Extraction notes</h3>
280
+ {notes_block}
281
+ <div class="bte-disclaimer">
282
+ <strong>Draft only</strong>
283
+ <span>These are raw extracted values, not medical advice. Confirm values against the original document.</span>
284
+ </div>
285
+ </aside>
286
+ </div>
287
+ </section>
288
+ """
289
+
290
+
291
+ def _count_status(tests: list[dict[str, Any]], status: str) -> int:
292
+ return sum(1 for test in tests if str(test.get("status") or "").lower() == status)
293
+
294
+
295
+ def _metric(label: str, value: int, caption: str) -> str:
296
+ return f"""
297
+ <div class="bte-metric">
298
+ <span>{escape(str(value))}</span>
299
+ <strong>{escape(label)}</strong>
300
+ <small>{escape(caption)}</small>
301
+ </div>
302
+ """
303
+
304
+
305
+ def _pill(label: str, value: int, tone: str) -> str:
306
+ return f'<span class="bte-pill bte-pill--{escape(tone)}">{escape(label)} <strong>{escape(str(value))}</strong></span>'
307
+
308
+
309
+ def _marker_card(test: dict[str, Any]) -> str:
310
+ marker = _text(test.get("marker"), "Unknown marker")
311
+ value = _text(test.get("value"), "-")
312
+ unit = _text(test.get("unit"), "")
313
+ reference = _text(test.get("reference_range"), "Reference range not extracted")
314
+ status = _text(test.get("status"), "unknown").lower()
315
+ source = _text(test.get("source_text"), "No source snippet returned.")
316
+ confidence = _confidence_percent(test.get("confidence"))
317
+
318
+ return f"""
319
+ <details class="bte-marker bte-marker--{escape(status)}" open>
320
+ <summary>
321
+ <span class="bte-marker-main">
322
+ <strong>{escape(marker)}</strong>
323
+ <small>{escape(reference)}</small>
324
+ </span>
325
+ <span class="bte-marker-value">
326
+ <strong>{escape(value)}</strong>
327
+ <small>{escape(unit)}</small>
328
+ </span>
329
+ <span class="bte-marker-status">{escape(status.title())}</span>
330
+ </summary>
331
+ <div class="bte-marker-body">
332
+ <div>
333
+ <span>Confidence</span>
334
+ <div class="bte-confidence"><i style="width: {confidence}%"></i></div>
335
+ <small>{confidence}%</small>
336
+ </div>
337
+ <blockquote>{escape(source)}</blockquote>
338
+ </div>
339
+ </details>
340
+ """
341
+
342
+
343
+ def _empty_marker_card() -> str:
344
+ return """
345
+ <div class="bte-marker-empty">
346
+ No markers extracted yet.
347
+ </div>
348
+ """
349
+
350
+
351
+ def _text(value: Any, fallback: str) -> str:
352
+ if value is None:
353
+ return fallback
354
+ text = str(value).strip()
355
+ return text or fallback
356
+
357
+
358
+ def _confidence_percent(value: Any) -> int:
359
+ try:
360
+ score = float(value)
361
+ except (TypeError, ValueError):
362
+ return 0
363
+ return max(0, min(100, round(score * 100)))
364
+
365
+
366
  CUSTOM_CSS = """
367
  :root {
368
+ color-scheme: light;
369
+ --bte-ink: #111827;
370
+ --bte-muted: #65707f;
371
+ --bte-soft: #8b97a7;
372
+ --bte-line: #e5eaf0;
373
+ --bte-blue: #2563eb;
374
+ --bte-blue-soft: #eff6ff;
375
+ --bte-green: #12805c;
376
+ --bte-green-soft: #eaf8f2;
377
+ --bte-red: #bf3434;
378
+ --bte-red-soft: #fff1f1;
379
+ --bte-amber: #9a6700;
380
+ --bte-amber-soft: #fff7df;
381
+ --bte-paper: #f7f9fc;
382
+ --bte-card: rgba(255, 255, 255, 0.92);
383
+ }
384
+
385
+ *,
386
+ *::before,
387
+ *::after {
388
+ box-sizing: border-box;
389
+ }
390
+
391
+ body,
392
+ gradio-app,
393
+ .gradio-container,
394
+ .dark,
395
+ .dark .gradio-container {
396
+ color-scheme: light !important;
397
+ --body-background-fill: #f7f9fc !important;
398
+ --body-text-color: #111827 !important;
399
+ --background-fill-primary: #ffffff !important;
400
+ --background-fill-secondary: #f7f9fc !important;
401
+ --block-background-fill: #ffffff !important;
402
+ --block-border-color: #e5eaf0 !important;
403
+ --block-border-width: 1px !important;
404
+ --block-info-text-color: #65707f !important;
405
+ --block-label-background-fill: #ffffff !important;
406
+ --block-label-text-color: #111827 !important;
407
+ --block-title-text-color: #111827 !important;
408
+ --border-color-primary: #e5eaf0 !important;
409
+ --border-color-accent: #2563eb !important;
410
+ --input-background-fill: #ffffff !important;
411
+ --input-border-color: #d8e2ee !important;
412
+ --input-placeholder-color: #8b97a7 !important;
413
+ --input-shadow: none !important;
414
+ --button-primary-background-fill: #111827 !important;
415
+ --button-primary-background-fill-hover: #263244 !important;
416
+ --button-primary-text-color: #ffffff !important;
417
+ --slider-color: #2563eb !important;
418
+ --shadow-drop: 0 18px 48px rgba(17, 24, 39, 0.06) !important;
419
  }
420
 
421
  .gradio-container {
422
+ max-width: 1240px !important;
423
+ width: 100% !important;
424
+ margin: 0 auto !important;
425
+ box-sizing: border-box !important;
426
  color: var(--bte-ink);
427
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
428
+ background:
429
+ radial-gradient(circle at 20% 0%, rgba(37, 99, 235, 0.08), transparent 34%),
430
+ linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%);
431
+ }
432
+
433
+ .gradio-container,
434
+ .gradio-container * {
435
+ letter-spacing: 0 !important;
436
+ }
437
+
438
+ .gradio-container .main,
439
+ .gradio-container .contain,
440
+ .gradio-container .wrap {
441
+ background: transparent !important;
442
+ }
443
+
444
+ .gradio-container .prose,
445
+ .gradio-container .prose *,
446
+ .gradio-container h1,
447
+ .gradio-container h2,
448
+ .gradio-container h3,
449
+ .gradio-container p,
450
+ .gradio-container span {
451
+ color: inherit;
452
+ }
453
+
454
+ .gradio-container label,
455
+ .gradio-container .label-wrap,
456
+ .gradio-container .label-wrap span,
457
+ .gradio-container input,
458
+ .gradio-container textarea {
459
+ color: var(--bte-ink) !important;
460
+ }
461
+
462
+ .gradio-container input,
463
+ .gradio-container textarea {
464
+ background: #ffffff !important;
465
+ border-color: var(--bte-line) !important;
466
+ border-radius: 12px !important;
467
+ }
468
+
469
+ .gradio-container .block,
470
+ .gradio-container .form,
471
+ .gradio-container .input-container,
472
+ .gradio-container fieldset {
473
+ background: #ffffff !important;
474
+ border-color: var(--bte-line) !important;
475
+ }
476
+
477
+ .gradio-container .form {
478
+ border: 0 !important;
479
+ }
480
+
481
+ .gradio-container .block {
482
+ box-shadow: none !important;
483
  }
484
 
485
  .bte-shell {
486
  border: 1px solid var(--bte-line);
487
+ border-radius: 22px;
488
  padding: 18px;
489
+ background: var(--bte-card);
490
+ box-shadow: 0 18px 48px rgba(17, 24, 39, 0.06);
491
  }
492
 
493
+ .bte-engine {
494
+ padding: 18px 18px 20px;
495
+ }
496
+
497
+ .bte-engine-compact {
498
+ margin-top: 8px;
499
+ padding: 16px;
500
+ box-shadow: none;
501
+ }
502
+
503
+ .bte-shell > div,
504
+ .bte-shell > div > div {
505
+ background: transparent !important;
506
+ }
507
+
508
+ .bte-shell h3,
509
+ .bte-shell h3 * {
510
+ color: var(--bte-ink) !important;
511
+ font-size: 18px !important;
512
+ line-height: 1.15 !important;
513
+ margin-bottom: 10px !important;
514
+ }
515
+
516
+ .bte-title {
517
+ padding: 26px 12px 18px;
518
+ }
519
+
520
+ .bte-title h1,
521
+ .bte-report h2 {
522
+ font-size: clamp(34px, 4vw, 42px) !important;
523
  line-height: 1.12 !important;
524
  margin-bottom: 8px !important;
525
+ letter-spacing: 0 !important;
526
+ color: var(--bte-ink) !important;
527
  }
528
 
529
  .bte-title p {
530
  color: var(--bte-muted);
531
+ font-size: 16px;
532
+ max-width: 820px;
533
+ margin: 0;
534
+ }
535
+
536
+ .bte-hero-grid {
537
+ align-items: stretch !important;
538
  }
539
 
540
+ .bte-upload-card {
541
+ height: 100%;
542
+ display: flex;
543
+ flex-direction: column;
544
+ justify-content: space-between;
545
+ }
546
+
547
+ .bte-formation {
548
+ height: 100%;
549
+ min-height: 390px;
550
+ border: 1px solid var(--bte-line);
551
+ border-radius: 24px;
552
+ padding: 22px;
553
+ background:
554
+ linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(247, 251, 255, 0.86)),
555
+ #ffffff;
556
+ box-shadow: 0 22px 70px rgba(17, 24, 39, 0.08);
557
+ overflow: hidden;
558
+ }
559
+
560
+ .bte-formation-stage {
561
+ height: 100%;
562
+ min-height: 342px;
563
+ display: grid;
564
+ grid-template-columns: minmax(138px, 0.95fr) 74px minmax(176px, 1.12fr);
565
+ align-items: center;
566
+ gap: 14px;
567
+ }
568
+
569
+ .bte-source-doc,
570
+ .bte-report-window {
571
+ position: relative;
572
+ border: 1px solid rgba(216, 226, 238, 0.95);
573
+ border-radius: 18px;
574
  background: #ffffff;
575
+ box-shadow: 0 20px 48px rgba(17, 24, 39, 0.1);
576
+ }
577
+
578
+ .bte-source-doc {
579
+ min-height: 280px;
580
+ padding: 18px;
581
+ overflow: hidden;
582
+ animation: bte-doc-float 5.6s ease-in-out infinite;
583
+ }
584
+
585
+ .bte-doc-top {
586
+ display: flex;
587
+ gap: 6px;
588
+ margin-bottom: 18px;
589
+ }
590
+
591
+ .bte-doc-top span {
592
+ width: 10px;
593
+ aspect-ratio: 1;
594
+ border-radius: 999px;
595
+ background: #d8e2ee;
596
+ }
597
+
598
+ .bte-doc-top span:nth-child(2) {
599
+ background: #91d7c0;
600
+ }
601
+
602
+ .bte-doc-top span:nth-child(3) {
603
+ background: #9cbcff;
604
+ }
605
+
606
+ .bte-doc-line {
607
+ height: 11px;
608
+ width: 76%;
609
+ border-radius: 999px;
610
+ margin-bottom: 10px;
611
+ background: #e7edf5;
612
+ }
613
+
614
+ .bte-doc-line--wide {
615
+ width: 92%;
616
+ height: 15px;
617
+ background: #dce8fb;
618
+ }
619
+
620
+ .bte-doc-line--short {
621
+ width: 54%;
622
+ }
623
+
624
+ .bte-doc-table {
625
+ display: grid;
626
+ grid-template-columns: repeat(3, minmax(0, 1fr));
627
+ gap: 8px;
628
+ margin-top: 22px;
629
+ }
630
+
631
+ .bte-doc-table span {
632
+ min-height: 28px;
633
+ border-radius: 9px;
634
+ background: #f0f4f8;
635
+ border: 1px solid #e2eaf3;
636
+ }
637
+
638
+ .bte-scan-band {
639
+ position: absolute;
640
+ left: -20%;
641
+ right: -20%;
642
+ top: 22%;
643
+ height: 34px;
644
+ transform: rotate(-6deg);
645
+ background: linear-gradient(90deg, transparent, rgba(18, 128, 92, 0.18), rgba(37, 99, 235, 0.16), transparent);
646
+ animation: bte-scan-doc 2.9s ease-in-out infinite;
647
+ }
648
+
649
+ .bte-flow {
650
+ display: grid;
651
+ justify-items: center;
652
+ align-items: center;
653
+ gap: 10px;
654
+ }
655
+
656
+ .bte-flow span {
657
+ width: 100%;
658
+ height: 2px;
659
+ border-radius: 999px;
660
+ background: linear-gradient(90deg, rgba(37, 99, 235, 0), rgba(37, 99, 235, 0.8), rgba(18, 128, 92, 0));
661
+ animation: bte-flow-line 1.8s ease-in-out infinite;
662
+ }
663
+
664
+ .bte-flow i,
665
+ .bte-flow b {
666
+ display: block;
667
+ width: 34px;
668
+ aspect-ratio: 1;
669
+ border-radius: 12px;
670
+ border: 1px solid #dbeafe;
671
+ background: #ffffff;
672
+ box-shadow: 0 12px 28px rgba(37, 99, 235, 0.12);
673
+ animation: bte-flow-tile 2.4s ease-in-out infinite;
674
+ }
675
+
676
+ .bte-flow b {
677
+ width: 22px;
678
+ border-color: #cfeee3;
679
+ animation-delay: 0.3s;
680
+ }
681
+
682
+ .bte-smart-report {
683
+ animation: bte-report-rise 5.6s ease-in-out infinite;
684
+ }
685
+
686
+ .bte-report-window {
687
+ min-height: 302px;
688
+ padding: 16px;
689
+ }
690
+
691
+ .bte-report-header {
692
+ display: flex;
693
+ justify-content: space-between;
694
+ gap: 10px;
695
+ align-items: center;
696
+ margin-bottom: 12px;
697
+ }
698
+
699
+ .bte-report-header strong {
700
+ font-size: 22px;
701
+ color: var(--bte-ink);
702
+ }
703
+
704
+ .bte-report-header small {
705
+ color: var(--bte-muted);
706
+ }
707
+
708
+ .bte-mini-card {
709
+ display: flex;
710
+ justify-content: space-between;
711
+ gap: 10px;
712
+ align-items: center;
713
+ border-radius: 14px;
714
+ padding: 12px;
715
+ margin-bottom: 10px;
716
+ border: 1px solid var(--bte-line);
717
+ background: #fbfdff;
718
+ animation: bte-card-pop 4.4s ease-in-out infinite;
719
+ }
720
+
721
+ .bte-mini-card--green {
722
+ border-color: rgba(18, 128, 92, 0.2);
723
+ background: var(--bte-green-soft);
724
+ }
725
+
726
+ .bte-mini-card--amber {
727
+ border-color: rgba(154, 103, 0, 0.2);
728
+ background: var(--bte-amber-soft);
729
+ animation-delay: 0.35s;
730
+ }
731
+
732
+ .bte-mini-card span {
733
+ color: var(--bte-muted);
734
+ font-size: 13px;
735
+ }
736
+
737
+ .bte-mini-card strong {
738
+ color: var(--bte-ink);
739
+ font-size: 13px;
740
+ }
741
+
742
+ .bte-mini-chart {
743
+ height: 116px;
744
+ display: flex;
745
+ align-items: end;
746
+ gap: 10px;
747
+ padding: 14px;
748
+ border: 1px solid var(--bte-line);
749
+ border-radius: 16px;
750
+ background: #ffffff;
751
+ }
752
+
753
+ .bte-mini-chart span {
754
+ flex: 1;
755
+ min-width: 10px;
756
+ border-radius: 999px 999px 4px 4px;
757
+ background: linear-gradient(180deg, #2563eb, #12805c);
758
+ animation: bte-bar-grow 2.6s ease-in-out infinite;
759
+ }
760
+
761
+ .bte-mini-chart span:nth-child(2) { animation-delay: 0.12s; }
762
+ .bte-mini-chart span:nth-child(3) { animation-delay: 0.24s; }
763
+ .bte-mini-chart span:nth-child(4) { animation-delay: 0.36s; }
764
+ .bte-mini-chart span:nth-child(5) { animation-delay: 0.48s; }
765
+
766
+ .bte-kicker {
767
+ color: var(--bte-blue);
768
+ font-size: 12px;
769
+ font-weight: 700;
770
+ letter-spacing: 0 !important;
771
+ margin: 0 0 8px;
772
+ text-transform: uppercase;
773
+ }
774
+
775
+ .bte-status,
776
+ .bte-run-status {
777
+ display: flex;
778
+ flex-direction: column;
779
+ gap: 4px;
780
+ border: 1px solid var(--bte-line);
781
+ border-radius: 14px;
782
+ padding: 12px 14px;
783
+ background: #ffffff;
784
+ color: var(--bte-muted);
785
+ font-size: 13px;
786
+ }
787
+
788
+ .bte-status span,
789
+ .bte-run-status span {
790
+ color: var(--bte-muted);
791
+ }
792
+
793
+ .bte-status code {
794
+ color: var(--bte-blue);
795
+ }
796
+
797
+ .bte-status strong,
798
+ .bte-run-status strong {
799
+ color: var(--bte-ink);
800
+ }
801
+
802
+ .bte-run-status--success {
803
+ border-color: rgba(18, 128, 92, 0.22);
804
+ background: var(--bte-green-soft);
805
+ }
806
+
807
+ .bte-run-status--danger {
808
+ border-color: rgba(191, 52, 52, 0.24);
809
+ background: var(--bte-red-soft);
810
+ }
811
+
812
+ .bte-run-status--loading {
813
+ border-color: rgba(37, 99, 235, 0.22);
814
+ background: var(--bte-blue-soft);
815
  }
816
 
817
  button.primary {
818
+ background: #111827 !important;
819
+ border-radius: 14px !important;
820
+ min-height: 44px !important;
821
+ border: 0 !important;
822
+ box-shadow: 0 12px 26px rgba(17, 24, 39, 0.2) !important;
823
+ font-size: 0 !important;
824
+ }
825
+
826
+ button.primary::after {
827
+ content: "Extract test results";
828
+ color: #ffffff !important;
829
+ -webkit-text-fill-color: #ffffff !important;
830
+ font-size: 16px !important;
831
+ font-weight: 720 !important;
832
+ }
833
+
834
+ .bte-action {
835
+ margin-top: 12px !important;
836
+ }
837
+
838
+ .bte-uploader {
839
+ border: 0 !important;
840
+ }
841
+
842
+ .bte-shell .file-preview,
843
+ .bte-shell [data-testid="file"],
844
+ .bte-shell .upload-container,
845
+ .bte-shell .file-drop,
846
+ .bte-shell .file-dropzone,
847
+ .bte-shell .file-container,
848
+ .bte-shell .dropzone,
849
+ .bte-shell [class*="drop"],
850
+ .bte-shell [class*="upload"] {
851
+ background: linear-gradient(180deg, #fbfdff, #f4f7fb) !important;
852
+ border-color: #d8e2ee !important;
853
+ border-radius: 18px !important;
854
+ color: var(--bte-ink) !important;
855
+ }
856
+
857
+ .bte-uploader [class*="drop"],
858
+ .bte-uploader [class*="upload"] {
859
+ min-height: 220px !important;
860
+ }
861
+
862
+ .bte-selected-document {
863
+ display: grid;
864
+ grid-template-columns: 68px minmax(0, 1fr);
865
+ gap: 16px;
866
+ align-items: center;
867
+ min-height: 220px;
868
+ border: 1px solid #d8e2ee;
869
+ border-radius: 18px;
870
+ padding: 22px;
871
+ background:
872
+ linear-gradient(180deg, rgba(234, 248, 242, 0.78), rgba(255, 255, 255, 0.94)),
873
+ #ffffff;
874
+ }
875
+
876
+ .bte-selected-document h3 {
877
+ margin: 0 0 6px !important;
878
+ color: var(--bte-ink) !important;
879
+ font-size: 22px !important;
880
+ line-height: 1.15 !important;
881
+ overflow-wrap: anywhere;
882
+ }
883
+
884
+ .bte-selected-document p:last-child {
885
+ margin: 0;
886
+ color: var(--bte-muted);
887
+ font-size: 14px;
888
+ }
889
+
890
+ .bte-selected-icon {
891
+ width: 68px;
892
+ aspect-ratio: 1;
893
+ display: grid;
894
+ place-items: center;
895
+ border-radius: 18px;
896
+ border: 1px solid rgba(18, 128, 92, 0.22);
897
+ background: #ffffff;
898
+ box-shadow: 0 14px 28px rgba(18, 128, 92, 0.12);
899
+ }
900
+
901
+ .bte-selected-icon span {
902
+ width: 30px;
903
+ aspect-ratio: 0.78;
904
+ display: block;
905
+ border-radius: 7px;
906
+ border: 2px solid var(--bte-green);
907
+ position: relative;
908
+ }
909
+
910
+ .bte-selected-icon span::after {
911
+ content: "";
912
+ position: absolute;
913
+ width: 15px;
914
+ height: 8px;
915
+ left: 7px;
916
+ top: 9px;
917
+ border-left: 2px solid var(--bte-green);
918
+ border-bottom: 2px solid var(--bte-green);
919
+ transform: rotate(-45deg);
920
+ }
921
+
922
+ .bte-shell [class*="drop"] *,
923
+ .bte-shell [class*="upload"] * {
924
+ color: var(--bte-ink) !important;
925
+ -webkit-text-fill-color: var(--bte-ink) !important;
926
+ }
927
+
928
+ .bte-shell [class*="drop"] {
929
+ border-style: dashed !important;
930
+ border-width: 2px !important;
931
+ }
932
+
933
+ .bte-shell svg,
934
+ .bte-shell .icon-wrap {
935
+ color: var(--bte-blue) !important;
936
+ }
937
+
938
+ button.bte-action,
939
+ button.bte-action *,
940
+ .bte-action button,
941
+ .bte-action button * {
942
+ color: #ffffff !important;
943
+ -webkit-text-fill-color: #ffffff !important;
944
  }
945
+
946
+ .bte-uploader button,
947
+ .bte-uploader button *,
948
+ .bte-uploader [class*="drop"] *,
949
+ .bte-uploader [class*="upload"] * {
950
+ color: var(--bte-ink) !important;
951
+ -webkit-text-fill-color: var(--bte-ink) !important;
952
+ }
953
+
954
+ .bte-upload-card button.bte-action,
955
+ .bte-upload-card button.bte-action *,
956
+ .bte-upload-card .bte-action,
957
+ .bte-upload-card .bte-action * {
958
+ color: #ffffff !important;
959
+ -webkit-text-fill-color: #ffffff !important;
960
+ }
961
+
962
+ .gradio-container details {
963
+ border-color: var(--bte-line) !important;
964
+ border-radius: 14px !important;
965
+ background: rgba(255, 255, 255, 0.86) !important;
966
+ box-shadow: none !important;
967
+ }
968
+
969
+ .gradio-container details > summary {
970
+ min-height: 42px !important;
971
+ color: var(--bte-ink) !important;
972
+ font-weight: 650 !important;
973
+ }
974
+
975
+ .gradio-container footer {
976
+ display: none !important;
977
+ }
978
+
979
+ .bte-report {
980
+ border: 1px solid var(--bte-line);
981
+ border-radius: 24px;
982
+ padding: 22px;
983
+ background: rgba(255, 255, 255, 0.95);
984
+ box-shadow: 0 22px 70px rgba(17, 24, 39, 0.08);
985
+ }
986
+
987
+ .bte-report--empty {
988
+ min-height: 360px;
989
+ display: grid;
990
+ place-items: center;
991
+ text-align: center;
992
+ color: var(--bte-muted);
993
+ background:
994
+ linear-gradient(180deg, rgba(239, 246, 255, 0.55), rgba(255, 255, 255, 0.96)),
995
+ #ffffff;
996
+ }
997
+
998
+ .bte-report--empty h2 {
999
+ font-size: 32px !important;
1000
+ }
1001
+
1002
+ .bte-loading-report {
1003
+ min-height: 430px;
1004
+ display: grid;
1005
+ grid-template-columns: 180px minmax(0, 1fr);
1006
+ align-items: center;
1007
+ gap: 28px;
1008
+ overflow: hidden;
1009
+ position: relative;
1010
+ background:
1011
+ linear-gradient(120deg, rgba(239, 246, 255, 0.92), rgba(255, 255, 255, 0.96)),
1012
+ #ffffff;
1013
+ }
1014
+
1015
+ .bte-loading-report::after {
1016
+ content: "";
1017
+ position: absolute;
1018
+ inset: 0;
1019
+ transform: translateX(-100%);
1020
+ background: linear-gradient(90deg, transparent, rgba(37, 99, 235, 0.08), transparent);
1021
+ animation: bte-scan 2.2s ease-in-out infinite;
1022
+ }
1023
+
1024
+ .bte-loader-orbit {
1025
+ width: 148px;
1026
+ aspect-ratio: 1;
1027
+ position: relative;
1028
+ display: grid;
1029
+ place-items: center;
1030
+ border-radius: 50%;
1031
+ background: #ffffff;
1032
+ border: 1px solid #dbeafe;
1033
+ box-shadow: 0 18px 48px rgba(37, 99, 235, 0.14);
1034
+ }
1035
+
1036
+ .bte-loader-orbit span {
1037
+ width: 86px;
1038
+ aspect-ratio: 1;
1039
+ display: block;
1040
+ border-radius: 50%;
1041
+ background:
1042
+ radial-gradient(circle at 50% 50%, #ffffff 0 42%, transparent 43%),
1043
+ conic-gradient(from 30deg, var(--bte-blue), var(--bte-green), #dbeafe, var(--bte-blue));
1044
+ animation: bte-spin 1.4s linear infinite;
1045
+ }
1046
+
1047
+ .bte-loader-orbit i {
1048
+ position: absolute;
1049
+ width: 12px;
1050
+ aspect-ratio: 1;
1051
+ border-radius: 50%;
1052
+ background: var(--bte-green);
1053
+ box-shadow: 0 0 0 8px rgba(18, 128, 92, 0.12);
1054
+ animation: bte-pulse 1.4s ease-in-out infinite;
1055
+ }
1056
+
1057
+ .bte-loading-copy {
1058
+ position: relative;
1059
+ z-index: 1;
1060
+ }
1061
+
1062
+ .bte-loading-copy h2 {
1063
+ margin-top: 0 !important;
1064
+ }
1065
+
1066
+ .bte-loading-copy p:last-child {
1067
+ max-width: 620px;
1068
+ color: var(--bte-muted);
1069
+ }
1070
+
1071
+ .bte-loading-stack {
1072
+ grid-column: 1 / -1;
1073
+ position: relative;
1074
+ z-index: 1;
1075
+ display: grid;
1076
+ gap: 10px;
1077
+ }
1078
+
1079
+ .bte-loading-stack div {
1080
+ display: grid;
1081
+ grid-template-columns: 140px minmax(0, 1fr);
1082
+ gap: 14px;
1083
+ align-items: center;
1084
+ padding: 14px;
1085
+ border: 1px solid var(--bte-line);
1086
+ border-radius: 16px;
1087
+ background: rgba(255, 255, 255, 0.74);
1088
+ }
1089
+
1090
+ .bte-loading-stack span,
1091
+ .bte-loading-stack strong {
1092
+ height: 12px;
1093
+ border-radius: 999px;
1094
+ background: linear-gradient(90deg, #edf3fb, #dce9fb, #edf3fb);
1095
+ background-size: 220% 100%;
1096
+ animation: bte-shimmer 1.35s ease-in-out infinite;
1097
+ }
1098
+
1099
+ .bte-loading-stack strong {
1100
+ height: 16px;
1101
+ }
1102
+
1103
+ @keyframes bte-spin {
1104
+ to { transform: rotate(360deg); }
1105
+ }
1106
+
1107
+ @keyframes bte-pulse {
1108
+ 0%, 100% { transform: scale(0.86); opacity: 0.72; }
1109
+ 50% { transform: scale(1.08); opacity: 1; }
1110
+ }
1111
+
1112
+ @keyframes bte-scan {
1113
+ 0% { transform: translateX(-100%); }
1114
+ 48%, 100% { transform: translateX(100%); }
1115
+ }
1116
+
1117
+ @keyframes bte-shimmer {
1118
+ 0% { background-position: 180% 0; }
1119
+ 100% { background-position: -40% 0; }
1120
+ }
1121
+
1122
+ @keyframes bte-doc-float {
1123
+ 0%, 100% { transform: translateY(0) rotate(-1.5deg); }
1124
+ 50% { transform: translateY(-8px) rotate(-0.5deg); }
1125
+ }
1126
+
1127
+ @keyframes bte-scan-doc {
1128
+ 0% { transform: translateY(-98px) rotate(-6deg); opacity: 0; }
1129
+ 18% { opacity: 1; }
1130
+ 72% { opacity: 1; }
1131
+ 100% { transform: translateY(238px) rotate(-6deg); opacity: 0; }
1132
+ }
1133
+
1134
+ @keyframes bte-flow-line {
1135
+ 0%, 100% { transform: scaleX(0.42); opacity: 0.45; }
1136
+ 50% { transform: scaleX(1); opacity: 1; }
1137
+ }
1138
+
1139
+ @keyframes bte-flow-tile {
1140
+ 0%, 100% { transform: translateY(0); opacity: 0.74; }
1141
+ 50% { transform: translateY(-6px); opacity: 1; }
1142
+ }
1143
+
1144
+ @keyframes bte-report-rise {
1145
+ 0%, 100% { transform: translateY(5px); }
1146
+ 50% { transform: translateY(-7px); }
1147
+ }
1148
+
1149
+ @keyframes bte-card-pop {
1150
+ 0%, 100% { transform: translateY(0); }
1151
+ 50% { transform: translateY(-3px); }
1152
+ }
1153
+
1154
+ @keyframes bte-bar-grow {
1155
+ 0%, 100% { transform: scaleY(0.86); transform-origin: bottom; }
1156
+ 50% { transform: scaleY(1); transform-origin: bottom; }
1157
+ }
1158
+
1159
+ .bte-report-hero {
1160
+ display: flex;
1161
+ align-items: center;
1162
+ justify-content: space-between;
1163
+ gap: 18px;
1164
+ padding: 6px 0 20px;
1165
+ }
1166
+
1167
+ .bte-report-hero p {
1168
+ color: var(--bte-muted);
1169
+ margin: 0;
1170
+ max-width: 620px;
1171
+ }
1172
+
1173
+ .tabs {
1174
+ border: 0 !important;
1175
+ }
1176
+
1177
+ .tab-nav {
1178
+ border-bottom: 1px solid var(--bte-line) !important;
1179
+ }
1180
+
1181
+ .tab-nav button {
1182
+ color: var(--bte-muted) !important;
1183
+ font-weight: 650 !important;
1184
+ }
1185
+
1186
+ .tab-nav button.selected {
1187
+ color: var(--bte-blue) !important;
1188
+ }
1189
+
1190
+ .bte-score {
1191
+ width: 136px;
1192
+ min-width: 136px;
1193
+ aspect-ratio: 1;
1194
+ border-radius: 50%;
1195
+ display: grid;
1196
+ place-items: center;
1197
+ background: linear-gradient(180deg, var(--bte-blue-soft), #ffffff);
1198
+ border: 1px solid #dbeafe;
1199
+ }
1200
+
1201
+ .bte-score span {
1202
+ display: block;
1203
+ font-size: 40px;
1204
+ font-weight: 760;
1205
+ }
1206
+
1207
+ .bte-score small {
1208
+ color: var(--bte-muted);
1209
+ }
1210
+
1211
+ .bte-metrics {
1212
+ display: grid;
1213
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1214
+ gap: 12px;
1215
+ margin-bottom: 14px;
1216
+ }
1217
+
1218
+ .bte-metric {
1219
+ border: 1px solid var(--bte-line);
1220
+ border-radius: 16px;
1221
+ padding: 14px;
1222
+ background: #fbfdff;
1223
+ }
1224
+
1225
+ .bte-metric span {
1226
+ display: block;
1227
+ font-size: 28px;
1228
+ font-weight: 760;
1229
+ }
1230
+
1231
+ .bte-metric strong,
1232
+ .bte-metric small {
1233
+ display: block;
1234
+ }
1235
+
1236
+ .bte-metric small {
1237
+ color: var(--bte-muted);
1238
+ }
1239
+
1240
+ .bte-status-strip {
1241
+ display: flex;
1242
+ flex-wrap: wrap;
1243
+ gap: 8px;
1244
+ margin-bottom: 18px;
1245
+ }
1246
+
1247
+ .bte-pill {
1248
+ border-radius: 999px;
1249
+ padding: 8px 11px;
1250
+ background: #f3f6fa;
1251
+ color: var(--bte-muted);
1252
+ font-size: 13px;
1253
+ }
1254
+
1255
+ .bte-pill--high,
1256
+ .bte-pill--abnormal {
1257
+ background: var(--bte-red-soft);
1258
+ color: var(--bte-red);
1259
+ }
1260
+
1261
+ .bte-pill--low {
1262
+ background: var(--bte-amber-soft);
1263
+ color: var(--bte-amber);
1264
+ }
1265
+
1266
+ .bte-pill--normal {
1267
+ background: var(--bte-green-soft);
1268
+ color: var(--bte-green);
1269
+ }
1270
+
1271
+ .bte-report-grid {
1272
+ display: grid;
1273
+ grid-template-columns: minmax(0, 1fr) 300px;
1274
+ gap: 16px;
1275
+ }
1276
+
1277
+ .bte-marker-list {
1278
+ display: grid;
1279
+ gap: 10px;
1280
+ }
1281
+
1282
+ .bte-marker {
1283
+ border: 1px solid var(--bte-line);
1284
+ border-radius: 16px;
1285
+ background: #ffffff;
1286
+ overflow: hidden;
1287
+ }
1288
+
1289
+ .bte-marker summary {
1290
+ cursor: pointer;
1291
+ display: grid;
1292
+ grid-template-columns: minmax(0, 1fr) auto auto;
1293
+ align-items: center;
1294
+ gap: 14px;
1295
+ padding: 14px;
1296
+ list-style: none;
1297
+ }
1298
+
1299
+ .bte-marker summary::-webkit-details-marker {
1300
+ display: none;
1301
+ }
1302
+
1303
+ .bte-marker-main strong,
1304
+ .bte-marker-main small,
1305
+ .bte-marker-value strong,
1306
+ .bte-marker-value small {
1307
+ display: block;
1308
+ }
1309
+
1310
+ .bte-marker-main small,
1311
+ .bte-marker-value small {
1312
+ color: var(--bte-muted);
1313
+ font-size: 12px;
1314
+ }
1315
+
1316
+ .bte-marker-value {
1317
+ text-align: right;
1318
+ }
1319
+
1320
+ .bte-marker-value strong {
1321
+ font-size: 20px;
1322
+ }
1323
+
1324
+ .bte-marker-status {
1325
+ border-radius: 999px;
1326
+ padding: 7px 10px;
1327
+ min-width: 82px;
1328
+ text-align: center;
1329
+ font-size: 12px;
1330
+ background: #f3f6fa;
1331
+ color: var(--bte-muted);
1332
+ }
1333
+
1334
+ .bte-marker--high .bte-marker-status,
1335
+ .bte-marker--abnormal .bte-marker-status {
1336
+ background: var(--bte-red-soft);
1337
+ color: var(--bte-red);
1338
+ }
1339
+
1340
+ .bte-marker--low .bte-marker-status {
1341
+ background: var(--bte-amber-soft);
1342
+ color: var(--bte-amber);
1343
+ }
1344
+
1345
+ .bte-marker--normal .bte-marker-status {
1346
+ background: var(--bte-green-soft);
1347
+ color: var(--bte-green);
1348
+ }
1349
+
1350
+ .bte-marker-body {
1351
+ display: grid;
1352
+ grid-template-columns: 180px minmax(0, 1fr);
1353
+ gap: 14px;
1354
+ padding: 0 14px 14px;
1355
+ color: var(--bte-muted);
1356
+ }
1357
+
1358
+ .bte-confidence {
1359
+ height: 8px;
1360
+ border-radius: 999px;
1361
+ overflow: hidden;
1362
+ background: #edf1f6;
1363
+ margin: 8px 0 4px;
1364
+ }
1365
+
1366
+ .bte-confidence i {
1367
+ display: block;
1368
+ height: 100%;
1369
+ border-radius: inherit;
1370
+ background: linear-gradient(90deg, var(--bte-blue), var(--bte-green));
1371
+ }
1372
+
1373
+ .bte-marker blockquote {
1374
+ margin: 0;
1375
+ padding: 12px;
1376
+ border-radius: 12px;
1377
+ background: #f7f9fc;
1378
+ color: var(--bte-muted);
1379
+ }
1380
+
1381
+ .bte-report-aside {
1382
+ border: 1px solid var(--bte-line);
1383
+ border-radius: 18px;
1384
+ padding: 16px;
1385
+ background: #fbfdff;
1386
+ align-self: start;
1387
+ }
1388
+
1389
+ .bte-report-aside h3 {
1390
+ margin-top: 0;
1391
+ }
1392
+
1393
+ .bte-report-aside ul {
1394
+ padding-left: 18px;
1395
+ color: var(--bte-muted);
1396
+ }
1397
+
1398
+ .bte-disclaimer {
1399
+ display: grid;
1400
+ gap: 4px;
1401
+ margin-top: 14px;
1402
+ padding: 12px;
1403
+ border-radius: 14px;
1404
+ background: var(--bte-blue-soft);
1405
+ color: var(--bte-muted);
1406
+ }
1407
+
1408
+ .bte-disclaimer strong {
1409
+ color: var(--bte-ink);
1410
+ }
1411
+
1412
+ @media (max-width: 860px) {
1413
+ body,
1414
+ html,
1415
+ gradio-app {
1416
+ overflow-x: hidden !important;
1417
+ max-width: 100vw !important;
1418
+ }
1419
+
1420
+ .gradio-container {
1421
+ width: calc(100vw - 16px) !important;
1422
+ max-width: calc(100vw - 16px) !important;
1423
+ min-width: 0 !important;
1424
+ margin: 0 !important;
1425
+ padding-left: 8px !important;
1426
+ padding-right: 8px !important;
1427
+ overflow-x: hidden !important;
1428
+ }
1429
+
1430
+ .gradio-container > *,
1431
+ .gradio-container div,
1432
+ .gradio-container .main,
1433
+ .gradio-container .contain,
1434
+ .gradio-container .wrap,
1435
+ .gradio-container .row,
1436
+ .gradio-container .column {
1437
+ max-width: 100% !important;
1438
+ min-width: 0 !important;
1439
+ }
1440
+
1441
+ .gradio-container input,
1442
+ .gradio-container textarea,
1443
+ .gradio-container button {
1444
+ max-width: 100% !important;
1445
+ min-width: 0 !important;
1446
+ }
1447
+
1448
+ .bte-title {
1449
+ padding: 22px 4px 16px;
1450
+ }
1451
+
1452
+ .bte-title h1,
1453
+ .bte-report h2 {
1454
+ font-size: 32px !important;
1455
+ overflow-wrap: anywhere;
1456
+ }
1457
+
1458
+ .bte-title p {
1459
+ font-size: 15px;
1460
+ max-width: 340px !important;
1461
+ overflow-wrap: anywhere;
1462
+ }
1463
+
1464
+ .bte-shell {
1465
+ border-radius: 18px;
1466
+ padding: 12px;
1467
+ }
1468
+
1469
+ .bte-engine {
1470
+ padding: 16px;
1471
+ }
1472
+
1473
+ .bte-formation {
1474
+ min-height: 560px;
1475
+ padding: 14px;
1476
+ border-radius: 20px;
1477
+ }
1478
+
1479
+ .bte-formation-stage {
1480
+ grid-template-columns: 1fr;
1481
+ min-height: 528px;
1482
+ gap: 12px;
1483
+ }
1484
+
1485
+ .bte-source-doc {
1486
+ min-height: 220px;
1487
+ transform: none;
1488
+ }
1489
+
1490
+ .bte-flow {
1491
+ grid-template-columns: 1fr auto 1fr;
1492
+ width: 100%;
1493
+ }
1494
+
1495
+ .bte-flow span {
1496
+ grid-column: 1 / -1;
1497
+ grid-row: 1;
1498
+ }
1499
+
1500
+ .bte-flow i,
1501
+ .bte-flow b {
1502
+ grid-row: 1;
1503
+ grid-column: 2;
1504
+ }
1505
+
1506
+ .bte-report-window {
1507
+ min-height: 244px;
1508
+ }
1509
+
1510
+ .bte-mini-chart {
1511
+ height: 80px;
1512
+ }
1513
+
1514
+ .bte-uploader [class*="drop"],
1515
+ .bte-uploader [class*="upload"] {
1516
+ min-height: 210px !important;
1517
+ }
1518
+
1519
+ .bte-selected-document {
1520
+ grid-template-columns: 1fr;
1521
+ min-height: 210px;
1522
+ text-align: center;
1523
+ justify-items: center;
1524
+ }
1525
+
1526
+ .bte-status span,
1527
+ .bte-run-status span,
1528
+ .bte-report p,
1529
+ .bte-report small {
1530
+ overflow-wrap: anywhere;
1531
+ }
1532
+
1533
+ .bte-report {
1534
+ border-radius: 20px;
1535
+ padding: 16px;
1536
+ }
1537
+
1538
+ .bte-loading-report {
1539
+ grid-template-columns: 1fr;
1540
+ min-height: 520px;
1541
+ text-align: center;
1542
+ justify-items: center;
1543
+ }
1544
+
1545
+ .bte-loading-stack {
1546
+ width: 100%;
1547
+ }
1548
+
1549
+ .bte-report-hero,
1550
+ .bte-report-grid {
1551
+ grid-template-columns: 1fr;
1552
+ display: grid;
1553
+ }
1554
+
1555
+ .bte-score {
1556
+ width: 110px;
1557
+ min-width: 110px;
1558
+ }
1559
+
1560
+ .bte-metrics {
1561
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1562
+ }
1563
+
1564
+ .bte-marker summary,
1565
+ .bte-marker-body {
1566
+ grid-template-columns: 1fr;
1567
+ }
1568
+
1569
+ .bte-marker-value {
1570
+ text-align: left;
1571
+ }
1572
+ }
1573
+
1574
  """
1575
 
1576
 
 
1578
  gr.HTML(
1579
  """
1580
  <section class="bte-title">
1581
+ <p class="bte-kicker">Clinical clarity from raw documents</p>
1582
  <h1>Blood Test Explainer</h1>
1583
+ <p>Upload a lab report and turn dense medical paperwork into a polished extraction report with raw markers, values, units, reference ranges, and confidence signals.</p>
1584
  </section>
1585
  """
1586
  )
1587
 
1588
+ with gr.Row(equal_height=False, elem_classes=["bte-hero-grid"]):
1589
  with gr.Column(scale=4, min_width=320):
1590
+ with gr.Group(elem_classes=["bte-shell", "bte-upload-card"]):
1591
+ with gr.Group() as upload_dropzone:
1592
+ uploaded = gr.File(
1593
+ label="Upload medical test document",
1594
+ file_count="single",
1595
+ file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp", ".tif", ".tiff", ".txt", ".csv"],
1596
+ type="filepath",
1597
+ elem_classes=["bte-uploader"],
1598
+ )
1599
+ selected_document = gr.HTML(selected_document_html(), visible=False)
1600
+ run_button = gr.Button("Extract test results", variant="primary", elem_classes=["bte-action"])
1601
+
1602
+ with gr.Column(scale=5, min_width=360):
1603
+ gr.HTML(formation_animation_html())
1604
+
1605
+ with gr.Accordion("Advanced extraction settings", open=False):
1606
+ with gr.Group(elem_classes=["bte-shell", "bte-engine", "bte-engine-compact"]):
1607
+ api_url = gr.Textbox(
1608
+ label="OpenBMB chat completions URL",
1609
+ value=os.getenv("OPENBMB_API_URL") or DEFAULT_API_URL,
1610
+ interactive=True,
1611
+ )
1612
+ model = gr.Textbox(
1613
+ label="Model",
1614
+ value=os.getenv("OPENBMB_MODEL") or DEFAULT_MODEL,
1615
+ interactive=True,
1616
+ )
1617
+ api_key_override = gr.Textbox(
1618
+ label="OpenBMB API key override",
1619
+ type="password",
1620
+ placeholder="Optional. Paste exact token here for local testing.",
1621
+ interactive=True,
1622
+ )
1623
+ max_pages = gr.Slider(1, 8, value=3, step=1, label="PDF pages to inspect")
1624
+ gr.HTML(api_status(), elem_classes=["bte-status"])
 
 
 
 
 
1625
 
1626
+ status = gr.HTML(_status_html("Ready", "Upload a lab report to create the first interactive extraction draft."))
 
1627
 
1628
+ uploaded.change(
1629
+ upload_state,
1630
+ inputs=[uploaded],
1631
+ outputs=[upload_dropzone, selected_document],
1632
+ show_progress="hidden",
1633
+ )
1634
+
1635
+ gr.HTML('<div id="bte-report-anchor" class="bte-report-anchor"></div>', visible=True)
1636
+ with gr.Group(visible=False) as report_panel:
1637
+ with gr.Tabs():
1638
+ with gr.Tab("Report"):
1639
+ report = gr.HTML(empty_report_html())
1640
+ with gr.Tab("Values"):
1641
+ results = gr.Dataframe(
1642
+ headers=TABLE_HEADERS,
1643
+ datatype=["str", "str", "str", "str", "str", "number", "str"],
1644
+ row_count=(0, "dynamic"),
1645
+ column_count=(len(TABLE_HEADERS), "fixed"),
1646
+ label="Extracted values",
1647
+ wrap=True,
1648
+ )
1649
+ with gr.Tab("JSON"):
1650
+ structured_json = gr.Code(language="json", label="Normalized extraction")
1651
+ with gr.Tab("Raw"):
1652
+ raw_response = gr.Textbox(label="Raw model response", lines=12)
1653
 
1654
  run_button.click(
1655
+ show_processing,
1656
+ outputs=[status, report_panel, report],
1657
+ scroll_to_output=True,
1658
+ show_progress="hidden",
1659
+ js="""
1660
+ () => {
1661
+ setTimeout(() => {
1662
+ document.getElementById("bte-report-anchor")?.scrollIntoView({
1663
+ behavior: "smooth",
1664
+ block: "start"
1665
+ });
1666
+ }, 450);
1667
+ return [];
1668
+ }
1669
+ """,
1670
+ ).then(
1671
  extract_lab_values,
1672
  inputs=[uploaded, api_url, model, api_key_override, max_pages],
1673
+ outputs=[results, status, structured_json, raw_response, report, report_panel],
1674
+ scroll_to_output=True,
1675
+ show_progress="hidden",
1676
  )
1677
 
1678