diff --git a/env_dev.env b/env_dev.env index d33c5598..7dfa75e4 100644 --- a/env_dev.env +++ b/env_dev.env @@ -39,7 +39,6 @@ Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/auth/callback STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09 STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGcHNWTWpBWkFHRExtdU01N3RyZzNsMjhUS3NiVTNCZmMwN2NEcFZ6UkQ1a2I0aUkyNU4wR2dUdHJXYmtkaEFRUnFpcThObHBEQmJkdEFnT1FXeUxOTlU3UDFNRzl6LWdpRFpYdExvY3FTTG9MTkswdEhrVkNKQVFucnBjSnhLNm4= STRIPE_API_VERSION = 2026-01-28.clover -APP_FRONTEND_URL = http://localhost:5176 # AI configuration Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9 diff --git a/env_int.env b/env_int.env index 95e28430..783c7461 100644 --- a/env_int.env +++ b/env_int.env @@ -36,10 +36,9 @@ Service_MSFT_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth Service_GOOGLE_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/callback # Stripe Billing (both end with _SECRET for encryption script) -STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnBudkpGOTVvaGhuSTVRTW5Fa0x3akFwZktQX21DZnFGQWgteDJwUWpnbTV5enRmbmZyWXlpY2lKVVNINkFManhNMnFQZ1VnOUxSQ0FTZFBVNmdhSGhwWFBHaDNnSzZXVnIxRmNUcnBQN0c3R19Xb2g1QnBxVXpiSXRRTk5NOGtzcU5HcUNiWDNvdmhYbGFkWkRCR25iVEJKTmwzcGRBZjNjaVNiWDJDaWlhLWpfdkdXYlQyUWk2NndKYW5lYXBaTkRzMWZsZjlFb3JOX1NzbkM4NWFyQU9MajZlZz09 +STRIPE_SECRET_KEY_SECRET = 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 STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnBudkpGamJBNW91VUdEaThWRTFiTWpyb3NqSDJJcGtjNkhUVVZqVElxUWExY05KcllSYVk1SkRuS1NjYWpZUk1uU29nb2pzdXUxRzBsOEgyRWtmUEw3dUF4ejFIXzNwTVZRM1R1bVVhTUs4ZHJMT0V4Xy1pcHVfWlBaQV9wVXo5MGlQYXA= STRIPE_API_VERSION = 2026-01-28.clover -APP_FRONTEND_URL = https://nyla-int.poweron-center.net # AI configuration Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9 diff --git a/env_prod.env b/env_prod.env index 5e60702c..e2d1189d 100644 --- a/env_prod.env +++ b/env_prod.env @@ -36,10 +36,9 @@ Service_MSFT_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/aut Service_GOOGLE_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/callback # Stripe Billing (both end with _SECRET for encryption script) -STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNmx3N3Q4QWdlcXVBSlJuYzVJX2hZRW8wbklJYUI0Rzh2YWRPcWY5dC1rMjhfUDRTOE91TlZyLTBEZkY1N015dmg5akEta1d0M0NpNk9oNDZpQTlMUGlLalV6aVowbl9Jc2hKMVlxbE9aaTZNRUxDQ3VGSnJxN040VERUMDFiekhITXdTR0N4aUxwWGxtcHdlU2NtOVNsSlVpOE0xTkRSdGhnN09UWGxuLURUaFdfQWJ4ZEw3R0c0bVRQaTA1NURhVEZudHY4d2gtTzItOF9TcmMwajFmZz09 +STRIPE_SECRET_KEY_SECRET = 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 STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08= STRIPE_API_VERSION = 2026-01-28.clover -APP_FRONTEND_URL = https://nyla.poweron-center.net # AI configuration Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9 diff --git a/modules/aicore/aicoreBase.py b/modules/aicore/aicoreBase.py index 2f998f1d..70dd67c4 100644 --- a/modules/aicore/aicoreBase.py +++ b/modules/aicore/aicoreBase.py @@ -11,11 +11,36 @@ IMPORTANT: Model Registration Requirements - If duplicate displayNames are detected during registration, an error will be raised """ +import re as _re + from abc import ABC, abstractmethod from typing import List, Dict, Any, Optional, AsyncGenerator, Union from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse +_RETRY_AFTER_PATTERN = _re.compile(r"try again in (\d+(?:\.\d+)?)\s*s", _re.IGNORECASE) + + +def _parseRetryAfterSeconds(message: str) -> float: + """Extract retry-after seconds from provider error messages like 'Please try again in 6.558s'.""" + match = _RETRY_AFTER_PATTERN.search(message) + return float(match.group(1)) if match else 0.0 + + +class RateLimitExceededException(Exception): + """Raised when a provider's rate limit (TPM / RPM) is exceeded.""" + def __init__(self, message: str = "Rate limit exceeded", retryAfterSeconds: float = 0.0): + super().__init__(message) + if retryAfterSeconds <= 0: + retryAfterSeconds = _parseRetryAfterSeconds(message) + self.retryAfterSeconds = retryAfterSeconds + + +class ContextLengthExceededException(Exception): + """Raised when the input exceeds a model's context window.""" + pass + + class BaseConnectorAi(ABC): """ Base class for all AI connectors. diff --git a/modules/aicore/aicoreModelSelector.py b/modules/aicore/aicoreModelSelector.py index ea0a4198..d3df1e45 100644 --- a/modules/aicore/aicoreModelSelector.py +++ b/modules/aicore/aicoreModelSelector.py @@ -19,26 +19,29 @@ class ModelSelector: """Model selector with priority scoring and recent-failure cooldown.""" def __init__(self): - self._failureLog: Dict[str, float] = {} + self._failureLog: Dict[str, Tuple[float, float]] = {} logger.info("ModelSelector initialized with failure cooldown support") - def reportFailure(self, modelName: str): + def reportFailure(self, modelName: str, cooldownSeconds: float = 0.0): """Record that a model just failed (rate limit, error, etc.). - The model will be deprioritized for COOLDOWN_DURATION seconds.""" - self._failureLog[modelName] = time.time() - logger.info(f"ModelSelector: Recorded failure for {modelName}, cooldown {_COOLDOWN_DURATION}s") + The model will be deprioritized for *cooldownSeconds* (default: _COOLDOWN_DURATION).""" + if cooldownSeconds <= 0: + cooldownSeconds = _COOLDOWN_DURATION + self._failureLog[modelName] = (time.time(), cooldownSeconds) + logger.info(f"ModelSelector: Recorded failure for {modelName}, cooldown {cooldownSeconds:.1f}s") def _getCooldownPenalty(self, modelName: str) -> float: """Return a score penalty (0.0 = no penalty, large negative = recently failed).""" - failedAt = self._failureLog.get(modelName) - if failedAt is None: + entry = self._failureLog.get(modelName) + if entry is None: return 0.0 + failedAt, cooldown = entry elapsed = time.time() - failedAt - if elapsed > _COOLDOWN_DURATION: + if elapsed > cooldown: del self._failureLog[modelName] return 0.0 - remaining = _COOLDOWN_DURATION - elapsed - return -(remaining / _COOLDOWN_DURATION) * 5000.0 + remaining = cooldown - elapsed + return -(remaining / cooldown) * 5000.0 def selectModel(self, prompt: str, diff --git a/modules/aicore/aicorePluginAnthropic.py b/modules/aicore/aicorePluginAnthropic.py index 85bbfa75..8b6ec197 100644 --- a/modules/aicore/aicorePluginAnthropic.py +++ b/modules/aicore/aicorePluginAnthropic.py @@ -7,7 +7,7 @@ import os from typing import Dict, Any, List, AsyncGenerator, Union from fastapi import HTTPException from modules.shared.configuration import APP_CONFIG -from .aicoreBase import BaseConnectorAi +from .aicoreBase import BaseConnectorAi, RateLimitExceededException from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings # Configure logger @@ -203,11 +203,12 @@ class AiAnthropic(BaseConnectorAi): error_detail = f"Anthropic API error: {response.status_code} - {response.text}" logger.error(error_detail) - # Provide more specific error messages based on status code + if response.status_code == 429: + raise RateLimitExceededException( + f"Rate limit exceeded for {model.name}: {response.text}" + ) if response.status_code == 529: error_message = "Anthropic API is currently overloaded. Please try again in a few minutes." - elif response.status_code == 429: - error_message = "Rate limit exceeded. Please wait before making another request." elif response.status_code == 401: error_message = "Invalid API key. Please check your Anthropic API configuration." elif response.status_code == 400: @@ -255,6 +256,8 @@ class AiAnthropic(BaseConnectorAi): metadata=metadata ) + except (RateLimitExceededException, HTTPException): + raise except Exception as e: error_msg = str(e) if str(e) else f"{type(e).__name__}" error_detail = f"Error calling Anthropic API: {error_msg}" @@ -296,7 +299,12 @@ class AiAnthropic(BaseConnectorAi): async with self.httpClient.stream("POST", model.apiUrl, json=payload) as response: if response.status_code != 200: body = await response.aread() - raise HTTPException(status_code=500, detail=f"Anthropic stream error: {response.status_code} - {body.decode()}") + bodyStr = body.decode() + if response.status_code == 429: + raise RateLimitExceededException( + f"Rate limit exceeded for {model.name}: {bodyStr}" + ) + raise HTTPException(status_code=500, detail=f"Anthropic stream error: {response.status_code} - {bodyStr}") async for line in response.aiter_lines(): if not line.startswith("data: "): @@ -354,7 +362,7 @@ class AiAnthropic(BaseConnectorAi): metadata=metadata, ) - except HTTPException: + except (RateLimitExceededException, HTTPException): raise except Exception as e: logger.error(f"Error streaming Anthropic API: {e}", exc_info=True) diff --git a/modules/aicore/aicorePluginMistral.py b/modules/aicore/aicorePluginMistral.py index a4f0e476..8c4fb6d9 100644 --- a/modules/aicore/aicorePluginMistral.py +++ b/modules/aicore/aicorePluginMistral.py @@ -6,20 +6,11 @@ import httpx from typing import List, Dict, Any, AsyncGenerator, Union from fastapi import HTTPException from modules.shared.configuration import APP_CONFIG -from .aicoreBase import BaseConnectorAi +from .aicoreBase import BaseConnectorAi, RateLimitExceededException, ContextLengthExceededException from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings -# Configure logger logger = logging.getLogger(__name__) -class ContextLengthExceededException(Exception): - """Exception raised when the context length exceeds the model's limit""" - pass - -class RateLimitExceededException(Exception): - """Exception raised when the provider's rate limit (TPM) is exceeded""" - pass - def loadConfigData(): """Load configuration data for Mistral connector""" return { @@ -264,7 +255,14 @@ class AiMistral(BaseConnectorAi): async with self.httpClient.stream("POST", model.apiUrl, json=payload) as response: if response.status_code != 200: body = await response.aread() - raise HTTPException(status_code=500, detail=f"Mistral stream error: {response.status_code} - {body.decode()}") + bodyStr = body.decode() + if response.status_code == 429: + try: + errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded") + except (ValueError, KeyError): + errorMsg = f"Rate limit exceeded for {model.name}" + raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}") + raise HTTPException(status_code=500, detail=f"Mistral stream error: {response.status_code} - {bodyStr}") async for line in response.aiter_lines(): if not line.startswith("data: "): @@ -289,7 +287,7 @@ class AiMistral(BaseConnectorAi): metadata={}, ) - except HTTPException: + except (RateLimitExceededException, ContextLengthExceededException, HTTPException): raise except Exception as e: logger.error(f"Error streaming Mistral API: {e}") @@ -317,6 +315,17 @@ class AiMistral(BaseConnectorAi): logger.error(errorMessage) if response.status_code == 429: raise RateLimitExceededException(f"Rate limit exceeded for {model.name}") + if response.status_code == 400: + try: + errorData = response.json() + errMsg = errorData.get("error", {}).get("message", "").lower() + errCode = errorData.get("error", {}).get("code", "") + if errCode == "context_length_exceeded" or "too many tokens" in errMsg or "maximum context length" in errMsg: + raise ContextLengthExceededException( + f"Embedding context length exceeded for {model.name}: {errorData.get('error', {}).get('message', '')}" + ) + except (ValueError, KeyError): + pass raise HTTPException(status_code=500, detail=errorMessage) responseJson = response.json() @@ -334,7 +343,7 @@ class AiMistral(BaseConnectorAi): }, metadata={"embeddings": embeddings}, ) - except RateLimitExceededException: + except (RateLimitExceededException, ContextLengthExceededException): raise except Exception as e: logger.error(f"Error calling Mistral Embedding API: {str(e)}") diff --git a/modules/aicore/aicorePluginOpenai.py b/modules/aicore/aicorePluginOpenai.py index 366f7dde..3b9f2c5f 100644 --- a/modules/aicore/aicorePluginOpenai.py +++ b/modules/aicore/aicorePluginOpenai.py @@ -6,20 +6,11 @@ import httpx from typing import List, Dict, Any, AsyncGenerator, Union from fastapi import HTTPException from modules.shared.configuration import APP_CONFIG -from .aicoreBase import BaseConnectorAi +from .aicoreBase import BaseConnectorAi, RateLimitExceededException, ContextLengthExceededException from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings, AiCallPromptImage -# Configure logger logger = logging.getLogger(__name__) -class ContextLengthExceededException(Exception): - """Exception raised when the context length exceeds the model's limit""" - pass - -class RateLimitExceededException(Exception): - """Exception raised when the provider's rate limit (TPM) is exceeded""" - pass - def loadConfigData(): """Load configuration data for OpenAI connector""" return { @@ -316,7 +307,14 @@ class AiOpenai(BaseConnectorAi): async with self.httpClient.stream("POST", model.apiUrl, json=payload) as response: if response.status_code != 200: body = await response.aread() - raise HTTPException(status_code=500, detail=f"OpenAI stream error: {response.status_code} - {body.decode()}") + bodyStr = body.decode() + if response.status_code == 429: + try: + errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded") + except (ValueError, KeyError): + errorMsg = f"Rate limit exceeded for {model.name}" + raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}") + raise HTTPException(status_code=500, detail=f"OpenAI stream error: {response.status_code} - {bodyStr}") async for line in response.aiter_lines(): if not line.startswith("data: "): @@ -362,7 +360,7 @@ class AiOpenai(BaseConnectorAi): metadata=metadata, ) - except HTTPException: + except (RateLimitExceededException, ContextLengthExceededException, HTTPException): raise except Exception as e: logger.error(f"Error streaming OpenAI API: {e}") @@ -390,6 +388,17 @@ class AiOpenai(BaseConnectorAi): logger.error(errorMessage) if response.status_code == 429: raise RateLimitExceededException(f"Rate limit exceeded for {model.name}") + if response.status_code == 400: + try: + errorData = response.json() + errMsg = errorData.get("error", {}).get("message", "").lower() + errCode = errorData.get("error", {}).get("code", "") + if errCode == "context_length_exceeded" or "too many tokens" in errMsg or "maximum context length" in errMsg: + raise ContextLengthExceededException( + f"Embedding context length exceeded for {model.name}: {errorData.get('error', {}).get('message', '')}" + ) + except (ValueError, KeyError): + pass raise HTTPException(status_code=500, detail=errorMessage) responseJson = response.json() diff --git a/modules/connectors/connectorProviderBase.py b/modules/connectors/connectorProviderBase.py index 71ad0ecf..107fe1c4 100644 --- a/modules/connectors/connectorProviderBase.py +++ b/modules/connectors/connectorProviderBase.py @@ -8,7 +8,16 @@ All ServiceAdapters share the same access token from the UserConnection. """ from abc import ABC, abstractmethod -from typing import List, Optional +from dataclasses import dataclass, field +from typing import List, Optional, Union + + +@dataclass +class DownloadResult: + """Rich return type for ServiceAdapter.download() when metadata is available.""" + data: bytes = field(default=b"", repr=False) + fileName: str = "" + mimeType: str = "" class ServiceAdapter(ABC): @@ -20,8 +29,8 @@ class ServiceAdapter(ABC): ... @abstractmethod - async def download(self, path: str) -> bytes: - """Download a file and return its content bytes.""" + async def download(self, path: str) -> Union[bytes, DownloadResult]: + """Download a file. Return bytes or DownloadResult with metadata.""" ... @abstractmethod diff --git a/modules/connectors/connectorVoiceGoogle.py b/modules/connectors/connectorVoiceGoogle.py index 31f5f728..ddb0d864 100644 --- a/modules/connectors/connectorVoiceGoogle.py +++ b/modules/connectors/connectorVoiceGoogle.py @@ -9,7 +9,8 @@ import json import html import asyncio import logging -from typing import Dict, Optional, Any, List +import time +from typing import AsyncGenerator, Dict, Optional, Any, List, Tuple from google.cloud import speech from google.cloud import translate_v2 as translate from google.cloud import texttospeech @@ -403,6 +404,155 @@ class ConnectorGoogleSpeech: "error": str(e) } + async def streamingRecognize( + self, + audioQueue: asyncio.Queue, + language: str = "de-DE", + phraseHints: Optional[list] = None, + ) -> AsyncGenerator[Dict[str, Any], None]: + """ + Stream audio chunks to Google Cloud Speech-to-Text Streaming API. + Google handles silence/endpoint detection natively. + + Args: + audioQueue: Queue of (bytes, bool) tuples. bytes=audio data, bool=isLast. + Send (b"", True) to signal end of stream. + language: Language code + phraseHints: Optional boost phrases + + Yields: + Dicts with keys: isFinal, transcript, confidence, stabilityScore, audioDurationSec + """ + STREAM_LIMIT_SEC = 290 + streamStartTs = time.time() + totalAudioBytes = 0 + + configParams = { + "encoding": speech.RecognitionConfig.AudioEncoding.WEBM_OPUS, + "sample_rate_hertz": 48000, + "audio_channel_count": 1, + "language_code": language, + "enable_automatic_punctuation": True, + "model": "latest_long", + "use_enhanced": True, + } + if phraseHints: + configParams["speech_contexts"] = [speech.SpeechContext(phrases=phraseHints, boost=15.0)] + + recognitionConfig = speech.RecognitionConfig(**configParams) + streamingConfig = speech.StreamingRecognitionConfig( + config=recognitionConfig, + interim_results=True, + single_utterance=False, + ) + + import queue as threadQueue + audioInQ: threadQueue.Queue = threadQueue.Queue() + resultOutQ: asyncio.Queue = asyncio.Queue() + + async def _pumpAudioToThread(): + try: + while True: + item = await audioQueue.get() + audioInQ.put(item) + if item[1]: + return + except asyncio.CancelledError: + audioInQ.put((b"", True)) + + def _requestGenerator(): + nonlocal totalAudioBytes + while True: + try: + chunk, isLast = audioInQ.get(timeout=30.0) + except threadQueue.Empty: + return + if isLast or not chunk: + return + totalAudioBytes += len(chunk) + yield speech.StreamingRecognizeRequest(audio_content=chunk) + + def _runStreamingInThread(): + try: + responseStream = self.speech_client.streaming_recognize( + config=streamingConfig, + requests=_requestGenerator(), + ) + for response in responseStream: + elapsed = time.time() - streamStartTs + estimatedDurationSec = totalAudioBytes / (48000 * 1 * 2) if totalAudioBytes else 0 + + finalTexts = [] + interimTexts = [] + lastFinalConfidence = 0.0 + + for result in response.results: + alt = result.alternatives[0] if result.alternatives else None + if not alt or not alt.transcript.strip(): + continue + if result.is_final: + finalTexts.append(alt.transcript.strip()) + lastFinalConfidence = alt.confidence + else: + interimTexts.append(alt.transcript.strip()) + + for ft in finalTexts: + asyncio.run_coroutine_threadsafe(resultOutQ.put({ + "isFinal": True, + "transcript": ft, + "confidence": lastFinalConfidence, + "stabilityScore": 0.0, + "audioDurationSec": estimatedDurationSec, + }), loop) + + if interimTexts: + combined = " ".join(interimTexts) + asyncio.run_coroutine_threadsafe(resultOutQ.put({ + "isFinal": False, + "transcript": combined, + "confidence": 0.0, + "stabilityScore": 0.0, + "audioDurationSec": estimatedDurationSec, + }), loop) + if elapsed >= STREAM_LIMIT_SEC: + logger.info("Streaming STT approaching 5-min limit, client should reconnect") + asyncio.run_coroutine_threadsafe(resultOutQ.put({ + "isFinal": False, "transcript": "", "confidence": 0.0, + "reconnectRequired": True, "audioDurationSec": 0, + }), loop) + return + except Exception as e: + logger.error(f"Google Streaming STT error: {e}") + asyncio.run_coroutine_threadsafe(resultOutQ.put({ + "error": str(e), + }), loop) + finally: + asyncio.run_coroutine_threadsafe(resultOutQ.put(None), loop) + + loop = asyncio.get_running_loop() + pumpTask = asyncio.ensure_future(_pumpAudioToThread()) + streamFuture = loop.run_in_executor(None, _runStreamingInThread) + + try: + while True: + item = await resultOutQ.get() + if item is None: + break + if "error" in item: + raise RuntimeError(item["error"]) + yield item + finally: + pumpTask.cancel() + await asyncio.shield(streamFuture) + + def calculateSttCostCHF(self, audioDurationSec: float) -> float: + """Google STT cost: ~$0.016/min (standard model).""" + return round((audioDurationSec / 60.0) * 0.016, 8) + + def calculateTtsCostCHF(self, characterCount: int) -> float: + """Google TTS WaveNet cost: ~$0.000004/char.""" + return round(characterCount * 0.000004, 8) + async def translateText(self, text: str, targetLanguage: str = "en", sourceLanguage: str = "de") -> Dict: """ diff --git a/modules/connectors/providerGoogle/connectorGoogle.py b/modules/connectors/providerGoogle/connectorGoogle.py index 216b9019..3928fa70 100644 --- a/modules/connectors/providerGoogle/connectorGoogle.py +++ b/modules/connectors/providerGoogle/connectorGoogle.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional import aiohttp -from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter +from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult from modules.datamodels.datamodelDataSource import ExternalEntry logger = logging.getLogger(__name__) @@ -192,8 +192,41 @@ class GmailAdapter(ServiceAdapter): )) return entries - async def download(self, path: str) -> bytes: - return b"" + async def download(self, path: str) -> DownloadResult: + """Download a Gmail message as RFC 822 EML via format=raw.""" + import base64 + import re + cleanPath = (path or "").strip("/") + msgId = cleanPath.split("/")[-1] if cleanPath else "" + if not msgId: + return DownloadResult() + + url = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=raw" + result = await _googleGet(self._token, url) + if "error" in result: + return DownloadResult() + + rawB64 = result.get("raw", "") + if not rawB64: + return DownloadResult() + + emlBytes = base64.urlsafe_b64decode(rawB64) + + metaUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject" + meta = await _googleGet(self._token, metaUrl) + subject = msgId + if "error" not in meta: + for h in meta.get("payload", {}).get("headers", []): + if h.get("name", "").lower() == "subject": + subject = h.get("value", msgId) + break + safeName = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", subject)[:80].strip(". ") or "email" + + return DownloadResult( + data=emlBytes, + fileName=f"{safeName}.eml", + mimeType="message/rfc822", + ) async def upload(self, path: str, data: bytes, fileName: str) -> dict: return {"error": "Gmail upload not applicable"} diff --git a/modules/connectors/providerMsft/connectorMsft.py b/modules/connectors/providerMsft/connectorMsft.py index 26aa3790..3654bad9 100644 --- a/modules/connectors/providerMsft/connectorMsft.py +++ b/modules/connectors/providerMsft/connectorMsft.py @@ -11,7 +11,7 @@ import aiohttp import asyncio from typing import Dict, Any, List, Optional -from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter +from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult from modules.datamodels.datamodelDataSource import ExternalEntry logger = logging.getLogger(__name__) @@ -256,14 +256,24 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): for m in result.get("value", []) ] - async def download(self, path: str) -> bytes: - """Download a mail message as JSON bytes.""" - import json + async def download(self, path: str) -> DownloadResult: + """Download a mail message as RFC 822 EML via Graph API $value endpoint.""" + import re messageId = path.strip("/").split("/")[-1] - result = await self._graphGet(f"me/messages/{messageId}") - if "error" in result: - return b"" - return json.dumps(result, ensure_ascii=False).encode("utf-8") + + meta = await self._graphGet(f"me/messages/{messageId}?$select=subject") + subject = meta.get("subject", messageId) if "error" not in meta else messageId + safeName = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", subject)[:80].strip(". ") or "email" + + emlBytes = await self._graphDownload(f"me/messages/{messageId}/$value") + if not emlBytes: + return DownloadResult() + + return DownloadResult( + data=emlBytes, + fileName=f"{safeName}.eml", + mimeType="message/rfc822", + ) async def upload(self, path: str, data: bytes, fileName: str) -> dict: """Not applicable for Outlook in the file sense.""" diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py index f33c84e3..42d3da21 100644 --- a/modules/datamodels/datamodelBilling.py +++ b/modules/datamodels/datamodelBilling.py @@ -119,7 +119,7 @@ class BillingTransaction(BaseModel): # Context for workflow transactions workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)") featureInstanceId: Optional[str] = Field(None, description="Feature instance ID") - featureCode: Optional[str] = Field(None, description="Feature code (e.g., chatplayground, automation)") + featureCode: Optional[str] = Field(None, description="Feature code (e.g., automation)") aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)") aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)") createdByUserId: Optional[str] = Field(None, description="User who created/caused this transaction") @@ -224,7 +224,7 @@ class UsageStatistics(BaseModel): # Breakdown by feature costByFeature: Dict[str, float] = Field( default_factory=dict, - description="Cost breakdown by feature (e.g., {'chatplayground': 15.00, 'automation': 5.80})" + description="Cost breakdown by feature (e.g., {'automation': 5.80, 'workspace': 3.20})" ) diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py index 33a8ca7b..7002187a 100644 --- a/modules/datamodels/datamodelChat.py +++ b/modules/datamodels/datamodelChat.py @@ -247,8 +247,6 @@ class WorkflowModeEnum(str, Enum): WORKFLOW_DYNAMIC = "Dynamic" WORKFLOW_AUTOMATION = "Automation" WORKFLOW_CHATBOT = "Chatbot" - WORKFLOW_CODEEDITOR = "CodeEditor" - WORKFLOW_REACT = "React" # Legacy mode - kept for backward compatibility registerModelLabels( @@ -258,8 +256,6 @@ registerModelLabels( "WORKFLOW_DYNAMIC": {"en": "Dynamic", "fr": "Dynamique"}, "WORKFLOW_AUTOMATION": {"en": "Automation", "fr": "Automatisation"}, "WORKFLOW_CHATBOT": {"en": "Chatbot", "fr": "Chatbot"}, - "WORKFLOW_CODEEDITOR": {"en": "Code Editor", "fr": "Éditeur de code"}, - "WORKFLOW_REACT": {"en": "React (Legacy)", "fr": "React (Hérité)"}, }, ) @@ -298,10 +294,6 @@ class ChatWorkflow(BaseModel): "value": WorkflowModeEnum.WORKFLOW_CHATBOT.value, "label": {"en": "Chatbot", "fr": "Chatbot"}, }, - { - "value": WorkflowModeEnum.WORKFLOW_REACT.value, - "label": {"en": "React (Legacy)", "fr": "React (Hérité)"}, - }, ]}) maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"frontend_type": "integer", "frontend_readonly": False, "frontend_required": False}) expectedFormats: Optional[List[str]] = Field(None, description="List of expected file format extensions from user request (e.g., ['xlsx', 'pdf']). Extracted during intent analysis.", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py index e14879a0..afaad996 100644 --- a/modules/datamodels/datamodelFiles.py +++ b/modules/datamodels/datamodelFiles.py @@ -14,7 +14,7 @@ class FileItem(BaseModel): model_config = ConfigDict(extra='allow') # Preserve system fields (_createdBy, _createdAt, etc.) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"}) fileName: str = Field(description="Name of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) @@ -31,7 +31,7 @@ registerModelLabels( { "id": {"en": "ID", "fr": "ID"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, + "featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité"}, "fileName": {"en": "fileName", "fr": "Nom de fichier"}, "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, "fileHash": {"en": "File Hash", "fr": "Hash du fichier"}, diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py index eacb90f4..6cdc4d44 100644 --- a/modules/features/automation/routeFeatureAutomation.py +++ b/modules/features/automation/routeFeatureAutomation.py @@ -17,7 +17,7 @@ from modules.features.automation.interfaceFeatureAutomation import getInterface from modules.features.automation.mainAutomation import getAutomationServices from modules.auth import limiter, getRequestContext, RequestContext from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate -from modules.datamodels.datamodelChat import ChatWorkflow, ChatMessage, ChatLog +from modules.datamodels.datamodelChat import ChatWorkflow, ChatMessage, ChatLog, UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.interfaces import interfaceDbChat @@ -235,7 +235,7 @@ def get_available_actions( # ----------------------------------------------------------------------------- -# Workflow routes under /{instanceId}/workflows/ (instance-scoped, same as chatplayground) +# Workflow routes under /{instanceId}/workflows/ (instance-scoped) # ----------------------------------------------------------------------------- def _validateAutomationInstanceAccess(instanceId: str, context: RequestContext) -> Optional[str]: @@ -854,6 +854,46 @@ def delete_automation( detail=f"Error deleting automation: {str(e)}" ) +@router.post("/{instanceId}/start", response_model=ChatWorkflow) +@limiter.limit("120/minute") +async def start_automation_workflow( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue"), + workflowMode: WorkflowModeEnum = Query(..., description="Workflow mode: 'Dynamic' or 'Automation' (mandatory)"), + userInput: UserInputRequest = Body(...), + context: RequestContext = Depends(getRequestContext) +) -> ChatWorkflow: + """Start a new workflow or continue an existing one.""" + try: + from modules.workflows.automation import chatStart + mandateId = _validateAutomationInstanceAccess(instanceId, context) + services = getAutomationServices( + context.user, + mandateId=mandateId, + featureInstanceId=instanceId, + ) + services.featureCode = "automation" + if hasattr(userInput, 'allowedProviders') and userInput.allowedProviders: + services.allowedProviders = userInput.allowedProviders + workflow = await chatStart( + context.user, + userInput, + workflowMode, + workflowId, + mandateId=mandateId, + featureInstanceId=instanceId, + featureCode="automation", + services=services, + ) + return workflow + except HTTPException: + raise + except Exception as e: + logger.error(f"Error in start_automation_workflow: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/{automationId}/execute", response_model=ChatWorkflow) @limiter.limit("5/minute") async def execute_automation_route( diff --git a/modules/features/chatplayground/__init__.py b/modules/features/chatplayground/__init__.py deleted file mode 100644 index 4b2f2bd4..00000000 --- a/modules/features/chatplayground/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -Chat Playground Feature Container. -Provides workflow-based chat playground functionality. -""" diff --git a/modules/features/chatplayground/interfaceFeatureChatplayground.py b/modules/features/chatplayground/interfaceFeatureChatplayground.py deleted file mode 100644 index 3cddbc85..00000000 --- a/modules/features/chatplayground/interfaceFeatureChatplayground.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -Chat Playground Feature Interface. -Wrapper around interfaceDbChat with feature instance context. -""" - -import logging -from typing import Dict, Any, List, Optional - -from modules.datamodels.datamodelUam import User -from modules.interfaces import interfaceDbChat - -logger = logging.getLogger(__name__) - -# Feature code constant -FEATURE_CODE = "chatplayground" - -# Singleton instances cache -_instances: Dict[str, "ChatPlaygroundObjects"] = {} - - -def getInterface(currentUser: User, mandateId: str = None, featureInstanceId: str = None) -> "ChatPlaygroundObjects": - """ - Factory function to get or create a ChatPlaygroundObjects instance. - Uses singleton pattern per user context. - - Args: - currentUser: Current user object - mandateId: Mandate ID - featureInstanceId: Feature instance ID - - Returns: - ChatPlaygroundObjects instance - """ - cacheKey = f"{currentUser.id}_{mandateId}_{featureInstanceId}" - - if cacheKey not in _instances: - _instances[cacheKey] = ChatPlaygroundObjects(currentUser, mandateId, featureInstanceId) - else: - # Update context if needed - _instances[cacheKey].setUserContext(currentUser, mandateId, featureInstanceId) - - return _instances[cacheKey] - - -class ChatPlaygroundObjects: - """ - Chat Playground feature interface. - Wraps the shared interfaceDbChat with feature instance context. - """ - - FEATURE_CODE = FEATURE_CODE - - def __init__(self, currentUser: User, mandateId: str = None, featureInstanceId: str = None): - """ - Initialize the Chat Playground interface. - - Args: - currentUser: Current user object - mandateId: Mandate ID - featureInstanceId: Feature instance ID - """ - self.currentUser = currentUser - self.mandateId = mandateId - self.featureInstanceId = featureInstanceId - - # Get the underlying chat interface - self._chatInterface = interfaceDbChat.getInterface( - currentUser, - mandateId=mandateId, - featureInstanceId=featureInstanceId - ) - - def setUserContext(self, currentUser: User, mandateId: str = None, featureInstanceId: str = None): - """ - Update the user context. - - Args: - currentUser: Current user object - mandateId: Mandate ID - featureInstanceId: Feature instance ID - """ - self.currentUser = currentUser - self.mandateId = mandateId - self.featureInstanceId = featureInstanceId - - # Update underlying interface - self._chatInterface = interfaceDbChat.getInterface( - currentUser, - mandateId=mandateId, - featureInstanceId=featureInstanceId - ) - - # ========================================================================= - # Delegated methods from interfaceDbChat - # ========================================================================= - - def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]: - """Get a workflow by ID.""" - return self._chatInterface.getWorkflow(workflowId) - - def getWorkflows(self, pagination=None) -> Dict[str, Any]: - """Get all workflows with pagination.""" - return self._chatInterface.getWorkflows(pagination=pagination) - - def getUnifiedChatData(self, workflowId: str, afterTimestamp: float = None) -> Dict[str, Any]: - """Get unified chat data for a workflow.""" - return self._chatInterface.getUnifiedChatData(workflowId, afterTimestamp) - - def createWorkflow(self, workflow) -> Dict[str, Any]: - """Create a new workflow.""" - return self._chatInterface.createWorkflow(workflow) - - def updateWorkflow(self, workflowId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Update a workflow.""" - return self._chatInterface.updateWorkflow(workflowId, updates) - - def deleteWorkflow(self, workflowId: str) -> bool: - """Delete a workflow.""" - return self._chatInterface.deleteWorkflow(workflowId) - - def getMessages(self, workflowId: str) -> List[Dict[str, Any]]: - """Get messages for a workflow.""" - return self._chatInterface.getMessages(workflowId) - - def createMessage(self, message) -> Dict[str, Any]: - """Create a new message.""" - return self._chatInterface.createMessage(message) - - def getLogs(self, workflowId: str) -> List[Dict[str, Any]]: - """Get logs for a workflow.""" - return self._chatInterface.getLogs(workflowId) - - def createLog(self, log) -> Dict[str, Any]: - """Create a new log entry.""" - return self._chatInterface.createLog(log) diff --git a/modules/features/chatplayground/mainChatplayground.py b/modules/features/chatplayground/mainChatplayground.py deleted file mode 100644 index ba1cd094..00000000 --- a/modules/features/chatplayground/mainChatplayground.py +++ /dev/null @@ -1,381 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -Chat Playground Feature Container - Main Module. -Handles feature initialization and RBAC catalog registration. -""" - -import logging -from typing import Dict, List, Any, Optional - -logger = logging.getLogger(__name__) - -# Feature metadata -FEATURE_CODE = "chatplayground" -FEATURE_LABEL = {"en": "Chat Playground", "de": "Chat Playground", "fr": "Chat Playground"} -FEATURE_ICON = "mdi-message-text" - -# UI Objects for RBAC catalog -UI_OBJECTS = [ - { - "objectKey": "ui.feature.chatplayground.playground", - "label": {"en": "Playground", "de": "Playground", "fr": "Playground"}, - "meta": {"area": "playground"} - }, - { - "objectKey": "ui.feature.chatplayground.workflows", - "label": {"en": "Workflows", "de": "Workflows", "fr": "Workflows"}, - "meta": {"area": "workflows"} - }, -] - -# Resource Objects for RBAC catalog -RESOURCE_OBJECTS = [ - { - "objectKey": "resource.feature.chatplayground.start", - "label": {"en": "Start Workflow", "de": "Workflow starten", "fr": "Démarrer workflow"}, - "meta": {"endpoint": "/api/chatplayground/{instanceId}/start", "method": "POST"} - }, - { - "objectKey": "resource.feature.chatplayground.stop", - "label": {"en": "Stop Workflow", "de": "Workflow stoppen", "fr": "Arrêter workflow"}, - "meta": {"endpoint": "/api/chatplayground/{instanceId}/workflows/{workflowId}/stop", "method": "POST"} - }, - { - "objectKey": "resource.feature.chatplayground.chatData", - "label": {"en": "Get Chat Data", "de": "Chat-Daten abrufen", "fr": "Récupérer données chat"}, - "meta": {"endpoint": "/api/chatplayground/{instanceId}/workflows/{workflowId}/chatData", "method": "GET"} - }, -] - -# Service requirements - services this feature needs from the service center -# Same as automation: chatplayground runs the same WorkflowManager and workflow methods -REQUIRED_SERVICES = [ - {"serviceKey": "chat", "meta": {"usage": "Workflow CRUD, messages, logs"}}, - {"serviceKey": "ai", "meta": {"usage": "AI planning for workflow execution"}}, - {"serviceKey": "utils", "meta": {"usage": "Timestamps, utilities"}}, - {"serviceKey": "billing", "meta": {"usage": "AI call billing"}}, - {"serviceKey": "extraction", "meta": {"usage": "Workflow method actions"}}, - {"serviceKey": "sharepoint", "meta": {"usage": "SharePoint actions (listDocuments, uploadDocument, etc.)"}}, - {"serviceKey": "generation", "meta": {"usage": "Action completion messages, document creation from results"}}, -] -# Template roles for this feature -# Role names MUST follow convention: {featureCode}-{roleName} -TEMPLATE_ROLES = [ - { - "roleLabel": "chatplayground-viewer", - "description": { - "en": "Chat Playground Viewer - View chat playground (read-only)", - "de": "Chat Playground Betrachter - Chat Playground ansehen (nur lesen)", - "fr": "Visualiseur Chat Playground - Consulter le chat playground (lecture seule)" - }, - "accessRules": [ - # UI: only playground view, NO workflows - {"context": "UI", "item": "ui.feature.chatplayground.playground", "view": True}, - # RESOURCE: NO access (viewer cannot start/stop/access chat data) - # DATA access (own records, read-only) - {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, - ] - }, - { - "roleLabel": "chatplayground-user", - "description": { - "en": "Chat Playground User - Use chat playground and workflows", - "de": "Chat Playground Benutzer - Chat Playground und Workflows nutzen", - "fr": "Utilisateur Chat Playground - Utiliser le chat playground et les workflows" - }, - "accessRules": [ - # UI: full access to all views - {"context": "UI", "item": "ui.feature.chatplayground.playground", "view": True}, - {"context": "UI", "item": "ui.feature.chatplayground.workflows", "view": True}, - # Resource access: can start/stop workflows and access chat data - {"context": "RESOURCE", "item": "resource.feature.chatplayground.start", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.chatplayground.stop", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.chatplayground.chatData", "view": True}, - # DATA access (own records) - {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, - ] - }, - { - "roleLabel": "chatplayground-admin", - "description": { - "en": "Chat Playground Admin - Full access to chat playground", - "de": "Chat Playground Admin - Vollzugriff auf Chat Playground", - "fr": "Administrateur Chat Playground - Accès complet au chat playground" - }, - "accessRules": [ - # Full UI access - {"context": "UI", "item": None, "view": True}, - # Full resource access - {"context": "RESOURCE", "item": None, "view": True}, - # Full DATA access - {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, - ] - }, -] - - -def getRequiredServiceKeys() -> List[str]: - """Return list of service keys this feature requires.""" - return [s["serviceKey"] for s in REQUIRED_SERVICES] - - -def getChatplaygroundServices( - user, - mandateId: Optional[str] = None, - featureInstanceId: Optional[str] = None, - workflow=None, -) -> "_ChatplaygroundServiceHub": - """ - Get a service hub for the chatplayground feature using the service center. - Resolves only the services declared in REQUIRED_SERVICES. - No legacy fallback - service center only. - - Returns a hub-like object with: chat, ai, utils, billing, extraction, - sharepoint, rbac, interfaceDbApp, interfaceDbComponent, interfaceDbChat. - """ - from modules.serviceCenter import getService - from modules.serviceCenter.context import ServiceCenterContext - - _workflow = workflow - if _workflow is None: - _workflow = type("_Placeholder", (), {"featureCode": FEATURE_CODE})() - ctx = ServiceCenterContext( - user=user, - mandate_id=mandateId, - feature_instance_id=featureInstanceId, - workflow=_workflow, - ) - - hub = _ChatplaygroundServiceHub() - hub.user = user - hub.mandateId = mandateId - hub.featureInstanceId = featureInstanceId - hub.workflow = workflow - hub.featureCode = FEATURE_CODE - hub.allowedProviders = None - - for spec in REQUIRED_SERVICES: - key = spec["serviceKey"] - try: - svc = getService(key, ctx) - setattr(hub, key, svc) - except Exception as e: - logger.warning(f"Could not resolve service '{key}' for chatplayground: {e}") - setattr(hub, key, None) - - # Copy interfaces from chat service for WorkflowManager compatibility - if hub.chat: - hub.interfaceDbApp = getattr(hub.chat, "interfaceDbApp", None) - hub.interfaceDbComponent = getattr(hub.chat, "interfaceDbComponent", None) - hub.interfaceDbChat = getattr(hub.chat, "interfaceDbChat", None) - - # RBAC for MethodBase action permission checks (workflow methods) - hub.rbac = getattr(hub.interfaceDbApp, "rbac", None) if hub.interfaceDbApp else None - - return hub - - -class _ChatplaygroundServiceHub: - """Lightweight hub exposing only services required by the chatplayground feature.""" - - user = None - mandateId = None - featureInstanceId = None - workflow = None - featureCode = "chatplayground" - allowedProviders = None - interfaceDbApp = None - interfaceDbComponent = None - interfaceDbChat = None - rbac = None - chat = None - ai = None - utils = None - billing = None - extraction = None - sharepoint = None - - -def getFeatureDefinition() -> Dict[str, Any]: - """Return the feature definition for registration.""" - return { - "code": FEATURE_CODE, - "label": FEATURE_LABEL, - "icon": FEATURE_ICON, - "autoCreateInstance": True, # Automatically create instance in root mandate during bootstrap - } - - -def getUiObjects() -> List[Dict[str, Any]]: - """Return UI objects for RBAC catalog registration.""" - return UI_OBJECTS - - -def getResourceObjects() -> List[Dict[str, Any]]: - """Return resource objects for RBAC catalog registration.""" - return RESOURCE_OBJECTS - - -def getTemplateRoles() -> List[Dict[str, Any]]: - """Return template roles for this feature.""" - return TEMPLATE_ROLES - - -def registerFeature(catalogService) -> bool: - """ - Register this feature's RBAC objects in the catalog. - - Args: - catalogService: The RBAC catalog service instance - - Returns: - True if registration was successful - """ - try: - # Register UI objects - for uiObj in UI_OBJECTS: - catalogService.registerUiObject( - featureCode=FEATURE_CODE, - objectKey=uiObj["objectKey"], - label=uiObj["label"], - meta=uiObj.get("meta") - ) - - # Register Resource objects - for resObj in RESOURCE_OBJECTS: - catalogService.registerResourceObject( - featureCode=FEATURE_CODE, - objectKey=resObj["objectKey"], - label=resObj["label"], - meta=resObj.get("meta") - ) - - # Sync template roles to database - _syncTemplateRolesToDb() - - logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects") - return True - - except Exception as e: - logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") - return False - - -def _syncTemplateRolesToDb() -> int: - """ - Sync template roles and their AccessRules to the database. - Creates global template roles (mandateId=None) if they don't exist. - - Returns: - Number of roles created/updated - """ - try: - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext - - rootInterface = getRootInterface() - - # Get existing template roles for this feature (Pydantic models) - existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) - # Filter to template roles (mandateId is None) - templateRoles = [r for r in existingRoles if r.mandateId is None] - existingRoleLabels = {r.roleLabel: str(r.id) for r in templateRoles} - - createdCount = 0 - for roleTemplate in TEMPLATE_ROLES: - roleLabel = roleTemplate["roleLabel"] - - if roleLabel in existingRoleLabels: - roleId = existingRoleLabels[roleLabel] - # Ensure AccessRules exist for this role - _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) - else: - # Create new template role - newRole = Role( - roleLabel=roleLabel, - description=roleTemplate.get("description", {}), - featureCode=FEATURE_CODE, - mandateId=None, # Global template - featureInstanceId=None, - isSystemRole=False - ) - createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump()) - roleId = createdRole.get("id") - - # Create AccessRules for this role - _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) - - logger.info(f"Created template role '{roleLabel}' with ID {roleId}") - createdCount += 1 - - if createdCount > 0: - logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles") - - return createdCount - - except Exception as e: - logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}") - return 0 - - -def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int: - """ - Ensure AccessRules exist for a role based on templates. - - Args: - rootInterface: Root interface instance - roleId: Role ID - ruleTemplates: List of rule templates - - Returns: - Number of rules created - """ - from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext - - # Get existing rules for this role (Pydantic models) - existingRules = rootInterface.getAccessRulesByRole(roleId) - - # Create a set of existing rule signatures to avoid duplicates - # IMPORTANT: Use .value for enum comparison, not str() which gives "AccessRuleContext.DATA" in Python 3.11+ - existingSignatures = set() - for rule in existingRules: - sig = (rule.context.value if rule.context else None, rule.item) - existingSignatures.add(sig) - - createdCount = 0 - for template in ruleTemplates: - context = template.get("context", "UI") - item = template.get("item") - sig = (context, item) - - if sig in existingSignatures: - continue - - # Map context string to enum - if context == "UI": - contextEnum = AccessRuleContext.UI - elif context == "DATA": - contextEnum = AccessRuleContext.DATA - elif context == "RESOURCE": - contextEnum = AccessRuleContext.RESOURCE - else: - contextEnum = context - - newRule = AccessRule( - roleId=roleId, - context=contextEnum, - item=item, - view=template.get("view", False), - read=template.get("read"), - create=template.get("create"), - update=template.get("update"), - delete=template.get("delete"), - ) - rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) - createdCount += 1 - - if createdCount > 0: - logger.debug(f"Created {createdCount} AccessRules for role {roleId}") - - return createdCount diff --git a/modules/features/chatplayground/routeFeatureChatplayground.py b/modules/features/chatplayground/routeFeatureChatplayground.py deleted file mode 100644 index 1566c07b..00000000 --- a/modules/features/chatplayground/routeFeatureChatplayground.py +++ /dev/null @@ -1,722 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -Chat Playground Feature Routes. -Implements the endpoints for chat playground workflow management as a feature. -""" - -import json -import logging -from typing import Optional, Dict, Any -from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request, status - -# Import auth modules -from modules.auth import limiter, getRequestContext, RequestContext - -# Import interfaces -from modules.interfaces import interfaceDbChat -from modules.interfaces.interfaceDbBilling import getInterface as _getBillingInterface - -# Import models -from modules.datamodels.datamodelChat import ( - ChatWorkflow, - ChatMessage, - ChatLog, - UserInputRequest, - WorkflowModeEnum, -) -from modules.datamodels.datamodelPagination import ( - PaginationParams, - PaginatedResponse, - PaginationMetadata, - normalize_pagination_dict, -) - -# Import workflow control functions -from modules.workflows.automation import chatStart, chatStop -from modules.features.chatplayground.mainChatplayground import getChatplaygroundServices -from modules.shared.attributeUtils import getModelAttributeDefinitions - -# Configure logger -logger = logging.getLogger(__name__) - -# Model attributes for ChatWorkflow (workflow attributes endpoint) -workflowAttributes = getModelAttributeDefinitions(ChatWorkflow) - -# Create router for chat playground feature endpoints -router = APIRouter( - prefix="/api/chatplayground", - tags=["Chat Playground Feature"], - responses={404: {"description": "Not found"}} -) - - -def _getServiceChat(context: RequestContext, featureInstanceId: str = None): - """Get chat interface with feature instance context.""" - return interfaceDbChat.getInterface( - context.user, - mandateId=str(context.mandateId) if context.mandateId else None, - featureInstanceId=featureInstanceId - ) - - -def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: - """ - Validate that user has access to the feature instance. - - Args: - instanceId: Feature instance ID - context: Request context - - Returns: - mandateId for the instance - - Raises: - HTTPException if access is denied - """ - from modules.interfaces.interfaceDbApp import getRootInterface - - rootInterface = getRootInterface() - - # Get feature instance (Pydantic model) - instance = rootInterface.getFeatureInstance(instanceId) - if not instance: - raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found") - - # Check user has access to this instance using interface method - featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId) - - if not featureAccess or not featureAccess.enabled: - raise HTTPException(status_code=403, detail="Access denied to this feature instance") - - return str(instance.mandateId) if instance.mandateId else None - - -# Workflow start endpoint -@router.post("/{instanceId}/start", response_model=ChatWorkflow) -@limiter.limit("120/minute") -async def start_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue"), - workflowMode: WorkflowModeEnum = Query(..., description="Workflow mode: 'Dynamic' or 'Automation' (mandatory)"), - userInput: UserInputRequest = Body(...), - context: RequestContext = Depends(getRequestContext) -) -> ChatWorkflow: - """ - Starts a new workflow or continues an existing one. - - Args: - instanceId: Feature instance ID - workflowMode: "Dynamic" for iterative dynamic-style processing, "Automation" for automated workflow execution - """ - try: - # Validate access and get mandate ID - mandateId = _validateInstanceAccess(instanceId, context) - - # Get chatplayground services from service center (not automation) - services = getChatplaygroundServices( - context.user, - mandateId=mandateId, - featureInstanceId=instanceId, - ) - services.featureCode = 'chatplayground' - if hasattr(userInput, 'allowedProviders') and userInput.allowedProviders: - services.allowedProviders = userInput.allowedProviders - - # Start or continue workflow - workflow = await chatStart( - context.user, - userInput, - workflowMode, - workflowId, - mandateId=mandateId, - featureInstanceId=instanceId, - featureCode='chatplayground', - services=services, - ) - - return workflow - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error in start_workflow: {str(e)}") - raise HTTPException( - status_code=500, - detail=str(e) - ) - - -# Stop workflow endpoint (under /workflows/{workflowId}/ for consistency) -@router.post("/{instanceId}/workflows/{workflowId}/stop", response_model=ChatWorkflow) -@limiter.limit("120/minute") -async def stop_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow to stop"), - context: RequestContext = Depends(getRequestContext) -) -> ChatWorkflow: - """Stops a running workflow.""" - try: - # Validate access and get mandate ID - mandateId = _validateInstanceAccess(instanceId, context) - - # Get chatplayground services from service center (not automation) - services = getChatplaygroundServices( - context.user, - mandateId=mandateId, - featureInstanceId=instanceId, - ) - services.featureCode = 'chatplayground' - - # Stop workflow (pass featureInstanceId for proper RBAC filtering) - workflow = await chatStop( - context.user, - workflowId, - mandateId=mandateId, - featureInstanceId=instanceId, - featureCode='chatplayground', - services=services, - ) - - return workflow - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error in stop_workflow: {str(e)}") - raise HTTPException( - status_code=500, - detail=str(e) - ) - - -# Unified Chat Data Endpoint for Polling (under /workflows/{workflowId}/ for consistency) -@router.get("/{instanceId}/workflows/{workflowId}/chatData") -@limiter.limit("120/minute") -def get_workflow_chat_data( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow"), - afterTimestamp: Optional[float] = Query(None, description="Unix timestamp to get data after"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """ - Get unified chat data (messages, logs, stats) for a workflow with timestamp-based selective data transfer. - Returns all data types in chronological order based on _createdAt timestamp. - """ - try: - # Validate access - _validateInstanceAccess(instanceId, context) - - # Get service with feature instance context - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - - # Verify workflow exists - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException( - status_code=404, - detail=f"Workflow with ID {workflowId} not found" - ) - - # Get workflow cost from billing transactions (single source of truth) - billingInterface = _getBillingInterface(context.user, context.mandateId) - workflowCost = billingInterface.getWorkflowCost(workflowId) - - chatData = chatInterface.getUnifiedChatData(workflowId, afterTimestamp, workflowCost=workflowCost) - return chatData - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting unified chat data: {str(e)}", exc_info=True) - raise HTTPException( - status_code=500, - detail=f"Error getting unified chat data: {str(e)}" - ) - - -# Get workflow attributes (ChatWorkflow model) -@router.get("/{instanceId}/workflows/attributes", response_model=Dict[str, Any]) -@limiter.limit("120/minute") -def get_workflow_attributes( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Get attribute definitions for ChatWorkflow model.""" - _validateInstanceAccess(instanceId, context) - return {"attributes": workflowAttributes} - - -# Get workflows for this instance -@router.get("/{instanceId}/workflows", response_model=PaginatedResponse[ChatWorkflow]) -@limiter.limit("120/minute") -def get_workflows( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - page: int = Query(1, ge=1, description="Page number (legacy)"), - pageSize: int = Query(20, ge=1, le=100, description="Items per page (legacy)"), - context: RequestContext = Depends(getRequestContext) -) -> PaginatedResponse[ChatWorkflow]: - """ - Get all workflows for this feature instance with optional pagination. - """ - try: - # Validate access - _validateInstanceAccess(instanceId, context) - - # Get service with feature instance context - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - - # Parse pagination parameter - paginationParams = None - if pagination: - try: - paginationDict = json.loads(pagination) - if paginationDict: - paginationDict = normalize_pagination_dict(paginationDict) - paginationParams = PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException( - status_code=400, - detail=f"Invalid pagination parameter: {str(e)}" - ) - else: - paginationParams = PaginationParams(page=page, pageSize=pageSize) - - result = chatInterface.getWorkflows(pagination=paginationParams) - - if paginationParams: - return PaginatedResponse( - items=result.items, - pagination=PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort, - filters=paginationParams.filters - ) - ) - else: - return PaginatedResponse(items=result, pagination=None) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting workflows: {str(e)}", exc_info=True) - raise HTTPException( - status_code=500, - detail=f"Error getting workflows: {str(e)}" - ) - - -# Action Discovery Endpoints (must be before /{workflowId} to avoid path conflict) -@router.get("/{instanceId}/workflows/actions", response_model=Dict[str, Any]) -@limiter.limit("120/minute") -def get_all_workflow_actions( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Get all available workflow actions for the current user (filtered by RBAC).""" - try: - mandateId = _validateInstanceAccess(instanceId, context) - services = getChatplaygroundServices( - context.user, - mandateId=mandateId, - featureInstanceId=instanceId, - ) - from modules.workflows.processing.shared.methodDiscovery import discoverMethods, methods - discoverMethods(services) - allActions = [] - for methodName, methodInfo in methods.items(): - if methodName.startswith('Method'): - continue - methodInstance = methodInfo['instance'] - methodActions = methodInstance.actions - for actionName, actionInfo in methodActions.items(): - actionResponse = { - "module": methodInstance.name, - "actionId": f"{methodInstance.name}.{actionName}", - "name": actionName, - "description": actionInfo.get('description', ''), - "parameters": actionInfo.get('parameters', {}) - } - allActions.append(actionResponse) - return {"actions": allActions} - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting all actions: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get actions: {str(e)}") - - -@router.get("/{instanceId}/workflows/actions/{method}", response_model=Dict[str, Any]) -@limiter.limit("120/minute") -def get_method_workflow_actions( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - method: str = Path(..., description="Method name (e.g., 'outlook', 'sharepoint')"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Get all available actions for a specific method.""" - try: - mandateId = _validateInstanceAccess(instanceId, context) - services = getChatplaygroundServices( - context.user, - mandateId=mandateId, - featureInstanceId=instanceId, - ) - from modules.workflows.processing.shared.methodDiscovery import discoverMethods, methods - discoverMethods(services) - methodInstance = None - for methodName, methodInfo in methods.items(): - if methodInfo['instance'].name == method: - methodInstance = methodInfo['instance'] - break - if not methodInstance: - raise HTTPException(status_code=404, detail=f"Method '{method}' not found") - actions = [] - for actionName, actionInfo in methodInstance.actions.items(): - actionResponse = { - "actionId": f"{methodInstance.name}.{actionName}", - "name": actionName, - "description": actionInfo.get('description', ''), - "parameters": actionInfo.get('parameters', {}) - } - actions.append(actionResponse) - return {"module": methodInstance.name, "description": methodInstance.description, "actions": actions} - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting actions for method {method}: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get actions for method {method}: {str(e)}") - - -@router.get("/{instanceId}/workflows/actions/{method}/{action}", response_model=Dict[str, Any]) -@limiter.limit("120/minute") -def get_action_schema( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - method: str = Path(..., description="Method name (e.g., 'outlook', 'sharepoint')"), - action: str = Path(..., description="Action name (e.g., 'readEmails', 'uploadDocument')"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Get action schema with parameter definitions for a specific action.""" - try: - mandateId = _validateInstanceAccess(instanceId, context) - services = getChatplaygroundServices( - context.user, - mandateId=mandateId, - featureInstanceId=instanceId, - ) - from modules.workflows.processing.shared.methodDiscovery import discoverMethods, methods - discoverMethods(services) - methodInstance = None - for methodName, methodInfo in methods.items(): - if methodInfo['instance'].name == method: - methodInstance = methodInfo['instance'] - break - if not methodInstance: - raise HTTPException(status_code=404, detail=f"Method '{method}' not found") - methodActions = methodInstance.actions - if action not in methodActions: - raise HTTPException(status_code=404, detail=f"Action '{action}' not found in method '{method}'") - actionInfo = methodActions[action] - return { - "method": methodInstance.name, - "action": action, - "actionId": f"{methodInstance.name}.{action}", - "description": actionInfo.get('description', ''), - "parameters": actionInfo.get('parameters', {}) - } - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting action schema for {method}.{action}: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get action schema: {str(e)}") - - -# Get single workflow by ID -@router.get("/{instanceId}/workflows/{workflowId}", response_model=ChatWorkflow) -@limiter.limit("120/minute") -def get_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow"), - context: RequestContext = Depends(getRequestContext) -) -> ChatWorkflow: - """Get workflow by ID.""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") - return workflow - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting workflow: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to get workflow: {str(e)}") - - -# Update workflow -@router.put("/{instanceId}/workflows/{workflowId}", response_model=ChatWorkflow) -@limiter.limit("120/minute") -def update_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow to update"), - workflowData: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext) -) -> ChatWorkflow: - """Update workflow by ID.""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") - if not chatInterface.checkRbacPermission(ChatWorkflow, "update", workflowId): - raise HTTPException(status_code=403, detail="You don't have permission to update this workflow") - updatedWorkflow = chatInterface.updateWorkflow(workflowId, workflowData) - if not updatedWorkflow: - raise HTTPException(status_code=500, detail="Failed to update workflow") - return updatedWorkflow - except HTTPException: - raise - except Exception as e: - logger.error(f"Error updating workflow: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to update workflow: {str(e)}") - - -# Delete workflow -@router.delete("/{instanceId}/workflows/{workflowId}") -@limiter.limit("120/minute") -def delete_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow to delete"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Deletes a workflow and its associated data.""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail=f"Workflow with ID {workflowId} not found") - if not chatInterface.checkRbacPermission(ChatWorkflow, "delete", workflowId): - raise HTTPException(status_code=403, detail="You don't have permission to delete this workflow") - success = chatInterface.deleteWorkflow(workflowId) - if not success: - raise HTTPException(status_code=500, detail="Failed to delete workflow") - return {"id": workflowId, "message": "Workflow and associated data deleted successfully"} - except HTTPException: - raise - except Exception as e: - logger.error(f"Error deleting workflow: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error deleting workflow: {str(e)}") - - -# Get workflow status -@router.get("/{instanceId}/workflows/{workflowId}/status", response_model=ChatWorkflow) -@limiter.limit("120/minute") -def get_workflow_status( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow"), - context: RequestContext = Depends(getRequestContext) -) -> ChatWorkflow: - """Get the current status of a workflow.""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail=f"Workflow with ID {workflowId} not found") - return workflow - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting workflow status: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error getting workflow status: {str(e)}") - - -# Get workflow logs -@router.get("/{instanceId}/workflows/{workflowId}/logs", response_model=PaginatedResponse[ChatLog]) -@limiter.limit("120/minute") -def get_workflow_logs( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow"), - logId: Optional[str] = Query(None, description="Optional log ID for selective data transfer"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - context: RequestContext = Depends(getRequestContext) -) -> PaginatedResponse[ChatLog]: - """Get logs for a workflow with optional pagination.""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail=f"Workflow with ID {workflowId} not found") - - paginationParams = None - if pagination: - try: - paginationDict = json.loads(pagination) - if paginationDict: - paginationDict = normalize_pagination_dict(paginationDict) - paginationParams = PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") - - result = chatInterface.getLogs(workflowId, pagination=paginationParams) - - if logId: - allLogs = result.items if paginationParams else result - logIndex = next((i for i, log in enumerate(allLogs) if log.id == logId), -1) - if logIndex >= 0: - filteredLogs = allLogs[logIndex + 1:] - return PaginatedResponse(items=filteredLogs, pagination=None) - - if paginationParams: - return PaginatedResponse( - items=result.items, - pagination=PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort, - filters=paginationParams.filters - ) - ) - return PaginatedResponse(items=result, pagination=None) - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting workflow logs: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error getting workflow logs: {str(e)}") - - -# Get workflow messages -@router.get("/{instanceId}/workflows/{workflowId}/messages", response_model=PaginatedResponse[ChatMessage]) -@limiter.limit("120/minute") -def get_workflow_messages( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow"), - messageId: Optional[str] = Query(None, description="Optional message ID for selective data transfer"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - context: RequestContext = Depends(getRequestContext) -) -> PaginatedResponse[ChatMessage]: - """Get messages for a workflow with optional pagination.""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail=f"Workflow with ID {workflowId} not found") - - paginationParams = None - if pagination: - try: - paginationDict = json.loads(pagination) - if paginationDict: - paginationDict = normalize_pagination_dict(paginationDict) - paginationParams = PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") - - result = chatInterface.getMessages(workflowId, pagination=paginationParams) - - if messageId: - allMessages = result.items if paginationParams else result - messageIndex = next((i for i, msg in enumerate(allMessages) if msg.id == messageId), -1) - if messageIndex >= 0: - filteredMessages = allMessages[messageIndex + 1:] - return PaginatedResponse(items=filteredMessages, pagination=None) - - if paginationParams: - return PaginatedResponse( - items=result.items, - pagination=PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort, - filters=paginationParams.filters - ) - ) - return PaginatedResponse(items=result, pagination=None) - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting workflow messages: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error getting workflow messages: {str(e)}") - - -# Delete message from workflow -@router.delete("/{instanceId}/workflows/{workflowId}/messages/{messageId}") -@limiter.limit("120/minute") -def delete_workflow_message( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow"), - messageId: str = Path(..., description="ID of the message to delete"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Delete a message from a workflow.""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail=f"Workflow with ID {workflowId} not found") - success = chatInterface.deleteMessage(workflowId, messageId) - if not success: - raise HTTPException(status_code=404, detail=f"Message with ID {messageId} not found in workflow {workflowId}") - return {"workflowId": workflowId, "messageId": messageId, "message": "Message deleted successfully"} - except HTTPException: - raise - except Exception as e: - logger.error(f"Error deleting message: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error deleting message: {str(e)}") - - -# Delete file from message -@router.delete("/{instanceId}/workflows/{workflowId}/messages/{messageId}/files/{fileId}") -@limiter.limit("120/minute") -def delete_file_from_message( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="ID of the workflow"), - messageId: str = Path(..., description="ID of the message"), - fileId: str = Path(..., description="ID of the file to delete"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Delete a file reference from a message in a workflow.""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail=f"Workflow with ID {workflowId} not found") - success = chatInterface.deleteFileFromMessage(workflowId, messageId, fileId) - if not success: - raise HTTPException(status_code=404, detail=f"File with ID {fileId} not found in message {messageId}") - return {"workflowId": workflowId, "messageId": messageId, "fileId": fileId, "message": "File reference deleted successfully"} - except HTTPException: - raise - except Exception as e: - logger.error(f"Error deleting file reference: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error deleting file reference: {str(e)}") diff --git a/modules/features/codeeditor/__init__.py b/modules/features/codeeditor/__init__.py deleted file mode 100644 index d6cca46d..00000000 --- a/modules/features/codeeditor/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""CodeEditor Feature - Cursor-style AI file editing via chat interface.""" diff --git a/modules/features/codeeditor/codeEditorProcessor.py b/modules/features/codeeditor/codeEditorProcessor.py deleted file mode 100644 index 7e472016..00000000 --- a/modules/features/codeeditor/codeEditorProcessor.py +++ /dev/null @@ -1,280 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""CodeEditor processor -- single-shot (Phase 1) and agent loop (Phase 2). -Orchestrates file loading, prompt building, AI calls, response parsing, and SSE emission.""" - -import logging -from typing import List, Dict, Any - -from modules.features.codeeditor import fileContextManager, promptAssembly, responseParser -from modules.features.codeeditor.datamodelCodeeditor import ( - FileEditProposal, SegmentTypeEnum, AgentState -) -from modules.features.codeeditor import toolRegistry -from modules.shared.timeUtils import getUtcTimestamp - -logger = logging.getLogger(__name__) - - -async def processMessage( - workflowId: str, - userPrompt: str, - selectedFileIds: List[str], - dbManagement, - interfaceAi, - chatInterface, - eventManager, - agentMode: bool = False -): - """Process a user message. Dispatches to single-shot or agent loop based on mode.""" - if agentMode: - await _processAgentMessage( - workflowId, userPrompt, dbManagement, interfaceAi, chatInterface, eventManager - ) - else: - await _processSingleShot( - workflowId, userPrompt, selectedFileIds, dbManagement, interfaceAi, chatInterface, eventManager - ) - - -async def _processSingleShot( - workflowId, userPrompt, selectedFileIds, dbManagement, interfaceAi, chatInterface, eventManager -): - """Phase 1: Single AI call with pre-loaded file context.""" - try: - await _emitStatus(eventManager, workflowId, "Loading files...") - fileContexts = await fileContextManager.loadFileContexts(dbManagement, selectedFileIds) - - await _emitStatus(eventManager, workflowId, "Building prompt...") - chatHistory = _loadChatHistory(chatInterface, workflowId) - aiRequest = promptAssembly.buildRequest(userPrompt, fileContexts, chatHistory) - - await _emitStatus(eventManager, workflowId, "AI is processing...") - aiResponse = await interfaceAi.callWithTextContext(aiRequest) - - if aiResponse.errorCount > 0: - await _emitError(eventManager, workflowId, aiResponse.content) - return - - segments = responseParser.parseResponse(aiResponse.content) - await _emitSegments(eventManager, workflowId, segments, fileContexts) - _logAiStats(aiResponse, workflowId) - - await eventManager.emit_event(workflowId, "complete", { - "workflowId": workflowId, - "modelName": aiResponse.modelName, - "priceCHF": aiResponse.priceCHF, - "processingTime": aiResponse.processingTime - }) - - except Exception as e: - logger.error(f"CodeEditor single-shot failed for {workflowId}: {e}", exc_info=True) - await eventManager.emit_event(workflowId, "error", { - "workflowId": workflowId, "error": str(e) - }) - - -async def _processAgentMessage( - workflowId, userPrompt, dbManagement, interfaceAi, chatInterface, eventManager -): - """Phase 2: Agent loop -- multiple AI calls with tool execution until done.""" - state = AgentState(workflowId=workflowId) - - try: - await _emitStatus(eventManager, workflowId, "Agent: Scanning available files...") - fileListContext = fileContextManager.buildFileListContext(dbManagement) - - state.conversationHistory.append({"role": "user", "content": userPrompt}) - - aiRequest = promptAssembly.buildAgentRequest( - userPrompt=userPrompt, - fileListContext=fileListContext, - conversationHistory=[] - ) - - while state.status == "running" and state.currentRound < state.maxRounds: - state.currentRound += 1 - state.totalAiCalls += 1 - - await _emitStatus(eventManager, workflowId, - f"Agent round {state.currentRound}: AI is thinking...") - - await eventManager.emit_event(workflowId, "chatdata", { - "type": "agent_progress", - "item": { - "round": state.currentRound, - "totalAiCalls": state.totalAiCalls, - "totalToolCalls": state.totalToolCalls, - "costCHF": round(state.totalCostCHF, 4), - } - }) - - aiResponse = await interfaceAi.callWithTextContext(aiRequest) - state.totalCostCHF += aiResponse.priceCHF - state.totalProcessingTime += aiResponse.processingTime - - if aiResponse.errorCount > 0: - logger.error(f"Agent AI call failed in round {state.currentRound}: {aiResponse.content}") - await _emitError(eventManager, workflowId, aiResponse.content) - state.status = "error" - break - - _logAiStats(aiResponse, workflowId) - - state.conversationHistory.append({"role": "assistant", "content": aiResponse.content}) - - segments = responseParser.parseResponse(aiResponse.content) - - textAndEditSegments = [s for s in segments if s.type != SegmentTypeEnum.TOOL_CALL] - if textAndEditSegments: - await _emitSegments(eventManager, workflowId, textAndEditSegments, []) - - toolCallSegments = [s for s in segments if s.type == SegmentTypeEnum.TOOL_CALL] - - if not toolCallSegments: - state.status = "completed" - break - - toolResultTexts = [] - for tc in toolCallSegments: - state.totalToolCalls += 1 - await _emitStatus(eventManager, workflowId, - f"Agent: Running {tc.toolName}...") - - result = await toolRegistry.dispatch(tc.toolName, tc.toolArgs or {}, dbManagement) - toolResultTexts.append(f"[{tc.toolName}] (success={result.success}):\n{result.result}") - - logger.info(f"Agent tool {tc.toolName}: success={result.success}, time={result.executionTime:.2f}s") - - combinedResults = "\n\n".join(toolResultTexts) - state.conversationHistory.append({ - "role": "tool_result", - "content": combinedResults, - "toolName": "batch" - }) - - aiRequest = promptAssembly.buildAgentRequest( - userPrompt=None, - fileListContext=fileListContext, - conversationHistory=state.conversationHistory - ) - - if state.currentRound >= state.maxRounds and state.status == "running": - state.status = "max_rounds" - await eventManager.emit_event(workflowId, "chatdata", { - "type": "message", - "item": { - "role": "system", - "content": f"Agent stopped: maximum rounds ({state.maxRounds}) reached.", - "createdAt": getUtcTimestamp() - } - }) - - await eventManager.emit_event(workflowId, "chatdata", { - "type": "agent_summary", - "item": { - "rounds": state.currentRound, - "totalAiCalls": state.totalAiCalls, - "totalToolCalls": state.totalToolCalls, - "costCHF": round(state.totalCostCHF, 4), - "processingTime": round(state.totalProcessingTime, 1), - "status": state.status, - } - }) - - await eventManager.emit_event(workflowId, "complete", { - "workflowId": workflowId, - "agentRounds": state.currentRound, - "totalCostCHF": round(state.totalCostCHF, 4), - "processingTime": round(state.totalProcessingTime, 1) - }) - - except Exception as e: - logger.error(f"CodeEditor agent loop failed for {workflowId}: {e}", exc_info=True) - await eventManager.emit_event(workflowId, "error", { - "workflowId": workflowId, "error": str(e) - }) - - -# --------------------------------------------------------------------------- -# Shared helpers -# --------------------------------------------------------------------------- - -async def _emitStatus(eventManager, workflowId: str, label: str): - await eventManager.emit_event(workflowId, "chatdata", { - "type": "status", "label": label - }) - - -async def _emitError(eventManager, workflowId: str, errorMsg: str): - await eventManager.emit_event(workflowId, "chatdata", { - "type": "message", - "item": {"role": "assistant", "content": f"Error: {errorMsg}"} - }) - await eventManager.emit_event(workflowId, "error", { - "workflowId": workflowId, "error": errorMsg - }) - - -async def _emitSegments(eventManager, workflowId: str, segments, fileContexts): - """Emit parsed segments as SSE events.""" - for segment in segments: - messageData = { - "role": "assistant", - "content": segment.content, - "type": segment.type.value, - "createdAt": getUtcTimestamp() - } - await eventManager.emit_event(workflowId, "chatdata", { - "type": "message", "item": messageData - }) - - if segment.type == SegmentTypeEnum.FILE_EDIT: - proposal = FileEditProposal( - workflowId=workflowId, - fileId=_resolveFileId(segment.fileName, fileContexts), - fileName=segment.fileName, - operation="edit", - oldContent=segment.oldContent, - newContent=segment.newContent - ) - await eventManager.emit_event(workflowId, "chatdata", { - "type": "file_edit_proposal", "item": proposal.model_dump() - }) - - -def _loadChatHistory(chatInterface, workflowId: str) -> List[Dict[str, Any]]: - """Load recent chat messages for multi-turn context.""" - try: - messages = chatInterface.getMessages(workflowId) - if not messages: - return [] - history = [] - for msg in messages: - role = msg.get("role", "unknown") if isinstance(msg, dict) else getattr(msg, "role", "unknown") - content = msg.get("content", "") if isinstance(msg, dict) else getattr(msg, "content", "") - history.append({"role": role, "content": content}) - return history - except Exception as e: - logger.warning(f"Could not load chat history for {workflowId}: {e}") - return [] - - -def _resolveFileId(fileName: str, fileContexts) -> str: - """Resolve a fileName to its fileId from the loaded contexts.""" - for fc in fileContexts: - if fc.fileName == fileName: - return fc.fileId - return f"unknown-{fileName}" - - -def _logAiStats(aiResponse, workflowId: str): - """Log AI call statistics.""" - logger.info( - f"CodeEditor AI call for {workflowId}: " - f"model={aiResponse.modelName}, " - f"provider={aiResponse.provider}, " - f"cost={aiResponse.priceCHF:.4f} CHF, " - f"time={aiResponse.processingTime:.1f}s, " - f"sent={aiResponse.bytesSent}B, received={aiResponse.bytesReceived}B" - ) diff --git a/modules/features/codeeditor/datamodelCodeeditor.py b/modules/features/codeeditor/datamodelCodeeditor.py deleted file mode 100644 index 448496bf..00000000 --- a/modules/features/codeeditor/datamodelCodeeditor.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Data models for the CodeEditor feature.""" - -from typing import List, Dict, Any, Optional -from enum import Enum -from pydantic import BaseModel, Field -from modules.shared.timeUtils import getUtcTimestamp -import uuid - - -class SegmentTypeEnum(str, Enum): - TEXT = "text" - CODE_BLOCK = "code_block" - FILE_EDIT = "file_edit" - TOOL_CALL = "tool_call" - - -class EditStatusEnum(str, Enum): - PENDING = "pending" - ACCEPTED = "accepted" - REJECTED = "rejected" - - -class FileContext(BaseModel): - """A text file loaded as context for the AI.""" - fileId: str - fileName: str - content: Optional[str] = None - mimeType: str - sizeBytes: int = 0 - modifiedAt: Optional[float] = None - tags: List[str] = Field(default_factory=list) - - -class ResponseSegment(BaseModel): - """A parsed segment from the AI response.""" - type: SegmentTypeEnum - content: str - language: Optional[str] = None - fileId: Optional[str] = None - fileName: Optional[str] = None - oldContent: Optional[str] = None - newContent: Optional[str] = None - toolName: Optional[str] = None - toolArgs: Optional[Dict[str, Any]] = None - - -class FileEditProposal(BaseModel): - """A proposed file edit from the AI, awaiting user accept/reject.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - workflowId: str - fileId: str - fileName: str - operation: str = "edit" - oldContent: Optional[str] = None - newContent: str - diffSummary: Optional[str] = None - status: EditStatusEnum = EditStatusEnum.PENDING - createdAt: float = Field(default_factory=getUtcTimestamp) - - -class FileVersion(BaseModel): - """A new version of a file created after accepting an edit proposal.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - sourceFileId: str - editProposalId: str - newFileId: str - createdAt: float = Field(default_factory=getUtcTimestamp) - - -class AgentState(BaseModel): - """Tracks state across an agent loop execution.""" - workflowId: str - currentRound: int = 0 - maxRounds: int = 50 - totalAiCalls: int = 0 - totalToolCalls: int = 0 - totalCostCHF: float = 0.0 - totalProcessingTime: float = 0.0 - conversationHistory: List[Dict[str, Any]] = Field(default_factory=list) - status: str = "running" - - -class ToolResult(BaseModel): - """Result from executing a tool.""" - toolName: str - result: str - success: bool = True - executionTime: float = 0.0 - - -TEXT_MIME_TYPES = { - "text/plain", "text/markdown", "text/html", "text/css", "text/csv", - "text/xml", "text/yaml", "text/x-python", "text/x-java", - "text/javascript", "text/x-typescript", "text/x-sql", - "application/json", "application/xml", "application/yaml", - "application/x-yaml", "application/javascript", -} - -TEXT_EXTENSIONS = { - ".md", ".txt", ".json", ".yaml", ".yml", ".xml", ".csv", - ".py", ".js", ".ts", ".tsx", ".jsx", ".html", ".htm", ".css", ".scss", - ".sql", ".sh", ".bash", ".zsh", ".ps1", ".bat", - ".toml", ".ini", ".cfg", ".conf", ".env", ".gitignore", - ".dockerfile", ".docker-compose", ".makefile", - ".java", ".kt", ".go", ".rs", ".rb", ".php", ".swift", ".c", ".cpp", ".h", - ".r", ".lua", ".dart", ".vue", ".svelte", -} - - -def isTextFile(mimeType: Optional[str], fileName: Optional[str] = None) -> bool: - """Check if a file is a text-based file suitable for the editor.""" - if mimeType and mimeType.lower() in TEXT_MIME_TYPES: - return True - if mimeType and mimeType.lower().startswith("text/"): - return True - if fileName: - ext = "." + fileName.rsplit(".", 1)[-1].lower() if "." in fileName else "" - if ext in TEXT_EXTENSIONS: - return True - return False diff --git a/modules/features/codeeditor/fileContextManager.py b/modules/features/codeeditor/fileContextManager.py deleted file mode 100644 index af13f09b..00000000 --- a/modules/features/codeeditor/fileContextManager.py +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""File context manager for CodeEditor feature. -Loads text files from the database and provides them as context for AI calls.""" - -import logging -from typing import List, Optional - -from modules.features.codeeditor.datamodelCodeeditor import FileContext, isTextFile - -logger = logging.getLogger(__name__) - - -async def loadFileContexts(dbManagement, fileIds: List[str]) -> List[FileContext]: - """Load text files from DB and return as FileContext list. - - Args: - dbManagement: interfaceDbManagement instance with user context set - fileIds: list of file IDs to load - """ - contexts = [] - for fileId in fileIds: - fileItem = dbManagement.getFile(fileId) - if not fileItem: - logger.warning(f"File {fileId} not found or no access") - continue - - if not isTextFile(fileItem.mimeType, fileItem.fileName): - logger.warning(f"File {fileItem.fileName} ({fileItem.mimeType}) is not a text file, skipping") - continue - - fileData = dbManagement.getFileData(fileId) - if not fileData: - logger.warning(f"No data for file {fileId}") - continue - - try: - content = fileData.decode("utf-8") - except UnicodeDecodeError: - logger.warning(f"File {fileItem.fileName} is not valid UTF-8, skipping") - continue - - contexts.append(FileContext( - fileId=fileId, - fileName=fileItem.fileName, - content=content, - mimeType=fileItem.mimeType, - sizeBytes=fileItem.fileSize - )) - - logger.info(f"Loaded {len(contexts)} file contexts from {len(fileIds)} requested") - return contexts - - -def listTextFiles(dbManagement) -> List[FileContext]: - """List all text files accessible to the user (metadata only, no content).""" - allFiles = dbManagement.getAllFiles() - textFiles = [] - - if not allFiles: - return textFiles - - for fileItem in allFiles: - if isTextFile(fileItem.mimeType, fileItem.fileName): - modifiedAt = getattr(fileItem, "_modifiedAt", None) or getattr(fileItem, "creationDate", None) - textFiles.append(FileContext( - fileId=fileItem.id, - fileName=fileItem.fileName, - content=None, - mimeType=fileItem.mimeType, - sizeBytes=fileItem.fileSize, - modifiedAt=modifiedAt - )) - - return textFiles - - -def buildFileListContext(dbManagement) -> str: - """Build a compact file list string for the agent prompt (no content, just metadata).""" - textFiles = listTextFiles(dbManagement) - if not textFiles: - return "No text files available." - lines = [f"- {f.fileName} (id: {f.fileId}, size: {f.sizeBytes}B)" for f in textFiles] - return f"Total: {len(lines)} text files\n" + "\n".join(lines) diff --git a/modules/features/codeeditor/mainCodeeditor.py b/modules/features/codeeditor/mainCodeeditor.py deleted file mode 100644 index 4237f3ab..00000000 --- a/modules/features/codeeditor/mainCodeeditor.py +++ /dev/null @@ -1,248 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -CodeEditor Feature Container - Main Module. -Handles feature initialization and RBAC catalog registration. -Cursor-style AI file editing via chat interface. -""" - -import logging -from typing import Dict, List, Any - -logger = logging.getLogger(__name__) - -FEATURE_CODE = "codeeditor" -FEATURE_LABEL = {"en": "Code Editor", "de": "Code Editor", "fr": "Code Editor"} -FEATURE_ICON = "mdi-file-document-edit" - -UI_OBJECTS = [ - { - "objectKey": "ui.feature.codeeditor.editor", - "label": {"en": "Editor", "de": "Editor", "fr": "Editeur"}, - "meta": {"area": "editor"} - }, - { - "objectKey": "ui.feature.codeeditor.workflows", - "label": {"en": "Workflows", "de": "Workflows", "fr": "Workflows"}, - "meta": {"area": "workflows"} - }, -] - -RESOURCE_OBJECTS = [ - { - "objectKey": "resource.feature.codeeditor.start", - "label": {"en": "Start Workflow", "de": "Workflow starten", "fr": "Demarrer workflow"}, - "meta": {"endpoint": "/api/codeeditor/{instanceId}/start/stream", "method": "POST"} - }, - { - "objectKey": "resource.feature.codeeditor.stop", - "label": {"en": "Stop Workflow", "de": "Workflow stoppen", "fr": "Arreter workflow"}, - "meta": {"endpoint": "/api/codeeditor/{instanceId}/{workflowId}/stop", "method": "POST"} - }, - { - "objectKey": "resource.feature.codeeditor.chatData", - "label": {"en": "Get Chat Data", "de": "Chat-Daten abrufen", "fr": "Recuperer donnees chat"}, - "meta": {"endpoint": "/api/codeeditor/{instanceId}/{workflowId}/chatData", "method": "GET"} - }, - { - "objectKey": "resource.feature.codeeditor.files", - "label": {"en": "Manage Files", "de": "Dateien verwalten", "fr": "Gerer fichiers"}, - "meta": {"endpoint": "/api/codeeditor/{instanceId}/files", "method": "GET"} - }, - { - "objectKey": "resource.feature.codeeditor.apply", - "label": {"en": "Apply Edit", "de": "Aenderung anwenden", "fr": "Appliquer modification"}, - "meta": {"endpoint": "/api/codeeditor/{instanceId}/{workflowId}/apply", "method": "POST"} - }, -] - -TEMPLATE_ROLES = [ - { - "roleLabel": "codeeditor-viewer", - "description": { - "en": "Code Editor Viewer - View editor (read-only)", - "de": "Code Editor Betrachter - Editor ansehen (nur lesen)", - "fr": "Visualiseur Code Editor - Consulter l'editeur (lecture seule)" - }, - "accessRules": [ - {"context": "UI", "item": "ui.feature.codeeditor.editor", "view": True}, - {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, - ] - }, - { - "roleLabel": "codeeditor-user", - "description": { - "en": "Code Editor User - Use editor and workflows", - "de": "Code Editor Benutzer - Editor und Workflows nutzen", - "fr": "Utilisateur Code Editor - Utiliser l'editeur et les workflows" - }, - "accessRules": [ - {"context": "UI", "item": "ui.feature.codeeditor.editor", "view": True}, - {"context": "UI", "item": "ui.feature.codeeditor.workflows", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.codeeditor.start", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.codeeditor.stop", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.codeeditor.chatData", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.codeeditor.files", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.codeeditor.apply", "view": True}, - {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, - ] - }, - { - "roleLabel": "codeeditor-admin", - "description": { - "en": "Code Editor Admin - Full access to code editor", - "de": "Code Editor Admin - Vollzugriff auf Code Editor", - "fr": "Administrateur Code Editor - Acces complet au code editor" - }, - "accessRules": [ - {"context": "UI", "item": None, "view": True}, - {"context": "RESOURCE", "item": None, "view": True}, - {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, - ] - }, -] - - -def getFeatureDefinition() -> Dict[str, Any]: - """Return the feature definition for registration.""" - return { - "code": FEATURE_CODE, - "label": FEATURE_LABEL, - "icon": FEATURE_ICON, - "autoCreateInstance": True, - } - - -def getUiObjects() -> List[Dict[str, Any]]: - """Return UI objects for RBAC catalog registration.""" - return UI_OBJECTS - - -def getResourceObjects() -> List[Dict[str, Any]]: - """Return resource objects for RBAC catalog registration.""" - return RESOURCE_OBJECTS - - -def getTemplateRoles() -> List[Dict[str, Any]]: - """Return template roles for this feature.""" - return TEMPLATE_ROLES - - -def registerFeature(catalogService) -> bool: - """Register this feature's RBAC objects in the catalog.""" - try: - for uiObj in UI_OBJECTS: - catalogService.registerUiObject( - featureCode=FEATURE_CODE, - objectKey=uiObj["objectKey"], - label=uiObj["label"], - meta=uiObj.get("meta") - ) - - for resObj in RESOURCE_OBJECTS: - catalogService.registerResourceObject( - featureCode=FEATURE_CODE, - objectKey=resObj["objectKey"], - label=resObj["label"], - meta=resObj.get("meta") - ) - - _syncTemplateRolesToDb() - - logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects") - return True - - except Exception as e: - logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") - return False - - -def _syncTemplateRolesToDb() -> int: - """Sync template roles and their AccessRules to the database.""" - try: - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext - - rootInterface = getRootInterface() - - existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) - templateRoles = [r for r in existingRoles if r.mandateId is None] - existingRoleLabels = {r.roleLabel: str(r.id) for r in templateRoles} - - createdCount = 0 - for roleTemplate in TEMPLATE_ROLES: - roleLabel = roleTemplate["roleLabel"] - - if roleLabel in existingRoleLabels: - roleId = existingRoleLabels[roleLabel] - _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) - else: - newRole = Role( - roleLabel=roleLabel, - description=roleTemplate.get("description", {}), - featureCode=FEATURE_CODE, - mandateId=None, - featureInstanceId=None, - isSystemRole=False - ) - createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump()) - roleId = createdRole.get("id") - _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) - logger.info(f"Created template role '{roleLabel}' with ID {roleId}") - createdCount += 1 - - if createdCount > 0: - logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles") - - return createdCount - - except Exception as e: - logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}") - return 0 - - -def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int: - """Ensure AccessRules exist for a role based on templates.""" - from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext - - existingRules = rootInterface.getAccessRulesByRole(roleId) - existingSignatures = set() - for rule in existingRules: - sig = (rule.context.value if rule.context else None, rule.item) - existingSignatures.add(sig) - - createdCount = 0 - for template in ruleTemplates: - context = template.get("context", "UI") - item = template.get("item") - sig = (context, item) - - if sig in existingSignatures: - continue - - if context == "UI": - contextEnum = AccessRuleContext.UI - elif context == "DATA": - contextEnum = AccessRuleContext.DATA - elif context == "RESOURCE": - contextEnum = AccessRuleContext.RESOURCE - else: - contextEnum = context - - newRule = AccessRule( - roleId=roleId, - context=contextEnum, - item=item, - view=template.get("view", False), - read=template.get("read"), - create=template.get("create"), - update=template.get("update"), - delete=template.get("delete"), - ) - rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) - createdCount += 1 - - if createdCount > 0: - logger.debug(f"Created {createdCount} AccessRules for role {roleId}") - - return createdCount diff --git a/modules/features/codeeditor/promptAssembly.py b/modules/features/codeeditor/promptAssembly.py deleted file mode 100644 index 95f4ce91..00000000 --- a/modules/features/codeeditor/promptAssembly.py +++ /dev/null @@ -1,183 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Prompt assembly for the CodeEditor feature. -Builds Cursor-style system prompts with file context and format instructions.""" - -import logging -from typing import List, Optional, Dict, Any - -from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum -from modules.features.codeeditor.datamodelCodeeditor import FileContext - -logger = logging.getLogger(__name__) - -SYSTEM_PROMPT = """You are an AI assistant for text and code file editing. You receive files as context and can suggest changes. - -## Rules for file edits -- Use ```file_edit``` blocks for file changes -- Each file_edit block must contain: fileName, oldContent (exact text to replace), newContent (replacement text) -- Explain changes in normal text before or after the block -- oldContent must EXACTLY match existing content (including whitespace and indentation) -- You may propose edits to multiple files in one response - -## Response format -Normal text is displayed as explanation. -File changes must use this format: - -```file_edit -fileName: -oldContent: | - -newContent: | - -``` - -Code examples (without edits) use standard markdown code blocks: -```language -code here -``` - -## Important -- Only edit files that are provided in context -- Make minimal, targeted changes -- Preserve existing formatting and style -- If a task is unclear, ask for clarification instead of guessing""" - - -def buildRequest( - userPrompt: str, - fileContexts: List[FileContext], - chatHistory: Optional[List[Dict[str, Any]]] = None -) -> AiCallRequest: - """Build an AiCallRequest with system prompt, file context, and user prompt.""" - systemPart = SYSTEM_PROMPT - fileContextPart = _buildFileContext(fileContexts) - historyPart = _buildChatHistory(chatHistory) if chatHistory else "" - - fullPrompt = systemPart - if historyPart: - fullPrompt += f"\n\n## Previous conversation\n{historyPart}" - fullPrompt += f"\n\n## User request\n{userPrompt}" - - return AiCallRequest( - prompt=fullPrompt, - context=fileContextPart if fileContextPart else None, - options=AiCallOptions( - operationType=OperationTypeEnum.DATA_ANALYSE, - temperature=0.0, - compressPrompt=False, - compressContext=False, - resultFormat="txt" - ) - ) - - -def _buildFileContext(fileContexts: List[FileContext]) -> str: - """Build the file context string with line numbers.""" - if not fileContexts: - return "" - - parts = [] - for fc in fileContexts: - if not fc.content: - continue - lines = fc.content.split("\n") - numberedLines = [f"{i + 1}|{line}" for i, line in enumerate(lines)] - numbered = "\n".join(numberedLines) - parts.append(f"--- FILE: {fc.fileName} ---\n{numbered}\n--- END FILE ---") - - return "\n\n".join(parts) - - -def buildAgentRequest( - userPrompt: Optional[str], - fileListContext: str, - conversationHistory: List[Dict[str, Any]] -) -> AiCallRequest: - """Build an AiCallRequest for agent mode with tool definitions and conversation history.""" - from modules.features.codeeditor.toolRegistry import formatToolDefinitions - - systemPrompt = _AGENT_SYSTEM_PROMPT.replace("{{TOOL_DEFINITIONS}}", formatToolDefinitions()) - - if not conversationHistory: - fullPrompt = systemPrompt - context = f"## Available files\n{fileListContext}\n\n## Task\n{userPrompt}" - else: - fullPrompt = systemPrompt - historyText = _buildConversationHistory(conversationHistory) - context = f"## Available files\n{fileListContext}\n\n## Conversation\n{historyText}" - - return AiCallRequest( - prompt=fullPrompt, - context=context, - options=AiCallOptions( - operationType=OperationTypeEnum.DATA_ANALYSE, - temperature=0.0, - compressPrompt=False, - compressContext=False, - resultFormat="txt" - ) - ) - - -_AGENT_SYSTEM_PROMPT = """You are an AI agent for file analysis and editing. You work autonomously by using tools to read files, search content, and propose edits. - -## Available tools -{{TOOL_DEFINITIONS}} - -## How to call tools -Use this exact format for each tool call: - -```tool_call -tool: -args: {"param": "value"} -``` - -## Rules -- Read files ONE AT A TIME with read_file, never assume file contents -- First create a plan, then execute it step by step -- Use search_files to find relevant files before reading them -- Use list_files to discover what files are available -- For file changes, use ```file_edit``` blocks (same format as before) -- You may combine text explanations, tool calls, and file edits in one response -- When you are DONE and need no more tool calls, simply respond with text only (no tool_call blocks) -- Keep responses focused and efficient - -## file_edit format (for changes) -```file_edit -fileName: -oldContent: | - -newContent: | - -```""" - - -def _buildConversationHistory(history: List[Dict[str, Any]]) -> str: - """Build the full conversation history for agent multi-turn context.""" - parts = [] - for msg in history: - role = msg.get("role", "unknown") - content = msg.get("content", "") - if role == "tool_result": - toolName = msg.get("toolName", "") - parts.append(f"[Tool Result - {toolName}]:\n{content}") - else: - parts.append(f"[{role}]:\n{content}") - return "\n\n".join(parts) - - -def _buildChatHistory(chatHistory: List[Dict[str, Any]]) -> str: - """Build a condensed chat history string for multi-turn context.""" - if not chatHistory: - return "" - - parts = [] - for msg in chatHistory[-10:]: - role = msg.get("role", "unknown") - content = msg.get("content", "") - if len(content) > 500: - content = content[:500] + "..." - parts.append(f"[{role}]: {content}") - - return "\n".join(parts) diff --git a/modules/features/codeeditor/responseParser.py b/modules/features/codeeditor/responseParser.py deleted file mode 100644 index 8003e7b2..00000000 --- a/modules/features/codeeditor/responseParser.py +++ /dev/null @@ -1,184 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Response parser for the CodeEditor feature. -Parses AI responses into typed segments (text, code_block, file_edit, tool_call).""" - -import logging -import json -import re -from typing import List, Optional - -from modules.features.codeeditor.datamodelCodeeditor import ResponseSegment, SegmentTypeEnum - -logger = logging.getLogger(__name__) - -_FENCE_PATTERN = re.compile(r"^```(\w*)\s*$", re.MULTILINE) - - -def parseResponse(rawContent: str) -> List[ResponseSegment]: - """Parse an AI response into typed segments.""" - if not rawContent or not rawContent.strip(): - return [] - - segments = [] - lines = rawContent.split("\n") - i = 0 - - textBuffer = [] - - while i < len(lines): - line = lines[i] - - match = _FENCE_PATTERN.match(line) - if match: - if textBuffer: - _flushTextBuffer(textBuffer, segments) - textBuffer = [] - - lang = match.group(1).strip() - blockLines, endIdx = _collectBlock(lines, i + 1) - blockContent = "\n".join(blockLines) - - if lang == "file_edit": - segment = _parseFileEditBlock(blockContent) - if segment: - segments.append(segment) - else: - segments.append(ResponseSegment( - type=SegmentTypeEnum.CODE_BLOCK, - content=blockContent, - language="text" - )) - elif lang == "tool_call": - segment = _parseToolCallBlock(blockContent) - if segment: - segments.append(segment) - else: - segments.append(ResponseSegment( - type=SegmentTypeEnum.CODE_BLOCK, - content=blockContent, - language="text" - )) - else: - segments.append(ResponseSegment( - type=SegmentTypeEnum.CODE_BLOCK, - content=blockContent, - language=lang or "text" - )) - - i = endIdx + 1 - else: - textBuffer.append(line) - i += 1 - - if textBuffer: - _flushTextBuffer(textBuffer, segments) - - return segments - - -def hasToolCalls(segments: List[ResponseSegment]) -> bool: - """Check if any segments contain tool calls.""" - return any(s.type == SegmentTypeEnum.TOOL_CALL for s in segments) - - -def _collectBlock(lines: List[str], startIdx: int) -> tuple: - """Collect lines inside a fenced code block until closing ```.""" - blockLines = [] - idx = startIdx - while idx < len(lines): - if lines[idx].strip() == "```": - return blockLines, idx - blockLines.append(lines[idx]) - idx += 1 - return blockLines, idx - - -def _flushTextBuffer(buffer: List[str], segments: List[ResponseSegment]): - """Flush accumulated text lines into a text segment.""" - text = "\n".join(buffer).strip() - buffer.clear() - if text: - segments.append(ResponseSegment( - type=SegmentTypeEnum.TEXT, - content=text - )) - - -def _parseFileEditBlock(blockContent: str) -> Optional[ResponseSegment]: - """Parse a file_edit block into a ResponseSegment with fileName, oldContent, newContent.""" - fields = {"fileName": None, "oldContent": None, "newContent": None} - currentField = None - currentLines = [] - - for line in blockContent.split("\n"): - stripped = line.strip() - - newField = None - for key in ("fileName", "oldContent", "newContent"): - if stripped.startswith(f"{key}:"): - newField = key - break - - if newField: - if currentField and currentLines: - fields[currentField] = "\n".join(currentLines) - currentField = newField - value = stripped[len(f"{newField}:"):].strip() - if newField == "fileName": - fields["fileName"] = value if value else None - currentField = None - currentLines = [] - else: - currentLines = [value] if value and value != "|" else [] - else: - if currentField in ("oldContent", "newContent"): - dedented = line[2:] if line.startswith(" ") else line - currentLines.append(dedented) - - if currentField and currentLines: - fields[currentField] = "\n".join(currentLines) - - if not fields["fileName"]: - logger.warning("file_edit block missing fileName") - return None - if fields["newContent"] is None: - logger.warning(f"file_edit block for {fields['fileName']} missing newContent") - return None - - return ResponseSegment( - type=SegmentTypeEnum.FILE_EDIT, - content=f"Edit: {fields['fileName']}", - fileName=fields["fileName"], - oldContent=fields["oldContent"], - newContent=fields["newContent"] - ) - - -def _parseToolCallBlock(blockContent: str) -> Optional[ResponseSegment]: - """Parse a tool_call block into a ResponseSegment with toolName and toolArgs.""" - toolName = None - toolArgs = {} - - for line in blockContent.split("\n"): - stripped = line.strip() - if stripped.startswith("tool:"): - toolName = stripped[len("tool:"):].strip() - elif stripped.startswith("args:"): - argsStr = stripped[len("args:"):].strip() - try: - toolArgs = json.loads(argsStr) - except json.JSONDecodeError: - logger.warning(f"Could not parse tool args as JSON: {argsStr}") - toolArgs = {"raw": argsStr} - - if not toolName: - logger.warning("tool_call block missing tool name") - return None - - return ResponseSegment( - type=SegmentTypeEnum.TOOL_CALL, - content=f"Tool: {toolName}", - toolName=toolName, - toolArgs=toolArgs - ) diff --git a/modules/features/codeeditor/routeFeatureCodeeditor.py b/modules/features/codeeditor/routeFeatureCodeeditor.py deleted file mode 100644 index ef239b35..00000000 --- a/modules/features/codeeditor/routeFeatureCodeeditor.py +++ /dev/null @@ -1,395 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -CodeEditor Feature Routes. -SSE-based endpoints for Cursor-style AI file editing. -""" - -import logging -import json -import asyncio -from typing import Optional, Dict, Any, List - -from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request -from fastapi.responses import StreamingResponse - -from modules.auth import limiter, getRequestContext, RequestContext -from modules.interfaces import interfaceDbChat, interfaceDbManagement -from modules.interfaces.interfaceAiObjects import AiObjects -from modules.datamodels.datamodelChat import UserInputRequest -from modules.serviceCenter.core.serviceStreaming import get_event_manager -from modules.features.codeeditor import codeEditorProcessor, fileContextManager -from modules.features.codeeditor.datamodelCodeeditor import FileEditProposal, EditStatusEnum - -logger = logging.getLogger(__name__) - -router = APIRouter( - prefix="/api/codeeditor", - tags=["Code Editor Feature"], - responses={404: {"description": "Not found"}} -) - -_aiObjects: Optional[AiObjects] = None - - -async def _getAiObjects() -> AiObjects: - """Lazy-init singleton for AiObjects.""" - global _aiObjects - if _aiObjects is None: - _aiObjects = await AiObjects.create() - return _aiObjects - - -def _getServiceChat(context: RequestContext, featureInstanceId: str = None): - """Get chat interface with feature instance context.""" - return interfaceDbChat.getInterface( - context.user, - mandateId=str(context.mandateId) if context.mandateId else None, - featureInstanceId=featureInstanceId - ) - - -def _getDbManagement(context: RequestContext, featureInstanceId: str = None): - """Get management interface with user context for file access.""" - return interfaceDbManagement.getInterface( - context.user, - mandateId=str(context.mandateId) if context.mandateId else None, - featureInstanceId=featureInstanceId - ) - - -def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: - """Validate user has access to the feature instance. Returns mandateId.""" - from modules.interfaces.interfaceDbApp import getRootInterface - - rootInterface = getRootInterface() - instance = rootInterface.getFeatureInstance(instanceId) - if not instance: - raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found") - - featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId) - if not featureAccess or not featureAccess.enabled: - raise HTTPException(status_code=403, detail="Access denied to this feature instance") - - return str(instance.mandateId) if instance.mandateId else None - - -@router.post("/{instanceId}/start/stream") -@limiter.limit("60/minute") -async def streamCodeeditorStart( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: Optional[str] = Query(None, description="Optional workflow ID to continue"), - mode: str = Query("simple", description="Processing mode: 'simple' (single AI call) or 'agent' (multi-step with tools)"), - userInput: UserInputRequest = Body(...), - context: RequestContext = Depends(getRequestContext) -): - """Start or continue a CodeEditor workflow with SSE streaming. Supports simple and agent mode.""" - try: - mandateId = _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - dbManagement = _getDbManagement(context, featureInstanceId=instanceId) - aiObjects = await _getAiObjects() - eventManager = get_event_manager() - - if workflowId: - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found") - else: - workflow = chatInterface.createWorkflow({ - "workflowMode": "CodeEditor", - "status": "running", - "label": userInput.prompt[:80] if userInput.prompt else "CodeEditor Session", - }) - workflowId = workflow.get("id") if isinstance(workflow, dict) else workflow.id - - queue = eventManager.create_queue(workflowId) - - userMessage = { - "role": "user", - "content": userInput.prompt, - "selectedFiles": userInput.listFileId or [] - } - await eventManager.emit_event(workflowId, "chatdata", { - "type": "message", "item": userMessage - }) - - selectedFileIds = userInput.listFileId or [] - - agentMode = mode.lower() == "agent" - - asyncio.create_task( - codeEditorProcessor.processMessage( - workflowId=workflowId, - userPrompt=userInput.prompt, - selectedFileIds=selectedFileIds, - dbManagement=dbManagement, - interfaceAi=aiObjects, - chatInterface=chatInterface, - eventManager=eventManager, - agentMode=agentMode - ) - ) - - async def _eventStream(): - streamTimeout = 300 - lastActivity = asyncio.get_event_loop().time() - - while True: - now = asyncio.get_event_loop().time() - if now - lastActivity > streamTimeout: - yield f"data: {json.dumps({'type': 'error', 'error': 'Stream timeout'})}\n\n" - break - - if await request.is_disconnected(): - logger.info(f"Client disconnected for workflow {workflowId}") - break - - try: - event = await asyncio.wait_for(queue.get(), timeout=1.0) - lastActivity = asyncio.get_event_loop().time() - - eventType = event.get("type", "") - - if eventType == "chatdata": - yield f"data: {json.dumps(event.get('data', {}))}\n\n" - elif eventType in ("complete", "stopped", "error"): - yield f"data: {json.dumps({'type': eventType, **event.get('data', {})})}\n\n" - break - else: - yield f"data: {json.dumps(event)}\n\n" - - except asyncio.TimeoutError: - yield ": keepalive\n\n" - - await eventManager.cleanup(workflowId) - - return StreamingResponse( - _eventStream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no" - } - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error in streamCodeeditorStart: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/{instanceId}/{workflowId}/stop") -@limiter.limit("120/minute") -async def stopWorkflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - context: RequestContext = Depends(getRequestContext) -): - """Stop a running CodeEditor workflow.""" - try: - _validateInstanceAccess(instanceId, context) - eventManager = get_event_manager() - await eventManager.emit_event(workflowId, "stopped", { - "workflowId": workflowId - }, event_category="workflow", message="Workflow stopped by user") - return {"status": "stopped", "workflowId": workflowId} - except HTTPException: - raise - except Exception as e: - logger.error(f"Error stopping workflow: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{instanceId}/{workflowId}/chatData") -@limiter.limit("120/minute") -def getWorkflowChatData( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - afterTimestamp: Optional[float] = Query(None, description="Unix timestamp for incremental fetch"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Get chat data for a workflow (polling fallback).""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - workflow = chatInterface.getWorkflow(workflowId) - if not workflow: - raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found") - return chatInterface.getUnifiedChatData(workflowId, afterTimestamp) - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting chat data: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{instanceId}/workflows") -@limiter.limit("120/minute") -def getWorkflows( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - page: int = Query(1, ge=1), - pageSize: int = Query(20, ge=1, le=100), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """List workflows for this feature instance.""" - try: - _validateInstanceAccess(instanceId, context) - chatInterface = _getServiceChat(context, featureInstanceId=instanceId) - from modules.datamodels.datamodelPagination import PaginationParams - pagination = PaginationParams(page=page, pageSize=pageSize) - return chatInterface.getWorkflows(pagination=pagination) - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting workflows: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{instanceId}/files") -@limiter.limit("120/minute") -def getFiles( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """List all text files accessible to the user.""" - try: - _validateInstanceAccess(instanceId, context) - dbManagement = _getDbManagement(context, featureInstanceId=instanceId) - textFiles = fileContextManager.listTextFiles(dbManagement) - return { - "files": [f.model_dump(exclude={"content"}) for f in textFiles], - "count": len(textFiles) - } - except HTTPException: - raise - except Exception as e: - logger.error(f"Error listing files: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{instanceId}/files/{fileId}/content") -@limiter.limit("120/minute") -def getFileContent( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - fileId: str = Path(..., description="File ID"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Get the text content of a file.""" - try: - _validateInstanceAccess(instanceId, context) - dbManagement = _getDbManagement(context, featureInstanceId=instanceId) - - fileItem = dbManagement.getFile(fileId) - if not fileItem: - raise HTTPException(status_code=404, detail=f"File {fileId} not found") - - fileData = dbManagement.getFileData(fileId) - if not fileData: - raise HTTPException(status_code=404, detail=f"No data for file {fileId}") - - try: - content = fileData.decode("utf-8") - except UnicodeDecodeError: - raise HTTPException(status_code=400, detail="File is not valid UTF-8 text") - - return { - "fileId": fileId, - "fileName": fileItem.fileName, - "mimeType": fileItem.mimeType, - "content": content - } - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting file content: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/{instanceId}/{workflowId}/apply") -@limiter.limit("60/minute") -async def applyEdit( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - proposalData: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Accept a file edit proposal. Updates existing file or creates new one.""" - try: - _validateInstanceAccess(instanceId, context) - dbManagement = _getDbManagement(context, featureInstanceId=instanceId) - - fileId = proposalData.get("fileId", "") - newContent = proposalData.get("newContent") - fileName = proposalData.get("fileName", "") - - if newContent is None: - raise HTTPException(status_code=400, detail="newContent is required") - - contentBytes = newContent.encode("utf-8") - isNewFile = not fileId or fileId.startswith("unknown-") - - if isNewFile: - mimeType = _guessMimeType(fileName) - fileItem = dbManagement.createFile(fileName, mimeType, contentBytes) - resultFileId = fileItem.id - resultFileName = fileItem.fileName - else: - fileItem = dbManagement.getFile(fileId) - if not fileItem: - raise HTTPException(status_code=404, detail=f"File {fileId} not found") - success = dbManagement.createFileData(fileId, contentBytes) - if not success: - raise HTTPException(status_code=500, detail="Failed to store updated file content") - resultFileId = fileId - resultFileName = fileName or fileItem.fileName - - eventManager = get_event_manager() - await eventManager.emit_event(workflowId, "chatdata", { - "type": "file_version", - "item": { - "fileId": resultFileId, - "fileName": resultFileName, - "status": "accepted", - "isNew": isNewFile - } - }) - - return { - "status": "accepted", - "fileId": resultFileId, - "fileName": resultFileName, - "isNew": isNewFile - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error applying edit: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -_MIME_MAP = { - ".md": "text/markdown", ".txt": "text/plain", ".json": "application/json", - ".yaml": "application/yaml", ".yml": "application/yaml", ".xml": "application/xml", - ".csv": "text/csv", ".py": "text/x-python", ".js": "text/javascript", - ".ts": "text/x-typescript", ".html": "text/html", ".css": "text/css", - ".sql": "text/x-sql", ".sh": "text/x-shellscript", -} - - -def _guessMimeType(fileName: str) -> str: - """Guess MIME type from file extension.""" - if not fileName or "." not in fileName: - return "text/plain" - ext = "." + fileName.rsplit(".", 1)[-1].lower() - return _MIME_MAP.get(ext, "text/plain") diff --git a/modules/features/codeeditor/toolRegistry.py b/modules/features/codeeditor/toolRegistry.py deleted file mode 100644 index 69256671..00000000 --- a/modules/features/codeeditor/toolRegistry.py +++ /dev/null @@ -1,157 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Tool registry and dispatcher for the CodeEditor agent loop. -Defines available tools and executes them against the file context manager.""" - -import logging -import time -import fnmatch -from typing import Dict, Any, List - -from modules.features.codeeditor.datamodelCodeeditor import ToolResult - -logger = logging.getLogger(__name__) - -TOOL_DEFINITIONS = [ - { - "name": "read_file", - "description": "Read the full content of a single file by its fileId.", - "parameters": {"fileId": "string (required)"} - }, - { - "name": "list_files", - "description": "List all available text files with metadata (name, size, mimeType). Optionally filter by glob pattern.", - "parameters": {"filter": "string (optional, glob pattern e.g. '*.py')"} - }, - { - "name": "search_files", - "description": "Search all file contents for a text query. Returns matching lines with file name and line number.", - "parameters": {"query": "string (required)", "fileType": "string (optional, extension e.g. 'py')"} - }, -] - - -async def dispatch(toolName: str, toolArgs: Dict[str, Any], dbManagement) -> ToolResult: - """Execute a tool and return the result.""" - startTime = time.time() - try: - if toolName == "read_file": - result = await _toolReadFile(toolArgs, dbManagement) - elif toolName == "list_files": - result = _toolListFiles(toolArgs, dbManagement) - elif toolName == "search_files": - result = await _toolSearchFiles(toolArgs, dbManagement) - else: - result = f"Unknown tool: {toolName}" - return ToolResult(toolName=toolName, result=result, success=False, - executionTime=time.time() - startTime) - - return ToolResult(toolName=toolName, result=result, success=True, - executionTime=time.time() - startTime) - except Exception as e: - logger.error(f"Tool {toolName} failed: {e}", exc_info=True) - return ToolResult(toolName=toolName, result=f"Error: {str(e)}", success=False, - executionTime=time.time() - startTime) - - -async def _toolReadFile(args: Dict[str, Any], dbManagement) -> str: - """Read a single file's content.""" - fileId = args.get("fileId", "") - if not fileId: - return "Error: fileId is required" - - fileItem = dbManagement.getFile(fileId) - if not fileItem: - return f"Error: File {fileId} not found" - - fileData = dbManagement.getFileData(fileId) - if not fileData: - return f"Error: No data for file {fileId}" - - try: - content = fileData.decode("utf-8") - except UnicodeDecodeError: - return f"Error: File {fileItem.fileName} is not valid UTF-8" - - lines = content.split("\n") - numbered = "\n".join([f"{i + 1}|{line}" for i, line in enumerate(lines)]) - return f"--- FILE: {fileItem.fileName} (id: {fileId}) ---\n{numbered}\n--- END FILE ---" - - -def _toolListFiles(args: Dict[str, Any], dbManagement) -> str: - """List all text files, optionally filtered by glob pattern.""" - from modules.features.codeeditor.datamodelCodeeditor import isTextFile - - filterPattern = args.get("filter", "") - allFiles = dbManagement.getAllFiles() - if not allFiles: - return "No files found." - - lines = [] - for f in allFiles: - if not isTextFile(f.mimeType, f.fileName): - continue - if filterPattern and not fnmatch.fnmatch(f.fileName, filterPattern): - continue - lines.append(f"- {f.fileName} (id: {f.id}, size: {f.fileSize}B, type: {f.mimeType})") - - if not lines: - return "No matching text files found." - return f"Available files ({len(lines)}):\n" + "\n".join(lines) - - -async def _toolSearchFiles(args: Dict[str, Any], dbManagement) -> str: - """Search file contents for a query string.""" - from modules.features.codeeditor.datamodelCodeeditor import isTextFile - - query = args.get("query", "") - if not query: - return "Error: query is required" - - fileType = args.get("fileType", "") - allFiles = dbManagement.getAllFiles() - if not allFiles: - return "No files to search." - - hits = [] - maxHits = 50 - queryLower = query.lower() - - for f in allFiles: - if not isTextFile(f.mimeType, f.fileName): - continue - if fileType and not f.fileName.endswith(f".{fileType}"): - continue - - fileData = dbManagement.getFileData(f.id) - if not fileData: - continue - - try: - content = fileData.decode("utf-8") - except UnicodeDecodeError: - continue - - for lineNum, line in enumerate(content.split("\n"), 1): - if queryLower in line.lower(): - hits.append(f"{f.fileName}:{lineNum}: {line.strip()}") - if len(hits) >= maxHits: - break - if len(hits) >= maxHits: - break - - if not hits: - return f"No matches found for '{query}'." - result = f"Search results for '{query}' ({len(hits)} matches):\n" + "\n".join(hits) - if len(hits) >= maxHits: - result += f"\n... (truncated at {maxHits} matches)" - return result - - -def formatToolDefinitions() -> str: - """Format tool definitions for inclusion in the system prompt.""" - parts = [] - for tool in TOOL_DEFINITIONS: - params = ", ".join([f"{k}: {v}" for k, v in tool["parameters"].items()]) - parts.append(f"- **{tool['name']}**: {tool['description']}\n Parameters: {{{params}}}") - return "\n".join(parts) diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index 424b94f3..81585a19 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -10,8 +10,10 @@ import json import asyncio import base64 import uuid + + from typing import Optional -from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi import APIRouter, HTTPException, Depends, Request, WebSocket, WebSocketDisconnect, Query from fastapi.responses import StreamingResponse, Response from modules.auth import limiter, getRequestContext, RequestContext @@ -31,7 +33,6 @@ from .datamodelCommcoach import ( StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, ) from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents - logger = logging.getLogger(__name__) _activeProcessTasks: dict = {} diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index ac9ad085..be47a917 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -1011,15 +1011,15 @@ class CommcoachService: async def _callAi(self, systemPrompt: str, userPrompt: str): """Call the AI service with the given prompts.""" - from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService + from modules.serviceCenter import getService + from modules.serviceCenter.context import ServiceCenterContext - serviceContext = type('Ctx', (), { - 'user': self.currentUser, - 'mandateId': self.mandateId, - 'featureInstanceId': self.instanceId, - 'featureCode': 'commcoach', - })() - aiService = AiService(serviceCenter=serviceContext) + serviceContext = ServiceCenterContext( + user=self.currentUser, + mandate_id=self.mandateId, + feature_instance_id=self.instanceId, + ) + aiService = getService("ai", serviceContext) await aiService.ensureAiObjectsInitialized() aiRequest = AiCallRequest( diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py index 193c5bf6..b8d6a03d 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py @@ -40,15 +40,15 @@ class AccountingConnectorAbacus(BaseAccountingConnector): def getRequiredConfigFields(self) -> List[ConnectorConfigField]: return [ ConnectorConfigField( - key="abacusHost", - label={"en": "Abacus Host URL", "de": "Abacus Host-URL", "fr": "URL Hôte Abacus"}, + key="apiBaseUrl", + label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"}, fieldType="text", secret=False, - placeholder="e.g. abacus.meinefirma.ch", + placeholder="e.g. https://abacus.meinefirma.ch/api/entity/v1/", ), ConnectorConfigField( - key="mandant", - label={"en": "Mandant Number", "de": "Mandantennummer", "fr": "Numéro de mandant"}, + key="clientName", + label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"}, fieldType="text", secret=False, placeholder="e.g. 7777", @@ -68,22 +68,37 @@ class AccountingConnectorAbacus(BaseAccountingConnector): ] def _buildBaseUrl(self, config: Dict[str, Any]) -> str: - host = config["abacusHost"].rstrip("/") - if not host.startswith("http"): - host = f"https://{host}" - return host + apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() + if not apiBaseUrl: + raise ValueError("Missing required config: apiBaseUrl") + if not apiBaseUrl.startswith("http"): + apiBaseUrl = f"https://{apiBaseUrl}" + return apiBaseUrl.rstrip("/") + + def _buildAuthBaseUrl(self, config: Dict[str, Any]) -> str: + apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() + if not apiBaseUrl: + raise ValueError("Missing required config: apiBaseUrl") + if not apiBaseUrl.startswith("http"): + apiBaseUrl = f"https://{apiBaseUrl}" + apiBaseUrl = apiBaseUrl.rstrip("/") + if "/api/entity/v1" in apiBaseUrl: + return apiBaseUrl.split("/api/entity/v1", 1)[0] + if "/api/" in apiBaseUrl: + return apiBaseUrl.split("/api/", 1)[0] + return apiBaseUrl async def _getAccessToken(self, config: Dict[str, Any]) -> Optional[str]: """Obtain an OAuth access token using client_credentials grant. Tokens are cached and refreshed when expired (default 600s). """ - cacheKey = f"{config.get('abacusHost')}_{config.get('clientId')}" + cacheKey = f"{config.get('apiBaseUrl')}_{config.get('clientName')}_{config.get('clientId')}" cached = self._tokenCache.get(cacheKey) if cached and cached.get("expiresAt", 0) > time.time() + 30: return cached["accessToken"] - baseUrl = self._buildBaseUrl(config) + baseUrl = self._buildAuthBaseUrl(config) try: async with aiohttp.ClientSession() as session: @@ -120,8 +135,10 @@ class AccountingConnectorAbacus(BaseAccountingConnector): def _buildEntityUrl(self, config: Dict[str, Any], entity: str) -> str: baseUrl = self._buildBaseUrl(config) - mandant = config["mandant"] - return f"{baseUrl}/api/entity/v1/{mandant}/{entity}" + clientName = config.get("clientName") + if not clientName: + raise ValueError("Missing required config: clientName") + return f"{baseUrl}/{clientName}/{entity}" async def _buildAuthHeaders(self, config: Dict[str, Any]) -> Optional[Dict[str, str]]: token = await self._getAccessToken(config) @@ -130,6 +147,19 @@ class AccountingConnectorAbacus(BaseAccountingConnector): return {"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"} async def testConnection(self, config: Dict[str, Any]) -> SyncResult: + apiBaseUrl = str(config.get("apiBaseUrl") or "") + clientName = str(config.get("clientName") or "") + clientId = str(config.get("clientId") or "") + clientSecret = str(config.get("clientSecret") or "") + if not apiBaseUrl or not clientName or not clientId or not clientSecret: + return SyncResult( + success=False, + errorMessage=( + f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, " + f"clientName={bool(clientName)}, clientId={bool(clientId)}, " + f"clientSecret={bool(clientSecret)}" + ), + ) headers = await self._buildAuthHeaders(config) if not headers: return SyncResult(success=False, errorMessage="Failed to obtain access token") diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py index ec60d761..a5487a82 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py @@ -24,7 +24,7 @@ from ..accountingConnectorBase import ( logger = logging.getLogger(__name__) -_BASE_URL = "https://api.bexio.com" +_DEFAULT_API_BASE_URL = "https://api.bexio.com/" class AccountingConnectorBexio(BaseAccountingConnector): @@ -40,6 +40,20 @@ class AccountingConnectorBexio(BaseAccountingConnector): def getRequiredConfigFields(self) -> List[ConnectorConfigField]: return [ + ConnectorConfigField( + key="apiBaseUrl", + label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"}, + fieldType="text", + secret=False, + placeholder="https://api.bexio.com/", + ), + ConnectorConfigField( + key="clientName", + label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"}, + fieldType="text", + secret=False, + placeholder="e.g. poweronag", + ), ConnectorConfigField( key="accessToken", label={"en": "Personal Access Token", "de": "Persönlicher Zugriffstoken", "fr": "Jeton d'accès personnel"}, @@ -49,6 +63,14 @@ class AccountingConnectorBexio(BaseAccountingConnector): ), ] + def _buildUrl(self, config: Dict[str, Any], resource: str) -> str: + apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() + if not apiBaseUrl: + raise ValueError("Missing required config: apiBaseUrl") + apiBaseUrl = apiBaseUrl.rstrip("/") + resourcePath = resource.lstrip("/") + return f"{apiBaseUrl}/{resourcePath}" + def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]: return { "Authorization": f"Bearer {config['accessToken']}", @@ -57,9 +79,20 @@ class AccountingConnectorBexio(BaseAccountingConnector): } async def testConnection(self, config: Dict[str, Any]) -> SyncResult: + apiBaseUrl = str(config.get("apiBaseUrl") or "") + clientName = str(config.get("clientName") or "") + accessToken = str(config.get("accessToken") or "") + if not apiBaseUrl or not clientName or not accessToken: + return SyncResult( + success=False, + errorMessage=( + f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, " + f"clientName={bool(clientName)}, accessToken={bool(accessToken)}" + ), + ) try: async with aiohttp.ClientSession() as session: - async with session.get(f"{_BASE_URL}/3.0/users/me", headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp: + async with session.get(self._buildUrl(config, "3.0/users/me"), headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status == 200: return SyncResult(success=True) body = await resp.text() @@ -75,7 +108,7 @@ class AccountingConnectorBexio(BaseAccountingConnector): try: async with aiohttp.ClientSession() as session: - async with session.get(f"{_BASE_URL}/2.0/accounts", headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp: + async with session.get(self._buildUrl(config, "2.0/accounts"), headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: return [] accounts = await resp.json() @@ -139,7 +172,7 @@ class AccountingConnectorBexio(BaseAccountingConnector): } async with aiohttp.ClientSession() as session: - url = f"{_BASE_URL}/3.0/accounting/manual-entries" + url = self._buildUrl(config, "3.0/accounting/manual-entries") async with session.post(url, headers=self._buildHeaders(config), json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp: body = await resp.json() if resp.content_type == "application/json" else {"raw": await resp.text()} if resp.status in (200, 201): @@ -152,7 +185,7 @@ class AccountingConnectorBexio(BaseAccountingConnector): async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult: try: async with aiohttp.ClientSession() as session: - url = f"{_BASE_URL}/3.0/accounting/manual-entries/{externalId}" + url = self._buildUrl(config, f"3.0/accounting/manual-entries/{externalId}") async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status == 200: return SyncResult(success=True, externalId=externalId) @@ -163,7 +196,7 @@ class AccountingConnectorBexio(BaseAccountingConnector): async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]: try: async with aiohttp.ClientSession() as session: - async with session.get(f"{_BASE_URL}/2.0/contact", headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp: + async with session.get(self._buildUrl(config, "2.0/contact"), headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: return [] return await resp.json() diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py index 4ec1ebd7..fa93ff40 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py @@ -3,7 +3,8 @@ """Run My Accounts (Infoniqa) accounting connector. API docs: https://runmyaccountsag.github.io/runmyaccounts-rest-api/ -Auth: API key (incl. ``pat_`` tokens since Sep 2025) via ``X-RMA-KEY`` request header. +Auth: PAT tokens (``pat_...``) via ``Authorization: Bearer``. +Fallback for legacy API keys via ``X-RMA-KEY``. Base URL: https://service.runmyaccounts.com/api/latest/clients/{clientName}/ """ @@ -26,7 +27,7 @@ from ..accountingConnectorBase import ( logger = logging.getLogger(__name__) -_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients" +_DEFAULT_API_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients/" class AccountingConnectorRma(BaseAccountingConnector): @@ -39,6 +40,13 @@ class AccountingConnectorRma(BaseAccountingConnector): def getRequiredConfigFields(self) -> List[ConnectorConfigField]: return [ + ConnectorConfigField( + key="apiBaseUrl", + label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"}, + fieldType="text", + secret=False, + placeholder="https://service.runmyaccounts.com/api/latest/clients/", + ), ConnectorConfigField( key="clientName", label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"}, @@ -55,33 +63,55 @@ class AccountingConnectorRma(BaseAccountingConnector): ] def _buildUrl(self, config: Dict[str, Any], resource: str) -> str: - clientName = config.get("clientName", "") - return f"{_BASE_URL}/{clientName}/{resource}" + apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() + if not apiBaseUrl: + raise ValueError("Missing required config: apiBaseUrl") + apiBaseUrl = apiBaseUrl.rstrip("/") + "/" + + clientName = str(config.get("clientName") or "").strip() + if not clientName: + raise ValueError("Missing required config: clientName") + return f"{apiBaseUrl}{clientName}/{resource}" def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]: apiKey = config.get("apiKey", "") - return { - "X-RMA-KEY": apiKey, + headers = { "Accept": "application/json, application/xml, */*", "Content-Type": "application/json", } + if str(apiKey).startswith("pat_"): + headers["Authorization"] = f"Bearer {apiKey}" + else: + headers["X-RMA-KEY"] = apiKey + return headers async def testConnection(self, config: Dict[str, Any]) -> SyncResult: clientName = config.get("clientName", "") apiKey = config.get("apiKey", "") - if not clientName or not apiKey: - return SyncResult(success=False, errorMessage=f"Missing credentials: clientName={bool(clientName)}, apiKey={bool(apiKey)}") + apiBaseUrl = str(config.get("apiBaseUrl") or "") + if not clientName or not apiKey or not apiBaseUrl: + return SyncResult( + success=False, + errorMessage=( + f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, " + f"clientName={bool(clientName)}, apiKey={bool(apiKey)}" + ), + ) url = self._buildUrl(config, "customers") headers = self._buildHeaders(config) - logger.info("RMA testConnection: url=%s, clientName=%s, apiKey=%s...", url, clientName, apiKey[:6] if len(apiKey) > 6 else "***") + authMethod = "Bearer" if str(apiKey).startswith("pat_") else "X-RMA-KEY" + logger.info( + "RMA testConnection: url=%s, clientName=%s, apiKey=%s..., auth=%s", + url, clientName, apiKey[:6] if len(apiKey) > 6 else "***", authMethod, + ) try: async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status == 200: - logger.info("RMA connection successful") - return SyncResult(success=True) + logger.info("RMA connection successful with auth method: %s", authMethod) + return SyncResult(success=True, rawResponse={"authMethod": authMethod}) body = await resp.text() logger.warning("RMA testConnection failed: status=%s, url=%s, body=%s", resp.status, url, body[:500]) return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}") diff --git a/modules/features/workspace/__init__.py b/modules/features/workspace/__init__.py index e4d7dac9..2e48ea1c 100644 --- a/modules/features/workspace/__init__.py +++ b/modules/features/workspace/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""Unified AI Workspace feature -- merges Codeeditor, Chatbot, and Playground.""" +"""Unified AI Workspace feature.""" diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py index 0737bf13..5da67a45 100644 --- a/modules/features/workspace/mainWorkspace.py +++ b/modules/features/workspace/mainWorkspace.py @@ -3,7 +3,7 @@ """ Workspace Feature Container - Main Module. Handles feature initialization and RBAC catalog registration. -Unified AI Workspace combining Codeeditor, Chatbot, and Playground capabilities. +Unified AI Workspace feature. """ import logging @@ -21,6 +21,11 @@ UI_OBJECTS = [ "label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, "meta": {"area": "dashboard"} }, + { + "objectKey": "ui.feature.workspace.editor", + "label": {"en": "Editor", "de": "Editor", "fr": "Editeur"}, + "meta": {"area": "editor"} + }, { "objectKey": "ui.feature.workspace.settings", "label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"}, @@ -59,6 +64,11 @@ RESOURCE_OBJECTS = [ "label": {"en": "Voice Input/Output", "de": "Spracheingabe/-ausgabe", "fr": "Entree/sortie vocale"}, "meta": {"endpoint": "/api/workspace/{instanceId}/voice/*", "method": "POST"} }, + { + "objectKey": "resource.feature.workspace.edits", + "label": {"en": "Review File Edits", "de": "Datei-Aenderungen pruefen", "fr": "Verifier les modifications de fichiers"}, + "meta": {"endpoint": "/api/workspace/{instanceId}/edit/*", "method": "POST"} + }, ] TEMPLATE_ROLES = [ @@ -71,6 +81,7 @@ TEMPLATE_ROLES = [ }, "accessRules": [ {"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.workspace.editor", "view": True}, {"context": "UI", "item": "ui.feature.workspace.settings", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, ] @@ -84,6 +95,7 @@ TEMPLATE_ROLES = [ }, "accessRules": [ {"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.workspace.editor", "view": True}, {"context": "UI", "item": "ui.feature.workspace.settings", "view": True}, {"context": "RESOURCE", "item": "resource.feature.workspace.start", "view": True}, {"context": "RESOURCE", "item": "resource.feature.workspace.stop", "view": True}, @@ -91,6 +103,7 @@ TEMPLATE_ROLES = [ {"context": "RESOURCE", "item": "resource.feature.workspace.folders", "view": True}, {"context": "RESOURCE", "item": "resource.feature.workspace.datasources", "view": True}, {"context": "RESOURCE", "item": "resource.feature.workspace.voice", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.workspace.edits", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, ] }, diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 7a5a92a7..1ea8a93a 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -2,14 +2,13 @@ # All rights reserved. """Unified AI Workspace routes. -SSE-based endpoints that combine the capabilities of Codeeditor, Chatbot, -and Playground into a single agent-driven workspace. +SSE-based endpoints for the agent-driven AI Workspace. """ import logging import json import asyncio -from typing import Dict, Optional, List +from typing import Any, Dict, Optional, List from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request, UploadFile, File from fastapi.responses import StreamingResponse, JSONResponse @@ -19,7 +18,7 @@ from modules.auth import limiter, getRequestContext, RequestContext from modules.interfaces import interfaceDbChat, interfaceDbManagement from modules.interfaces.interfaceAiObjects import AiObjects from modules.serviceCenter.core.serviceStreaming import get_event_manager -from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum +from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit logger = logging.getLogger(__name__) @@ -32,6 +31,41 @@ router = APIRouter( _aiObjects: Optional[AiObjects] = None +class _InstanceEdits: + """Pending file edits for a single workspace instance.""" + + def __init__(self): + self._edits: Dict[str, PendingFileEdit] = {} + + def add(self, edit: PendingFileEdit) -> None: + self._edits[edit.id] = edit + + def get(self, editId: str) -> Optional[PendingFileEdit]: + return self._edits.get(editId) + + def getPending(self) -> List[PendingFileEdit]: + return [e for e in self._edits.values() if e.status == "pending"] + + def items(self): + return self._edits.items() + + +class _PendingEditsStore: + """Global store for pending file edits across all workspace instances.""" + + def __init__(self): + self._instances: Dict[str, _InstanceEdits] = {} + + def forInstance(self, instanceId: str) -> _InstanceEdits: + """Get-or-create the edit collection for a workspace instance.""" + if instanceId not in self._instances: + self._instances[instanceId] = _InstanceEdits() + return self._instances[instanceId] + + +_pendingEditsStore = _PendingEditsStore() + + class WorkspaceInputRequest(BaseModel): """Prompt input for the unified workspace.""" prompt: str = Field(description="User prompt text") @@ -41,6 +75,7 @@ class WorkspaceInputRequest(BaseModel): voiceMode: bool = Field(default=False, description="Enable voice response") workflowId: Optional[str] = Field(default=None, description="Continue existing workflow") userLanguage: str = Field(default="en", description="User language code") + allowedProviders: List[str] = Field(default_factory=list, description="Restrict AI to these providers") async def _getAiObjects() -> AiObjects: @@ -50,7 +85,8 @@ async def _getAiObjects() -> AiObjects: return _aiObjects -def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: +def _validateInstanceAccess(instanceId: str, context: RequestContext): + """Validate access and return (mandateId, instanceConfig) tuple.""" from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() instance = rootInterface.getFeatureInstance(instanceId) @@ -59,7 +95,9 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId) if not featureAccess or not featureAccess.enabled: raise HTTPException(status_code=403, detail="Access denied to this feature instance") - return str(instance.mandateId) if instance.mandateId else None + mandateId = str(instance.mandateId) if instance.mandateId else None + instanceConfig = instance.config if hasattr(instance, "config") and instance.config else {} + return mandateId, instanceConfig def _getChatInterface(context: RequestContext, featureInstanceId: str = None): @@ -218,7 +256,7 @@ async def streamWorkspaceStart( context: RequestContext = Depends(getRequestContext), ): """Start or continue a Workspace session with SSE streaming via serviceAgent.""" - mandateId = _validateInstanceAccess(instanceId, context) + mandateId, instanceConfig = _validateInstanceAccess(instanceId, context) chatInterface = _getChatInterface(context, featureInstanceId=instanceId) aiObjects = await _getAiObjects() eventManager = get_event_manager() @@ -260,6 +298,8 @@ async def streamWorkspaceStart( chatInterface=chatInterface, eventManager=eventManager, userLanguage=userInput.userLanguage, + instanceConfig=instanceConfig, + allowedProviders=userInput.allowedProviders, ) ) eventManager.register_agent_task(queueId, agentTask) @@ -312,6 +352,8 @@ async def _runWorkspaceAgent( chatInterface, eventManager, userLanguage: str = "en", + instanceConfig: Dict[str, Any] = None, + allowedProviders: List[str] = None, ): """Run the serviceAgent loop and forward events to the SSE queue.""" try: @@ -327,6 +369,9 @@ async def _runWorkspaceAgent( chatService = getService("chat", ctx) aiService = getService("ai", ctx) + if allowedProviders: + aiService.services.allowedProviders = allowedProviders + wfRecord = chatInterface.getWorkflow(workflowId) if workflowId else None wfName = "" if wfRecord: @@ -356,12 +401,20 @@ async def _runWorkspaceAgent( accumulatedText = "" messagePersisted = False + _cfg = instanceConfig or {} + _toolSet = _cfg.get("toolSet", "core") + _agentCfg = _cfg.get("agentConfig") + from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentConfig + agentConfig = AgentConfig(**_agentCfg) if isinstance(_agentCfg, dict) else None + async for event in agentService.runAgent( prompt=enrichedPrompt, fileIds=fileIds, workflowId=workflowId, userLanguage=userLanguage, conversationHistory=conversationHistory, + toolSet=_toolSet, + config=agentConfig, ): if eventManager.is_cancelled(queueId): logger.info(f"Agent cancelled by user for workflow {workflowId}") @@ -370,6 +423,35 @@ async def _runWorkspaceAgent( if event.type == AgentEventTypeEnum.CHUNK and event.content: accumulatedText += event.content + if event.type == AgentEventTypeEnum.FILE_EDIT_PROPOSAL and event.data: + editData = event.data + editId = editData.get("id", "") + if editId: + pendingEdit = PendingFileEdit( + id=editId, + fileId=editData.get("fileId", ""), + fileName=editData.get("fileName", ""), + mimeType=editData.get("mimeType", ""), + oldContent=editData.get("oldContent", ""), + newContent=editData.get("newContent", ""), + workflowId=workflowId, + ) + _pendingEditsStore.forInstance(instanceId).add(pendingEdit) + logger.info(f"Stored pending edit {editId} for file '{pendingEdit.fileName}' in instance {instanceId}") + await eventManager.emit_event(queueId, "fileEditProposal", { + "type": "fileEditProposal", + "workflowId": workflowId, + "item": { + "id": editId, + "fileId": editData.get("fileId", ""), + "fileName": editData.get("fileName", ""), + "mimeType": editData.get("mimeType", ""), + "oldSize": len(editData.get("oldContent", "")), + "newSize": len(editData.get("newContent", "")), + }, + }) + continue + sseEvent = { "type": event.type.value if hasattr(event.type, "value") else event.type, "workflowId": workflowId, @@ -1034,7 +1116,7 @@ async def getVoiceLanguages( context: RequestContext = Depends(getRequestContext), ): """Return available TTS languages.""" - mandateId = _validateInstanceAccess(instanceId, context) + mandateId, _ = _validateInstanceAccess(instanceId, context) from modules.interfaces.interfaceVoiceObjects import getVoiceInterface voiceInterface = getVoiceInterface(context.user, mandateId) languagesResult = await voiceInterface.getAvailableLanguages() @@ -1051,7 +1133,7 @@ async def getVoiceVoices( context: RequestContext = Depends(getRequestContext), ): """Return available TTS voices for a given language.""" - mandateId = _validateInstanceAccess(instanceId, context) + mandateId, _ = _validateInstanceAccess(instanceId, context) from modules.interfaces.interfaceVoiceObjects import getVoiceInterface voiceInterface = getVoiceInterface(context.user, mandateId) voicesResult = await voiceInterface.getAvailableVoices(language) @@ -1069,7 +1151,7 @@ async def testVoice( ): """Test a specific voice with a sample text.""" import base64 - mandateId = _validateInstanceAccess(instanceId, context) + mandateId, _ = _validateInstanceAccess(instanceId, context) text = body.get("text", "Hallo, das ist ein Stimmtest.") language = body.get("language", "de-DE") voiceId = body.get("voiceId") @@ -1090,3 +1172,137 @@ async def testVoice( except Exception as e: logger.error(f"Voice test failed: {e}") raise HTTPException(status_code=500, detail=f"TTS test failed: {str(e)}") + + +# ============================================================================= +# FILE EDIT PROPOSAL ENDPOINTS +# ============================================================================= + + +@router.get("/{instanceId}/pending-edits") +@limiter.limit("30/minute") +async def getPendingEdits( + request: Request, + instanceId: str = Path(...), + context: RequestContext = Depends(getRequestContext), +): + """Return all pending file edit proposals for this workspace instance.""" + _validateInstanceAccess(instanceId, context) + editList = [e.model_dump() for e in _pendingEditsStore.forInstance(instanceId).getPending()] + return JSONResponse({"edits": editList}) + + +@router.post("/{instanceId}/edit/{editId}/accept") +@limiter.limit("30/minute") +async def acceptEdit( + request: Request, + instanceId: str = Path(...), + editId: str = Path(...), + context: RequestContext = Depends(getRequestContext), +): + """Accept a proposed file edit -- applies the new content to the file.""" + _validateInstanceAccess(instanceId, context) + edit = _pendingEditsStore.forInstance(instanceId).get(editId) + if not edit: + raise HTTPException(status_code=404, detail=f"Edit proposal {editId} not found") + if edit.status != "pending": + raise HTTPException(status_code=409, detail=f"Edit proposal is already {edit.status}") + + dbMgmt = _getDbManagement(context, instanceId) + try: + success = dbMgmt.updateFileData(edit.fileId, edit.newContent.encode("utf-8")) + if not success: + raise HTTPException(status_code=500, detail="Failed to update file data") + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to apply edit {editId}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to apply edit: {str(e)}") + + edit.status = "accepted" + logger.info(f"Edit {editId} accepted for file '{edit.fileName}' in instance {instanceId}") + return JSONResponse({ + "success": True, + "editId": editId, + "fileId": edit.fileId, + "fileName": edit.fileName, + }) + + +@router.post("/{instanceId}/edit/{editId}/reject") +@limiter.limit("30/minute") +async def rejectEdit( + request: Request, + instanceId: str = Path(...), + editId: str = Path(...), + context: RequestContext = Depends(getRequestContext), +): + """Reject a proposed file edit -- discards the change.""" + _validateInstanceAccess(instanceId, context) + edit = _pendingEditsStore.forInstance(instanceId).get(editId) + if not edit: + raise HTTPException(status_code=404, detail=f"Edit proposal {editId} not found") + if edit.status != "pending": + raise HTTPException(status_code=409, detail=f"Edit proposal is already {edit.status}") + + edit.status = "rejected" + logger.info(f"Edit {editId} rejected for file '{edit.fileName}' in instance {instanceId}") + return JSONResponse({ + "success": True, + "editId": editId, + "fileId": edit.fileId, + "fileName": edit.fileName, + }) + + +@router.post("/{instanceId}/edit/accept-all") +@limiter.limit("10/minute") +async def acceptAllEdits( + request: Request, + instanceId: str = Path(...), + context: RequestContext = Depends(getRequestContext), +): + """Accept all pending file edit proposals for this instance.""" + _validateInstanceAccess(instanceId, context) + instanceEdits = _pendingEditsStore.forInstance(instanceId) + dbMgmt = _getDbManagement(context, instanceId) + accepted = [] + errors = [] + + for editId, edit in instanceEdits.items(): + if edit.status != "pending": + continue + try: + success = dbMgmt.updateFileData(edit.fileId, edit.newContent.encode("utf-8")) + if success: + edit.status = "accepted" + accepted.append(editId) + else: + errors.append({"editId": editId, "error": "updateFileData returned False"}) + except Exception as e: + errors.append({"editId": editId, "error": str(e)}) + + logger.info(f"Accepted {len(accepted)} edits for instance {instanceId}, {len(errors)} errors") + return JSONResponse({"accepted": accepted, "errors": errors}) + + +@router.post("/{instanceId}/edit/reject-all") +@limiter.limit("10/minute") +async def rejectAllEdits( + request: Request, + instanceId: str = Path(...), + context: RequestContext = Depends(getRequestContext), +): + """Reject all pending file edit proposals for this instance.""" + _validateInstanceAccess(instanceId, context) + instanceEdits = _pendingEditsStore.forInstance(instanceId) + rejected = [] + + for editId, edit in instanceEdits.items(): + if edit.status != "pending": + continue + edit.status = "rejected" + rejected.append(editId) + + logger.info(f"Rejected {len(rejected)} edits for instance {instanceId}") + return JSONResponse({"rejected": rejected}) diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py index d53c9b5a..981a6b46 100644 --- a/modules/interfaces/interfaceAiObjects.py +++ b/modules/interfaces/interfaceAiObjects.py @@ -12,6 +12,7 @@ logger = logging.getLogger(__name__) from modules.aicore.aicoreModelRegistry import modelRegistry from modules.aicore.aicoreModelSelector import modelSelector +from modules.aicore.aicoreBase import RateLimitExceededException from modules.datamodels.datamodelAi import ( AiModel, AiCallOptions, @@ -97,15 +98,18 @@ class AiObjects: # Get failover models for this operation type availableModels = modelRegistry.getAvailableModels() - # Filter by allowedProviders if specified (from workflow config) allowedProviders = getattr(options, 'allowedProviders', None) if options else None if allowedProviders: filteredModels = [m for m in availableModels if m.connectorType in allowedProviders] if filteredModels: - logger.info(f"Filtered models by allowedProviders {allowedProviders}: {len(filteredModels)} models (from {len(availableModels)})") availableModels = filteredModels else: - logger.warning(f"No models match allowedProviders {allowedProviders}, using all {len(availableModels)} available models") + errorMsg = f"No models match allowedProviders {allowedProviders} for operation {options.operationType}" + logger.error(errorMsg) + return AiCallResponse( + content=errorMsg, modelName="error", priceCHF=0.0, + processingTime=0.0, bytesSent=0, bytesReceived=0, errorCount=1, + ) failoverModelList = modelSelector.getFailoverModelList(prompt, context, options, availableModels) @@ -122,7 +126,8 @@ class AiObjects: errorCount=1 ) - # Try each model in failover sequence + _MAX_SHORT_RETRY = 15.0 + lastError = None for attempt, model in enumerate(failoverModelList): try: @@ -135,7 +140,32 @@ class AiObjects: logger.info(f"AI call successful with model: {model.name}") return response - + + except RateLimitExceededException as rle: + retryAfter = rle.retryAfterSeconds + lastError = rle + if 0 < retryAfter <= _MAX_SHORT_RETRY: + logger.info(f"Rate limit on {model.name}, waiting {retryAfter:.1f}s before retry") + await asyncio.sleep(retryAfter + 0.5) + try: + if request.messages: + response = await self._callWithMessages(model, request.messages, options, request.tools) + else: + response = await self._callWithModel(model, prompt, context, options) + logger.info(f"AI call successful with {model.name} after rate-limit retry") + return response + except Exception as retryErr: + lastError = retryErr + logger.warning(f"Retry after rate-limit wait also failed for {model.name}: {retryErr}") + else: + logger.warning(f"Rate limit on {model.name} (retryAfter={retryAfter:.1f}s), failing over") + cooldown = max(retryAfter, 10.0) if retryAfter > 0 else 0.0 + modelSelector.reportFailure(model.name, cooldownSeconds=cooldown) + if attempt < len(failoverModelList) - 1: + continue + logger.error(f"All {len(failoverModelList)} models failed for operation {options.operationType}") + break + except Exception as e: lastError = e logger.warning(f"AI call failed with model {model.name}: {str(e)}") @@ -323,6 +353,13 @@ class AiObjects: filtered = [m for m in availableModels if m.connectorType in allowedProviders] if filtered: availableModels = filtered + else: + yield AiCallResponse( + content=f"No models match allowedProviders {allowedProviders} for operation {options.operationType}", + modelName="error", priceCHF=0.0, processingTime=0.0, + bytesSent=0, bytesReceived=0, errorCount=1, + ) + return failoverModelList = modelSelector.getFailoverModelList( request.prompt, request.context or "", options, availableModels @@ -335,6 +372,8 @@ class AiObjects: ) return + _MAX_SHORT_RETRY = 15.0 + lastError = None for attempt, model in enumerate(failoverModelList): try: @@ -342,6 +381,28 @@ class AiObjects: async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools): yield chunk return + + except RateLimitExceededException as rle: + retryAfter = rle.retryAfterSeconds + lastError = rle + if 0 < retryAfter <= _MAX_SHORT_RETRY: + logger.info(f"Rate limit on {model.name}, waiting {retryAfter:.1f}s before retry") + await asyncio.sleep(retryAfter + 0.5) + try: + async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools): + yield chunk + return + except Exception as retryErr: + lastError = retryErr + logger.warning(f"Retry after rate-limit wait also failed for {model.name}: {retryErr}") + else: + logger.warning(f"Rate limit on {model.name} (retryAfter={retryAfter:.1f}s), failing over") + cooldown = max(retryAfter, 10.0) if retryAfter > 0 else 0.0 + modelSelector.reportFailure(model.name, cooldownSeconds=cooldown) + if attempt < len(failoverModelList) - 1: + continue + break + except Exception as e: lastError = e logger.warning(f"Streaming AI call failed with {model.name}: {e}") @@ -421,12 +482,18 @@ class AiObjects: async def callEmbedding(self, texts: List[str], options: AiCallOptions = None) -> AiCallResponse: """Generate embeddings for a list of texts using the best available embedding model. - Uses the standard model selector with OperationTypeEnum.EMBEDDING to pick the model. - Failover across providers (OpenAI → Mistral) works identically to chat models. + Token-aware batching: splits the texts list into batches that respect the + model's contextLength (with 10% safety margin). Each batch is sent as a + separate API call; the resulting embeddings are merged in order. + + Failover across providers (OpenAI -> Mistral) works identically to chat models, + but ContextLengthExceededException is NOT retried via failover (same limits). Returns: AiCallResponse with metadata["embeddings"] containing the vectors. """ + from modules.aicore.aicoreBase import ContextLengthExceededException as _CtxExc + if options is None: options = AiCallOptions(operationType=OperationTypeEnum.EMBEDDING) else: @@ -434,6 +501,15 @@ class AiObjects: combinedText = " ".join(texts[:3])[:500] availableModels = modelRegistry.getAvailableModels() + + allowedProviders = getattr(options, 'allowedProviders', None) if options else None + if allowedProviders: + filtered = [m for m in availableModels if m.connectorType in allowedProviders] + if filtered: + availableModels = filtered + else: + logger.warning(f"No embedding models match allowedProviders {allowedProviders}") + failoverModelList = modelSelector.getFailoverModelList( combinedText, "", options, availableModels ) @@ -451,23 +527,39 @@ class AiObjects: inputBytes = sum(len(t.encode("utf-8")) for t in texts) startTime = time.time() - modelCall = AiModelCall( - model=model, options=options, embeddingInput=texts + batches = _buildEmbeddingBatches(texts, model.contextLength) + logger.info( + f"Embedding: {len(texts)} texts -> {len(batches)} batch(es), " + f"model contextLength={model.contextLength}" ) - modelResponse = await model.functionCall(modelCall) - if not modelResponse.success: - raise ValueError(f"Embedding call failed: {modelResponse.error}") + allEmbeddings: List[List[float]] = [] + totalPriceCHF = 0.0 + + for batchIdx, batch in enumerate(batches): + modelCall = AiModelCall( + model=model, options=options, embeddingInput=batch + ) + modelResponse = await model.functionCall(modelCall) + + if not modelResponse.success: + raise ValueError(f"Embedding batch {batchIdx + 1} failed: {modelResponse.error}") + + batchEmbeddings = (modelResponse.metadata or {}).get("embeddings", []) + allEmbeddings.extend(batchEmbeddings) + + batchBytes = sum(len(t.encode("utf-8")) for t in batch) + totalPriceCHF += model.calculatepriceCHF(0, batchBytes, 0) processingTime = time.time() - startTime - priceCHF = model.calculatepriceCHF(processingTime, inputBytes, 0) - embeddings = (modelResponse.metadata or {}).get("embeddings", []) + if totalPriceCHF == 0.0: + totalPriceCHF = model.calculatepriceCHF(processingTime, inputBytes, 0) response = AiCallResponse( content="", modelName=model.name, provider=model.connectorType, - priceCHF=priceCHF, processingTime=processingTime, + priceCHF=totalPriceCHF, processingTime=processingTime, bytesSent=inputBytes, bytesReceived=0, errorCount=0, - metadata={"embeddings": embeddings} + metadata={"embeddings": allEmbeddings} ) if self.billingCallback: @@ -478,6 +570,23 @@ class AiObjects: return response + except _CtxExc as e: + logger.error(f"ContextLengthExceeded for {model.name} despite batching – aborting failover: {e}") + return AiCallResponse( + content=str(e), modelName=model.name, priceCHF=0.0, + processingTime=0.0, bytesSent=0, bytesReceived=0, errorCount=1 + ) + + except RateLimitExceededException as rle: + retryAfter = rle.retryAfterSeconds + lastError = rle + cooldown = max(retryAfter, 10.0) if retryAfter > 0 else 0.0 + logger.warning(f"Rate limit on {model.name} during embedding (retryAfter={retryAfter:.1f}s)") + modelSelector.reportFailure(model.name, cooldownSeconds=cooldown) + if attempt < len(failoverModelList) - 1: + continue + break + except Exception as e: lastError = e logger.warning(f"Embedding call failed with {model.name}: {str(e)}") @@ -514,4 +623,50 @@ class AiObjects: return [model.displayName for model in models] +# ============================================================================= +# Internal helpers +# ============================================================================= + +_CHARS_PER_TOKEN = 4 +_SAFETY_MARGIN = 0.90 + + +def _estimateTokens(text: str) -> int: + """Rough token estimate: 1 token ~ 4 characters.""" + return max(1, len(text) // _CHARS_PER_TOKEN) + + +def _buildEmbeddingBatches(texts: List[str], contextLength: int) -> List[List[str]]: + """Split a list of texts into batches whose total estimated token count + stays within the model's contextLength (with safety margin). + + Each individual text is assumed to already be within limits (enforced by + the chunking layer). If a single text exceeds the budget, it is placed + in its own batch as a last resort. + """ + if not texts: + return [] + if contextLength <= 0: + return [texts] + + maxTokensPerBatch = int(contextLength * _SAFETY_MARGIN) + batches: List[List[str]] = [] + currentBatch: List[str] = [] + currentTokens = 0 + + for text in texts: + textTokens = _estimateTokens(text) + if currentBatch and (currentTokens + textTokens) > maxTokensPerBatch: + batches.append(currentBatch) + currentBatch = [] + currentTokens = 0 + currentBatch.append(text) + currentTokens += textTokens + + if currentBatch: + batches.append(currentBatch) + + return batches + + diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 609dd61b..63978165 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -96,6 +96,9 @@ def initBootstrap(db: DatabaseConnector) -> None: if mandateId: initRootMandateFeatures(db, mandateId) + # Remove feature instances for features that no longer exist in the codebase + _cleanupRemovedFeatureInstances(db) + # Initialize billing settings for root mandate if mandateId: initRootMandateBilling(mandateId) @@ -257,6 +260,33 @@ def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None: logger.info("Root mandate features initialization completed") +def _cleanupRemovedFeatureInstances(db: DatabaseConnector) -> None: + """Remove feature instances whose featureCode no longer exists in the codebase.""" + from modules.datamodels.datamodelFeatures import FeatureInstance + from modules.system.registry import loadFeatureMainModules + + mainModules = loadFeatureMainModules() + activeCodes = set() + for featureName, module in mainModules.items(): + if hasattr(module, "getFeatureDefinition"): + try: + featureDef = module.getFeatureDefinition() + activeCodes.add(featureDef.get("code", featureName)) + except Exception: + pass + + allInstances = db.getRecordset(FeatureInstance) + for inst in allInstances: + code = inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", None) + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + if code and code not in activeCodes: + try: + db.recordDelete(FeatureInstance, str(instId)) + logger.info(f"Removed orphaned feature instance '{instId}' (featureCode='{code}')") + except Exception as e: + logger.warning(f"Could not remove orphaned feature instance '{instId}': {e}") + + def initRootMandate(db: DatabaseConnector) -> Optional[str]: """ Creates the Root mandate if it doesn't exist. @@ -443,7 +473,7 @@ def initRoles(db: DatabaseConnector) -> None: # Check specifically for system template roles: # mandateId=NULL, isSystemRole=True, featureCode=NULL - # Feature templates (e.g. chatplayground admin) share the same labels but have featureCode set! + # Feature templates (e.g. automation admin) share the same labels but have featureCode set! allTemplates = db.getRecordset( Role, recordFilter={"mandateId": None, "isSystemRole": True} @@ -475,7 +505,7 @@ def _deduplicateRoles(db: DatabaseConnector) -> None: # Group by (roleLabel, mandateId, featureInstanceId, featureCode) # featureCode is essential: system template ('admin', None, None, None) - # must NOT be grouped with feature template ('admin', None, None, 'chatplayground') + # must NOT be grouped with feature template ('admin', None, None, 'automation') groups: dict = {} for role in allRoles: key = (role.get("roleLabel"), role.get("mandateId"), role.get("featureInstanceId"), role.get("featureCode")) @@ -1931,8 +1961,6 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None: """ storeResources = [ "resource.store.automation", - "resource.store.chatplayground", - "resource.store.codeeditor", "resource.store.teamsbot", ] diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 08c43189..3075966a 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -679,6 +679,29 @@ class BillingObjects: """ record = StripeWebhookEvent(event_id=event_id) return self.db.recordCreate(StripeWebhookEvent, record.model_dump()) + + def getPaymentTransactionByReferenceId(self, referenceId: str) -> Optional[Dict[str, Any]]: + """ + Find an existing Stripe payment credit transaction by Checkout Session ID. + + Args: + referenceId: Stripe Checkout Session ID (cs_xxx) + + Returns: + Transaction record if found, else None + """ + try: + results = self.db.getRecordset( + BillingTransaction, + recordFilter={ + "referenceType": ReferenceTypeEnum.PAYMENT.value, + "referenceId": referenceId, + } + ) + return results[0] if results else None + except Exception as e: + logger.error(f"Error checking Stripe payment transaction by referenceId: {e}") + return None # ========================================================================= # Balance Check Operations diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py index e15f19c2..fa83f1c8 100644 --- a/modules/interfaces/interfaceDbKnowledge.py +++ b/modules/interfaces/interfaceDbKnowledge.py @@ -195,6 +195,19 @@ class KnowledgeObjects: if contentType: recordFilter["contentType"] = contentType + if isShared and mandateId: + sharedIndexes = self.db.getRecordset( + FileContentIndex, + recordFilter={"mandateId": mandateId, "isShared": True}, + ) + sharedFileIds = [idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None) for idx in sharedIndexes] + sharedFileIds = [fid for fid in sharedFileIds if fid] + if not sharedFileIds: + return [] + recordFilter.pop("userId", None) + recordFilter.pop("featureInstanceId", None) + recordFilter["fileId"] = sharedFileIds + return self.db.semanticSearch( modelClass=ContentChunk, vectorColumn="embedding", diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index e065bf6d..4ddcb972 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -10,6 +10,7 @@ import logging import base64 import hashlib import math +import mimetypes from typing import Dict, Any, List, Optional, Union from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector @@ -18,6 +19,7 @@ from modules.security.rbac import RbacClass from modules.datamodels.datamodelRbac import AccessRuleContext from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelFiles import FilePreview, FileItem, FileData +from modules.datamodels.datamodelFileFolder import FileFolder from modules.datamodels.datamodelUtils import Prompt from modules.datamodels.datamodelVoice import VoiceSettings from modules.datamodels.datamodelMessaging import ( @@ -851,7 +853,9 @@ class ComponentObjects: "svg": "image/svg+xml", "py": "text/x-python", "js": "application/javascript", - "css": "text/css" + "css": "text/css", + "eml": "message/rfc822", + "msg": "application/vnd.ms-outlook", } return extensionToMime.get(ext.lower(), "application/octet-stream") @@ -1143,6 +1147,350 @@ class ComponentObjects: logger.error(f"Error deleting file {fileId}: {str(e)}") raise FileDeletionError(f"Error deleting file: {str(e)}") + def deleteFilesBatch(self, fileIds: List[str]) -> Dict[str, Any]: + """Delete multiple files in a single SQL batch call.""" + uniqueIds = [str(fid) for fid in dict.fromkeys(fileIds or []) if fid] + if not uniqueIds: + return {"deletedFiles": 0} + + try: + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s', + (uniqueIds, self.userId or ""), + ) + accessibleIds = [row["id"] for row in cursor.fetchall()] + + if len(accessibleIds) != len(uniqueIds): + missingIds = sorted(set(uniqueIds) - set(accessibleIds)) + raise FileNotFoundError(f"Files not found or not accessible: {missingIds}") + + cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,)) + cursor.execute( + 'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s', + (accessibleIds, self.userId or ""), + ) + deletedFiles = cursor.rowcount + + self.db.connection.commit() + return {"deletedFiles": deletedFiles} + except Exception as e: + logger.error(f"Error deleting files in batch: {e}") + self.db.connection.rollback() + raise FileDeletionError(f"Error deleting files in batch: {str(e)}") + + # ---- Folder methods ---- + + _RESERVED_FOLDER_NAMES = {"(Global)"} + + def _validateFolderName(self, name: str, parentId: Optional[str], excludeFolderId: Optional[str] = None): + """Ensures folder name is not reserved and is unique within parent.""" + if name in self._RESERVED_FOLDER_NAMES: + raise ValueError(f"Folder name '{name}' is reserved") + if not name or not name.strip(): + raise ValueError("Folder name cannot be empty") + existingFolders = self.db.getRecordset(FileFolder, recordFilter={"parentId": parentId or ""}) + for f in existingFolders: + if f.get("name") == name and f.get("id") != excludeFolderId: + raise ValueError(f"Folder '{name}' already exists in this directory") + + def _isDescendantOf(self, folderId: str, ancestorId: str) -> bool: + """Checks if folderId is a descendant of ancestorId (circular reference check).""" + visited = set() + currentId = folderId + while currentId: + if currentId == ancestorId: + return True + if currentId in visited: + break + visited.add(currentId) + folders = self.db.getRecordset(FileFolder, recordFilter={"id": currentId}) + if not folders: + break + currentId = folders[0].get("parentId") + return False + + def getFolder(self, folderId: str) -> Optional[Dict[str, Any]]: + """Returns a folder by ID if it belongs to the current user.""" + folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "_createdBy": self.userId or ""}) + return folders[0] if folders else None + + def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]: + """List folders for current user, optionally filtered by parentId.""" + recordFilter = {"_createdBy": self.userId or ""} + if parentId is not None: + recordFilter["parentId"] = parentId + return self.db.getRecordset(FileFolder, recordFilter=recordFilter) + + def createFolder(self, name: str, parentId: Optional[str] = None) -> Dict[str, Any]: + """Create a new folder with unique name validation.""" + self._validateFolderName(name, parentId) + folder = FileFolder( + name=name, + parentId=parentId, + mandateId=self.mandateId or "", + featureInstanceId=self.featureInstanceId or "", + ) + return self.db.recordCreate(FileFolder, folder) + + def renameFolder(self, folderId: str, newName: str) -> bool: + """Rename a folder with unique name validation.""" + folder = self.getFolder(folderId) + if not folder: + raise FileNotFoundError(f"Folder {folderId} not found") + self._validateFolderName(newName, folder.get("parentId"), excludeFolderId=folderId) + return self.db.recordModify(FileFolder, folderId, {"name": newName}) + + def moveFolder(self, folderId: str, targetParentId: Optional[str] = None) -> bool: + """Move a folder to a new parent, with circular reference and unique name checks.""" + folder = self.getFolder(folderId) + if not folder: + raise FileNotFoundError(f"Folder {folderId} not found") + if targetParentId and self._isDescendantOf(targetParentId, folderId): + raise ValueError("Cannot move folder into its own subtree") + self._validateFolderName(folder.get("name", ""), targetParentId, excludeFolderId=folderId) + return self.db.recordModify(FileFolder, folderId, {"parentId": targetParentId}) + + def moveFilesBatch(self, fileIds: List[str], targetFolderId: Optional[str] = None) -> Dict[str, Any]: + """Move multiple files with one SQL update.""" + uniqueIds = [str(fid) for fid in dict.fromkeys(fileIds or []) if fid] + if not uniqueIds: + return {"movedFiles": 0} + + if targetFolderId: + targetFolder = self.getFolder(targetFolderId) + if not targetFolder: + raise FileNotFoundError(f"Target folder {targetFolderId} not found") + + try: + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s', + (uniqueIds, self.userId or ""), + ) + accessibleIds = [row["id"] for row in cursor.fetchall()] + if len(accessibleIds) != len(uniqueIds): + missingIds = sorted(set(uniqueIds) - set(accessibleIds)) + raise FileNotFoundError(f"Files not found or not accessible: {missingIds}") + + cursor.execute( + 'UPDATE "FileItem" SET "folderId" = %s, "_modifiedAt" = %s, "_modifiedBy" = %s ' + 'WHERE "id" = ANY(%s) AND "_createdBy" = %s', + (targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds, self.userId or ""), + ) + movedFiles = cursor.rowcount + + self.db.connection.commit() + return {"movedFiles": movedFiles} + except Exception as e: + logger.error(f"Error moving files in batch: {e}") + self.db.connection.rollback() + raise FileError(f"Error moving files in batch: {str(e)}") + + def moveFoldersBatch(self, folderIds: List[str], targetParentId: Optional[str] = None) -> Dict[str, Any]: + """Move multiple folders with one SQL update after validation.""" + uniqueIds = [str(fid) for fid in dict.fromkeys(folderIds or []) if fid] + if not uniqueIds: + return {"movedFolders": 0} + + foldersToMove: List[Dict[str, Any]] = [] + for folderId in uniqueIds: + folder = self.getFolder(folderId) + if not folder: + raise FileNotFoundError(f"Folder {folderId} not found") + if targetParentId and self._isDescendantOf(targetParentId, folderId): + raise ValueError("Cannot move folder into its own subtree") + foldersToMove.append(folder) + + existingInTarget = self.db.getRecordset( + FileFolder, + recordFilter={"parentId": targetParentId or "", "_createdBy": self.userId or ""}, + ) + existingNames = {f.get("name"): f.get("id") for f in existingInTarget} + movingNames: Dict[str, str] = {} + movingIds = set(uniqueIds) + + for folder in foldersToMove: + name = folder.get("name", "") + folderId = folder.get("id") + if name in movingNames and movingNames[name] != folderId: + raise ValueError(f"Folder '{name}' already exists in this move batch") + movingNames[name] = folderId + + existingId = existingNames.get(name) + if existingId and existingId not in movingIds: + raise ValueError(f"Folder '{name}' already exists in target directory") + + try: + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'UPDATE "FileFolder" SET "parentId" = %s, "_modifiedAt" = %s, "_modifiedBy" = %s ' + 'WHERE "id" = ANY(%s) AND "_createdBy" = %s', + (targetParentId, getUtcTimestamp(), self.userId or "", uniqueIds, self.userId or ""), + ) + movedFolders = cursor.rowcount + + self.db.connection.commit() + return {"movedFolders": movedFolders} + except Exception as e: + logger.error(f"Error moving folders in batch: {e}") + self.db.connection.rollback() + raise FileError(f"Error moving folders in batch: {str(e)}") + + def deleteFolder(self, folderId: str, recursive: bool = False) -> Dict[str, Any]: + """Delete a folder. If recursive, deletes all contents. Returns summary of deletions.""" + folder = self.getFolder(folderId) + if not folder: + raise FileNotFoundError(f"Folder {folderId} not found") + + childFolders = self.db.getRecordset(FileFolder, recordFilter={"parentId": folderId, "_createdBy": self.userId or ""}) + childFiles = self._getFilesByCurrentUser(recordFilter={"folderId": folderId}) + + if not recursive and (childFolders or childFiles): + raise ValueError( + f"Folder '{folder.get('name')}' is not empty " + f"({len(childFiles)} files, {len(childFolders)} subfolders). " + f"Use recursive=true to delete contents." + ) + + deletedFiles = 0 + deletedFolders = 0 + + if recursive: + for subFolder in childFolders: + subResult = self.deleteFolder(subFolder["id"], recursive=True) + deletedFiles += subResult.get("deletedFiles", 0) + deletedFolders += subResult.get("deletedFolders", 0) + for childFile in childFiles: + try: + self.deleteFile(childFile["id"]) + deletedFiles += 1 + except Exception as e: + logger.warning(f"Failed to delete file {childFile['id']} during folder deletion: {e}") + + self.db.recordDelete(FileFolder, folderId) + deletedFolders += 1 + + return {"deletedFiles": deletedFiles, "deletedFolders": deletedFolders} + + def deleteFoldersBatch(self, folderIds: List[str], recursive: bool = True) -> Dict[str, Any]: + """Delete multiple folders and their content in batched SQL calls.""" + uniqueIds = [str(fid) for fid in dict.fromkeys(folderIds or []) if fid] + if not uniqueIds: + return {"deletedFiles": 0, "deletedFolders": 0} + + if not recursive: + deletedFiles = 0 + deletedFolders = 0 + for folderId in uniqueIds: + result = self.deleteFolder(folderId, recursive=False) + deletedFiles += result.get("deletedFiles", 0) + deletedFolders += result.get("deletedFolders", 0) + return {"deletedFiles": deletedFiles, "deletedFolders": deletedFolders} + + try: + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'SELECT "id" FROM "FileFolder" WHERE "id" = ANY(%s) AND "_createdBy" = %s', + (uniqueIds, self.userId or ""), + ) + rootAccessibleIds = [row["id"] for row in cursor.fetchall()] + if len(rootAccessibleIds) != len(uniqueIds): + missingIds = sorted(set(uniqueIds) - set(rootAccessibleIds)) + raise FileNotFoundError(f"Folders not found or not accessible: {missingIds}") + + cursor.execute( + """ + WITH RECURSIVE folder_tree AS ( + SELECT "id" + FROM "FileFolder" + WHERE "id" = ANY(%s) AND "_createdBy" = %s + UNION ALL + SELECT child."id" + FROM "FileFolder" child + INNER JOIN folder_tree ft ON child."parentId" = ft."id" + WHERE child."_createdBy" = %s + ) + SELECT DISTINCT "id" FROM folder_tree + """, + (rootAccessibleIds, self.userId or "", self.userId or ""), + ) + allFolderIds = [row["id"] for row in cursor.fetchall()] + + cursor.execute( + 'SELECT "id" FROM "FileItem" WHERE "folderId" = ANY(%s) AND "_createdBy" = %s', + (allFolderIds, self.userId or ""), + ) + allFileIds = [row["id"] for row in cursor.fetchall()] + + if allFileIds: + cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (allFileIds,)) + cursor.execute( + 'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s', + (allFileIds, self.userId or ""), + ) + deletedFiles = cursor.rowcount + else: + deletedFiles = 0 + + cursor.execute( + 'DELETE FROM "FileFolder" WHERE "id" = ANY(%s) AND "_createdBy" = %s', + (allFolderIds, self.userId or ""), + ) + deletedFolders = cursor.rowcount + + self.db.connection.commit() + return {"deletedFiles": deletedFiles, "deletedFolders": deletedFolders} + except Exception as e: + logger.error(f"Error deleting folders in batch: {e}") + self.db.connection.rollback() + raise FileDeletionError(f"Error deleting folders in batch: {str(e)}") + + def copyFile(self, sourceFileId: str, targetFolderId: Optional[str] = None, newFileName: Optional[str] = None) -> FileItem: + """Create a full duplicate of a file (FileItem + FileData).""" + sourceFile = self.getFile(sourceFileId) + if not sourceFile: + raise FileNotFoundError(f"File {sourceFileId} not found") + + sourceData = self.getFileData(sourceFileId) + if sourceData is None: + raise FileStorageError(f"No data found for file {sourceFileId}") + + fileName = newFileName or sourceFile.fileName + copiedFile = self.createFile(fileName, sourceFile.mimeType, sourceData) + + if targetFolderId: + self.updateFile(copiedFile.id, {"folderId": targetFolderId}) + elif sourceFile.folderId: + self.updateFile(copiedFile.id, {"folderId": sourceFile.folderId}) + + self.createFileData(copiedFile.id, sourceData) + return copiedFile + + def updateFileData(self, fileId: str, data: bytes) -> bool: + """Replace existing file data (delete + create). Updates FileItem metadata.""" + file = self.getFile(fileId) + if not file: + raise FileNotFoundError(f"File {fileId} not found") + + try: + self.db.recordDelete(FileData, fileId) + logger.debug(f"Deleted existing FileData for {fileId}") + except Exception as e: + logger.debug(f"No existing FileData to delete for {fileId}: {e}") + + success = self.createFileData(fileId, data) + if success: + newSize = len(data) + newHash = hashlib.sha256(data).hexdigest() + self.db.recordModify(FileItem, fileId, {"fileSize": newSize, "fileHash": newHash}) + logger.info(f"Updated file data for {fileId} ({newSize} bytes)") + return success + # FileData methods - data operations def createFileData(self, fileId: str, data: bytes) -> bool: diff --git a/modules/interfaces/interfaceVoiceObjects.py b/modules/interfaces/interfaceVoiceObjects.py index 5c84c047..dc391bae 100644 --- a/modules/interfaces/interfaceVoiceObjects.py +++ b/modules/interfaces/interfaceVoiceObjects.py @@ -6,8 +6,9 @@ Provides a generic interface layer between routes and voice connectors. Handles voice operations including speech-to-text, text-to-speech, and translation. """ +import asyncio import logging -from typing import Dict, Any, Optional, List +from typing import AsyncGenerator, Callable, Dict, Any, Optional, List from modules.connectors.connectorVoiceGoogle import ConnectorGoogleSpeech from modules.datamodels.datamodelVoice import VoiceSettings @@ -30,6 +31,7 @@ class VoiceObjects: self.currentUser: Optional[User] = None self.userId: Optional[str] = None self._google_speech_connector: Optional[ConnectorGoogleSpeech] = None + self.billingCallback: Optional[Callable[[Dict[str, Any]], None]] = None def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): """Set the user context for the interface. @@ -115,6 +117,32 @@ class VoiceObjects: "error": str(e) } + async def streamingSpeechToText( + self, + audioQueue: asyncio.Queue, + language: str = "de-DE", + phraseHints: Optional[list] = None, + ) -> AsyncGenerator[Dict[str, Any], None]: + """ + Stream audio to Google Streaming STT and yield interim/final results. + Billing is recorded for each final result. + """ + connector = self._getGoogleSpeechConnector() + async for event in connector.streamingRecognize(audioQueue, language, phraseHints): + if event.get("isFinal") and self.billingCallback: + durationSec = event.get("audioDurationSec", 0) + priceCHF = connector.calculateSttCostCHF(durationSec) + if priceCHF > 0: + try: + self.billingCallback({ + "operation": "stt-streaming", + "priceCHF": priceCHF, + "audioDurationSec": durationSec, + }) + except Exception as e: + logger.warning(f"Voice STT billing callback failed: {e}") + yield event + # Translation Operations async def detectLanguage(self, text: str) -> Dict[str, Any]: @@ -277,7 +305,18 @@ class VoiceObjects: if result["success"]: logger.info(f"✅ Text-to-Speech successful: {len(result['audio_content'])} bytes") - # Map connector snake_case keys to camelCase for consistent API + if self.billingCallback: + connector = self._getGoogleSpeechConnector() + priceCHF = connector.calculateTtsCostCHF(len(text)) + if priceCHF > 0: + try: + self.billingCallback({ + "operation": "tts-wavenet", + "priceCHF": priceCHF, + "characterCount": len(text), + }) + except Exception as e: + logger.warning(f"Voice TTS billing callback failed: {e}") return { "success": True, "audioContent": result["audio_content"], diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 194ebba0..ab12c568 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -236,6 +236,7 @@ class CheckoutCreateRequest(BaseModel): """Request model for creating Stripe Checkout Session.""" userId: Optional[str] = Field(None, description="Target user ID (for PREPAY_USER model)") amount: float = Field(..., gt=0, description="Amount to pay in CHF (must be in allowed presets)") + returnUrl: str = Field(..., min_length=1, description="Absolute frontend URL used for Stripe success/cancel redirects") class CheckoutCreateResponse(BaseModel): @@ -243,6 +244,20 @@ class CheckoutCreateResponse(BaseModel): redirectUrl: str = Field(..., description="Stripe Checkout URL for redirect") +class CheckoutConfirmRequest(BaseModel): + """Request model for confirming Stripe Checkout after redirect.""" + sessionId: str = Field(..., min_length=1, description="Stripe Checkout Session ID (cs_xxx)") + + +class CheckoutConfirmResponse(BaseModel): + """Response model for Stripe Checkout confirmation.""" + credited: bool = Field(..., description="True if a new billing credit was created") + alreadyCredited: bool = Field(..., description="True if session was already credited before") + sessionId: str = Field(..., description="Stripe Checkout Session ID") + mandateId: str = Field(..., description="Mandate ID from Stripe metadata") + amountChf: float = Field(..., description="Credited amount in CHF") + + class BillingSettingsUpdate(BaseModel): """Request model for updating billing settings.""" billingModel: Optional[BillingModelEnum] = None @@ -345,6 +360,107 @@ class UserTransactionResponse(BaseModel): userName: Optional[str] = None +def _getStripeClient(): + """Initialize and return configured Stripe SDK module.""" + import stripe + from modules.shared.configuration import APP_CONFIG + + api_version = APP_CONFIG.get("STRIPE_API_VERSION") + if api_version: + stripe.api_version = api_version + + secret_key = APP_CONFIG.get("STRIPE_SECRET_KEY_SECRET") or APP_CONFIG.get("STRIPE_SECRET_KEY") + if not secret_key: + raise ValueError("STRIPE_SECRET_KEY_SECRET not configured") + + stripe.api_key = secret_key + return stripe + + +def _creditStripeSessionIfNeeded( + billingInterface, + session: Dict[str, Any], + eventId: Optional[str] = None, +) -> CheckoutConfirmResponse: + """ + Credit balance from Stripe Checkout session if not already credited. + Uses Checkout session ID for idempotency across webhook + manual confirmation flows. + """ + from modules.serviceCenter.services.serviceBilling.stripeCheckout import ALLOWED_AMOUNTS_CHF + + session_id = session.get("id") + metadata = session.get("metadata") or {} + mandate_id = metadata.get("mandateId") + user_id = metadata.get("userId") or None + amount_chf_str = metadata.get("amountChf", "0") + + if not session_id: + raise HTTPException(status_code=400, detail="Stripe session id missing") + if not mandate_id: + raise HTTPException(status_code=400, detail="Invalid session metadata: mandateId missing") + + existing_payment_tx = billingInterface.getPaymentTransactionByReferenceId(session_id) + if existing_payment_tx: + if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId): + billingInterface.createStripeWebhookEvent(eventId) + return CheckoutConfirmResponse( + credited=False, + alreadyCredited=True, + sessionId=session_id, + mandateId=mandate_id, + amountChf=float(existing_payment_tx.get("amount", 0.0)), + ) + + try: + amount_chf = float(amount_chf_str) + except (TypeError, ValueError): + amount_chf = None + + if amount_chf is None or amount_chf not in ALLOWED_AMOUNTS_CHF: + amount_total = session.get("amount_total") + if amount_total is not None: + amount_chf = amount_total / 100.0 + else: + raise HTTPException(status_code=400, detail="Invalid amount in Stripe session") + + settings = billingInterface.getSettings(mandate_id) + if not settings: + raise HTTPException(status_code=404, detail="Billing settings not found") + + billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) + if billing_model == BillingModelEnum.PREPAY_USER: + if not user_id: + raise HTTPException(status_code=400, detail="userId required for PREPAY_USER") + account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0) + elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: + account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0) + else: + raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}") + + transaction = BillingTransaction( + accountId=account["id"], + transactionType=TransactionTypeEnum.CREDIT, + amount=amount_chf, + description="Stripe-Zahlung", + referenceType=ReferenceTypeEnum.PAYMENT, + referenceId=session_id, + createdByUserId=user_id, + ) + billingInterface.createTransaction(transaction) + + if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId): + billingInterface.createStripeWebhookEvent(eventId) + + logger.info(f"Stripe credit applied: {amount_chf} CHF for session {session_id} on mandate {mandate_id}") + return CheckoutConfirmResponse( + credited=True, + alreadyCredited=False, + sessionId=session_id, + mandateId=mandate_id, + amountChf=amount_chf, + ) + + # ============================================================================= # Router Setup # ============================================================================= @@ -769,7 +885,8 @@ def createCheckoutSession( redirect_url = create_checkout_session( mandate_id=targetMandateId, user_id=checkoutRequest.userId, - amount_chf=checkoutRequest.amount + amount_chf=checkoutRequest.amount, + return_url=checkoutRequest.returnUrl ) return CheckoutCreateResponse(redirectUrl=redirect_url) @@ -782,6 +899,65 @@ def createCheckoutSession( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/checkout/confirm", response_model=CheckoutConfirmResponse) +@limiter.limit("20/minute") +def confirmCheckoutSession( + request: Request, + confirmRequest: CheckoutConfirmRequest = Body(...), + ctx: RequestContext = Depends(getRequestContext), +): + """ + Confirm Stripe Checkout success by session ID and apply credit idempotently. + This is a fallback/reconciliation path in addition to webhook processing. + """ + try: + stripe = _getStripeClient() + session = stripe.checkout.Session.retrieve(confirmRequest.sessionId) + if not session: + raise HTTPException(status_code=404, detail="Stripe Checkout Session not found") + + session_dict = session.to_dict_recursive() if hasattr(session, "to_dict_recursive") else dict(session) + metadata = session_dict.get("metadata") or {} + mandate_id = metadata.get("mandateId") + user_id = metadata.get("userId") or None + + if not mandate_id: + raise HTTPException(status_code=400, detail="Invalid session metadata: mandateId missing") + + payment_status = session_dict.get("payment_status") + if payment_status != "paid": + raise HTTPException(status_code=409, detail=f"Payment not completed yet (payment_status={payment_status})") + + billingInterface = getBillingInterface(ctx.user, mandate_id) + settings = billingInterface.getSettings(mandate_id) + if not settings: + raise HTTPException(status_code=404, detail="Billing settings not found") + + billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) + if billing_model == BillingModelEnum.PREPAY_USER: + if not user_id: + raise HTTPException(status_code=400, detail="userId required for PREPAY_USER") + if str(user_id) != str(ctx.user.id): + raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions") + if not _isMemberOfMandate(ctx, mandate_id): + raise HTTPException(status_code=403, detail="User is not a member of this mandate") + elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: + if not _isAdminOfMandate(ctx, mandate_id): + raise HTTPException(status_code=403, detail="Mandate admin role required") + else: + raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}") + + root_billing_interface = _getRootInterface() + return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error confirming checkout session {confirmRequest.sessionId}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/webhook/stripe") async def stripeWebhook( request: Request, @@ -792,8 +968,7 @@ async def stripeWebhook( No JWT auth - Stripe authenticates via Stripe-Signature header. """ from modules.shared.configuration import APP_CONFIG - from modules.serviceCenter.services.serviceBilling.stripeCheckout import ALLOWED_AMOUNTS_CHF - + webhook_secret = APP_CONFIG.get("STRIPE_WEBHOOK_SECRET") if not webhook_secret: logger.error("STRIPE_WEBHOOK_SECRET not configured") @@ -816,71 +991,26 @@ async def stripeWebhook( logger.warning(f"Stripe webhook signature verification failed: {e}") raise HTTPException(status_code=400, detail="Invalid signature") - if event.type != "checkout.session.completed": + logger.info(f"Stripe webhook received: event={event.id}, type={event.type}") + + accepted_event_types = {"checkout.session.completed", "checkout.session.async_payment_succeeded"} + if event.type not in accepted_event_types: return {"received": True} session = event.data.object event_id = event.id - session_id = session.id - + billingInterface = _getRootInterface() - if billingInterface.getStripeWebhookEventByEventId(event_id): logger.info(f"Stripe event {event_id} already processed, skipping") return {"received": True} - - metadata = session.get("metadata") or {} - mandate_id = metadata.get("mandateId") - user_id = metadata.get("userId") or None - amount_chf_str = metadata.get("amountChf", "0") - - if not mandate_id: - logger.error(f"Stripe webhook missing mandateId in session {session_id}") - raise HTTPException(status_code=400, detail="Invalid session metadata") - - try: - amount_chf = float(amount_chf_str) - except (TypeError, ValueError): - amount_chf = None - - if amount_chf is None or amount_chf not in ALLOWED_AMOUNTS_CHF: - amount_total = session.get("amount_total") - if amount_total is not None: - amount_chf = amount_total / 100.0 - else: - logger.error(f"Stripe webhook invalid amount for session {session_id}") - raise HTTPException(status_code=400, detail="Invalid amount") - - settings = billingInterface.getSettings(mandate_id) - if not settings: - logger.error(f"Stripe webhook: billing settings not found for mandate {mandate_id}") - raise HTTPException(status_code=404, detail="Billing settings not found") - - billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) - - if billing_model == BillingModelEnum.PREPAY_USER: - if not user_id: - logger.error(f"Stripe webhook: userId required for PREPAY_USER mandate {mandate_id}") - raise HTTPException(status_code=400, detail="userId required") - account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0) - elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: - account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0) - else: - logger.error(f"Stripe webhook: cannot credit mandate {mandate_id} with model {billing_model}") - raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}") - - transaction = BillingTransaction( - accountId=account["id"], - transactionType=TransactionTypeEnum.CREDIT, - amount=amount_chf, - description="Stripe-Zahlung", - referenceType=ReferenceTypeEnum.PAYMENT, - referenceId=session_id + + session_dict = session.to_dict_recursive() if hasattr(session, "to_dict_recursive") else dict(session) + result = _creditStripeSessionIfNeeded(billingInterface, session_dict, eventId=event_id) + logger.info( + f"Stripe webhook processed session {result.sessionId}: " + f"credited={result.credited}, alreadyCredited={result.alreadyCredited}" ) - billingInterface.createTransaction(transaction) - billingInterface.createStripeWebhookEvent(event_id) - - logger.info(f"Stripe webhook: credited {amount_chf} CHF to account {account['id']} (session {session_id})") return {"received": True} diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 46182f60..c3138aed 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -12,6 +12,7 @@ from modules.auth import limiter, getCurrentUser, getRequestContext, RequestCont # Import interfaces import modules.interfaces.interfaceDbManagement as interfaceDbManagement from modules.datamodels.datamodelFiles import FileItem, FilePreview +from modules.datamodels.datamodelFileFolder import FileFolder from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict @@ -310,6 +311,222 @@ async def upload_file( detail=f"Error during file upload: {str(e)}" ) +# ── Folder endpoints (MUST be before /{fileId} catch-all) ───────────────────── + +@router.get("/folders", response_model=List[Dict[str, Any]]) +@limiter.limit("30/minute") +def list_folders( + request: Request, + parentId: Optional[str] = Query(None, description="Parent folder ID (omit for all folders)"), + currentUser: User = Depends(getCurrentUser), + context: RequestContext = Depends(getRequestContext) +) -> List[Dict[str, Any]]: + """List folders for the current user.""" + try: + mgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, + ) + if parentId is not None: + return mgmt.listFolders(parentId=parentId) + return mgmt.listFolders() + except Exception as e: + logger.error(f"Error listing folders: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/folders", status_code=status.HTTP_201_CREATED) +@limiter.limit("10/minute") +def create_folder( + request: Request, + body: Dict[str, Any] = Body(...), + currentUser: User = Depends(getCurrentUser), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Create a new folder.""" + name = body.get("name", "") + parentId = body.get("parentId") + if not name: + raise HTTPException(status_code=400, detail="name is required") + try: + mgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, + ) + return mgmt.createFolder(name=name, parentId=parentId) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error creating folder: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/folders/{folderId}") +@limiter.limit("10/minute") +def rename_folder( + request: Request, + folderId: str = Path(...), + body: Dict[str, Any] = Body(...), + currentUser: User = Depends(getCurrentUser), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Rename a folder.""" + newName = body.get("name", "") + if not newName: + raise HTTPException(status_code=400, detail="name is required") + try: + mgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, + ) + mgmt.renameFolder(folderId, newName) + return {"success": True, "folderId": folderId, "name": newName} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error renaming folder: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/folders/{folderId}") +@limiter.limit("10/minute") +def delete_folder( + request: Request, + folderId: str = Path(...), + recursive: bool = Query(False, description="Delete folder contents recursively"), + currentUser: User = Depends(getCurrentUser), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Delete a folder. Use recursive=true to delete non-empty folders.""" + try: + mgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, + ) + return mgmt.deleteFolder(folderId, recursive=recursive) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error deleting folder: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/folders/{folderId}/move") +@limiter.limit("10/minute") +def move_folder( + request: Request, + folderId: str = Path(...), + body: Dict[str, Any] = Body(...), + currentUser: User = Depends(getCurrentUser), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Move a folder to a new parent.""" + targetParentId = body.get("targetParentId") + try: + mgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, + ) + mgmt.moveFolder(folderId, targetParentId) + return {"success": True, "folderId": folderId, "parentId": targetParentId} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error moving folder: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/batch-delete") +@limiter.limit("10/minute") +def batch_delete_items( + request: Request, + body: Dict[str, Any] = Body(...), + currentUser: User = Depends(getCurrentUser), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Batch delete files/folders with a single SQL-backed operation per type.""" + fileIds = body.get("fileIds") or [] + folderIds = body.get("folderIds") or [] + recursiveFolders = bool(body.get("recursiveFolders", True)) + + if not isinstance(fileIds, list) or not isinstance(folderIds, list): + raise HTTPException(status_code=400, detail="fileIds and folderIds must be arrays") + + try: + mgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, + ) + + result = {"deletedFiles": 0, "deletedFolders": 0} + + if fileIds: + fileResult = mgmt.deleteFilesBatch(fileIds) + result["deletedFiles"] += fileResult.get("deletedFiles", 0) + + if folderIds: + folderResult = mgmt.deleteFoldersBatch(folderIds, recursive=recursiveFolders) + result["deletedFiles"] += folderResult.get("deletedFiles", 0) + result["deletedFolders"] += folderResult.get("deletedFolders", 0) + + return result + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error in batch delete: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/batch-move") +@limiter.limit("10/minute") +def batch_move_items( + request: Request, + body: Dict[str, Any] = Body(...), + currentUser: User = Depends(getCurrentUser), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Batch move files/folders with a single SQL-backed operation per type.""" + fileIds = body.get("fileIds") or [] + folderIds = body.get("folderIds") or [] + targetFolderId = body.get("targetFolderId") + targetParentId = body.get("targetParentId") + + if not isinstance(fileIds, list) or not isinstance(folderIds, list): + raise HTTPException(status_code=400, detail="fileIds and folderIds must be arrays") + + try: + mgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, + ) + + result = {"movedFiles": 0, "movedFolders": 0} + + if fileIds: + fileResult = mgmt.moveFilesBatch(fileIds, targetFolderId=targetFolderId) + result["movedFiles"] += fileResult.get("movedFiles", 0) + + if folderIds: + folderResult = mgmt.moveFoldersBatch(folderIds, targetParentId=targetParentId) + result["movedFolders"] += folderResult.get("movedFolders", 0) + + return result + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error in batch move: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ── File endpoints with path parameters (catch-all /{fileId}) ───────────────── + @router.get("/{fileId}", response_model=FileItem) @limiter.limit("30/minute") def get_file( @@ -557,3 +774,25 @@ def preview_file( ) +@router.post("/{fileId}/move") +@limiter.limit("10/minute") +def move_file( + request: Request, + fileId: str = Path(...), + body: Dict[str, Any] = Body(...), + currentUser: User = Depends(getCurrentUser), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Move a file to a different folder.""" + targetFolderId = body.get("targetFolderId") + try: + mgmt = interfaceDbManagement.getInterface( + currentUser, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, + ) + mgmt.updateFile(fileId, {"folderId": targetFolderId}) + return {"success": True, "fileId": fileId, "folderId": targetFolderId} + except Exception as e: + logger.error(f"Error moving file: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 95d90aa6..c2be23a6 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -102,12 +102,6 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]: elif featureCode == "realestate": from modules.features.realEstate.mainRealEstate import UI_OBJECTS return UI_OBJECTS - elif featureCode == "chatplayground": - from modules.features.chatplayground.mainChatplayground import UI_OBJECTS - return UI_OBJECTS - elif featureCode == "codeeditor": - from modules.features.codeeditor.mainCodeeditor import UI_OBJECTS - return UI_OBJECTS elif featureCode == "automation": from modules.features.automation.mainAutomation import UI_OBJECTS return UI_OBJECTS @@ -127,7 +121,7 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]: from modules.features.workspace.mainWorkspace import UI_OBJECTS return UI_OBJECTS else: - logger.warning(f"Unknown feature code: {featureCode}") + logger.debug(f"Skipping removed feature code: {featureCode}") return [] except ImportError as e: logger.error(f"Failed to import UI_OBJECTS for feature {featureCode}: {e}") diff --git a/modules/routes/routeVoiceGoogle.py b/modules/routes/routeVoiceGoogle.py index 8e72207c..af4db355 100644 --- a/modules/routes/routeVoiceGoogle.py +++ b/modules/routes/routeVoiceGoogle.py @@ -6,13 +6,16 @@ Replaces Azure voice services with Google Cloud Speech-to-Text and Translation Includes WebSocket support for real-time voice streaming """ +import asyncio import logging import json import base64 -from fastapi import APIRouter, File, Form, UploadFile, Depends, HTTPException, Body, WebSocket, WebSocketDisconnect +import secrets +import time +from fastapi import APIRouter, File, Form, UploadFile, Depends, HTTPException, Body, Query, Request, WebSocket, WebSocketDisconnect from fastapi.responses import Response from typing import Optional, Dict, Any, List -from modules.auth import getCurrentUser +from modules.auth import getCurrentUser, getRequestContext, RequestContext, limiter from modules.datamodels.datamodelUam import User from modules.interfaces.interfaceVoiceObjects import getVoiceInterface, VoiceObjects @@ -290,10 +293,11 @@ async def realtime_interpreter( @router.post("/text-to-speech") async def text_to_speech( + request: Request, text: str = Form(...), language: str = Form("de-DE"), voice: str = Form(None), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext), ): """Convert text to speech using Google Cloud Text-to-Speech.""" try: @@ -305,7 +309,20 @@ async def text_to_speech( detail="Empty text provided for text-to-speech" ) - voiceInterface = _getVoiceInterface(currentUser) + mandateId = str(getattr(context, "mandateId", "") or "") + voiceInterface = getVoiceInterface(context.user, mandateId) + try: + from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService + billingService = getBillingService(context.user, mandateId) + def _billingCb(data): + priceCHF = data.get("priceCHF", 0.0) + operation = data.get("operation", "voice") + if priceCHF > 0: + billingService.recordUsage(priceCHF=priceCHF, aicoreProvider="google-voice", aicoreModel=operation, description=f"Voice {operation}") + voiceInterface.billingCallback = _billingCb + except Exception as e: + logger.warning(f"TTS billing setup skipped: {e}") + result = await voiceInterface.textToSpeech( text=text, languageCode=language, @@ -314,12 +331,12 @@ async def text_to_speech( if result["success"]: return Response( - content=result["audio_content"], + content=result["audioContent"], media_type="audio/mpeg", headers={ "Content-Disposition": "attachment; filename=speech.mp3", - "X-Voice-Name": result["voice_name"], - "X-Language-Code": result["language_code"] + "X-Voice-Name": result.get("voiceName", ""), + "X-Language-Code": result.get("languageCode", language), } ) else: @@ -533,189 +550,192 @@ async def save_voice_settings( detail=f"Failed to save voice settings: {str(e)}" ) -# WebSocket endpoints for real-time voice streaming +# ========================================================================= +# STT Streaming WebSocket — generic, used by all features +# ========================================================================= -@router.websocket("/ws/realtime-interpreter") -async def websocket_realtime_interpreter( - websocket: WebSocket, - userId: str = "default", - fromLanguage: str = "de-DE", - toLanguage: str = "en-US" -): - """WebSocket endpoint for real-time voice interpretation""" - connectionId = f"realtime_{userId}_{fromLanguage}_{toLanguage}" - - try: - await manager.connect(websocket, connectionId) - - # Send connection confirmation - await manager.sendPersonalMessage({ - "type": "connected", - "connection_id": connectionId, - "message": "Connected to real-time interpreter" - }, websocket) - - # Initialize voice interface - voiceInterface = _getVoiceInterface(User(id=userId)) - - while True: - # Receive message from client - data = await websocket.receive_text() - message = json.loads(data) - - if message["type"] == "audio_chunk": - # Process audio chunk - try: - # Decode base64 audio data - audioData = base64.b64decode(message["data"]) - - # For now, just acknowledge receipt - # In a full implementation, this would: - # 1. Buffer audio chunks - # 2. Process with Google Cloud Speech-to-Text streaming - # 3. Send partial results back - # 4. Handle translation - - await manager.sendPersonalMessage({ - "type": "audio_received", - "chunk_size": len(audioData), - "timestamp": message.get("timestamp") - }, websocket) - - except Exception as e: - logger.error(f"Error processing audio chunk: {e}") - await manager.send_personal_message({ - "type": "error", - "error": f"Failed to process audio: {str(e)}" - }, websocket) - - elif message["type"] == "ping": - # Respond to ping - await manager.sendPersonalMessage({ - "type": "pong", - "timestamp": message.get("timestamp") - }, websocket) - - else: - logger.warning(f"Unknown message type: {message['type']}") - - except WebSocketDisconnect: - manager.disconnect(websocket, connectionId) - logger.info(f"Client disconnected: {connectionId}") - except Exception as e: - logger.error(f"WebSocket error: {e}") - manager.disconnect(websocket, connectionId) +_sttTokens: Dict[str, Dict[str, Any]] = {} +_STT_TOKEN_TTL = 45 -@router.websocket("/ws/speech-to-text") -async def websocket_speech_to_text( - websocket: WebSocket, - userId: str = "default", - language: str = "de-DE" -): - """WebSocket endpoint for real-time speech-to-text""" - connectionId = f"stt_{userId}_{language}" - - try: - await manager.connect(websocket, connectionId) - - await manager.sendPersonalMessage({ - "type": "connected", - "connection_id": connectionId, - "message": "Connected to speech-to-text" - }, websocket) - - # Initialize voice interface - voiceInterface = _getVoiceInterface(User(id=userId)) - - while True: - data = await websocket.receive_text() - message = json.loads(data) - - if message["type"] == "audio_chunk": - try: - audioData = base64.b64decode(message["data"]) - - # Process audio chunk - # This would integrate with Google Cloud Speech-to-Text streaming API - - await manager.sendPersonalMessage({ - "type": "transcription_result", - "text": "Audio chunk received", # Placeholder - "confidence": 0.95, - "is_final": False - }, websocket) - - except Exception as e: - logger.error(f"Error processing audio: {e}") - await manager.sendPersonalMessage({ - "type": "error", - "error": f"Failed to process audio: {str(e)}" - }, websocket) - - elif message["type"] == "ping": - await manager.sendPersonalMessage({ - "type": "pong", - "timestamp": message.get("timestamp") - }, websocket) - - except WebSocketDisconnect: - manager.disconnect(websocket, connectionId) - except Exception as e: - logger.error(f"WebSocket error: {e}") - manager.disconnect(websocket, connectionId) -@router.websocket("/ws/text-to-speech") -async def websocket_text_to_speech( - websocket: WebSocket, - userId: str = "default", - language: str = "de-DE", - voice: str = "de-DE-Wavenet-A" +def _cleanupSttTokens(): + now = time.time() + expired = [t for t, p in _sttTokens.items() if p.get("expiresAt", 0) <= now] + for t in expired: + _sttTokens.pop(t, None) + + +@router.post("/stt/token") +@limiter.limit("60/minute") +async def createSttToken( + request: Request, + context: RequestContext = Depends(getRequestContext), ): - """WebSocket endpoint for real-time text-to-speech""" - connectionId = f"tts_{userId}_{language}_{voice}" - + """Issue a short-lived single-use token for the STT streaming WebSocket.""" + _cleanupSttTokens() + token = secrets.token_urlsafe(32) + _sttTokens[token] = { + "userId": str(context.user.id), + "mandateId": str(getattr(context, "mandateId", "") or ""), + "expiresAt": time.time() + _STT_TOKEN_TTL, + } + return {"wsToken": token, "expiresInSeconds": _STT_TOKEN_TTL} + + +@router.websocket("/stt/stream") +async def sttStream( + websocket: WebSocket, + wsToken: Optional[str] = Query(None), +): + """ + Generic STT streaming WebSocket. + + Protocol: + Client sends JSON: + {"type": "open", "language": "de-DE"} + {"type": "audio", "chunk": ""} + {"type": "close"} + Server sends JSON: + {"type": "interim", "text": "..."} + {"type": "final", "text": "...", "confidence": 0.95} + {"type": "error", "message": "..."} + {"type": "closed"} + """ + await websocket.accept() + + # --- authenticate via wsToken --- + if not wsToken: + await websocket.send_json({"type": "error", "code": "ws_token_required", "message": "wsToken query param required"}) + await websocket.close(code=1008) + return + + _cleanupSttTokens() + tokenPayload = _sttTokens.pop(wsToken, None) + if not tokenPayload: + await websocket.send_json({"type": "error", "code": "ws_token_invalid", "message": "Invalid or expired wsToken"}) + await websocket.close(code=1008) + return + + tokenUserId = tokenPayload["userId"] + tokenMandateId = tokenPayload.get("mandateId", "") + + # Resolve real user for billing + from modules.interfaces.interfaceDbApp import getRootInterface + rootInterface = getRootInterface() + currentUser = rootInterface.getUser(tokenUserId) + if not currentUser: + await websocket.send_json({"type": "error", "code": "user_not_found", "message": "User not found"}) + await websocket.close(code=1008) + return + + # --- billing pre-flight --- + billingService = None try: - await manager.connect(websocket, connectionId) - - await manager.sendPersonalMessage({ - "type": "connected", - "connection_id": connectionId, - "message": "Connected to text-to-speech" - }, websocket) - - while True: - data = await websocket.receive_text() - message = json.loads(data) - - if message["type"] == "text_to_speak": - try: - text = message["text"] - - # Process text-to-speech - # This would integrate with Google Cloud Text-to-Speech API - - # For now, send a placeholder response - await manager.sendPersonalMessage({ - "type": "audio_data", - "audio": "base64_encoded_audio_here", # Placeholder - "format": "mp3" - }, websocket) - - except Exception as e: - logger.error(f"Error processing text-to-speech: {e}") - await manager.sendPersonalMessage({ - "type": "error", - "error": f"Failed to process text: {str(e)}" - }, websocket) - - elif message["type"] == "ping": - await manager.sendPersonalMessage({ - "type": "pong", - "timestamp": message.get("timestamp") - }, websocket) - - except WebSocketDisconnect: - manager.disconnect(websocket, connectionId) + from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService + billingService = getBillingService(currentUser, tokenMandateId) + billingCheck = billingService.checkBalance(0.0) + if not billingCheck.allowed: + await websocket.send_json({"type": "error", "code": "billing_insufficient", "message": "Insufficient balance for voice services"}) + await websocket.close(code=1008) + return except Exception as e: - logger.error(f"WebSocket error: {e}") - manager.disconnect(websocket, connectionId) + logger.warning(f"STT billing pre-flight skipped: {e}") + + audioQueue: asyncio.Queue = asyncio.Queue() + language = "de-DE" + streamingTask: Optional[asyncio.Task] = None + voiceInterface: Optional[VoiceObjects] = None + + async def _sendJson(payload: Dict[str, Any]) -> bool: + try: + await websocket.send_json(payload) + return True + except Exception: + return False + + async def _runStreaming(): + nonlocal voiceInterface + voiceInterface = getVoiceInterface(currentUser, tokenMandateId) + if billingService: + def _billingCb(data): + priceCHF = data.get("priceCHF", 0.0) + operation = data.get("operation", "voice") + if priceCHF > 0: + billingService.recordUsage( + priceCHF=priceCHF, + aicoreProvider="google-voice", + aicoreModel=operation, + description=f"Voice {operation}", + ) + voiceInterface.billingCallback = _billingCb + + try: + async for event in voiceInterface.streamingSpeechToText(audioQueue, language): + if event.get("reconnectRequired"): + await _sendJson({"type": "reconnect_required"}) + return + if event.get("isFinal"): + if event.get("transcript"): + await _sendJson({"type": "final", "text": event["transcript"], "confidence": event.get("confidence", 0.0)}) + else: + if event.get("transcript"): + await _sendJson({"type": "interim", "text": event["transcript"]}) + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"STT streaming error: {e}") + await _sendJson({"type": "error", "message": str(e)}) + + try: + await _sendJson({"type": "status", "label": "STT stream connected"}) + + while True: + raw = await websocket.receive_text() + msg = json.loads(raw) + msgType = (msg.get("type") or "").strip() + + if msgType == "open": + language = msg.get("language") or "de-DE" + if streamingTask and not streamingTask.done(): + await audioQueue.put((b"", True)) + streamingTask.cancel() + audioQueue = asyncio.Queue() + streamingTask = asyncio.create_task(_runStreaming()) + await _sendJson({"type": "status", "label": "Listening..."}) + + elif msgType == "audio": + chunkB64 = msg.get("chunk") + if not chunkB64: + continue + chunkBytes = base64.b64decode(chunkB64) + if len(chunkBytes) > 400_000: + await _sendJson({"type": "error", "code": "chunk_too_large", "message": "Audio chunk too large"}) + continue + await audioQueue.put((chunkBytes, False)) + + elif msgType == "close": + await audioQueue.put((b"", True)) + if streamingTask: + try: + await asyncio.wait_for(streamingTask, timeout=10.0) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass + await _sendJson({"type": "closed"}) + await websocket.close() + break + + elif msgType == "ping": + await _sendJson({"type": "pong"}) + + except WebSocketDisconnect: + logger.info(f"STT WebSocket disconnected: userId={tokenUserId}") + except Exception as e: + logger.error(f"STT WebSocket error: {e}", exc_info=True) + try: + await websocket.send_json({"type": "error", "message": str(e)}) + except Exception: + pass + finally: + await audioQueue.put((b"", True)) + if streamingTask and not streamingTask.done(): + streamingTask.cancel() diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py index c80ffdeb..17e953fd 100644 --- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py +++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py @@ -41,7 +41,7 @@ class ActionToolAdapter: if not actionDef or not getattr(actionDef, "dynamicMode", False): continue - compoundName = f"{shortName}.{actionName}" + compoundName = f"{shortName}_{actionName}" toolDef = _buildToolDefinition(compoundName, actionDef, actionInfo) handler = _createDispatchHandler(self._actionExecutor, shortName, actionName) @@ -120,16 +120,16 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str): data = _formatActionResult(result) return ToolResult( toolCallId="", - toolName=f"{methodName}.{actionName}", + toolName=f"{methodName}_{actionName}", success=result.success, data=data, error=result.error ) except Exception as e: - logger.error(f"ActionToolAdapter dispatch failed for {methodName}.{actionName}: {e}") + logger.error(f"ActionToolAdapter dispatch failed for {methodName}_{actionName}: {e}") return ToolResult( toolCallId="", - toolName=f"{methodName}.{actionName}", + toolName=f"{methodName}_{actionName}", success=False, error=str(e) ) diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py index 2c798680..eaa3bd75 100644 --- a/modules/serviceCenter/services/serviceAgent/agentLoop.py +++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py @@ -24,9 +24,6 @@ from modules.shared.timeUtils import getUtcTimestamp logger = logging.getLogger(__name__) -MAX_RETRIES_PER_TOOL = 3 -RETRY_BASE_DELAY_S = 1.0 - async def runAgentLoop( prompt: str, @@ -315,25 +312,87 @@ async def _checkBudget(config: AgentConfig, async def _executeToolCalls(toolCalls: List[ToolCallRequest], toolRegistry: ToolRegistry, context: Dict[str, Any]) -> List[ToolResult]: - """Execute tool calls: readOnly tools in parallel, others sequentially.""" + """Execute tool calls: readOnly tools in parallel, others sequentially. + + Tool calls with _parseError (truncated JSON from LLM) are short-circuited + with an error result so the agent can retry. + """ readOnlyCalls = [tc for tc in toolCalls if toolRegistry.isReadOnly(tc.name)] writeCalls = [tc for tc in toolCalls if not toolRegistry.isReadOnly(tc.name)] results: Dict[str, ToolResult] = {} - if readOnlyCalls: + for tc in toolCalls: + if "_parseError" in tc.args: + results[tc.id] = ToolResult( + toolCallId=tc.id, + toolName=tc.name, + success=False, + data="", + error=tc.args["_parseError"], + durationMs=0, + ) + + activeCalls = [tc for tc in toolCalls if tc.id not in results] + activeReadOnly = [tc for tc in activeCalls if toolRegistry.isReadOnly(tc.name)] + activeWrite = [tc for tc in activeCalls if not toolRegistry.isReadOnly(tc.name)] + + if activeReadOnly: readResults = await asyncio.gather(*[ - toolRegistry.dispatch(tc, context) for tc in readOnlyCalls + toolRegistry.dispatch(tc, context) for tc in activeReadOnly ]) - for tc, result in zip(readOnlyCalls, readResults): + for tc, result in zip(activeReadOnly, readResults): results[tc.id] = result - for tc in writeCalls: + for tc in activeWrite: results[tc.id] = await toolRegistry.dispatch(tc, context) return [results[tc.id] for tc in toolCalls] +def _repairTruncatedJson(raw: str) -> Optional[Dict[str, Any]]: + """Try to repair truncated JSON from LLM output by closing open brackets/braces. + + Returns parsed dict on success, None if unrecoverable. + """ + if not raw or not raw.strip().startswith("{"): + return None + + openBraces = raw.count("{") - raw.count("}") + openBrackets = raw.count("[") - raw.count("]") + + inString = False + lastQuoteEscaped = False + quoteCount = 0 + for ch in raw: + if ch == '"' and not lastQuoteEscaped: + quoteCount += 1 + inString = not inString + lastQuoteEscaped = (ch == '\\') + + candidate = raw + if quoteCount % 2 != 0: + candidate += '"' + + candidate += "]" * max(0, openBrackets) + candidate += "}" * max(0, openBraces) + + try: + return json.loads(candidate) + except json.JSONDecodeError: + pass + + lastComma = candidate.rfind(",") + if lastComma > 0: + trimmed = candidate[:lastComma] + candidate[lastComma + 1:] + try: + return json.loads(trimmed) + except json.JSONDecodeError: + pass + + return None + + def _parseToolCalls(aiResponse: AiCallResponse) -> List[ToolCallRequest]: """Parse tool calls from AI response. Supports native function calling and text-based fallback.""" toolCalls = [] @@ -347,8 +406,12 @@ def _parseToolCalls(aiResponse: AiCallResponse) -> List[ToolCallRequest]: try: parsedArgs = json.loads(rawArgs) if rawArgs else {} except json.JSONDecodeError: - logger.warning(f"Failed to parse tool args for '{tc['function']['name']}': {rawArgs[:200]}") - parsedArgs = {} + parsedArgs = _repairTruncatedJson(rawArgs) + if parsedArgs is None: + logger.warning(f"Unrecoverable truncated JSON for '{tc['function']['name']}': {rawArgs[:200]}") + parsedArgs = {"_parseError": f"Truncated JSON arguments – model output was cut off. Raw start: {rawArgs[:120]}"} + else: + logger.info(f"Repaired truncated JSON for '{tc['function']['name']}'") else: parsedArgs = rawArgs if rawArgs else {} toolCalls.append(ToolCallRequest( diff --git a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py index b786b550..c70d8344 100644 --- a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py +++ b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py @@ -26,6 +26,10 @@ class AgentEventTypeEnum(str, Enum): AGENT_PROGRESS = "agentProgress" AGENT_SUMMARY = "agentSummary" FILE_CREATED = "fileCreated" + FILE_UPDATED = "fileUpdated" + FILE_EDIT_PROPOSAL = "fileEditProposal" + FILE_VERSION = "fileVersion" + FILE_EDIT_REJECTED = "fileEditRejected" DATA_SOURCE_ACCESS = "dataSourceAccess" VOICE_RESPONSE = "voiceResponse" FINAL = "final" @@ -48,6 +52,10 @@ class ToolDefinition(BaseModel): default=None, description="Feature scope for this tool (None = available to all)" ) + toolSet: Optional[str] = Field( + default=None, + description="Tool-set scope (None = available to all sets, e.g. 'core', 'workspace')" + ) class ToolCallRequest(BaseModel): @@ -79,7 +87,6 @@ class AgentConfig(BaseModel): """Configuration for an agent run.""" maxRounds: int = Field(default=25, ge=1, le=100) maxCostCHF: Optional[float] = Field(default=None, ge=0.0) - entityCacheEnabled: bool = Field(default=False) toolSet: str = Field(default="core") temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0) @@ -130,3 +137,16 @@ class AgentTrace(BaseModel): totalCostCHF: float = 0.0 abortReason: Optional[str] = None rounds: List[AgentRoundLog] = Field(default_factory=list) + + +class PendingFileEdit(BaseModel): + """A proposed file edit awaiting user approval.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + fileId: str + fileName: str + mimeType: str = "" + oldContent: str = "" + newContent: str = "" + status: str = Field(default="pending", description="pending | accepted | rejected") + toolCallId: str = "" + workflowId: str = "" diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 7a088911..c642a2fa 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -160,7 +160,8 @@ class AgentService: ): if event.type == AgentEventTypeEnum.AGENT_SUMMARY: await self._persistTrace(workflowId, event.data or {}) - logger.debug(f"runAgent yielding event type={event.type}") + if event.type != AgentEventTypeEnum.CHUNK: + logger.debug(f"runAgent yielding event type={event.type}") yield event logger.info(f"runAgent loop completed for workflow {workflowId}") @@ -186,9 +187,32 @@ class AgentService: for fid in fileIds: try: info = chatService.getFileInfo(fid) - fileName = info.get("fileName", fid) if info else fid - mimeType = info.get("mimeType", "unknown") if info else "unknown" - fileSize = info.get("size", "?") if info else "?" + + if not info: + folderInfo = chatService.interfaceDbComponent.getFolder(fid) + if folderInfo: + folderName = folderInfo.get("name", fid) + folderFiles = chatService.listFiles(folderId=fid) + desc = f"### Folder: {folderName}\n - id: {fid}\n - type: folder\n - contains: {len(folderFiles)} file(s)" + if folderFiles: + desc += "\n - files:" + for ff in folderFiles[:30]: + ffName = ff.get("fileName", "?") + ffId = ff.get("id", "?") + ffMime = ff.get("mimeType", "?") + ffSize = ff.get("fileSize", ff.get("size", "?")) + desc += f"\n * {ffName} (id: {ffId}, type: {ffMime}, size: {ffSize} bytes)" + if len(folderFiles) > 30: + desc += f"\n ... and {len(folderFiles) - 30} more files" + desc += f'\nUse `listFiles(folderId="{fid}")` to get the full file list, then `readFile(fileId)` to read individual files.' + fileDescriptions.append(desc) + continue + fileDescriptions.append(f"### File id: {fid}") + continue + + fileName = info.get("fileName", fid) + mimeType = info.get("mimeType", "unknown") + fileSize = info.get("size", "?") desc = f"### File: {fileName}\n - id: {fid}\n - type: {mimeType}\n - size: {fileSize} bytes" @@ -226,10 +250,11 @@ class AgentService: if fileDescriptions: header = ( - "## Attached Files\n" - "These files have been uploaded and processed through the extraction pipeline.\n" + "## Attached Files & Folders\n" + "These files/folders have been uploaded and processed through the extraction pipeline.\n" "Use `readFile(fileId)` to read text content, `readContentObjects(fileId)` for structured access, " "or `describeImage(fileId)` for image analysis.\n" + "For folders, use `listFiles(folderId)` to get the files inside, then `readFile(fileId)` for each.\n" "When generating documents with `renderDocument`, embed images using `![alt text](file:fileId)` in the markdown content.\n\n" ) header += "\n\n".join(fileDescriptions) @@ -322,8 +347,27 @@ class AgentService: return _buildRagContext +def _getOrCreateTempFolder(chatService) -> Optional[str]: + """Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist.""" + try: + allFolders = chatService.interfaceDbComponent.listFolders() + tempFolder = next( + (f for f in allFolders + if f.get("name") == "Temp" and not f.get("parentId")), + None, + ) + if tempFolder: + return tempFolder.get("id") + newFolder = chatService.interfaceDbComponent.createFolder("Temp", parentId=None) + return newFolder.get("id") if newFolder else None + except Exception as e: + logger.warning(f"Could not get/create Temp folder: {e}") + return None + + def _registerCoreTools(registry: ToolRegistry, services): """Register built-in core tools: file operations, search, and folder management.""" + import uuid as _uuid from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult # ---- Read-only tools ---- @@ -369,7 +413,8 @@ def _registerCoreTools(registry: ToolRegistry, services): _BINARY_TYPES = ("application/pdf", "image/", "application/vnd.", "application/zip", "application/x-zip", "application/x-tar", "application/x-7z", - "application/msword", "application/octet-stream") + "application/msword", "application/octet-stream", + "message/rfc822") isBinary = any(mimeType.startswith(t) for t in _BINARY_TYPES) rawBytes = chatService.getFileData(fileId) @@ -730,6 +775,349 @@ def _registerCoreTools(registry: ToolRegistry, services): readOnly=False ) + # ---- Phase 1: deleteFile, renameFile, readUrl, translateText ---- + + async def _deleteFile(args: Dict[str, Any], context: Dict[str, Any]): + fileId = args.get("fileId", "") + if not fileId: + return ToolResult(toolCallId="", toolName="deleteFile", success=False, error="fileId is required") + try: + chatService = services.chat + file = chatService.interfaceDbComponent.getFile(fileId) + if not file: + return ToolResult(toolCallId="", toolName="deleteFile", success=False, error=f"File {fileId} not found") + fileName = file.fileName + try: + knowledgeService = services.getService("knowledge") + if knowledgeService and hasattr(knowledgeService, "removeFile"): + knowledgeService.removeFile(fileId) + except Exception: + pass + chatService.interfaceDbComponent.deleteFile(fileId) + return ToolResult( + toolCallId="", toolName="deleteFile", success=True, + data=f"File '{fileName}' (id: {fileId}) deleted", + sideEvents=[{"type": "fileDeleted", "data": {"fileId": fileId, "fileName": fileName}}], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="deleteFile", success=False, error=str(e)) + + async def _renameFile(args: Dict[str, Any], context: Dict[str, Any]): + fileId = args.get("fileId", "") + newName = args.get("newName", "") + if not fileId or not newName: + return ToolResult(toolCallId="", toolName="renameFile", success=False, error="fileId and newName are required") + try: + chatService = services.chat + chatService.interfaceDbComponent.updateFile(fileId, {"fileName": newName}) + return ToolResult( + toolCallId="", toolName="renameFile", success=True, + data=f"File {fileId} renamed to '{newName}'", + sideEvents=[{"type": "fileUpdated", "data": {"fileId": fileId, "fileName": newName}}], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="renameFile", success=False, error=str(e)) + + async def _readUrl(args: Dict[str, Any], context: Dict[str, Any]): + url = args.get("url", "") + if not url: + return ToolResult(toolCallId="", toolName="readUrl", success=False, error="url is required") + try: + webService = services.getService("web") + result = await webService._performWebCrawl( + instruction="Extract all content from this page", + urls=[url], + maxDepth=1, + maxWidth=1, + ) + if isinstance(result, list) and result: + content = "\n\n".join( + item.get("content", "") or item.get("text", "") or str(item) + for item in result if item + ) + elif isinstance(result, dict): + content = result.get("content", "") or result.get("summary", "") or str(result) + else: + content = str(result) if result else "No content retrieved" + _MAX = 30000 + if len(content) > _MAX: + content = content[:_MAX] + f"\n\n... (truncated at {_MAX} chars)" + return ToolResult(toolCallId="", toolName="readUrl", success=True, data=content) + except Exception as e: + return ToolResult(toolCallId="", toolName="readUrl", success=False, error=str(e)) + + async def _translateText(args: Dict[str, Any], context: Dict[str, Any]): + text = args.get("text", "") + targetLanguage = args.get("targetLanguage", "") + if not text or not targetLanguage: + return ToolResult(toolCallId="", toolName="translateText", success=False, error="text and targetLanguage are required") + try: + from modules.interfaces.interfaceVoiceObjects import getVoiceInterface + mandateId = context.get("mandateId", "") + voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId) + sourceLanguage = args.get("sourceLanguage", "auto") + result = await voiceInterface.translateText(text, sourceLanguage=sourceLanguage, targetLanguage=targetLanguage) + if result and result.get("success"): + translated = result.get("translated_text", "") + return ToolResult(toolCallId="", toolName="translateText", success=True, data=translated) + return ToolResult(toolCallId="", toolName="translateText", success=False, error=result.get("error", "Translation failed")) + except Exception as e: + return ToolResult(toolCallId="", toolName="translateText", success=False, error=str(e)) + + registry.register( + "deleteFile", _deleteFile, + description="Delete a file from the workspace. Use when the user asks to remove or delete a file.", + parameters={ + "type": "object", + "properties": { + "fileId": {"type": "string", "description": "The file ID to delete"}, + }, + "required": ["fileId"] + }, + readOnly=False + ) + + registry.register( + "renameFile", _renameFile, + description="Rename a file in the workspace.", + parameters={ + "type": "object", + "properties": { + "fileId": {"type": "string", "description": "The file ID to rename"}, + "newName": {"type": "string", "description": "New file name including extension"}, + }, + "required": ["fileId", "newName"] + }, + readOnly=False + ) + + registry.register( + "readUrl", _readUrl, + description=( + "Read and extract content from a specific URL. " + "Use when the user provides a specific URL to read, or when you need to fetch content from a known web page. " + "For general information searches, use webSearch instead." + ), + parameters={ + "type": "object", + "properties": { + "url": {"type": "string", "description": "The URL to read"}, + }, + "required": ["url"] + }, + readOnly=True + ) + + registry.register( + "translateText", _translateText, + description=( + "Translate text to a target language using Google Cloud Translation. " + "More efficient than AI translation for large text volumes. " + "Use ISO language codes (e.g. 'en', 'de', 'fr', 'es', 'it', 'pt', 'zh', 'ja', 'ko', 'ar')." + ), + parameters={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to translate"}, + "targetLanguage": {"type": "string", "description": "Target language ISO code (e.g. 'en', 'de', 'fr')"}, + "sourceLanguage": {"type": "string", "description": "Source language ISO code (default: auto-detect)"}, + }, + "required": ["text", "targetLanguage"] + }, + readOnly=True + ) + + # ---- Phase 2: deleteFolder, renameFolder, moveFolder, copyFile, editFile ---- + + async def _deleteFolder(args: Dict[str, Any], context: Dict[str, Any]): + folderId = args.get("folderId", "") + recursive = args.get("recursive", False) + if not folderId: + return ToolResult(toolCallId="", toolName="deleteFolder", success=False, error="folderId is required") + try: + chatService = services.chat + result = chatService.interfaceDbComponent.deleteFolder(folderId, recursive=recursive) + summary = f"Deleted {result.get('deletedFolders', 1)} folder(s) and {result.get('deletedFiles', 0)} file(s)" + return ToolResult( + toolCallId="", toolName="deleteFolder", success=True, data=summary, + sideEvents=[{"type": "folderDeleted", "data": {"folderId": folderId, **result}}], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="deleteFolder", success=False, error=str(e)) + + async def _renameFolder(args: Dict[str, Any], context: Dict[str, Any]): + folderId = args.get("folderId", "") + newName = args.get("newName", "") + if not folderId or not newName: + return ToolResult(toolCallId="", toolName="renameFolder", success=False, error="folderId and newName are required") + try: + chatService = services.chat + chatService.interfaceDbComponent.renameFolder(folderId, newName) + return ToolResult( + toolCallId="", toolName="renameFolder", success=True, + data=f"Folder {folderId} renamed to '{newName}'", + sideEvents=[{"type": "folderUpdated", "data": {"folderId": folderId, "name": newName}}], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="renameFolder", success=False, error=str(e)) + + async def _moveFolder(args: Dict[str, Any], context: Dict[str, Any]): + folderId = args.get("folderId", "") + targetParentId = args.get("targetParentId") + if not folderId: + return ToolResult(toolCallId="", toolName="moveFolder", success=False, error="folderId is required") + try: + chatService = services.chat + chatService.interfaceDbComponent.moveFolder(folderId, targetParentId) + return ToolResult( + toolCallId="", toolName="moveFolder", success=True, + data=f"Folder {folderId} moved to {targetParentId or 'root'}", + sideEvents=[{"type": "folderUpdated", "data": {"folderId": folderId, "parentId": targetParentId}}], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="moveFolder", success=False, error=str(e)) + + async def _copyFile(args: Dict[str, Any], context: Dict[str, Any]): + fileId = args.get("fileId", "") + if not fileId: + return ToolResult(toolCallId="", toolName="copyFile", success=False, error="fileId is required") + try: + chatService = services.chat + copiedFile = chatService.interfaceDbComponent.copyFile( + fileId, + targetFolderId=args.get("targetFolderId"), + newFileName=args.get("newFileName"), + ) + return ToolResult( + toolCallId="", toolName="copyFile", success=True, + data=f"File copied as '{copiedFile.fileName}' (id: {copiedFile.id})", + sideEvents=[{ + "type": "fileCreated", + "data": {"fileId": copiedFile.id, "fileName": copiedFile.fileName, + "mimeType": copiedFile.mimeType, "fileSize": copiedFile.fileSize}, + }], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="copyFile", success=False, error=str(e)) + + async def _editFile(args: Dict[str, Any], context: Dict[str, Any]): + fileId = args.get("fileId", "") + content = args.get("content", "") + if not fileId or not content: + return ToolResult(toolCallId="", toolName="editFile", success=False, error="fileId and content are required") + try: + chatService = services.chat + dbMgmt = chatService.interfaceDbComponent + file = dbMgmt.getFile(fileId) + if not file: + return ToolResult(toolCallId="", toolName="editFile", success=False, error=f"File {fileId} not found") + if not dbMgmt.isTextMimeType(file.mimeType): + return ToolResult( + toolCallId="", toolName="editFile", success=False, + error=f"Cannot edit binary file ({file.mimeType}). Only text-based files are supported." + ) + oldContent = "" + oldData = dbMgmt.getFileData(fileId) + if oldData: + try: + oldContent = oldData.decode("utf-8") + except UnicodeDecodeError: + oldContent = "" + + editId = str(_uuid.uuid4()) + return ToolResult( + toolCallId="", toolName="editFile", success=True, + data=f"Edit proposed for '{file.fileName}'. Waiting for user review.", + sideEvents=[{ + "type": "fileEditProposal", + "data": { + "id": editId, + "fileId": fileId, + "fileName": file.fileName, + "mimeType": file.mimeType, + "oldContent": oldContent, + "newContent": content, + }, + }], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="editFile", success=False, error=str(e)) + + registry.register( + "deleteFolder", _deleteFolder, + description="Delete a folder. Set recursive=true to delete folder with all contents.", + parameters={ + "type": "object", + "properties": { + "folderId": {"type": "string", "description": "The folder ID to delete"}, + "recursive": {"type": "boolean", "description": "If true, delete folder and all contents (files and subfolders). Default: false"}, + }, + "required": ["folderId"] + }, + readOnly=False + ) + + registry.register( + "renameFolder", _renameFolder, + description="Rename a folder. Folder names must be unique within their parent.", + parameters={ + "type": "object", + "properties": { + "folderId": {"type": "string", "description": "The folder ID to rename"}, + "newName": {"type": "string", "description": "New folder name"}, + }, + "required": ["folderId", "newName"] + }, + readOnly=False + ) + + registry.register( + "moveFolder", _moveFolder, + description="Move a folder to a different parent folder. Cannot move a folder into its own subtree.", + parameters={ + "type": "object", + "properties": { + "folderId": {"type": "string", "description": "The folder ID to move"}, + "targetParentId": {"type": "string", "description": "Target parent folder ID (null/omit for root)"}, + }, + "required": ["folderId"] + }, + readOnly=False + ) + + registry.register( + "copyFile", _copyFile, + description="Create a full copy of a file. The copy is independent and can be edited separately.", + parameters={ + "type": "object", + "properties": { + "fileId": {"type": "string", "description": "The file ID to copy"}, + "targetFolderId": {"type": "string", "description": "Target folder for the copy (default: same folder)"}, + "newFileName": {"type": "string", "description": "New file name (default: same name, auto-numbered if duplicate)"}, + }, + "required": ["fileId"] + }, + readOnly=False + ) + + registry.register( + "editFile", _editFile, + description=( + "Propose an edit to an existing text file. The change is shown to the user " + "for review (accept/reject) before being applied. Only works for text-based " + "files (text/*, application/json, etc.). For binary files, create a new file instead." + ), + parameters={ + "type": "object", + "properties": { + "fileId": {"type": "string", "description": "The file ID to edit"}, + "content": {"type": "string", "description": "New file content (replaces entire file content)"}, + }, + "required": ["fileId", "content"] + }, + readOnly=False + ) + # ---- Connection tools (external data sources) ---- def _buildResolverDb(): @@ -792,22 +1180,38 @@ def _registerCoreTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="externalDownload", success=False, error="connectionId, service, and path are required") try: from modules.connectors.connectorResolver import ConnectorResolver + from modules.connectors.connectorProviderBase import DownloadResult as _DR resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, service) - fileBytes = await adapter.download(path) + result = await adapter.download(path) + + if isinstance(result, _DR): + fileBytes = result.data + fileName = result.fileName or path.split("/")[-1] or "downloaded_file" + else: + fileBytes = result + fileName = path.split("/")[-1] or "downloaded_file" + if not fileBytes: return ToolResult(toolCallId="", toolName="externalDownload", success=False, error="Download returned empty") - fileName = path.split("/")[-1] or "downloaded_file" + chatService = services.chat fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName) + fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") + fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") + if fiId: + chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) + tempFolderId = _getOrCreateTempFolder(chatService) + if tempFolderId: + chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) ext = fileName.rsplit(".", 1)[-1].lower() if "." in fileName else "" - hint = "Use readFile to read text content." if ext in ("doc", "docx", "txt", "csv", "json", "xml", "html", "md", "rtf", "odt", "xls", "xlsx", "pptx") else "Use readFile to access the content." + hint = "Use readFile to read text content." if ext in ("doc", "docx", "txt", "csv", "json", "xml", "html", "md", "rtf", "odt", "xls", "xlsx", "pptx", "eml", "msg") else "Use readFile to access the content." return ToolResult( toolCallId="", toolName="externalDownload", success=True, - data=f"Downloaded '{fileName}' ({len(fileBytes)} bytes) → local file id: {fileItem.id}. {hint}" + data=f"Downloaded '{fileName}' ({len(fileBytes)} bytes) → local file id: {fid}. {hint}" ) except Exception as e: return ToolResult(toolCallId="", toolName="externalDownload", success=False, error=str(e)) @@ -996,6 +1400,8 @@ def _registerCoreTools(registry: ToolRegistry, services): logger.info(f"Resolved DataSource '{dsId}' ({label}): sourceType={sourceType}, service={service}, connectionId={connectionId}, path={path[:80]}") return connectionId, service, path + _MAIL_SERVICES = {"outlook", "gmail"} + async def _browseDataSource(args: Dict[str, Any], context: Dict[str, Any]): dsId = args.get("dataSourceId", "") subPath = args.get("subPath", "") @@ -1024,7 +1430,10 @@ def _registerCoreTools(registry: ToolRegistry, services): prefix = "[DIR]" if e.isFolder else "[FILE]" sizeInfo = f" ({e.size} bytes)" if e.size else "" lines.append(f"- {prefix} {e.name}{sizeInfo} path: {e.path}") - return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data="\n".join(lines)) + result = "\n".join(lines) + if service in _MAIL_SERVICES: + result += "\n\nIMPORTANT: These are email subjects only. To read the full email content, use downloadFromDataSource with the path, then readFile on the returned file ID." + return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data=result) except Exception as e: return ToolResult(toolCallId="", toolName="browseDataSource", success=False, error=str(e)) @@ -1045,7 +1454,10 @@ def _registerCoreTools(registry: ToolRegistry, services): if not entries: return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data="No results found.") lines = [f"- {e.name} (path: {e.path})" for e in entries] - return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data="\n".join(lines)) + result = "\n".join(lines) + if service in _MAIL_SERVICES: + result += "\n\nIMPORTANT: These are email subjects only. To read the full email content, use downloadFromDataSource with the path, then readFile on the returned file ID." + return ToolResult(toolCallId="", toolName="searchDataSource", success=True, data=result) except Exception as e: return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error=str(e)) @@ -1056,17 +1468,26 @@ def _registerCoreTools(registry: ToolRegistry, services): if not dsId or not filePath: return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error="dataSourceId and filePath are required") try: + from modules.connectors.connectorResolver import ConnectorResolver + from modules.connectors.connectorProviderBase import DownloadResult as _DR connectionId, service, basePath = await _resolveDataSource(dsId) fullPath = filePath if filePath.startswith("/") else f"{basePath.rstrip('/')}/{filePath}" - from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, service) - fileBytes = await adapter.download(fullPath) + result = await adapter.download(fullPath) + + if isinstance(result, _DR): + fileBytes = result.data + fileName = result.fileName or fileName + else: + fileBytes = result + if not fileBytes: return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error="Download returned empty") + if not fileName or "." not in fileName: pathSegment = fullPath.split("/")[-1] or "downloaded_file" fileName = fileName or pathSegment @@ -1081,16 +1502,20 @@ def _registerCoreTools(registry: ToolRegistry, services): except Exception: pass if "." not in fileName: - import mimetypes as _mt - guessed = _mt.guess_type(f"file.{_mt.guess_extension('application/octet-stream') or ''}")[0] - if not guessed and fileBytes[:4] == b"%PDF": + if fileBytes[:4] == b"%PDF": fileName = f"{fileName}.pdf" - elif not guessed and fileBytes[:2] == b"PK": + elif fileBytes[:2] == b"PK": fileName = f"{fileName}.zip" chatService = services.chat fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName) + fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") + if fiId: + chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId}) + tempFolderId = _getOrCreateTempFolder(chatService) + if tempFolderId: + chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId}) ext = fileName.rsplit(".", 1)[-1].lower() if "." in fileName else "" - hint = "Use readFile to read the text content." if ext in ("doc", "docx", "txt", "csv", "json", "xml", "html", "md", "rtf", "odt", "xls", "xlsx", "pptx", "pdf") else "Use readFile to access the content." + hint = "Use readFile to read the text content." if ext in ("doc", "docx", "txt", "csv", "json", "xml", "html", "md", "rtf", "odt", "xls", "xlsx", "pptx", "pdf", "eml", "msg") else "Use readFile to access the content." return ToolResult( toolCallId="", toolName="downloadFromDataSource", success=True, data=f"Downloaded '{fileName}' ({len(fileBytes)} bytes) → local file id: {fileItem.id}. {hint}" @@ -1129,7 +1554,7 @@ def _registerCoreTools(registry: ToolRegistry, services): registry.register( "downloadFromDataSource", _downloadFromDataSource, - description="Download a file from an attached data source into local storage. Returns the local file ID which can then be read with readFile. Always provide the fileName if known from the browse results.", + description="Download a file or email message from an attached data source into local storage. Returns the local file ID which can then be read with readFile. For email sources (Outlook, Gmail), this downloads the full email content -- browse/search only return subjects. Always provide the fileName if known.", parameters={ "type": "object", "properties": { @@ -1234,7 +1659,7 @@ def _registerCoreTools(registry: ToolRegistry, services): result = await knowledgeService.extractContainerItem(fileId, containerPath) if result: return ToolResult(toolCallId="", toolName="extractContainerItem", success=True, data=str(result)) - return ToolResult(toolCallId="", toolName="extractContainerItem", success=True, data=f"On-demand extraction for '{containerPath}' queued.") + return ToolResult(toolCallId="", toolName="extractContainerItem", success=False, error=f"Item '{containerPath}' not found in container index for file {fileId}. On-demand extraction is not yet implemented.") except Exception as e: return ToolResult(toolCallId="", toolName="extractContainerItem", success=False, error=str(e)) @@ -1710,6 +2135,9 @@ def _registerCoreTools(registry: ToolRegistry, services): fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") if fiId: chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) + tempFolderId = _getOrCreateTempFolder(chatService) + if tempFolderId: + chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) savedFiles.append(f"- {docName} (id: {fid})") sideEvents.append({ "type": "fileCreated", @@ -1947,6 +2375,9 @@ def _registerCoreTools(registry: ToolRegistry, services): fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") if fiId: chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) + tempFolderId = _getOrCreateTempFolder(chatService) + if tempFolderId: + chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) savedFiles.append(f"- {docName} (id: {fid})") sideEvents.append({ "type": "fileCreated", @@ -1984,3 +2415,144 @@ def _registerCoreTools(registry: ToolRegistry, services): }, readOnly=False, ) + + # ── Phase 3: speechToText, detectLanguage, neutralizeData, executeCode ── + + async def _speechToText(args: Dict[str, Any], context: Dict[str, Any]): + fileId = args.get("fileId", "") + if not fileId: + return ToolResult(toolCallId="", toolName="speechToText", success=False, error="fileId is required") + try: + chatService = services.chat + audioData = chatService.interfaceDbComponent.getFileData(fileId) + if not audioData: + return ToolResult(toolCallId="", toolName="speechToText", success=False, error=f"No data found for file {fileId}") + from modules.interfaces.interfaceVoiceObjects import getVoiceInterface + mandateId = context.get("mandateId", "") + voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId) + language = args.get("language", "de-DE") + result = await voiceInterface.speechToText(audioData, language=language) + if result and result.get("success"): + transcript = result.get("text", "") + confidence = result.get("confidence", 0) + return ToolResult( + toolCallId="", toolName="speechToText", success=True, + data=f"Transcript (confidence: {confidence:.0%}):\n{transcript}" + ) + return ToolResult(toolCallId="", toolName="speechToText", success=False, error=result.get("error", "Transcription failed")) + except Exception as e: + return ToolResult(toolCallId="", toolName="speechToText", success=False, error=str(e)) + + async def _detectLanguage(args: Dict[str, Any], context: Dict[str, Any]): + text = args.get("text", "") + if not text: + return ToolResult(toolCallId="", toolName="detectLanguage", success=False, error="text is required") + try: + from modules.interfaces.interfaceVoiceObjects import getVoiceInterface + mandateId = context.get("mandateId", "") + voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId) + result = await voiceInterface.detectLanguage(text) + if result and result.get("success"): + lang = result.get("language", "unknown") + return ToolResult(toolCallId="", toolName="detectLanguage", success=True, data=f"Detected language: {lang}") + return ToolResult(toolCallId="", toolName="detectLanguage", success=False, error=result.get("error", "Detection failed")) + except Exception as e: + return ToolResult(toolCallId="", toolName="detectLanguage", success=False, error=str(e)) + + async def _neutralizeData(args: Dict[str, Any], context: Dict[str, Any]): + text = args.get("text", "") + fileId = args.get("fileId", "") + if not text and not fileId: + return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="text or fileId is required") + try: + neutralizationService = services.getService("neutralization") + if not neutralizationService: + return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization service not available") + if text: + result = neutralizationService.processText(text) + else: + result = neutralizationService.processFile(fileId) + if result: + neutralized = result.get("neutralized_text", "") or result.get("result", str(result)) + return ToolResult(toolCallId="", toolName="neutralizeData", success=True, data=neutralized) + return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization returned no result") + except Exception as e: + return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error=str(e)) + + async def _executeCode(args: Dict[str, Any], context: Dict[str, Any]): + code = args.get("code", "") + language = args.get("language", "python") + if not code: + return ToolResult(toolCallId="", toolName="executeCode", success=False, error="code is required") + if language != "python": + return ToolResult(toolCallId="", toolName="executeCode", success=False, error=f"Language '{language}' not supported. Only 'python' is available.") + try: + from modules.serviceCenter.services.serviceAgent.sandboxExecutor import executePython + result = await executePython(code) + if result.get("success"): + output = result.get("output", "(no output)") + return ToolResult(toolCallId="", toolName="executeCode", success=True, data=output) + error = result.get("error", "Execution failed") + tb = result.get("traceback", "") + return ToolResult(toolCallId="", toolName="executeCode", success=False, error=f"{error}\n{tb}" if tb else error) + except Exception as e: + return ToolResult(toolCallId="", toolName="executeCode", success=False, error=str(e)) + + registry.register( + "speechToText", _speechToText, + description="Transcribe an audio file to text. Provide the fileId of an audio file from the workspace.", + parameters={ + "type": "object", + "properties": { + "fileId": {"type": "string", "description": "Audio file ID from the workspace"}, + "language": {"type": "string", "description": "BCP-47 language code (e.g. 'de-DE', 'en-US'). Default: 'de-DE'"}, + }, + "required": ["fileId"] + }, + readOnly=True + ) + + registry.register( + "detectLanguage", _detectLanguage, + description="Detect the language of a text.", + parameters={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to analyze"}, + }, + "required": ["text"] + }, + readOnly=True + ) + + registry.register( + "neutralizeData", _neutralizeData, + description="Anonymize/neutralize text or file content. Replaces personal data (names, addresses, etc.) with placeholders. Does not modify the original.", + parameters={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to anonymize"}, + "fileId": {"type": "string", "description": "File ID to anonymize (alternative to text)"}, + }, + }, + readOnly=True + ) + + registry.register( + "executeCode", _executeCode, + description=( + "Execute Python code in a sandboxed environment for calculations and data analysis. " + "Available modules: math, statistics, json, csv, re, datetime, collections, itertools, functools, decimal, fractions, random. " + "No file system, network, or OS access. Max 30s execution time. " + "Use print() to produce output." + ), + parameters={ + "type": "object", + "properties": { + "code": {"type": "string", "description": "Python code to execute"}, + "language": {"type": "string", "description": "Programming language (only 'python' supported)", "default": "python"}, + }, + "required": ["code"] + }, + readOnly=True + ) diff --git a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py new file mode 100644 index 00000000..1882d7eb --- /dev/null +++ b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py @@ -0,0 +1,108 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Sandboxed code execution for the AI agent executeCode tool.""" + +import logging +import signal +import sys +import io +import traceback +from typing import Dict, Any + +logger = logging.getLogger(__name__) + +_PYTHON_ALLOWED_MODULES = { + "math", "statistics", "json", "csv", "re", "datetime", + "collections", "itertools", "functools", "decimal", "fractions", + "random", "string", "textwrap", "operator", "copy", +} + +_PYTHON_BLOCKED_BUILTINS = { + "open", "exec", "eval", "compile", "__import__", "globals", "locals", + "getattr", "setattr", "delattr", "breakpoint", "exit", "quit", + "input", "memoryview", "type", +} + +_MAX_EXECUTION_TIME_S = 30 +_MAX_OUTPUT_CHARS = 50000 + + +def _safeImport(name, *args, **kwargs): + """Restricted import that only allows whitelisted modules.""" + if name not in _PYTHON_ALLOWED_MODULES: + raise ImportError(f"Module '{name}' is not allowed. Permitted: {', '.join(sorted(_PYTHON_ALLOWED_MODULES))}") + return __builtins__["__import__"](name, *args, **kwargs) if isinstance(__builtins__, dict) else __import__(name, *args, **kwargs) + + +def _buildRestrictedGlobals() -> Dict[str, Any]: + """Build a restricted globals dict for exec().""" + import builtins + safeBuiltins = {} + for name in dir(builtins): + if name.startswith("_"): + continue + if name in _PYTHON_BLOCKED_BUILTINS: + continue + safeBuiltins[name] = getattr(builtins, name) + + safeBuiltins["__import__"] = _safeImport + safeBuiltins["__name__"] = "__sandbox__" + safeBuiltins["__builtins__"] = safeBuiltins + + for modName in _PYTHON_ALLOWED_MODULES: + try: + safeBuiltins[modName] = __import__(modName) + except ImportError: + pass + + return {"__builtins__": safeBuiltins} + + +async def executePython(code: str) -> Dict[str, Any]: + """Execute Python code in a restricted sandbox. Returns {success, output, error}.""" + import asyncio + + def _run(): + restrictedGlobals = _buildRestrictedGlobals() + capturedOutput = io.StringIO() + oldStdout = sys.stdout + oldStderr = sys.stderr + + try: + sys.stdout = capturedOutput + sys.stderr = capturedOutput + + if sys.platform != "win32": + signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError("Execution timed out"))) + signal.alarm(_MAX_EXECUTION_TIME_S) + + exec(compile(code, "", "exec"), restrictedGlobals) + + if sys.platform != "win32": + signal.alarm(0) + + output = capturedOutput.getvalue() + if len(output) > _MAX_OUTPUT_CHARS: + output = output[:_MAX_OUTPUT_CHARS] + f"\n... (truncated at {_MAX_OUTPUT_CHARS} chars)" + return {"success": True, "output": output} + + except TimeoutError: + return {"success": False, "error": f"Execution timed out after {_MAX_EXECUTION_TIME_S}s"} + except Exception as e: + tb = traceback.format_exc() + return {"success": False, "error": f"{type(e).__name__}: {e}", "traceback": tb} + finally: + sys.stdout = oldStdout + sys.stderr = oldStderr + if sys.platform != "win32": + signal.alarm(0) + + loop = asyncio.get_event_loop() + try: + result = await asyncio.wait_for( + loop.run_in_executor(None, _run), + timeout=_MAX_EXECUTION_TIME_S + 5, + ) + return result + except asyncio.TimeoutError: + return {"success": False, "error": f"Execution timed out after {_MAX_EXECUTION_TIME_S}s"} diff --git a/modules/serviceCenter/services/serviceAgent/toolRegistry.py b/modules/serviceCenter/services/serviceAgent/toolRegistry.py index 625001fb..d241bb93 100644 --- a/modules/serviceCenter/services/serviceAgent/toolRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolRegistry.py @@ -22,7 +22,8 @@ class ToolRegistry: def register(self, name: str, handler: Callable[..., Awaitable[ToolResult]], description: str = "", parameters: Dict[str, Any] = None, - readOnly: bool = False, featureType: str = None): + readOnly: bool = False, featureType: str = None, + toolSet: str = None): """Register a tool with its handler function.""" if name in self._tools: logger.warning(f"Tool '{name}' already registered, overwriting") @@ -32,10 +33,11 @@ class ToolRegistry: description=description, parameters=parameters or {}, readOnly=readOnly, - featureType=featureType + featureType=featureType, + toolSet=toolSet, ) self._handlers[name] = handler - logger.debug(f"Registered tool: {name} (readOnly={readOnly})") + logger.debug(f"Registered tool: {name} (readOnly={readOnly}, toolSet={toolSet})") def registerFromDefinition(self, definition: ToolDefinition, handler: Callable[..., Awaitable[ToolResult]]): @@ -54,6 +56,8 @@ class ToolRegistry: tools = list(self._tools.values()) if featureType: tools = [t for t in tools if t.featureType is None or t.featureType == featureType] + if toolSet: + tools = [t for t in tools if t.toolSet is None or t.toolSet == toolSet] return tools def getToolNames(self) -> List[str]: diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py index a24af9a9..17ff2da2 100644 --- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py +++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py @@ -7,7 +7,7 @@ import time import base64 from typing import Dict, Any, List, Optional, Tuple, Callable from modules.datamodels.datamodelChat import PromptPlaceholder, ChatDocument, WorkflowModeEnum -from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum +from modules.datamodels.datamodelAi import AiCallRequest, AiCallResponse, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent from modules.datamodels.datamodelWorkflow import AiResponse, AiResponseMetadata, DocumentData from modules.datamodels.datamodelDocument import RenderedDocument @@ -198,6 +198,19 @@ class AiService: finally: self.aiObjects.billingCallback = None + async def callEmbedding(self, texts: List[str]) -> AiCallResponse: + """Generate embeddings while respecting allowedProviders.""" + await self.ensureAiObjectsInitialized() + options = AiCallOptions(operationType=OperationTypeEnum.EMBEDDING) + effectiveProviders = self._calculateEffectiveProviders() + if effectiveProviders: + options.allowedProviders = effectiveProviders + self.aiObjects.billingCallback = self._createBillingCallback() + try: + return await self.aiObjects.callEmbedding(texts, options) + finally: + self.aiObjects.billingCallback = None + # ========================================================================= # SPEECH_TEAMS: Dedicated handler for Teams Meeting AI analysis # Bypasses standard model selection. Uses a fixed fast model. diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py index 7a6951ae..ac475251 100644 --- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py +++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py @@ -42,7 +42,7 @@ def getService(currentUser: User, mandateId: str, featureInstanceId: str = None, currentUser: Current user object mandateId: Mandate ID for context featureInstanceId: Optional feature instance ID - featureCode: Optional feature code (e.g., 'chatplayground', 'automation') + featureCode: Optional feature code (e.g., 'automation') Returns: BillingService instance diff --git a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py index 692e5087..9f3f7e68 100644 --- a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py +++ b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py @@ -7,6 +7,7 @@ Creates Checkout Sessions for redirect-based payment flow. import logging from typing import Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from modules.shared.configuration import APP_CONFIG @@ -16,10 +17,45 @@ logger = logging.getLogger(__name__) ALLOWED_AMOUNTS_CHF = [10, 25, 50, 100, 250, 500] +def _normalizeReturnUrl(returnUrl: str) -> str: + """ + Validate and normalize an absolute frontend return URL. + + Allowed examples: + - https://nyla.poweron-center.net/billing/transactions + - https://nyla-int.poweron-center.net/billing/transactions?tab=overview + """ + if not returnUrl: + raise ValueError("returnUrl is required") + + parsed = urlsplit(returnUrl.strip()) + + if parsed.scheme not in ("http", "https"): + raise ValueError("returnUrl must use http or https") + + if not parsed.netloc: + raise ValueError("returnUrl must contain a host") + + if parsed.username or parsed.password: + raise ValueError("returnUrl must not contain credentials") + + query_items = [ + (key, value) + for key, value in parse_qsl(parsed.query, keep_blank_values=True) + if key not in {"success", "canceled", "session_id"} + ] + normalized_query = urlencode(query_items, doseq=True) + normalized_path = parsed.path or "/" + + # Keep scheme + host + path + query, strip fragment for deterministic redirects. + return urlunsplit((parsed.scheme, parsed.netloc, normalized_path, normalized_query, "")) + + def create_checkout_session( mandate_id: str, user_id: Optional[str], - amount_chf: float + amount_chf: float, + return_url: str ) -> str: """ Create a Stripe Checkout Session for credit top-up. @@ -58,10 +94,10 @@ def create_checkout_session( stripe.api_key = secret_key - frontend_url = APP_CONFIG.get("APP_FRONTEND_URL", "https://nyla-int.poweron-center.net") - base_path = "/admin/billing" - success_url = f"{frontend_url.rstrip('/')}{base_path}?success=true&session_id={{CHECKOUT_SESSION_ID}}" - cancel_url = f"{frontend_url.rstrip('/')}{base_path}?canceled=true" + base_return_url = _normalizeReturnUrl(return_url) + query_separator = "&" if "?" in base_return_url else "?" + success_url = f"{base_return_url}{query_separator}success=true&session_id={{CHECKOUT_SESSION_ID}}" + cancel_url = f"{base_return_url}{query_separator}canceled=true" # Amount in cents for Stripe (CHF uses 2 decimal places) amount_cents = int(round(amount_chf * 100)) diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 026dc70c..3ec1c504 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -492,24 +492,17 @@ class ChatService: """List file folders for the current user. Args: - parentId: Parent folder ID (None = root folders). + parentId: Optional parent folder ID to filter by. + None = return ALL folders (for tree building). Returns: List of folder dicts. """ - from modules.datamodels.datamodelFileFolder import FileFolder - recordFilter = {"_createdBy": self.user.id if self.user else ""} - if parentId is not None: - recordFilter["parentId"] = parentId - else: - recordFilter["parentId"] = None - return self.interfaceDbComponent.db.getRecordset(FileFolder, recordFilter=recordFilter) + return self.interfaceDbComponent.listFolders(parentId=parentId) def createFolder(self, name: str, parentId: str = None) -> Dict[str, Any]: - """Create a new file folder.""" - from modules.datamodels.datamodelFileFolder import FileFolder - folder = FileFolder(name=name, parentId=parentId) - return self.interfaceDbComponent.db.recordCreate(FileFolder, folder) + """Create a new file folder with unique name validation.""" + return self.interfaceDbComponent.createFolder(name=name, parentId=parentId) # ---- DataSource CRUD ---- diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py index e580b07d..850f6aa8 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py @@ -545,6 +545,10 @@ class RendererDocx(BaseRenderer): # Append table to document body body.append(tbl) + # Add an empty paragraph after the table to prevent Word from merging consecutive tables + separatorParagraph = OxmlElement('w:p') + body.append(separatorParagraph) + total_time = time.time() - create_start totalCells = (rowCount + 1) * len(headers) rate = totalCells / total_time if total_time > 0 else 0 @@ -1352,6 +1356,9 @@ class RendererDocx(BaseRenderer): # Style the table self._styleTable(table) + # Add an empty paragraph after the table to prevent Word from merging consecutive tables + doc.add_paragraph() + except Exception as e: self.logger.warning(f"Could not add table: {str(e)}") @@ -1505,6 +1512,9 @@ class RendererDocx(BaseRenderer): for run in paragraph.runs: run.bold = True + # Add an empty paragraph after the table to prevent Word from merging consecutive tables + doc.add_paragraph() + # Add placeholder to mark where table was inserted processed_lines.append(f"[TABLE_INSERTED_{len(processed_lines)}]") diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py index a2dabadf..91e85da4 100644 --- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py +++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py @@ -3,6 +3,7 @@ """Knowledge service: 3-tier RAG with indexing, semantic search, and context building.""" import logging +import re from typing import Any, Callable, Dict, List, Optional from modules.datamodels.datamodelKnowledge import ( @@ -14,7 +15,8 @@ from modules.shared.timeUtils import getUtcTimestamp logger = logging.getLogger(__name__) -DEFAULT_CHUNK_SIZE = 512 +CHARS_PER_TOKEN = 4 +DEFAULT_CHUNK_TOKENS = 400 DEFAULT_CONTEXT_BUDGET = 8000 @@ -31,14 +33,9 @@ class KnowledgeService: # ========================================================================= async def _embed(self, texts: List[str]) -> List[List[float]]: - """Embed texts via the AI interface's generic embedding method.""" + """Embed texts via AiService (respects allowedProviders).""" aiService = self._getService("ai") - await aiService.ensureAiObjectsInitialized() - aiObjects = aiService.aiObjects - if aiObjects is None: - logger.warning("Embedding skipped: aiObjects not available") - return [] - response = await aiObjects.callEmbedding(texts) + response = await aiService.callEmbedding(texts) if response.errorCount > 0: logger.error(f"Embedding failed: {response.content}") return [] @@ -115,9 +112,16 @@ class KnowledgeService: textObjects = [o for o in contentObjects if o.get("contentType") == "text"] if textObjects: self._knowledgeDb.updateFileStatus(fileId, "embedding") - chunks = _chunkForEmbedding(textObjects, chunkSize=DEFAULT_CHUNK_SIZE) + chunks = _chunkForEmbedding(textObjects, maxTokens=DEFAULT_CHUNK_TOKENS) texts = [c["data"] for c in chunks] + totalChars = sum(len(t) for t in texts) + estTokens = totalChars // CHARS_PER_TOKEN + logger.info( + f"Embedding file {fileId}: {len(textObjects)} text objects -> " + f"{len(chunks)} chunks, ~{estTokens} tokens total" + ) + embeddings = await self._embed(texts) if texts else [] for i, chunk in enumerate(chunks): @@ -428,49 +432,77 @@ class KnowledgeService: # Internal helpers # ============================================================================= +def _estimateTokens(text: str) -> int: + """Estimate token count using character-based heuristic (1 token ~ 4 chars).""" + return max(1, len(text) // CHARS_PER_TOKEN) + + +def _splitSentences(text: str) -> List[str]: + """Split text into sentences at common boundaries (.!?) followed by whitespace.""" + parts = re.split(r'(?<=[.!?])\s+', text.replace("\n", " ").strip()) + return [p for p in parts if p.strip()] + + +def _hardSplitByTokens(text: str, maxTokens: int) -> List[str]: + """Force-split text into pieces that each fit within maxTokens. + + Used as safety net when sentence splitting produces oversized segments. + Splits at word boundaries where possible. + """ + maxChars = maxTokens * CHARS_PER_TOKEN + pieces = [] + while len(text) > maxChars: + splitAt = text.rfind(" ", 0, maxChars) + if splitAt <= 0: + splitAt = maxChars + pieces.append(text[:splitAt].strip()) + text = text[splitAt:].strip() + if text: + pieces.append(text) + return pieces + + def _chunkForEmbedding( - textObjects: List[Dict[str, Any]], chunkSize: int = 512 + textObjects: List[Dict[str, Any]], maxTokens: int = DEFAULT_CHUNK_TOKENS ) -> List[Dict[str, Any]]: - """Split text content objects into chunks suitable for embedding. + """Split text content objects into token-aware chunks suitable for embedding. Each chunk preserves the contextRef from its source object. - Long texts are split at sentence boundaries where possible. + Splits at sentence boundaries; applies hard-cap if a single sentence exceeds maxTokens. """ chunks = [] for obj in textObjects: - text = obj.get("data", "") + text = (obj.get("data", "") or "").strip() + if not text: + continue contentObjectId = obj.get("contentObjectId", "") contextRef = obj.get("contextRef", {}) - if len(text) <= chunkSize: - chunks.append({ - "data": text, - "contentObjectId": contentObjectId, - "contextRef": contextRef, - }) + if _estimateTokens(text) <= maxTokens: + chunks.append({"data": text, "contentObjectId": contentObjectId, "contextRef": contextRef}) continue - # Split at sentence boundaries - sentences = text.replace("\n", " ").split(". ") + sentences = _splitSentences(text) currentChunk = "" for sentence in sentences: - candidate = f"{currentChunk}. {sentence}" if currentChunk else sentence - if len(candidate) > chunkSize and currentChunk: - chunks.append({ - "data": currentChunk.strip(), - "contentObjectId": contentObjectId, - "contextRef": contextRef, - }) + if _estimateTokens(sentence) > maxTokens: + if currentChunk.strip(): + chunks.append({"data": currentChunk.strip(), "contentObjectId": contentObjectId, "contextRef": contextRef}) + currentChunk = "" + for piece in _hardSplitByTokens(sentence, maxTokens): + chunks.append({"data": piece, "contentObjectId": contentObjectId, "contextRef": contextRef}) + continue + + candidate = f"{currentChunk} {sentence}".strip() if currentChunk else sentence + if _estimateTokens(candidate) > maxTokens: + if currentChunk.strip(): + chunks.append({"data": currentChunk.strip(), "contentObjectId": contentObjectId, "contextRef": contextRef}) currentChunk = sentence else: currentChunk = candidate if currentChunk.strip(): - chunks.append({ - "data": currentChunk.strip(), - "contentObjectId": contentObjectId, - "contextRef": contextRef, - }) + chunks.append({"data": currentChunk.strip(), "contentObjectId": contentObjectId, "contextRef": contextRef}) return chunks diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 3268ffa3..35f06ed1 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -437,16 +437,6 @@ RESOURCE_OBJECTS = [ "label": {"en": "Store: Automation", "de": "Store: Automation", "fr": "Store: Automatisation"}, "meta": {"category": "store", "featureCode": "automation"} }, - { - "objectKey": "resource.store.chatplayground", - "label": {"en": "Store: Chat Playground", "de": "Store: Chat Playground", "fr": "Store: Chat Playground"}, - "meta": {"category": "store", "featureCode": "chatplayground"} - }, - { - "objectKey": "resource.store.codeeditor", - "label": {"en": "Store: Code Editor", "de": "Store: Code Editor", "fr": "Store: Code Editor"}, - "meta": {"category": "store", "featureCode": "codeeditor"} - }, { "objectKey": "resource.store.teamsbot", "label": {"en": "Store: Teams Bot", "de": "Store: Teams Bot", "fr": "Store: Teams Bot"}, diff --git a/modules/workflows/automation/__init__.py b/modules/workflows/automation/__init__.py index ce459ef8..89022240 100644 --- a/modules/workflows/automation/__init__.py +++ b/modules/workflows/automation/__init__.py @@ -1,11 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Workflow feature - handles workflow execution, scheduling, and chat playground operations. - -Combines functionality from: -- automation: Automation workflow execution and scheduling -- chatPlayground: Chat playground workflow start/stop operations +Workflow feature - handles workflow execution, scheduling, and start/stop operations. """ from .mainWorkflow import chatStart, chatStop, executeAutomation, syncAutomationEvents, createAutomationEventHandler diff --git a/modules/workflows/automation/mainWorkflow.py b/modules/workflows/automation/mainWorkflow.py index 625384d7..19473c01 100644 --- a/modules/workflows/automation/mainWorkflow.py +++ b/modules/workflows/automation/mainWorkflow.py @@ -1,11 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Main workflow service - handles workflow execution, scheduling, and chat playground operations. - -Combines functionality from: -- mainAutomation.py: Automation workflow execution and scheduling -- mainChatPlayground.py: Chat playground workflow start/stop operations +Main workflow service - handles workflow execution, scheduling, and start/stop operations. """ import logging @@ -35,11 +31,11 @@ async def chatStart(currentUser: User, userInput: UserInputRequest, workflowMode workflowMode: Workflow mode (Dynamic, Automation, etc.) mandateId: Mandate ID (required for billing) featureInstanceId: Feature instance ID (required for billing) - featureCode: Feature code (e.g., 'chatplayground', 'automation') + featureCode: Feature code (e.g., 'automation') services: Pre-built service hub from the calling feature (required). Each feature must pass its own services. """ if services is None: - raise ValueError("services is required: each feature must pass its own service hub (e.g. getChatplaygroundServices, getAutomationServices)") + raise ValueError("services is required: each feature must pass its own service hub (e.g. getAutomationServices)") try: # Store allowedProviders in services context for model selection @@ -61,7 +57,7 @@ async def chatStart(currentUser: User, userInput: UserInputRequest, workflowMode async def chatStop(currentUser: User, workflowId: str, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None, featureCode: Optional[str] = None, services=None) -> ChatWorkflow: """Stops a running chat. Caller must pass services from the owning feature.""" if services is None: - raise ValueError("services is required: each feature must pass its own service hub (e.g. getChatplaygroundServices, getAutomationServices)") + raise ValueError("services is required: each feature must pass its own service hub (e.g. getAutomationServices)") try: if featureCode: services.featureCode = featureCode diff --git a/scripts/migrate_async_to_sync.py b/scripts/migrate_async_to_sync.py index d2b030a4..4e18b3ea 100644 --- a/scripts/migrate_async_to_sync.py +++ b/scripts/migrate_async_to_sync.py @@ -76,10 +76,6 @@ _MUST_STAY_ASYNC: Dict[str, Set[str]] = { "event_stream", # await request.is_disconnected(), await asyncio.wait_for(...) "stop_chatbot", # await event_manager.emit_event(...) }, - "modules/features/chatplayground/routeFeatureChatplayground.py": { - "start_workflow", # await chatStart(...) - "stop_workflow", # await chatStop(...) - }, "modules/features/neutralization/routeFeatureNeutralizer.py": { "process_sharepoint_files", # await service.processSharepointFiles(...) }, @@ -105,7 +101,6 @@ _SKIP_FILES: Set[str] = { # Helper functions that are fake-async (async def but no await inside) # These will be converted from async def -> def _FAKE_ASYNC_HELPERS: Dict[str, Set[str]] = { - "modules/features/chatplayground/routeFeatureChatplayground.py": {"_validateInstanceAccess"}, "modules/features/trustee/routeFeatureTrustee.py": {"_validateInstanceAccess", "_validateInstanceAdmin"}, "modules/features/realestate/routeFeatureRealEstate.py": {"_validateInstanceAccess"}, "modules/features/chatbot/routeFeatureChatbot.py": {"_validateInstanceAccess"}, diff --git a/tests/functional/test03_ai_operations.py b/tests/functional/test03_ai_operations.py index 169d3ba6..4df53c5d 100644 --- a/tests/functional/test03_ai_operations.py +++ b/tests/functional/test03_ai_operations.py @@ -96,7 +96,7 @@ class MethodAiOperationsTester: import logging logging.getLogger().setLevel(logging.DEBUG) - # Import and initialize services - use the same approach as routeChatPlayground + # Import and initialize services import modules.interfaces.interfaceDbChat as interfaceFeatureAiChat interfaceDbChat = interfaceDbChat.getInterface(self.testUser)