abstractcore 2.9.1__py3-none-any.whl → 2.11.2__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 (83) hide show
  1. abstractcore/__init__.py +7 -27
  2. abstractcore/apps/extractor.py +33 -100
  3. abstractcore/apps/intent.py +19 -0
  4. abstractcore/apps/judge.py +20 -1
  5. abstractcore/apps/summarizer.py +20 -1
  6. abstractcore/architectures/detection.py +34 -1
  7. abstractcore/architectures/response_postprocessing.py +313 -0
  8. abstractcore/assets/architecture_formats.json +38 -8
  9. abstractcore/assets/model_capabilities.json +781 -160
  10. abstractcore/compression/__init__.py +1 -2
  11. abstractcore/compression/glyph_processor.py +6 -4
  12. abstractcore/config/main.py +31 -19
  13. abstractcore/config/manager.py +389 -11
  14. abstractcore/config/vision_config.py +5 -5
  15. abstractcore/core/interface.py +151 -3
  16. abstractcore/core/session.py +16 -10
  17. abstractcore/download.py +1 -1
  18. abstractcore/embeddings/manager.py +20 -6
  19. abstractcore/endpoint/__init__.py +2 -0
  20. abstractcore/endpoint/app.py +458 -0
  21. abstractcore/mcp/client.py +3 -1
  22. abstractcore/media/__init__.py +52 -17
  23. abstractcore/media/auto_handler.py +42 -22
  24. abstractcore/media/base.py +44 -1
  25. abstractcore/media/capabilities.py +12 -33
  26. abstractcore/media/enrichment.py +105 -0
  27. abstractcore/media/handlers/anthropic_handler.py +19 -28
  28. abstractcore/media/handlers/local_handler.py +124 -70
  29. abstractcore/media/handlers/openai_handler.py +19 -31
  30. abstractcore/media/processors/__init__.py +4 -2
  31. abstractcore/media/processors/audio_processor.py +57 -0
  32. abstractcore/media/processors/office_processor.py +8 -3
  33. abstractcore/media/processors/pdf_processor.py +46 -3
  34. abstractcore/media/processors/text_processor.py +22 -24
  35. abstractcore/media/processors/video_processor.py +58 -0
  36. abstractcore/media/types.py +97 -4
  37. abstractcore/media/utils/image_scaler.py +20 -2
  38. abstractcore/media/utils/video_frames.py +219 -0
  39. abstractcore/media/vision_fallback.py +136 -22
  40. abstractcore/processing/__init__.py +32 -3
  41. abstractcore/processing/basic_deepsearch.py +15 -10
  42. abstractcore/processing/basic_intent.py +3 -2
  43. abstractcore/processing/basic_judge.py +3 -2
  44. abstractcore/processing/basic_summarizer.py +1 -1
  45. abstractcore/providers/__init__.py +3 -1
  46. abstractcore/providers/anthropic_provider.py +95 -8
  47. abstractcore/providers/base.py +1516 -81
  48. abstractcore/providers/huggingface_provider.py +546 -69
  49. abstractcore/providers/lmstudio_provider.py +35 -923
  50. abstractcore/providers/mlx_provider.py +382 -35
  51. abstractcore/providers/model_capabilities.py +5 -1
  52. abstractcore/providers/ollama_provider.py +99 -15
  53. abstractcore/providers/openai_compatible_provider.py +406 -180
  54. abstractcore/providers/openai_provider.py +188 -44
  55. abstractcore/providers/openrouter_provider.py +76 -0
  56. abstractcore/providers/registry.py +61 -5
  57. abstractcore/providers/streaming.py +138 -33
  58. abstractcore/providers/vllm_provider.py +92 -817
  59. abstractcore/server/app.py +461 -13
  60. abstractcore/server/audio_endpoints.py +139 -0
  61. abstractcore/server/vision_endpoints.py +1319 -0
  62. abstractcore/structured/handler.py +316 -41
  63. abstractcore/tools/common_tools.py +5501 -2012
  64. abstractcore/tools/comms_tools.py +1641 -0
  65. abstractcore/tools/core.py +37 -7
  66. abstractcore/tools/handler.py +4 -9
  67. abstractcore/tools/parser.py +49 -2
  68. abstractcore/tools/tag_rewriter.py +2 -1
  69. abstractcore/tools/telegram_tdlib.py +407 -0
  70. abstractcore/tools/telegram_tools.py +261 -0
  71. abstractcore/utils/cli.py +1085 -72
  72. abstractcore/utils/token_utils.py +2 -0
  73. abstractcore/utils/truncation.py +29 -0
  74. abstractcore/utils/version.py +3 -4
  75. abstractcore/utils/vlm_token_calculator.py +12 -2
  76. abstractcore-2.11.2.dist-info/METADATA +562 -0
  77. abstractcore-2.11.2.dist-info/RECORD +133 -0
  78. {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/WHEEL +1 -1
  79. {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/entry_points.txt +1 -0
  80. abstractcore-2.9.1.dist-info/METADATA +0 -1190
  81. abstractcore-2.9.1.dist-info/RECORD +0 -119
  82. {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/licenses/LICENSE +0 -0
  83. {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,58 @@
1
+ """
2
+ Video processor for AbstractCore media handling.
3
+
4
+ v0 goals:
5
+ - Treat video as a first-class media type (MediaType.VIDEO) in the media pipeline.
6
+ - Keep processing lightweight and dependency-free by default (store as a file ref).
7
+
8
+ Higher-level semantic handling (native video models, frame sampling, captioning)
9
+ is handled by policy and capability layers (see planned video policy backlog).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import mimetypes
15
+ from pathlib import Path
16
+
17
+ from ..base import BaseMediaHandler, MediaProcessingError
18
+ from ..types import ContentFormat, MediaCapabilities, MediaContent, MediaType
19
+
20
+
21
+ class VideoProcessor(BaseMediaHandler):
22
+ """Lightweight video processor that stores a video file reference."""
23
+
24
+ def __init__(self, **kwargs):
25
+ super().__init__(**kwargs)
26
+
27
+ self.capabilities = MediaCapabilities(
28
+ vision_support=False,
29
+ audio_support=False,
30
+ video_support=True,
31
+ document_support=False,
32
+ max_file_size=self.max_file_size,
33
+ )
34
+
35
+ def _process_internal(self, file_path: Path, media_type: MediaType, **kwargs) -> MediaContent:
36
+ if media_type != MediaType.VIDEO:
37
+ raise MediaProcessingError(f"VideoProcessor only handles video, got {media_type}")
38
+
39
+ mime_type, _enc = mimetypes.guess_type(str(file_path))
40
+ mime_type = mime_type or "application/octet-stream"
41
+
42
+ metadata = {
43
+ "file_name": file_path.name,
44
+ "file_path": str(file_path),
45
+ "file_size": file_path.stat().st_size if file_path.exists() else None,
46
+ "processor": self.__class__.__name__,
47
+ }
48
+ metadata.update(kwargs.get("metadata", {}) if isinstance(kwargs.get("metadata"), dict) else {})
49
+
50
+ return MediaContent(
51
+ media_type=MediaType.VIDEO,
52
+ content=str(file_path),
53
+ content_format=ContentFormat.FILE_PATH,
54
+ mime_type=mime_type,
55
+ file_path=str(file_path),
56
+ metadata=metadata,
57
+ )
58
+
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from typing import Union, Dict, Any, Optional, List, Literal
13
13
  from enum import Enum
14
14
 
15
- from pydantic import BaseModel, Field, validator
15
+ from pydantic import BaseModel, Field, field_validator
16
16
 
17
17
 
18
18
  class MediaType(Enum):
@@ -63,6 +63,98 @@ class MediaContent:
63
63
  elif self.content_format == ContentFormat.TEXT and isinstance(self.content, bytes):
64
64
  self.content = self.content.decode('utf-8')
65
65
 
66
+ def to_dict(self) -> Dict[str, Any]:
67
+ """Return a JSON-safe dict representation (best-effort)."""
68
+ content: Any = self.content
69
+ if isinstance(content, (bytes, bytearray)):
70
+ content = base64.b64encode(bytes(content)).decode("utf-8")
71
+ return {
72
+ "media_type": self.media_type.value if isinstance(self.media_type, MediaType) else str(self.media_type),
73
+ "content": content,
74
+ "content_format": self.content_format.value
75
+ if isinstance(self.content_format, ContentFormat)
76
+ else str(self.content_format),
77
+ "mime_type": str(self.mime_type or "application/octet-stream"),
78
+ "file_path": self.file_path,
79
+ "metadata": dict(self.metadata or {}),
80
+ }
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: Dict[str, Any]) -> "MediaContent":
84
+ """Parse a dict into a MediaContent (best-effort, tolerant of common aliases)."""
85
+ if not isinstance(data, dict):
86
+ raise TypeError("MediaContent.from_dict expects a dict")
87
+
88
+ media_type_raw = data.get("media_type")
89
+ if media_type_raw is None:
90
+ media_type_raw = data.get("mediaType")
91
+
92
+ mime_type_raw = data.get("mime_type")
93
+ if mime_type_raw is None:
94
+ mime_type_raw = data.get("mimeType")
95
+ if mime_type_raw is None:
96
+ mime_type_raw = data.get("mime")
97
+
98
+ mime_type = str(mime_type_raw or "application/octet-stream")
99
+
100
+ if isinstance(media_type_raw, MediaType):
101
+ media_type = media_type_raw
102
+ elif isinstance(media_type_raw, str) and media_type_raw.strip():
103
+ media_type = MediaType(media_type_raw.strip())
104
+ else:
105
+ # Infer from MIME type when missing.
106
+ mt = mime_type.lower()
107
+ if mt.startswith("image/"):
108
+ media_type = MediaType.IMAGE
109
+ elif mt.startswith("audio/"):
110
+ media_type = MediaType.AUDIO
111
+ elif mt.startswith("video/"):
112
+ media_type = MediaType.VIDEO
113
+ elif mt.startswith("text/"):
114
+ media_type = MediaType.TEXT
115
+ else:
116
+ media_type = MediaType.DOCUMENT
117
+
118
+ content_format_raw = data.get("content_format")
119
+ if content_format_raw is None:
120
+ content_format_raw = data.get("contentFormat")
121
+ if content_format_raw is None:
122
+ content_format_raw = data.get("format")
123
+
124
+ file_path_raw = data.get("file_path")
125
+ if file_path_raw is None:
126
+ file_path_raw = data.get("filePath")
127
+
128
+ content = data.get("content")
129
+
130
+ if isinstance(content_format_raw, ContentFormat):
131
+ content_format = content_format_raw
132
+ elif isinstance(content_format_raw, str) and content_format_raw.strip():
133
+ content_format = ContentFormat(content_format_raw.strip())
134
+ else:
135
+ if isinstance(file_path_raw, str) and file_path_raw.strip():
136
+ content_format = ContentFormat.FILE_PATH
137
+ elif isinstance(content, (bytes, bytearray)):
138
+ content_format = ContentFormat.BINARY
139
+ elif isinstance(content, str):
140
+ content_format = ContentFormat.TEXT
141
+ else:
142
+ content_format = ContentFormat.AUTO
143
+
144
+ metadata_raw = data.get("metadata")
145
+ metadata = dict(metadata_raw) if isinstance(metadata_raw, dict) else {}
146
+
147
+ file_path = str(file_path_raw).strip() if isinstance(file_path_raw, str) and file_path_raw.strip() else None
148
+
149
+ return cls(
150
+ media_type=media_type,
151
+ content=content,
152
+ content_format=content_format,
153
+ mime_type=mime_type,
154
+ file_path=file_path,
155
+ metadata=metadata,
156
+ )
157
+
66
158
 
67
159
  class MultimodalMessage(BaseModel):
68
160
  """
@@ -78,8 +170,9 @@ class MultimodalMessage(BaseModel):
78
170
  )
79
171
  metadata: Dict[str, Any] = Field(default_factory=dict)
80
172
 
81
- @validator('role')
82
- def validate_role(cls, v):
173
+ @field_validator("role")
174
+ @classmethod
175
+ def validate_role(cls, v: str) -> str:
83
176
  valid_roles = {'user', 'assistant', 'system', 'tool'}
84
177
  if v not in valid_roles:
85
178
  raise ValueError(f"Role must be one of {valid_roles}")
@@ -454,4 +547,4 @@ def create_media_content(
454
547
  'file_name': path.name,
455
548
  'file_extension': path.suffix
456
549
  }
457
- )
550
+ )
@@ -5,11 +5,19 @@ Provides intelligent image scaling based on model-specific requirements
5
5
  and capabilities for vision models.
6
6
  """
7
7
 
8
+ from __future__ import annotations # PEP 563 - avoid hard PIL dependency at import time
9
+
8
10
  from typing import Tuple, Optional, Union, Dict, Any
9
11
  from enum import Enum
10
12
  from pathlib import Path
11
13
 
12
- from PIL import Image, ImageOps
14
+ try:
15
+ from PIL import Image, ImageOps
16
+ PIL_AVAILABLE = True
17
+ except ImportError: # pragma: no cover
18
+ Image = None
19
+ ImageOps = None
20
+ PIL_AVAILABLE = False
13
21
 
14
22
  from ..base import MediaProcessingError
15
23
  from ...utils.structured_logging import get_logger
@@ -132,6 +140,11 @@ class ModelOptimizedScaler:
132
140
  Returns:
133
141
  Scaled PIL Image
134
142
  """
143
+ if not PIL_AVAILABLE:
144
+ raise MediaProcessingError(
145
+ "PIL/Pillow is required for image scaling. "
146
+ "Install with: pip install \"abstractcore[media]\""
147
+ )
135
148
  target_width, target_height = target_size
136
149
 
137
150
  if mode == ScalingMode.FIT:
@@ -278,6 +291,11 @@ def scale_image_for_model(image: Union[Image.Image, str, Path],
278
291
  Returns:
279
292
  Optimally scaled PIL Image
280
293
  """
294
+ if not PIL_AVAILABLE:
295
+ raise MediaProcessingError(
296
+ "PIL/Pillow is required for image scaling. "
297
+ "Install with: pip install \"abstractcore[media]\""
298
+ )
281
299
  if isinstance(image, (str, Path)):
282
300
  image = Image.open(image)
283
301
 
@@ -296,4 +314,4 @@ def get_optimal_size_for_model(model_name: str, original_size: Tuple[int, int])
296
314
  Optimal target size (width, height)
297
315
  """
298
316
  scaler = get_scaler()
299
- return scaler.get_optimal_resolution(model_name, original_size)
317
+ return scaler.get_optimal_resolution(model_name, original_size)
@@ -0,0 +1,219 @@
1
+ """
2
+ Video frame extraction utilities (v0).
3
+
4
+ This module provides a small, dependency-light wrapper around ffmpeg/ffprobe
5
+ to sample a bounded number of frames from a video for downstream analysis.
6
+
7
+ Design goals:
8
+ - deterministic sampling (timestamp-based)
9
+ - bounded output (max_frames)
10
+ - actionable errors when ffmpeg/ffprobe are unavailable
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
18
+ from pathlib import Path
19
+ from typing import List, Optional, Tuple
20
+
21
+
22
+ class VideoToolUnavailableError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def _which(cmd: str) -> Optional[str]:
27
+ try:
28
+ return shutil.which(cmd)
29
+ except Exception:
30
+ return None
31
+
32
+
33
+ def probe_duration_s(video_path: Path) -> Optional[float]:
34
+ """Return best-effort duration (seconds) using ffprobe, or None."""
35
+ ffprobe = _which("ffprobe")
36
+ if not ffprobe:
37
+ return None
38
+
39
+ try:
40
+ out = subprocess.check_output(
41
+ [
42
+ ffprobe,
43
+ "-v",
44
+ "error",
45
+ "-show_entries",
46
+ "format=duration",
47
+ "-of",
48
+ "default=nk=1:nw=1",
49
+ str(video_path),
50
+ ],
51
+ text=True,
52
+ stderr=subprocess.STDOUT,
53
+ ).strip()
54
+ if not out:
55
+ return None
56
+ return float(out)
57
+ except Exception:
58
+ return None
59
+
60
+
61
+ def probe_keyframe_timestamps_s(video_path: Path) -> List[float]:
62
+ """Return keyframe timestamps (seconds) using ffprobe when available."""
63
+ ffprobe = _which("ffprobe")
64
+ if not ffprobe:
65
+ return []
66
+
67
+ try:
68
+ out = subprocess.check_output(
69
+ [
70
+ ffprobe,
71
+ "-v",
72
+ "error",
73
+ "-skip_frame",
74
+ "nokey",
75
+ "-select_streams",
76
+ "v:0",
77
+ "-show_entries",
78
+ "frame=pkt_pts_time",
79
+ "-of",
80
+ "csv=p=0",
81
+ str(video_path),
82
+ ],
83
+ text=True,
84
+ stderr=subprocess.STDOUT,
85
+ )
86
+ except Exception:
87
+ return []
88
+
89
+ timestamps: List[float] = []
90
+ for line in (out or "").splitlines():
91
+ s = line.strip()
92
+ if not s:
93
+ continue
94
+ try:
95
+ t = float(s)
96
+ except Exception:
97
+ continue
98
+ if t < 0:
99
+ continue
100
+ timestamps.append(t)
101
+
102
+ # Deduplicate while preserving order (ffprobe can sometimes repeat values).
103
+ seen = set()
104
+ uniq: List[float] = []
105
+ for t in timestamps:
106
+ if t in seen:
107
+ continue
108
+ seen.add(t)
109
+ uniq.append(t)
110
+ return uniq
111
+
112
+
113
+ def _build_timestamps(duration_s: Optional[float], max_frames: int) -> List[float]:
114
+ n = max(1, int(max_frames))
115
+ if duration_s is None or duration_s <= 0:
116
+ return [0.0]
117
+ # Sample away from the extreme endpoints to avoid decode edge-cases.
118
+ return [duration_s * (i + 1) / (n + 1) for i in range(n)]
119
+
120
+
121
+ def _pick_evenly_spaced(values: List[float], k: int) -> List[float]:
122
+ if not values:
123
+ return []
124
+ n = len(values)
125
+ k = max(1, int(k))
126
+ if n <= k:
127
+ return list(values)
128
+ # Evenly spaced indices (include ends).
129
+ idxs = [round(i * (n - 1) / (k - 1)) for i in range(k)] if k > 1 else [round((n - 1) / 2)]
130
+ out: List[float] = []
131
+ last = None
132
+ for i in idxs:
133
+ i = max(0, min(n - 1, int(i)))
134
+ v = values[i]
135
+ if last is not None and v == last:
136
+ continue
137
+ out.append(v)
138
+ last = v
139
+ return out
140
+
141
+
142
+ def extract_video_frames(
143
+ video_path: Path,
144
+ *,
145
+ max_frames: int = 3,
146
+ frame_format: str = "jpg",
147
+ sampling_strategy: str = "uniform",
148
+ max_side: Optional[int] = None,
149
+ output_dir: Optional[Path] = None,
150
+ ) -> Tuple[List[Path], List[float]]:
151
+ """
152
+ Extract up to max_frames as image files and return (frame_paths, timestamps_s).
153
+
154
+ Uses ffmpeg for extraction and ffprobe for duration (best-effort).
155
+ """
156
+ ffmpeg = _which("ffmpeg")
157
+ if not ffmpeg:
158
+ raise VideoToolUnavailableError("ffmpeg is required for video frame extraction. Install ffmpeg and ensure it is on PATH.")
159
+
160
+ if not isinstance(video_path, Path):
161
+ video_path = Path(video_path)
162
+ if not video_path.exists():
163
+ raise FileNotFoundError(str(video_path))
164
+
165
+ fmt = str(frame_format or "jpg").strip().lower()
166
+ if fmt == "jpeg":
167
+ fmt = "jpg"
168
+ if fmt not in {"jpg", "png"}:
169
+ fmt = "jpg"
170
+
171
+ out_dir = Path(output_dir) if output_dir is not None else Path(tempfile.mkdtemp(prefix="abstractcore_video_frames_"))
172
+ out_dir.mkdir(parents=True, exist_ok=True)
173
+
174
+ duration_s = probe_duration_s(video_path)
175
+ strategy = str(sampling_strategy or "uniform").strip().lower()
176
+ if strategy == "keyframes":
177
+ keyframes = probe_keyframe_timestamps_s(video_path)
178
+ timestamps = _pick_evenly_spaced(keyframes, int(max_frames)) if keyframes else _build_timestamps(duration_s, max_frames=max_frames)
179
+ else:
180
+ timestamps = _build_timestamps(duration_s, max_frames=max_frames)
181
+
182
+ frames: List[Path] = []
183
+ for idx, ts in enumerate(timestamps):
184
+ out_path = out_dir / f"frame_{idx+1:02d}.{fmt}"
185
+ cmd = [
186
+ ffmpeg,
187
+ "-hide_banner",
188
+ "-loglevel",
189
+ "error",
190
+ "-ss",
191
+ f"{ts:.3f}",
192
+ "-i",
193
+ str(video_path),
194
+ "-frames:v",
195
+ "1",
196
+ ]
197
+ if isinstance(max_side, int) and max_side > 0:
198
+ ms = int(max_side)
199
+ # Preserve aspect ratio, never upscale.
200
+ # If width >= height: clamp width to ms, derive height. Else clamp height.
201
+ vf = (
202
+ f"scale="
203
+ f"if(gt(iw\\,ih)\\,min(iw\\,{ms})\\,-2):"
204
+ f"if(gt(iw\\,ih)\\,-2\\,min(ih\\,{ms}))"
205
+ )
206
+ cmd.extend(["-vf", vf])
207
+ if fmt == "jpg":
208
+ cmd.extend(["-q:v", "2"])
209
+ cmd.append(str(out_path))
210
+
211
+ try:
212
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
213
+ except subprocess.CalledProcessError:
214
+ continue
215
+
216
+ if out_path.exists() and out_path.stat().st_size > 0:
217
+ frames.append(out_path)
218
+
219
+ return frames, timestamps