oberbics commited on
Commit
6227cca
·
verified ·
1 Parent(s): 8bf8409

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +116 -245
app.py CHANGED
@@ -347,124 +347,84 @@ def extract_info(template, text):
347
  print(f"Error in extract_info: {e}\n{trace}")
348
  return f"❌ Fehler: {str(e)}", "{}"
349
  @spaces.GPU
350
-
351
- # Instead of using folium directly with Gradio's HTML component,
352
- # try this alternative approach using Plotly for maps
353
-
354
- def create_map_plotly(df, location_col):
355
- """
356
- Creates an interactive map using Plotly instead of Folium
357
- which should be more stable in Gradio applications
358
- """
359
- import plotly.graph_objects as go
360
- import pandas as pd
361
 
362
- # Track processing for debugging
363
- print(f"Processing {len(df)} rows with location column: {location_col}")
 
 
 
 
 
 
 
 
364
 
365
- # Initialize geocoder and tracking variables
366
  geocoder = SafeGeocoder()
 
 
367
  processed_count = 0
368
- all_lats = []
369
- all_lons = []
370
- hover_texts = []
371
 
372
- # Process each row
 
 
373
  for idx, row in df.iterrows():
374
  if pd.isna(row[location_col]):
375
  continue
376
 
377
  location = str(row[location_col]).strip()
378
 
379
- # Build additional info for hover text
 
 
 
380
  additional_info = ""
381
  for col in df.columns:
382
  if col != location_col and not pd.isna(row[col]):
383
  additional_info += f"<br><b>{col}:</b> {row[col]}"
384
 
385
- # Split location if it contains multiple comma-separated places
386
  try:
387
  locations = [loc.strip() for loc in location.split(',') if loc.strip()]
388
  if not locations:
389
  locations = [location]
390
  except Exception as e:
391
- print(f"Error splitting location '{location}': {str(e)}")
 
392
  locations = [location]
 
 
 
 
393
 
394
- # Process each individual location
395
  for loc in locations:
396
  try:
397
- # Get coordinates
 
 
 
398
  point = geocoder.get_coords(loc)
399
 
400
- if point:
401
- lat, lon = point
402
- all_lats.append(lat)
403
- all_lons.append(lon)
404
-
405
- # Create hover text with location name and info
406
- hover_text = f"<b>{loc}</b>{additional_info}"
407
- hover_texts.append(hover_text)
408
 
409
- processed_count += 1
410
  except Exception as e:
411
- print(f"Error processing location {loc}: {str(e)}")
 
 
 
 
 
 
 
412
 
413
- # Create Plotly map
414
- if len(all_lats) > 0:
415
- fig = go.Figure()
416
-
417
- # Add scatter mapbox points
418
- fig.add_trace(go.Scattermapbox(
419
- lat=all_lats,
420
- lon=all_lons,
421
- mode='markers',
422
- marker=go.scattermapbox.Marker(
423
- size=10,
424
- color='blue'
425
- ),
426
- text=hover_texts,
427
- hoverinfo="text"
428
- ))
429
-
430
- # Configure map layout
431
- fig.update_layout(
432
- mapbox_style="open-street-map",
433
- mapbox=dict(
434
- center={"lat": sum(all_lats)/len(all_lats) if all_lats else 20,
435
- "lon": sum(all_lons)/len(all_lons) if all_lons else 0},
436
- zoom=3
437
- ),
438
- margin={"r":0,"t":0,"l":0,"b":0},
439
- height=600
440
- )
441
-
442
- return fig, processed_count
443
- else:
444
- # Create empty figure if no coordinates found
445
- fig = go.Figure()
446
- fig.update_layout(
447
- mapbox_style="open-street-map",
448
- mapbox=dict(
449
- center={"lat": 20, "lon": 0},
450
- zoom=2
451
- ),
452
- margin={"r":0,"t":0,"l":0,"b":0},
453
- height=600,
454
- annotations=[
455
- dict(
456
- text="No locations found to display",
457
- showarrow=False,
458
- xref="paper",
459
- yref="paper",
460
- x=0.5,
461
- y=0.5
462
- )
463
- ]
464
- )
465
- return fig, 0
466
-
467
-
468
  # Custom CSS for map
