astronolan commited on
Commit
fa7eb7f
Β·
1 Parent(s): 0d23870

Enhance search functionality and UI components

Browse files

- Updated example queries for better clarity and relevance
- Added a "Copy link" button for search results and galaxy details
- Implemented URL state management for shareable search links

Files changed (6) hide show
  1. src/callbacks.py +549 -28
  2. src/components.py +22 -6
  3. src/config.py +7 -0
  4. src/hf_logging.py +83 -0
  5. src/url_state.py +206 -0
  6. src/utils.py +137 -39
src/callbacks.py CHANGED
@@ -4,9 +4,10 @@ import json
4
  import time
5
  import logging
6
  import traceback
 
7
  import pandas as pd
8
  import dash
9
- from dash import Input, Output, State, callback_context, html
10
  import dash_bootstrap_components as dbc
11
 
12
  import src.config as config
@@ -184,12 +185,12 @@ def register_callbacks(app, search_service: SearchService):
184
  button_id = ctx.triggered[0]["prop_id"].split(".")[0]
185
 
186
  example_queries = {
187
- "example-1": "Merging edge-on galaxy",
188
  "example-2": "A peculiar interacting galaxy system featuring plenty of tidal tails and a disturbed morphology",
189
  "example-3": "galaxy with stream",
190
  "example-5": "A violent merger in progress with visible tidal features",
191
  "example-6": "Low surface brightness",
192
- "example-7": "face-on ring galaxy",
193
  "example-8": "a bursty, star forming galaxy"
194
  }
195
 
@@ -205,7 +206,8 @@ def register_callbacks(app, search_service: SearchService):
205
  Output("search-results", "children"),
206
  Output("search-data", "data"),
207
  Output("download-button", "disabled"),
208
- Output("download-button-advanced", "disabled")],
 
209
  [Input("search-button", "n_clicks"),
210
  Input("search-input", "n_submit"),
211
  Input("search-button-advanced", "n_clicks")],
@@ -246,7 +248,10 @@ def register_callbacks(app, search_service: SearchService):
246
  # Otherwise perform basic search
247
  query = query_basic
248
  if not query or not query.strip():
249
- return "", dbc.Alert("Please enter a search query", color="warning"), None, True, True
 
 
 
250
 
251
  try:
252
  # Extract min and max from slider range
@@ -264,13 +269,13 @@ def register_callbacks(app, search_service: SearchService):
264
  rmag_min=rmag_min,
265
  rmag_max=rmag_max
266
  )
267
- log_query_to_csv(query_xml)
268
 
269
  # Build results grid - only load first 60 images
270
  grid_items = build_galaxy_grid(df.head(DEFAULT_DISPLAY_COUNT))
271
 
272
  # Prepare data for store
273
- search_data = prepare_search_data(df, query)
274
 
275
  # Create load more button
276
  load_more_button = create_load_more_button(len(df), DEFAULT_DISPLAY_COUNT) if len(df) > DEFAULT_DISPLAY_COUNT else None
@@ -282,8 +287,19 @@ def register_callbacks(app, search_service: SearchService):
282
 
283
  # Build complete results container
284
  results_container = html.Div([
285
- html.P(f"Top {len(df)} matching galaxies (showing {min(DEFAULT_DISPLAY_COUNT, len(df))})",
286
- className="results-header mb-2 text-center"),
 
 
 
 
 
 
 
 
 
 
 
287
  html.P(f"'{query}'{filter_desc}",
288
  className="text-center mb-3",
289
  style={"color": "rgba(245, 245, 247, 0.6)", "font-size": "0.9rem"}),
@@ -291,20 +307,51 @@ def register_callbacks(app, search_service: SearchService):
291
  load_more_button
292
  ])
293
 
294
- return "", results_container, search_data, False, False
 
 
 
 
 
 
 
 
 
 
295
 
296
  except Exception as e:
297
  error_msg = dbc.Alert(f"Search failed: {str(e)}", color="danger")
298
  logger.error(f"Search error: {e}")
299
  logger.error(f"Full traceback:\n{traceback.format_exc()}")
