vibesecurityguy commited on
Commit
d4977a3
·
verified ·
1 Parent(s): 4a6f75b

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +275 -13
app.py CHANGED
@@ -31,6 +31,9 @@ except ImportError:
31
  import json
32
  import logging
33
  import os
 
 
 
34
  from importlib import metadata, util
35
  from typing import Any
36
 
@@ -41,6 +44,7 @@ from src.veris_classifier.classifier import (
41
  answer_question,
42
  classify_incident,
43
  )
 
44
 
45
  load_dotenv()
46
  logging.basicConfig(level=logging.INFO)
@@ -276,6 +280,17 @@ textarea:focus {
276
  text-decoration: none;
277
  }
278
 
 
 
 
 
 
 
 
 
 
 
 
279
  /* Mobile */
280
  @media (max-width: 900px) {
281
  .hero-section {
@@ -299,6 +314,12 @@ textarea:focus {
299
  .hero-badges {
300
  gap: 8px;
301
  }
 
 
 
 
 
 
302
  }
303
  """
304
 
@@ -330,6 +351,8 @@ EXAMPLES_QA = [
330
  ZEROGPU_QUEUE_HINT = "No GPU was available after"
331
  SPACES_PAGE_URL = "https://huggingface.co/spaces/vibesecurityguy/veris-classifier"
332
  SPACE_HOST_URL = "https://vibesecurityguy-veris-classifier.hf.space"
 
 
333
 
334
 
335
  def _is_zerogpu_queue_timeout(err: Exception) -> bool:
@@ -355,6 +378,57 @@ def _spaces_user_logged_in(
355
  return False
356
 
357
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
 
359
  def _use_hf_model() -> bool:
360
  """Check if we should use the fine-tuned HF model."""
@@ -393,7 +467,7 @@ def classify(
393
 
394
  if use_hf:
395
  try:
396
- result = _classify_gpu(description)
397
  return json.dumps(result, indent=2)
398
  except Exception as e:
399
  logger.error(f"HF model error: {e}")
@@ -445,7 +519,7 @@ def ask(
445
 
446
  if use_hf:
447
  try:
448
- return _ask_gpu(question)
449
  except Exception as e:
450
  logger.error(f"HF model error: {e}")
451
  if _is_zerogpu_queue_timeout(e):
@@ -515,31 +589,150 @@ def _classification_rows_from_json(raw_json: str) -> list[list[str]]:
515
  return rows
516
 
517
 
518
- def _render_classification_output(raw_json: str, output_format: str):
519
- """Render classification as JSON code or flattened table."""
520
- if output_format == "Table":
521
- table_rows = _classification_rows_from_json(raw_json)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  return (
523
  gr.update(value=raw_json, visible=False),
524
- gr.update(value=table_rows, visible=True),
 
 
525
  )
 
526
  return (
527
  gr.update(value=raw_json, visible=True),
528
  gr.update(value=[], visible=False),
 
 
529
  )
530
 
531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  def classify_and_render(
533
  description: str,
534
  api_key: str,
535
  output_format: str,
 
 
536
  request: gr.Request | None = None,
537
  profile: gr.OAuthProfile | None = None,
538
  ):
539
  """Run classification and return display-ready outputs."""
540
  raw_json = classify(description, api_key, request=request, profile=profile)
541
- code_update, table_update = _render_classification_output(raw_json, output_format)
542
- return raw_json, code_update, table_update
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
 
544
 
545
  # ---------------------------------------------------------------------------
@@ -622,6 +815,7 @@ def build_app() -> gr.Blocks:
622
  theme=THEME,
623
  css=CUSTOM_CSS,
624
  ) as app:
 
625
 
626
  # --- Hero Header ---
627
  gr.HTML("""
@@ -702,6 +896,10 @@ def build_app() -> gr.Blocks:
702
  'border:1px solid #334155;color:#cbd5e1;text-decoration:none;font-weight:600;">'
703
  "Direct sign-in (if button refreshes)</a>"
704
  )
 
 
 
 
705
  else:
706
  gr.HTML("""
707
  <div class="model-banner">
@@ -760,12 +958,38 @@ def build_app() -> gr.Blocks:
760
  info="Switch between raw JSON and a flattened table view.",
761
  )
762
  last_classification_raw = gr.State("")
 
 
 
 
 
763
  classification_output = gr.Code(
764
  label="VERIS Classification (JSON)",
765
  language="json",
766
  lines=20,
767
  elem_classes=["code-output"],
768
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
769
  classification_table = gr.Dataframe(
770
  headers=["Dimension", "Field", "Value"],
771
  datatype=["str", "str", "str"],
@@ -774,6 +998,7 @@ def build_app() -> gr.Blocks:
774
  visible=False,
775
  interactive=False,
776
  wrap=True,
 
777
  label="VERIS Classification (Table)",
778
  )
779
 
@@ -787,13 +1012,43 @@ def build_app() -> gr.Blocks:
787
 
788
  classify_btn.click(
789
  fn=classify_and_render,
790
- inputs=[incident_input, api_key, output_format],
791
- outputs=[last_classification_raw, classification_output, classification_table],
 
 
 
 
 
 
 
 
 
792
  )
793
  output_format.change(
794
  fn=_render_classification_output,
795
- inputs=[last_classification_raw, output_format],
796
- outputs=[classification_output, classification_table],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
797
  )
798
 
799
  # ---- TAB 2: Q&A ----
@@ -946,6 +1201,13 @@ def build_app() -> gr.Blocks:
946
  </div>
947
  """)
948
 
 
 
 
 
 
 
 
949
  return app
950
 
951
 
 
31
  import json
32
  import logging
33
  import os
34
+ import csv
35
+ import tempfile
36
+ import time
37
  from importlib import metadata, util
38
  from typing import Any
39
 
 
44
  answer_question,
45
  classify_incident,
46
  )
