abstractcore 2.5.0__py3-none-any.whl → 2.5.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. abstractcore/__init__.py +12 -0
  2. abstractcore/apps/__main__.py +8 -1
  3. abstractcore/apps/deepsearch.py +644 -0
  4. abstractcore/apps/intent.py +614 -0
  5. abstractcore/architectures/detection.py +250 -4
  6. abstractcore/assets/architecture_formats.json +14 -1
  7. abstractcore/assets/model_capabilities.json +583 -44
  8. abstractcore/compression/__init__.py +29 -0
  9. abstractcore/compression/analytics.py +420 -0
  10. abstractcore/compression/cache.py +250 -0
  11. abstractcore/compression/config.py +279 -0
  12. abstractcore/compression/exceptions.py +30 -0
  13. abstractcore/compression/glyph_processor.py +381 -0
  14. abstractcore/compression/optimizer.py +388 -0
  15. abstractcore/compression/orchestrator.py +380 -0
  16. abstractcore/compression/pil_text_renderer.py +818 -0
  17. abstractcore/compression/quality.py +226 -0
  18. abstractcore/compression/text_formatter.py +666 -0
  19. abstractcore/compression/vision_compressor.py +371 -0
  20. abstractcore/config/main.py +66 -1
  21. abstractcore/config/manager.py +111 -5
  22. abstractcore/core/session.py +105 -5
  23. abstractcore/events/__init__.py +1 -1
  24. abstractcore/media/auto_handler.py +312 -18
  25. abstractcore/media/handlers/local_handler.py +14 -2
  26. abstractcore/media/handlers/openai_handler.py +62 -3
  27. abstractcore/media/processors/__init__.py +11 -1
  28. abstractcore/media/processors/direct_pdf_processor.py +210 -0
  29. abstractcore/media/processors/glyph_pdf_processor.py +227 -0
  30. abstractcore/media/processors/image_processor.py +7 -1
  31. abstractcore/media/processors/text_processor.py +18 -3
  32. abstractcore/media/types.py +164 -7
  33. abstractcore/processing/__init__.py +5 -1
  34. abstractcore/processing/basic_deepsearch.py +2173 -0
  35. abstractcore/processing/basic_intent.py +690 -0
  36. abstractcore/providers/__init__.py +18 -0
  37. abstractcore/providers/anthropic_provider.py +29 -2
  38. abstractcore/providers/base.py +279 -6
  39. abstractcore/providers/huggingface_provider.py +658 -27
  40. abstractcore/providers/lmstudio_provider.py +52 -2
  41. abstractcore/providers/mlx_provider.py +103 -4
  42. abstractcore/providers/model_capabilities.py +352 -0
  43. abstractcore/providers/ollama_provider.py +44 -6
  44. abstractcore/providers/openai_provider.py +29 -2
  45. abstractcore/providers/registry.py +91 -19
  46. abstractcore/server/app.py +91 -81
  47. abstractcore/structured/handler.py +161 -1
  48. abstractcore/tools/common_tools.py +98 -3
  49. abstractcore/utils/__init__.py +4 -1
  50. abstractcore/utils/cli.py +114 -1
  51. abstractcore/utils/trace_export.py +287 -0
  52. abstractcore/utils/version.py +1 -1
  53. abstractcore/utils/vlm_token_calculator.py +655 -0
  54. {abstractcore-2.5.0.dist-info → abstractcore-2.5.3.dist-info}/METADATA +140 -23
  55. abstractcore-2.5.3.dist-info/RECORD +107 -0
  56. {abstractcore-2.5.0.dist-info → abstractcore-2.5.3.dist-info}/entry_points.txt +4 -0
  57. abstractcore-2.5.0.dist-info/RECORD +0 -86
  58. {abstractcore-2.5.0.dist-info → abstractcore-2.5.3.dist-info}/WHEEL +0 -0
  59. {abstractcore-2.5.0.dist-info → abstractcore-2.5.3.dist-info}/licenses/LICENSE +0 -0
  60. {abstractcore-2.5.0.dist-info → abstractcore-2.5.3.dist-info}/top_level.txt +0 -0
@@ -34,9 +34,10 @@ class BasicSession:
34
34
  auto_compact: bool = False,
35
35
  auto_compact_threshold: int = 6000,