300
- return "", error_msg, None, True, True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
  @app.callback(
303
  [Output("galaxy-modal", "is_open"),
304
  Output("modal-title", "children"),
305
  Output("modal-image", "children"),
306
  Output("modal-description", "children"),
307
- Output("current-galaxy-data", "data")],
 
308
  [Input({"type": "galaxy-image", "index": dash.dependencies.ALL}, "n_clicks"),
309
  Input("close-modal", "n_clicks")],
310
  [State("galaxy-modal", "is_open"),
@@ -316,17 +363,17 @@ def register_callbacks(app, search_service: SearchService):
316
  ctx = callback_context
317
 
318
  if not ctx.triggered:
319
- return False, "", "", "", None
320
 
321
  if ctx.triggered[0]["prop_id"] == "close-modal.n_clicks":
322
- return False, "", "", "", None
323
 
324
  if search_data:
325
  triggered_prop = ctx.triggered[0]["prop_id"]
326
  triggered_value = ctx.triggered[0]["value"]
327
 
328
  if triggered_value is None or triggered_value == 0:
329
- return False, "", "", "", None
330
 
331
  if "galaxy-image" in triggered_prop:
332
  try:
@@ -345,17 +392,101 @@ def register_callbacks(app, search_service: SearchService):
345
  "r_mag": galaxy_info["r_mag"]
346
  }
347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  return (
349
  True,
350
  f"Galaxy at RA={galaxy_info['ra']:.6f}, Dec={galaxy_info['dec']:.6f}",
351
  image_element,
352
  description_element,
353
- galaxy_data
 
354
  )
355
  except:
356
  pass
357
 
358
- return False, "", "", "", None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
 
360
  @app.callback(
361
  Output("info-modal", "is_open"),
@@ -401,8 +532,19 @@ def register_callbacks(app, search_service: SearchService):
401
  load_more_button = create_load_more_button(total_count, next_count) if next_count < total_count else None
402
 
403
  results_container = html.Div([
404
- html.P(f"Top {total_count} matching galaxies (showing {next_count})",
405
- className="results-header mb-2 text-center"),
 
 
 
 
 
 
 
 
 
 
 
406
  html.P(f"'{search_data['query']}'",
407
  className="text-center mb-3",
408
  style={"color": "rgba(245, 245, 247, 0.6)", "font-size": "0.9rem"}),
@@ -492,6 +634,9 @@ def register_callbacks(app, search_service: SearchService):
492
  additional_ra_values, additional_dec_values,
493
  additional_operations):
494
  """Perform advanced search combining main query with additional queries."""
 
 
 
495
  def operation_to_weight(op_str):
496
  """Convert operation string to float weight."""
497
  if op_str == "+":
@@ -557,7 +702,7 @@ def register_callbacks(app, search_service: SearchService):
557
 
558
  # Validate that we have at least one query
559
  if not text_queries and not image_queries:
560
- return "", dbc.Alert("Please enter at least one text or image query", color="warning"), None, True, True
561
 
562
  try:
563
  # Extract min and max from slider range
@@ -585,7 +730,7 @@ def register_callbacks(app, search_service: SearchService):
585
  rmag_min=rmag_min,
586
  rmag_max=rmag_max
587
  )
588
- log_query_to_csv(query_xml)
589
 
590
  # Build results grid
591
  grid_items = build_galaxy_grid(df.head(DEFAULT_DISPLAY_COUNT))
@@ -637,7 +782,7 @@ def register_callbacks(app, search_service: SearchService):
637
  filter_desc = f" + r-mag: [{rmag_min:.1f}, {rmag_max:.1f}]"
638
 
639
  # Prepare data for store
640
- search_data = prepare_search_data(df, query_description, is_vector_search=True)
641
  search_data["text_queries"] = text_queries
642
  search_data["text_weights"] = text_weights
643
  search_data["image_queries"] = image_queries
@@ -648,8 +793,19 @@ def register_callbacks(app, search_service: SearchService):
648
 
649
  # Build results container
650
  results_container = html.Div([
651
- html.P(f"Top {len(df)} matching galaxies (showing {min(DEFAULT_DISPLAY_COUNT, len(df))})",
652
- className="results-header mb-2 text-center"),
 
 
 
 
 
 
 
 
 
 
 
653
  html.P(
654
  query_display_parts + ([f"{filter_desc}"] if filter_desc else []),
655
  className="text-center mb-3",
@@ -659,17 +815,378 @@ def register_callbacks(app, search_service: SearchService):
659
  load_more_button
660
  ])
661
 
662
- return "", results_container, search_data, False, False
 
 
 
 
 
 
 
 
 
 
663
 
664
  except Exception as e:
665
  error_msg = dbc.Alert(f"Advanced search failed: {str(e)}", color="danger")
666
  logger.error(f"Advanced search error: {e}")
667
  logger.error(f"Full traceback:\n{traceback.format_exc()}")
668
- return "", error_msg, None, True, True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
 
670
 
671
  # Helper functions for callbacks
672
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  def build_galaxy_grid(df: pd.DataFrame) -> list:
674
  """Build galaxy grid items from DataFrame.
675
 
@@ -731,18 +1248,19 @@ def build_galaxy_card(galaxy_info: dict, index: int):
731
  ], width=6, md=4, lg=2, className="mb-2 px-1")
732
 
733
 
734
- def prepare_search_data(df: pd.DataFrame, query: str, is_vector_search: bool = False) -> dict:
735
  """Prepare search data for storage.
736
 
737
  Args:
738
  df: DataFrame with search results
739
  query: Search query string
740
  is_vector_search: Whether this is a vector search
 
741
 
742
  Returns:
743
  Dictionary with search data
744
  """
745
- return {
746
  ZILLIZ_PRIMARY_KEY: df[ZILLIZ_PRIMARY_KEY].tolist(),
747
  "ra": df['ra'].tolist(),
748
  "dec": df['dec'].tolist(),
@@ -753,6 +1271,9 @@ def prepare_search_data(df: pd.DataFrame, query: str, is_vector_search: bool = F
753
  "query": query,
754
  "is_vector_search": is_vector_search
755
  }
 
 
 
756
 
757
 
758
  def extract_galaxy_info(search_data: dict, index: int) -> dict:
 
4
  import time
5
  import logging
6
  import traceback
7
+ import uuid
8
  import pandas as pd
9
  import dash
10
+ from dash import Input, Output, State, callback_context, html, dcc
11
  import dash_bootstrap_components as dbc
12
 
13
  import src.config as config
 
185
  button_id = ctx.triggered[0]["prop_id"].split(".")[0]
186
 
187
  example_queries = {
188
+ "example-1": "Two edge-on galaxies",
189
  "example-2": "A peculiar interacting galaxy system featuring plenty of tidal tails and a disturbed morphology",
190
  "example-3": "galaxy with stream",
191
  "example-5": "A violent merger in progress with visible tidal features",
192
  "example-6": "Low surface brightness",
193
+ "example-7": "A face-on spiral with ring-like circular structure around a core",
194
  "example-8": "a bursty, star forming galaxy"
195
  }
196
 
 
206
  Output("search-results", "children"),
207
  Output("search-data", "data"),
208
  Output("download-button", "disabled"),
209
+ Output("download-button-advanced", "disabled"),
210
+ Output("current-search-params", "data")],
211
  [Input("search-button", "n_clicks"),
212
  Input("search-input", "n_submit"),
213
  Input("search-button-advanced", "n_clicks")],
 
248
  # Otherwise perform basic search
249
  query = query_basic
250
  if not query or not query.strip():
251
+ return "", dbc.Alert("Please enter a search query", color="warning"), None, True, True, None
252
+
253
+ # Generate unique request_id for this search
254
+ request_id = uuid.uuid4().hex
255
 
256
  try:
257
  # Extract min and max from slider range
 
269
  rmag_min=rmag_min,
270
  rmag_max=rmag_max
271
  )
272
+ log_query_to_csv(query_xml, request_id=request_id)
273
 
274
  # Build results grid - only load first 60 images
275
  grid_items = build_galaxy_grid(df.head(DEFAULT_DISPLAY_COUNT))
276
 
277
  # Prepare data for store
278
+ search_data = prepare_search_data(df, query, request_id=request_id)
279
 
280
  # Create load more button
281
  load_more_button = create_load_more_button(len(df), DEFAULT_DISPLAY_COUNT) if len(df) > DEFAULT_DISPLAY_COUNT else None
 
287
 
288
  # Build complete results container
289
  results_container = html.Div([
290
+ html.Div([
291
+ html.P(f"Top {len(df)} matching galaxies (showing {min(DEFAULT_DISPLAY_COUNT, len(df))})",
292
+ className="results-header mb-2 text-center d-inline-block me-2"),
293
+ dbc.Button(
294
+ [html.I(className="fas fa-link me-1"), "Copy link"],
295
+ id="copy-results-link",
296
+ color="link",
297
+ size="sm",
298
+ n_clicks=0,
299
+ className="info-button"
300
+ ),
301
+ html.Span(id="copy-results-feedback", style={"marginLeft": "8px", "color": "#28a745", "fontSize": "0.8rem"})
302
+ ], className="text-center"),
303
  html.P(f"'{query}'{filter_desc}",
304
  className="text-center mb-3",
305
  style={"color": "rgba(245, 245, 247, 0.6)", "font-size": "0.9rem"}),
 
307
  load_more_button
308
  ])
309
 
310
+ # Store search params for URL generation
311
+ search_params = {
312
+ "text_queries": [query],
313
+ "text_weights": [1.0],
314
+ "image_queries": [],
315
+ "image_weights": [],
316
+ "rmag_min": rmag_min,
317
+ "rmag_max": rmag_max
318
+ }
319
+
320
+ return "", results_container, search_data, False, False, search_params
321
 
322
  except Exception as e:
323
  error_msg = dbc.Alert(f"Search failed: {str(e)}", color="danger")
324
  logger.error(f"Search error: {e}")
325
  logger.error(f"Full traceback:\n{traceback.format_exc()}")
326
+
327
+ # Log error
328
+ from src.utils import build_query_xml, log_query_to_csv
329
+ try:
330
+ query_xml = build_query_xml(
331
+ text_queries=[query],
332
+ text_weights=[1.0],
333
+ rmag_min=rmag_range[0] if rmag_range else None,
334
+ rmag_max=rmag_range[1] if rmag_range else None
335
+ )
336
+ log_query_to_csv(
337
+ query_xml,
338
+ request_id=request_id,
339
+ error_occurred=True,
340
+ error_message=str(e),
341
+ error_type=type(e).__name__
342
+ )
343
+ except:
344
+ pass
345
+
346
+ return "", error_msg, None, True, True, None
347
 
348
  @app.callback(
349
  [Output("galaxy-modal", "is_open"),
350
  Output("modal-title", "children"),
351
  Output("modal-image", "children"),
352
  Output("modal-description", "children"),
353
+ Output("current-galaxy-data", "data"),
354
+ Output("copy-galaxy-feedback", "children", allow_duplicate=True)],
355
  [Input({"type": "galaxy-image", "index": dash.dependencies.ALL}, "n_clicks"),
356
  Input("close-modal", "n_clicks")],
357
  [State("galaxy-modal", "is_open"),
 
363
  ctx = callback_context
364
 
365
  if not ctx.triggered:
366
+ return False, "", "", "", None, ""
367
 
368
  if ctx.triggered[0]["prop_id"] == "close-modal.n_clicks":
369
+ return False, "", "", "", None, ""
370
 
371
  if search_data:
372
  triggered_prop = ctx.triggered[0]["prop_id"]
373
  triggered_value = ctx.triggered[0]["value"]
374
 
375
  if triggered_value is None or triggered_value == 0:
376
+ return False, "", "", "", None, ""
377
 
378
  if "galaxy-image" in triggered_prop:
379
  try:
 
392
  "r_mag": galaxy_info["r_mag"]
393
  }
394
 
395
+ # Log click event
396
+ from src.utils import log_click_event
397
+ request_id = search_data.get("request_id")
398
+ log_click_event(
399
+ request_id=request_id,
400
+ rank=clicked_idx, # 0-indexed rank
401
+ primary_key=galaxy_info[ZILLIZ_PRIMARY_KEY],
402
+ ra=galaxy_info["ra"],
403
+ dec=galaxy_info["dec"],
404
+ r_mag=galaxy_info["r_mag"],
405
+ distance=galaxy_info["distance"]
406
+ )
407
+
408
  return (
409
  True,
410
  f"Galaxy at RA={galaxy_info['ra']:.6f}, Dec={galaxy_info['dec']:.6f}",
411
  image_element,
412
  description_element,
413
+ galaxy_data,
414
+ "" # Clear copy feedback when opening new galaxy
415
  )
416
  except:
417
  pass
418
 
419
+ return False, "", "", "", None, ""
420
+
421
+ @app.callback(
422
+ [Output("galaxy-modal", "is_open", allow_duplicate=True),
423
+ Output("modal-title", "children", allow_duplicate=True),
424
+ Output("modal-image", "children", allow_duplicate=True),
425
+ Output("modal-description", "children", allow_duplicate=True),
426
+ Output("current-galaxy-data", "data", allow_duplicate=True),
427
+ Output("pending-expand-galaxy", "data", allow_duplicate=True),
428
+ Output("copy-galaxy-feedback", "children", allow_duplicate=True)],
429
+ [Input("search-data", "data")],
430
+ [State("pending-expand-galaxy", "data")],
431
+ prevent_initial_call=True
432
+ )
433
+ def expand_galaxy_from_url(search_data, pending_galaxy_id):
434
+ """Expand galaxy modal if there's a pending galaxy from URL state."""
435
+ if not pending_galaxy_id or not search_data:
436
+ return dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update
437
+
438
+ # Find the galaxy by ID in search results
439
+ primary_keys = search_data.get(ZILLIZ_PRIMARY_KEY, [])
440
+
441
+ # Handle potential bytes encoding issue - try to match with and without b'' wrapper
442
+ target_id = pending_galaxy_id
443
+ # Clean up the ID if it has bytes notation
444
+ if isinstance(target_id, str) and target_id.startswith("b'") and target_id.endswith("'"):
445
+ target_id = target_id[2:-1]
446
+
447
+ try:
448
+ # Try to find exact match first
449
+ if target_id in primary_keys:
450
+ idx = primary_keys.index(target_id)
451
+ else:
452
+ # Try cleaning primary keys too
453
+ cleaned_keys = []
454
+ for pk in primary_keys:
455
+ if isinstance(pk, str) and pk.startswith("b'") and pk.endswith("'"):
456
+ cleaned_keys.append(pk[2:-1])
457
+ else:
458
+ cleaned_keys.append(pk)
459
+
460
+ if target_id in cleaned_keys:
461
+ idx = cleaned_keys.index(target_id)
462
+ else:
463
+ # Galaxy not found in results
464
+ return dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, None, dash.no_update
465
+
466
+ # Extract galaxy info and build modal content
467
+ galaxy_info = extract_galaxy_info(search_data, idx)
468
+ image_element, description_element = build_modal_content(galaxy_info)
469
+
470
+ galaxy_data = {
471
+ ZILLIZ_PRIMARY_KEY: galaxy_info[ZILLIZ_PRIMARY_KEY],
472
+ "ra": galaxy_info["ra"],
473
+ "dec": galaxy_info["dec"],
474
+ "distance": galaxy_info["distance"],
475
+ "r_mag": galaxy_info["r_mag"]
476
+ }
477
+
478
+ return (
479
+ True, # Open modal
480
+ f"Galaxy at RA={galaxy_info['ra']:.6f}, Dec={galaxy_info['dec']:.6f}",
481
+ image_element,
482
+ description_element,
483
+ galaxy_data,
484
+ None, # Clear pending galaxy
485
+ "" # Clear copy feedback when opening galaxy from URL
486
+ )
487
+ except (ValueError, IndexError):
488
+ # Galaxy not found, clear the pending state
489
+ return dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, None, dash.no_update
490
 
491
  @app.callback(
492
  Output("info-modal", "is_open"),
 
532
  load_more_button = create_load_more_button(total_count, next_count) if next_count < total_count else None
533
 
534
  results_container = html.Div([
535
+ html.Div([
536
+ html.P(f"Top {total_count} matching galaxies (showing {next_count})",
537
+ className="results-header mb-2 text-center d-inline-block me-2"),
538
+ dbc.Button(
539
+ [html.I(className="fas fa-link me-1"), "Copy link"],
540
+ id="copy-results-link",
541
+ color="link",
542
+ size="sm",
543
+ n_clicks=0,
544
+ className="info-button"
545
+ ),
546
+ html.Span(id="copy-results-feedback", style={"marginLeft": "8px", "color": "#28a745", "fontSize": "0.8rem"})
547
+ ], className="text-center"),
548
  html.P(f"'{search_data['query']}'",
549
  className="text-center mb-3",
550
  style={"color": "rgba(245, 245, 247, 0.6)", "font-size": "0.9rem"}),
 
634
  additional_ra_values, additional_dec_values,
635
  additional_operations):
636
  """Perform advanced search combining main query with additional queries."""
637
+ # Generate unique request_id for this search
638
+ request_id = uuid.uuid4().hex
639
+
640
  def operation_to_weight(op_str):
641
  """Convert operation string to float weight."""
642
  if op_str == "+":
 
702
 
703
  # Validate that we have at least one query
704
  if not text_queries and not image_queries:
705
+ return "", dbc.Alert("Please enter at least one text or image query", color="warning"), None, True, True, None
706
 
707
  try:
708
  # Extract min and max from slider range
 
730
  rmag_min=rmag_min,
731
  rmag_max=rmag_max
732
  )
733
+ log_query_to_csv(query_xml, request_id=request_id)
734
 
735
  # Build results grid
736
  grid_items = build_galaxy_grid(df.head(DEFAULT_DISPLAY_COUNT))
 
782
  filter_desc = f" + r-mag: [{rmag_min:.1f}, {rmag_max:.1f}]"
783
 
784
  # Prepare data for store
785
+ search_data = prepare_search_data(df, query_description, is_vector_search=True, request_id=request_id)
786
  search_data["text_queries"] = text_queries
787
  search_data["text_weights"] = text_weights
788
  search_data["image_queries"] = image_queries
 
793
 
794
  # Build results container
795
  results_container = html.Div([
796
+ html.Div([
797
+ html.P(f"Top {len(df)} matching galaxies (showing {min(DEFAULT_DISPLAY_COUNT, len(df))})",
798
+ className="results-header mb-2 text-center d-inline-block me-2"),
799
+ dbc.Button(
800
+ [html.I(className="fas fa-link me-1"), "Copy link"],
801
+ id="copy-results-link",
802
+ color="link",
803
+ size="sm",
804
+ n_clicks=0,
805
+ className="info-button"
806
+ ),
807
+ html.Span(id="copy-results-feedback", style={"marginLeft": "8px", "color": "#28a745", "fontSize": "0.8rem"})
808
+ ], className="text-center"),
809
  html.P(
810
  query_display_parts + ([f"{filter_desc}"] if filter_desc else []),
811
  className="text-center mb-3",
 
815
  load_more_button
816
  ])
817
 
818
+ # Store search params for URL generation (convert image_queries dict to tuple format)
819
+ search_params = {
820
+ "text_queries": text_queries,
821
+ "text_weights": text_weights,
822
+ "image_queries": [(img['ra'], img['dec'], img.get('fov', 0.025)) for img in image_queries],
823
+ "image_weights": image_weights,
824
+ "rmag_min": rmag_min,
825
+ "rmag_max": rmag_max
826
+ }
827
+
828
+ return "", results_container, search_data, False, False, search_params
829
 
830
  except Exception as e:
831
  error_msg = dbc.Alert(f"Advanced search failed: {str(e)}", color="danger")
832
  logger.error(f"Advanced search error: {e}")
833
  logger.error(f"Full traceback:\n{traceback.format_exc()}")
834
+
835
+ # Log error
836
+ from src.utils import build_query_xml, log_query_to_csv
837
+ try:
838
+ query_xml = build_query_xml(
839
+ text_queries=text_queries if text_queries else None,
840
+ text_weights=text_weights if text_weights else None,
841
+ image_queries=image_queries if image_queries else None,
842
+ image_weights=image_weights if image_weights else None,
843
+ rmag_min=rmag_range[0] if rmag_range else None,
844
+ rmag_max=rmag_range[1] if rmag_range else None
845
+ )
846
+ log_query_to_csv(
847
+ query_xml,
848
+ request_id=request_id,
849
+ error_occurred=True,
850
+ error_message=str(e),
851
+ error_type=type(e).__name__
852
+ )
853
+ except:
854
+ pass
855
+
856
+ return "", error_msg, None, True, True, None
857
+
858
+ # ===== URL State Management Callbacks =====
859
+
860
+ @app.callback(
861
+ [Output("search-input-advanced", "value", allow_duplicate=True),
862
+ Output("main-vector-operation", "value", allow_duplicate=True),
863
+ Output("main-query-type", "value", allow_duplicate=True),
864
+ Output("main-vector-ra", "value", allow_duplicate=True),
865
+ Output("main-vector-dec", "value", allow_duplicate=True),
866
+ Output("vector-inputs", "children", allow_duplicate=True),
867
+ Output("rmag-slider", "value", allow_duplicate=True),
868
+ Output("advanced-search-interface", "style", allow_duplicate=True),
869
+ Output("basic-search-bar", "style", allow_duplicate=True),
870
+ Output("vector-arrow", "className", allow_duplicate=True),
871
+ Output("url-search-trigger", "data", allow_duplicate=True),
872
+ Output("pending-expand-galaxy", "data")],
873
+ Input("url", "search"),
874
+ prevent_initial_call=True
875
+ )
876
+ def restore_state_from_url(search_string):
877
+ """Parse URL and populate UI, then trigger search if state is present."""
878
+ from src.url_state import parse_url_search_param
879
+
880
+ # Parse URL state
881
+ state = parse_url_search_param(search_string)
882
+
883
+ # If no state, return defaults (don't trigger search)
884
+ if not state.get('text_queries') and not state.get('image_queries'):
885
+ return (
886
+ dash.no_update, # search-input-advanced
887
+ dash.no_update, # main-vector-operation
888
+ dash.no_update, # main-query-type
889
+ dash.no_update, # main-vector-ra
890
+ dash.no_update, # main-vector-dec
891
+ dash.no_update, # vector-inputs
892
+ dash.no_update, # rmag-slider
893
+ dash.no_update, # advanced-search-interface style
894
+ dash.no_update, # basic-search-bar style
895
+ dash.no_update, # vector-arrow
896
+ dash.no_update, # url-search-trigger
897
+ None # pending-expand-galaxy
898
+ )
899
+
900
+ # Always restore to advanced mode
901
+ text_queries = state.get('text_queries', [])
902
+ text_weights = state.get('text_weights', [])
903
+ image_queries = state.get('image_queries', [])
904
+ image_weights = state.get('image_weights', [])
905
+ rmag_min = state.get('rmag_min', 13.0)
906
+ rmag_max = state.get('rmag_max', 20.0)
907
+ expand_galaxy = state.get('expand_galaxy')
908
+
909
+ # Determine main query (first query - text or image)
910
+ main_text = ""
911
+ main_operation = "+"
912
+ main_query_type = "text"
913
+ main_ra = None
914
+ main_dec = None
915
+ vector_children = []
916
+
917
+ all_queries = []
918
+ # Combine text and image queries with their types
919
+ for i, (query, weight) in enumerate(zip(text_queries, text_weights)):
920
+ all_queries.append(('text', query, None, None, weight))
921
+ for i, (img_query, weight) in enumerate(zip(image_queries, image_weights)):
922
+ all_queries.append(('image', None, img_query[0], img_query[1], weight))
923
+
924
+ if all_queries:
925
+ # First query becomes main query
926
+ first_type, first_text, first_ra, first_dec, first_weight = all_queries[0]
927
+ main_query_type = first_type
928
+ main_operation = weight_to_operation_str(first_weight)
929
+
930
+ if first_type == 'text':
931
+ main_text = first_text
932
+ else:
933
+ main_ra = first_ra
934
+ main_dec = first_dec
935
+
936
+ # Remaining queries become additional vectors
937
+ for idx, (qtype, qtext, qra, qdec, qweight) in enumerate(all_queries[1:]):
938
+ from src.components import create_vector_input_row
939
+ operation_str = weight_to_operation_str(qweight)
940
+ if qtype == 'text':
941
+ row = create_vector_input_row(
942
+ idx,
943
+ query_type='text',
944
+ text_value=qtext,
945
+ operation=operation_str
946
+ )
947
+ vector_children.append(row)
948
+ else:
949
+ row = create_vector_input_row(
950
+ idx,
951
+ query_type='image',
952
+ ra=qra,
953
+ dec=qdec,
954
+ operation=operation_str
955
+ )
956
+ vector_children.append(row)
957
+
958
+ return (
959
+ main_text, # search-input-advanced
960
+ main_operation, # main-vector-operation
961
+ main_query_type, # main-query-type
962
+ main_ra, # main-vector-ra
963
+ main_dec, # main-vector-dec
964
+ vector_children, # vector-inputs
965
+ [rmag_min, rmag_max], # rmag-slider
966
+ {"display": "block"}, # advanced-search-interface (show)
967
+ {"display": "none"}, # basic-search-bar (hide)
968
+ "fas fa-chevron-up me-2", # vector-arrow (up arrow for advanced mode)
969
+ 1, # url-search-trigger (trigger search)
970
+ expand_galaxy # pending-expand-galaxy
971
+ )
972
+
973
+ @app.callback(
974
+ Output("search-button-advanced", "n_clicks", allow_duplicate=True),
975
+ Input("url-search-trigger", "data"),
976
+ State("search-button-advanced", "n_clicks"),
977
+ prevent_initial_call=True
978
+ )
979
+ def trigger_search_from_url(trigger, current_clicks):
980
+ """Click search button after URL state is restored."""
981
+ if trigger:
982
+ return (current_clicks or 0) + 1
983
+ return dash.no_update
984
+
985
+ @app.callback(
986
+ Output("url", "search", allow_duplicate=True),
987
+ Input("current-search-params", "data"),
988
+ State("url", "search"),
989
+ prevent_initial_call=True
990
+ )
991
+ def update_url_after_search(search_params, current_url):
992
+ """Update browser URL without reload after search completes."""
993
+ from src.url_state import encode_search_state
994
+ from src.config import URL_STATE_PARAM
995
+
996
+ if not search_params:
997
+ return dash.no_update
998
+
999
+ # Extract params
1000
+ text_queries = search_params.get('text_queries', [])
1001
+ text_weights = search_params.get('text_weights', [])
1002
+ image_queries = search_params.get('image_queries', [])
1003
+ image_weights = search_params.get('image_weights', [])
1004
+ rmag_min = search_params.get('rmag_min', 13.0)
1005
+ rmag_max = search_params.get('rmag_max', 20.0)
1006
+
1007
+ # Encode state
1008
+ encoded = encode_search_state(
1009
+ text_queries=text_queries,
1010
+ text_weights=text_weights,
1011
+ image_queries=image_queries,
1012
+ image_weights=image_weights,
1013
+ rmag_min=rmag_min,
1014
+ rmag_max=rmag_max
1015
+ )
1016
+
1017
+ # Build new URL search string
1018
+ new_url = f"?{URL_STATE_PARAM}={encoded}"
1019
+
1020
+ # Only update if different from current
1021
+ if new_url == current_url:
1022
+ raise dash.exceptions.PreventUpdate
1023
+
1024
+ return new_url
1025
+
1026
+ # Clientside callback for copying results link to clipboard
1027
+ app.clientside_callback(
1028
+ """
1029
+ function(n_clicks, search_params) {
1030
+ if (!n_clicks || !search_params) {
1031
+ return "";
1032
+ }
1033
+
1034
+ // Build the URL from search params
1035
+ var state = {};
1036
+ if (search_params.text_queries && search_params.text_queries.length > 0) {
1037
+ state.tq = search_params.text_queries;
1038
+ state.tw = search_params.text_weights;
1039
+ }
1040
+ if (search_params.image_queries && search_params.image_queries.length > 0) {
1041
+ state.iq = search_params.image_queries;
1042
+ state.iw = search_params.image_weights;
1043
+ }
1044
+ if (search_params.rmag_min !== 13.0) {
1045
+ state.rmin = search_params.rmag_min;
1046
+ }
1047
+ if (search_params.rmag_max !== 20.0) {
1048
+ state.rmax = search_params.rmag_max;
1049
+ }
1050
+
1051
+ var jsonStr = JSON.stringify(state);
1052
+ var encoded = btoa(jsonStr).replace(/=/g, '');
1053
+ var url = window.location.origin + "?s=" + encoded;
1054
+
1055
+ // Copy to clipboard using fallback method
1056
+ var textArea = document.createElement("textarea");
1057
+ textArea.value = url;
1058
+ textArea.style.position = "fixed";
1059
+ textArea.style.left = "-999999px";
1060
+ textArea.style.top = "-999999px";
1061
+ document.body.appendChild(textArea);
1062
+ textArea.focus();
1063
+ textArea.select();
1064
+
1065
+ try {
1066
+ document.execCommand('copy');
1067
+ document.body.removeChild(textArea);
1068
+ // Clear feedback after 2 seconds
1069
+ setTimeout(function() {
1070
+ var el = document.getElementById('copy-results-feedback');
1071
+ if (el) el.textContent = '';
1072
+ }, 2000);
1073
+ return "Copied!";
1074
+ } catch (err) {
1075
+ document.body.removeChild(textArea);
1076
+ // Try modern API as fallback
1077
+ if (navigator.clipboard) {
1078
+ navigator.clipboard.writeText(url);
1079
+ setTimeout(function() {
1080
+ var el = document.getElementById('copy-results-feedback');
1081
+ if (el) el.textContent = '';
1082
+ }, 2000);
1083
+ return "Copied!";
1084
+ }
1085
+ return "Failed to copy";
1086
+ }
1087
+ }
1088
+ """,
1089
+ Output("copy-results-feedback", "children"),
1090
+ Input("copy-results-link", "n_clicks"),
1091
+ State("current-search-params", "data"),
1092
+ prevent_initial_call=True
1093
+ )
1094
+
1095
+ # Clientside callback for copying galaxy link to clipboard
1096
+ app.clientside_callback(
1097
+ """
1098
+ function(n_clicks, search_params, galaxy_data) {
1099
+ if (!n_clicks || !search_params || !galaxy_data) {
1100
+ return "";
1101
+ }
1102
+
1103
+ // Build the URL from search params with galaxy expansion
1104
+ var state = {};
1105
+ if (search_params.text_queries && search_params.text_queries.length > 0) {
1106
+ state.tq = search_params.text_queries;
1107
+ state.tw = search_params.text_weights;
1108
+ }
1109
+ if (search_params.image_queries && search_params.image_queries.length > 0) {
1110
+ state.iq = search_params.image_queries;
1111
+ state.iw = search_params.image_weights;
1112
+ }
1113
+ if (search_params.rmag_min !== 13.0) {
1114
+ state.rmin = search_params.rmag_min;
1115
+ }
1116
+ if (search_params.rmag_max !== 20.0) {
1117
+ state.rmax = search_params.rmag_max;
1118
+ }
1119
+ // Add galaxy expansion
1120
+ if (galaxy_data.object_id) {
1121
+ var galaxyId = galaxy_data.object_id;
1122
+ // Clean up bytes notation if present (e.g., "b'1500m885-7090'" -> "1500m885-7090")
1123
+ if (typeof galaxyId === 'string' && galaxyId.startsWith("b'") && galaxyId.endsWith("'")) {
1124
+ galaxyId = galaxyId.slice(2, -1);
1125
+ }
1126
+ state.exp = galaxyId;
1127
+ }
1128
+
1129
+ var jsonStr = JSON.stringify(state);
1130
+ var encoded = btoa(jsonStr).replace(/=/g, '');
1131
+ var url = window.location.origin + "?s=" + encoded;
1132
+
1133
+ // Copy to clipboard using fallback method
1134
+ var textArea = document.createElement("textarea");
1135
+ textArea.value = url;
1136
+ textArea.style.position = "fixed";
1137
+ textArea.style.left = "-999999px";
1138
+ textArea.style.top = "-999999px";
1139
+ document.body.appendChild(textArea);
1140
+ textArea.focus();
1141
+ textArea.select();
1142
+
1143
+ try {
1144
+ document.execCommand('copy');
1145
+ document.body.removeChild(textArea);
1146
+ // Clear feedback after 2 seconds
1147
+ setTimeout(function() {
1148
+ var el = document.getElementById('copy-galaxy-feedback');
1149
+ if (el) el.textContent = '';
1150
+ }, 2000);
1151
+ return "Copied!";
1152
+ } catch (err) {
1153
+ document.body.removeChild(textArea);
1154
+ // Try modern API as fallback
1155
+ if (navigator.clipboard) {
1156
+ navigator.clipboard.writeText(url);
1157
+ setTimeout(function() {
1158
+ var el = document.getElementById('copy-galaxy-feedback');
1159
+ if (el) el.textContent = '';
1160
+ }, 2000);
1161
+ return "Copied!";
1162
+ }
1163
+ return "Failed to copy";
1164
+ }
1165
+ }
1166
+ """,
1167
+ Output("copy-galaxy-feedback", "children"),
1168
+ Input("copy-galaxy-link", "n_clicks"),
1169
+ [State("current-search-params", "data"),
1170
+ State("current-galaxy-data", "data")],
1171
+ prevent_initial_call=True
1172
+ )
1173
 
1174
 
1175
  # Helper functions for callbacks
1176
 
1177
+ def weight_to_operation_str(weight):
1178
+ """Convert weight float to operation string for UI."""
1179
+ if weight == 1.0:
1180
+ return "+"
1181
+ elif weight == -1.0:
1182
+ return "-"
1183
+ elif weight > 0:
1184
+ return f"+{int(weight)}"
1185
+ else:
1186
+ return str(int(weight))
1187
+
1188
+
1189
+
1190
  def build_galaxy_grid(df: pd.DataFrame) -> list:
1191
  """Build galaxy grid items from DataFrame.
1192
 
 
1248
  ], width=6, md=4, lg=2, className="mb-2 px-1")
1249
 
1250
 
1251
+ def prepare_search_data(df: pd.DataFrame, query: str, is_vector_search: bool = False, request_id: str = None) -> dict:
1252
  """Prepare search data for storage.
1253
 
1254
  Args:
1255
  df: DataFrame with search results
1256
  query: Search query string
1257
  is_vector_search: Whether this is a vector search
1258
+ request_id: Unique ID for this request
1259
 
1260
  Returns:
1261
  Dictionary with search data
1262
  """
1263
+ data = {
1264
  ZILLIZ_PRIMARY_KEY: df[ZILLIZ_PRIMARY_KEY].tolist(),
1265
  "ra": df['ra'].tolist(),
1266
  "dec": df['dec'].tolist(),
 
1271
  "query": query,
1272
  "is_vector_search": is_vector_search
1273
  }
1274
+ if request_id:
1275
+ data["request_id"] = request_id
1276
+ return data
1277
 
1278
 
1279
  def extract_galaxy_info(search_data: dict, index: int) -> dict:
src/components.py CHANGED
@@ -542,8 +542,8 @@ def create_search_container():
542
  style={"color": "rgba(245, 245, 247, 0.5)", "font-weight": "300",
543
  "font-size": "0.75rem", "letter-spacing": "0.02em"}),
544
  html.Div([
545
- dbc.Button([html.I(className="fas fa-satellite me-2"), "Merging edge-on galaxy"],
546
- id="example-1", className="example-button me-2 mb-2", size="sm", color="light"),
547
  dbc.Button([html.I(className="fas fa-water me-2"), "Tidal"],
548
  id="example-2", className="example-button me-2 mb-2", size="sm", color="light"),
549
  dbc.Button([html.I(className="fas fa-stream me-2"), "Stream"],
@@ -552,7 +552,7 @@ def create_search_container():
552
  id="example-5", className="example-button me-2 mb-2", size="sm", color="light"),
553
  dbc.Button([html.I(className="fas fa-moon me-2"), "Low surface brightness"],
554
  id="example-6", className="example-button me-2 mb-2", size="sm", color="light"),
555
- dbc.Button([html.I(className="fas fa-ring me-2"), "Ring galaxy"],
556
  id="example-7", className="example-button me-2 mb-2", size="sm", color="light"),
557
  dbc.Button([html.I(className="fas fa-star me-2"), "A bursty, star forming galaxy"],
558
  id="example-8", className="example-button mb-2", size="sm", color="light")
@@ -699,7 +699,8 @@ def create_search_container():
699
  ], className="mb-3")
700
 
701
 
702
- def create_vector_input_row(index: int, query_type: str = "text", ra: float = None, dec: float = None, fov: float = 0.025):
 
703
  """Create a single vector input row with operation selector, query type toggle, and conditional inputs.
704
 
705
  Args:
@@ -708,6 +709,8 @@ def create_vector_input_row(index: int, query_type: str = "text", ra: float = No
708
  ra: Initial RA value for image queries (default: None)
709
  dec: Initial Dec value for image queries (default: None)
710
  fov: Initial FoV value for image queries (default: 0.025)
 
 
711
 
712
  Returns:
713
  Dash Bootstrap Row component with text/image mode toggle
@@ -731,7 +734,7 @@ def create_vector_input_row(index: int, query_type: str = "text", ra: float = No
731
  {"label": "-5", "value": "-5"},
732
  {"label": "-10", "value": "-10"}
733
  ],
734
- value="+",
735
  style={"width": "70px"},
736
  className="d-inline-block vector-operation-select"
737
  )
@@ -756,7 +759,8 @@ def create_vector_input_row(index: int, query_type: str = "text", ra: float = No
756
  dbc.Input(
757
  id={"type": "vector-text", "index": index},
758
  placeholder="Enter text query...",
759
- type="text"
 
760
  )
761
  ], id={"type": "text-input-container", "index": index}, style=text_display),
762
  # Image inputs (shown when type is "image")
@@ -810,9 +814,13 @@ def create_results_container():
810
  def create_stores():
811
  """Create Dash Store components for data persistence."""
812
  return [
 
813
  dcc.Store(id="search-data"),
814
  dcc.Store(id="current-galaxy-data"),
815
  dcc.Store(id="vector-inputs-count", data=1),
 
 
 
816
  dcc.Download(id="download-csv")
817
  ]
818
 
@@ -832,6 +840,14 @@ def create_galaxy_modal():
832
  color="primary",
833
  className="me-2"
834
  ),
 
 
 
 
 
 
 
 
835
  dbc.Button("Close", id="close-modal", className="ms-auto")
836
  ])
837
  ], id="galaxy-modal", size="lg", is_open=False)
 