469
  custom_css = """
470
  <style>
@@ -588,30 +548,62 @@ h2 {
588
 
589
  with gr.Blocks(css=custom_css, title="Daten Strukturieren und Analysieren") as demo:
590
  gr.HTML("""
591
- <div style="text-align: center; margin-bottom: 1rem">
592
- <h1>Strukturierung und Visualisierung von historischen Daten</h1>
593
- </div>
594
- <div style="font-family: 'Source Sans Pro', sans-serif; max-width: 1000px; margin: 0 auto; color: #333; line-height: 1.7; font-size: 1.15rem;">
595
- <h3 style="color: #2c6bb3; margin-top: 0; margin-bottom: 15px; font-size: 1.3rem;">Kurz erklärt: Was macht diese Anwendung?</h3>
 
 
 
 
596
 
597
  <ol style="padding-left: 20px; margin-bottom: 0;">
598
- <li style="margin-bottom: 10px; font-size: 1.1rem;">
599
- <strong>Textanalyse-Tool:</strong> Findet automatisch wichtige Informationen in alten Zeitungsartikeln, wie Erdbeben-Orte oder Berichterstattungsstädte.
600
  </li>
601
 
602
- <li style="margin-bottom: 10px; font-size: 1.1rem;">
603
- <strong>Fragemethode:</strong> Verwandelt leere Felder in Fragen (z.B. "Wo war das Erdbeben?") und findet Antworten im Text.
604
  </li>
605
 
606
- <li style="margin-bottom: 10px; font-size: 1.1rem;">
607
- <strong>Kartenvisualisierung:</strong> Zeigt gefundene Orte automatisch auf interaktiven Karten an.
608
  </li>
609
 
610
- <li style="margin-bottom: 0; font-size: 1.1rem;">
611
- <strong>Forschungshilfe:</strong> Organisiert historische Daten zu strukturierten, leicht analysierbaren Informationen.
612
  </li>
613
  </ol>
614
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  </div>
616
  <div style="font-family: 'Source Sans Pro', sans-serif; max-width: 1000px; margin: 0 auto; color: #333; line-height: 1.7; font-size: 1.15rem;">
617
  <p style="font-size: 1.3rem; margin-bottom: 1.8rem; color: #2c3e50; font-weight: 400; padding: 0 1rem;">