36
36
  temperature: Optional[float] = None,
37
- seed: Optional[int] = None):
37
+ seed: Optional[int] = None,
38
+ enable_tracing: bool = False):
38
39
  """Initialize basic session
39
-
40
+
40
41
  Args:
41
42
  provider: LLM provider instance
42
43
  system_prompt: System prompt for the session
@@ -48,6 +49,7 @@ class BasicSession:
48
49
  auto_compact_threshold: Token threshold for auto-compaction
49
50
  temperature: Default temperature for generation (0.0-1.0)
50
51
  seed: Default seed for deterministic generation
52
+ enable_tracing: Enable interaction tracing for observability
51
53
  """
52
54
 
53
55
  self.provider = provider
@@ -59,11 +61,15 @@ class BasicSession:
59
61
  self.auto_compact = auto_compact
60
62
  self.auto_compact_threshold = auto_compact_threshold
61
63
  self._original_session = None # Track if this is a compacted session
62
-
64
+
63
65
  # Store session-level generation parameters
64
66
  self.temperature = temperature
65
67
  self.seed = seed
66
-
68
+
69
+ # Setup interaction tracing
70
+ self.enable_tracing = enable_tracing
71
+ self.interaction_traces: List[Dict[str, Any]] = [] # Session-specific traces
72
+
67
73
  # Optional analytics fields
68
74
  self.summary = None
69
75
  self.assessment = None
@@ -214,6 +220,16 @@ class BasicSession:
214
220
  if 'seed' not in kwargs and self.seed is not None:
215
221
  kwargs['seed'] = self.seed
216
222
 
223
+ # Add trace metadata if tracing is enabled
224
+ if self.enable_tracing:
225
+ if 'trace_metadata' not in kwargs:
226
+ kwargs['trace_metadata'] = {}
227
+ kwargs['trace_metadata'].update({
228
+ 'session_id': self.id,
229
+ 'step_type': kwargs.get('step_type', 'chat'),
230
+ 'attempt_number': kwargs.get('attempt_number', 1)
231
+ })
232
+
217
233
  # Call provider
218
234
  response = self.provider.generate(
219
235
  prompt=prompt,
@@ -231,6 +247,14 @@ class BasicSession:
231
247
  # Non-streaming response
232
248
  if hasattr(response, 'content') and response.content:
233
249
  self.add_message('assistant', response.content)
250
+
251
+ # Capture trace if enabled and available
252
+ if self.enable_tracing and hasattr(self.provider, 'get_traces'):
253
+ if hasattr(response, 'metadata') and response.metadata and 'trace_id' in response.metadata:
254
+ trace = self.provider.get_traces(response.metadata['trace_id'])
255
+ if trace:
256
+ self.interaction_traces.append(trace)
257
+
234
258
  return response
235
259
 
236
260
  def _handle_streaming_response(self, response_iterator: Iterator[GenerateResponse]) -> Iterator[GenerateResponse]:
@@ -893,4 +917,80 @@ class BasicSession:
893
917
  "statistics": extraction_result.get("statistics", {})
894
918
  }
895
919
 