542
  style={"color": "rgba(245, 245, 247, 0.5)", "font-weight": "300",
543
  "font-size": "0.75rem", "letter-spacing": "0.02em"}),
544
  html.Div([
545
+ dbc.Button([html.I(className="fas fa-clone me-2"), "Two edge-on galaxies"],
546
+ id="example-1", className="example-button me-2 mb-2", size="sm", color="light"),
547
  dbc.Button([html.I(className="fas fa-water me-2"), "Tidal"],
548
  id="example-2", className="example-button me-2 mb-2", size="sm", color="light"),
549
  dbc.Button([html.I(className="fas fa-stream me-2"), "Stream"],
 
552
  id="example-5", className="example-button me-2 mb-2", size="sm", color="light"),
553
  dbc.Button([html.I(className="fas fa-moon me-2"), "Low surface brightness"],
554
  id="example-6", className="example-button me-2 mb-2", size="sm", color="light"),
555
+ dbc.Button([html.I(className="fas fa-ring me-2"), "Ring-like structure"],
556
  id="example-7", className="example-button me-2 mb-2", size="sm", color="light"),
557
  dbc.Button([html.I(className="fas fa-star me-2"), "A bursty, star forming galaxy"],
558
  id="example-8", className="example-button mb-2", size="sm", color="light")
 
699
  ], className="mb-3")
700
 
701
 
702
+ def create_vector_input_row(index: int, query_type: str = "text", ra: float = None, dec: float = None, fov: float = 0.025,
703
+ text_value: str = None, operation: str = "+"):
704
  """Create a single vector input row with operation selector, query type toggle, and conditional inputs.
705
 
706
  Args:
 
709
  ra: Initial RA value for image queries (default: None)
710
  dec: Initial Dec value for image queries (default: None)
711
  fov: Initial FoV value for image queries (default: 0.025)
712
+ text_value: Initial text value for text queries (default: None)
713
+ operation: Initial operation value (default: "+")
714
 
715
  Returns:
716
  Dash Bootstrap Row component with text/image mode toggle
 
734
  {"label": "-5", "value": "-5"},
735
  {"label": "-10", "value": "-10"}
736
  ],
737
+ value=operation,
738
  style={"width": "70px"},
739
  className="d-inline-block vector-operation-select"
740
  )
 
759
  dbc.Input(
760
  id={"type": "vector-text", "index": index},
761
  placeholder="Enter text query...",
762
+ type="text",
763
+ value=text_value
764
  )
765
  ], id={"type": "text-input-container", "index": index}, style=text_display),
766
  # Image inputs (shown when type is "image")
 
814
  def create_stores():
815
  """Create Dash Store components for data persistence."""
816
  return [
817
+ dcc.Location(id='url', refresh=False),
818
  dcc.Store(id="search-data"),
819
  dcc.Store(id="current-galaxy-data"),
820
  dcc.Store(id="vector-inputs-count", data=1),
821
+ dcc.Store(id="current-search-params"), # Stores current search params for URL generation
822
+ dcc.Store(id="pending-expand-galaxy"), # Stores galaxy to expand after URL-based search
823
+ dcc.Store(id="url-search-trigger", data=0), # Triggers search after URL state restore
824
  dcc.Download(id="download-csv")
825
  ]
826
 
 
840
  color="primary",
841
  className="me-2"
842
  ),
843
+ dbc.Button(
844
+ [html.I(className="fas fa-link me-2"), "Copy link"],
845
+ id="copy-galaxy-link",
846
+ color="secondary",
847
+ className="me-2",
848
+ n_clicks=0
849
+ ),
850
+ html.Span(id="copy-galaxy-feedback", style={"color": "#28a745", "fontSize": "0.8rem"}),
851
  dbc.Button("Close", id="close-modal", className="ms-auto")
852
  ])
853
  ], id="galaxy-modal", size="lg", is_open=False)
src/config.py CHANGED
@@ -56,9 +56,16 @@ IMAGE_WIDTH = "100%"
56
  CUTOUT_FOV = 0.025
57
  CUTOUT_SIZE = 256
58
 
 
 
 
59
  # Logging Configuration
60
  VCU_COST_PER_MILLION = 4.0 # $4 per 1 million vCU
61
 
 
 
 
 
62
  # Feature Flags (for future features)
63
  FEATURE_IMAGE_SEARCH = False
64
  FEATURE_AUTH = False
 
56
  CUTOUT_FOV = 0.025
57
  CUTOUT_SIZE = 256
58
 
59
+ # URL State Configuration
60
+ URL_STATE_PARAM = "s" # Query parameter name for encoded state
61
+
62
  # Logging Configuration
63
  VCU_COST_PER_MILLION = 4.0 # $4 per 1 million vCU
64
 
65
+ # Hugging Face Dataset Logging
66
+ HF_LOG_REPO_ID = os.getenv("HF_LOG_REPO_ID") # e.g. "nolank/aionsearch-query-logs"
67
+ HF_LOG_EVERY_MINUTES = int(os.getenv("HF_LOG_EVERY_MINUTES", "10"))
68
+
69
  # Feature Flags (for future features)
70
  FEATURE_IMAGE_SEARCH = False
71
  FEATURE_AUTH = False
src/hf_logging.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/hf_logging.py
2
+ """Hugging Face dataset logging with CommitScheduler."""
3
+
4
+ import json
5
+ import uuid
6
+ import logging
7
+ from pathlib import Path
8
+ from datetime import datetime
9
+
10
+ from huggingface_hub import CommitScheduler
11
+
12
+ from src.config import HF_LOG_REPO_ID, HF_LOG_EVERY_MINUTES
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Generate a unique session ID for this app instance
17
+ SESSION_ID = uuid.uuid4().hex
18
+
19
+ # Initialize scheduler & local file if logging is configured
20
+ if HF_LOG_REPO_ID:
21
+ feedback_file = Path("query_logs") / f"queries_{uuid.uuid4().hex}.jsonl"
22
+ feedback_folder = feedback_file.parent
23
+ feedback_folder.mkdir(parents=True, exist_ok=True)
24
+
25
+ scheduler = CommitScheduler(
26
+ repo_id=HF_LOG_REPO_ID,
27
+ repo_type="dataset",
28
+ folder_path=feedback_folder,
29
+ path_in_repo="data", # files go in data/ on the dataset
30
+ every=HF_LOG_EVERY_MINUTES, # in minutes
31
+ )
32
+
33
+ # Print initialization info
34
+ print("\n" + "="*80)
35
+ print("HF DATASET LOGGING INITIALIZED")
36
+ print("="*80)
37
+ print(f"Session ID: {SESSION_ID}")
38
+ print(f"Repository: {HF_LOG_REPO_ID}")
39
+ print(f"Local directory: {feedback_folder.absolute()}")
40
+ print(f"Local file: {feedback_file.name}")
41
+ print(f"Commit frequency: every {HF_LOG_EVERY_MINUTES} minutes")
42
+ print("="*80 + "\n")
43
+ else:
44
+ scheduler = None
45
+ feedback_file = None
46
+ print("HF dataset logging disabled (HF_LOG_REPO_ID not set)")
47
+
48
+
49
+ def log_query_event(payload: dict) -> None:
50
+ """Append one JSON log line that CommitScheduler will push to the Hub.
51
+
52
+ payload must be JSON-serializable. session_id and timestamp are added if missing.
53
+ No-op if HF_LOG_REPO_ID is not configured.
54
+
55
+ Expected payload fields:
56
+ - log_type: Type of event (aql_query, zilliz_query_stats, click_event)
57
+ - request_id: Unique ID for the request/search (optional, but recommended)
58
+ - session_id: Session ID (auto-added if missing)
59
+ - timestamp: ISO timestamp (auto-added if missing)
60
+ - error_occurred: Boolean indicating if an error occurred (optional)
61
+ - error_message: Error message if error_occurred is True (optional)
62
+ - error_type: Type of error (optional)
63
+ ... plus other event-specific fields
64
+ """
65
+ if scheduler is None or feedback_file is None:
66
+ return
67
+
68
+ # Auto-add session_id if not present
69
+ if "session_id" not in payload:
70
+ payload["session_id"] = SESSION_ID
71
+
72
+ # Auto-add timestamp if not present
73
+ if "timestamp" not in payload:
74
+ payload["timestamp"] = datetime.utcnow().isoformat()
75
+
76
+ try:
77
+ with scheduler.lock:
78
+ with feedback_file.open("a") as f:
79
+ f.write(json.dumps(payload))
80
+ f.write("\n")
81
+ print(f"βœ“ Logged to HF dataset: {payload.get('log_type', 'unknown')}")
82
+ except Exception as e:
83
+ logger.error(f"Failed to write query log locally: {e}")
src/url_state.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ URL state encoding/decoding for shareable search URLs.
3
+
4
+ Core principle: All searches are represented uniformly - basic search is just
5
+ advanced search with a single text query at weight +1.
6
+ """
7
+
8
+ import base64
9
+ import json
10
+ from typing import Dict, List, Optional, Tuple, Any
11
+
12
+ try:
13
+ from flask import request
14
+ except ImportError:
15
+ request = None
16
+
17
+
18
+ def encode_search_state(
19
+ text_queries: List[str],
20
+ text_weights: List[float],
21
+ image_queries: List[Tuple[float, float, float]], # (ra, dec, fov)
22
+ image_weights: List[float],
23
+ rmag_min: float = 13.0,
24
+ rmag_max: float = 20.0,
25
+ expand_galaxy: Optional[str] = None
26
+ ) -> str:
27
+ """
28
+ Encode search state into a compact base64 string.
29
+
30
+ Args:
31
+ text_queries: List of text query strings
32
+ text_weights: List of weights for text queries
33
+ image_queries: List of (ra, dec, fov) tuples
34
+ image_weights: List of weights for image queries
35
+ rmag_min: Minimum r-band magnitude (default 13.0)
36
+ rmag_max: Maximum r-band magnitude (default 20.0)
37
+ expand_galaxy: Optional object_id to expand in modal after search
38
+
39
+ Returns:
40
+ URL-safe base64 encoded string
41
+ """
42
+ state = {}
43
+
44
+ # Only include non-empty query arrays
45
+ if text_queries:
46
+ state["tq"] = text_queries
47
+ state["tw"] = text_weights
48
+
49
+ if image_queries:
50
+ state["iq"] = image_queries
51
+ state["iw"] = image_weights
52
+
53
+ # Only include magnitude limits if they differ from defaults
54
+ if rmag_min != 13.0:
55
+ state["rmin"] = rmag_min
56
+ if rmag_max != 20.0:
57
+ state["rmax"] = rmag_max
58
+
59
+ # Include expand galaxy if specified
60
+ if expand_galaxy:
61
+ state["exp"] = expand_galaxy
62
+
63
+ # Encode to JSON then base64 (URL-safe)
64
+ json_str = json.dumps(state, separators=(',', ':')) # Compact JSON
65
+ encoded = base64.urlsafe_b64encode(json_str.encode('utf-8')).decode('utf-8')
66
+
67
+ # Remove padding (= characters) to make URL shorter - we'll add back on decode
68
+ return encoded.rstrip('=')
69
+
70
+
71
+ def decode_search_state(encoded: str) -> Dict[str, Any]:
72
+ """
73
+ Decode a base64-encoded search state string.
74
+
75
+ Args:
76
+ encoded: URL-safe base64 encoded string
77
+
78
+ Returns:
79
+ Dictionary with full keys:
80
+ {
81
+ 'text_queries': [...],
82
+ 'text_weights': [...],
83
+ 'image_queries': [...],
84
+ 'image_weights': [...],
85
+ 'rmag_min': float,
86
+ 'rmag_max': float,
87
+ 'expand_galaxy': str or None
88
+ }
89
+
90
+ Returns empty/default state if decoding fails.
91
+ """
92
+ # Default/empty state
93
+ default_state = {
94
+ 'text_queries': [],
95
+ 'text_weights': [],
96
+ 'image_queries': [],
97
+ 'image_weights': [],
98
+ 'rmag_min': 13.0,
99
+ 'rmag_max': 20.0,
100
+ 'expand_galaxy': None
101
+ }
102
+
103
+ if not encoded:
104
+ return default_state
105
+
106
+ try:
107
+ # Add back padding if needed
108
+ padding = 4 - (len(encoded) % 4)
109
+ if padding != 4:
110
+ encoded += '=' * padding
111
+
112
+ # Decode from base64 then JSON
113
+ json_str = base64.urlsafe_b64decode(encoded.encode('utf-8')).decode('utf-8')
114
+ compact_state = json.loads(json_str)
115
+
116
+ # Expand compact keys to full keys
117
+ state = {
118
+ 'text_queries': compact_state.get('tq', []),
119
+ 'text_weights': compact_state.get('tw', []),
120
+ 'image_queries': compact_state.get('iq', []),
121
+ 'image_weights': compact_state.get('iw', []),
122
+ 'rmag_min': compact_state.get('rmin', 13.0),
123
+ 'rmag_max': compact_state.get('rmax', 20.0),
124
+ 'expand_galaxy': compact_state.get('exp', None)
125
+ }
126
+
127
+ return state
128
+
129
+ except (ValueError, KeyError, json.JSONDecodeError, Exception):
130
+ # Silently fail and return empty state for any decode errors
131
+ return default_state
132
+
133
+
134
+ def build_share_url(
135
+ text_queries: List[str],
136
+ text_weights: List[float],
137
+ image_queries: List[Tuple[float, float, float]],
138
+ image_weights: List[float],
139
+ rmag_min: float = 13.0,
140
+ rmag_max: float = 20.0,
141
+ expand_galaxy: Optional[str] = None
142
+ ) -> str:
143
+ """
144
+ Build a complete shareable URL for the current search state.
145
+
146
+ Args:
147
+ Same as encode_search_state()
148
+
149
+ Returns:
150
+ Complete URL like: https://app.com?s=<encoded_state>
151
+ """
152
+ from src.config import URL_STATE_PARAM
153
+
154
+ # Get base URL from Flask request context
155
+ try:
156
+ if request and hasattr(request, 'host_url'):
157
+ base_url = request.host_url.rstrip('/')
158
+ else:
159
+ base_url = "http://localhost:8050"
160
+ except (RuntimeError, AttributeError):
161
+ # If not in request context (e.g., during testing), use a placeholder
162
+ base_url = "http://localhost:8050"
163
+
164
+ # Encode the state
165
+ encoded = encode_search_state(
166
+ text_queries=text_queries,
167
+ text_weights=text_weights,
168
+ image_queries=image_queries,
169
+ image_weights=image_weights,
170
+ rmag_min=rmag_min,
171
+ rmag_max=rmag_max,
172
+ expand_galaxy=expand_galaxy
173
+ )
174
+
175
+ return f"{base_url}?{URL_STATE_PARAM}={encoded}"
176
+
177
+
178
+ def parse_url_search_param(search_string: str) -> Dict[str, Any]:
179
+ """
180
+ Parse the search parameter from a URL query string.
181
+
182
+ Args:
183
+ search_string: The URL search/query string (e.g., "?s=eyJxdWVy...")
184
+
185
+ Returns:
186
+ Decoded state dictionary (same format as decode_search_state)
187
+ """
188
+ from src.config import URL_STATE_PARAM
189
+
190
+ if not search_string:
191
+ return decode_search_state("")
192
+
193
+ # Remove leading '?' if present
194
+ search_string = search_string.lstrip('?')
195
+
196
+ # Parse query parameters
197
+ params = {}
198
+ for param in search_string.split('&'):
199
+ if '=' in param:
200
+ key, value = param.split('=', 1)
201
+ params[key] = value
202
+
203
+ # Get the state parameter
204
+ encoded = params.get(URL_STATE_PARAM, '')
205
+
206
+ return decode_search_state(encoded)
src/utils.py CHANGED
@@ -2,10 +2,12 @@
2
 