47
+ from src.veris_classifier.validator import validate_classification
48
 
49
  load_dotenv()
50
  logging.basicConfig(level=logging.INFO)
 
280
  text-decoration: none;
281
  }
282
 
283
+ .status-card {
284
+ border: 1px solid #334155;
285
+ background: rgba(15, 23, 42, 0.6);
286
+ border-radius: 10px;
287
+ padding: 8px 12px;
288
+ }
289
+
290
+ #table-controls .wrap {
291
+ align-items: end;
292
+ }
293
+
294
  /* Mobile */
295
  @media (max-width: 900px) {
296
  .hero-section {
 
314
  .hero-badges {
315
  gap: 8px;
316
  }
317
+ .primary-btn {
318
+ width: 100% !important;
319
+ }
320
+ #table-controls .wrap {
321
+ gap: 8px !important;
322
+ }
323
  }
324
  """
325
 
 
351
  ZEROGPU_QUEUE_HINT = "No GPU was available after"
352
  SPACES_PAGE_URL = "https://huggingface.co/spaces/vibesecurityguy/veris-classifier"
353
  SPACE_HOST_URL = "https://vibesecurityguy-veris-classifier.hf.space"
354
+ ZEROGPU_RETRY_ATTEMPTS = 2
355
+ ZEROGPU_RETRY_DELAY_SECONDS = 3
356
 
357
 
358
  def _is_zerogpu_queue_timeout(err: Exception) -> bool:
 
378
  return False
379
 
380
 
381
+ def _session_status_markdown(
382
+ request: gr.Request | None = None,
383
+ profile: gr.OAuthProfile | None = None,
384
+ ) -> str:
385
+ """Render current Spaces auth status for the user."""
386
+ if not IS_SPACES:
387
+ return ""
388
+
389
+ if _spaces_user_logged_in(request, profile):
390
+ username = None
391
+ if profile is not None:
392
+ username = (
393
+ getattr(profile, "preferred_username", None)
394
+ or getattr(profile, "name", None)
395
+ )
396
+ if not username and request is not None:
397
+ username = getattr(request, "username", None)
398
+ if username:
399
+ return (
400
+ f"**Session status:** Logged in as `{username}`. "
401
+ "ZeroGPU requests will use your account quota."
402
+ )
403
+ return "**Session status:** Logged in. ZeroGPU requests will use your account quota."
404
+
405
+ return (
406
+ "**Session status:** Not logged in. Click sign in to attach this browser session "
407
+ "to your Hugging Face quota."
408
+ )
409
+
410
+
411
+ def _run_with_zerogpu_retry(call):
412
+ """Retry queue-timeout failures once before returning an error."""
413
+ last_error = None
414
+ for attempt in range(1, ZEROGPU_RETRY_ATTEMPTS + 1):
415
+ try:
416
+ return call()
417
+ except Exception as e:
418
+ last_error = e
419
+ if _is_zerogpu_queue_timeout(e) and attempt < ZEROGPU_RETRY_ATTEMPTS:
420
+ logger.warning(
421
+ "ZeroGPU queue timeout (attempt %d/%d). Retrying in %ss.",
422
+ attempt,
423
+ ZEROGPU_RETRY_ATTEMPTS,
424
+ ZEROGPU_RETRY_DELAY_SECONDS,
425
+ )
426
+ time.sleep(ZEROGPU_RETRY_DELAY_SECONDS)
427
+ continue
428
+ raise
429
+ raise last_error
430
+
431
+
432
 
433
  def _use_hf_model() -> bool:
434
  """Check if we should use the fine-tuned HF model."""
 
467
 
468
  if use_hf:
469
  try:
470
+ result = _run_with_zerogpu_retry(lambda: _classify_gpu(description))
471
  return json.dumps(result, indent=2)
472
  except Exception as e:
473
  logger.error(f"HF model error: {e}")
 
519
 
520
  if use_hf:
521
  try:
522
+ return _run_with_zerogpu_retry(lambda: _ask_gpu(question))
523
  except Exception as e:
524
  logger.error(f"HF model error: {e}")
525
  if _is_zerogpu_queue_timeout(e):
 
589
  return rows
590
 
591
 
592
+ def _validation_summary_markdown(raw_json: str) -> str:
593
+ """Build validation summary for the classification output."""
594
+ try:
595
+ parsed = json.loads(raw_json)
596
+ except Exception:
597
+ return ""
598
+
599
+ if not isinstance(parsed, dict) or parsed.get("error"):
600
+ return "**Validation:** Skipped."
601
+
602
+ result = validate_classification(parsed)
603
+ lines = [f"**Validation:** {'Passed' if result.valid else 'Issues found'}"]
604
+ if result.errors:
605
+ lines.append("**Errors**")
606
+ lines.extend(f"- {err}" for err in result.errors[:8])
607
+ if len(result.errors) > 8:
608
+ lines.append(f"- ... {len(result.errors) - 8} more")
609
+ if result.warnings:
610
+ lines.append("**Warnings**")
611
+ lines.extend(f"- {warn}" for warn in result.warnings[:8])
612
+ if len(result.warnings) > 8:
613
+ lines.append(f"- ... {len(result.warnings) - 8} more")
614
+ return "\n".join(lines)
615
+
616
+
617
+ def _filter_classification_rows(
618
+ rows: list[list[str]],
619
+ dimension_filter: str,
620
+ errors_only: bool,
621
+ ) -> list[list[str]]:
622
+ """Filter table rows by dimension and optionally error-only rows."""
623
+ filtered: list[list[str]] = []
624
+ for row in rows:
625
+ if len(row) != 3:
626
+ continue
627
+ dimension, field, value = row
628
+
629
+ if dimension_filter != "All" and dimension != dimension_filter:
630
+ continue
631
+
632
+ if errors_only:
633
+ blob = f"{dimension} {field} {value}".lower()
634
+ if "error" not in blob:
635
+ continue
636
+
637
+ filtered.append(row)
638
+ return filtered
639
+
640
+
641
+ def _render_classification_output(
642
+ raw_json: str,
643
+ output_format: str,
644
+ all_rows: list[list[str]],
645
+ dimension_filter: str,
646
+ errors_only: bool,
647
+ ):
648
+ """Render classification as JSON code or filtered table."""
649
+ filtered_rows = _filter_classification_rows(all_rows, dimension_filter, errors_only)
650
+ show_table = output_format == "Table"
651
+
652
+ if show_table:
653
  return (
654
  gr.update(value=raw_json, visible=False),
655
+ gr.update(value=filtered_rows, visible=True),
656
+ gr.update(visible=True),
657
+ gr.update(visible=True, interactive=bool(filtered_rows)),
658
  )
659
+
660
  return (
661
  gr.update(value=raw_json, visible=True),
662
  gr.update(value=[], visible=False),
663
+ gr.update(visible=False),
664
+ gr.update(visible=False, interactive=False),
665
  )
666
 
667
 
668
+ def _apply_table_filters(
669
+ all_rows: list[list[str]],
670
+ dimension_filter: str,
671
+ errors_only: bool,
672
+ ):
673
+ """Apply table-only filters without re-running inference."""
674
+ filtered_rows = _filter_classification_rows(all_rows, dimension_filter, errors_only)
675
+ return (
676
+ gr.update(value=filtered_rows),
677
+ gr.update(interactive=bool(filtered_rows)),
678
+ )
679
+
680
+
681
+ def _build_filtered_csv(
682
+ all_rows: list[list[str]],
683
+ dimension_filter: str,
684
+ errors_only: bool,
685
+ ):
686
+ """Create downloadable CSV file for filtered rows."""
687
+ filtered_rows = _filter_classification_rows(all_rows, dimension_filter, errors_only)
688
+ if not filtered_rows:
689
+ return gr.update(value=None, visible=False)
690
+
691
+ with tempfile.NamedTemporaryFile(
692
+ mode="w",
693
+ suffix=".csv",
694
+ delete=False,
695
+ newline="",
696
+ encoding="utf-8",
697
+ ) as tmp:
698
+ writer = csv.writer(tmp)
699
+ writer.writerow(["Dimension", "Field", "Value"])
700
+ writer.writerows(filtered_rows)
701
+ csv_path = tmp.name
702
+
703
+ return gr.update(value=csv_path, visible=True)
704
+
705
+
706
  def classify_and_render(
707
  description: str,
708
  api_key: str,
709
  output_format: str,
710
+ dimension_filter: str,
711
+ errors_only: bool,
712
  request: gr.Request | None = None,
713
  profile: gr.OAuthProfile | None = None,
714
  ):
715
  """Run classification and return display-ready outputs."""
716
  raw_json = classify(description, api_key, request=request, profile=profile)
717
+ all_rows = _classification_rows_from_json(raw_json)
718
+ validation_md = _validation_summary_markdown(raw_json)
719
+ code_update, table_update, controls_update, export_btn_update = _render_classification_output(
720
+ raw_json,
721
+ output_format,
722
+ all_rows,
723
+ dimension_filter,
724
+ errors_only,
725
+ )
726
+ return (
727
+ raw_json,
728
+ all_rows,
729
+ validation_md,
730
+ code_update,
731
+ table_update,
732
+ controls_update,
733
+ export_btn_update,
734
+ gr.update(value=None, visible=False),
735
+ )
736
 
737
 
738
  # ---------------------------------------------------------------------------
 
815
  theme=THEME,
816
  css=CUSTOM_CSS,
817
  ) as app:
818
+ session_status = None
819
 
820
  # --- Hero Header ---
821
  gr.HTML("""
 