896
- return self.facts
920
+ return self.facts
921
+
922
+ def analyze_intents(self,
923
+ focus_participant: Optional[str] = None,
924
+ depth: str = "underlying",
925
+ context_type: str = "conversational") -> Dict[str, Any]:
926
+ """
927
+ Analyze intents in the conversation using BasicIntentAnalyzer.
928
+
929
+ Args:
930
+ focus_participant: Optional role to focus analysis on (e.g., "user", "assistant")
931
+ depth: Depth of intent analysis ("surface", "underlying", "comprehensive")
932
+ context_type: Context type for analysis ("conversational" is default for sessions)
933
+
934
+ Returns:
935
+ Dict containing intent analysis results for each participant
936
+ """
937
+ if not self.messages:
938
+ return {}
939
+
940
+ if not self.provider:
941
+ raise ValueError("No provider available for intent analysis")
942
+
943
+ try:
944
+ from ..processing import BasicIntentAnalyzer, IntentDepth, IntentContext
945
+ except ImportError:
946
+ raise ImportError("BasicIntentAnalyzer not available")
947
+
948
+ # Create intent analyzer
949
+ analyzer = BasicIntentAnalyzer(self.provider)
950
+
951
+ # Convert depth and context strings to enums
952
+ depth_enum = IntentDepth(depth)
953
+ context_enum = IntentContext(context_type)
954
+
955
+ # Convert session messages to the format expected by analyze_conversation_intents
956
+ message_dicts = [{"role": msg.role, "content": msg.content} for msg in self.messages]
957
+
958
+ # Analyze conversation intents
959
+ results = analyzer.analyze_conversation_intents(
960
+ messages=message_dicts,
961
+ focus_participant=focus_participant,
962
+ depth=depth_enum
963
+ )
964
+
965
+ return results
966
+
967
+ def get_interaction_history(self) -> List[Dict[str, Any]]:
968
+ """
969
+ Get all interaction traces for this session.
970
+
971
+ Returns a list of all LLM interaction traces captured during the session.
972
+ Each trace contains complete information about the prompt, parameters,
973
+ and response for observability and debugging.
974
+
975
+ Returns:
976
+ List of trace dictionaries containing:
977
+ - trace_id: Unique identifier for the interaction
978
+ - timestamp: ISO format timestamp
979
+ - provider: Provider name
980
+ - model: Model name
981
+ - prompt: User prompt
982
+ - system_prompt: System prompt (if any)
983
+ - messages: Conversation history
984
+ - parameters: Generation parameters (temperature, tokens, etc.)
985
+ - response: Full response with content, usage, timing
986
+ - metadata: Custom metadata (session_id, step_type, etc.)
987
+
988
+ Example:
989
+ >>> session = BasicSession(provider=llm, enable_tracing=True)
990
+ >>> response = session.generate("What is Python?")
991
+ >>> traces = session.get_interaction_history()
992
+ >>> print(f"Captured {len(traces)} interactions")
993
+ >>> print(f"First trace: {traces[0]['trace_id']}")
994
+ >>> print(f"Tokens used: {traces[0]['response']['usage']}")
995
+ """
996
+ return self.interaction_traces.copy()
@@ -216,7 +216,7 @@ def emit_global(event_type: EventType, data: Dict[str, Any], source: Optional[st
216
216
  def create_generation_event(model_name: str, provider_name: str,
217
217
  tokens_input: int = None, tokens_output: int = None,
218
218
  duration_ms: float = None, cost_usd: float = None,
219
- **data) -> Dict[str, Any]:
219
+ **data) -> tuple[Dict[str, Any], Dict[str, Any]]:
220
220
  """Create standardized generation event data"""
221
221
  event_data = {
222
222
  "model_name": model_name,
@@ -13,6 +13,25 @@ from typing import Dict, Any, Optional, List
13
13
  from .base import BaseMediaHandler
14
14
  from .types import MediaContent, MediaType, ContentFormat, detect_media_type
15
15
  from .processors import ImageProcessor, TextProcessor, PDFProcessor, OfficeProcessor
16
+ from ..exceptions import UnsupportedFeatureError
17
+
18
+ # Import Glyph compression support
19
+ try:
20
+ from ..compression.orchestrator import CompressionOrchestrator
21
+ from ..compression.config import GlyphConfig
22
+ GLYPH_AVAILABLE = True
23
+ except ImportError:
24
+ CompressionOrchestrator = None
25
+ GlyphConfig = None
26
+ GLYPH_AVAILABLE = False
27
+
28
+ # Import vision detection
29
+ try:
30
+ from ..architectures.detection import supports_vision
31
+ VISION_DETECTION_AVAILABLE = True
32
+ except ImportError:
33
+ supports_vision = None
34
+ VISION_DETECTION_AVAILABLE = False
16
35
 
17
36
 
18
37
  class AutoMediaHandler(BaseMediaHandler):
@@ -41,6 +60,11 @@ class AutoMediaHandler(BaseMediaHandler):
41
60
  self._text_processor = None
42
61
  self._pdf_processor = None
43
62
  self._office_processor = None
63
+
64
+ # Initialize Glyph compression support
65
+ self._compression_orchestrator = None
66
+ self.glyph_config = kwargs.get('glyph_config')
67
+ self.enable_compression = kwargs.get('enable_glyph_compression', GLYPH_AVAILABLE)
44
68
 
45
69
  # Track which processors are available
46
70
  self._available_processors = self._check_processor_availability()
@@ -74,6 +98,20 @@ class AutoMediaHandler(BaseMediaHandler):
74
98
  availability['office'] = True
75
99
  except ImportError:
76
100
  availability['office'] = False
101
+
102
+ # GlyphProcessor (requires reportlab and pdf2image)
103
+ glyph_deps_available = True
104
+ if GLYPH_AVAILABLE and self.enable_compression:
105
+ # Check actual dependencies
106
+ try:
107
+ import reportlab
108
+ import pdf2image
109
+ except ImportError:
110
+ glyph_deps_available = False
111
+ else:
112
+ glyph_deps_available = False
113
+
114
+ availability['glyph'] = glyph_deps_available
77
115
 
78
116
  return availability
79
117
 
@@ -100,6 +138,13 @@ class AutoMediaHandler(BaseMediaHandler):
100
138
  if self._office_processor is None:
101
139
  self._office_processor = OfficeProcessor(**self.processor_config)
102
140
  return self._office_processor
141
+
142
+ def _get_compression_orchestrator(self) -> 'CompressionOrchestrator':
143
+ """Get or create CompressionOrchestrator instance."""
144
+ if self._compression_orchestrator is None and GLYPH_AVAILABLE:
145
+ config = self.glyph_config or GlyphConfig.from_abstractcore_config()
146
+ self._compression_orchestrator = CompressionOrchestrator(config)
147
+ return self._compression_orchestrator
103
148
 
104
149
  def _select_processor(self, file_path: Path, media_type: MediaType) -> Optional[BaseMediaHandler]:
105
150
  """
@@ -167,6 +212,20 @@ class AutoMediaHandler(BaseMediaHandler):
167
212
  Returns:
168
213
  MediaContent object with processed content
169
214
  """
215
+ # Check if Glyph compression should be applied
216
+ provider = kwargs.get('provider')
217
+ model = kwargs.get('model')
218
+ glyph_compression = kwargs.get('glyph_compression', 'auto')
219
+
220
+ if self._should_apply_compression(file_path, media_type, provider, model, glyph_compression):
221
+ try:
222
+ # Remove provider and model from kwargs to avoid duplicate arguments
223
+ compression_kwargs = {k: v for k, v in kwargs.items() if k not in ['provider', 'model']}
224
+ return self._apply_compression(file_path, provider, model, **compression_kwargs)
225
+ except Exception as e:
226
+ self.logger.warning(f"Glyph compression failed, falling back to standard processing: {e}")
227
+ # Continue with standard processing
228
+
170
229
  # Select the appropriate processor
171
230
  processor = self._select_processor(file_path, media_type)
172
231
 
@@ -218,6 +277,221 @@ class AutoMediaHandler(BaseMediaHandler):
218
277
  fallback_processing=True,
219
278
  available_processors=list(self._available_processors.keys())
220
279
  )
280
+
281
+ def _should_apply_compression(self, file_path: Path, media_type: MediaType,
282
+ provider: str, model: str, glyph_compression: str) -> bool:
283
+ """
284
+ Check if Glyph compression should be applied.
285
+
286
+ ⚠️ EXPERIMENTAL FEATURE: Glyph compression requires vision-capable models.
287
+
288
+ Raises:
289
+ UnsupportedFeatureError: When glyph_compression="always" but model lacks vision support
290
+ """
291
+ # Check if Glyph is available
292
+ if not self._available_processors.get('glyph', False):
293
+ if glyph_compression == "always":
294
+ # User explicitly requested compression but it's not available
295
+ self._log_compression_unavailable_warning()
296
+ return False
297
+
298
+ if glyph_compression == "never":
299
+ return False
300
+
301
+ # Check vision support for compression
302
+ model_supports_vision = self._check_vision_support(model)
303
+
304
+ if glyph_compression == "always":
305
+ # Explicit compression request - enforce vision requirement
306
+ if not model_supports_vision:
307
+ raise UnsupportedFeatureError(
308
+ f"Glyph compression requires a vision-capable model. "
309
+ f"Model '{model}' does not support vision. "
310
+ f"Vision-capable models include: gpt-4o, gpt-4o-mini, claude-3-5-sonnet, "
311
+ f"llama3.2-vision, qwen2-vl, gemini-1.5-pro, gemini-1.5-flash, etc."
312
+ )
313
+ return True
314
+
315
+ # Auto-decision logic
316
+ if not provider or not model:
317
+ return False
318
+
319
+ # Only compress text-based content
320
+ if media_type not in [MediaType.TEXT, MediaType.DOCUMENT]:
321
+ return False
322
+
323
+ # Auto mode: check vision support and warn if not supported
324
+ if not model_supports_vision:
325
+ self.logger.warning(
326
+ f"Glyph compression skipped: model '{model}' does not support vision. "
327
+ f"Use a vision-capable model to enable compression."
328
+ )
329
+ return False
330
+
331
+ try:
332
+ orchestrator = self._get_compression_orchestrator()
333
+ if orchestrator:
334
+ return orchestrator.should_compress(file_path, provider, model, glyph_compression)
335
+ except Exception as e:
336
+ self.logger.debug(f"Compression decision failed: {e}")
337
+
338
+ return False
339
+
340
+ def _check_vision_support(self, model: str) -> bool:
341
+ """
342
+ Check if the model supports vision capabilities.
343
+
344
+ Args:
345
+ model: Model name to check
346
+
347
+ Returns:
348
+ True if model supports vision, False otherwise
349
+ """
350
+ if not model or not VISION_DETECTION_AVAILABLE:
351
+ # Conservative approach: assume no vision if detection unavailable
352
+ return False
353
+
354
+ try:
355
+ return supports_vision(model)
356
+ except Exception as e:
357
+ self.logger.debug(f"Failed to check vision support for model '{model}': {e}")
358
+ return False
359
+
360
+ def _log_compression_unavailable_warning(self):
361
+ """Log detailed warning about why Glyph compression is unavailable."""
362
+ self.logger.warning("Glyph compression requested but not available")
363
+
364
+ # Check specific reasons
365
+ if not GLYPH_AVAILABLE:
366
+ self.logger.warning("Glyph compression modules could not be imported")
367
+
368
+ # Check dependencies
369
+ missing_deps = []
370
+ try:
371
+ import reportlab
372
+ except ImportError:
373
+ missing_deps.append("reportlab")
374
+
375
+ try:
376
+ import pdf2image
377
+ except ImportError:
378
+ missing_deps.append("pdf2image")
379
+
380
+ if missing_deps:
381
+ deps_str = ", ".join(missing_deps)
382
+ self.logger.warning(f"Missing Glyph dependencies: {deps_str}")
383
+ self.logger.warning(f"Install with: pip install {' '.join(missing_deps)}")
384
+
385
+ if not self.enable_compression:
386
+ self.logger.warning("Glyph compression is disabled in AutoMediaHandler configuration")
387
+
388
+ def _apply_compression(self, file_path: Path, provider: str, model: str, **kwargs) -> MediaContent:
389
+ """Apply Glyph compression to the file."""
390
+ media_type = detect_media_type(file_path)
391
+
392
+ # For PDF files, use direct PDF-to-image conversion (no text extraction!)
393
+ if media_type == MediaType.DOCUMENT and file_path.suffix.lower() == '.pdf':
394
+ try:
395
+ from .processors.direct_pdf_processor import DirectPDFProcessor
396
+
397
+ # Configure for optimal compression (2 pages per image)
398
+ direct_processor = DirectPDFProcessor(
399
+ pages_per_image=2, # 16 pages → 8 images
400
+ dpi=150, # Good quality for VLM processing
401
+ layout='horizontal', # Side-by-side like open book
402
+ gap=20, # Small gap between pages
403
+ **kwargs
404
+ )
405
+
406
+ # Get all combined images
407
+ combined_images = direct_processor.get_combined_image_paths(file_path)
408
+
409
+ # Get session info for metadata from DirectPDFProcessor
410
+ from ..config import get_config_manager
411
+ import hashlib
412
+ config_manager = get_config_manager()
413
+ glyph_cache_base = Path(config_manager.config.cache.glyph_cache_dir).expanduser()
414
+ pdf_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
415
+ session_id = f"pdf_{pdf_hash}_{len(combined_images)}pages"
416
+
417
+ # Create MediaContent objects for each combined image
418
+ media_contents = []
419
+ for i, img_path in enumerate(combined_images):
420
+ with open(img_path, 'rb') as f:
421
+ image_data = f.read()
422
+
423
+ import base64
424
+ encoded_data = base64.b64encode(image_data).decode('utf-8')
425
+
426
+ media_content = MediaContent(
427
+ media_type=MediaType.IMAGE,
428
+ content=encoded_data,
429
+ content_format=ContentFormat.BASE64,
430
+ mime_type="image/png",
431
+ metadata={
432
+ 'compression_used': True,
433
+ 'compression_method': 'direct_pdf_conversion',
434
+ 'pages_per_image': 2,
435
+ 'image_index': i,
436
+ 'total_images': len(combined_images),
437
+ 'original_file': str(file_path),
438
+ 'glyph_session_id': session_id,
439
+ 'glyph_cache_dir': str(glyph_cache_base / session_id),
440
+ 'processing_method': 'direct_pdf_conversion' # For compatibility with test script
441
+ }
442
+ )
443
+ media_contents.append(media_content)
444
+
445
+ self.logger.info(f"Direct PDF conversion: {len(combined_images)} combined images created")
446
+
447
+ # Return first image (in full implementation, would handle multiple)
448
+ if media_contents:
449
+ return media_contents[0]
450
+ else:
451
+ raise Exception("No combined images created")
452
+
453
+ except Exception as e:
454
+ self.logger.warning(f"DirectPDFProcessor failed: {e}, falling back to text extraction")
455
+ # Fall back to text extraction method
456
+ pass
457
+
458
+ # Fallback: text extraction method (for non-PDF or if direct method fails)
459
+ orchestrator = self._get_compression_orchestrator()
460
+ if not orchestrator:
461
+ raise Exception("Compression orchestrator not available")
462
+
463
+ if media_type == MediaType.DOCUMENT and file_path.suffix.lower() == '.pdf':
464
+ processor = self._get_pdf_processor()
465
+ elif media_type == MediaType.DOCUMENT:
466
+ processor = self._get_office_processor()
467
+ else:
468
+ processor = self._get_text_processor()
469
+
470
+ # Extract text content
471
+ extracted_content = processor._process_internal(file_path, media_type, **kwargs)
472
+ text_content = extracted_content.content
473
+
474
+ # Compress the extracted text content
475
+ glyph_compression = kwargs.get('glyph_compression', 'auto')
476
+ compressed_content = orchestrator.compress_content(text_content, provider, model, glyph_compression)
477
+
478
+ if compressed_content and len(compressed_content) > 0:
479
+ # Return first compressed image as primary content
480
+ # Additional images can be accessed through metadata
481
+ primary_content = compressed_content[0]
482
+
483
+ # Add information about additional images
484
+ if len(compressed_content) > 1:
485
+ primary_content.metadata['additional_images'] = len(compressed_content) - 1
486
+ primary_content.metadata['total_compressed_images'] = len(compressed_content)
487
+
488
+ # Add compression metadata
489
+ primary_content.metadata['compression_used'] = True
490
+ primary_content.metadata['original_file'] = str(file_path)
491
+
492
+ return primary_content
493
+ else:
494
+ raise Exception("No compressed content generated")
221
495
 
222
496
  def supports_media_type(self, media_type: MediaType) -> bool:
223
497
  """
@@ -259,9 +533,9 @@ class AutoMediaHandler(BaseMediaHandler):
259
533
  return format_ext.lower() in image_formats
260
534
 
261
535
  elif media_type == MediaType.TEXT:
262
- # Text formats (always available)
263
- text_formats = {'txt', 'md', 'csv', 'tsv', 'json', 'yaml', 'yml'}
264
- return format_ext.lower() in text_formats
536
+ # TextProcessor can handle ANY text file through its plain text fallback
537
+ # This is always available and supports all text-based files
538
+ return True
265
539
 
266
540
  elif media_type == MediaType.DOCUMENT:
267
541
  # PDF support
@@ -272,9 +546,9 @@ class AutoMediaHandler(BaseMediaHandler):
272
546
  if format_ext.lower() in {'docx', 'xlsx', 'pptx'}:
273
547
  return self._available_processors.get('office', False) or True # Fallback to text
274
548
 
275
- # Text document support (always available)
276
- text_formats = {'txt', 'md', 'csv', 'tsv', 'json', 'yaml', 'yml'}
277
- return format_ext.lower() in text_formats
549
+ # Any other document type can be handled by text processor as fallback
550
+ # This allows processing of unknown document formats
551
+ return True
278
552
 
279
553
  return False
280
554
 
@@ -282,27 +556,47 @@ class AutoMediaHandler(BaseMediaHandler):
282
556
  """
283
557
  Get supported formats organized by media type.
284
558
 
559
+ Returns comprehensive list of all supported file extensions.
560
+ Note: TEXT type supports ANY text-based file through content detection
561
+ and fallback processing, not just the listed extensions.
562
+
285
563
  Returns:
286
564
  Dictionary mapping media type to list of supported extensions
565
+
566
+ Example:
567
+ >>> handler = AutoMediaHandler()
568
+ >>> formats = handler.get_supported_formats()
569
+ >>> len(formats['text']) # 70+ text extensions
570
+ 70+
571
+ >>> 'r' in formats['text'] # R scripts supported
572
+ True
287
573
  """
288
- formats = {}
574
+ from .types import get_all_supported_extensions
289
575
 
290
- # Image formats
291
- if self._available_processors.get('image', False):
292
- formats['image'] = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp']
576
+ # Get comprehensive list from FILE_TYPE_MAPPINGS
577
+ all_formats = get_all_supported_extensions()
293
578
 
294
- # Document formats
295
- doc_formats = ['txt', 'md', 'csv', 'tsv', 'json', 'yaml', 'yml']
579
+ # Filter based on available processors
580
+ result = {}
296
581
 
297
- if self._available_processors.get('pdf', False):
298
- doc_formats.append('pdf')
582
+ # Image formats (requires PIL)
583
+ if self._available_processors.get('image', False):
584
+ result['image'] = all_formats.get('image', [])
299
585
 
300
- if self._available_processors.get('office', False):
301
- doc_formats.extend(['docx', 'xlsx', 'pptx'])
586
+ # Text formats (always available - TextProcessor has built-in fallback)
587
+ # Note: This includes 70+ extensions + unknown text files via content detection
588
+ result['text'] = all_formats.get('text', [])
589
+
590
+ # Document formats (includes PDFs, Office docs, and text fallbacks)
591
+ result['document'] = all_formats.get('document', [])
302
592
 
303
- formats['document'] = doc_formats
593
+ # Audio/Video (not yet implemented but listed for completeness)
594
+ if 'audio' in all_formats:
595
+ result['audio'] = all_formats['audio']
596
+ if 'video' in all_formats:
597
+ result['video'] = all_formats['video']
304
598
 
305
- return formats
599
+ return result
306
600
 
307
601
  def get_processor_info(self) -> Dict[str, Any]:
308
602
  """
@@ -412,12 +412,24 @@ class LocalMediaHandler(BaseProviderMediaHandler):
412
412
  if media_content.media_type == MediaType.IMAGE and self.can_handle_media(media_content):
413
413
  if media_content.content_format == ContentFormat.BASE64:
414
414
  data_url = f"data:{media_content.mime_type};base64,{media_content.content}"
415
- content.append({
415
+ image_obj = {
416
416
  "type": "image_url",
417
417
  "image_url": {
418
418
  "url": data_url
419
419
  }
420
- })
420
+ }
421
+
422
+ # Add detail level if specified in metadata (for Qwen models)
423
+ detail_level = media_content.metadata.get('detail_level', 'auto')
424
+ self.logger.debug(f"MediaContent metadata: {media_content.metadata}")
425
+ self.logger.debug(f"Found detail_level: {detail_level}")
426
+ if detail_level in ['low', 'high', 'auto']:
427
+ image_obj["image_url"]["detail"] = detail_level
428
+ self.logger.info(f"Setting detail level to '{detail_level}' for LMStudio image")
429
+ else:
430
+ self.logger.warning(f"Invalid detail level '{detail_level}', skipping")
431
+
432
+ content.append(image_obj)
421
433
  else:
422
434
  self.logger.warning(f"LMStudio requires base64 image format, got {media_content.content_format}")
423
435