3
  import json
4
  import logging
 
5
  from pathlib import Path
6
  from datetime import datetime
7
- from typing import Dict, Any
8
  from src.config import CUTOUT_FOV, CUTOUT_SIZE, VCU_COST_PER_MILLION
 
9
 
10
  logger = logging.getLogger(__name__)
11
 
@@ -34,9 +36,13 @@ def log_zilliz_query(
34
  query_info: Dict[str, Any],
35
  result_count: int,
36
  query_time: float,
37
- cost_vcu: int = 0
 
 
 
 
38
  ) -> None:
39
- """Print Zilliz query info to terminal instead of saving to file.
40
 
41
  Args:
42
  query_type: Type of query (e.g., "vector_search", "text_search")
@@ -44,6 +50,10 @@ def log_zilliz_query(
44
  result_count: Number of results returned
45
  query_time: Query execution time in seconds
46
  cost_vcu: Cost in vCU units
 
 
 
 
47
  """
48
  timestamp = datetime.now().isoformat()
49
 
@@ -72,6 +82,29 @@ def log_zilliz_query(
72
  f"{cost_vcu} vCU (${cost_usd:.6f})"
73
  )
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  def format_galaxy_count(count: int) -> str:
77
  """Format galaxy count with thousands separator.
@@ -104,69 +137,77 @@ def build_query_xml(
104
  rmag_max: Maximum r_mag filter value
105
 
106
  Returns:
107
- XML string representation of the query
108
  """
