mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-20 23:21:06 +02:00
added initial backend files
This commit is contained in:
parent
5acc05a119
commit
0b32b66888
10 changed files with 3485 additions and 2462 deletions
217
surfsense_backend/app/connectors/elasticsearch_connector.py
Normal file
217
surfsense_backend/app/connectors/elasticsearch_connector.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from elasticsearch import AsyncElasticsearch
|
||||
from elasticsearch.exceptions import AuthenticationException, ConnectionError
|
||||
|
||||
from app.schemas.connector import ConnectorConfig
|
||||
from app.schemas.documents import DocumentCreate, DocumentType
|
||||
from app.utils.document_converter import DocumentConverter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ElasticsearchConnector:
|
||||
def __init__(self, config: ConnectorConfig):
|
||||
self.config = config
|
||||
self.client: AsyncElasticsearch | None = None
|
||||
self.document_converter = DocumentConverter()
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Establish connection to Elasticsearch"""
|
||||
try:
|
||||
connection_params = {
|
||||
"hosts": [f"{self.config.hostname}:{self.config.port}"],
|
||||
"verify_certs": self.config.ssl_enabled
|
||||
if hasattr(self.config, "ssl_enabled")
|
||||
else True,
|
||||
"request_timeout": 30,
|
||||
}
|
||||
|
||||
# Add authentication if provided
|
||||
if (
|
||||
hasattr(self.config, "username")
|
||||
and hasattr(self.config, "password")
|
||||
and self.config.username
|
||||
and self.config.password
|
||||
):
|
||||
connection_params["basic_auth"] = (
|
||||
self.config.username,
|
||||
self.config.password,
|
||||
)
|
||||
|
||||
# Add API key authentication if provided
|
||||
if hasattr(self.config, "api_key") and self.config.api_key:
|
||||
connection_params["api_key"] = self.config.api_key
|
||||
|
||||
self.client = AsyncElasticsearch(**connection_params)
|
||||
|
||||
# Test connection
|
||||
await self.client.info()
|
||||
logger.info("Successfully connected to Elasticsearch")
|
||||
return True
|
||||
|
||||
except (ConnectionError, AuthenticationException) as e:
|
||||
logger.error(f"Failed to connect to Elasticsearch: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error connecting to Elasticsearch: {e}")
|
||||
return False
|
||||
|
||||
async def disconnect(self):
|
||||
"""Close Elasticsearch connection"""
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
self.client = None
|
||||
|
||||
async def test_connection(self) -> dict[str, Any]:
|
||||
"""Test the Elasticsearch connection and return cluster info"""
|
||||
try:
|
||||
if not self.client:
|
||||
await self.connect()
|
||||
|
||||
if not self.client:
|
||||
return {"success": False, "error": "Failed to establish connection"}
|
||||
|
||||
info = await self.client.info()
|
||||
indices = await self.client.cat.indices(format="json")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"cluster_name": info.get("cluster_name"),
|
||||
"version": info.get("version", {}).get("number"),
|
||||
"indices_count": len(indices) if indices else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def get_indices(self) -> list[str]:
|
||||
"""Get list of available indices"""
|
||||
try:
|
||||
if not self.client:
|
||||
await self.connect()
|
||||
|
||||
indices = await self.client.cat.indices(format="json")
|
||||
return [idx["index"] for idx in indices if not idx["index"].startswith(".")]
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching indices: {e}")
|
||||
return []
|
||||
|
||||
async def search_documents(
|
||||
self,
|
||||
query: str,
|
||||
indices: list[str] | None = None,
|
||||
size: int = 100,
|
||||
fields: list[str] | None = None,
|
||||
) -> AsyncGenerator[DocumentCreate, None]:
|
||||
"""Search documents in Elasticsearch and yield them as DocumentCreate objects"""
|
||||
try:
|
||||
if not self.client:
|
||||
await self.connect()
|
||||
|
||||
if not self.client:
|
||||
logger.error("No Elasticsearch client available")
|
||||
return
|
||||
|
||||
# Build search query
|
||||
search_body = {
|
||||
"query": {
|
||||
"multi_match": {
|
||||
"query": query,
|
||||
"fields": fields or ["*"],
|
||||
"type": "best_fields",
|
||||
"fuzziness": "AUTO",
|
||||
}
|
||||
},
|
||||
"size": size,
|
||||
"_source": True,
|
||||
}
|
||||
|
||||
# Search across specified indices or all indices
|
||||
index_pattern = ",".join(indices) if indices else "*"
|
||||
|
||||
response = await self.client.search(index=index_pattern, body=search_body)
|
||||
|
||||
for hit in response["hits"]["hits"]:
|
||||
try:
|
||||
# Convert Elasticsearch document to SurfSense document
|
||||
document = await self._convert_es_document(hit)
|
||||
if document:
|
||||
yield document
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to convert document {hit.get('_id')}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching Elasticsearch: {e}")
|
||||
|
||||
async def get_document_by_id(
|
||||
self, index: str, doc_id: str
|
||||
) -> DocumentCreate | None:
|
||||
"""Get a specific document by ID"""
|
||||
try:
|
||||
if not self.client:
|
||||
await self.connect()
|
||||
|
||||
response = await self.client.get(index=index, id=doc_id)
|
||||
return await self._convert_es_document(response)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching document {doc_id} from {index}: {e}")
|
||||
return None
|
||||
|
||||
async def _convert_es_document(
|
||||
self, es_doc: dict[str, Any]
|
||||
) -> DocumentCreate | None:
|
||||
"""Convert Elasticsearch document to SurfSense DocumentCreate"""
|
||||
try:
|
||||
source = es_doc.get("_source", {})
|
||||
|
||||
# Extract title - try common field names
|
||||
title = (
|
||||
source.get("title")
|
||||
or source.get("name")
|
||||
or source.get("subject")
|
||||
or source.get("filename")
|
||||
or f"Document {es_doc.get('_id', 'Unknown')}"
|
||||
)
|
||||
|
||||
# Extract content - try common field names
|
||||
content = (
|
||||
source.get("content")
|
||||
or source.get("text")
|
||||
or source.get("body")
|
||||
or source.get("message")
|
||||
or json.dumps(source) # Fallback to full source as JSON
|
||||
)
|
||||
|
||||
# Generate URL pointing back to Elasticsearch
|
||||
url = f"elasticsearch://{es_doc.get('_index')}/{es_doc.get('_id')}"
|
||||
|
||||
# Convert content to markdown if it's HTML
|
||||
if content and isinstance(content, str):
|
||||
content = await self.document_converter.convert_to_markdown(content)
|
||||
|
||||
return DocumentCreate(
|
||||
title=title[:500], # Limit title length
|
||||
content=content,
|
||||
url=url,
|
||||
document_type=DocumentType.ELASTICSEARCH,
|
||||
metadata={
|
||||
"elasticsearch_index": es_doc.get("_index"),
|
||||
"elasticsearch_id": es_doc.get("_id"),
|
||||
"elasticsearch_score": es_doc.get("_score"),
|
||||
"source_fields": list(source.keys()),
|
||||
**{
|
||||
k: v
|
||||
for k, v in source.items()
|
||||
if k not in ["content", "text", "body", "message"]
|
||||
and isinstance(v, str | int | float | bool)
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting Elasticsearch document: {e}")
|
||||
return None
|
||||
|
|
@ -50,6 +50,7 @@ class DocumentType(str, Enum):
|
|||
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR"
|
||||
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR"
|
||||
LUMA_CONNECTOR = "LUMA_CONNECTOR"
|
||||
ELASTICSEARCH = "ELASTICSEARCH"
|
||||
|
||||
|
||||
class SearchSourceConnectorType(str, Enum):
|
||||
|
|
@ -68,6 +69,7 @@ class SearchSourceConnectorType(str, Enum):
|
|||
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR"
|
||||
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR"
|
||||
LUMA_CONNECTOR = "LUMA_CONNECTOR"
|
||||
ELASTICSEARCH = "ELASTICSEARCH"
|
||||
|
||||
|
||||
class ChatType(str, Enum):
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from .airtable_add_connector_route import (
|
|||
)
|
||||
from .chats_routes import router as chats_router
|
||||
from .documents_routes import router as documents_router
|
||||
from .elasticsearch_add_connector_route import router as elasticsearch_router
|
||||
from .google_calendar_add_connector_route import (
|
||||
router as google_calendar_add_connector_router,
|
||||
)
|
||||
|
|
@ -24,6 +25,7 @@ router.include_router(search_spaces_router)
|
|||
router.include_router(documents_router)
|
||||
router.include_router(podcasts_router)
|
||||
router.include_router(chats_router)
|
||||
router.include_router(elasticsearch_router)
|
||||
router.include_router(search_source_connectors_router)
|
||||
router.include_router(google_calendar_add_connector_router)
|
||||
router.include_router(google_gmail_add_connector_router)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,526 @@
|
|||
import hashlib
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import config
|
||||
from app.connectors.elasticsearch_connector import ElasticsearchConnector
|
||||
from app.db import (
|
||||
Chunk,
|
||||
Document,
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas.users import User
|
||||
from app.tasks.connector_indexers.elasticsearch_indexer import ElasticsearchIndexer
|
||||
from app.users import current_active_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/elasticsearch", tags=["elasticsearch"])
|
||||
|
||||
|
||||
class ElasticsearchConnectorConfig(BaseModel):
|
||||
hostname: str
|
||||
port: int = 9200
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
api_key: str | None = None
|
||||
ssl_enabled: bool = True
|
||||
indices: list[str] | None = None
|
||||
query: str = "*"
|
||||
search_fields: list[str] | None = None
|
||||
max_documents: int = 1000
|
||||
|
||||
|
||||
@router.post("/add-connector")
|
||||
async def add_elasticsearch_connector(
|
||||
connector_data: ElasticsearchConnectorConfig,
|
||||
current_user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Add a new Elasticsearch connector for the current user"""
|
||||
|
||||
# Test the connection before saving
|
||||
elasticsearch_connector = ElasticsearchConnector(connector_data)
|
||||
|
||||
try:
|
||||
connection_test = await elasticsearch_connector.test_connection()
|
||||
if not connection_test.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to connect to Elasticsearch: {connection_test.get('error')}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing Elasticsearch connection: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to test Elasticsearch connection: {e!s}",
|
||||
) from e
|
||||
finally:
|
||||
await elasticsearch_connector.disconnect()
|
||||
|
||||
try:
|
||||
# Check if connector already exists for this user
|
||||
existing_connector_result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.user_id == current_user.id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.ELASTICSEARCH,
|
||||
)
|
||||
)
|
||||
existing_connector = existing_connector_result.scalars().first()
|
||||
|
||||
if existing_connector:
|
||||
# Update existing connector
|
||||
existing_connector.config = connector_data.model_dump()
|
||||
existing_connector.name = (
|
||||
f"Elasticsearch - {connector_data.hostname}:{connector_data.port}"
|
||||
)
|
||||
existing_connector.is_indexable = True
|
||||
logger.info(
|
||||
f"Updated existing Elasticsearch connector for user {current_user.id}"
|
||||
)
|
||||
else:
|
||||
# Create new connector
|
||||
new_connector = SearchSourceConnector(
|
||||
name=f"Elasticsearch - {connector_data.hostname}:{connector_data.port}",
|
||||
connector_type=SearchSourceConnectorType.ELASTICSEARCH,
|
||||
is_indexable=True,
|
||||
config=connector_data.model_dump(),
|
||||
user_id=current_user.id,
|
||||
)
|
||||
session.add(new_connector)
|
||||
logger.info(
|
||||
f"Created new Elasticsearch connector for user {current_user.id}"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Get the connector (either existing or new) to return
|
||||
if existing_connector:
|
||||
connector = existing_connector
|
||||
else:
|
||||
await session.refresh(new_connector)
|
||||
connector = new_connector
|
||||
|
||||
logger.info(
|
||||
f"Successfully saved Elasticsearch connector for user {current_user.id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Elasticsearch connector added successfully",
|
||||
"connector": {
|
||||
"id": connector.id,
|
||||
"name": connector.name,
|
||||
"connector_type": connector.connector_type.value,
|
||||
"is_indexable": connector.is_indexable,
|
||||
"created_at": connector.created_at.isoformat()
|
||||
if connector.created_at
|
||||
else None,
|
||||
"last_indexed_at": connector.last_indexed_at.isoformat()
|
||||
if connector.last_indexed_at
|
||||
else None,
|
||||
},
|
||||
}
|
||||
|
||||
except IntegrityError as e:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Integrity error: A connector with this configuration already exists. {e!s}",
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Elasticsearch connector: {e!s}")
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create Elasticsearch connector: {e!s}",
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/connectors")
|
||||
async def get_elasticsearch_connectors(
|
||||
current_user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Get all Elasticsearch connectors for the current user"""
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.user_id == current_user.id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.ELASTICSEARCH,
|
||||
)
|
||||
)
|
||||
connectors = result.scalars().all()
|
||||
|
||||
connector_list = []
|
||||
for connector in connectors:
|
||||
connector_list.append(
|
||||
{
|
||||
"id": connector.id,
|
||||
"name": connector.name,
|
||||
"connector_type": connector.connector_type.value,
|
||||
"is_indexable": connector.is_indexable,
|
||||
"created_at": connector.created_at.isoformat()
|
||||
if connector.created_at
|
||||
else None,
|
||||
"last_indexed_at": connector.last_indexed_at.isoformat()
|
||||
if connector.last_indexed_at
|
||||
else None,
|
||||
"config": {
|
||||
"hostname": connector.config.get("hostname"),
|
||||
"port": connector.config.get("port"),
|
||||
"indices": connector.config.get("indices"),
|
||||
# Don't expose sensitive credentials
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"success": True, "connectors": connector_list}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Elasticsearch connectors: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to fetch Elasticsearch connectors",
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/connectors/{connector_id}")
|
||||
async def update_elasticsearch_connector(
|
||||
connector_id: int,
|
||||
connector_data: ElasticsearchConnectorConfig,
|
||||
current_user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing Elasticsearch connector"""
|
||||
|
||||
try:
|
||||
# Get the connector
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == connector_id,
|
||||
SearchSourceConnector.user_id == current_user.id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.ELASTICSEARCH,
|
||||
)
|
||||
)
|
||||
connector = result.scalar_one_or_none()
|
||||
|
||||
if not connector:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Elasticsearch connector not found",
|
||||
)
|
||||
|
||||
# Test the new connection
|
||||
elasticsearch_connector = ElasticsearchConnector(connector_data)
|
||||
|
||||
try:
|
||||
connection_test = await elasticsearch_connector.test_connection()
|
||||
if not connection_test.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to connect to Elasticsearch: {connection_test.get('error')}",
|
||||
)
|
||||
finally:
|
||||
await elasticsearch_connector.disconnect()
|
||||
|
||||
# Update the connector
|
||||
connector.config = connector_data.model_dump()
|
||||
connector.name = (
|
||||
f"Elasticsearch - {connector_data.hostname}:{connector_data.port}"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(connector)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Elasticsearch connector updated successfully",
|
||||
"connector": {
|
||||
"id": connector.id,
|
||||
"name": connector.name,
|
||||
"connector_type": connector.connector_type.value,
|
||||
"is_indexable": connector.is_indexable,
|
||||
"created_at": connector.created_at.isoformat()
|
||||
if connector.created_at
|
||||
else None,
|
||||
"last_indexed_at": connector.last_indexed_at.isoformat()
|
||||
if connector.last_indexed_at
|
||||
else None,
|
||||
},
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating Elasticsearch connector: {e}")
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update Elasticsearch connector",
|
||||
) from e
|
||||
|
||||
|
||||
@router.delete("/connectors/{connector_id}")
|
||||
async def delete_elasticsearch_connector(
|
||||
connector_id: int,
|
||||
current_user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete an Elasticsearch connector"""
|
||||
|
||||
try:
|
||||
# Get the connector
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == connector_id,
|
||||
SearchSourceConnector.user_id == current_user.id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.ELASTICSEARCH,
|
||||
)
|
||||
)
|
||||
connector = result.scalar_one_or_none()
|
||||
|
||||
if not connector:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Elasticsearch connector not found",
|
||||
)
|
||||
|
||||
# Delete the connector
|
||||
await session.delete(connector)
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Elasticsearch connector deleted successfully",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting Elasticsearch connector: {e}")
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete Elasticsearch connector",
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/test-connection")
|
||||
async def test_elasticsearch_connection(
|
||||
hostname: str,
|
||||
port: int = 9200,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
api_key: str | None = None,
|
||||
ssl_enabled: bool = True,
|
||||
current_user: User = Depends(current_active_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Test Elasticsearch connection with provided credentials"""
|
||||
|
||||
config_data = ElasticsearchConnectorConfig(
|
||||
hostname=hostname,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
api_key=api_key,
|
||||
ssl_enabled=ssl_enabled,
|
||||
)
|
||||
|
||||
elasticsearch_connector = ElasticsearchConnector(config_data)
|
||||
|
||||
try:
|
||||
result = await elasticsearch_connector.test_connection()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing Elasticsearch connection: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
await elasticsearch_connector.disconnect()
|
||||
|
||||
|
||||
@router.get("/indices")
|
||||
async def get_elasticsearch_indices(
|
||||
hostname: str,
|
||||
port: int = 9200,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
api_key: str | None = None,
|
||||
ssl_enabled: bool = True,
|
||||
current_user: User = Depends(current_active_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Get list of available Elasticsearch indices"""
|
||||
|
||||
config_data = ElasticsearchConnectorConfig(
|
||||
hostname=hostname,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
api_key=api_key,
|
||||
ssl_enabled=ssl_enabled,
|
||||
)
|
||||
|
||||
elasticsearch_connector = ElasticsearchConnector(config_data)
|
||||
|
||||
try:
|
||||
if not await elasticsearch_connector.connect():
|
||||
return {"success": False, "error": "Failed to connect to Elasticsearch"}
|
||||
|
||||
indices = await elasticsearch_connector.get_indices()
|
||||
return {"success": True, "indices": indices}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Elasticsearch indices: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
await elasticsearch_connector.disconnect()
|
||||
|
||||
|
||||
@router.post("/index-connector/{connector_id}")
|
||||
async def index_elasticsearch_connector(
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
current_user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Index documents from an Elasticsearch connector into a search space
|
||||
"""
|
||||
try:
|
||||
# Get the connector directly from database
|
||||
stmt = select(SearchSourceConnector).where(
|
||||
SearchSourceConnector.id == connector_id,
|
||||
SearchSourceConnector.user_id == current_user.id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
connector = result.scalar_one_or_none()
|
||||
|
||||
if not connector:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Connector {connector_id} not found",
|
||||
)
|
||||
|
||||
if connector.connector_type != SearchSourceConnectorType.ELASTICSEARCH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Connector is not an Elasticsearch connector",
|
||||
)
|
||||
|
||||
# Initialize the indexer
|
||||
indexer = ElasticsearchIndexer(connector.config)
|
||||
|
||||
# Test connection first
|
||||
logger.info(f"Testing Elasticsearch connection for connector {connector_id}")
|
||||
connection_test = await indexer.test_connection()
|
||||
if not connection_test.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Connection test failed: {connection_test.get('error')}",
|
||||
)
|
||||
|
||||
# Index documents
|
||||
logger.info(f"Starting indexing for Elasticsearch connector {connector_id}")
|
||||
indexed_count = 0
|
||||
error_count = 0
|
||||
|
||||
async for document in indexer.get_documents():
|
||||
try:
|
||||
# Create content hash
|
||||
content_hash = hashlib.sha256(document.content.encode()).hexdigest()
|
||||
|
||||
# Check if document already exists
|
||||
existing_doc_stmt = select(Document).where(
|
||||
Document.content_hash == content_hash,
|
||||
Document.search_space_id == search_space_id,
|
||||
)
|
||||
existing_doc_result = await session.execute(existing_doc_stmt)
|
||||
if existing_doc_result.scalar_one_or_none():
|
||||
logger.info(f"Document already exists, skipping: {document.title}")
|
||||
continue
|
||||
|
||||
# Generate embedding for document
|
||||
embedding = await config.embedding_model_instance.encode(
|
||||
document.content
|
||||
)
|
||||
|
||||
# Create document
|
||||
db_document = Document(
|
||||
title=document.title,
|
||||
content=document.content,
|
||||
content_hash=content_hash,
|
||||
document_type=document.document_type,
|
||||
document_metadata=document.metadata,
|
||||
search_space_id=search_space_id,
|
||||
embedding=embedding,
|
||||
)
|
||||
session.add(db_document)
|
||||
await session.flush() # Get the document ID
|
||||
|
||||
# Create a single chunk with the entire document content
|
||||
chunk_embedding = await config.embedding_model_instance.encode(
|
||||
document.content
|
||||
)
|
||||
db_chunk = Chunk(
|
||||
content=document.content,
|
||||
embedding=chunk_embedding,
|
||||
document_id=db_document.id,
|
||||
)
|
||||
session.add(db_chunk)
|
||||
|
||||
indexed_count += 1
|
||||
|
||||
if indexed_count % 10 == 0:
|
||||
logger.info(f"Indexed {indexed_count} Elasticsearch documents")
|
||||
await session.commit() # Periodic commit
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing document: {e}")
|
||||
error_count += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Update last_indexed_at timestamp
|
||||
update_stmt = (
|
||||
update(SearchSourceConnector)
|
||||
.where(SearchSourceConnector.id == connector_id)
|
||||
.values(last_indexed_at=datetime.now(UTC))
|
||||
)
|
||||
await session.execute(update_stmt)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Elasticsearch indexing completed: {indexed_count} documents indexed, {error_count} errors"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"indexed_documents": indexed_count,
|
||||
"errors": error_count,
|
||||
"connector_id": connector_id,
|
||||
"search_space_id": search_space_id,
|
||||
"message": f"Successfully indexed {indexed_count} documents from Elasticsearch",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Elasticsearch indexing: {e}")
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error during indexing: {e!s}",
|
||||
) from e
|
||||
55
surfsense_backend/app/schemas/elasticsearch.py
Normal file
55
surfsense_backend/app/schemas/elasticsearch.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ElasticsearchConnectorConfig(BaseModel):
|
||||
"""Configuration for Elasticsearch connector"""
|
||||
|
||||
hostname: str = Field(..., description="Elasticsearch hostname")
|
||||
port: int = Field(default=9200, description="Elasticsearch port")
|
||||
username: str | None = Field(
|
||||
default=None, description="Username for authentication"
|
||||
)
|
||||
password: str | None = Field(
|
||||
default=None, description="Password for authentication"
|
||||
)
|
||||
api_key: str | None = Field(default=None, description="API key for authentication")
|
||||
ssl_enabled: bool = Field(default=True, description="Whether to use SSL/TLS")
|
||||
indices: list[str] | None = Field(
|
||||
default=None, description="Specific indices to search (optional)"
|
||||
)
|
||||
query: str = Field(default="*", description="Default search query")
|
||||
search_fields: list[str] | None = Field(
|
||||
default=None, description="Specific fields to search in"
|
||||
)
|
||||
max_documents: int = Field(
|
||||
default=1000, description="Maximum number of documents to retrieve"
|
||||
)
|
||||
|
||||
|
||||
class ElasticsearchTestConnectionRequest(BaseModel):
|
||||
"""Request model for testing Elasticsearch connection"""
|
||||
|
||||
hostname: str
|
||||
port: int = 9200
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
api_key: str | None = None
|
||||
ssl_enabled: bool = True
|
||||
|
||||
|
||||
class ElasticsearchTestConnectionResponse(BaseModel):
|
||||
"""Response model for Elasticsearch connection test"""
|
||||
|
||||
success: bool
|
||||
cluster_name: str | None = None
|
||||
version: str | None = None
|
||||
indices_count: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ElasticsearchIndicesResponse(BaseModel):
|
||||
"""Response model for Elasticsearch indices list"""
|
||||
|
||||
success: bool
|
||||
indices: list[str] | None = None
|
||||
error: str | None = None
|
||||
|
|
@ -2013,3 +2013,104 @@ class ConnectorService:
|
|||
}
|
||||
|
||||
return result_object, luma_chunks
|
||||
|
||||
async def search_elasticsearch(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
) -> tuple:
|
||||
"""
|
||||
Search for Elasticsearch documents and return both the source information and langchain documents
|
||||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
||||
Returns:
|
||||
tuple: (sources_info, langchain_documents)
|
||||
"""
|
||||
if search_mode == SearchMode.CHUNKS:
|
||||
elasticsearch_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="ELASTICSEARCH",
|
||||
)
|
||||
elif search_mode == SearchMode.DOCUMENTS:
|
||||
elasticsearch_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="ELASTICSEARCH",
|
||||
)
|
||||
# Transform document retriever results to match expected format
|
||||
elasticsearch_chunks = self._transform_document_results(
|
||||
elasticsearch_chunks
|
||||
)
|
||||
|
||||
# Early return if no results
|
||||
if not elasticsearch_chunks:
|
||||
return {
|
||||
"id": 12,
|
||||
"name": "Elasticsearch",
|
||||
"type": "ELASTICSEARCH",
|
||||
"sources": [],
|
||||
}, []
|
||||
|
||||
# Process each chunk and create sources directly without deduplication
|
||||
sources_list = []
|
||||
async with self.counter_lock:
|
||||
for _i, chunk in enumerate(elasticsearch_chunks):
|
||||
# Extract document metadata
|
||||
document = chunk.get("document", {})
|
||||
metadata = document.get("metadata", {})
|
||||
|
||||
# Extract Elasticsearch-specific metadata
|
||||
es_index = metadata.get("elasticsearch_index", "unknown")
|
||||
es_id = metadata.get("elasticsearch_id", "")
|
||||
es_score = metadata.get("elasticsearch_score", 0)
|
||||
|
||||
# Create title with Elasticsearch context
|
||||
title = document.get("title", "Elasticsearch Document")
|
||||
if es_index:
|
||||
title = f"[{es_index}] {title}"
|
||||
|
||||
# Create description from content
|
||||
description = chunk.get("content", "")[:100]
|
||||
if len(description) == 100:
|
||||
description += "..."
|
||||
|
||||
# Add score information to description
|
||||
if es_score:
|
||||
description = f"Score: {es_score:.2f} | {description}"
|
||||
|
||||
source = {
|
||||
"id": chunk.get("chunk_id", self.source_id_counter),
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": metadata.get("url", f"elasticsearch://{es_index}/{es_id}"),
|
||||
"elasticsearch_index": es_index,
|
||||
"elasticsearch_id": es_id,
|
||||
"elasticsearch_score": es_score,
|
||||
}
|
||||
|
||||
self.source_id_counter += 1
|
||||
sources_list.append(source)
|
||||
|
||||
# Create result object
|
||||
result_object = {
|
||||
"id": 12,
|
||||
"name": "Elasticsearch",
|
||||
"type": "ELASTICSEARCH",
|
||||
"sources": sources_list,
|
||||
}
|
||||
|
||||
return result_object, elasticsearch_chunks
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from app.connectors.elasticsearch_connector import ElasticsearchConnector
|
||||
from app.schemas.documents import DocumentCreate
|
||||
from app.tasks.connector_indexers.base import BaseConnectorIndexer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ElasticsearchIndexer(BaseConnectorIndexer):
|
||||
"""Indexer for Elasticsearch data sources"""
|
||||
|
||||
def __init__(self, connector_config: dict[str, Any]):
|
||||
super().__init__(connector_config)
|
||||
self.connector = ElasticsearchConnector(self.config)
|
||||
|
||||
async def get_documents(self) -> AsyncGenerator[DocumentCreate, None]:
|
||||
"""Fetch documents from Elasticsearch based on configuration"""
|
||||
try:
|
||||
# Connect to Elasticsearch
|
||||
if not await self.connector.connect():
|
||||
logger.error("Failed to connect to Elasticsearch")
|
||||
return
|
||||
|
||||
# Get configuration parameters
|
||||
query = self.connector_config.get("query", "*")
|
||||
indices = self.connector_config.get("indices", None)
|
||||
max_docs = self.connector_config.get("max_documents", 1000)
|
||||
fields = self.connector_config.get("search_fields", None)
|
||||
|
||||
logger.info(f"Searching Elasticsearch with query: {query}")
|
||||
|
||||
# Search documents
|
||||
doc_count = 0
|
||||
async for document in self.connector.search_documents(
|
||||
query=query, indices=indices, size=max_docs, fields=fields
|
||||
):
|
||||
if doc_count >= max_docs:
|
||||
break
|
||||
|
||||
yield document
|
||||
doc_count += 1
|
||||
|
||||
# Add small delay to prevent overwhelming Elasticsearch
|
||||
if doc_count % 10 == 0:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
logger.info(f"Fetched {doc_count} documents from Elasticsearch")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching documents from Elasticsearch: {e}")
|
||||
finally:
|
||||
await self.connector.disconnect()
|
||||
|
||||
async def test_connection(self) -> dict[str, Any]:
|
||||
"""Test the Elasticsearch connection"""
|
||||
return await self.connector.test_connection()
|
||||
Loading…
Add table
Add a link
Reference in a new issue