diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index 2136d8e0..93cc27a2 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -3290,16 +3290,50 @@ class TeamsbotService: self, lastToolLabel: Optional[str] = None ) -> Optional[str]: """Per-round progress notice for long agent runs (meeting voice / - chat, ephemeral). Phrasing is AI-localised once per session; - ``{activity}`` placeholder is substituted with the tool's - ``displayLabel`` from the ToolDefinition. Returns ``None`` if - generation failed.""" - activity = lastToolLabel or "processing your request" - return await self._pickEphemeralPhrase( - "agentRound", - substitutions={"activity": activity}, + chat, ephemeral). Generates a single short phrase in the bot's + configured language that describes the current activity. Unlike + the cached ephemeral phrases, this is a per-call AI generation + to avoid mixing English displayLabels into non-English speech.""" + targetLang = (self.config.language or "").strip() or "en-US" + botName = (self.config.botName or "the assistant").strip() + activityHint = lastToolLabel or "working on the task" + + prompt = ( + f"You are a meeting assistant named '{botName}'.\n" + f"Target spoken language (BCP-47): {targetLang}\n\n" + f"The assistant is currently busy with: {activityHint}\n\n" + f"Generate ONE short sentence (max 12 words) in {targetLang} " + f"that tells the audience what the assistant is doing right now. " + f"Natural, spoken style. No step numbers. No quotes around the output.\n" + f"Output ONLY the sentence, nothing else." ) + try: + aiService = createAiService( + self.currentUser, self.mandateId, self.instanceId + ) + await aiService.ensureAiObjectsInitialized() + request = AiCallRequest( + prompt=prompt, + context="", + options=AiCallOptions( + operationType=OperationTypeEnum.DATA_ANALYSE, + priority=PriorityEnum.SPEED, + ), + ) + response = await aiService.callAi(request) + except Exception as aiErr: + logger.debug(f"Agent round phrase generation failed: {aiErr}") + return None + + if not response or response.errorCount != 0 or not response.content: + return None + + result = response.content.strip().strip('"').strip("'") + if len(result) > 200: + result = result[:200] + return result + async def _notifyMeetingEphemeral(self, sessionId: str, text: str) -> None: """Deliver a short line to the meeting (TTS + chat per config) without persisting botResponses/transcripts, so the main agent answer stays the @@ -3455,6 +3489,18 @@ class TeamsbotService: "promptId": promptId, "status": "toolCall", "toolName": toolName, + "displayLabel": lastToolLabel, + }) + elif event.type == AgentEventTypeEnum.TOOL_RESULT: + evtData = event.data or {} + resultSnippet = (evtData.get("data") or "")[:200] + await _emitSessionEvent(sessionId, "agentRun", { + "source": sourceLabel, + "promptId": promptId, + "status": "toolResult", + "toolName": evtData.get("toolName", ""), + "success": evtData.get("success", True), + "summary": resultSnippet, }) elif event.type == AgentEventTypeEnum.FILE_CREATED: await _emitSessionEvent(sessionId, "documentCreated", event.data or {}) diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py index c6584735..ed30538a 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py @@ -310,11 +310,15 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required for mode=create") fileItem, _ = dbMgmt.saveUploadedFile(content.encode("utf-8"), name) fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") + updateFields: Dict[str, Any] = {} if fiId: - dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId}) - # File group tree removed — groupId arg and instance-group assignment no longer apply + updateFields["featureInstanceId"] = fiId + if args.get("folderId"): + updateFields["folderId"] = args["folderId"] if args.get("tags"): - dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]}) + updateFields["tags"] = args["tags"] + if updateFields: + dbMgmt.updateFile(fileItem.id, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, @@ -429,7 +433,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): "writeFile", _writeFile, description=( "Create, append, or overwrite a file. Modes:\n" - "- create (default): create a new file (name required).\n" + "- create (default): create a new file (name required). Use folderId to place it in a specific folder.\n" "- append: append content to an existing file (fileId required). " "Use for large content that exceeds a single tool call (~8000 chars per call).\n" "- overwrite: replace entire file content (fileId required).\n" @@ -445,7 +449,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): "content": {"type": "string", "description": "Content to write/append"}, "mode": {"type": "string", "enum": ["create", "append", "overwrite"], "description": "Write mode (default: create)"}, "fileId": {"type": "string", "description": "File ID (required for mode=append/overwrite)"}, - "groupId": {"type": "string", "description": "Group ID to place the file in (mode=create only). Omit to use the instance default group."}, + "folderId": {"type": "string", "description": "Folder ID to place the file in (mode=create only). Use listFolders to find IDs. Omit for root."}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags (mode=create only)"}, }, "required": ["content"] @@ -704,7 +708,147 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): readOnly=False ) - # Group tree tools removed — file grouping now uses view-based display grouping (TableListView) + # ---- Folder management tools ---- + + async def _createFolder(args: Dict[str, Any], context: Dict[str, Any]): + name = args.get("name", "") + parentId = args.get("parentId") or None + if not name: + return ToolResult(toolCallId="", toolName="createFolder", success=False, error="name is required") + try: + chatService = services.chat + dbMgmt = chatService.interfaceDbComponent + folder = dbMgmt.createFolder(name, parentId=parentId) + folderId = folder.get("id") if isinstance(folder, dict) else getattr(folder, "id", None) + folderName = folder.get("name") if isinstance(folder, dict) else getattr(folder, "name", name) + return ToolResult( + toolCallId="", toolName="createFolder", success=True, + data=f"Folder '{folderName}' created (id: {folderId})" + (f" inside parent {parentId}" if parentId else ""), + sideEvents=[{"type": "folderCreated", "data": {"folderId": folderId, "folderName": folderName, "parentId": parentId}}], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="createFolder", success=False, error=str(e)) + + async def _listFolders(args: Dict[str, Any], context: Dict[str, Any]): + try: + chatService = services.chat + dbMgmt = chatService.interfaceDbComponent + folders = dbMgmt.getOwnFolderTree() + if not folders: + return ToolResult(toolCallId="", toolName="listFolders", success=True, data="No folders found.") + lines = [] + folderMap: Dict[Optional[str], List] = {} + for f in folders: + pid = f.get("parentId") if isinstance(f, dict) else getattr(f, "parentId", None) + folderMap.setdefault(pid, []).append(f) + + def _walk(parentId: Optional[str], indent: int): + for f in sorted(folderMap.get(parentId, []), key=lambda x: (x.get("name") if isinstance(x, dict) else getattr(x, "name", "")).lower()): + fId = f.get("id") if isinstance(f, dict) else getattr(f, "id", "") + fName = f.get("name") if isinstance(f, dict) else getattr(f, "name", "") + prefix = " " * indent + lines.append(f"{prefix}- {fName} (id: {fId})") + _walk(fId, indent + 1) + + _walk(None, 0) + return ToolResult(toolCallId="", toolName="listFolders", success=True, data="\n".join(lines)) + except Exception as e: + return ToolResult(toolCallId="", toolName="listFolders", success=False, error=str(e)) + + async def _moveFile(args: Dict[str, Any], context: Dict[str, Any]): + fileId = args.get("fileId", "") + folderId = args.get("folderId") + if not fileId: + return ToolResult(toolCallId="", toolName="moveFile", success=False, error="fileId is required") + try: + chatService = services.chat + dbMgmt = chatService.interfaceDbComponent + file = dbMgmt.getFile(fileId) + if not file: + return ToolResult(toolCallId="", toolName="moveFile", success=False, error=f"File {fileId} not found") + dbMgmt.updateFile(fileId, {"folderId": folderId or None}) + targetLabel = f"folder {folderId}" if folderId else "root" + return ToolResult( + toolCallId="", toolName="moveFile", success=True, + data=f"File '{file.fileName}' (id: {fileId}) moved to {targetLabel}", + sideEvents=[{"type": "fileUpdated", "data": {"fileId": fileId, "fileName": file.fileName}}], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="moveFile", success=False, error=str(e)) + + registry.register( + "createFolder", _createFolder, + description=( + "Create a new folder in the workspace file tree. " + "Use parentId to create nested folders. Returns the new folder ID." + ), + parameters={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Folder name"}, + "parentId": {"type": "string", "description": "Parent folder ID for nesting. Omit to create at root level."}, + }, + "required": ["name"] + }, + readOnly=False + ) + + registry.register( + "listFolders", _listFolders, + description=( + "List all folders in the workspace as an indented tree. " + "Use to find folder IDs for createFolder (parentId), writeFile (folderId), or moveFile." + ), + parameters={"type": "object", "properties": {}}, + readOnly=True + ) + + 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 + dbMgmt = chatService.interfaceDbComponent + folder = dbMgmt.renameFolder(folderId, newName) + return ToolResult( + toolCallId="", toolName="renameFolder", success=True, + data=f"Folder {folderId} renamed to '{newName}'", + sideEvents=[{"type": "folderUpdated", "data": {"folderId": folderId, "folderName": newName}}], + ) + except Exception as e: + return ToolResult(toolCallId="", toolName="renameFolder", success=False, error=str(e)) + + registry.register( + "renameFolder", _renameFolder, + description="Rename an existing folder in the workspace file tree.", + 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( + "moveFile", _moveFile, + description=( + "Move a file into a specific folder. Set folderId to null or omit to move the file back to the root level." + ), + parameters={ + "type": "object", + "properties": { + "fileId": {"type": "string", "description": "The file ID to move"}, + "folderId": {"type": "string", "description": "Target folder ID. Omit or null to move to root."}, + }, + "required": ["fileId"] + }, + readOnly=False + ) registry.register( "replaceInFile", _replaceInFile, diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py index 941168d5..a7b06266 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py @@ -77,6 +77,7 @@ class ContainerExtractor(Extractor): """Extract by recursively unpacking the container.""" fileName = context.get("fileName", "archive") mimeType = context.get("mimeType", "application/octet-stream") + cascadeDepth = context.get("_cascadeDepth", 0) rootId = makeId() parts: List[ContentPart] = [ @@ -97,7 +98,7 @@ class ContainerExtractor(Extractor): parts.extend(lazy) return parts - state = {"totalSize": 0, "fileCount": 0} + state = {"totalSize": 0, "fileCount": 0, "cascadeDepth": cascadeDepth} try: childParts = _resolveContainerRecursive( fileBytes, mimeType, fileName, rootId, "", 0, state @@ -209,7 +210,12 @@ def _addFilePart( if extractor and not isinstance(extractor, ContainerExtractor): try: - childParts = extractor.extract(data, {"fileName": fileName, "mimeType": detectedMime}) + cascadeDepth = state.get("cascadeDepth", 0) + childParts = extractor.extract(data, { + "fileName": fileName, + "mimeType": detectedMime, + "_cascadeDepth": cascadeDepth + 1, + }) for part in childParts: part.parentId = parentId if not part.metadata: diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py index 2c4295ab..7f750835 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py @@ -53,12 +53,13 @@ class EmailExtractor(Extractor): def extract(self, fileBytes: bytes, context: Dict[str, Any]) -> List[ContentPart]: fileName = context.get("fileName", "email") lower = (fileName or "").lower() + depth = context.get("_cascadeDepth", 0) if lower.endswith(".msg"): - return self._extractMsg(fileBytes, fileName) - return self._extractEml(fileBytes, fileName) + return self._extractMsg(fileBytes, fileName, depth) + return self._extractEml(fileBytes, fileName, depth) - def _extractEml(self, fileBytes: bytes, fileName: str) -> List[ContentPart]: + def _extractEml(self, fileBytes: bytes, fileName: str, depth: int = 0) -> List[ContentPart]: """Parse standard EML (RFC 822) using stdlib email.""" rootId = makeId() parts: List[ContentPart] = [] @@ -91,7 +92,7 @@ class EmailExtractor(Extractor): attachName = part.get_filename() or "attachment" attachData = part.get_payload(decode=True) if attachData: - parts.extend(_delegateAttachment(attachData, attachName, rootId)) + parts.extend(_delegateAttachment(attachData, attachName, rootId, depth)) continue if contentType == "text/plain": @@ -113,7 +114,7 @@ class EmailExtractor(Extractor): return parts - def _extractMsg(self, fileBytes: bytes, fileName: str) -> List[ContentPart]: + def _extractMsg(self, fileBytes: bytes, fileName: str, depth: int = 0) -> List[ContentPart]: """Parse Outlook MSG files using extract-msg (optional).""" rootId = makeId() parts: List[ContentPart] = [] @@ -179,7 +180,7 @@ class EmailExtractor(Extractor): attachName = getattr(attachment, "longFilename", None) or getattr(attachment, "shortFilename", None) or "attachment" attachData = getattr(attachment, "data", None) if attachData: - parts.extend(_delegateAttachment(attachData, attachName, rootId)) + parts.extend(_delegateAttachment(attachData, attachName, rootId, depth)) try: msgFile.close() @@ -199,18 +200,39 @@ def _buildHeaderText(msg) -> str: return "\n".join(lines) -def _delegateAttachment(attachData: bytes, attachName: str, parentId: str) -> List[ContentPart]: - """Delegate an attachment to the appropriate type-specific extractor.""" +_MAX_CASCADE_DEPTH = 10 + +def _delegateAttachment(attachData: bytes, attachName: str, parentId: str, depth: int = 0) -> List[ContentPart]: + """Delegate an attachment to the appropriate type-specific extractor. + + Passes ``_cascadeDepth`` through the context so nested Email→Container→Email + chains share a global depth counter and don't recurse infinitely. + """ + if depth >= _MAX_CASCADE_DEPTH: + logger.warning(f"Cascade depth {depth} reached for {attachName}, skipping extraction") + import base64 + encodedData = base64.b64encode(attachData).decode("utf-8") if attachData else "" + return [ContentPart( + id=makeId(), parentId=parentId, label=attachName, + typeGroup="binary", mimeType="application/octet-stream", + data=encodedData, + metadata={"size": len(attachData), "emailAttachment": attachName, "cascadeDepthExceeded": True}, + )] + guessedMime, _ = mimetypes.guess_type(attachName) detectedMime = guessedMime or "application/octet-stream" - from ..subRegistry import ExtractorRegistry - registry = ExtractorRegistry() + from ..subRegistry import getExtractorRegistry + registry = getExtractorRegistry() extractor = registry.resolve(detectedMime, attachName) - if extractor and not isinstance(extractor, EmailExtractor): + if extractor: try: - childParts = extractor.extract(attachData, {"fileName": attachName, "mimeType": detectedMime}) + childParts = extractor.extract(attachData, { + "fileName": attachName, + "mimeType": detectedMime, + "_cascadeDepth": depth + 1, + }) for part in childParts: part.parentId = parentId if not part.metadata: diff --git a/requirements.txt b/requirements.txt index f5ffb715..2d2f5ee5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -110,6 +110,9 @@ asyncpg==0.30.0 ## Stripe payments stripe>=11.0.0 +## Outlook MSG file extraction +extract-msg>=0.55.0 + ## Geospatial libraries for STAC connector pyproj>=3.6.0 # For coordinate transformations (EPSG:2056 <-> EPSG:4326) shapely>=2.0.0 # For geometric operations (intersections, area calculations)