SorrelC commited on
Commit
97f0c67
Β·
verified Β·
1 Parent(s): bf02724

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +549 -0
app.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import plotly.graph_objects as go
3
+ import networkx as nx
4
+ import pandas as pd
5
+ from collections import defaultdict
6
+
7
+ # Entity type colors
8
+ ENTITY_COLORS = {
9
+ 'PERSON': '#00B894', # Green
10
+ 'LOCATION': '#A0E7E5', # Light Cyan
11
+ 'EVENT': '#4ECDC4', # Teal
12
+ 'ORGANIZATION': '#55A3FF', # Light Blue
13
+ 'DATE': '#FF6B6B' # Red
14
+ }
15
+
16
+ # Relationship types for dropdown
17
+ RELATIONSHIP_TYPES = [
18
+ 'works_with',
19
+ 'located_in',
20
+ 'participated_in',
21
+ 'member_of',
22
+ 'occurred_at',
23
+ 'employed_by',
24
+ 'founded',
25
+ 'attended',
26
+ 'knows',
27
+ 'related_to',
28
+ 'collaborates_with',
29
+ 'other'
30
+ ]
31
+
32
+ class NetworkGraphBuilder:
33
+ def __init__(self):
34
+ self.entities = []
35
+ self.relationships = []
36
+
37
+ def add_entity(self, name, entity_type, record_id):
38
+ """Add an entity to the collection"""
39
+ if name.strip():
40
+ self.entities.append({
41
+ 'name': name.strip(),
42
+ 'type': entity_type,
43
+ 'record_id': record_id
44
+ })
45
+
46
+ def add_relationship(self, source, target, rel_type):
47
+ """Add a relationship between entities"""
48
+ if source and target and source != target:
49
+ self.relationships.append({
50
+ 'source': source.strip(),
51
+ 'target': target.strip(),
52
+ 'type': rel_type
53
+ })
54
+
55
+ def build_graph(self):
56
+ """Build NetworkX graph from entities and relationships"""
57
+ G = nx.Graph()
58
+
59
+ # Add nodes with attributes
60
+ for entity in self.entities:
61
+ G.add_node(
62
+ entity['name'],
63
+ entity_type=entity['type'],
64
+ record_id=entity['record_id']
65
+ )
66
+
67
+ # Add edges
68
+ for rel in self.relationships:
69
+ if rel['source'] in G.nodes and rel['target'] in G.nodes:
70
+ G.add_edge(
71
+ rel['source'],
72
+ rel['target'],
73
+ relationship=rel['type']
74
+ )
75
+
76
+ return G
77
+
78
+ def create_plotly_graph(self, G, layout_type='spring'):
79
+ """Create interactive Plotly visualization"""
80
+ if len(G.nodes) == 0:
81
+ return None
82
+
83
+ # Choose layout
84
+ if layout_type == 'spring':
85
+ pos = nx.spring_layout(G, k=2, iterations=50)
86
+ elif layout_type == 'circular':
87
+ pos = nx.circular_layout(G)
88
+ elif layout_type == 'kamada_kawai':
89
+ pos = nx.kamada_kawai_layout(G)
90
+ else:
91
+ pos = nx.shell_layout(G)
92
+
93
+ # Create edge traces
94
+ edge_traces = []
95
+ edge_labels = []
96
+
97
+ for edge in G.edges(data=True):
98
+ x0, y0 = pos[edge[0]]
99
+ x1, y1 = pos[edge[1]]
100
+
101
+ # Edge line
102
+ edge_trace = go.Scatter(
103
+ x=[x0, x1, None],
104
+ y=[y0, y1, None],
105
+ mode='lines',
106
+ line=dict(width=2, color='#888'),
107
+ hoverinfo='none',
108
+ showlegend=False
109
+ )
110
+ edge_traces.append(edge_trace)
111
+
112
+ # Edge label (relationship type)
113
+ rel_type = edge[2].get('relationship', '')
114
+ edge_label = go.Scatter(
115
+ x=[(x0 + x1) / 2],
116
+ y=[(y0 + y1) / 2],
117
+ mode='text',
118
+ text=[rel_type],
119
+ textfont=dict(size=10, color='#555'),
120
+ hoverinfo='text',
121
+ hovertext=f"{edge[0]} β†’ {rel_type} β†’ {edge[1]}",
122
+ showlegend=False
123
+ )
124
+ edge_labels.append(edge_label)
125
+
126
+ # Create node traces (one per entity type for legend)
127
+ node_traces = {}
128
+ for node, data in G.nodes(data=True):
129
+ entity_type = data.get('entity_type', 'UNKNOWN')
130
+
131
+ if entity_type not in node_traces:
132
+ node_traces[entity_type] = {
133
+ 'x': [],
134
+ 'y': [],
135
+ 'text': [],
136
+ 'hovertext': [],
137
+ 'degree': []
138
+ }
139
+
140
+ x, y = pos[node]
141
+ node_traces[entity_type]['x'].append(x)
142
+ node_traces[entity_type]['y'].append(y)
143
+ node_traces[entity_type]['text'].append(node)
144
+
145
+ # Create hover text with connections
146
+ connections = list(G.neighbors(node))
147
+ hover_info = f"<b>{node}</b><br>"
148
+ hover_info += f"Type: {entity_type}<br>"
149
+ hover_info += f"Connections: {len(connections)}<br>"
150
+ if connections:
151
+ hover_info += f"Connected to: {', '.join(connections[:5])}"
152
+ if len(connections) > 5:
153
+ hover_info += f"... and {len(connections) - 5} more"
154
+
155
+ node_traces[entity_type]['hovertext'].append(hover_info)
156
+ node_traces[entity_type]['degree'].append(G.degree(node))
157
+
158
+ # Create Plotly traces for each entity type
159
+ data = edge_traces + edge_labels
160
+
161
+ for entity_type, trace_data in node_traces.items():
162
+ # Calculate node sizes based on degree
163
+ max_degree = max(trace_data['degree']) if trace_data['degree'] else 1
164
+ sizes = [20 + (degree / max_degree) * 30 for degree in trace_data['degree']]
165
+
166
+ node_trace = go.Scatter(
167
+ x=trace_data['x'],
168
+ y=trace_data['y'],
169
+ mode='markers+text',
170
+ marker=dict(
171
+ size=sizes,
172
+ color=ENTITY_COLORS.get(entity_type, '#CCCCCC'),
173
+ line=dict(width=2, color='white')
174
+ ),
175
+ text=trace_data['text'],
176
+ textposition='top center',
177
+ textfont=dict(size=10, color='#333'),
178
+ hovertext=trace_data['hovertext'],
179
+ hoverinfo='text',
180
+ name=entity_type,
181
+ showlegend=True
182
+ )
183
+ data.append(node_trace)
184
+
185
+ # Create figure
186
+ fig = go.Figure(
187
+ data=data,
188
+ layout=go.Layout(
189
+ title=dict(
190
+ text='<b>Entity Network Graph</b><br><sub>Node size indicates number of connections</sub>',
191
+ x=0.5,
192
+ xanchor='center'
193
+ ),
194
+ showlegend=True,
195
+ hovermode='closest',
196
+ margin=dict(b=20, l=5, r=5, t=80),
197
+ xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
198
+ yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
199
+ plot_bgcolor='#fafafa',
200
+ height=700,
201
+ legend=dict(
202
+ title=dict(text='<b>Entity Types</b>'),
203
+ orientation='v',
204
+ yanchor='top',
205
+ y=1,
206
+ xanchor='left',
207
+ x=1.02
208
+ )
209
+ )
210
+ )
211
+
212
+ return fig
213
+
214
+ def collect_entities_from_records(*args):
215
+ """Collect all entities from the input fields"""
216
+ builder = NetworkGraphBuilder()
217
+
218
+ # Each record has 5 entity fields (person, location, event, org, date)
219
+ num_records = 6
220
+ fields_per_record = 5
221
+
222
+ for i in range(num_records):
223
+ record_id = i + 1
224
+ base_idx = i * fields_per_record
225
+
226
+ # Extract entities for this record
227
+ person = args[base_idx] if base_idx < len(args) else ""
228
+ location = args[base_idx + 1] if base_idx + 1 < len(args) else ""
229
+ event = args[base_idx + 2] if base_idx + 2 < len(args) else ""
230
+ org = args[base_idx + 3] if base_idx + 3 < len(args) else ""
231
+ date = args[base_idx + 4] if base_idx + 4 < len(args) else ""
232
+
233
+ if person:
234
+ builder.add_entity(person, 'PERSON', record_id)
235
+ if location:
236
+ builder.add_entity(location, 'LOCATION', record_id)
237
+ if event:
238
+ builder.add_entity(event, 'EVENT', record_id)
239
+ if org:
240
+ builder.add_entity(org, 'ORGANIZATION', record_id)
241
+ if date:
242
+ builder.add_entity(date, 'DATE', record_id)
243
+
244
+ # Create list of all entity names for relationship dropdowns
245
+ entity_names = [e['name'] for e in builder.entities]
246
+
247
+ # Create summary
248
+ summary = f"""
249
+ ### πŸ“Š Entities Collected
250
+ - **Total entities:** {len(builder.entities)}
251
+ - **People:** {sum(1 for e in builder.entities if e['type'] == 'PERSON')}
252
+ - **Locations:** {sum(1 for e in builder.entities if e['type'] == 'LOCATION')}
253
+ - **Events:** {sum(1 for e in builder.entities if e['type'] == 'EVENT')}
254
+ - **Organizations:** {sum(1 for e in builder.entities if e['type'] == 'ORGANIZATION')}
255
+ - **Dates:** {sum(1 for e in builder.entities if e['type'] == 'DATE')}
256
+
257
+ Now define relationships between these entities below.
258
+ """
259
+
260
+ # Return summary and update dropdowns
261
+ return (
262
+ summary,
263
+ gr.update(visible=True), # Show relationship section
264
+ gr.update(choices=entity_names, value=None), # Update all relationship dropdowns
265
+ gr.update(choices=entity_names, value=None),
266
+ gr.update(choices=entity_names, value=None),
267
+ gr.update(choices=entity_names, value=None),
268
+ gr.update(choices=entity_names, value=None),
269
+ gr.update(choices=entity_names, value=None),
270
+ gr.update(choices=entity_names, value=None),
271
+ gr.update(choices=entity_names, value=None),
272
+ gr.update(choices=entity_names, value=None),
273
+ gr.update(choices=entity_names, value=None)
274
+ )
275
+
276
+ def generate_network_graph(*args):
277
+ """Generate the network graph from all inputs"""
278
+ builder = NetworkGraphBuilder()
279
+
280
+ # Collect entities (first 30 args: 6 records Γ— 5 fields)
281
+ num_records = 6
282
+ fields_per_record = 5
283
+
284
+ for i in range(num_records):
285
+ record_id = i + 1
286
+ base_idx = i * fields_per_record
287
+
288
+ person = args[base_idx] if base_idx < len(args) else ""
289
+ location = args[base_idx + 1] if base_idx + 1 < len(args) else ""
290
+ event = args[base_idx + 2] if base_idx + 2 < len(args) else ""
291
+ org = args[base_idx + 3] if base_idx + 3 < len(args) else ""
292
+ date = args[base_idx + 4] if base_idx + 4 < len(args) else ""
293
+
294
+ if person:
295
+ builder.add_entity(person, 'PERSON', record_id)
296
+ if location:
297
+ builder.add_entity(location, 'LOCATION', record_id)
298
+ if event:
299
+ builder.add_entity(event, 'EVENT', record_id)
300
+ if org:
301
+ builder.add_entity(org, 'ORGANIZATION', record_id)
302
+ if date:
303
+ builder.add_entity(date, 'DATE', record_id)
304
+
305
+ # Collect relationships (next args: 5 relationships Γ— 3 fields)
306
+ relationship_start = 30
307
+ num_relationships = 5
308
+
309
+ for i in range(num_relationships):
310
+ base_idx = relationship_start + (i * 3)
311
+ source = args[base_idx] if base_idx < len(args) else None
312
+ target = args[base_idx + 1] if base_idx + 1 < len(args) else None
313
+ rel_type = args[base_idx + 2] if base_idx + 2 < len(args) else None
314
+
315
+ if source and target:
316
+ builder.add_relationship(source, target, rel_type)
317
+
318
+ # Get layout type (last arg)
319
+ layout_type = args[-1] if len(args) > relationship_start else 'spring'
320
+
321
+ # Build graph
322
+ G = builder.build_graph()
323
+
324
+ if len(G.nodes) == 0:
325
+ return None, "❌ No entities to display. Please add some entities first."
326
+
327
+ if len(G.edges) == 0:
328
+ return None, "⚠️ No relationships defined. The graph will show isolated nodes."
329
+
330
+ # Create visualization
331
+ fig = builder.create_plotly_graph(G, layout_type)
332
+
333
+ # Create statistics
334
+ stats = f"""
335
+ ### πŸ“ˆ Network Statistics
336
+ - **Nodes (Entities):** {G.number_of_nodes()}
337
+ - **Edges (Relationships):** {G.number_of_edges()}
338
+ - **Network Density:** {nx.density(G):.3f}
339
+ - **Average Connections per Node:** {sum(dict(G.degree()).values()) / G.number_of_nodes():.2f}
340
+ """
341
+
342
+ if G.number_of_edges() > 0:
343
+ # Find most connected nodes
344
+ degrees = dict(G.degree())
345
+ top_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)[:3]
346
+ stats += "\n**Most Connected Entities:**\n"
347
+ for node, degree in top_nodes:
348
+ stats += f"- {node}: {degree} connections\n"
349
+
350
+ return fig, stats
351
+
352
+ def create_interface():
353
+ with gr.Blocks(title="Basic Networks Explorer", theme=gr.themes.Soft()) as demo:
354
+ gr.Markdown("""
355
+ # Basic Networks Explorer
356
+
357
+ Build interactive social network graphs by entering entities extracted through Named Entity Recognition (NER).
358
+ This tool demonstrates how NER can be used to visualize relationships and connections in text data.
359
+
360
+ ### How to use this tool:
361
+ 1. **πŸ“ Enter entities** in the records below (people, locations, events, organizations, dates)
362
+ 2. **πŸ”— Click "Collect Entities"** to gather all your inputs
363
+ 3. **🀝 Define relationships** between entities in the relationship builder
364
+ 4. **🎨 Choose a layout style** and click "Generate Network Graph"
365
+ 5. **πŸ‘οΈ Explore** the interactive visualization
366
+ 6. **πŸ”„ Refresh the page** to start over with new data
367
+ """)
368
+
369
+ # Add tip box
370
+ gr.HTML("""
371
+ <div style="background-color: #fff3cd; border: 1px solid #ffeaa7; border-radius: 8px; padding: 12px; margin: 15px 0;">
372
+ <strong style="color: #856404;">πŸ’‘ Top tip:</strong> This tool works best when you have already identified entities from text using NER. Try the NER Explorer Tool first to extract entities automatically!
373
+ </div>
374
+ """)
375
+
376
+ # Entity input section
377
+ entity_inputs = []
378
+
379
+ with gr.Accordion("πŸ“š Step 1: Enter Entities from Your Records", open=True):
380
+ for i in range(6):
381
+ with gr.Group():
382
+ gr.Markdown(f"### Record {i+1}")
383
+ with gr.Row():
384
+ person = gr.Textbox(label="πŸ‘€ Person", placeholder="e.g., Albert Einstein")
385
+ location = gr.Textbox(label="πŸ“ Location", placeholder="e.g., Berlin")
386
+ event = gr.Textbox(label="πŸ“… Event", placeholder="e.g., Nobel Prize Ceremony")
387
+ with gr.Row():
388
+ org = gr.Textbox(label="🏒 Organization", placeholder="e.g., Princeton University")
389
+ date = gr.Textbox(label="πŸ—“οΈ Date", placeholder="e.g., 1921")
390
+
391
+ entity_inputs.extend([person, location, event, org, date])
392
+
393
+ collect_btn = gr.Button("πŸ” Collect Entities", variant="primary", size="lg")
394
+
395
+ entity_summary = gr.Markdown()
396
+
397
+ # Relationship section (initially hidden)
398
+ with gr.Column(visible=False) as relationship_section:
399
+ with gr.Accordion("🀝 Step 2: Define Relationships Between Entities", open=True):
400
+ gr.Markdown("Select entities and specify how they're connected:")
401
+
402
+ relationship_inputs = []
403
+
404
+ for i in range(5):
405
+ with gr.Row():
406
+ source = gr.Dropdown(label=f"From Entity {i+1}", choices=[], interactive=True)
407
+ rel_type = gr.Dropdown(
408
+ label="Relationship Type",
409
+ choices=RELATIONSHIP_TYPES,
410
+ value="related_to",
411
+ interactive=True
412
+ )
413
+ target = gr.Dropdown(label=f"To Entity {i+1}", choices=[], interactive=True)
414
+
415
+ relationship_inputs.extend([source, rel_type, target])
416
+
417
+ with gr.Accordion("🎨 Step 3: Customize and Generate", open=True):
418
+ layout_type = gr.Dropdown(
419
+ label="Graph Layout",
420
+ choices=['spring', 'circular', 'kamada_kawai', 'shell'],
421
+ value='spring',
422
+ info="Choose how nodes are arranged"
423
+ )
424
+
425
+ generate_btn = gr.Button("πŸ” Generate Network Graph", variant="primary", size="lg")
426
+
427
+ # Output section
428
+ gr.HTML("<hr style='margin: 30px 0;'>")
429
+
430
+ with gr.Row():
431
+ network_stats = gr.Markdown()
432
+
433
+ with gr.Row():
434
+ network_plot = gr.Plot(label="Interactive Network Graph")
435
+
436
+ # Examples
437
+ with gr.Column():
438
+ gr.Markdown("""
439
+ ### πŸ’‘ No example entities to test? No problem!
440
+ Simply click on one of the examples provided below, and the fields will be populated for you.
441
+ """, elem_id="examples-heading")
442
+ gr.Examples(
443
+ examples=[
444
+ [
445
+ # Record 1
446
+ "Winston Churchill", "London", "Battle of Britain", "Royal Air Force", "1940",
447
+ # Record 2
448
+ "Franklin D. Roosevelt", "Washington D.C.", "Pearl Harbor Attack", "United States Navy", "December 7, 1941",
449
+ # Record 3
450
+ "Dwight D. Eisenhower", "Normandy", "D-Day Invasion", "Allied Forces", "June 6, 1944",
451
+ # Record 4
452
+ "Winston Churchill", "Yalta", "Yalta Conference", "Allied Powers", "February 1945",
453
+ # Record 5
454
+ "Harry S. Truman", "Potsdam", "Potsdam Conference", "Allied Powers", "July 1945",
455
+ # Record 6
456
+ "Douglas MacArthur", "Tokyo Bay", "Japanese Surrender", "United States Military", "September 2, 1945"
457
+ ],
458
+ [
459
+ # Record 1 - Pride and Prejudice
460
+ "Elizabeth Bennet", "Longbourn", "First Ball", "", "1811",
461
+ # Record 2
462
+ "Mr. Darcy", "Pemberley", "First Ball", "", "1811",
463
+ # Record 3
464
+ "Jane Bennet", "Netherfield", "Dinner Party", "", "1811",
465
+ # Record 4
466
+ "Mr. Bingley", "Netherfield", "Dinner Party", "", "1811",
467
+ # Record 5
468
+ "Elizabeth Bennet", "Rosings Park", "Easter Visit", "", "1812",
469
+ # Record 6
470
+ "Mr. Darcy", "Rosings Park", "Easter Visit", "", "1812"
471
+ ]
472
+ ],
473
+ inputs=entity_inputs,
474
+ label="Examples"
475
+ )
476
+
477
+ # Add custom CSS to match NER tool styling
478
+ gr.HTML("""
479
+ <style>
480
+ /* Make the Examples label text black */
481
+ .gradio-examples-label {
482
+ color: black !important;
483
+ }
484
+ h4.examples-label, .examples-label {
485
+ color: black !important;
486
+ }
487
+ #examples-heading + div label,
488
+ #examples-heading + div .label-text {
489
+ color: black !important;
490
+ }
491
+ </style>
492
+ """)
493
+
494
+ # Wire up the interface
495
+ # Collect entities button
496
+ collect_btn.click(
497
+ fn=collect_entities_from_records,
498
+ inputs=entity_inputs,
499
+ outputs=[
500
+ entity_summary,
501
+ relationship_section
502
+ ] + relationship_inputs[::3] + relationship_inputs[2::3] # Update source and target dropdowns
503
+ )
504
+
505
+ # Generate graph button
506
+ all_inputs = entity_inputs + relationship_inputs + [layout_type]
507
+ generate_btn.click(
508
+ fn=generate_network_graph,
509
+ inputs=all_inputs,
510
+ outputs=[network_plot, network_stats]
511
+ )
512
+
513
+ # Information footer
514
+ gr.HTML("""
515
+ <hr style="margin-top: 40px; margin-bottom: 20px;">
516
+ <div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin-top: 20px;">
517
+ <h4 style="margin-top: 0;">ℹ️ About This Tool</h4>
518
+ <p style="font-size: 14px; line-height: 1.8;">
519
+ This tool demonstrates how <strong>Named Entity Recognition (NER)</strong> can be combined with
520
+ <strong>network analysis</strong> to visualize relationships in text data. In real-world applications,
521
+ entities would be automatically extracted from text using NER models, and relationships could be
522
+ identified through co-occurrence analysis, dependency parsing, or machine learning.
523
+ </p>
524
+ <p style="font-size: 14px; line-height: 1.8; margin-bottom: 0;">
525
+ <strong>Built with:</strong> Gradio, NetworkX, and Plotly |
526
+ <strong>Graph Layouts:</strong> Spring (force-directed), Circular, Kamada-Kawai, Shell
527
+ </p>
528
+ </div>
529
+
530
+ <br>
531
+ <hr style="margin-top: 40px; margin-bottom: 20px;">
532
+ <div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin-top: 20px; text-align: center;">
533
+ <p style="font-size: 14px; line-height: 1.8; margin: 0;">
534
+ This <strong>Basic Networks Explorer</strong> was created as part of a Bodleian Libraries Oxford Sassoon Research Fellowship.
535
+ </a>
536
+ funded research project:<br>
537
+ <em>Extracting Keywords from Crowdsourced Collections</em>.
538
+ </p><br><br>
539
+ <p style="font-size: 14px; line-height: 1.8; margin: 0;">
540
+ The code for this tool was built with the aid of Claude Sonnet 4.5.
541
+ </p>
542
+ </div>
543
+ """)
544
+
545
+ return demo
546
+
547
+ if __name__ == "__main__":
548
+ demo = create_interface()
549
+ demo.launch()