896
  'border:1px solid #334155;color:#cbd5e1;text-decoration:none;font-weight:600;">'
897
  "Direct sign-in (if button refreshes)</a>"
898
  )
899
+ session_status = gr.Markdown(
900
+ value="**Session status:** Checking...",
901
+ elem_classes=["status-card"],
902
+ )
903
  else:
904
  gr.HTML("""
905
  <div class="model-banner">
 
958
  info="Switch between raw JSON and a flattened table view.",
959
  )
960
  last_classification_raw = gr.State("")
961
+ classification_rows = gr.State([])
962
+ validation_output = gr.Markdown(
963
+ label="Validation",
964
+ value="*Validation summary will appear after classification.*",
965
+ )
966
  classification_output = gr.Code(
967
  label="VERIS Classification (JSON)",
968
  language="json",
969
  lines=20,
970
  elem_classes=["code-output"],
971
  )
972
+ with gr.Row(visible=False, elem_id="table-controls") as table_controls:
973
+ dimension_filter = gr.Dropdown(
974
+ choices=["All", "Actor", "Action", "Asset", "Attribute", "Error", "General"],
975
+ value="All",
976
+ label="Filter Dimension",
977
+ )
978
+ errors_only = gr.Checkbox(
979
+ value=False,
980
+ label="Errors Only",
981
+ )
982
+ export_csv_btn = gr.Button(
983
+ "Generate CSV",
984
+ size="sm",
985
+ interactive=False,
986
+ visible=False,
987
+ )
988
+ csv_file = gr.File(
989
+ label="Download Filtered CSV",
990
+ visible=False,
991
+ interactive=False,
992
+ )
993
  classification_table = gr.Dataframe(
994
  headers=["Dimension", "Field", "Value"],
995
  datatype=["str", "str", "str"],
 
998
  visible=False,
999
  interactive=False,
1000
  wrap=True,
1001
+ max_height=500,
1002
  label="VERIS Classification (Table)",
1003
  )
1004
 
 
1012
 
1013
  classify_btn.click(
1014
  fn=classify_and_render,
1015
+ inputs=[incident_input, api_key, output_format, dimension_filter, errors_only],
1016
+ outputs=[
1017
+ last_classification_raw,
1018
+ classification_rows,
1019
+ validation_output,
1020
+ classification_output,
1021
+ classification_table,
1022
+ table_controls,
1023
+ export_csv_btn,
1024
+ csv_file,
1025
+ ],
1026
  )
1027
  output_format.change(
1028
  fn=_render_classification_output,
1029
+ inputs=[
1030
+ last_classification_raw,
1031
+ output_format,
1032
+ classification_rows,
1033
+ dimension_filter,
1034
+ errors_only,
1035
+ ],
1036
+ outputs=[classification_output, classification_table, table_controls, export_csv_btn],
1037
+ )
1038
+ dimension_filter.change(
1039
+ fn=_apply_table_filters,
1040
+ inputs=[classification_rows, dimension_filter, errors_only],
1041
+ outputs=[classification_table, export_csv_btn],
1042
+ )
1043
+ errors_only.change(
1044
+ fn=_apply_table_filters,
1045
+ inputs=[classification_rows, dimension_filter, errors_only],
1046
+ outputs=[classification_table, export_csv_btn],
1047
+ )
1048
+ export_csv_btn.click(
1049
+ fn=_build_filtered_csv,
1050
+ inputs=[classification_rows, dimension_filter, errors_only],
1051
+ outputs=[csv_file],
1052
  )
1053
 
1054
  # ---- TAB 2: Q&A ----
 
1201
  </div>
1202
  """)
1203
 
1204
+ if IS_SPACES and session_status is not None:
1205
+ app.load(
1206
+ fn=_session_status_markdown,
1207
+ outputs=[session_status],
1208
+ queue=False,
1209
+ )
1210
+
1211
  return app
1212
 
1213