@@ -805,124 +797,6 @@ Die Katastrophe in <span style="background-color: #a8e6cf; font-weight: bold; pa
805
  """)
806
 
807
 
808
- # Instead of using folium directly with Gradio's HTML component,
809
- # try this alternative approach using Plotly for maps
810
-
811
- def create_map_plotly(df, location_col):
812
- """
813
- Creates an interactive map using Plotly instead of Folium
814
- which should be more stable in Gradio applications
815
- """
816
- import plotly.graph_objects as go
817
- import pandas as pd
818
-
819
- # Track processing for debugging
820
- print(f"Processing {len(df)} rows with location column: {location_col}")
821
-
822
- # Initialize geocoder and tracking variables
823
- geocoder = SafeGeocoder()
824
- processed_count = 0
825
- all_lats = []
826
- all_lons = []
827
- hover_texts = []
828
-
829
- # Process each row
830
- for idx, row in df.iterrows():
831
- if pd.isna(row[location_col]):
832
- continue
833
-
834
- location = str(row[location_col]).strip()
835
-
836
- # Build additional info for hover text
837
- additional_info = ""
838
- for col in df.columns:
839
- if col != location_col and not pd.isna(row[col]):
840
- additional_info += f"<br><b>{col}:</b> {row[col]}"
841
-
842
- # Split location if it contains multiple comma-separated places
843
- try:
844
- locations = [loc.strip() for loc in location.split(',') if loc.strip()]
845
- if not locations:
846
- locations = [location]
847
- except Exception as e:
848
- print(f"Error splitting location '{location}': {str(e)}")
849
- locations = [location]
850
-
851
- # Process each individual location
852
- for loc in locations:
853
- try:
854
- # Get coordinates
855
- point = geocoder.get_coords(loc)
856
-
857
- if point:
858
- lat, lon = point
859
- all_lats.append(lat)
860
- all_lons.append(lon)
861
-
862
- # Create hover text with location name and info
863
- hover_text = f"<b>{loc}</b>{additional_info}"
864
- hover_texts.append(hover_text)
865
-
866
- processed_count += 1
867
- except Exception as e:
868
- print(f"Error processing location {loc}: {str(e)}")
869
-
870
- # Create Plotly map
871
- if len(all_lats) > 0:
872
- fig = go.Figure()
873
-
874
- # Add scatter mapbox points
875
- fig.add_trace(go.Scattermapbox(
876
- lat=all_lats,
877
- lon=all_lons,
878
- mode='markers',
879
- marker=go.scattermapbox.Marker(
880
- size=10,
881
- color='blue'
882
- ),
883
- text=hover_texts,
884
- hoverinfo="text"
885
- ))
886
-
887
- # Configure map layout
888
- fig.update_layout(
889
- mapbox_style="open-street-map",
890
- mapbox=dict(
891
- center={"lat": sum(all_lats)/len(all_lats) if all_lats else 20,
892
- "lon": sum(all_lons)/len(all_lons) if all_lons else 0},
893
- zoom=3
894
- ),
895
- margin={"r":0,"t":0,"l":0,"b":0},
896
- height=600
897
- )
898
-
899
- return fig, processed_count
900
- else:
901
- # Create empty figure if no coordinates found
902
- fig = go.Figure()
903
- fig.update_layout(
904
- mapbox_style="open-street-map",
905
- mapbox=dict(
906
- center={"lat": 20, "lon": 0},
907
- zoom=2
908
- ),
909
- margin={"r":0,"t":0,"l":0,"b":0},
910
- height=600,
911
- annotations=[
912
- dict(
913
- text="No locations found to display",
914
- showarrow=False,
915
- xref="paper",
916
- yref="paper",
917
- x=0.5,
918
- y=0.5
919
- )
920
- ]
921
- )
922
- return fig, 0
923
-
924
- # To use this in your Gradio app, modify your code to use gr.Plot instead of gr.HTML:
925
-
926
  with gr.TabItem("📍 Visualisierung von strukturierten Daten"):
927
  gr.HTML("""
928
  <div class="info-box">
@@ -956,39 +830,37 @@ def create_map_plotly(df, location_col):
956
  )
957
 
958
  with gr.Column():
959
- # Replace HTML with Plot component
960
- map_output = gr.Plot(
961
- label="Interaktive Karte"
 
 
 
 
 
 
 
 
 
962
  )
 
963
 
964
- # Modify your process_and_map function to use the new create_map_plotly function
965
  def process_and_map(file, column):
966
  if file is None:
967
  return None, "Hier bitte die Excel-Tabelle hochladen", None
968
 
969
  try:
970
- # Load the Excel file
971
- if hasattr(file, 'name'):
972
- df = pd.read_excel(file.name)
973
- elif isinstance(file, bytes):
974
- df = pd.read_excel(io.BytesIO(file))
975
- else:
976
- df = pd.read_excel(file)
977
-
978
- if column not in df.columns:
979
- return None, f"Spalte '{column}' wurde in der Excel-Datei nicht gefunden.", None
980
-
981
- # Create map using Plotly
982
- fig, processed_count = create_map_plotly(df, column)
983
 
984
- # Save processed data to Excel
985
- with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
986
- processed_path = tmp.name
987
- df.to_excel(processed_path, index=False)
988
-
989
- stats = f"Gefunden: {processed_count} Orte"
990
-
991
- return fig, stats, processed_path
 
992
  except Exception as e:
993
  import traceback
994
  trace = traceback.format_exc()
@@ -1000,7 +872,6 @@ def create_map_plotly(df, location_col):
1000
  inputs=[excel_file, places_column],
1001
  outputs=[map_output, stats_output, processed_file]
1002
  )
1003
- """
1004
 
1005
  gr.HTML("""
1006
  <div style="text-align: center; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #eee; font-size: 0.9rem; color: #666;">
 
347
  print(f"Error in extract_info: {e}\n{trace}")
348
  return f"❌ Fehler: {str(e)}", "{}"
349
  @spaces.GPU
350
+ @spaces.GPU
351
+ def create_map(df, location_col):
352
+ # Start a simple log to track execution
353
+ with open("map_debug.log", "w") as log:
354
+ log.write(f"Starting map creation at {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
355
+
356
+ m = folium.Map(
357
+ location=[20, 0],
358
+ zoom_start=2,
359
+ control_scale=True
360
+ )
361
 
362
+ folium.TileLayer(
363
+ tiles=MAP_TILES["GreenMap"]["url"],
364
+ attr=MAP_TILES["GreenMap"]["attr"],
365
+ name="GreenMap",
366
+ overlay=False,
367
+ control=False
368
+ ).add_to(m)
369
+
370
+ Fullscreen().add_to(m)
371
+ MeasureControl(position='topright', primary_length_unit='kilometers').add_to(m)
372
 
 
373
  geocoder = SafeGeocoder()
374
+ coords = []
375
+ marker_cluster = MarkerCluster(name="Locations").add_to(m)
376
  processed_count = 0
 
 
 
377
 
378
+ with open("map_debug.log", "a") as log:
379
+ log.write(f"Processing {len(df)} rows from dataframe\n")
380
+
381
  for idx, row in df.iterrows():
382
  if pd.isna(row[location_col]):
383
  continue
384
 
385
  location = str(row[location_col]).strip()
386
 
387
+ # Log the location being processed
388
+ with open("map_debug.log", "a") as log:
389
+ log.write(f"Processing location: {location}\n")
390
+
391
  additional_info = ""
392
  for col in df.columns:
393
  if col != location_col and not pd.isna(row[col]):
394
  additional_info += f"<br><b>{col}:</b> {row[col]}"
395
 
 
396
  try:
397
  locations = [loc.strip() for loc in location.split(',') if loc.strip()]
398
  if not locations:
399
  locations = [location]
400
  except Exception as e:
401
+ with open("map_debug.log", "a") as log:
402
+ log.write(f"Error splitting location '{location}': {str(e)}\n")
403
  locations = [location]
404
+
405
+ # Log the parsed locations
406
+ with open("map_debug.log", "a") as log:
407
+ log.write(f"Split into locations: {locations}\n")
408
 
 
409
  for loc in locations:
410
  try:
411
+ # Log the current location
412
+ with open("map_debug.log", "a") as log:
413
+ log.write(f"Getting coordinates for: {loc}\n")
414
+
415
  point = geocoder.get_coords(loc)
416
 
 
 
 
 
 
 
 
 
417
 
 
418
  except Exception as e:
419
+ # Log any errors in processing this location
420
+ with open("map_debug.log", "a") as log:
421
+ log.write(f"Error processing {loc}: {str(e)}\n")
422
+ log.write(traceback.format_exc() + "\n")
423
+
424
+ # Fit map bounds if we have coordinates
425
+ if coords:
426
+ m.fit_bounds(coords)
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  # Custom CSS for map
429
  custom_css = """
430
  <style>
 
548
 
549
  with gr.Blocks(css=custom_css, title="Daten Strukturieren und Analysieren") as demo:
550
  gr.HTML("""
551
+ <div style="font-family: 'Source Sans Pro', sans-serif; max-width: 1000px; margin: 0 auto; color: #333;">
552
+ <!-- Header Section with Improved Styling -->
553
+ <div style="text-align: center; margin-bottom: 2rem; background: linear-gradient(135deg, #2c6bb3 0%, #4e8fd1 100%); padding: 2rem; border-radius: 10px; color: white;">
554
+ <h1 style="margin: 0; font-size: 2.5rem; font-weight: 700;">Strukturierung und Visualisierung von historischen Daten</h1>
555
+ </div>
556
+
557
+ <!-- Quick Summary Box -->
558
+ <div style="background-color: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin-bottom: 2rem; border-left: 5px solid #4e8fd1; box-shadow: 0 2px 10px rgba(0,0,0,0.05);">
559
+ <h3 style="color: #2c6bb3; margin-top: 0; margin-bottom: 15px; font-size: 1.4rem;">Kurz erklärt: Was macht diese Anwendung?</h3>
560
 
561
  <ol style="padding-left: 20px; margin-bottom: 0;">
562
+ <li style="margin-bottom: 15px; font-size: 1.1rem; line-height: 1.5;">
563
+ <strong style="color: #2c6bb3;">Textanalyse-Tool:</strong> Findet automatisch wichtige Informationen in alten Zeitungsartikeln, wie Erdbeben-Orte oder Berichterstattungsstädte.
564
  </li>
565
 
566
+ <li style="margin-bottom: 15px; font-size: 1.1rem; line-height: 1.5;">
567
+ <strong style="color: #2c6bb3;">Fragemethode:</strong> Verwandelt leere Felder in Fragen (z.B. "Wo war das Erdbeben?") und findet Antworten im Text.
568
  </li>
569
 
570
+ <li style="margin-bottom: 15px; font-size: 1.1rem; line-height: 1.5;">
571
+ <strong style="color: #2c6bb3;">Kartenvisualisierung:</strong> Zeigt gefundene Orte automatisch auf interaktiven Karten an.
572
  </li>
573
 
574
+ <li style="margin-bottom: 0; font-size: 1.1rem; line-height: 1.5;">
575
+ <strong style="color: #2c6bb3;">Forschungshilfe:</strong> Organisiert historische Daten zu strukturierten, leicht analysierbaren Informationen.
576
  </li>
577
  </ol>
578
  </div>
579
+
580
+ <!-- Main Explanation Section -->
581
+ <div style="line-height: 1.7; font-size: 1.15rem; margin-bottom: 2rem;">
582
+ <p style="font-size: 1.2rem; margin-bottom: 1.5rem; color: #2c3e50; font-weight: 400; padding: 0 1rem; line-height: 1.8;">
583
+ In dieser Unterrichtseinheit befassen wir uns mit der Strukturierung unstrukturierter historischer Texte und der Visualisierung von extrahierten Daten auf Karten. Die systematische Strukturierung von Daten wird mit einem für Informationsextrahierung trainiertem Sprachmodell durchgeführt, das auf der Question-Answering-Methode basiert. Diese Methode erlaubt es, Informationen mit Hilfe einer Frage zu extrahieren, wie etwa „Wo fand das Erdbeben statt"? Dies ermöglicht die Extrahierung des Ortes, an dem ein Erdbeben stattfand, auch wenn im Text selbst noch andere Orte genannt werden.
584
+ </p>
585
+ </div>
586
+
587
+ <!-- Example Section with Improved Styling -->
588
+ <div style="line-height: 1.7; font-size: 1.15rem; background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin: 2rem 0; box-shadow: 0 2px 10px rgba(0,0,0,0.05); border: 1px solid #e8e8e8;">
589
+ <h3 style="color: #2c6bb3; margin-top: 0; margin-bottom: 15px;">Beispiel einer Textanalyse</h3>
590
+
591
+ <div style="background-color: white; padding: 1.5rem; border-radius: 6px; font-family: 'Source Sans Pro', sans-serif; line-height: 1.7;">
592
+ Die Katastrophe in <span style="background-color: #a8e6cf; font-weight: bold; padding: 2px 5px; border-radius: 3px;" title="Earthquake Location">Japan</span> — 3 Millionen Tote. Mtb. <span style="background-color: #ffdfba; font-weight: bold; padding: 2px 5px; border-radius: 3px;" title="Non-Earthquake Location">London</span>, 4. Sept. (Drahtbericht.) Zu dem Unglück in <span style="background-color: #a8e6cf; font-weight: bold; padding: 2px 5px; border-radius: 3px;" title="Earthquake Location">Japan</span> liegen noch folgende Nachrichten vor: Wie die japanische Gesandtschaft in <span style="background-color: #ffdfba; font-weight: bold; padding: 2px 5px; border-radius: 3px;" title="Non-Earthquake Location">Peking</span> meldet, sind Unterhandlungen mit <span style="background-color: #ffdfba; font-weight: bold; padding: 2px 5px; border-radius: 3px;" title="Non-Earthquake Location">China</span> über die sofortige Lieferung von Lebensmitteln ausgenommen worden. Von <span style="background-color: #ffdfba; font-weight: bold; padding: 2px 5px; border-radius: 3px;" title="Non-Earthquake Location">Peking</span> seien amerikanische, englische und italienische Schiffe mit Lebensmitteln nach <span style="background-color: #a8e6cf; font-weight: bold; padding: 2px 5px; border-radius: 3px;" title="Earthquake Location">Japan</span> abgegangen.
593
+ </div>
594
+
595
+ <!-- Legend with Improved Layout -->
596
+ <div style="display: flex; margin-top: 20px; flex-wrap: wrap; justify-content: flex-start;">
597
+ <div style="display: flex; align-items: center; margin-right: 30px; margin-bottom: 10px;">
598
+ <div style="width: 20px; height: 20px; background-color: #a8e6cf; margin-right: 10px; border-radius: 3px;"></div>
599
+ <span style="font-weight: 600;">Ort des Erdbebens: Japan</span>
600
+ </div>
601
+ <div style="display: flex; align-items: center;">
602
+ <div style="width: 20px; height: 20px; background-color: #ffdfba; margin-right: 10px; border-radius: 3px;"></div>
603
+ <span style="font-weight: 600;">Andere Orte: London, Peking, China</span>
604
+ </div>
605
+ </div>
606
+ </div>
607
  </div>
608
  <div style="font-family: 'Source Sans Pro', sans-serif; max-width: 1000px; margin: 0 auto; color: #333; line-height: 1.7; font-size: 1.15rem;">
609
  <p style="font-size: 1.3rem; margin-bottom: 1.8rem; color: #2c3e50; font-weight: 400; padding: 0 1rem;">
 
797
  """)
798
 
799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  with gr.TabItem("📍 Visualisierung von strukturierten Daten"):
801
  gr.HTML("""
802
  <div class="info-box">
 
830
  )
