diff --git a/config/graphql/graphene_types.py b/config/graphql/graphene_types.py index abeb63c6..f096fbb0 100644 --- a/config/graphql/graphene_types.py +++ b/config/graphql/graphene_types.py @@ -3,7 +3,7 @@ import graphene import graphene.types.json from django.contrib.auth import get_user_model -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from graphene import relay from graphene.types.generic import GenericScalar from graphene_django import DjangoObjectType @@ -409,8 +409,11 @@ def resolve_all_annotations( def resolve_all_relationships(self, info, corpus_id, analysis_id=None): try: + # Want to limit to strucutural relationships or corpus relationships corpus_pk = from_global_id(corpus_id)[1] - relationships = self.relationships.filter(corpus_id=corpus_pk) + relationships = self.relationships.filter( + Q(corpus_id=corpus_pk) | Q(structural=True) + ) if analysis_id == "__none__": relationships = relationships.filter(analysis__isnull=True) diff --git a/config/settings/base.py b/config/settings/base.py index 5424f981..e6f441d7 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -25,7 +25,6 @@ "DJANGO_ALLOWED_HOSTS", default=["localhost", "0.0.0.0", "127.0.0.1"] ) - # https://docs.djangoproject.com/en/dev/ref/settings/#debug DEBUG = env.bool("DJANGO_DEBUG", False) @@ -466,20 +465,7 @@ # Constants for Permissioning DEFAULT_PERMISSIONS_GROUP = "Public Objects Access" -# Nlm-ingestor settings -# ----------------------------------------------------------------------------- -NLM_INGESTOR_ACTIVE = env.bool( - "NLM_INGESTOR_ACTIVE", False -) # Use nlm-ingestor where this is True... otherwise PAWLs -NLM_INGEST_USE_OCR = ( - True # IF True, allow ingestor to use OCR when no text found in pdf. -) -NLM_INGEST_HOSTNAME = ( - "http://nlm-ingestor:5001" # Hostname to send nlm-ingestor REST requests to -) -NLM_INGEST_API_KEY = None # If the endpoint is secured with an API_KEY, specify it here, otherwise use None - -# Embeddings / Semantic Search +# Embeddings / Semantic Search - TODO move to EMBEDDER_KWARGS and use like PARSER_KWARGS EMBEDDINGS_MICROSERVICE_URL = "http://vector-embedder:8000" VECTOR_EMBEDDER_API_KEY = "abc123" @@ -560,5 +546,18 @@ # Add more MIME types as needed } +PARSER_KWARGS = { + "opencontractserver.pipeline.parsers.docling_parser.DoclingParser": { + "force_ocr": False, + "roll_up_groups": False, + "llm_enhanced_hierarchy": False, + }, + "opencontractserver.pipeline.parsers.nlm_ingest_parser.NLMIngestParser": { + "endpoint": "http://nlm-ingestor:5001", + "api_key": "", + "use_ocr": True, + }, +} + # Default embedder DEFAULT_EMBEDDER = "opencontractserver.pipeline.embedders.sent_transformer_microservice.MicroserviceEmbedder" diff --git a/docs/pipelines/README.md b/docs/pipelines/README.md new file mode 100644 index 00000000..6dc750b3 --- /dev/null +++ b/docs/pipelines/README.md @@ -0,0 +1,226 @@ +# OpenContracts Pipeline Architecture + +The OpenContracts pipeline system is a modular and extensible architecture for processing documents through various stages: parsing, thumbnail generation, and embedding. This document provides an overview of the system architecture and guides you through creating new pipeline components. + +## Architecture Overview + +The pipeline system consists of three main component types: + +1. **Parsers**: Extract text and structure from documents +2. **Thumbnailers**: Generate visual previews of documents +3. **Embedders**: Create vector embeddings for semantic search + +Each component type has a base abstract class that defines the interface and common functionality: + +```mermaid +graph TD + A[Document Upload] --> B[Parser] + B --> C[Thumbnailer] + B --> D[Embedder] + + subgraph "Pipeline Components" + B --> B1[DoclingParser] + B --> B2[NlmIngestParser] + B --> B3[TxtParser] + + C --> C1[PdfThumbnailer] + C --> C2[TextThumbnailer] + + D --> D1[MicroserviceEmbedder] + end + + C1 --> E[Document Preview] + C2 --> E + D1 --> F[Vector Database] +``` + +### Component Registration + +Components are registered in `settings/base.py` through configuration dictionaries: + +```python +PREFERRED_PARSERS = { + "application/pdf": "opencontractserver.pipeline.parsers.docling_parser.DoclingParser", + "text/plain": "opencontractserver.pipeline.parsers.oc_text_parser.TxtParser", + # ... other mime types +} + +THUMBNAIL_TASKS = { + "application/pdf": "opencontractserver.tasks.doc_tasks.extract_pdf_thumbnail", + "text/plain": "opencontractserver.tasks.doc_tasks.extract_txt_thumbnail", + # ... other mime types +} + +PREFERRED_EMBEDDERS = { + "application/pdf": "opencontractserver.pipeline.embedders.sent_transformer_microservice.MicroserviceEmbedder", + # ... other mime types +} +``` + +## Component Types + +### Parsers + +Parsers inherit from `BaseParser` and implement the `parse_document` method: + +```python +class BaseParser(ABC): + title: str = "" + description: str = "" + author: str = "" + dependencies: list[str] = [] + supported_file_types: list[FileTypeEnum] = [] + + @abstractmethod + def parse_document( + self, user_id: int, doc_id: int, **kwargs + ) -> Optional[OpenContractDocExport]: + pass +``` + +Current implementations: +- **DoclingParser**: Advanced PDF parser using machine learning +- **NlmIngestParser**: Alternative PDF parser using NLM ingestor +- **TxtParser**: Simple text file parser + +### Thumbnailers + +Thumbnailers inherit from `BaseThumbnailGenerator` and implement the `_generate_thumbnail` method: + +```python +class BaseThumbnailGenerator(ABC): + title: str = "" + description: str = "" + author: str = "" + dependencies: list[str] = [] + supported_file_types: list[FileTypeEnum] = [] + + @abstractmethod + def _generate_thumbnail( + self, + txt_content: Optional[str], + pdf_bytes: Optional[bytes], + height: int = 300, + width: int = 300, + ) -> Optional[tuple[bytes, str]]: + pass +``` + +Current implementations: +- **PdfThumbnailer**: Generates thumbnails from PDF first pages +- **TextThumbnailer**: Creates text-based preview images + +### Embedders + +Embedders inherit from `BaseEmbedder` and implement the `embed_text` method: + +```python +class BaseEmbedder(ABC): + title: str = "" + description: str = "" + author: str = "" + dependencies: list[str] = [] + vector_size: int = 0 + supported_file_types: list[FileTypeEnum] = [] + + @abstractmethod + def embed_text(self, text: str) -> Optional[list[float]]: + pass +``` + +Current implementations: +- **MicroserviceEmbedder**: Generates embeddings using a remote service + +## Creating New Components + +To create a new pipeline component: + +1. Choose the appropriate base class (`BaseParser`, `BaseThumbnailGenerator`, or `BaseEmbedder`) +2. Create a new class inheriting from the base class +3. Implement required abstract methods +4. Set component metadata (title, description, author, etc.) +5. Register the component in the appropriate settings dictionary + +Example of a new parser: + +```python +from opencontractserver.pipeline.base.parser import BaseParser +from opencontractserver.pipeline.base.file_types import FileTypeEnum + +class MyCustomParser(BaseParser): + title = "My Custom Parser" + description = "Parses documents in a custom way" + author = "Your Name" + dependencies = ["custom-lib>=1.0.0"] + supported_file_types = [FileTypeEnum.PDF] + + def parse_document( + self, user_id: int, doc_id: int, **kwargs + ) -> Optional[OpenContractDocExport]: + # Implementation here + pass +``` + +Then register it in settings: + +```python +PREFERRED_PARSERS = { + "application/pdf": "path.to.your.MyCustomParser", + # ... other parsers +} +``` + +## Best Practices + +1. **Error Handling**: Always handle exceptions gracefully and return None on failure +2. **Dependencies**: List all required dependencies in the component's `dependencies` list +3. **Documentation**: Provide clear docstrings and type hints +4. **Testing**: Create unit tests for your component in the `tests` directory +5. **Metadata**: Fill out all metadata fields (title, description, author) + +## Advanced Topics + +### Parallel Processing + +The pipeline system supports parallel processing through Celery tasks. Each component can be executed asynchronously: + +```python +from opencontractserver.tasks.doc_tasks import process_document + +# Async document processing +process_document.delay(user_id, doc_id) +``` + +### Custom File Types + +To add support for new file types: + +1. Add the MIME type to `ALLOWED_DOCUMENT_MIMETYPES` in settings +2. Update `FileTypeEnum` in `base/file_types.py` +3. Create appropriate parser/thumbnailer/embedder implementations +4. Register the implementations in settings + +### Error Handling + +Components should implement robust error handling: + +```python +def parse_document(self, user_id: int, doc_id: int, **kwargs): + try: + # Implementation + return result + except Exception as e: + logger.error(f"Error parsing document {doc_id}: {e}") + return None +``` + +## Contributing + +When contributing new pipeline components: + +1. Follow the project's coding style +2. Add comprehensive tests +3. Update this documentation +4. Submit a pull request with a clear description + +For questions or support, please open an issue on the GitHub repository. \ No newline at end of file diff --git a/docs/pipelines/docling_parser.md b/docs/pipelines/docling_parser.md new file mode 100644 index 00000000..51c18ee6 --- /dev/null +++ b/docs/pipelines/docling_parser.md @@ -0,0 +1,211 @@ +# Docling Parser + +The Docling Parser is an advanced PDF document parser that uses machine learning to extract structured information from PDF documents. It's the primary parser for PDF documents in OpenContracts. + +## Overview + +```mermaid +sequenceDiagram + participant U as User + participant D as DoclingParser + participant DC as DocumentConverter + participant OCR as Tesseract OCR + participant DB as Database + + U->>D: parse_document(user_id, doc_id) + D->>DB: Load document + D->>DC: Convert PDF + + alt PDF needs OCR + DC->>OCR: Process PDF + OCR-->>DC: OCR results + end + + DC-->>D: DoclingDocument + D->>D: Process structure + D->>D: Generate PAWLS tokens + D->>D: Build relationships + D-->>U: OpenContractDocExport +``` + +## Features + +- **Intelligent OCR**: Automatically detects when OCR is needed +- **Hierarchical Structure**: Extracts document structure (headings, paragraphs, lists) +- **Token-based Annotations**: Creates precise token-level annotations +- **Relationship Detection**: Builds relationships between document elements +- **PAWLS Integration**: Generates PAWLS-compatible token data + +## Configuration + +The Docling Parser requires model files to be present in the path specified by `DOCLING_MODELS_PATH` in your settings: + +```python +DOCLING_MODELS_PATH = env.str("DOCLING_MODELS_PATH", default="/models/docling") +``` + +## Usage + +Basic usage: + +```python +from opencontractserver.pipeline.parsers.docling_parser import DoclingParser + +parser = DoclingParser() +result = parser.parse_document(user_id=1, doc_id=123) +``` + +With options: + +```python +result = parser.parse_document( + user_id=1, + doc_id=123, + force_ocr=True, # Force OCR processing + roll_up_groups=True, # Combine related items into groups +) +``` + +## Input + +The parser expects: +- A PDF document stored in Django's storage system +- A valid user ID and document ID +- Optional configuration parameters + +## Output + +The parser returns an `OpenContractDocExport` dictionary containing: + +```python +{ + "title": str, # Extracted document title + "description": str, # Generated description + "content": str, # Full text content + "page_count": int, # Number of pages + "pawls_file_content": List[dict], # PAWLS token data + "labelled_text": List[dict], # Structural annotations + "relationships": List[dict], # Relationships between annotations +} +``` + +## Processing Steps + +1. **Document Loading** + - Loads PDF from storage + - Creates DocumentStream for processing + +2. **Conversion** + - Converts PDF using Docling's DocumentConverter + - Applies OCR if needed + - Extracts document structure + +3. **Token Generation** + - Creates PAWLS-compatible tokens + - Builds spatial indices for token lookup + - Transforms coordinates to screen space + +4. **Annotation Creation** + - Converts Docling items to annotations + - Assigns hierarchical relationships + - Creates group relationships + +5. **Metadata Extraction** + - Extracts document title + - Generates description + - Counts pages + +## Advanced Features + +### OCR Processing + +The parser can use Tesseract OCR when needed: + +```python +# Force OCR processing +result = parser.parse_document(user_id=1, doc_id=123, force_ocr=True) +``` + +### Group Relationships + +Enable group relationship detection: + +```python +# Enable group rollup +result = parser.parse_document(user_id=1, doc_id=123, roll_up_groups=True) +``` + +### Spatial Processing + +The parser uses Shapely for spatial operations: +- Creates STRtrees for efficient spatial queries +- Handles coordinate transformations +- Manages token-annotation mapping + +## Error Handling + +The parser includes robust error handling: +- Validates model file presence +- Checks conversion status +- Handles OCR failures +- Manages coordinate transformation errors + +## Dependencies + +Required Python packages: +- `docling`: Core document processing +- `pytesseract`: OCR support +- `pdf2image`: PDF rendering +- `shapely`: Spatial operations +- `numpy`: Numerical operations + +## Performance Considerations + +- OCR processing can be time-intensive +- Large documents may require significant memory +- Spatial indexing improves token lookup performance +- Group relationship processing may impact performance with `roll_up_groups=True` + +## Best Practices + +1. **OCR Usage** + - Let the parser auto-detect OCR needs + - Only use `force_ocr=True` when necessary + +2. **Group Relationships** + - Start with `roll_up_groups=False` + - Enable if hierarchical grouping is needed + +3. **Error Handling** + - Always check return values + - Monitor logs for conversion issues + +4. **Memory Management** + - Process large documents in batches + - Monitor memory usage with large PDFs + +## Troubleshooting + +Common issues and solutions: + +1. **Missing Models** + ``` + FileNotFoundError: Docling models path does not exist + ``` + - Verify DOCLING_MODELS_PATH setting + - Check model file permissions + +2. **OCR Failures** + ``` + Error: Tesseract not found + ``` + - Install Tesseract OCR + - Verify system PATH + +3. **Memory Issues** + ``` + MemoryError during processing + ``` + - Reduce concurrent processing + - Increase system memory + - Process smaller batches diff --git a/docs/pipelines/nlm_ingest_parser.md b/docs/pipelines/nlm_ingest_parser.md new file mode 100644 index 00000000..614a2468 --- /dev/null +++ b/docs/pipelines/nlm_ingest_parser.md @@ -0,0 +1,252 @@ +# NLM Ingest Parser + +The NLM Ingest Parser is a lightweight alternative to the Docling Parser that uses an NLM-Ingest REST parser for PDF document processing. Like Docling, it provides structural labels and relationships. Unlike Docling, it uses heuristics and a rules-based approach to determine the structure of the document. *Note*: The relationships _between_ annotations are not yet implemented in our conversion. + +## Overview + +```mermaid +sequenceDiagram + participant U as User + participant N as NLMIngestParser + participant DB as Database + participant NLM as NLM Service + participant OCR as OCR Service + + U->>N: parse_document(user_id, doc_id) + N->>DB: Load document + N->>N: Check OCR needs + + alt PDF needs OCR + N->>NLM: Request with OCR + NLM->>OCR: Process PDF + OCR-->>NLM: OCR results + else PDF has text + N->>NLM: Request without OCR + end + + NLM-->>N: OpenContracts data + N->>N: Process annotations + N-->>U: OpenContractDocExport +``` + +## Features + +- **Automatic OCR Detection**: Intelligently determines OCR needs +- **Token-based Annotations**: Provides token-level annotations +- **Rules-Based Relationships**: Provides relationships between annotations especially well-suited to contract layouts. +- **Simple Integration**: Easy to set up and use +- **Configurable API**: Supports custom API endpoints and keys + +## Configuration + +Configure the NLM Ingest Parser in your settings: + +```python +# Enable/disable NLM ingest +NLM_INGESTOR_ACTIVE = env.bool("NLM_INGESTOR_ACTIVE", False) + +# OCR configuration +NLM_INGEST_USE_OCR = True + +# Service endpoint +NLM_INGEST_HOSTNAME = "http://nlm-ingestor:5001" + +# Optional API key +NLM_INGEST_API_KEY = None # or your API key +``` + +## Usage + +Basic usage: + +```python +from opencontractserver.pipeline.parsers.nlm_ingest_parser import NLMIngestParser + +parser = NLMIngestParser() +result = parser.parse_document(user_id=1, doc_id=123) +``` + +## Input + +The parser requires: +- A PDF document in Django's storage +- A valid user ID and document ID +- Proper NLM service configuration + +## Output + +Returns an `OpenContractDocExport` dictionary: + +```python +{ + "content": str, # Full text content + "page_count": int, # Number of pages + "pawls_file_content": List[dict], # PAWLS token data + "labelled_text": List[dict], # Structural annotations +} +``` + +## Processing Steps + +1. **Document Loading** + - Retrieves PDF from storage + - Checks if OCR is needed + +2. **Service Request** + - Prepares API headers and parameters + - Sends document to NLM service + - Handles OCR configuration + +3. **Response Processing** + - Validates service response + - Extracts OpenContracts data + - Processes annotations + +4. **Annotation Enhancement** + - Sets structural flags + - Assigns token label types + - Prepares final output + +## API Integration + +### Request Format + +```python +# Headers +headers = {"API_KEY": settings.NLM_INGEST_API_KEY} if settings.NLM_INGEST_API_KEY else {} + +# Parameters +params = { + "calculate_opencontracts_data": "yes", + "applyOcr": "yes" if needs_ocr else "no" +} + +# Files +files = {"file": pdf_file} +``` + +### Endpoint + +``` +POST {NLM_INGEST_HOSTNAME}/api/parseDocument +``` + +## Error Handling + +The parser includes error handling for: +- Service connection issues +- Invalid responses +- Missing data +- OCR failures + +Example error handling: + +```python +if response.status_code != 200: + logger.error(f"NLM ingest service returned status code {response.status_code}") + response.raise_for_status() + +if open_contracts_data is None: + logger.error("No 'opencontracts_data' found in NLM ingest service response") + return None +``` + +## Dependencies + +Required configurations: +- Working NLM ingest service +- Network access to service +- Optional API key +- Optional OCR service + +## Performance Considerations + +- Network latency affects processing time +- OCR processing adds significant time +- Service availability is critical +- Consider rate limiting +- Monitor service response times + +## Best Practices + +1. **Service Configuration** + - Use HTTPS for security + - Configure timeouts + - Handle service outages + +2. **OCR Usage** + - Enable OCR only when needed + - Monitor OCR processing time + - Consider OCR quality settings + +3. **Error Handling** + - Implement retries for failures + - Log service responses + - Monitor error rates + +4. **Security** + - Use API keys when available + - Validate service certificates + - Protect sensitive documents + +## Troubleshooting + +Common issues and solutions: + +1. **Service Connection** + ``` + ConnectionError: Failed to connect to NLM service + ``` + - Check service URL + - Verify network connectivity + - Check firewall settings + +2. **Authentication** + ``` + 401 Unauthorized + ``` + - Verify API key + - Check key configuration + - Ensure key is active + +3. **OCR Issues** + ``` + OCR processing failed + ``` + - Check OCR service status + - Verify PDF quality + - Monitor OCR logs + +4. **Response Format** + ``` + KeyError: 'opencontracts_data' + ``` + - Check service version + - Verify response format + - Update parser if needed + +## Comparison with Docling Parser + +| Feature | NLM Ingest Parser | Docling Parser | +|---------|------------------|----------------| +| Processing | Remote | Local | +| Setup | Simple | Complex | +| Dependencies | Minimal | Many | +| Control | Limited | Full | +| Scalability | Service-dependent | Resource-dependent | +| Customization | Limited | Extensive | + +## When to Use + +Choose the NLM Ingest Parser when: +- You want to offload processing +- You need simple setup +- You have reliable network access +- You prefer managed services +- You don't need extensive customization + +Consider alternatives when: +- You need offline processing +- You require custom processing logic +- You have network restrictions +- You need full control over the pipeline diff --git a/frontend/package.json b/frontend/package.json index d63bfd56..3c53d1f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,7 +48,6 @@ "react-json-tree": "^0.19.0", "react-json-view": "^1.21.3", "react-markdown": "8.0.7", - "react-pdf": "^5.7.1", "react-router-dom": "6", "react-scripts": "5.0.0", "react-syntax-highlighter": "^15.5.0", diff --git a/frontend/src/components/annotator/DocumentAnnotator.tsx b/frontend/src/components/annotator/DocumentAnnotator.tsx index 1c89821b..1e1ad9d6 100644 --- a/frontend/src/components/annotator/DocumentAnnotator.tsx +++ b/frontend/src/components/annotator/DocumentAnnotator.tsx @@ -326,7 +326,10 @@ export const DocumentAnnotator = ({ console.error("Error loading PDF document:", err); viewStateVar(ViewState.ERROR); }); - } else if (opened_document.fileType === "application/txt") { + } else if ( + opened_document.fileType === "application/txt" || + opened_document.fileType === "text/plain" + ) { console.debug("React to TXT document"); Promise.all([ @@ -414,7 +417,8 @@ export const DocumentAnnotator = ({ .map((edge) => edge?.node?.id) .filter((id): id is string => id !== undefined), rel.relationshipLabel, - rel.id + rel.id, + rel.structural ) ); diff --git a/frontend/src/components/annotator/display/components/Containers.tsx b/frontend/src/components/annotator/display/components/Containers.tsx index 3645b2ce..f327ccd5 100644 --- a/frontend/src/components/annotator/display/components/Containers.tsx +++ b/frontend/src/components/annotator/display/components/Containers.tsx @@ -55,23 +55,24 @@ export const SelectionInfo = styled.div` font-size: 12px; user-select: none; box-sizing: border-box; + transition: all 0.2s ease-in-out; ${(props) => props.approved && css` - border-top: 2px solid green; - border-left: 2px solid green; - border-right: 2px solid green; - animation: ${pulseGreen} 2s infinite; + border-top: 2px solid #2ecc71; + border-left: 2px solid #2ecc71; + border-right: 2px solid #2ecc71; + box-shadow: 0 0 8px rgba(46, 204, 113, 0.2); `} ${(props) => props.rejected && css` - border-top: 2px solid maroon; - border-left: 2px solid maroon; - border-right: 2px solid maroon; - animation: ${pulseMaroon} 2s infinite; + border-top: 2px solid #e74c3c; + border-left: 2px solid #e74c3c; + border-right: 2px solid #e74c3c; + box-shadow: 0 0 8px rgba(231, 76, 60, 0.2); `} * { @@ -80,10 +81,17 @@ export const SelectionInfo = styled.div` `; export const SelectionInfoContainer = styled.div` + position: relative; display: flex; flex-direction: row; justify-content: space-between; align-items: center; + pointer-events: none; // Let hover events pass through to children + + /* Enable pointer events only on interactive elements */ + > * { + pointer-events: auto; + } `; export const LabelTagContainer = styled.div<{ diff --git a/frontend/src/components/annotator/display/components/Selection.tsx b/frontend/src/components/annotator/display/components/Selection.tsx index 0bbae977..d1c56bd2 100644 --- a/frontend/src/components/annotator/display/components/Selection.tsx +++ b/frontend/src/components/annotator/display/components/Selection.tsx @@ -1,17 +1,12 @@ import React, { useState, useEffect, useRef } from "react"; import _ from "lodash"; +import styled from "styled-components"; -import { Image, Icon } from "semantic-ui-react"; +import { Icon } from "semantic-ui-react"; -import { - HorizontallyJustifiedStartDiv, - VerticallyJustifiedEndDiv, -} from "../../sidebar/common"; +import { VerticallyJustifiedEndDiv } from "../../sidebar/common"; -import { - annotationSelectedViaRelationship, - getRelationImageHref, -} from "../../utils"; +import { annotationSelectedViaRelationship } from "../../utils"; import { PermissionTypes } from "../../../types"; import { SelectionBoundary } from "./SelectionBoundary"; @@ -20,7 +15,10 @@ import { SelectionInfo, SelectionInfoContainer, } from "./Containers"; -import { getBorderWidthFromBounds } from "../../../../utils/transform"; +import { + getBorderWidthFromBounds, + getContrastColor, +} from "../../../../utils/transform"; import RadialButtonCloud, { CloudButtonItem, } from "../../../widgets/buttons/RadialButtonCloud"; @@ -54,6 +52,63 @@ interface SelectionProps { scrollIntoView?: boolean; } +const RelationshipIndicator = styled.div<{ type: string; color: string }>` + position: absolute; + left: -24px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: ${ + (props) => + props.type === "SOURCE" + ? "rgba(25, 118, 210, 0.95)" // Blue for source + : "rgba(230, 74, 25, 0.95)" // Orange-red for target + }; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + cursor: help; + z-index: 1; // Ensure it's above other elements + + /* Create tooltip */ + &::before { + content: "${(props) => props.type}"; + position: absolute; + left: -8px; + transform: translateX(-100%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.75rem; + white-space: nowrap; + opacity: 0; + pointer-events: none; // Prevent tooltip from interfering with hover + transition: opacity 0.2s ease; + } + + &::after { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: white; + animation: ${(props) => + props.type === "SOURCE" + ? "sourcePulse 2s infinite" + : "targetPulse 2s infinite"}; + pointer-events: none; + } + + /* Show tooltip on hover */ + &:hover::before { + opacity: 1; + } +`; + export const Selection: React.FC = ({ selected, pageInfo, @@ -207,6 +262,14 @@ export const Selection: React.FC = ({ ); } + const handleMouseEnter = () => { + setHovered(true); + }; + + const handleMouseLeave = () => { + setHovered(false); + }; + return ( <> = ({ showBoundingBox={showBoundingBoxes} approved={approved} rejected={rejected} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} > - - -
- -
-
- -
- - {relationship_type} - -
-
-
+ {relationship_type && ( + + )}