109
  xml_parts = ['<query>']
110
 
111
  # Add text queries
112
  if text_queries and len(text_queries) > 0:
113
- xml_parts.append(' <text>')
114
  for query, weight in zip(text_queries, text_weights):
115
- xml_parts.append(' <term>')
116
- xml_parts.append(f' <weight>{weight}</weight>')
117
- xml_parts.append(f' <content>{query}</content>')
118
- xml_parts.append(' </term>')
119
- xml_parts.append(' </text>')
120
 
121
  # Add image queries
122
  if image_queries and len(image_queries) > 0:
123
- xml_parts.append(' <image>')
124
  for img_query, weight in zip(image_queries, image_weights):
125
- xml_parts.append(' <reference>')
126
- xml_parts.append(f' <ra>{img_query["ra"]}</ra>')
127
- xml_parts.append(f' <dec>{img_query["dec"]}</dec>')
128
- xml_parts.append(f' <fov>{img_query["fov"]}</fov>')
129
- xml_parts.append(f' <weight>{weight}</weight>')
130
- xml_parts.append(' </reference>')
131
- xml_parts.append(' </image>')
132
 
133
  # Add filters
134
  if rmag_min is not None or rmag_max is not None:
135
- xml_parts.append(' <filters>')
136
  if rmag_min is not None and rmag_max is not None:
