Spaces:
Running
Running
Commit ·
28fcc08
1
Parent(s): 14148a2
Added moderation and tutorial
Browse files- src/components.py +49 -7
- src/services.py +114 -61
src/components.py
CHANGED
|
@@ -488,9 +488,21 @@ def create_header():
|
|
| 488 |
dbc.Col([
|
| 489 |
html.Div([
|
| 490 |
html.H1("galaxy semantic search", className="galaxy-title text-center mb-1"),
|
| 491 |
-
html.P(
|
| 492 |
-
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
html.Div(id="galaxy-count", className="galaxy-count text-center")
|
| 495 |
], className="text-center mb-3")
|
| 496 |
])
|
|
@@ -533,13 +545,29 @@ def create_search_container():
|
|
| 533 |
return dbc.Row([
|
| 534 |
dbc.Col([
|
| 535 |
html.Div([
|
| 536 |
-
# Info button in top right
|
| 537 |
html.Div([
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
dbc.Button([
|
| 539 |
html.I(className="fas fa-info-circle")
|
| 540 |
], id="info-button", color="link", size="sm",
|
| 541 |
className="info-button")
|
| 542 |
-
], style={"position": "absolute", "top": "8px", "right": "8px", "z-index": "1000"
|
|
|
|
| 543 |
|
| 544 |
# Example search buttons
|
| 545 |
html.Div([
|
|
@@ -886,9 +914,23 @@ def create_info_modal():
|
|
| 886 |
html.P("Images are from DESI Legacy Surveys DR10 via the hips2fits service provided by the Strasbourg Astronomical Data Centre (CDS).",
|
| 887 |
style={"color": "rgba(245, 245, 247, 0.6)", "margin-bottom": "0", "font-size": "0.75rem"})
|
| 888 |
]),
|
| 889 |
-
dbc.ModalFooter(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
dbc.Button("Close", id="close-info-modal", className="ms-auto")
|
| 891 |
-
)
|
| 892 |
], id="info-modal", size="lg", is_open=False)
|
| 893 |
|
| 894 |
|
|
|
|
| 488 |
dbc.Col([
|
| 489 |
html.Div([
|
| 490 |
html.H1("galaxy semantic search", className="galaxy-title text-center mb-1"),
|
| 491 |
+
html.P([
|
| 492 |
+
"powered by ",
|
| 493 |
+
html.A(
|
| 494 |
+
"AION-Search",
|
| 495 |
+
href="https://aion-search.github.io/",
|
| 496 |
+
target="_blank",
|
| 497 |
+
rel="noopener noreferrer",
|
| 498 |
+
style={
|
| 499 |
+
"color": "rgba(245, 245, 247, 0.5)",
|
| 500 |
+
"textDecoration": "underline"
|
| 501 |
+
}
|
| 502 |
+
)
|
| 503 |
+
], className="text-center mb-2",
|
| 504 |
+
style={"color": "rgba(245, 245, 247, 0.5)", "font-weight": "300",
|
| 505 |
+
"font-size": "0.8rem", "letter-spacing": "0.05em"}),
|
| 506 |
html.Div(id="galaxy-count", className="galaxy-count text-center")
|
| 507 |
], className="text-center mb-3")
|
| 508 |
])
|
|
|
|
| 545 |
return dbc.Row([
|
| 546 |
dbc.Col([
|
| 547 |
html.Div([
|
| 548 |
+
# Info button and tutorial link in top right
|
| 549 |
html.Div([
|
| 550 |
+
html.A(
|
| 551 |
+
"Tutorial",
|
| 552 |
+
href="https://blog.nolank.ca/aion-search/#best-practices",
|
| 553 |
+
target="_blank",
|
| 554 |
+
rel="noopener noreferrer",
|
| 555 |
+
style={
|
| 556 |
+
"textDecoration": "underline",
|
| 557 |
+
"marginRight": "0.1rem",
|
| 558 |
+
"color": "rgba(245, 245, 247, 0.6)",
|
| 559 |
+
"fontSize": "1.0rem",
|
| 560 |
+
"fontWeight": "600",
|
| 561 |
+
"letterSpacing": "0.03em",
|
| 562 |
+
"transition": "all 0.3s ease"
|
| 563 |
+
}
|
| 564 |
+
),
|
| 565 |
dbc.Button([
|
| 566 |
html.I(className="fas fa-info-circle")
|
| 567 |
], id="info-button", color="link", size="sm",
|
| 568 |
className="info-button")
|
| 569 |
+
], style={"position": "absolute", "top": "8px", "right": "8px", "z-index": "1000",
|
| 570 |
+
"display": "flex", "alignItems": "center"}),
|
| 571 |
|
| 572 |
# Example search buttons
|
| 573 |
html.Div([
|
|
|
|
| 914 |
html.P("Images are from DESI Legacy Surveys DR10 via the hips2fits service provided by the Strasbourg Astronomical Data Centre (CDS).",
|
| 915 |
style={"color": "rgba(245, 245, 247, 0.6)", "margin-bottom": "0", "font-size": "0.75rem"})
|
| 916 |
]),
|
| 917 |
+
dbc.ModalFooter([
|
| 918 |
+
html.A(
|
| 919 |
+
dbc.Button([html.I(className="fas fa-file-alt me-2"), "Paper"], color="secondary"),
|
| 920 |
+
href="https://arxiv.org/abs/2512.11982",
|
| 921 |
+
target="_blank",
|
| 922 |
+
rel="noopener noreferrer",
|
| 923 |
+
className="me-2"
|
| 924 |
+
),
|
| 925 |
+
html.A(
|
| 926 |
+
dbc.Button([html.I(className="fas fa-book me-2"), "Tutorial"], color="secondary"),
|
| 927 |
+
href="https://blog.nolank.ca/aion-search/#best-practices",
|
| 928 |
+
target="_blank",
|
| 929 |
+
rel="noopener noreferrer",
|
| 930 |
+
className="me-2"
|
| 931 |
+
),
|
| 932 |
dbc.Button("Close", id="close-info-modal", className="ms-auto")
|
| 933 |
+
])
|
| 934 |
], id="info-modal", size="lg", is_open=False)
|
| 935 |
|
| 936 |
|
src/services.py
CHANGED
|
@@ -185,6 +185,29 @@ class EmbeddingService:
|
|
| 185 |
self.openai_client = OpenAI(api_key=OPENAI_API_KEY)
|
| 186 |
return self.openai_client
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
def encode_text_query(self, query: str) -> np.ndarray:
|
| 189 |
"""Encode text query using OpenAI embeddings + CLIP text projector.
|
| 190 |
|
|
@@ -194,6 +217,10 @@ class EmbeddingService:
|
|
| 194 |
Returns:
|
| 195 |
CLIP embedding vector
|
| 196 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
client = self._get_openai_client()
|
| 198 |
|
| 199 |
# Get OpenAI text embedding
|
|
@@ -220,6 +247,11 @@ class EmbeddingService:
|
|
| 220 |
Returns:
|
| 221 |
Combined normalized embedding vector
|
| 222 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
client = self._get_openai_client()
|
| 224 |
|
| 225 |
# Get all embeddings at once for efficiency
|
|
@@ -426,14 +458,21 @@ class SearchService:
|
|
| 426 |
Returns:
|
| 427 |
DataFrame with search results
|
| 428 |
"""
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
| 438 |
def search_vector(
|
| 439 |
self,
|
|
@@ -455,14 +494,21 @@ class SearchService:
|
|
| 455 |
Returns:
|
| 456 |
DataFrame with search results
|
| 457 |
"""
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
|
| 467 |
def search_advanced(
|
| 468 |
self,
|
|
@@ -488,51 +534,58 @@ class SearchService:
|
|
| 488 |
Returns:
|
| 489 |
DataFrame with search results
|
| 490 |
"""
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
# Process text queries
|
| 494 |
-
if text_queries and len(text_queries) > 0:
|
| 495 |
-
for query, weight in zip(text_queries, text_weights):
|
| 496 |
-
query_embedding = self.embedding_service.encode_text_query(query)
|
| 497 |
-
|
| 498 |
-
# Apply weight
|
| 499 |
-
weighted_embedding = query_embedding * weight
|
| 500 |
-
|
| 501 |
-
if combined_embedding is None:
|
| 502 |
-
combined_embedding = weighted_embedding
|
| 503 |
-
else:
|
| 504 |
-
combined_embedding += weighted_embedding
|
| 505 |
-
|
| 506 |
-
# Process image queries
|
| 507 |
-
if image_queries and len(image_queries) > 0:
|
| 508 |
-
if self.image_service is None:
|
| 509 |
-
raise RuntimeError("Image service not initialized")
|
| 510 |
-
|
| 511 |
-
for img_query, weight in zip(image_queries, image_weights):
|
| 512 |
-
# Encode image
|
| 513 |
-
image_embedding = self.image_service.encode_image(
|
| 514 |
-
ra=img_query['ra'],
|
| 515 |
-
dec=img_query['dec'],
|
| 516 |
-
fov=img_query.get('fov', 0.025),
|
| 517 |
-
size=256
|
| 518 |
-
)
|
| 519 |
-
|
| 520 |
-
# Apply weight
|
| 521 |
-
weighted_embedding = image_embedding * weight
|
| 522 |
-
|
| 523 |
-
if combined_embedding is None:
|
| 524 |
-
combined_embedding = weighted_embedding
|
| 525 |
-
else:
|
| 526 |
-
combined_embedding += weighted_embedding
|
| 527 |
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
combined_embedding = combined_embedding / norm
|
| 533 |
|
| 534 |
-
|
| 535 |
-
|
| 536 |
|
| 537 |
-
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
self.openai_client = OpenAI(api_key=OPENAI_API_KEY)
|
| 186 |
return self.openai_client
|
| 187 |
|
| 188 |
+
def _moderate_content(self, text: str) -> bool:
|
| 189 |
+
"""Check if text content is appropriate using OpenAI Moderation API.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
text: Text to moderate
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
True if content is safe, False if flagged
|
| 196 |
+
"""
|
| 197 |
+
try:
|
| 198 |
+
client = self._get_openai_client()
|
| 199 |
+
response = client.moderations.create(input=text)
|
| 200 |
+
|
| 201 |
+
# If any category is flagged, reject the content
|
| 202 |
+
if response.results[0].flagged:
|
| 203 |
+
logger.warning(f"Content moderation flagged input")
|
| 204 |
+
return False
|
| 205 |
+
return True
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.error(f"Moderation API error: {e}")
|
| 208 |
+
# On error, allow the content through (fail open)
|
| 209 |
+
return True
|
| 210 |
+
|
| 211 |
def encode_text_query(self, query: str) -> np.ndarray:
|
| 212 |
"""Encode text query using OpenAI embeddings + CLIP text projector.
|
| 213 |
|
|
|
|
| 217 |
Returns:
|
| 218 |
CLIP embedding vector
|
| 219 |
"""
|
| 220 |
+
# Moderate content first
|
| 221 |
+
if not self._moderate_content(query):
|
| 222 |
+
raise ValueError("Content moderation filter triggered")
|
| 223 |
+
|
| 224 |
client = self._get_openai_client()
|
| 225 |
|
| 226 |
# Get OpenAI text embedding
|
|
|
|
| 247 |
Returns:
|
| 248 |
Combined normalized embedding vector
|
| 249 |
"""
|
| 250 |
+
# Moderate all queries first
|
| 251 |
+
for query in queries:
|
| 252 |
+
if not self._moderate_content(query):
|
| 253 |
+
raise ValueError("Content moderation filter triggered")
|
| 254 |
+
|
| 255 |
client = self._get_openai_client()
|
| 256 |
|
| 257 |
# Get all embeddings at once for efficiency
|
|
|
|
| 458 |
Returns:
|
| 459 |
DataFrame with search results
|
| 460 |
"""
|
| 461 |
+
try:
|
| 462 |
+
# Encode query
|
| 463 |
+
query_embedding = self.embedding_service.encode_text_query(query)
|
| 464 |
+
|
| 465 |
+
# Build filter
|
| 466 |
+
filter_expr = self._build_rmag_filter(rmag_min, rmag_max)
|
| 467 |
+
|
| 468 |
+
# Search Zilliz
|
| 469 |
+
return self.zilliz_service.search(query_embedding, top_k, filter_expr)
|
| 470 |
+
except ValueError as e:
|
| 471 |
+
# Content moderation triggered - return empty results silently
|
| 472 |
+
if "moderation" in str(e).lower():
|
| 473 |
+
logger.info("Search blocked by content moderation")
|
| 474 |
+
return pd.DataFrame()
|
| 475 |
+
raise
|
| 476 |
|
| 477 |
def search_vector(
|
| 478 |
self,
|
|
|
|
| 494 |
Returns:
|
| 495 |
DataFrame with search results
|
| 496 |
"""
|
| 497 |
+
try:
|
| 498 |
+
# Encode and combine vectors
|
| 499 |
+
combined_embedding = self.embedding_service.encode_vector_queries(queries, operations)
|
| 500 |
+
|
| 501 |
+
# Build filter
|
| 502 |
+
filter_expr = self._build_rmag_filter(rmag_min, rmag_max)
|
| 503 |
+
|
| 504 |
+
# Search Zilliz
|
| 505 |
+
return self.zilliz_service.search(combined_embedding, top_k, filter_expr)
|
| 506 |
+
except ValueError as e:
|
| 507 |
+
# Content moderation triggered - return empty results silently
|
| 508 |
+
if "moderation" in str(e).lower():
|
| 509 |
+
logger.info("Search blocked by content moderation")
|
| 510 |
+
return pd.DataFrame()
|
| 511 |
+
raise
|
| 512 |
|
| 513 |
def search_advanced(
|
| 514 |
self,
|
|
|
|
| 534 |
Returns:
|
| 535 |
DataFrame with search results
|
| 536 |
"""
|
| 537 |
+
try:
|
| 538 |
+
combined_embedding = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
|
| 540 |
+
# Process text queries
|
| 541 |
+
if text_queries and len(text_queries) > 0:
|
| 542 |
+
for query, weight in zip(text_queries, text_weights):
|
| 543 |
+
query_embedding = self.embedding_service.encode_text_query(query)
|
|
|
|
| 544 |
|
| 545 |
+
# Apply weight
|
| 546 |
+
weighted_embedding = query_embedding * weight
|
| 547 |
|
| 548 |
+
if combined_embedding is None:
|
| 549 |
+
combined_embedding = weighted_embedding
|
| 550 |
+
else:
|
| 551 |
+
combined_embedding += weighted_embedding
|
| 552 |
+
|
| 553 |
+
# Process image queries
|
| 554 |
+
if image_queries and len(image_queries) > 0:
|
| 555 |
+
if self.image_service is None:
|
| 556 |
+
raise RuntimeError("Image service not initialized")
|
| 557 |
+
|
| 558 |
+
for img_query, weight in zip(image_queries, image_weights):
|
| 559 |
+
# Encode image
|
| 560 |
+
image_embedding = self.image_service.encode_image(
|
| 561 |
+
ra=img_query['ra'],
|
| 562 |
+
dec=img_query['dec'],
|
| 563 |
+
fov=img_query.get('fov', 0.025),
|
| 564 |
+
size=256
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
# Apply weight
|
| 568 |
+
weighted_embedding = image_embedding * weight
|
| 569 |
+
|
| 570 |
+
if combined_embedding is None:
|
| 571 |
+
combined_embedding = weighted_embedding
|
| 572 |
+
else:
|
| 573 |
+
combined_embedding += weighted_embedding
|
| 574 |
+
|
| 575 |
+
# Normalize the final combined embedding
|
| 576 |
+
if combined_embedding is not None:
|
| 577 |
+
norm = np.linalg.norm(combined_embedding)
|
| 578 |
+
if norm > 0:
|
| 579 |
+
combined_embedding = combined_embedding / norm
|
| 580 |
+
|
| 581 |
+
# Build filter
|
| 582 |
+
filter_expr = self._build_rmag_filter(rmag_min, rmag_max)
|
| 583 |
+
|
| 584 |
+
# Search Zilliz
|
| 585 |
+
return self.zilliz_service.search(combined_embedding, top_k, filter_expr)
|
| 586 |
+
except ValueError as e:
|
| 587 |
+
# Content moderation triggered - return empty results silently
|
| 588 |
+
if "moderation" in str(e).lower():
|
| 589 |
+
logger.info("Search blocked by content moderation")
|
| 590 |
+
return pd.DataFrame()
|
| 591 |
+
raise
|