igerasimov commited on
Commit
f89b574
·
1 Parent(s): 08942f5

UI adjustments

Browse files
Files changed (2) hide show
  1. src/gcmd_classifier/ui/gradio_app.py +127 -20
  2. tests/test_ui.py +87 -15
src/gcmd_classifier/ui/gradio_app.py CHANGED
@@ -34,6 +34,46 @@ _DEMO_NO_TOPIC_RESPONSE = {
34
  ModelClientFactory = Callable[[ModelSettings], ModelClient]
35
  ClassifyFunction = Callable[..., ArticleResult]
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  def create_demo(
39
  *,
@@ -45,6 +85,7 @@ def create_demo(
45
  """Construct the Gradio demo without moving classification logic into the UI."""
46
  gr = _import_gradio()
47
  active_settings = settings or ModelSettings.from_environment()
 
48
 
49
  def submit(title: str, abstract: str, doi: str | None, year: int | float | None):
50
  return run_demo_classification(
@@ -58,26 +99,53 @@ def create_demo(
58
  classify_func=classify_func,
59
  )
60
 
61
- return gr.Interface(
62
- fn=submit,
63
- inputs=[
64
- gr.Textbox(label="Title", lines=1, placeholder="Enter article title"),
65
- gr.Textbox(label="Abstract", lines=8, placeholder="Paste article abstract"),
66
- gr.Textbox(label="DOI (optional)", lines=1, placeholder="10.xxxx/example"),
67
- gr.Number(label="Year (optional)", precision=0, value=None),
68
- ],
69
- outputs=[
70
- gr.Markdown(label="Classification Summary"),
71
- gr.JSON(label="Detailed ArticleResult JSON"),
72
- gr.JSON(label="Warnings and Errors"),
73
- ],
74
  title="GCMD Science Keyword Classifier MVP",
75
- description=(
 
 
 
76
  "Lightweight demonstration UI. Classification is performed by the tested "
77
- "pipeline service; the UI only collects input and displays structured output."
78
- ),
79
- allow_flagging="never",
80
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
 
83
  def run_demo_classification(
@@ -90,7 +158,7 @@ def run_demo_classification(
90
  settings: ModelSettings | None = None,
91
  model_client_factory: ModelClientFactory | None = None,
92
  classify_func: ClassifyFunction | None = None,
93
- ) -> tuple[str, dict[str, Any], dict[str, Any]]:
94
  """Run one UI classification request and return display-ready values."""
95
  active_settings = settings or ModelSettings.from_environment()
96
  warnings: list[OutputWarning] = []
@@ -118,7 +186,8 @@ def run_demo_classification(
118
  if warnings:
119
  result = result.model_copy(update={"warnings": (*warnings, *result.warnings)})
120
  return (
121
- format_classification_summary(result),
 
122
  result.model_dump(mode="json"),
123
  diagnostics_payload(result),
124
  )
@@ -126,6 +195,7 @@ def run_demo_classification(
126
  error = OutputError(code=exc.__class__.__name__, message=str(exc), stage="ui")
127
  return (
128
  f"**Processing failed**\n\n{_md(error.message)}",
 
129
  {"error": error.model_dump(mode="json")},
130
  {
131
  "warnings": [warning.model_dump(mode="json") for warning in warnings],
@@ -134,6 +204,43 @@ def run_demo_classification(
134
  )
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  def format_classification_summary(result: ArticleResult) -> str:
138
  """Format the important ArticleResult fields for the demo summary panel."""
139
  outcome = None if result.classification_outcome is None else result.classification_outcome.value
 
34
  ModelClientFactory = Callable[[ModelSettings], ModelClient]
35
  ClassifyFunction = Callable[..., ArticleResult]
36
 
37
+ CLASSIFICATION_TABLE_COLUMNS = [
38
+ "GCMD Keyword Path",
39
+ "Evidence",
40
+ "Support",
41
+ "Review Required",
42
+ ]
43
+
44
+ _GRADIO_CSS = """
45
+ .input-panel textarea,
46
+ .input-panel input {
47
+ font-size: 0.95rem;
48
+ }
49
+ .summary-panel {
50
+ border: 1px solid var(--border-color-primary);
51
+ border-radius: 8px;
52
+ padding: 0.75rem 1rem;
53
+ }
54
+ .results-table {
55
+ width: 100%;
56
+ overflow-x: auto;
57
+ }
58
+ .results-table table {
59
+ min-width: 1100px;
60
+ }
61
+ .results-table th,
62
+ .results-table td {
63
+ vertical-align: top;
64
+ }
65
+ .results-table td:nth-child(1),
66
+ .results-table td:nth-child(2) {
67
+ min-width: 420px;
68
+ white-space: normal;
69
+ }
70
+ .results-table td:nth-child(3),
71
+ .results-table td:nth-child(4) {
72
+ min-width: 140px;
73
+ white-space: nowrap;
74
+ }
75
+ """
76
+
77
 
78
  def create_demo(
79
  *,
 
85
  """Construct the Gradio demo without moving classification logic into the UI."""
86
  gr = _import_gradio()
87
  active_settings = settings or ModelSettings.from_environment()
88
+ mode_label = "fake/demo" if active_settings.provider == "fake" else active_settings.provider
89
 
90
  def submit(title: str, abstract: str, doi: str | None, year: int | float | None):
91
  return run_demo_classification(
 
99
  classify_func=classify_func,
100
  )
101
 
102
+ with gr.Blocks(
 
 
 
 
 
 
 
 
 
 
 
 
103
  title="GCMD Science Keyword Classifier MVP",
104
+ css=_GRADIO_CSS,
105
+ ) as demo:
106
+ gr.Markdown(
107
+ "# GCMD Science Keyword Classifier MVP\n"
108
  "Lightweight demonstration UI. Classification is performed by the tested "
109
+ "pipeline service; the UI only collects input and displays structured output.\n\n"
110
+ f"**Mode:** `{mode_label}` **Model:** `{active_settings.model_name}`"
111
+ )
112
+ with gr.Group(elem_classes=["input-panel"]):
113
+ title_input = gr.Textbox(label="Title", lines=2, placeholder="Enter article title")
114
+ abstract_input = gr.Textbox(
115
+ label="Abstract",
116
+ lines=8,
117
+ placeholder="Paste article abstract",
118
+ )
119
+ with gr.Row():
120
+ doi_input = gr.Textbox(
121
+ label="DOI (optional)",
122
+ lines=1,
123
+ placeholder="10.xxxx/example",
124
+ )
125
+ year_input = gr.Number(label="Year (optional)", precision=0, value=None)
126
+ submit_button = gr.Button("Classify", variant="primary")
127
+
128
+ gr.Markdown("## Result Summary")
129
+ summary_output = gr.Markdown(elem_classes=["summary-panel"])
130
+ gr.Markdown("## Classification Results")
131
+ table_output = gr.Dataframe(
132
+ headers=CLASSIFICATION_TABLE_COLUMNS,
133
+ datatype=["str"] * len(CLASSIFICATION_TABLE_COLUMNS),
134
+ interactive=False,
135
+ wrap=False,
136
+ label="Accepted Classifications",
137
+ elem_classes=["results-table"],
138
+ )
139
+ with gr.Row():
140
+ json_output = gr.JSON(label="Detailed ArticleResult JSON")
141
+ diagnostics_output = gr.JSON(label="Warnings and Errors")
142
+
143
+ submit_button.click(
144
+ fn=submit,
145
+ inputs=[title_input, abstract_input, doi_input, year_input],
146
+ outputs=[summary_output, table_output, json_output, diagnostics_output],
147
+ )
148
+ return demo
149
 
150
 
151
  def run_demo_classification(
 
158
  settings: ModelSettings | None = None,
159
  model_client_factory: ModelClientFactory | None = None,
160
  classify_func: ClassifyFunction | None = None,
161
+ ) -> tuple[str, list[list[object]], dict[str, Any], dict[str, Any]]:
162
  """Run one UI classification request and return display-ready values."""
163
  active_settings = settings or ModelSettings.from_environment()
164
  warnings: list[OutputWarning] = []
 
186
  if warnings:
187
  result = result.model_copy(update={"warnings": (*warnings, *result.warnings)})
188
  return (
189
+ format_compact_summary(result),
190
+ classification_table_rows(result),
191
  result.model_dump(mode="json"),
192
  diagnostics_payload(result),
193
  )
 
195
  error = OutputError(code=exc.__class__.__name__, message=str(exc), stage="ui")
196
  return (
197
  f"**Processing failed**\n\n{_md(error.message)}",
198
+ [],
199
  {"error": error.model_dump(mode="json")},
200
  {
201
  "warnings": [warning.model_dump(mode="json") for warning in warnings],
 
204
  )
205
 
206
 
207
+ def format_compact_summary(result: ArticleResult) -> str:
208
+ """Format compact run metadata above the full-width classification table."""
209
+ metadata = result.processing_metadata
210
+ outcome = None if result.classification_outcome is None else result.classification_outcome.value
211
+ review_count = sum(record.review_required for record in result.classifications)
212
+ lines = [
213
+ f"**processing_status:** `{result.processing_status.value}`",
214
+ f"**classification_outcome:** `{outcome}`",
215
+ f"**model_provider:** `{metadata.model_provider or ''}`",
216
+ f"**model_name:** `{metadata.model_name or ''}`",
217
+ f"**classifications:** `{len(result.classifications)}`",
218
+ f"**requiring_review:** `{review_count}`",
219
+ ]
220
+ if result.classification_outcome is ArticleClassificationOutcome.NOT_CLASSIFIED:
221
+ lines.append(f"**no_classification_reason:** {_md(result.no_classification_reason or '')}")
222
+ if result.errors:
223
+ lines.append(f"**errors:** `{len(result.errors)}`")
224
+ if result.warnings:
225
+ lines.append(f"**warnings:** `{len(result.warnings)}`")
226
+ return " \n".join(lines)
227
+
228
+
229
+ def classification_table_rows(result: ArticleResult) -> list[list[object]]:
230
+ """Return full-width table rows for accepted classifications."""
231
+ return [classification_table_row(record) for record in result.classifications]
232
+
233
+
234
+ def classification_table_row(record: ClassificationRecord) -> list[object]:
235
+ """Return one display row for the Gradio results Dataframe."""
236
+ return [
237
+ record.canonical_path,
238
+ record.classifier_evidence or "",
239
+ "" if record.support_type is None else record.support_type.value,
240
+ record.review_required,
241
+ ]
242
+
243
+
244
  def format_classification_summary(result: ArticleResult) -> str:
245
  """Format the important ArticleResult fields for the demo summary panel."""
246
  outcome = None if result.classification_outcome is None else result.classification_outcome.value
tests/test_ui.py CHANGED
@@ -28,29 +28,47 @@ PROTOTYPE_PATH = Path("prototype/app_hf_poc.py")
28
 
29
 
30
  class FakeComponent:
31
- def __init__(self, **kwargs: Any) -> None:
 
 
 
32
  self.kwargs = kwargs
 
 
33
 
 
 
34
 
35
- class FakeInterface:
36
- launched = 0
37
 
 
38
  def __init__(self, **kwargs: Any) -> None:
39
  self.kwargs = kwargs
40
- self.fn = kwargs["fn"]
41
- self.inputs = kwargs["inputs"]
42
- self.outputs = kwargs["outputs"]
 
 
 
 
 
 
 
43
 
44
  def launch(self) -> None:
45
  type(self).launched += 1
46
 
47
 
48
  def _fake_gradio_module() -> SimpleNamespace:
 
49
  return SimpleNamespace(
50
- Interface=FakeInterface,
 
 
51
  Textbox=FakeComponent,
52
  Number=FakeComponent,
53
  Markdown=FakeComponent,
 
 
54
  JSON=FakeComponent,
55
  )
56
 
@@ -123,9 +141,15 @@ def test_create_demo_constructs_gradio_interface(monkeypatch) -> None:
123
 
124
  demo = gradio_app.create_demo(vocabulary=load_vocabulary(FIXTURE_PATH))
125
 
126
- assert isinstance(demo, FakeInterface)
127
- assert len(demo.inputs) == 4
128
- assert len(demo.outputs) == 3
 
 
 
 
 
 
129
 
130
 
131
  def test_ui_module_does_not_classify_or_call_model_at_import_time(monkeypatch) -> None:
@@ -142,14 +166,14 @@ def test_ui_module_does_not_classify_or_call_model_at_import_time(monkeypatch) -
142
 
143
 
144
  def test_root_app_imports_without_launching_server(monkeypatch) -> None:
145
- FakeInterface.launched = 0
146
  monkeypatch.setitem(sys.modules, "gradio", _fake_gradio_module())
147
  sys.modules.pop("app", None)
148
 
149
  module = importlib.import_module("app")
150
 
151
- assert isinstance(module.demo, FakeInterface)
152
- assert FakeInterface.launched == 0
153
 
154
 
155
  def test_root_app_is_thin_launcher_without_classification_logic() -> None:
@@ -171,7 +195,7 @@ def test_ui_calls_pipeline_service(monkeypatch) -> None:
171
 
172
  monkeypatch.setattr(gradio_app.pipeline_service, "classify_article", fake_classify)
173
 
174
- summary, payload, diagnostics = gradio_app.run_demo_classification(
175
  Title="A title",
176
  Abstract="",
177
  DOI="10.example/ui",
@@ -182,6 +206,7 @@ def test_ui_calls_pipeline_service(monkeypatch) -> None:
182
 
183
  assert calls["count"] == 1
184
  assert "not_classified" in summary
 
185
  assert payload["DOI"] == "10.example/ui"
186
  assert diagnostics["errors"] == []
187
 
@@ -204,6 +229,52 @@ def test_classified_result_includes_uuid_and_canonical_path() -> None:
204
  assert "The article discusses atmospheric carbon dioxide." in summary
205
 
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  def test_errors_and_warnings_are_displayed() -> None:
208
  article = ArticleRecord(
209
  DOI="10.example/diagnostics",
@@ -232,7 +303,7 @@ def test_empty_abstract_is_accepted_by_ui_input_path(monkeypatch) -> None:
232
 
233
  monkeypatch.setattr(gradio_app.pipeline_service, "classify_article", fake_classify)
234
 
235
- summary, payload, _ = gradio_app.run_demo_classification(
236
  Title="Title only",
237
  Abstract="",
238
  DOI="10.example/title-only",
@@ -242,6 +313,7 @@ def test_empty_abstract_is_accepted_by_ui_input_path(monkeypatch) -> None:
242
  )
243
 
244
  assert seen["abstract"] == ""
 
245
  assert payload["Abstract"] == ""
246
  assert "not_classified" in summary
247
 
 
28
 
29
 
30
  class FakeComponent:
31
+ instances: list[FakeComponent] = []
32
+
33
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
34
+ self.args = args
35
  self.kwargs = kwargs
36
+ self.click_kwargs: dict[str, Any] | None = None
37
+ type(self).instances.append(self)
38
 
39
+ def click(self, **kwargs: Any) -> None:
40
+ self.click_kwargs = kwargs
41
 
 
 
42
 
43
+ class FakeLayout:
44
  def __init__(self, **kwargs: Any) -> None:
45
  self.kwargs = kwargs
46
+
47
+ def __enter__(self):
48
+ return self
49
+
50
+ def __exit__(self, exc_type, exc, traceback) -> None:
51
+ return None
52
+
53
+
54
+ class FakeBlocks(FakeLayout):
55
+ launched = 0
56
 
57
  def launch(self) -> None:
58
  type(self).launched += 1
59
 
60
 
61
  def _fake_gradio_module() -> SimpleNamespace:
62
+ FakeComponent.instances = []
63
  return SimpleNamespace(
64
+ Blocks=FakeBlocks,
65
+ Group=FakeLayout,
66
+ Row=FakeLayout,
67
  Textbox=FakeComponent,
68
  Number=FakeComponent,
69
  Markdown=FakeComponent,
70
+ Dataframe=FakeComponent,
71
+ Button=FakeComponent,
72
  JSON=FakeComponent,
73
  )
74
 
 
141
 
142
  demo = gradio_app.create_demo(vocabulary=load_vocabulary(FIXTURE_PATH))
143
 
144
+ assert isinstance(demo, FakeBlocks)
145
+ assert "results-table" in gradio_app._GRADIO_CSS
146
+ dataframes = [
147
+ component
148
+ for component in FakeComponent.instances
149
+ if component.kwargs.get("headers") == gradio_app.CLASSIFICATION_TABLE_COLUMNS
150
+ ]
151
+ assert dataframes
152
+ assert dataframes[0].kwargs["wrap"] is False
153
 
154
 
155
  def test_ui_module_does_not_classify_or_call_model_at_import_time(monkeypatch) -> None:
 
166
 
167
 
168
  def test_root_app_imports_without_launching_server(monkeypatch) -> None:
169
+ FakeBlocks.launched = 0
170
  monkeypatch.setitem(sys.modules, "gradio", _fake_gradio_module())
171
  sys.modules.pop("app", None)
172
 
173
  module = importlib.import_module("app")
174
 
175
+ assert isinstance(module.demo, FakeBlocks)
176
+ assert FakeBlocks.launched == 0
177
 
178
 
179
  def test_root_app_is_thin_launcher_without_classification_logic() -> None:
 
195
 
196
  monkeypatch.setattr(gradio_app.pipeline_service, "classify_article", fake_classify)
197
 
198
+ summary, table, payload, diagnostics = gradio_app.run_demo_classification(
199
  Title="A title",
200
  Abstract="",
201
  DOI="10.example/ui",
 
206
 
207
  assert calls["count"] == 1
208
  assert "not_classified" in summary
209
+ assert table == []
210
  assert payload["DOI"] == "10.example/ui"
211
  assert diagnostics["errors"] == []
212
 
 
229
  assert "The article discusses atmospheric carbon dioxide." in summary
230
 
231
 
232
+ def test_compact_summary_includes_status_model_and_review_counts() -> None:
233
+ article = ArticleRecord(DOI="10.example/summary", Title="Summary", Year=2025, Abstract="Text.")
234
+ result = _classified_result(article).model_copy(
235
+ update={
236
+ "processing_metadata": ProcessingMetadata(
237
+ model_provider="fake",
238
+ model_name="fake-model",
239
+ ),
240
+ "classifications": (
241
+ _classification_record().model_copy(update={"review_required": True}),
242
+ ),
243
+ }
244
+ )
245
+
246
+ summary = gradio_app.format_compact_summary(result)
247
+
248
+ assert "processing_status" in summary
249
+ assert "classification_outcome" in summary
250
+ assert "model_provider:** `fake`" in summary
251
+ assert "model_name:** `fake-model`" in summary
252
+ assert "classifications:** `1`" in summary
253
+ assert "requiring_review:** `1`" in summary
254
+
255
+
256
+ def test_classification_table_rows_include_only_demo_relevant_fields() -> None:
257
+ article = ArticleRecord(DOI="10.example/table", Title="Table", Year=2025, Abstract="Text.")
258
+ result = _classified_result(article)
259
+
260
+ rows = gradio_app.classification_table_rows(result)
261
+
262
+ assert gradio_app.CLASSIFICATION_TABLE_COLUMNS == [
263
+ "GCMD Keyword Path",
264
+ "Evidence",
265
+ "Support",
266
+ "Review Required",
267
+ ]
268
+ assert rows == [
269
+ [
270
+ "ATMOSPHERE > ATMOSPHERIC CHEMISTRY > CARBON > CARBON DIOXIDE",
271
+ "The article discusses atmospheric carbon dioxide.",
272
+ "explicit",
273
+ False,
274
+ ]
275
+ ]
276
+
277
+
278
  def test_errors_and_warnings_are_displayed() -> None:
279
  article = ArticleRecord(
280
  DOI="10.example/diagnostics",
 
303
 
304
  monkeypatch.setattr(gradio_app.pipeline_service, "classify_article", fake_classify)
305
 
306
+ summary, table, payload, _ = gradio_app.run_demo_classification(
307
  Title="Title only",
308
  Abstract="",
309
  DOI="10.example/title-only",
 
313
  )
314
 
315
  assert seen["abstract"] == ""
316
+ assert table == []
317
  assert payload["Abstract"] == ""
318
  assert "not_classified" in summary
319