137
- xml_parts.append(' <filter>')
138
- xml_parts.append(' <column>r_mag</column>')
139
- xml_parts.append(' <operator>between</operator>')
140
- xml_parts.append(f' <value_min>{rmag_min}</value_min>')
141
- xml_parts.append(f' <value_max>{rmag_max}</value_max>')
142
- xml_parts.append(' </filter>')
143
  elif rmag_min is not None:
144
- xml_parts.append(' <filter>')
145
- xml_parts.append(' <column>r_mag</column>')
146
- xml_parts.append(' <operator>gte</operator>')
147
- xml_parts.append(f' <value>{rmag_min}</value>')
148
- xml_parts.append(' </filter>')
149
  elif rmag_max is not None:
150
- xml_parts.append(' <filter>')
151
- xml_parts.append(' <column>r_mag</column>')
152
- xml_parts.append(' <operator>lte</operator>')
153
- xml_parts.append(f' <value>{rmag_max}</value>')
154
- xml_parts.append(' </filter>')
155
- xml_parts.append(' </filters>')
156
 
157
  xml_parts.append('</query>')
158
- return '\n'.join(xml_parts)
159
 
160
 
161
  def log_query_to_csv(
162
  query_xml: str,
163
- csv_path: str = "logs/query_log.csv"
 
 
 
 
164
  ) -> None:
