Merge pull request #137 from Hawksight-AI/utils

feat: Add pipeline_id support and fix parsing table display
This commit is contained in:
Mohd Kaif
2026-01-05 18:17:24 +05:30
committed by GitHub
14 changed files with 729 additions and 87 deletions
+119
View File
@@ -549,3 +549,122 @@ class SourceTracker:
List of traceability records
"""
return self.get_traceability_chain(entity_id, property_name)
def track_sources_batch(
self,
source_data: List[Dict[str, Any]],
pipeline_id: Optional[str] = None,
) -> Dict[str, int]:
"""
Track sources for multiple entities, properties, or relationships in batch.
Args:
source_data: List of source tracking dictionaries, each containing:
- type: "entity", "property", or "relationship"
- entity_id: Entity identifier (required for all types)
- property_name: Property name (required for "property" type)
- value: Property value (required for "property" type)
- relationship_id: Relationship identifier (required for "relationship" type)
- source: SourceReference object or dict to create SourceReference
- metadata: Optional metadata dictionary
pipeline_id: Optional pipeline ID for progress tracking
Returns:
dict: Statistics with keys:
- entities_tracked: Number of entities tracked
- properties_tracked: Number of properties tracked
- relationships_tracked: Number of relationships tracked
- total_tracked: Total number of items tracked
"""
if not source_data:
return {
"entities_tracked": 0,
"properties_tracked": 0,
"relationships_tracked": 0,
"total_tracked": 0,
}
tracking_id = self.progress_tracker.start_tracking(
module="conflicts",
submodule="SourceTracker",
message=f"Tracking sources for {len(source_data)} items",
pipeline_id=pipeline_id,
)
stats = {
"entities_tracked": 0,
"properties_tracked": 0,
"relationships_tracked": 0,
"total_tracked": 0,
}
try:
for i, item in enumerate(source_data):
item_type = item.get("type", "property")
source_ref = item.get("source")
metadata = item.get("metadata", {})
# Convert source dict to SourceReference if needed
if isinstance(source_ref, dict):
source_ref = SourceReference(**source_ref)
elif not isinstance(source_ref, SourceReference):
self.logger.warning(f"Invalid source reference in item {i}: {item}")
continue
if item_type == "entity":
entity_id = item.get("entity_id")
if entity_id:
self.track_entity_source(entity_id, source_ref, **metadata)
stats["entities_tracked"] += 1
stats["total_tracked"] += 1
elif item_type == "property":
entity_id = item.get("entity_id")
property_name = item.get("property_name")
value = item.get("value")
if entity_id and property_name is not None:
self.track_property_source(
entity_id, property_name, value, source_ref, **metadata
)
stats["properties_tracked"] += 1
stats["total_tracked"] += 1
elif item_type == "relationship":
relationship_id = item.get("relationship_id")
if relationship_id:
self.track_relationship_source(
relationship_id, source_ref, **metadata
)
stats["relationships_tracked"] += 1
stats["total_tracked"] += 1
else:
self.logger.warning(
f"Unknown type '{item_type}' in item {i}, skipping"
)
# Update progress
self.progress_tracker.update_progress(
tracking_id,
processed=i + 1,
total=len(source_data),
message=f"Tracking sources {i+1}/{len(source_data)}...",
)
message = (
f"Tracked {stats['total_tracked']} items: "
f"{stats['entities_tracked']} entities, "
f"{stats['properties_tracked']} properties, "
f"{stats['relationships_tracked']} relationships"
)
self.progress_tracker.stop_tracking(
tracking_id, status="completed", message=message
)
return stats
except Exception as e:
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message=str(e)
)
raise
+33
View File
@@ -203,6 +203,7 @@ class Semantica:
sources: List of data sources (files, URLs, streams)
**kwargs: Additional processing options:
- pipeline: Custom pipeline configuration
- pipeline_id: Optional pipeline ID for progress tracking
- embeddings: Whether to generate embeddings
- graph: Whether to build knowledge graph
- normalize: Whether to normalize data
@@ -220,11 +221,19 @@ class Semantica:
# Auto-initialize if not already initialized
self._ensure_initialized()
# Extract or generate pipeline_id
pipeline_id = kwargs.get("pipeline_id")
if not pipeline_id:
# Generate a unique pipeline ID
import uuid
pipeline_id = f"kb_build_{uuid.uuid4().hex[:8]}"
# Start overall progress tracking
overall_tracking_id = self.progress_tracker.start_tracking(
module="core",
submodule="Semantica",
message=f"Building knowledge base from {len(sources)} sources",
pipeline_id=pipeline_id,
)
try:
@@ -236,6 +245,23 @@ class Semantica:
# Create processing pipeline
pipeline_config = kwargs.get("pipeline", {})
pipeline = self._create_pipeline(pipeline_config)
# Register pipeline modules for progress tracking
if hasattr(pipeline, 'steps') and pipeline.steps:
module_list = []
module_order = {}
for idx, step in enumerate(pipeline.steps):
module_name = getattr(step, 'module', None) or getattr(step, 'name', None) or str(step)
if module_name and module_name not in module_list:
module_list.append(module_name)
module_order[module_name] = idx
if module_list:
self.progress_tracker.register_pipeline_modules(
pipeline_id=pipeline_id,
module_list=module_list,
module_order=module_order
)
# Process sources
results = []
@@ -251,6 +277,7 @@ class Semantica:
module="core",
submodule="build_knowledge_base",
message=f"Processing {Path(file_str).name if file_str else 'source'}",
pipeline_id=pipeline_id,
)
try:
result = self.run_pipeline(pipeline, source)
@@ -308,6 +335,9 @@ class Semantica:
message=f"Processed {len(results)} sources",
)
# Clear pipeline context when complete
self.progress_tracker.clear_pipeline_context(pipeline_id)
# Show summary
self.progress_tracker.show_summary()
@@ -319,6 +349,7 @@ class Semantica:
"metadata": {
"sources": validated_sources,
"pipeline": pipeline_config,
"pipeline_id": pipeline_id,
},
}
@@ -326,6 +357,8 @@ class Semantica:
self.progress_tracker.stop_tracking(
overall_tracking_id, status="failed", message=str(e)
)
# Clear pipeline context on failure
self.progress_tracker.clear_pipeline_context(pipeline_id)
self.logger.error(f"Failed to build knowledge base: {e}")
raise ProcessingError(f"Failed to build knowledge base: {e}")
+3 -1
View File
@@ -258,13 +258,14 @@ class EmbeddingGenerator:
return max(0.0, min(1.0, similarity))
def process_batch(
self, data_items: List[Union[str, Path]], **options
self, data_items: List[Union[str, Path]], pipeline_id: Optional[str] = None, **options
) -> Dict[str, Any]:
"""
Process multiple data items for embedding generation.
Args:
data_items: List of data items
pipeline_id: Optional pipeline ID for progress tracking
**options: Processing options
Returns:
@@ -275,6 +276,7 @@ class EmbeddingGenerator:
module="embeddings",
submodule="EmbeddingGenerator",
message=f"Batch of {len(data_items)} items",
pipeline_id=pipeline_id,
)
try:
+5
View File
@@ -237,6 +237,7 @@ class GraphBuilder:
self,
sources: Union[List[Any], Any],
second_arg: Optional[Any] = None,
pipeline_id: Optional[str] = None,
**options,
) -> Dict[str, Any]:
"""
@@ -245,6 +246,7 @@ class GraphBuilder:
Args:
sources: Entities or sources list
second_arg: Optional relationships list or entity_resolver (for backward compatibility)
pipeline_id: Optional pipeline ID for progress tracking
**options: Additional build options
- extract: Whether to extract entities from text (default: True)
- extract_relations: Whether to extract relations from text (default: False)
@@ -307,6 +309,7 @@ class GraphBuilder:
module="kg",
submodule="GraphBuilder",
message=f"Knowledge graph from {len(sources)} source(s)",
pipeline_id=pipeline_id,
)
try:
@@ -340,6 +343,7 @@ class GraphBuilder:
module="kg",
submodule="GraphBuilder",
message=f"Processing {len(entities_list)} entities",
pipeline_id=pipeline_id,
)
# Check if entities are already in dictionary format
@@ -409,6 +413,7 @@ class GraphBuilder:
module="kg",
submodule="GraphBuilder",
message=f"Processing {len(relationships_list)} relationships",
pipeline_id=pipeline_id,
)
# Check if relationships are already in dictionary format
+128
View File
@@ -217,3 +217,131 @@ class ProvenanceTracker:
or None if entity is not tracked
"""
return self.provenance_data.get(entity_id)
def track_entities_batch(
self,
entities: List[Dict[str, Any]],
source: str,
pipeline_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> int:
"""
Track provenance for multiple entities in batch.
Args:
entities: List of entity dictionaries, each containing at least 'id' key
source: Source identifier
pipeline_id: Optional pipeline ID for progress tracking
metadata: Optional metadata dictionary to apply to all entities
Returns:
int: Number of entities tracked
"""
if not entities:
return 0
tracking_id = self.progress_tracker.start_tracking(
module="kg",
submodule="ProvenanceTracker",
message=f"Tracking provenance for {len(entities)} entities",
pipeline_id=pipeline_id,
)
try:
tracked_count = 0
for i, entity in enumerate(entities):
entity_id = entity.get("id") or entity.get("entity_id")
if not entity_id:
self.logger.warning(f"Skipping entity without ID: {entity}")
continue
# Merge entity-specific metadata with batch metadata
entity_metadata = {**(metadata or {}), **(entity.get("metadata", {}))}
self.track_entity(entity_id, source, entity_metadata)
tracked_count += 1
# Update progress
self.progress_tracker.update_progress(
tracking_id,
processed=i + 1,
total=len(entities),
message=f"Tracking entity {i+1}/{len(entities)}...",
)
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Tracked {tracked_count} entities",
)
return tracked_count
except Exception as e:
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message=str(e)
)
raise
def track_relationships_batch(
self,
relationships: List[Dict[str, Any]],
source: str,
pipeline_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> int:
"""
Track provenance for multiple relationships in batch.
Args:
relationships: List of relationship dictionaries, each containing at least 'id' key
source: Source identifier
pipeline_id: Optional pipeline ID for progress tracking
metadata: Optional metadata dictionary to apply to all relationships
Returns:
int: Number of relationships tracked
"""
if not relationships:
return 0
tracking_id = self.progress_tracker.start_tracking(
module="kg",
submodule="ProvenanceTracker",
message=f"Tracking provenance for {len(relationships)} relationships",
pipeline_id=pipeline_id,
)
try:
tracked_count = 0
for i, relationship in enumerate(relationships):
relationship_id = relationship.get("id") or relationship.get("relationship_id")
if not relationship_id:
self.logger.warning(f"Skipping relationship without ID: {relationship}")
continue
# Merge relationship-specific metadata with batch metadata
rel_metadata = {**(metadata or {}), **(relationship.get("metadata", {}))}
self.track_relationship(relationship_id, source, rel_metadata)
tracked_count += 1
# Update progress
self.progress_tracker.update_progress(
tracking_id,
processed=i + 1,
total=len(relationships),
message=f"Tracking relationship {i+1}/{len(relationships)}...",
)
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Tracked {tracked_count} relationships",
)
return tracked_count
except Exception as e:
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message=str(e)
)
raise
+34 -1
View File
@@ -119,12 +119,16 @@ class DoclingParser:
"""
file_path = Path(file_path)
# Get pipeline_id from options if provided
pipeline_id = options.get("pipeline_id", None)
# Track document parsing
tracking_id = self.progress_tracker.start_tracking(
file=str(file_path),
module="parse",
submodule="DoclingParser",
message=f"Docling: {file_path.name}",
pipeline_id=pipeline_id,
)
try:
@@ -159,6 +163,7 @@ class DoclingParser:
# Stage 2: Document conversion (10-70%) - This is the longest step
# Note: This is a blocking operation, but we'll update progress after it completes
# Emphasize Docling as core dependency in message
self.progress_tracker.update_progress(
tracking_id,
processed=2,
@@ -247,10 +252,38 @@ class DoclingParser:
)
images = self._extract_images(result)
# Prepare extraction counts and metadata
extraction_counts = {
"tables": len(tables),
"images": len(images),
"pages": len(pages) or metadata.page_count or 0,
}
# Build completion message with Docling emphasis
count_parts = []
if extraction_counts["tables"] > 0:
count_parts.append(f"{extraction_counts['tables']} tables")
if extraction_counts["images"] > 0:
count_parts.append(f"{extraction_counts['images']} images")
if extraction_counts["pages"] > 0:
count_parts.append(f"{extraction_counts['pages']} pages")
if count_parts:
completion_message = f"Parsed document (Docling): {', '.join(count_parts)} extracted"
else:
completion_message = f"Parsed document (Docling): 0 tables, 0 images, {extraction_counts['pages']} pages extracted"
# Store metadata with extraction counts and core dependency
metadata_dict = {
"extraction_counts": extraction_counts,
"core_dependency": "docling",
}
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Parsed document: {len(tables)} tables extracted",
message=completion_message,
metadata=metadata_dict,
)
return {
+3 -1
View File
@@ -84,12 +84,13 @@ class DOCXParser:
self.config = config
self.progress_tracker = get_progress_tracker()
def parse(self, file_path: Union[str, Path], **options) -> Dict[str, Any]:
def parse(self, file_path: Union[str, Path], pipeline_id: Optional[str] = None, **options) -> Dict[str, Any]:
"""
Parse DOCX document.
Args:
file_path: Path to DOCX file
pipeline_id: Optional pipeline ID for progress tracking
**options: Parsing options:
- extract_formatting: Whether to extract formatting (default: False)
- extract_tables: Whether to extract tables (default: True)
@@ -106,6 +107,7 @@ class DOCXParser:
module="parse",
submodule="DOCXParser",
message=f"DOCX: {file_path.name}",
pipeline_id=pipeline_id,
)
try:
+3 -1
View File
@@ -84,12 +84,13 @@ class PDFParser:
if not self.progress_tracker.enabled:
self.progress_tracker.enabled = True
def parse(self, file_path: Union[str, Path], **options) -> Dict[str, Any]:
def parse(self, file_path: Union[str, Path], pipeline_id: Optional[str] = None, **options) -> Dict[str, Any]:
"""
Parse PDF document.
Args:
file_path: Path to PDF file
pipeline_id: Optional pipeline ID for progress tracking
**options: Parsing options:
- extract_text: Whether to extract text (default: True)
- extract_tables: Whether to extract tables (default: True)
@@ -107,6 +108,7 @@ class PDFParser:
module="parse",
submodule="PDFParser",
message=f"PDF: {file_path.name}",
pipeline_id=pipeline_id,
)
try:
+31
View File
@@ -132,11 +132,37 @@ class ExecutionEngine:
module="pipeline",
submodule="ExecutionEngine",
message=f"Executing pipeline: {pipeline_id}",
pipeline_id=pipeline_id,
)
try:
self.logger.info(f"Executing pipeline: {pipeline_id}")
# Register pipeline modules for progress tracking
module_list = []
module_order = {}
if hasattr(pipeline, 'steps') and pipeline.steps:
for idx, step in enumerate(pipeline.steps):
# Extract module name from step
module_name = getattr(step, 'module', None) or getattr(step, 'name', None) or str(step)
if module_name and module_name not in module_list:
module_list.append(module_name)
module_order[module_name] = idx
# If no steps found, try to infer from pipeline structure
if not module_list:
# Common pipeline modules
module_list = ["ingest", "parse", "normalize", "semantic_extract", "kg", "embeddings"]
module_order = {module: idx for idx, module in enumerate(module_list)}
# Register pipeline modules
if module_list:
self.progress_tracker.register_pipeline_modules(
pipeline_id=pipeline_id,
module_list=module_list,
module_order=module_order
)
# Set status
with self.pipeline_lock:
self.pipeline_status[pipeline_id] = PipelineStatus.RUNNING
@@ -174,6 +200,9 @@ class ExecutionEngine:
status="completed" if metrics["steps_failed"] == 0 else "failed",
message=f"Executed {metrics['steps_executed']} steps in {execution_time:.2f}s",
)
# Clear pipeline context when pipeline completes
self.progress_tracker.clear_pipeline_context(pipeline_id)
return ExecutionResult(
success=metrics["steps_failed"] == 0,
@@ -193,6 +222,8 @@ class ExecutionEngine:
self.progress_tracker.stop_tracking(
pipeline_tracking_id, status="failed", message=str(e)
)
# Clear pipeline context on failure
self.progress_tracker.clear_pipeline_context(pipeline_id)
self.logger.error(f"Pipeline execution failed: {e}")
with self.pipeline_lock:
self.pipeline_status[pipeline_id] = PipelineStatus.FAILED
+3 -1
View File
@@ -104,13 +104,14 @@ class ParallelismManager:
self.lock = threading.Lock()
def execute_parallel(
self, tasks: List[Task], **options
self, tasks: List[Task], pipeline_id: Optional[str] = None, **options
) -> List[ParallelExecutionResult]:
"""
Execute tasks in parallel.
Args:
tasks: List of tasks to execute
pipeline_id: Optional pipeline ID for progress tracking
**options: Additional options
Returns:
@@ -120,6 +121,7 @@ class ParallelismManager:
module="pipeline",
submodule="ParallelismManager",
message=f"Executing {len(tasks)} tasks in parallel",
pipeline_id=pipeline_id,
)
try:
+3 -1
View File
@@ -141,13 +141,14 @@ class NERExtractor:
f"spaCy model {self.model_name} not found. ML method will fallback."
)
def extract(self, text: Union[str, List[Dict[str, Any]], List[str]], **kwargs) -> Union[List[Entity], List[List[Entity]]]:
def extract(self, text: Union[str, List[Dict[str, Any]], List[str]], pipeline_id: Optional[str] = None, **kwargs) -> Union[List[Entity], List[List[Entity]]]:
"""
Alias for extract_entities.
Handles both single string and list of documents.
Args:
text: Input text or list of documents
pipeline_id: Optional pipeline ID for progress tracking
**kwargs: Extraction options
Returns:
@@ -159,6 +160,7 @@ class NERExtractor:
module="semantic_extract",
submodule="NERExtractor",
message=f"Batch extracting entities from {len(text)} documents",
pipeline_id=pipeline_id,
)
try:
@@ -168,6 +168,7 @@ class RelationExtractor:
self,
text: Union[str, List[Dict[str, Any]], List[str]],
entities: Union[List[Entity], List[List[Entity]]],
pipeline_id: Optional[str] = None,
**kwargs
) -> Union[List[Relation], List[List[Relation]]]:
"""
@@ -177,6 +178,7 @@ class RelationExtractor:
Args:
text: Input text or list of documents
entities: List of entities or list of list of entities
pipeline_id: Optional pipeline ID for progress tracking
**kwargs: Extraction options
Returns:
@@ -188,6 +190,7 @@ class RelationExtractor:
module="semantic_extract",
submodule="RelationExtractor",
message=f"Batch extracting relations from {len(text)} documents",
pipeline_id=pipeline_id,
)
try:
+15 -3
View File
@@ -127,16 +127,22 @@ class ProvenanceTracker:
return provenance_id
def track(self, **kwargs) -> Union[str, List[str]]:
def track(self, pipeline_id: Optional[str] = None, **kwargs) -> Union[str, List[str]]:
"""
Track provenance (alias for track_chunk/track_chunks).
Delegates based on input arguments.
Args:
pipeline_id: Optional pipeline ID for progress tracking
**kwargs: Arguments passed to track_chunk or track_chunks
"""
if "chunks" in kwargs:
kwargs["pipeline_id"] = pipeline_id
return self.track_chunks(**kwargs)
if "chunk" in kwargs and isinstance(kwargs["chunk"], list):
# Handle list passed to chunk arg
kwargs["chunks"] = kwargs.pop("chunk")
kwargs["pipeline_id"] = pipeline_id
return self.track_chunks(**kwargs)
return self.track_chunk(**kwargs)
@@ -145,6 +151,7 @@ class ProvenanceTracker:
chunks: List[Chunk],
source_document: str,
source_path: Optional[str] = None,
pipeline_id: Optional[str] = None,
**metadata,
) -> List[str]:
"""
@@ -154,6 +161,7 @@ class ProvenanceTracker:
chunks: List of chunks to track
source_document: Source document identifier
source_path: Path to source document
pipeline_id: Optional pipeline ID for progress tracking
**metadata: Additional metadata
Returns:
@@ -163,6 +171,7 @@ class ProvenanceTracker:
module="split",
submodule="ProvenanceTracker",
message=f"Tracking provenance for {len(chunks)} chunks",
pipeline_id=pipeline_id,
)
try:
@@ -170,8 +179,11 @@ class ProvenanceTracker:
parent_chunk_id = None
for i, chunk in enumerate(chunks):
self.progress_tracker.update_tracking(
tracking_id, message=f"Tracking chunk {i+1}/{len(chunks)}..."
self.progress_tracker.update_progress(
tracking_id,
processed=i + 1,
total=len(chunks),
message=f"Tracking chunk {i+1}/{len(chunks)}...",
)
provenance_id = self.track_chunk(
chunk, source_document, source_path, parent_chunk_id, **metadata
+346 -78
View File
@@ -77,6 +77,8 @@ class ProgressItem:
total_items: Optional[int] = None # Total items to process
processed_items: Optional[int] = None # Items processed so far
estimated_remaining: Optional[float] = None # Estimated remaining time in seconds
pipeline_id: Optional[str] = None # Pipeline ID this item belongs to
pipeline_order: Optional[int] = None # Order of this module in the pipeline
class ProgressDisplay(ABC):
@@ -232,78 +234,130 @@ class ConsoleProgressDisplay(ProgressDisplay):
return
with self.lock:
# Create unique key for this item
key = f"{item.module}:{item.submodule}"
if item.file:
key = f"{item.file}:{key}"
# Build progress line
parts = []
# Semantica branding with action
if self.use_emoji:
parts.append("🧠")
# Create action message based on module (now includes custom message)
action_msg = self._get_action_message(item.module, item.message)
parts.append(action_msg)
# Progress bar and percentage
if item.progress_percentage is not None:
pct = item.progress_percentage
bar_width = 15
filled = int(bar_width * pct / 100)
bar = "" * filled + "" * (bar_width - filled)
parts.append(f"|{bar}| {pct:.1f}%")
# Count information [done/total]
if item.processed_items is not None and item.total_items is not None:
parts.append(f"[{item.processed_items}/{item.total_items}]")
# Status and Module emojis
if self.use_emoji:
status_emoji = self._get_status_emoji(item.status)
module_emoji = self._get_emoji_for_module(item.module or "")
parts.append(f"{status_emoji}{module_emoji}")
# ETA and Rate
metrics = []
if item.estimated_remaining is not None and item.estimated_remaining > 0:
if item.estimated_remaining < 60:
metrics.append(f"ETA: {item.estimated_remaining:.1f}s")
elif item.estimated_remaining < 3600:
metrics.append(f"ETA: {item.estimated_remaining/60:.1f}m")
else:
metrics.append(f"ETA: {item.estimated_remaining/3600:.1f}h")
# If item is part of a pipeline, get all pipeline items for display
pipeline_items = []
if item.pipeline_id:
# Get tracker instance to access pipeline items
# Use the singleton instance directly
tracker = ProgressTracker.get_instance()
pipeline_items = tracker.get_pipeline_items(item.pipeline_id)
if item.start_time and item.processed_items is not None and item.processed_items > 0:
elapsed = time.time() - item.start_time
if elapsed > 0:
rate = item.processed_items / elapsed
metrics.append(f"{rate:.1f}/s")
if metrics:
parts.append(f"({' | '.join(metrics)})")
# Elapsed time if no progress info
if item.start_time and item.progress_percentage is None:
elapsed = time.time() - item.start_time
parts.append(f"({elapsed:.1f}s)")
line = " ".join(parts)
self.current_lines[key] = line
# Print all current lines
# For multiple lines, we combine them to avoid console scrolling issues
sys.stdout.write("\r" + " " * 120 + "\r")
if len(self.current_lines) == 1:
sys.stdout.write(line)
# If we have pipeline items, show all of them
if pipeline_items:
# Clear and show all pipeline items
sys.stdout.write("\r" + " " * 150 + "\r")
# Show header if first time
if not hasattr(self, '_pipeline_header_shown'):
if self.use_emoji:
sys.stdout.write("🧠 Semantica - 📊 Current Progress\n")
else:
sys.stdout.write("Semantica - Current Progress\n")
sys.stdout.write("=" * 150 + "\n")
self._pipeline_header_shown = True
# Display all pipeline items
for pipeline_item in pipeline_items:
self._display_item_line(pipeline_item)
sys.stdout.write("\n")
sys.stdout.flush()
else:
# Combine multiple active lines onto one or two lines to be safe
lines_list = list(self.current_lines.values())
sys.stdout.write(" | ".join(lines_list[-2:]))
sys.stdout.flush()
# Original single-item display
self._display_item_line(item)
sys.stdout.flush()
def _display_item_line(self, item: ProgressItem) -> None:
"""Display a single progress item line."""
# Create unique key for this item
key = f"{item.module}:{item.submodule}"
if item.file:
key = f"{item.file}:{key}"
# Build progress line
parts = []
# Status emoji
if self.use_emoji:
status_emoji = self._get_status_emoji(item.status)
parts.append(status_emoji)
# Create action message based on module
action_msg = self._get_action_message(item.module, item.message)
parts.append(action_msg)
# Module and Submodule
if self.use_emoji:
module_emoji = self._get_emoji_for_module(item.module or "")
parts.append(f"{module_emoji} {item.module or 'N/A'}")
else:
parts.append(f"{item.module or 'N/A'}")
parts.append(f"{item.submodule or 'N/A'}")
# Progress bar and percentage
if item.progress_percentage is not None:
pct = item.progress_percentage
bar_width = 15
filled = int(bar_width * pct / 100)
bar = "" * filled + "" * (bar_width - filled)
parts.append(f"|{bar}| {pct:.1f}%")
else:
parts.append("|" + "" * 15 + "| 0.0%")
# ETA
if item.estimated_remaining is not None and item.estimated_remaining > 0:
if item.estimated_remaining < 60:
parts.append(f"ETA: {item.estimated_remaining:.1f}s")
elif item.estimated_remaining < 3600:
parts.append(f"ETA: {item.estimated_remaining/60:.1f}m")
else:
parts.append(f"ETA: {item.estimated_remaining/3600:.1f}h")
else:
parts.append("ETA: -")
# Rate
if item.start_time and item.processed_items is not None and item.processed_items > 0:
elapsed = time.time() - item.start_time
if elapsed > 0:
rate = item.processed_items / elapsed
parts.append(f"Rate: {rate:.1f}/s")
else:
parts.append("Rate: -")
# Time
if item.start_time:
if item.end_time:
elapsed = item.end_time - item.start_time
parts.append(f"Time: {elapsed:.2f}s")
else:
elapsed = time.time() - item.start_time
parts.append(f"Time: {elapsed:.2f}s")
else:
parts.append("Time: -")
# Extraction counts (if available)
if item.metadata.get('extraction_counts'):
counts = item.metadata['extraction_counts']
count_parts = []
if 'tables' in counts:
count_parts.append(f"{counts['tables']} tables")
if 'images' in counts:
count_parts.append(f"{counts['images']} images")
if 'pages' in counts:
count_parts.append(f"{counts['pages']} pages")
if count_parts:
extracted = ", ".join(count_parts)
# Add Docling indicator if core dependency is docling
if item.metadata.get('core_dependency') == 'docling':
parts.append(f"Extracted (Docling): {extracted}")
else:
parts.append(f"Extracted: {extracted}")
else:
parts.append("Extracted: -")
line = " ".join(parts)
self.current_lines[key] = line
sys.stdout.write(line)
def show_summary(self, items: List[ProgressItem]) -> None:
"""Show final summary."""
@@ -482,12 +536,52 @@ class JupyterProgressDisplay(ProgressDisplay):
# Current status
html_parts.append("<h4>🧠 Semantica - 📊 Current Progress</h4>")
html_parts.append("<table style='width: 100%; border-collapse: collapse;'>")
# Check if any item is part of a pipeline
pipeline_id = None
pipeline_items = []
for item in items:
if item.pipeline_id:
pipeline_id = item.pipeline_id
break
# If pipeline context exists, get all pipeline items
if pipeline_id:
tracker = ProgressTracker.get_instance()
pipeline_items = tracker.get_pipeline_items(pipeline_id)
# Also include expected modules that haven't started yet
if pipeline_id in tracker.pipeline_contexts:
expected_modules = tracker.pipeline_contexts[pipeline_id]
module_order = tracker.pipeline_module_order.get(pipeline_id, {})
# Create pending items for modules not yet started
existing_modules = {item.module for item in pipeline_items if item.module}
for module in expected_modules:
if module not in existing_modules:
pending_item = ProgressItem(
module=module,
submodule="Pending",
status="pending",
progress_percentage=0.0,
pipeline_id=pipeline_id,
pipeline_order=module_order.get(module, 999)
)
pipeline_items.append(pending_item)
# Sort by pipeline order
pipeline_items.sort(key=lambda x: (x.pipeline_order if x.pipeline_order is not None else 999, x.module or ""))
# Use pipeline items if available, otherwise use regular items
display_items = pipeline_items if pipeline_items else items[-10:]
# Determine column header based on whether we have Docling items
has_docling = any(item.metadata.get('core_dependency') == 'docling' for item in display_items)
extracted_header = "Extracted (Docling)" if has_docling else "Extracted"
html_parts.append(
"<tr><th>Status</th><th>Action</th><th>Module</th><th>Submodule</th><th>Progress</th><th>ETA</th><th>Rate</th><th>Time</th></tr>"
f"<tr><th>Status</th><th>Action</th><th>Module</th><th>Submodule</th><th>Progress</th><th>ETA</th><th>Rate</th><th>Time</th><th>{extracted_header}</th></tr>"
)
# Show last 10 items
for item in items[-10:]:
# Show pipeline items or last 10 items
for item in display_items:
status_emoji = self._get_status_emoji(item.status)
module_emoji = self._get_emoji_for_module(item.module or "")
action_msg = self._get_action_message(item.module, item.message)
@@ -523,8 +617,32 @@ class JupyterProgressDisplay(ProgressDisplay):
elapsed = f"{(item.end_time - item.start_time):.2f}s"
else:
elapsed = f"{(time.time() - item.start_time):.2f}s"
else:
elapsed = "-"
file_name = Path(item.file).name if item.file else "-"
# Extraction counts
extracted_str = "-"
if item.metadata.get('extraction_counts'):
counts = item.metadata['extraction_counts']
count_parts = []
if 'tables' in counts:
count_parts.append(f"{counts['tables']} tables")
if 'images' in counts:
count_parts.append(f"{counts['images']} images")
if 'pages' in counts:
count_parts.append(f"{counts['pages']} pages")
if count_parts:
extracted_str = ", ".join(count_parts)
elif item.status == "pending":
extracted_str = "-"
elif item.status == "running" and item.module == "parse":
# Show progress message for running parse operations
if "Docling" in item.message or item.metadata.get('core_dependency') == 'docling':
extracted_str = "Converting with Docling..."
else:
extracted_str = "-"
html_parts.append(
f"<tr>"
@@ -536,6 +654,7 @@ class JupyterProgressDisplay(ProgressDisplay):
f"<td>{eta_str}</td>"
f"<td>{rate_str}</td>"
f"<td>{elapsed}</td>"
f"<td>{extracted_str}</td>"
f"</tr>"
)
@@ -562,9 +681,35 @@ class JupyterProgressDisplay(ProgressDisplay):
else:
self.items.append(item)
# If item is part of a pipeline, get all pipeline items for display
display_items = self.items
if item.pipeline_id:
tracker = ProgressTracker.get_instance()
pipeline_items = tracker.get_pipeline_items(item.pipeline_id)
# Also include expected modules that haven't started yet
if item.pipeline_id in tracker.pipeline_contexts:
expected_modules = tracker.pipeline_contexts[item.pipeline_id]
module_order = tracker.pipeline_module_order.get(item.pipeline_id, {})
# Create pending items for modules not yet started
existing_modules = {item.module for item in pipeline_items if item.module}
for module in expected_modules:
if module not in existing_modules:
pending_item = ProgressItem(
module=module,
submodule="Pending",
status="pending",
progress_percentage=0.0,
pipeline_id=item.pipeline_id,
pipeline_order=module_order.get(module, 999)
)
pipeline_items.append(pending_item)
# Sort by pipeline order
pipeline_items.sort(key=lambda x: (x.pipeline_order if x.pipeline_order is not None else 999, x.module or ""))
display_items = pipeline_items
# Update display - always update immediately in Jupyter/Colab
if IPYTHON_AVAILABLE:
html = self._build_html(self.items)
html = self._build_html(display_items)
try:
# Check if we're in Google Colab (Colab sometimes needs fresh displays)
is_colab = False
@@ -901,6 +1046,11 @@ class ProgressTracker:
self.items: List[ProgressItem] = []
self.active_items: Dict[str, ProgressItem] = {}
self.lock = threading.Lock()
# Pipeline context tracking
self.pipeline_contexts: Dict[str, List[str]] = {} # pipeline_id -> list of module names
self.pipeline_items: Dict[str, Dict[str, ProgressItem]] = {} # pipeline_id -> {tracking_id: item}
self.pipeline_module_order: Dict[str, Dict[str, int]] = {} # pipeline_id -> {module: order}
def _detect_jupyter(self) -> bool:
"""Detect if running in Jupyter notebook or Google Colab."""
@@ -962,12 +1112,86 @@ class ProgressTracker:
cls._instance.enabled = True
return cls._instance
def register_pipeline_modules(
self, pipeline_id: str, module_list: List[str], module_order: Optional[Dict[str, int]] = None
) -> None:
"""
Register modules that belong to a pipeline.
Args:
pipeline_id: Unique pipeline identifier
module_list: List of module names in the pipeline
module_order: Optional dict mapping module names to their order in pipeline
"""
if not self.enabled:
return
with self.lock:
self.pipeline_contexts[pipeline_id] = module_list
if module_order:
self.pipeline_module_order[pipeline_id] = module_order
else:
# Auto-generate order if not provided
self.pipeline_module_order[pipeline_id] = {
module: idx for idx, module in enumerate(module_list)
}
# Initialize pipeline items dict
if pipeline_id not in self.pipeline_items:
self.pipeline_items[pipeline_id] = {}
def get_pipeline_items(self, pipeline_id: str) -> List[ProgressItem]:
"""
Get all items for a pipeline (completed + active).
Args:
pipeline_id: Pipeline identifier
Returns:
List of ProgressItem objects for the pipeline, ordered by pipeline_order
"""
if not self.enabled or pipeline_id not in self.pipeline_contexts:
return []
with self.lock:
items = []
# Get items from pipeline_items (completed)
if pipeline_id in self.pipeline_items:
items.extend(self.pipeline_items[pipeline_id].values())
# Get active items that belong to this pipeline
for tracking_id, item in self.active_items.items():
if item.pipeline_id == pipeline_id:
items.append(item)
# Sort by pipeline_order
items.sort(key=lambda x: (x.pipeline_order if x.pipeline_order is not None else 999, x.module or ""))
return items
def clear_pipeline_context(self, pipeline_id: str) -> None:
"""
Clear pipeline context when pipeline completes.
Args:
pipeline_id: Pipeline identifier to clear
"""
if not self.enabled:
return
with self.lock:
if pipeline_id in self.pipeline_contexts:
del self.pipeline_contexts[pipeline_id]
if pipeline_id in self.pipeline_items:
del self.pipeline_items[pipeline_id]
if pipeline_id in self.pipeline_module_order:
del self.pipeline_module_order[pipeline_id]
def start_tracking(
self,
file: Optional[str] = None,
module: Optional[str] = None,
submodule: Optional[str] = None,
message: str = "",
pipeline_id: Optional[str] = None,
) -> str:
"""
Start tracking a progress item.
@@ -1004,6 +1228,17 @@ class ProgressTracker:
# Create tracking ID
tracking_id = f"{module}:{submodule}:{file or ''}"
# Determine pipeline_id and pipeline_order if module is part of a pipeline
pipeline_order = None
if pipeline_id is None and module:
# Try to find pipeline_id from existing contexts
for pid, modules in self.pipeline_contexts.items():
if module in modules:
pipeline_id = pid
if pid in self.pipeline_module_order:
pipeline_order = self.pipeline_module_order[pid].get(module)
break
with self.lock:
item = ProgressItem(
file=file,
@@ -1013,9 +1248,17 @@ class ProgressTracker:
start_time=time.time(),
message=message,
emoji=self._get_emoji_for_module(module or ""),
pipeline_id=pipeline_id,
pipeline_order=pipeline_order,
)
self.active_items[tracking_id] = item
# If part of pipeline, also store in pipeline_items
if pipeline_id:
if pipeline_id not in self.pipeline_items:
self.pipeline_items[pipeline_id] = {}
self.pipeline_items[pipeline_id][tracking_id] = item
# Update displays
for display in self.displays:
@@ -1057,9 +1300,19 @@ class ProgressTracker:
# Reset progress fields on completion
item.progress_percentage = 100.0 if status == "completed" else None
item.estimated_remaining = 0.0 if status == "completed" else None
# Move to completed items
self.items.append(item)
del self.active_items[tracking_id]
# If part of an active pipeline, keep it in pipeline_items instead of removing
if item.pipeline_id and item.pipeline_id in self.pipeline_contexts:
# Keep in pipeline_items for visibility
if item.pipeline_id not in self.pipeline_items:
self.pipeline_items[item.pipeline_id] = {}
self.pipeline_items[item.pipeline_id][tracking_id] = item
# Remove from active_items but keep in pipeline_items
del self.active_items[tracking_id]
else:
# Not part of pipeline, move to completed items as before
self.items.append(item)
del self.active_items[tracking_id]
# Update displays
for display in self.displays:
@@ -1157,7 +1410,7 @@ class ProgressTracker:
return max(0.0, eta_seconds)
def stop_tracking(
self, tracking_id: str, status: str = "completed", message: str = ""
self, tracking_id: str, status: str = "completed", message: str = "", metadata: Optional[Dict[str, Any]] = None
) -> None:
"""
Stop tracking an item.
@@ -1166,7 +1419,22 @@ class ProgressTracker:
tracking_id: Tracking ID from start_tracking
status: Final status (completed, failed)
message: Final message
metadata: Optional metadata to store (e.g., extraction_counts, core_dependency)
"""
with self.lock:
# Update metadata if provided
if tracking_id in self.active_items:
item = self.active_items[tracking_id]
if metadata:
item.metadata.update(metadata)
# Also check pipeline_items
for pipeline_id, items in self.pipeline_items.items():
if tracking_id in items:
item = items[tracking_id]
if metadata:
item.metadata.update(metadata)
break
self.update_tracking(tracking_id, status=status, message=message)
def _get_emoji_for_module(self, module: str) -> str: