Ankushbl6 commited on
Commit
2ceae3c
·
verified ·
1 Parent(s): c00d839

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +84 -48
src/streamlit_app.py CHANGED
@@ -12,7 +12,7 @@ os.environ["HF_HOME"] = os.path.join(HOME, ".cache", "huggingface")
12
  os.environ["HUGGINGFACE_HUB_CACHE"] = os.path.join(HOME, ".cache", "huggingface", "hub")
13
  os.environ["TRANSFORMERS_CACHE"] = os.path.join(HOME, ".cache", "huggingface", "transformers")
14
  os.environ["TORCH_HOME"] = os.path.join(HOME, ".cache", "torch")
15
- # Streamlit reads Path.home() so ~/.streamlit will resolve under our HOME
16
  for d in [
17
  HOME,
18
  os.environ["XDG_CACHE_HOME"],
@@ -32,8 +32,6 @@ import base64
32
  import hashlib
33
  from io import BytesIO
34
  from pathlib import Path
35
- from typing import Dict, Any
36
-
37
  import pandas as pd
38
  from PIL import Image
39
  import streamlit as st
@@ -100,11 +98,11 @@ def _get_hf_token():
100
  pass
101
  return None, None
102
 
103
- def hf_login(token: str) -> tuple[bool, str | None]:
104
  try:
105
  try:
106
  login(token, add_to_git_credential=False)
107
- except TypeError: # older hub
108
  login(token)
109
  return True, None
110
  except Exception as e:
@@ -128,7 +126,6 @@ if hf_token is None:
128
  st.session_state.logged_in = True
129
  st.toast("Logged in successfully.", icon="✅")
130
  else:
131
- # Using env/secrets token; login caches to our writable HOME
132
  ok, err = hf_login(hf_token)
133
  if not ok:
134
  st.error(f"Failed to log in with {hf_src or 'unknown'} token: {err}")
@@ -168,11 +165,12 @@ def load_model_and_processor(hf_model_id: str, task_prompt: str):
168
  f"Original error: {e}"
169
  )
170
 
 
171
  model.eval()
172
- device = __import__("torch").device("cuda" if __import__("torch").cuda.is_available() else "cpu")
173
  model.to(device)
174
 
175
- with __import__("torch").no_grad():
176
  decoder_input_ids = processor.tokenizer(
177
  task_prompt,
178
  add_special_tokens=False,
@@ -181,7 +179,7 @@ def load_model_and_processor(hf_model_id: str, task_prompt: str):
181
 
182
  return processor, model, device, decoder_input_ids
183
 
184
- def run_inference_on_image(image: Image.Image, processor, model, device, decoder_input_ids):
185
  import torch
186
  pixel_values = processor(images=image, return_tensors="pt").pixel_values.to(device)
187
  gen_kwargs = dict(
@@ -211,7 +209,7 @@ def run_inference_on_image(image: Image.Image, processor, model, device, decoder
211
  return pred_dict
212
 
213
  def map_prediction_to_ui(pred):
214
- import json, re
215
  from collections import defaultdict
216
 
217
  def safe_json_load(s):
@@ -229,7 +227,8 @@ def map_prediction_to_ui(pred):
229
  subs, stack, start = [], [], None
230
  for i, ch in enumerate(s):
231
  if ch == "{":
232
- if not stack: start = i
 
233
  stack.append("{")
234
  elif ch == "}":
235
  if stack:
@@ -244,21 +243,28 @@ def map_prediction_to_ui(pred):
244
  return None
245
 
246
  def clean_number(x):
247
- if x is None: return 0.0
248
- if isinstance(x, (int, float)): return float(x)
 
 
249
  s = re.sub(r"[,\s]", "", str(x).strip())
250
  s = re.sub(r"[^\d\.\-]", "", s)
251
- if s in ("", ".", "-", "-."): return 0.0
252
- try: return float(s)
253
- except Exception: return 0.0
 
 
 
254
 
255
  def collect_keys(obj, out):
256
  if isinstance(obj, dict):
257
  for k, v in obj.items():
258
  lk = str(k).strip().lower()
259
- out[lk].append(v); collect_keys(v, out)
 
260
  elif isinstance(obj, list):
261
- for it in obj: collect_keys(it, out)
 
262
 
263
  def collect_lists_of_dicts(obj, out_lists):
264
  if isinstance(obj, dict):
@@ -275,7 +281,8 @@ def map_prediction_to_ui(pred):
275
  collect_lists_of_dicts(it, out_lists)
276
 
277
  def map_item_dict(it):
278
- if not isinstance(it, dict): return None
 
279
  lower = {str(k).strip().lower(): v for k, v in it.items()}
280
  desc = (lower.get("descriptions") or lower.get("description") or lower.get("desc") or lower.get("item") or "")
281
  qty = lower.get("quantity") or lower.get("qty") or lower.get("count") or ""
@@ -293,8 +300,10 @@ def map_prediction_to_ui(pred):
293
  }
294
 
295
  parsed = safe_json_load(pred) if isinstance(pred, str) else pred
296
- if parsed is None and isinstance(pred, str): parsed = None
297
- if parsed is None and not isinstance(pred, dict): parsed = pred
 
 
298
 
299
  ui = {
300
  "Invoice Number": "", "Invoice Date": "", "Due Date": "", "Currency": "",
@@ -315,10 +324,13 @@ def map_prediction_to_ui(pred):
315
  lk = k.strip().lower()
316
  if lk in key_map:
317
  for v in key_map[lk]:
318
- if v is None: continue
319
- if isinstance(v, (dict, list)): return v
 
 
320
  s = str(v).strip()
321
- if s != "": return s
 
322
  return None
323
 
324
  ui["Invoice Number"] = pick_first("invoice_no", "invoice_number", "invoiceid", "invoice id") or ""
@@ -333,23 +345,26 @@ def map_prediction_to_ui(pred):
333
  for bk in ("bank_name","bank_account_number","bank_acc_name","bank_iban","bank_swift","bank_routing","bank_branch","iban"):
334
  val = pick_first(bk, bk.replace("bank_", ""))
335
  if val:
336
- bank["bank_iban" if bk=="iban" else bk] = str(val)
337
  ui["Bank Details"] = bank
338
 
339
- def _num(*keys):
340
  v = pick_first(*keys) or 0.0
341
- try: return float(str(v).replace(",",""))
342
- except Exception: return 0.0
 
 
343
 
344
- ui["Subtotal"] = _num("subtotal","sub_total","sub total")
345
- ui["Tax Percentage"] = _num("tax_rate","tax_percentage","tax pct","tax percentage")
346
- ui["Total Tax"] = _num("tax_amount","tax","total_tax")
347
- ui["Total Amount"] = _num("total_amount","grand_total","total","amount")
348
  ui["Currency"] = (pick_first("currency") or "").strip()
349
 
350
  items_rows = []
351
  def list_looks_like_items(lst):
352
- if not isinstance(lst, list) or not lst or not isinstance(lst[0], dict): return False
 
353
  expected = {"descriptions","description","desc","item","quantity","qty","amount","unit_price","line_total","line total"}
354
  return bool(expected.intersection({str(k).strip().lower() for k in lst[0].keys()}))
355
 
@@ -357,14 +372,17 @@ def map_prediction_to_ui(pred):
357
  if list_looks_like_items(cand):
358
  for it in cand:
359
  row = map_item_dict(it)
360
- if row is not None: items_rows.append(row)
361
- if items_rows: break
 
 
362
 
363
  if not items_rows and isinstance(parsed, dict):
364
  single_keys = {k.strip().lower() for k in parsed.keys()}
365
  if single_keys.intersection({"descriptions","description","desc","item","quantity","qty","unit_price","unit price","price","amount","line_total","line total","sku","tax","tax_amount"}):
366
  row = map_item_dict(parsed)
367
- if row is not None: items_rows.append(row)
 
368
 
369
  if not items_rows:
370
  for _, vals in key_map.items():
@@ -373,7 +391,8 @@ def map_prediction_to_ui(pred):
373
  lower_keys = {str(x).strip().lower() for x in v.keys()}
374
  if lower_keys.intersection({"descriptions","description","desc","amount","line_total","quantity","qty","unit_price"}):
375
  row = map_item_dict(v)
376
- if row is not None: items_rows.append(row)
 
377
 
378
  if not items_rows:
379
  desc, amt, qty, unit_price = pick_first("descriptions","description"), pick_first("amount","line_total"), pick_first("quantity","qty"), pick_first("unit_price","price")
@@ -430,16 +449,19 @@ def flatten_invoice_to_rows(invoice_data) -> list:
430
 
431
  if not line_items:
432
  row = base_invoice_info()
433
- for k in EXPECTED_BANK_FIELDS: row[k] = bank_details.get(k, "")
 
434
  row.update({
435
  "Item Description": "", "Item Quantity": 0, "Item Unit Price": 0.0,
436
  "Item Amount": 0.0, "Item Tax": 0.0, "Item Line Total": 0.0,
437
  })
438
- rows.append(row); return rows
 
439
 
440
  for item in line_items:
441
  row = base_invoice_info()
442
- for k in EXPECTED_BANK_FIELDS: row[k] = bank_details.get(k, "")
 
443
  row.update({
444
  "Item Description": item.get("Description", "") if isinstance(item, dict) else "",
445
  "Item Quantity": item.get("Quantity", 0) if isinstance(item, dict) else 0,
@@ -641,11 +663,19 @@ elif len(st.session_state.batch_results) > 0:
641
  except Exception as e:
642
  st.error(f"Re-run failed: {e}")
643
 
644
- # All editors inside one form (prevents reruns while typing)
645
  with st.form(f"edit_form_{selected_hash}", clear_on_submit=False):
 
 
 
 
 
 
 
 
646
  tabs = st.tabs(["Invoice Details", "Sender/Recipient info", "Bank Details", "Line Items"])
647
 
648
- # Invoice Details
649
  with tabs[0]:
650
  form_data['Invoice Number'] = st.text_input("Invoice Number", value=form_data.get('Invoice Number', ''), key=f"invoice_number_{selected_hash}")
651
  form_data['Invoice Date'] = st.text_input("Invoice Date", value=str(form_data.get('Invoice Date', '')).strip(), key=f"invoice_date_text_{selected_hash}")
@@ -662,13 +692,14 @@ elif len(st.session_state.batch_results) > 0:
662
  form_data['Total Tax'] = safe_number_input("Total Tax", form_data.get('Total Tax', 0.0), f"total_tax_{selected_hash}")
663
  form_data['Total Amount'] = safe_number_input("Total Amount", form_data.get('Total Amount', 0.0), f"total_amount_{selected_hash}")
664
 
665
- # Sender / Recipient
666
  with tabs[1]:
667
  sender_name = st.text_input("Sender Name*", value=form_data.get('Sender Name', ''), key=f"sender_name_{selected_hash}")
668
  sender_address = st.text_area("Sender Address*", value=form_data.get('Sender Address', ''), key=f"sender_address_{selected_hash}")
669
  recipient_name = st.text_input("Recipient Name*", value=form_data.get('Recipient Name', ''), key=f"recipient_name_{selected_hash}")
670
  recipient_address = st.text_area("Recipient Address*", value=form_data.get('Recipient Address', ''), key=f"recipient_address_{selected_hash}")
671
 
 
672
  if st.button("⇄ Swap", help="Swap sender and recipient information", key=f"swap_{selected_hash}"):
673
  sender_name, recipient_name = recipient_name, sender_name
674
  sender_address, recipient_address = recipient_address, sender_address
@@ -678,7 +709,7 @@ elif len(st.session_state.batch_results) > 0:
678
  form_data['Recipient Name'] = recipient_name
679
  form_data['Recipient Address'] = recipient_address
680
 
681
- # Bank Details
682
  with tabs[2]:
683
  bank_details = form_data.get("Bank Details", {}) if isinstance(form_data.get("Bank Details", {}), dict) else {}
684
  bank_name = st.text_input("Bank Name", value=bank_details.get('bank_name', ''), key=f"bank_name_{selected_hash}")
@@ -698,7 +729,7 @@ elif len(st.session_state.batch_results) > 0:
698
  form_data["Bank Details"]["bank_routing"] = bank_routing
699
  form_data["Bank Details"]["bank_branch"] = bank_branch
700
 
701
- # Line Items (fixed height)
702
  with tabs[3]:
703
  editor_key = f"item_editor_{selected_hash}"
704
  item_rows = form_data.get('Itemized Data', []) or []
@@ -740,12 +771,17 @@ elif len(st.session_state.batch_results) > 0:
740
  height=420, # fixes growth/shrink reflow
741
  )
742
 
743
- # Submit (no rerun during typing)
744
- submitted = st.form_submit_button("💾 Save Edits for This File")
745
- if submitted:
 
 
 
 
746
  form_data['Itemized Data'] = edited_df.to_dict('records')
747
  st.session_state.batch_results[selected_hash]["edited_data"] = form_data
748
  st.toast(f"Saved edits for {current['file_name']}", icon="💾")
 
749
 
750
  # Per-file CSV download
751
  st.markdown("---")
 
12
  os.environ["HUGGINGFACE_HUB_CACHE"] = os.path.join(HOME, ".cache", "huggingface", "hub")
13
  os.environ["TRANSFORMERS_CACHE"] = os.path.join(HOME, ".cache", "huggingface", "transformers")
14
  os.environ["TORCH_HOME"] = os.path.join(HOME, ".cache", "torch")
15
+ # Create directories so first write doesn't fail
16
  for d in [
17
  HOME,
18
  os.environ["XDG_CACHE_HOME"],
 
32
  import hashlib
33
  from io import BytesIO
34
  from pathlib import Path
 
 
35
  import pandas as pd
36
  from PIL import Image
37
  import streamlit as st
 
98
  pass
99
  return None, None
100
 
101
+ def hf_login(token):
102
  try:
103
  try:
104
  login(token, add_to_git_credential=False)
105
+ except TypeError: # older hub versions
106
  login(token)
107
  return True, None
108
  except Exception as e:
 
126
  st.session_state.logged_in = True
127
  st.toast("Logged in successfully.", icon="✅")
128
  else:
 
129
  ok, err = hf_login(hf_token)
130
  if not ok:
131
  st.error(f"Failed to log in with {hf_src or 'unknown'} token: {err}")
 
165
  f"Original error: {e}"
166
  )
167
 
168
+ import torch
169
  model.eval()
170
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
171
  model.to(device)
172
 
173
+ with torch.no_grad():
174
  decoder_input_ids = processor.tokenizer(
175
  task_prompt,
176
  add_special_tokens=False,
 
179
 
180
  return processor, model, device, decoder_input_ids
181
 
182
+ def run_inference_on_image(image, processor, model, device, decoder_input_ids):
183
  import torch
184
  pixel_values = processor(images=image, return_tensors="pt").pixel_values.to(device)
185
  gen_kwargs = dict(
 
209
  return pred_dict
210
 
211
  def map_prediction_to_ui(pred):
212
+ import re, json
213
  from collections import defaultdict
214
 
215
  def safe_json_load(s):
 
227
  subs, stack, start = [], [], None
228
  for i, ch in enumerate(s):
229
  if ch == "{":
230
+ if not stack:
231
+ start = i
232
  stack.append("{")
233
  elif ch == "}":
234
  if stack:
 
243
  return None
244
 
245
  def clean_number(x):
246
+ if x is None:
247
+ return 0.0
248
+ if isinstance(x, (int, float)):
249
+ return float(x)
250
  s = re.sub(r"[,\s]", "", str(x).strip())
251
  s = re.sub(r"[^\d\.\-]", "", s)
252
+ if s in ("", ".", "-", "-."):
253
+ return 0.0
254
+ try:
255
+ return float(s)
256
+ except Exception:
257
+ return 0.0
258
 
259
  def collect_keys(obj, out):
260
  if isinstance(obj, dict):
261
  for k, v in obj.items():
262
  lk = str(k).strip().lower()
263
+ out[lk].append(v)
264
+ collect_keys(v, out)
265
  elif isinstance(obj, list):
266
+ for it in obj:
267
+ collect_keys(it, out)
268
 
269
  def collect_lists_of_dicts(obj, out_lists):
270
  if isinstance(obj, dict):
 
281
  collect_lists_of_dicts(it, out_lists)
282
 
283
  def map_item_dict(it):
284
+ if not isinstance(it, dict):
285
+ return None
286
  lower = {str(k).strip().lower(): v for k, v in it.items()}
287
  desc = (lower.get("descriptions") or lower.get("description") or lower.get("desc") or lower.get("item") or "")
288
  qty = lower.get("quantity") or lower.get("qty") or lower.get("count") or ""
 
300
  }
301
 
302
  parsed = safe_json_load(pred) if isinstance(pred, str) else pred
303
+ if parsed is None and isinstance(pred, str):
304
+ parsed = None
305
+ if parsed is None and not isinstance(pred, dict):
306
+ parsed = pred
307
 
308
  ui = {
309
  "Invoice Number": "", "Invoice Date": "", "Due Date": "", "Currency": "",
 
324
  lk = k.strip().lower()
325
  if lk in key_map:
326
  for v in key_map[lk]:
327
+ if v is None:
328
+ continue
329
+ if isinstance(v, (dict, list)):
330
+ return v
331
  s = str(v).strip()
332
+ if s != "":
333
+ return s
334
  return None
335
 
336
  ui["Invoice Number"] = pick_first("invoice_no", "invoice_number", "invoiceid", "invoice id") or ""
 
345
  for bk in ("bank_name","bank_account_number","bank_acc_name","bank_iban","bank_swift","bank_routing","bank_branch","iban"):
346
  val = pick_first(bk, bk.replace("bank_", ""))
347
  if val:
348
+ bank["bank_iban" if bk == "iban" else bk] = str(val)
349
  ui["Bank Details"] = bank
350
 
351
+ def _num(*keys):
352
  v = pick_first(*keys) or 0.0
353
+ try:
354
+ return float(str(v).replace(",", ""))
355
+ except Exception:
356
+ return 0.0
357
 
358
+ ui["Subtotal"] = _num("subtotal", "sub_total", "sub total")
359
+ ui["Tax Percentage"] = _num("tax_rate", "tax_percentage", "tax pct", "tax percentage")
360
+ ui["Total Tax"] = _num("tax_amount", "tax", "total_tax")
361
+ ui["Total Amount"] = _num("total_amount", "grand_total", "total", "amount")
362
  ui["Currency"] = (pick_first("currency") or "").strip()
363
 
364
  items_rows = []
365
  def list_looks_like_items(lst):
366
+ if not isinstance(lst, list) or not lst or not isinstance(lst[0], dict):
367
+ return False
368
  expected = {"descriptions","description","desc","item","quantity","qty","amount","unit_price","line_total","line total"}
369
  return bool(expected.intersection({str(k).strip().lower() for k in lst[0].keys()}))
370
 
 
372
  if list_looks_like_items(cand):
373
  for it in cand:
374
  row = map_item_dict(it)
375
+ if row is not None:
376
+ items_rows.append(row)
377
+ if items_rows:
378
+ break
379
 
380
  if not items_rows and isinstance(parsed, dict):
381
  single_keys = {k.strip().lower() for k in parsed.keys()}
382
  if single_keys.intersection({"descriptions","description","desc","item","quantity","qty","unit_price","unit price","price","amount","line_total","line total","sku","tax","tax_amount"}):
383
  row = map_item_dict(parsed)
384
+ if row is not None:
385
+ items_rows.append(row)
386
 
387
  if not items_rows:
388
  for _, vals in key_map.items():
 
391
  lower_keys = {str(x).strip().lower() for x in v.keys()}
392
  if lower_keys.intersection({"descriptions","description","desc","amount","line_total","quantity","qty","unit_price"}):
393
  row = map_item_dict(v)
394
+ if row is not None:
395
+ items_rows.append(row)
396
 
397
  if not items_rows:
398
  desc, amt, qty, unit_price = pick_first("descriptions","description"), pick_first("amount","line_total"), pick_first("quantity","qty"), pick_first("unit_price","price")
 
449
 
450
  if not line_items:
451
  row = base_invoice_info()
452
+ for k in EXPECTED_BANK_FIELDS:
453
+ row[k] = bank_details.get(k, "")
454
  row.update({
455
  "Item Description": "", "Item Quantity": 0, "Item Unit Price": 0.0,
456
  "Item Amount": 0.0, "Item Tax": 0.0, "Item Line Total": 0.0,
457
  })
458
+ rows.append(row)
459
+ return rows
460
 
461
  for item in line_items:
462
  row = base_invoice_info()
463
+ for k in EXPECTED_BANK_FIELDS:
464
+ row[k] = bank_details.get(k, "")
465
  row.update({
466
  "Item Description": item.get("Description", "") if isinstance(item, dict) else "",
467
  "Item Quantity": item.get("Quantity", 0) if isinstance(item, dict) else 0,
 
663
  except Exception as e:
664
  st.error(f"Re-run failed: {e}")
665
 
666
+ # --------- All editors inside one form (prevents reruns while typing)
667
  with st.form(f"edit_form_{selected_hash}", clear_on_submit=False):
668
+ # Top bar submit (ensures Streamlit sees a submit in the form)
669
+ top_bar = st.columns([1, 1, 6])
670
+ with top_bar[0]:
671
+ save_top = st.form_submit_button("💾 Save", use_container_width=True)
672
+ with top_bar[1]:
673
+ # Optional no-op to satisfy UX; not used
674
+ st.form_submit_button("↩️ Discard (no-op)", use_container_width=True)
675
+
676
  tabs = st.tabs(["Invoice Details", "Sender/Recipient info", "Bank Details", "Line Items"])
677
 
678
+ # --- Invoice Details ---
679
  with tabs[0]:
680
  form_data['Invoice Number'] = st.text_input("Invoice Number", value=form_data.get('Invoice Number', ''), key=f"invoice_number_{selected_hash}")
681
  form_data['Invoice Date'] = st.text_input("Invoice Date", value=str(form_data.get('Invoice Date', '')).strip(), key=f"invoice_date_text_{selected_hash}")
 
692
  form_data['Total Tax'] = safe_number_input("Total Tax", form_data.get('Total Tax', 0.0), f"total_tax_{selected_hash}")
693
  form_data['Total Amount'] = safe_number_input("Total Amount", form_data.get('Total Amount', 0.0), f"total_amount_{selected_hash}")
694
 
695
+ # --- Sender / Recipient ---
696
  with tabs[1]:
697
  sender_name = st.text_input("Sender Name*", value=form_data.get('Sender Name', ''), key=f"sender_name_{selected_hash}")
698
  sender_address = st.text_area("Sender Address*", value=form_data.get('Sender Address', ''), key=f"sender_address_{selected_hash}")
699
  recipient_name = st.text_input("Recipient Name*", value=form_data.get('Recipient Name', ''), key=f"recipient_name_{selected_hash}")
700
  recipient_address = st.text_area("Recipient Address*", value=form_data.get('Recipient Address', ''), key=f"recipient_address_{selected_hash}")
701
 
702
+ # This button won’t submit; it flags the swap action for this run
703
  if st.button("⇄ Swap", help="Swap sender and recipient information", key=f"swap_{selected_hash}"):
704
  sender_name, recipient_name = recipient_name, sender_name
705
  sender_address, recipient_address = recipient_address, sender_address
 
709
  form_data['Recipient Name'] = recipient_name
710
  form_data['Recipient Address'] = recipient_address
711
 
712
+ # --- Bank Details ---
713
  with tabs[2]:
714
  bank_details = form_data.get("Bank Details", {}) if isinstance(form_data.get("Bank Details", {}), dict) else {}
715
  bank_name = st.text_input("Bank Name", value=bank_details.get('bank_name', ''), key=f"bank_name_{selected_hash}")
 
729
  form_data["Bank Details"]["bank_routing"] = bank_routing
730
  form_data["Bank Details"]["bank_branch"] = bank_branch
731
 
732
+ # --- Line Items (fixed height) ---
733
  with tabs[3]:
734
  editor_key = f"item_editor_{selected_hash}"
735
  item_rows = form_data.get('Itemized Data', []) or []
 
771
  height=420, # fixes growth/shrink reflow
772
  )
773
 
774
+ # Bottom submit (near the editor)
775
+ save_bottom = st.form_submit_button("💾 Save Edits for This File", use_container_width=True)
776
+
777
+ # Commit once if either top or bottom button was clicked
778
+ if save_top or save_bottom:
779
+ # Make sure the table’s last edit is committed:
780
+ # (Press Enter or click outside the last cell before hitting Save)
781
  form_data['Itemized Data'] = edited_df.to_dict('records')
782
  st.session_state.batch_results[selected_hash]["edited_data"] = form_data
783
  st.toast(f"Saved edits for {current['file_name']}", icon="💾")
784
+ st.rerun() # one-time rerun to refresh everything
785
 
786
  # Per-file CSV download
787
  st.markdown("---")