From 16ab816c65a3a85b82b5e68e25ecd847cf438b3f Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 12 May 2026 22:39:42 +0200
Subject: [PATCH] teamsbot ux fixes
---
modules/features/teamsbot/service.py | 62 ++++++-
.../serviceAgent/coreTools/_workspaceTools.py | 156 +++++++++++++++++-
.../extractors/extractorContainer.py | 10 +-
.../extractors/extractorEmail.py | 46 ++++--
requirements.txt | 3 +
5 files changed, 249 insertions(+), 28 deletions(-)
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)