165
- """Print query XML to terminal instead of saving to file.
166
 
167
  Args:
168
  query_xml: XML string representation of the query
169
  csv_path: Deprecated parameter (kept for backward compatibility)
 
 
 
 
170
  """
171
  timestamp = datetime.now().isoformat()
172
 
@@ -178,3 +219,60 @@ def log_query_to_csv(
178
  print("="*80 + "\n")
179
 
180
  logger.info(f"Query printed to terminal")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import json
4
  import logging
5
+ import uuid
6
  from pathlib import Path
7
  from datetime import datetime
8
+ from typing import Dict, Any, Optional
9
  from src.config import CUTOUT_FOV, CUTOUT_SIZE, VCU_COST_PER_MILLION
10
+ from src.hf_logging import log_query_event, SESSION_ID
11
 
12
  logger = logging.getLogger(__name__)
13
 
 
36
  query_info: Dict[str, Any],
37
  result_count: int,
38
  query_time: float,
39
+ cost_vcu: int = 0,
40
+ request_id: Optional[str] = None,
41
+ error_occurred: bool = False,
42
+ error_message: Optional[str] = None,
43
+ error_type: Optional[str] = None
44
  ) -> None:
45
+ """Print Zilliz query info to terminal and log to HF dataset.
46
 
47
  Args:
48
  query_type: Type of query (e.g., "vector_search", "text_search")
 
50
  result_count: Number of results returned
51
  query_time: Query execution time in seconds
52
  cost_vcu: Cost in vCU units
53
+ request_id: Unique ID for this request
54
+ error_occurred: Whether an error occurred
55
+ error_message: Error message if error_occurred is True
56
+ error_type: Type of error if error_occurred is True
57
  """
58
  timestamp = datetime.now().isoformat()
59
 
 
82
  f"{cost_vcu} vCU (${cost_usd:.6f})"
83
  )
84
 
85
+ # Log Zilliz stats to HF dataset
86
+ try:
87
+ payload = {
88
+ "log_type": "zilliz_query_stats",
89
+ "timestamp": timestamp,
90
+ "query_type": query_type,
91
+ "query_info": query_info,
92
+ "result_count": result_count,
93
+ "query_time_seconds": query_time,
94
+ "cost_vcu": cost_vcu,
95
+ "cost_usd": cost_usd,
96
+ "error_occurred": error_occurred,
97
+ }
98
+ if request_id:
99
+ payload["request_id"] = request_id
100
+ if error_occurred:
101
+ payload["error_message"] = error_message
102
+ payload["error_type"] = error_type
103
+
104
+ log_query_event(payload)
105
+ except Exception as e:
106
+ logger.error(f"Failed to send Zilliz stats to HF dataset: {e}")
107
+
108
 
109
  def format_galaxy_count(count: int) -> str:
110
  """Format galaxy count with thousands separator.
 
137
  rmag_max: Maximum r_mag filter value
138
 
139
  Returns:
140
+ XML string representation of the query (single line)
141
  """
142
  xml_parts = ['<query>']
143
 
144
  # Add text queries
145
  if text_queries and len(text_queries) > 0:
146
+ xml_parts.append('<text>')
147
  for query, weight in zip(text_queries, text_weights):
148
+ xml_parts.append('<term>')
149
+ xml_parts.append(f'<weight>{weight}</weight>')
150
+ xml_parts.append(f'<content>{query}</content>')
151
+ xml_parts.append('</term>')
152
+ xml_parts.append('</text>')
153
 
154
  # Add image queries
155
  if image_queries and len(image_queries) > 0:
156
+ xml_parts.append('<image>')
157
  for img_query, weight in zip(image_queries, image_weights):
158
+ xml_parts.append('<reference>')
159
+ xml_parts.append(f'<ra>{img_query["ra"]}</ra>')
160
+ xml_parts.append(f'<dec>{img_query["dec"]}</dec>')
161
+ xml_parts.append(f'<fov>{img_query["fov"]}</fov>')
162
+ xml_parts.append(f'<weight>{weight}</weight>')
163
+ xml_parts.append('</reference>')
164
+ xml_parts.append('</image>')
165
 
166
  # Add filters
167
  if rmag_min is not None or rmag_max is not None:
168
+ xml_parts.append('<filters>')
169
  if rmag_min is not None and rmag_max is not None:
170
+ xml_parts.append('<filter>')
171
+ xml_parts.append('<column>r_mag</column>')
172
+ xml_parts.append('<operator>between</operator>')
173
+ xml_parts.append(f'<value_min>{rmag_min}</value_min>')
174
+ xml_parts.append(f'<value_max>{rmag_max}</value_max>')
175
+ xml_parts.append('</filter>')
176
  elif rmag_min is not None:
177
+ xml_parts.append('<filter>')
178
+ xml_parts.append('<column>r_mag</column>')
179
+ xml_parts.append('<operator>gte</operator>')
180
+ xml_parts.append(f'<value>{rmag_min}</value>')
181
+ xml_parts.append('</filter>')
182
  elif rmag_max is not None:
183
+ xml_parts.append('<filter>')
184
+ xml_parts.append('<column>r_mag</column>')
185
+ xml_parts.append('<operator>lte</operator>')
186
+ xml_parts.append(f'<value>{rmag_max}</value>')
187
+ xml_parts.append('</filter>')
188
+ xml_parts.append('</filters>')
189
 
190
  xml_parts.append('</query>')
191
+ return ''.join(xml_parts)
192
 
193
 
194
  def log_query_to_csv(
195
  query_xml: str,
196
+ csv_path: str = "logs/query_log.csv",
197
+ request_id: Optional[str] = None,
198
+ error_occurred: bool = False,
199
+ error_message: Optional[str] = None,
200
+ error_type: Optional[str] = None
201
  ) -> None:
202
+ """Print query XML to terminal and log to HF dataset.
203
 
204
  Args:
205
  query_xml: XML string representation of the query
206
  csv_path: Deprecated parameter (kept for backward compatibility)
207
+ request_id: Unique ID for this request
208
+ error_occurred: Whether an error occurred during search
209
+ error_message: Error message if error_occurred is True
210
+ error_type: Type of error if error_occurred is True
211
  """
212
  timestamp = datetime.now().isoformat()
213
 
 
219
  print("="*80 + "\n")
220
 
221
  logger.info(f"Query printed to terminal")
222
+
223
+ # Log to HF dataset
224
+ try:
225
+ payload = {
226
+ "log_type": "aql_query",
227
+ "timestamp": timestamp,
228
+ "query_xml": query_xml,
229
+ "error_occurred": error_occurred,
230
+ }
231
+ if request_id:
232
+ payload["request_id"] = request_id
233
+ if error_occurred:
234
+ payload["error_message"] = error_message
235
+ payload["error_type"] = error_type
236
+
237
+ log_query_event(payload)
238
+ except Exception as e:
239
+ logger.error(f"Failed to send query log to HF dataset: {e}")
240
+
241
+
242
+ def log_click_event(
243
+ request_id: Optional[str],
244
+ rank: int,
245
+ primary_key: str,
246
+ ra: float,
247
+ dec: float,
248
+ r_mag: float,
249
+ distance: float
250
+ ) -> None:
251
+ """Log a galaxy tile click event to HF dataset.
252
+
253
+ Args:
254
+ request_id: Unique ID for the search request that produced this galaxy
255
+ rank: Position in search results (0-indexed)
256
+ primary_key: Primary key of the clicked galaxy
257
+ ra: Right ascension
258
+ dec: Declination
259
+ r_mag: r-band magnitude
260
+ distance: Cosine similarity score
261
+ """
262
+ try:
263
+ payload = {
264
+ "log_type": "click_event",
265
+ "rank": rank,
266
+ "primary_key": primary_key,
267
+ "ra": ra,
268
+ "dec": dec,
269
+ "r_mag": r_mag,
270
+ "distance": distance,
271
+ }
272
+ if request_id:
273
+ payload["request_id"] = request_id
274
+
275
+ log_query_event(payload)
276
+ logger.info(f"Logged click event: rank={rank}, primary_key={primary_key}")
277
+ except Exception as e:
278
+ logger.error(f"Failed to log click event: {e}")