831
 
832
  with gr.Column():
833
+ map_output = gr.HTML(
834
+ label="Interaktive Karte",
835
+ value="""
836
+ <div style="text-align:center; height:20vh; width:100%; display:flex; align-items:center; justify-content:center;
837
+ background-color:#f5f5f5; border:1px solid #e0e0e0; border-radius:8px;">
838
+ <div>
839
+ <img src="https://cdn-icons-png.flaticon.com/512/854/854878.png" width="100">
840
+ <p style="margin-top:20px; color:#666;">Your map will appear here after processing</p>
841
+ </div>
842
+ </div>
843
+ """,
844
+ elem_id="map-container"
845
  )
846
+
847
 
 
848
  def process_and_map(file, column):
849
  if file is None:
850
  return None, "Hier bitte die Excel-Tabelle hochladen", None
851
 
852
  try:
853
+ map_html, stats, processed_path = process_excel(file, column)
 
 
 
 
 
 
 
 
 
 
 
 
854
 
855
+ if map_html and processed_path:
856
+ responsive_html = f"""
857
+ <div style="width:100%; height:20vh; margin:0; padding:0; border:1px solid #e0e0e0; border-radius:8px; overflow:hidden;">
858
+ {map_html}
859
+ </div>
860
+ """
861
+ return responsive_html, stats, processed_path
862
+ else:
863
+ return None, stats, None
864
  except Exception as e:
865
  import traceback
866
  trace = traceback.format_exc()
 
872
  inputs=[excel_file, places_column],
873
  outputs=[map_output, stats_output, processed_file]
874
  )
 
875
 
876
  gr.HTML("""
877
  <div style="text-align: center; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #eee; font-size: 0.9rem; color: #666;">