aiecs 1.7.6__py3-none-any.whl → 1.8.4__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.

Potentially problematic release.


This version of aiecs might be problematic. Click here for more details.

Files changed (35) hide show
  1. aiecs/__init__.py +1 -1
  2. aiecs/application/knowledge_graph/extractors/llm_entity_extractor.py +5 -1
  3. aiecs/application/knowledge_graph/retrieval/query_intent_classifier.py +7 -5
  4. aiecs/config/config.py +3 -0
  5. aiecs/config/tool_config.py +55 -19
  6. aiecs/domain/agent/base_agent.py +79 -0
  7. aiecs/domain/agent/hybrid_agent.py +552 -175
  8. aiecs/domain/agent/knowledge_aware_agent.py +3 -2
  9. aiecs/domain/agent/llm_agent.py +2 -0
  10. aiecs/domain/agent/models.py +10 -0
  11. aiecs/domain/agent/tools/schema_generator.py +17 -4
  12. aiecs/llm/callbacks/custom_callbacks.py +9 -4
  13. aiecs/llm/client_factory.py +20 -7
  14. aiecs/llm/clients/base_client.py +50 -5
  15. aiecs/llm/clients/google_function_calling_mixin.py +46 -88
  16. aiecs/llm/clients/googleai_client.py +183 -9
  17. aiecs/llm/clients/openai_client.py +12 -0
  18. aiecs/llm/clients/openai_compatible_mixin.py +42 -2
  19. aiecs/llm/clients/openrouter_client.py +272 -0
  20. aiecs/llm/clients/vertex_client.py +385 -22
  21. aiecs/llm/clients/xai_client.py +41 -3
  22. aiecs/llm/protocols.py +19 -1
  23. aiecs/llm/utils/image_utils.py +179 -0
  24. aiecs/main.py +2 -2
  25. aiecs/tools/docs/document_creator_tool.py +143 -2
  26. aiecs/tools/docs/document_parser_tool.py +9 -4
  27. aiecs/tools/docs/document_writer_tool.py +179 -0
  28. aiecs/tools/task_tools/image_tool.py +49 -14
  29. aiecs/tools/task_tools/scraper_tool.py +39 -2
  30. {aiecs-1.7.6.dist-info → aiecs-1.8.4.dist-info}/METADATA +4 -2
  31. {aiecs-1.7.6.dist-info → aiecs-1.8.4.dist-info}/RECORD +35 -33
  32. {aiecs-1.7.6.dist-info → aiecs-1.8.4.dist-info}/WHEEL +0 -0
  33. {aiecs-1.7.6.dist-info → aiecs-1.8.4.dist-info}/entry_points.txt +0 -0
  34. {aiecs-1.7.6.dist-info → aiecs-1.8.4.dist-info}/licenses/LICENSE +0 -0
  35. {aiecs-1.7.6.dist-info → aiecs-1.8.4.dist-info}/top_level.txt +0 -0
aiecs/llm/protocols.py CHANGED
@@ -4,7 +4,7 @@ LLM Client Protocols
4
4
  Defines Protocol interfaces for LLM clients to enable duck typing and flexible integration.
