teamsbot ux fixes

This commit is contained in:
ValueOn AG 2026-05-12 22:39:42 +02:00
parent 3718745931
commit 16ab816c65
5 changed files with 249 additions and 28 deletions

View file

@ -3290,16 +3290,50 @@ class TeamsbotService:
self, lastToolLabel: Optional[str] = None self, lastToolLabel: Optional[str] = None
) -> Optional[str]: ) -> Optional[str]:
"""Per-round progress notice for long agent runs (meeting voice / """Per-round progress notice for long agent runs (meeting voice /
chat, ephemeral). Phrasing is AI-localised once per session; chat, ephemeral). Generates a single short phrase in the bot's
``{activity}`` placeholder is substituted with the tool's configured language that describes the current activity. Unlike
``displayLabel`` from the ToolDefinition. Returns ``None`` if the cached ephemeral phrases, this is a per-call AI generation
generation failed.""" to avoid mixing English displayLabels into non-English speech."""
activity = lastToolLabel or "processing your request" targetLang = (self.config.language or "").strip() or "en-US"
return await self._pickEphemeralPhrase( botName = (self.config.botName or "the assistant").strip()
"agentRound", activityHint = lastToolLabel or "working on the task"
substitutions={"activity": activity},
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: async def _notifyMeetingEphemeral(self, sessionId: str, text: str) -> None:
"""Deliver a short line to the meeting (TTS + chat per config) without """Deliver a short line to the meeting (TTS + chat per config) without
persisting botResponses/transcripts, so the main agent answer stays the persisting botResponses/transcripts, so the main agent answer stays the
@ -3455,6 +3489,18 @@ class TeamsbotService:
"promptId": promptId, "promptId": promptId,
"status": "toolCall", "status": "toolCall",
"toolName": toolName, "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: elif event.type == AgentEventTypeEnum.FILE_CREATED:
await _emitSessionEvent(sessionId, "documentCreated", event.data or {}) await _emitSessionEvent(sessionId, "documentCreated", event.data or {})

View file

@ -310,11 +310,15 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required for mode=create") return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required for mode=create")
fileItem, _ = dbMgmt.saveUploadedFile(content.encode("utf-8"), name) fileItem, _ = dbMgmt.saveUploadedFile(content.encode("utf-8"), name)
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
updateFields: Dict[str, Any] = {}
if fiId: if fiId:
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId}) updateFields["featureInstanceId"] = fiId
# File group tree removed — groupId arg and instance-group assignment no longer apply if args.get("folderId"):
updateFields["folderId"] = args["folderId"]
if args.get("tags"): if args.get("tags"):
dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]}) updateFields["tags"] = args["tags"]
if updateFields:
dbMgmt.updateFile(fileItem.id, updateFields)
chatDocId = _attachFileAsChatDocument( chatDocId = _attachFileAsChatDocument(
services, fileItem, services, fileItem,
@ -429,7 +433,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
"writeFile", _writeFile, "writeFile", _writeFile,
description=( description=(
"Create, append, or overwrite a file. Modes:\n" "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). " "- append: append content to an existing file (fileId required). "
"Use for large content that exceeds a single tool call (~8000 chars per call).\n" "Use for large content that exceeds a single tool call (~8000 chars per call).\n"
"- overwrite: replace entire file content (fileId required).\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"}, "content": {"type": "string", "description": "Content to write/append"},
"mode": {"type": "string", "enum": ["create", "append", "overwrite"], "description": "Write mode (default: create)"}, "mode": {"type": "string", "enum": ["create", "append", "overwrite"], "description": "Write mode (default: create)"},
"fileId": {"type": "string", "description": "File ID (required for mode=append/overwrite)"}, "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)"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags (mode=create only)"},
}, },
"required": ["content"] "required": ["content"]
@ -704,7 +708,147 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
readOnly=False 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( registry.register(
"replaceInFile", _replaceInFile, "replaceInFile", _replaceInFile,

View file

@ -77,6 +77,7 @@ class ContainerExtractor(Extractor):
"""Extract by recursively unpacking the container.""" """Extract by recursively unpacking the container."""
fileName = context.get("fileName", "archive") fileName = context.get("fileName", "archive")
mimeType = context.get("mimeType", "application/octet-stream") mimeType = context.get("mimeType", "application/octet-stream")
cascadeDepth = context.get("_cascadeDepth", 0)
rootId = makeId() rootId = makeId()
parts: List[ContentPart] = [ parts: List[ContentPart] = [
@ -97,7 +98,7 @@ class ContainerExtractor(Extractor):
parts.extend(lazy) parts.extend(lazy)
return parts return parts
state = {"totalSize": 0, "fileCount": 0} state = {"totalSize": 0, "fileCount": 0, "cascadeDepth": cascadeDepth}
try: try:
childParts = _resolveContainerRecursive( childParts = _resolveContainerRecursive(
fileBytes, mimeType, fileName, rootId, "", 0, state fileBytes, mimeType, fileName, rootId, "", 0, state
@ -209,7 +210,12 @@ def _addFilePart(
if extractor and not isinstance(extractor, ContainerExtractor): if extractor and not isinstance(extractor, ContainerExtractor):
try: 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: for part in childParts:
part.parentId = parentId part.parentId = parentId
if not part.metadata: if not part.metadata:

View file

@ -53,12 +53,13 @@ class EmailExtractor(Extractor):
def extract(self, fileBytes: bytes, context: Dict[str, Any]) -> List[ContentPart]: def extract(self, fileBytes: bytes, context: Dict[str, Any]) -> List[ContentPart]:
fileName = context.get("fileName", "email") fileName = context.get("fileName", "email")
lower = (fileName or "").lower() lower = (fileName or "").lower()
depth = context.get("_cascadeDepth", 0)
if lower.endswith(".msg"): if lower.endswith(".msg"):
return self._extractMsg(fileBytes, fileName) return self._extractMsg(fileBytes, fileName, depth)
return self._extractEml(fileBytes, fileName) 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.""" """Parse standard EML (RFC 822) using stdlib email."""
rootId = makeId() rootId = makeId()
parts: List[ContentPart] = [] parts: List[ContentPart] = []
@ -91,7 +92,7 @@ class EmailExtractor(Extractor):
attachName = part.get_filename() or "attachment" attachName = part.get_filename() or "attachment"
attachData = part.get_payload(decode=True) attachData = part.get_payload(decode=True)
if attachData: if attachData:
parts.extend(_delegateAttachment(attachData, attachName, rootId)) parts.extend(_delegateAttachment(attachData, attachName, rootId, depth))
continue continue
if contentType == "text/plain": if contentType == "text/plain":
@ -113,7 +114,7 @@ class EmailExtractor(Extractor):
return parts 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).""" """Parse Outlook MSG files using extract-msg (optional)."""
rootId = makeId() rootId = makeId()
parts: List[ContentPart] = [] parts: List[ContentPart] = []
@ -179,7 +180,7 @@ class EmailExtractor(Extractor):
attachName = getattr(attachment, "longFilename", None) or getattr(attachment, "shortFilename", None) or "attachment" attachName = getattr(attachment, "longFilename", None) or getattr(attachment, "shortFilename", None) or "attachment"
attachData = getattr(attachment, "data", None) attachData = getattr(attachment, "data", None)
if attachData: if attachData:
parts.extend(_delegateAttachment(attachData, attachName, rootId)) parts.extend(_delegateAttachment(attachData, attachName, rootId, depth))
try: try:
msgFile.close() msgFile.close()
@ -199,18 +200,39 @@ def _buildHeaderText(msg) -> str:
return "\n".join(lines) return "\n".join(lines)
def _delegateAttachment(attachData: bytes, attachName: str, parentId: str) -> List[ContentPart]: _MAX_CASCADE_DEPTH = 10
"""Delegate an attachment to the appropriate type-specific extractor."""
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 EmailContainerEmail
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) guessedMime, _ = mimetypes.guess_type(attachName)
detectedMime = guessedMime or "application/octet-stream" detectedMime = guessedMime or "application/octet-stream"
from ..subRegistry import ExtractorRegistry from ..subRegistry import getExtractorRegistry
registry = ExtractorRegistry() registry = getExtractorRegistry()
extractor = registry.resolve(detectedMime, attachName) extractor = registry.resolve(detectedMime, attachName)
if extractor and not isinstance(extractor, EmailExtractor): if extractor:
try: try:
childParts = extractor.extract(attachData, {"fileName": attachName, "mimeType": detectedMime}) childParts = extractor.extract(attachData, {
"fileName": attachName,
"mimeType": detectedMime,
"_cascadeDepth": depth + 1,
})
for part in childParts: for part in childParts:
part.parentId = parentId part.parentId = parentId
if not part.metadata: if not part.metadata:

View file

@ -110,6 +110,9 @@ asyncpg==0.30.0
## Stripe payments ## Stripe payments
stripe>=11.0.0 stripe>=11.0.0
## Outlook MSG file extraction
extract-msg>=0.55.0
## Geospatial libraries for STAC connector ## Geospatial libraries for STAC connector
pyproj>=3.6.0 # For coordinate transformations (EPSG:2056 <-> EPSG:4326) pyproj>=3.6.0 # For coordinate transformations (EPSG:2056 <-> EPSG:4326)
shapely>=2.0.0 # For geometric operations (intersections, area calculations) shapely>=2.0.0 # For geometric operations (intersections, area calculations)