5
5
  """
6
6
 
7
- from typing import Protocol, List, Optional, AsyncGenerator, runtime_checkable
7
+ from typing import Protocol, List, Optional, AsyncGenerator, runtime_checkable, Dict, Any
8
8
  from aiecs.llm.clients.base_client import LLMMessage, LLMResponse
9
9
 
10
10
 
@@ -31,9 +31,12 @@ class LLMClientProtocol(Protocol):
31
31
  model: Optional[str] = None,
32
32
  temperature: float = 0.7,
33
33
  max_tokens: Optional[int] = None,
34
+ context: Optional[Dict[str, Any]] = None,
34
35
  **kwargs
35
36
  ) -> LLMResponse:
36
37
  # Custom implementation
38
+ # Use context for tracking, billing, observability, etc.
39
+ user_id = context.get("user_id") if context else None
37
40
  pass
38
41
 
39
42
  async def stream_text(
@@ -42,6 +45,7 @@ class LLMClientProtocol(Protocol):
42
45
  model: Optional[str] = None,
43
46
  temperature: float = 0.7,
44
47
  max_tokens: Optional[int] = None,
48
+ context: Optional[Dict[str, Any]] = None,
45
49
  **kwargs
46
50
  ) -> AsyncGenerator[str, None]:
47
51
  # Custom implementation
@@ -67,6 +71,7 @@ class LLMClientProtocol(Protocol):
67
71
  model: Optional[str] = None,
68
72
  temperature: float = 0.7,
69
73
  max_tokens: Optional[int] = None,
74
+ context: Optional[Dict[str, Any]] = None,
70
75
  **kwargs,
71
76
  ) -> LLMResponse:
72
77
  """
@@ -77,6 +82,12 @@ class LLMClientProtocol(Protocol):
77
82
  model: Model name (optional, uses default if not provided)
78
83
  temperature: Sampling temperature (0.0 to 1.0)
79
84
  max_tokens: Maximum tokens to generate
85
+ context: Optional context dictionary containing metadata such as:
86
+ - user_id: User identifier for tracking/billing
87
+ - tenant_id: Tenant identifier for multi-tenant setups
88
+ - request_id: Request identifier for tracing
89
+ - session_id: Session identifier
90
+ - Any other custom metadata for observability or middleware
80
91
  **kwargs: Additional provider-specific parameters
81
92
 
82
93
  Returns:
@@ -90,6 +101,7 @@ class LLMClientProtocol(Protocol):
90
101
  model: Optional[str] = None,
91
102
  temperature: float = 0.7,
92
103
  max_tokens: Optional[int] = None,
104
+ context: Optional[Dict[str, Any]] = None,
93
105
  **kwargs,
94
106
  ) -> AsyncGenerator[str, None]:
95
107
  """
@@ -100,6 +112,12 @@ class LLMClientProtocol(Protocol):
100
112
  model: Model name (optional, uses default if not provided)
101
113
  temperature: Sampling temperature (0.0 to 1.0)
102
114
  max_tokens: Maximum tokens to generate
115
+ context: Optional context dictionary containing metadata such as:
116
+ - user_id: User identifier for tracking/billing
117
+ - tenant_id: Tenant identifier for multi-tenant setups
118
+ - request_id: Request identifier for tracing
119
+ - session_id: Session identifier
120
+ - Any other custom metadata for observability or middleware
103
121
  **kwargs: Additional provider-specific parameters
104
122
 
105
123
  Yields:
@@ -0,0 +1,179 @@
1
+ """
2
+ Image processing utilities for LLM vision support.
3
+
4
+ This module provides functions to handle images in various formats:
5
+ - Image URLs
6
+ - Base64-encoded images
7
+ - Local file paths
8
+ """
9
+
10
+ import os
11
+ import base64
12
+ import mimetypes
13
+ from typing import Union, Optional, Dict, Any
14
+ from urllib.parse import urlparse
15
+ import logging
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ImageContent:
21
+ """Represents image content for LLM messages."""
22
+
23
+ def __init__(
24
+ self,
25
+ source: str,
26
+ mime_type: Optional[str] = None,
27
+ detail: Optional[str] = None,
28
+ ):
29
+ """
30
+ Initialize image content.
31
+
32
+ Args:
33
+ source: Image source - can be URL, base64 data URI, or file path
34
+ mime_type: MIME type (e.g., 'image/jpeg', 'image/png'). Auto-detected if not provided.
35
+ detail: Detail level for OpenAI API ('low', 'high', 'auto'). Defaults to 'auto'.
36
+ """
37
+ self.source = source
38
+ self.mime_type = mime_type or self._detect_mime_type(source)
39
+ self.detail = detail or "auto"
40
+
41
+ def _detect_mime_type(self, source: str) -> str:
42
+ """Detect MIME type from source."""
43
+ # Check if it's a data URI
44
+ if source.startswith("data:"):
45
+ header = source.split(",")[0]
46
+ mime_type = header.split(":")[1].split(";")[0]
47
+ return mime_type
48
+
49
+ # Check if it's a URL
50
+ if source.startswith(("http://", "https://")):
51
+ parsed = urlparse(source)
52
+ mime_type, _ = mimetypes.guess_type(parsed.path)
53
+ if mime_type and mime_type.startswith("image/"):
54
+ return mime_type
55
+
56
+ # Check if it's a file path
57
+ if os.path.exists(source):
58
+ mime_type, _ = mimetypes.guess_type(source)
59
+ if mime_type and mime_type.startswith("image/"):
60
+ return mime_type
61
+
62
+ # Default to jpeg if cannot detect
63
+ logger.warning(f"Could not detect MIME type for {source}, defaulting to image/jpeg")
64
+ return "image/jpeg"
65
+
66
+ def is_url(self) -> bool:
67
+ """Check if source is a URL."""
68
+ return self.source.startswith(("http://", "https://"))
69
+
70
+ def is_base64(self) -> bool:
71
+ """Check if source is a base64 data URI."""
72
+ return self.source.startswith("data:")
73
+
74
+ def is_file_path(self) -> bool:
75
+ """Check if source is a local file path."""
76
+ return os.path.exists(self.source) and not self.is_url() and not self.is_base64()
77
+
78
+ def get_base64_data(self) -> str:
79
+ """
80
+ Get base64-encoded image data.
81
+
82
+ Returns:
83
+ Base64 string without data URI prefix
84
+ """
85
+ if self.is_base64():
86
+ # Extract base64 data from data URI
87
+ return self.source.split(",", 1)[1]
88
+ elif self.is_file_path():
89
+ # Read file and encode to base64
90
+ with open(self.source, "rb") as f:
91
+ return base64.b64encode(f.read()).decode("utf-8")
92
+ else:
93
+ raise ValueError(f"Cannot get base64 data from URL: {self.source}. Use URL directly or download first.")
94
+
95
+ def get_url(self) -> str:
96
+ """
97
+ Get image URL.
98
+
99
+ Returns:
100
+ URL string
101
+ """
102
+ if self.is_url():
103
+ return self.source
104
+ else:
105
+ raise ValueError(f"Source is not a URL: {self.source}")
106
+
107
+
108
+ def parse_image_source(source: Union[str, Dict[str, Any]]) -> ImageContent:
109
+ """
110
+ Parse image source into ImageContent object.
111
+
112
+ Args:
113
+ source: Can be:
114
+ - String URL (http://... or https://...)
115
+ - String base64 data URI (data:image/...;base64,...)
116
+ - String file path
117
+ - Dict with 'url', 'data', or 'path' key
118
+
119
+ Returns:
120
+ ImageContent object
121
+ """
122
+ if isinstance(source, dict):
123
+ # Handle dict format
124
+ if "url" in source:
125
+ return ImageContent(
126
+ source=source["url"],
127
+ mime_type=source.get("mime_type"),
128
+ detail=source.get("detail"),
129
+ )
130
+ elif "data" in source:
131
+ # Base64 data
132
+ mime_type = source.get("mime_type", "image/jpeg")
133
+ data = source["data"]
134
+ if not data.startswith("data:"):
135
+ data = f"data:{mime_type};base64,{data}"
136
+ return ImageContent(
137
+ source=data,
138
+ mime_type=mime_type,
139
+ detail=source.get("detail"),
140
+ )
141
+ elif "path" in source:
142
+ return ImageContent(
143
+ source=source["path"],
144
+ mime_type=source.get("mime_type"),
145
+ detail=source.get("detail"),
146
+ )
147
+ else:
148
+ raise ValueError(f"Invalid image dict format: {source}")
149
+ elif isinstance(source, str):
150
+ return ImageContent(source=source)
151
+ else:
152
+ raise TypeError(f"Image source must be str or dict, got {type(source)}")
153
+
154
+
155
+ def validate_image_source(source: str) -> bool:
156
+ """
157
+ Validate that image source is accessible.
158
+
159
+ Args:
160
+ source: Image source (URL, base64, or file path)
161
+
162
+ Returns:
163
+ True if source is valid and accessible
164
+ """
165
+ try:
166
+ img = ImageContent(source)
167
+ if img.is_file_path():
168
+ return os.path.exists(source) and os.path.isfile(source)
169
+ elif img.is_url():
170
+ # URL validation - just check format, not accessibility
171
+ parsed = urlparse(source)
172
+ return bool(parsed.scheme and parsed.netloc)
173
+ elif img.is_base64():
174
+ # Base64 validation - check format
175
+ parts = source.split(",", 1)
176
+ return len(parts) == 2 and parts[0].startswith("data:image/")
177
+ return False
178
+ except Exception:
179
+ return False
aiecs/main.py CHANGED
@@ -142,7 +142,7 @@ async def lifespan(app: FastAPI):
142
142
  app = FastAPI(
143
143
  title="AIECS - AI Execute Services",
144
144
  description="Middleware service for AI-powered task execution and tool orchestration",
145
- version="1.7.6",
145
+ version="1.8.4",
146
146
  lifespan=lifespan,
147
147
  )
148
148
 
@@ -164,7 +164,7 @@ socket_app = socketio.ASGIApp(sio, other_asgi_app=app)
164
164
  @app.get("/health")
165
165
  async def health_check():
166
166
  """Health check endpoint"""
167
- return {"status": "healthy", "service": "aiecs", "version": "1.7.6"}
167
+ return {"status": "healthy", "service": "aiecs", "version": "1.8.4"}
168
168
 
169
169
 
170
170
  # Metrics health check endpoint
@@ -55,6 +55,8 @@ class DocumentFormat(str, Enum):
55
55
  PLAIN_TEXT = "txt"
56
56
  JSON = "json"
57
57
  XML = "xml"
58
+ PPTX = "pptx"
59
+ PPT = "ppt"
58
60
 
59
61
 
60
62
  class TemplateType(str, Enum):
@@ -175,6 +177,9 @@ class DocumentCreatorTool(BaseTool):
175
177
  # Initialize templates
176
178
  self._init_templates()
177
179
 
180
+ # Initialize office tool for PPTX/DOCX creation
181
+ self._init_office_tool()
182
+
178
183
  # Initialize document tracking
179
184
  self._documents_created: List[Any] = []
180
185
 
@@ -197,6 +202,17 @@ class DocumentCreatorTool(BaseTool):
197
202
  TemplateType.INVOICE: self._get_invoice_template(),
198
203
  }
199
204
 
205
+ def _init_office_tool(self):
206
+ """Initialize office tool for PPTX/DOCX creation"""
207
+ try:
208
+ from aiecs.tools.task_tools.office_tool import OfficeTool
209
+
210
+ self.office_tool = OfficeTool()
211
+ self.logger.info("OfficeTool initialized successfully for PPTX/DOCX support")
212
+ except ImportError:
213
+ self.logger.warning("OfficeTool not available, PPTX/DOCX creation will be limited")
214
+ self.office_tool = None
215
+
200
216
  # Schema definitions
201
217
  class Create_documentSchema(BaseModel):
202
218
  """Schema for create_document operation"""
@@ -943,7 +959,7 @@ class DocumentCreatorTool(BaseTool):
943
959
  "questions",
944
960
  "contact_info",
945
961
  ],
946
- "supported_formats": ["markdown", "html"],
962
+ "supported_formats": ["markdown", "html", "pptx"],
947
963
  "style_presets": ["presentation", "modern", "colorful"],
948
964
  }
949
965
 
@@ -1062,7 +1078,11 @@ class DocumentCreatorTool(BaseTool):
1062
1078
  ) -> str:
1063
1079
  """Generate output path for document"""
1064
1080
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1065
- filename = f"{document_type}_{timestamp}_{document_id[:8]}.{output_format.value}"
1081
+ # Handle PPT format - use pptx extension
1082
+ file_extension = output_format.value
1083
+ if output_format == DocumentFormat.PPT:
1084
+ file_extension = "pptx" # PPT format uses PPTX extension
1085
+ filename = f"{document_type}_{timestamp}_{document_id[:8]}.{file_extension}"
1066
1086
  return os.path.join(self.config.output_dir, filename)
1067
1087
 
1068
1088
  def _process_metadata(self, metadata: Dict[str, Any], output_format: DocumentFormat) -> Dict[str, Any]:
@@ -1175,11 +1195,130 @@ class DocumentCreatorTool(BaseTool):
1175
1195
  elif output_format == DocumentFormat.JSON:
1176
1196
  with open(output_path, "w", encoding="utf-8") as f:
1177
1197
  json.dump({"content": content}, f, indent=2, ensure_ascii=False)
1198
+ elif output_format in [DocumentFormat.PPTX, DocumentFormat.PPT]:
1199
+ # Use office_tool to create PPTX file
1200
+ self._write_pptx_file(output_path, content)
1201
+ elif output_format == DocumentFormat.DOCX:
1202
+ # Use office_tool to create DOCX file
1203
+ self._write_docx_file(output_path, content)
1178
1204
  else:
1179
1205
  # For other formats, write as text for now
1180
1206
  with open(output_path, "w", encoding="utf-8") as f:
1181
1207
  f.write(content)
1182
1208
 
1209
+ def _write_pptx_file(self, output_path: str, content: str):
1210
+ """Write content to PPTX file using office_tool"""
1211
+ if not self.office_tool:
1212
+ raise DocumentCreationError("OfficeTool not available. Cannot create PPTX files.")
1213
+
1214
+ try:
1215
+ # Parse content to extract slides
1216
+ # Slides are separated by "---" or slide markers like "## Slide X:"
1217
+ slides = self._parse_content_to_slides(content)
1218
+
1219
+ # Use office_tool to create PPTX
1220
+ result = self.office_tool.write_pptx(
1221
+ slides=slides,
1222
+ output_path=output_path,
1223
+ image_path=None, # Can be enhanced to extract image paths from metadata
1224
+ )
1225
+
1226
+ if not result.get("success"):
1227
+ raise DocumentCreationError(f"Failed to create PPTX file: {result}")
1228
+
1229
+ self.logger.info(f"PPTX file created successfully: {output_path}")
1230
+
1231
+ except Exception as e:
1232
+ raise DocumentCreationError(f"Failed to write PPTX file: {str(e)}")
1233
+
1234
+ def _write_docx_file(self, output_path: str, content: str):
1235
+ """Write content to DOCX file using office_tool"""
1236
+ if not self.office_tool:
1237
+ raise DocumentCreationError("OfficeTool not available. Cannot create DOCX files.")
1238
+
1239
+ try:
1240
+ # Use office_tool to create DOCX
1241
+ result = self.office_tool.write_docx(
1242
+ text=content,
1243
+ output_path=output_path,
1244
+ table_data=None, # Can be enhanced to extract tables from content
1245
+ )
1246
+
1247
+ if not result.get("success"):
1248
+ raise DocumentCreationError(f"Failed to create DOCX file: {result}")
1249
+
1250
+ self.logger.info(f"DOCX file created successfully: {output_path}")
1251
+
1252
+ except Exception as e:
1253
+ raise DocumentCreationError(f"Failed to write DOCX file: {str(e)}")
1254
+
1255
+ def _parse_content_to_slides(self, content: str) -> List[str]:
1256
+ """Parse content string into list of slide contents
1257
+
1258
+ Supports multiple slide separation formats:
1259
+ - "---" separator (markdown style)
1260
+ - "## Slide X:" headers
1261
+ - Empty lines between slides
1262
+ """
1263
+ slides = []
1264
+
1265
+ # Split by "---" separator (common in markdown presentations)
1266
+ if "---" in content:
1267
+ parts = content.split("---")
1268
+ for part in parts:
1269
+ part = part.strip()
1270
+ if part:
1271
+ # Remove slide headers like "## Slide X: Title"
1272
+ lines = part.split("\n")
1273
+ cleaned_lines = []
1274
+ for line in lines:
1275
+ # Skip slide headers
1276
+ if line.strip().startswith("## Slide") and ":" in line:
1277
+ continue
1278
+ cleaned_lines.append(line)
1279
+ slide_content = "\n".join(cleaned_lines).strip()
1280
+ if slide_content:
1281
+ slides.append(slide_content)
1282
+ else:
1283
+ # Try to split by "## Slide" headers
1284
+ if "## Slide" in content:
1285
+ parts = content.split("## Slide")
1286
+ for i, part in enumerate(parts):
1287
+ if i == 0:
1288
+ # First part might be title slide
1289
+ part = part.strip()
1290
+ if part:
1291
+ slides.append(part)
1292
+ else:
1293
+ # Extract content after "Slide X: Title"
1294
+ lines = part.split("\n", 1)
1295
+ if len(lines) > 1:
1296
+ slide_content = lines[1].strip()
1297
+ if slide_content:
1298
+ slides.append(slide_content)
1299
+ else:
1300
+ # Fallback: split by double newlines (paragraph breaks)
1301
+ parts = content.split("\n\n")
1302
+ current_slide = []
1303
+ for part in parts:
1304
+ part = part.strip()
1305
+ if part:
1306
+ # If it's a header, start a new slide
1307
+ if part.startswith("#"):
1308
+ if current_slide:
1309
+ slides.append("\n".join(current_slide))
1310
+ current_slide = []
1311
+ current_slide.append(part)
1312
+
1313
+ if current_slide:
1314
+ slides.append("\n".join(current_slide))
1315
+
1316
+ # If no slides found, create a single slide with all content
1317
+ if not slides:
1318
+ slides = [content.strip()] if content.strip() else [""]
1319
+
1320
+ return slides
1321
+
1183
1322
  def _process_template_variables(self, template_content: str, variables: Dict[str, Any]) -> str:
1184
1323
  """Process template variables in content"""
1185
1324
  result = template_content
@@ -1282,6 +1421,8 @@ class DocumentCreatorTool(BaseTool):
1282
1421
  ".tex": DocumentFormat.LATEX,
1283
1422
  ".docx": DocumentFormat.DOCX,
1284
1423
  ".pdf": DocumentFormat.PDF,
1424
+ ".pptx": DocumentFormat.PPTX,
1425
+ ".ppt": DocumentFormat.PPT,
1285
1426
  }
1286
1427
  return format_map.get(ext, DocumentFormat.PLAIN_TEXT)
1287
1428
 
@@ -798,13 +798,18 @@ class DocumentParserTool(BaseTool):
798
798
  raise UnsupportedDocumentError("ImageTool not available for image OCR")
799
799
 
800
800
  try:
801
- # Use image tool for OCR
802
- ocr_result = self.image_tool.ocr_image(file_path)
801
+ # Use image tool for OCR - the ocr method returns a string directly
802
+ ocr_text = self.image_tool.ocr(file_path=file_path)
803
803
 
804
804
  if strategy == ParsingStrategy.TEXT_ONLY:
805
- return ocr_result.get("text", "")
805
+ return ocr_text
806
806
  else:
807
- return ocr_result
807
+ # Return structured result for other strategies
808
+ return {
809
+ "text": ocr_text,
810
+ "file_path": file_path,
811
+ "document_type": DocumentType.IMAGE,
812
+ }
808
813
 
809
814
  except Exception as e:
810
815
  raise ParseError(f"Failed to parse image document: {str(e)}")