2558 lines
122 KiB
Python
2558 lines
122 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
||
# All rights reserved.
|
||
"""Agent service: entry point for running AI agents with tool use."""
|
||
|
||
import logging
|
||
from typing import Any, Callable, Dict, List, Optional, AsyncGenerator
|
||
|
||
from modules.datamodels.datamodelAi import (
|
||
AiCallRequest, AiCallOptions, AiCallResponse, OperationTypeEnum
|
||
)
|
||
from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
|
||
AgentConfig, AgentEvent, AgentEventTypeEnum
|
||
)
|
||
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
|
||
from modules.serviceCenter.services.serviceAgent.agentLoop import runAgentLoop
|
||
from modules.serviceCenter.services.serviceAgent.actionToolAdapter import ActionToolAdapter
|
||
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||
getService as getBillingService,
|
||
InsufficientBalanceException,
|
||
BillingContextError
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_MAX_TOOL_RESULT_CHARS = 50_000
|
||
|
||
_BINARY_SIGNATURES = (b"%PDF", b"\x89PNG", b"\xff\xd8\xff", b"GIF8", b"PK\x03\x04", b"Rar!", b"\x1f\x8b")
|
||
|
||
|
||
def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool:
|
||
"""Detect binary content by checking for magic bytes and non-printable char ratio."""
|
||
if any(data[:8].startswith(sig) for sig in _BINARY_SIGNATURES):
|
||
return True
|
||
sample = data[:sampleSize]
|
||
if not sample:
|
||
return False
|
||
nonPrintable = sum(1 for b in sample if b < 0x09 or (0x0E <= b < 0x20 and b != 0x1B))
|
||
return nonPrintable / len(sample) > 0.10
|
||
|
||
|
||
class _ServicesAdapter:
|
||
"""Adapter providing service access from (context, get_service)."""
|
||
|
||
def __init__(self, context, getService: Callable[[str], Any]):
|
||
self._context = context
|
||
self._getService = getService
|
||
self.user = context.user
|
||
self.mandateId = context.mandate_id
|
||
self.featureInstanceId = context.feature_instance_id
|
||
|
||
@property
|
||
def workflow(self):
|
||
return self._context.workflow
|
||
|
||
@property
|
||
def ai(self):
|
||
return self._getService("ai")
|
||
|
||
@property
|
||
def chat(self):
|
||
return self._getService("chat")
|
||
|
||
@property
|
||
def streaming(self):
|
||
return self._getService("streaming")
|
||
|
||
@property
|
||
def billing(self):
|
||
return self._getService("billing")
|
||
|
||
@property
|
||
def utils(self):
|
||
return self._getService("utils")
|
||
|
||
@property
|
||
def extraction(self):
|
||
return self._getService("extraction")
|
||
|
||
def getService(self, name: str):
|
||
"""Access any service by name."""
|
||
return self._getService(name)
|
||
|
||
@property
|
||
def featureCode(self) -> Optional[str]:
|
||
w = self.workflow
|
||
if w and hasattr(w, "feature") and w.feature:
|
||
return getattr(w.feature, "code", None)
|
||
return getattr(w, "featureCode", None) if w else None
|
||
|
||
|
||
class AgentService:
|
||
"""Service for running AI agents with ReAct loop and tool use.
|
||
|
||
Registered as IMPORTABLE_SERVICE with objectKey 'service.agent'.
|
||
Uses serviceAi for model selection/billing, streaming for SSE events.
|
||
"""
|
||
|
||
def __init__(self, context, get_service: Callable[[str], Any]):
|
||
self._context = context
|
||
self._getService = get_service
|
||
self.services = _ServicesAdapter(context, get_service)
|
||
|
||
async def runAgent(
|
||
self,
|
||
prompt: str,
|
||
fileIds: List[str] = None,
|
||
config: AgentConfig = None,
|
||
toolSet: str = "core",
|
||
workflowId: str = None,
|
||
additionalTools: List[Dict[str, Any]] = None,
|
||
userLanguage: str = "",
|
||
conversationHistory: List[Dict[str, Any]] = None,
|
||
) -> AsyncGenerator[AgentEvent, None]:
|
||
"""Run an agent with the given prompt and tools.
|
||
|
||
Args:
|
||
prompt: User prompt
|
||
fileIds: Optional list of file IDs to include as context
|
||
config: Agent configuration
|
||
toolSet: Which tool set to activate
|
||
workflowId: Workflow ID for tracking and billing
|
||
additionalTools: Extra tool definitions to register dynamically
|
||
userLanguage: ISO 639-1 language code; falls back to user.language from profile
|
||
conversationHistory: Prior messages for follow-up context
|
||
|
||
Yields:
|
||
AgentEvent for each step (SSE-ready)
|
||
"""
|
||
if config is None:
|
||
config = AgentConfig(toolSet=toolSet)
|
||
|
||
if workflowId is None:
|
||
workflowId = getattr(self.services.workflow, "id", "unknown") if self.services.workflow else "unknown"
|
||
|
||
resolvedLanguage = userLanguage or getattr(self.services.user, "language", "") or "de"
|
||
|
||
enrichedPrompt = await self._enrichPromptWithFiles(prompt, fileIds)
|
||
|
||
toolRegistry = self._buildToolRegistry(config)
|
||
|
||
aiCallFn = self._createAiCallFn()
|
||
aiCallStreamFn = self._createAiCallStreamFn()
|
||
getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId)
|
||
buildRagContextFn = self._createBuildRagContextFn()
|
||
|
||
async for event in runAgentLoop(
|
||
prompt=enrichedPrompt,
|
||
toolRegistry=toolRegistry,
|
||
config=config,
|
||
aiCallFn=aiCallFn,
|
||
getWorkflowCostFn=getWorkflowCostFn,
|
||
workflowId=workflowId,
|
||
userId=self.services.user.id if self.services.user else "",
|
||
featureInstanceId=self.services.featureInstanceId or "",
|
||
buildRagContextFn=buildRagContextFn,
|
||
mandateId=self.services.mandateId or "",
|
||
aiCallStreamFn=aiCallStreamFn,
|
||
userLanguage=resolvedLanguage,
|
||
conversationHistory=conversationHistory,
|
||
):
|
||
if event.type == AgentEventTypeEnum.AGENT_SUMMARY:
|
||
await self._persistTrace(workflowId, event.data or {})
|
||
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}")
|
||
|
||
async def _enrichPromptWithFiles(self, prompt: str, fileIds: List[str] = None) -> str:
|
||
"""Resolve file metadata + FileContentIndex for attached fileIds and prepend to prompt.
|
||
|
||
The FileContentIndex is produced by the upload pipeline (AI-free extraction)
|
||
and tells the agent exactly which content objects (text, images, tables, etc.)
|
||
exist inside a file, so the agent can work with them directly via tools.
|
||
"""
|
||
if not fileIds:
|
||
return prompt
|
||
try:
|
||
chatService = self.services.chat
|
||
knowledgeDb = None
|
||
try:
|
||
from modules.interfaces.interfaceDbKnowledge import getInterface as _getKnowledgeInterface
|
||
knowledgeDb = _getKnowledgeInterface()
|
||
except Exception:
|
||
pass
|
||
|
||
fileDescriptions = []
|
||
for fid in fileIds:
|
||
try:
|
||
info = chatService.getFileInfo(fid)
|
||
|
||
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"
|
||
|
||
if knowledgeDb:
|
||
contentIndex = knowledgeDb.getFileContentIndex(fid)
|
||
if contentIndex:
|
||
structure = contentIndex.get("structure", {})
|
||
totalObjects = contentIndex.get("totalObjects", 0)
|
||
desc += f"\n - indexed: yes ({totalObjects} content objects)"
|
||
if structure:
|
||
structParts = []
|
||
for key, val in structure.items():
|
||
if isinstance(val, (int, str)):
|
||
structParts.append(f"{key}: {val}")
|
||
if structParts:
|
||
desc += f"\n - structure: {', '.join(structParts)}"
|
||
|
||
objectSummary = contentIndex.get("objectSummary", [])
|
||
if objectSummary:
|
||
desc += "\n - content objects:"
|
||
for obj in objectSummary[:20]:
|
||
objType = obj.get("type", obj.get("contentType", "?"))
|
||
objRef = obj.get("ref", {})
|
||
objLabel = objRef.get("location", "") if isinstance(objRef, dict) else ""
|
||
objId = obj.get("id", obj.get("contentObjectId", ""))
|
||
desc += f"\n * [{objType}] {objLabel}" + (f" (id: {objId})" if objId else "")
|
||
if len(objectSummary) > 20:
|
||
desc += f"\n ... and {len(objectSummary) - 20} more objects"
|
||
else:
|
||
desc += "\n - indexed: no (use readFile to trigger extraction)"
|
||
|
||
fileDescriptions.append(desc)
|
||
except Exception:
|
||
fileDescriptions.append(f"### File id: {fid}")
|
||
|
||
if fileDescriptions:
|
||
header = (
|
||
"## 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 `` in the markdown content.\n\n"
|
||
)
|
||
header += "\n\n".join(fileDescriptions)
|
||
return f"{header}\n\n---\n\nUser request: {prompt}"
|
||
except Exception as e:
|
||
logger.warning(f"Could not enrich prompt with file metadata: {e}")
|
||
return prompt
|
||
|
||
def _buildToolRegistry(self, config: AgentConfig) -> ToolRegistry:
|
||
"""Build a tool registry with core tools and ActionToolAdapter tools."""
|
||
registry = ToolRegistry()
|
||
|
||
_registerCoreTools(registry, self.services)
|
||
|
||
try:
|
||
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
||
actionExecutor = ActionExecutor(self.services)
|
||
adapter = ActionToolAdapter(actionExecutor)
|
||
adapter.registerAll(registry)
|
||
except Exception as e:
|
||
logger.warning(f"Could not register action tools: {e}")
|
||
|
||
return registry
|
||
|
||
async def _persistTrace(self, workflowId: str, summaryData: Dict[str, Any]):
|
||
"""Persist the agent trace as a workflow memory entry in the knowledge store."""
|
||
try:
|
||
knowledgeService = self._getService("knowledge")
|
||
userId = self.services.user.id if self.services.user else ""
|
||
featureInstanceId = self.services.featureInstanceId or ""
|
||
|
||
import json
|
||
traceValue = json.dumps(summaryData, default=str)
|
||
|
||
await knowledgeService.storeEntity(
|
||
workflowId=workflowId,
|
||
userId=userId,
|
||
featureInstanceId=featureInstanceId,
|
||
key="_agentTrace",
|
||
value=traceValue,
|
||
source="agent",
|
||
)
|
||
logger.info(f"Persisted agent trace for workflow {workflowId}")
|
||
except Exception as e:
|
||
logger.warning(f"Could not persist agent trace: {e}")
|
||
|
||
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
|
||
"""Create the AI call function that wraps serviceAi with billing."""
|
||
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
|
||
aiService = self.services.ai
|
||
return await aiService.callAi(request)
|
||
return _aiCallFn
|
||
|
||
def _createAiCallStreamFn(self):
|
||
"""Create the streaming AI call function. Yields str deltas, then AiCallResponse."""
|
||
async def _aiCallStreamFn(request: AiCallRequest):
|
||
aiService = self.services.ai
|
||
async for chunk in aiService.callAiStream(request):
|
||
yield chunk
|
||
return _aiCallStreamFn
|
||
|
||
def _createGetWorkflowCostFn(self, workflowId: str) -> Callable[[], float]:
|
||
"""Create a function that returns the current workflow cost."""
|
||
async def _getWorkflowCost() -> float:
|
||
try:
|
||
billingService = self.services.billing
|
||
return await billingService.getWorkflowCost(workflowId)
|
||
except Exception:
|
||
return 0.0
|
||
return _getWorkflowCost
|
||
|
||
def _createBuildRagContextFn(self):
|
||
"""Create the RAG context builder function that delegates to KnowledgeService."""
|
||
async def _buildRagContext(
|
||
currentPrompt: str, workflowId: str, userId: str,
|
||
featureInstanceId: str, mandateId: str, **kwargs
|
||
) -> str:
|
||
try:
|
||
knowledgeService = self.services.getService("knowledge")
|
||
return await knowledgeService.buildAgentContext(
|
||
currentPrompt=currentPrompt,
|
||
workflowId=workflowId,
|
||
userId=userId,
|
||
featureInstanceId=featureInstanceId,
|
||
mandateId=mandateId,
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"RAG context not available: {e}")
|
||
return ""
|
||
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 ----
|
||
|
||
async def _readFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||
fileId = args.get("fileId", "")
|
||
if not fileId:
|
||
return ToolResult(toolCallId="", toolName="readFile", success=False, error="fileId is required")
|
||
try:
|
||
knowledgeService = services.getService("knowledge") if hasattr(services, "getService") else None
|
||
|
||
# 1) Knowledge Store: return already-extracted text chunks
|
||
if knowledgeService:
|
||
fileStatus = knowledgeService.getFileStatus(fileId)
|
||
if fileStatus == "indexed":
|
||
chunks = knowledgeService._knowledgeDb.getContentChunks(fileId)
|
||
textChunks = [
|
||
c for c in (chunks or [])
|
||
if c.get("contentType") != "image" and c.get("data")
|
||
]
|
||
if textChunks:
|
||
assembled = "\n\n".join(c["data"] for c in textChunks)
|
||
if len(assembled) > _MAX_TOOL_RESULT_CHARS:
|
||
assembled = assembled[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(assembled)}]"
|
||
return ToolResult(
|
||
toolCallId="", toolName="readFile", success=True,
|
||
data=assembled,
|
||
)
|
||
elif fileStatus in ("processing", "embedding", "extracted"):
|
||
return ToolResult(
|
||
toolCallId="", toolName="readFile", success=True,
|
||
data=f"[File {fileId} is currently being processed (status: {fileStatus}). Try again shortly.]",
|
||
)
|
||
|
||
# 2) Not indexed yet: try on-demand extraction
|
||
chatService = services.chat
|
||
fileInfo = chatService.getFileInfo(fileId)
|
||
if not fileInfo:
|
||
return ToolResult(toolCallId="", toolName="readFile", success=True, data="File not found.")
|
||
|
||
fileName = fileInfo.get("fileName", fileId)
|
||
mimeType = fileInfo.get("mimeType", "")
|
||
|
||
_BINARY_TYPES = ("application/pdf", "image/", "application/vnd.", "application/zip",
|
||
"application/x-zip", "application/x-tar", "application/x-7z",
|
||
"application/msword", "application/octet-stream",
|
||
"message/rfc822")
|
||
isBinary = any(mimeType.startswith(t) for t in _BINARY_TYPES)
|
||
|
||
rawBytes = chatService.getFileData(fileId)
|
||
if not rawBytes:
|
||
return ToolResult(toolCallId="", toolName="readFile", success=True, data="File data not accessible.")
|
||
|
||
if not isBinary:
|
||
isBinary = _looksLikeBinary(rawBytes)
|
||
|
||
if isBinary:
|
||
try:
|
||
from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry, ChunkerRegistry
|
||
from modules.serviceCenter.services.serviceExtraction.subPipeline import runExtraction
|
||
from modules.datamodels.datamodelExtraction import ExtractionOptions
|
||
|
||
extracted = runExtraction(
|
||
ExtractorRegistry(), ChunkerRegistry(),
|
||
rawBytes, fileName, mimeType, ExtractionOptions(),
|
||
)
|
||
|
||
contentObjects = []
|
||
for part in extracted.parts:
|
||
tg = (part.typeGroup or "").lower()
|
||
ct = "image" if tg == "image" else "text"
|
||
if not part.data or not part.data.strip():
|
||
continue
|
||
contentObjects.append({
|
||
"contentObjectId": part.id,
|
||
"contentType": ct,
|
||
"data": part.data,
|
||
"contextRef": {
|
||
"containerPath": fileName,
|
||
"location": part.label or "file",
|
||
**(part.metadata or {}),
|
||
},
|
||
})
|
||
|
||
if contentObjects:
|
||
if knowledgeService:
|
||
try:
|
||
userId = context.get("userId", "")
|
||
await knowledgeService.indexFile(
|
||
fileId=fileId, fileName=fileName, mimeType=mimeType,
|
||
userId=userId, contentObjects=contentObjects,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
textParts = [o["data"] for o in contentObjects if o["contentType"] != "image"]
|
||
if textParts:
|
||
joined = "\n\n".join(textParts)
|
||
if len(joined) > _MAX_TOOL_RESULT_CHARS:
|
||
joined = joined[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(joined)}]"
|
||
return ToolResult(
|
||
toolCallId="", toolName="readFile", success=True,
|
||
data=joined,
|
||
)
|
||
imgCount = sum(1 for o in contentObjects if o["contentType"] == "image")
|
||
return ToolResult(
|
||
toolCallId="", toolName="readFile", success=True,
|
||
data=f"[Extracted {len(contentObjects)} content objects from '{fileName}' "
|
||
f"({imgCount} images, no readable text). "
|
||
f"Use describeImage(fileId='{fileId}') to analyze visual content.]",
|
||
)
|
||
except Exception as extractErr:
|
||
logger.warning(f"readFile extraction failed for {fileId} ({fileName}): {extractErr}")
|
||
|
||
return ToolResult(
|
||
toolCallId="", toolName="readFile", success=True,
|
||
data=f"[Binary file: '{fileName}', type={mimeType}, size={len(rawBytes)} bytes. "
|
||
f"Text extraction not available. Use describeImage for images.]",
|
||
)
|
||
|
||
# 3) Text file: decode raw bytes
|
||
for encoding in ("utf-8", "utf-8-sig", "latin-1"):
|
||
try:
|
||
text = rawBytes.decode(encoding)
|
||
if text.strip():
|
||
if len(text) > _MAX_TOOL_RESULT_CHARS:
|
||
text = text[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(text)}]"
|
||
return ToolResult(
|
||
toolCallId="", toolName="readFile", success=True,
|
||
data=text,
|
||
)
|
||
except (UnicodeDecodeError, ValueError):
|
||
continue
|
||
|
||
return ToolResult(
|
||
toolCallId="", toolName="readFile", success=True,
|
||
data="File is empty or could not be decoded.",
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="readFile", success=False, error=str(e))
|
||
|
||
async def _listFiles(args: Dict[str, Any], context: Dict[str, Any]):
|
||
try:
|
||
chatService = services.chat
|
||
files = chatService.listFiles(
|
||
folderId=args.get("folderId"),
|
||
tags=args.get("tags"),
|
||
search=args.get("search"),
|
||
)
|
||
fileList = "\n".join(
|
||
f"- {f.get('fileName', 'unknown')} (id: {f.get('id', '?')}, "
|
||
f"type: {f.get('mimeType', '?')}, size: {f.get('fileSize', '?')}, "
|
||
f"tags: {f.get('tags', [])}, status: {f.get('status', 'n/a')})"
|
||
for f in files
|
||
) if files else "No files found."
|
||
return ToolResult(toolCallId="", toolName="listFiles", success=True, data=fileList)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="listFiles", success=False, error=str(e))
|
||
|
||
async def _searchFiles(args: Dict[str, Any], context: Dict[str, Any]):
|
||
query = args.get("query", "")
|
||
if not query:
|
||
return ToolResult(toolCallId="", toolName="searchFiles", success=False, error="query is required")
|
||
try:
|
||
chatService = services.chat
|
||
files = chatService.listFiles(search=query, tags=args.get("tags"))
|
||
fileList = "\n".join(
|
||
f"- {f.get('fileName', 'unknown')} (id: {f.get('id', '?')})"
|
||
for f in files
|
||
) if files else "No files matching query."
|
||
return ToolResult(toolCallId="", toolName="searchFiles", success=True, data=fileList)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="searchFiles", success=False, error=str(e))
|
||
|
||
async def _listFolders(args: Dict[str, Any], context: Dict[str, Any]):
|
||
try:
|
||
chatService = services.chat
|
||
folders = chatService.listFolders(parentId=args.get("parentId"))
|
||
folderList = "\n".join(
|
||
f"- {f.get('name', 'unnamed')} (id: {f.get('id', '?')})"
|
||
for f in folders
|
||
) if folders else "No folders found."
|
||
return ToolResult(toolCallId="", toolName="listFolders", success=True, data=folderList)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="listFolders", success=False, error=str(e))
|
||
|
||
async def _webSearch(args: Dict[str, Any], context: Dict[str, Any]):
|
||
query = args.get("query", "")
|
||
if not query:
|
||
return ToolResult(toolCallId="", toolName="webSearch", success=False, error="query is required")
|
||
try:
|
||
webService = services.getService("web")
|
||
result = await webService.performWebResearch(
|
||
prompt=query,
|
||
urls=[],
|
||
country=None,
|
||
language=args.get("language"),
|
||
)
|
||
summary = result.get("summary", "") if isinstance(result, dict) else str(result)
|
||
return ToolResult(
|
||
toolCallId="", toolName="webSearch", success=True,
|
||
data=summary or str(result)
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="webSearch", success=False, error=str(e))
|
||
|
||
# ---- Write tools ----
|
||
|
||
async def _tagFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||
fileId = args.get("fileId", "")
|
||
tags = args.get("tags", [])
|
||
if not fileId:
|
||
return ToolResult(toolCallId="", toolName="tagFile", success=False, error="fileId is required")
|
||
try:
|
||
chatService = services.chat
|
||
chatService.interfaceDbComponent.updateFile(fileId, {"tags": tags})
|
||
return ToolResult(
|
||
toolCallId="", toolName="tagFile", success=True,
|
||
data=f"Tags updated to {tags} for file {fileId}"
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="tagFile", success=False, error=str(e))
|
||
|
||
async def _moveFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||
fileId = args.get("fileId", "")
|
||
targetFolderId = args.get("targetFolderId")
|
||
if not fileId:
|
||
return ToolResult(toolCallId="", toolName="moveFile", success=False, error="fileId is required")
|
||
try:
|
||
chatService = services.chat
|
||
chatService.interfaceDbComponent.updateFile(fileId, {"folderId": targetFolderId})
|
||
return ToolResult(
|
||
toolCallId="", toolName="moveFile", success=True,
|
||
data=f"File {fileId} moved to folder {targetFolderId or 'root'}"
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="moveFile", success=False, error=str(e))
|
||
|
||
async def _createFolder(args: Dict[str, Any], context: Dict[str, Any]):
|
||
name = args.get("name", "")
|
||
if not name:
|
||
return ToolResult(toolCallId="", toolName="createFolder", success=False, error="name is required")
|
||
try:
|
||
chatService = services.chat
|
||
folder = chatService.createFolder(name=name, parentId=args.get("parentId"))
|
||
return ToolResult(
|
||
toolCallId="", toolName="createFolder", success=True,
|
||
data=f"Folder '{name}' created (id: {folder.get('id', '?')})"
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="createFolder", success=False, error=str(e))
|
||
|
||
async def _writeFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||
name = args.get("name", "")
|
||
content = args.get("content", "")
|
||
if not name:
|
||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required")
|
||
try:
|
||
chatService = services.chat
|
||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(
|
||
content.encode("utf-8"), name
|
||
)
|
||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||
if fiId:
|
||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
||
if args.get("folderId"):
|
||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": args["folderId"]})
|
||
if args.get("tags"):
|
||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"tags": args["tags"]})
|
||
return ToolResult(
|
||
toolCallId="", toolName="writeFile", success=True,
|
||
data=f"File '{name}' created (id: {fileItem.id})",
|
||
sideEvents=[{
|
||
"type": "fileCreated",
|
||
"data": {
|
||
"fileId": fileItem.id,
|
||
"fileName": name,
|
||
"mimeType": fileItem.mimeType,
|
||
"fileSize": fileItem.fileSize,
|
||
},
|
||
}],
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error=str(e))
|
||
|
||
# ---- Register all tools ----
|
||
|
||
registry.register(
|
||
"readFile", _readFile,
|
||
description="Read the content of a file by its fileId.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {"fileId": {"type": "string", "description": "The file ID to read"}},
|
||
"required": ["fileId"]
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
registry.register(
|
||
"listFiles", _listFiles,
|
||
description="List LOCAL workspace files (uploaded/generated). NOT for external data sources -- use browseDataSource instead.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"folderId": {"type": "string", "description": "Filter by folder ID"},
|
||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Filter by tags (any match)"},
|
||
"search": {"type": "string", "description": "Search in file names and descriptions"},
|
||
}
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
registry.register(
|
||
"searchFiles", _searchFiles,
|
||
description="Search LOCAL workspace files by name, description, or tags. NOT for external data sources -- use searchDataSource instead.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string", "description": "Search query"},
|
||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Additional tag filter"},
|
||
},
|
||
"required": ["query"]
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
registry.register(
|
||
"listFolders", _listFolders,
|
||
description="List LOCAL workspace folders. NOT for external data sources -- use browseDataSource instead.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"parentId": {"type": "string", "description": "Parent folder ID (omit for root)"},
|
||
}
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
registry.register(
|
||
"webSearch", _webSearch,
|
||
description="Search the web for information.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {"query": {"type": "string", "description": "Search query"}},
|
||
"required": ["query"]
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
registry.register(
|
||
"tagFile", _tagFile,
|
||
description="Set tags on a file for categorization.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"fileId": {"type": "string", "description": "The file ID"},
|
||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to set"},
|
||
},
|
||
"required": ["fileId", "tags"]
|
||
},
|
||
readOnly=False
|
||
)
|
||
|
||
registry.register(
|
||
"moveFile", _moveFile,
|
||
description="Move a file to a different folder.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"fileId": {"type": "string", "description": "The file ID to move"},
|
||
"targetFolderId": {"type": "string", "description": "Target folder ID (null for root)"},
|
||
},
|
||
"required": ["fileId"]
|
||
},
|
||
readOnly=False
|
||
)
|
||
|
||
registry.register(
|
||
"createFolder", _createFolder,
|
||
description="Create a new file folder.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string", "description": "Folder name"},
|
||
"parentId": {"type": "string", "description": "Parent folder ID (omit for root)"},
|
||
},
|
||
"required": ["name"]
|
||
},
|
||
readOnly=False
|
||
)
|
||
|
||
registry.register(
|
||
"writeFile", _writeFile,
|
||
description="Create a new file with text content.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string", "description": "File name including extension"},
|
||
"content": {"type": "string", "description": "File content as text"},
|
||
"folderId": {"type": "string", "description": "Target folder ID"},
|
||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags"},
|
||
},
|
||
"required": ["name", "content"]
|
||
},
|
||
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():
|
||
"""Build a DB adapter that ConnectorResolver can use to load UserConnections.
|
||
interfaceDbApp has getUserConnectionById; ConnectorResolver expects getUserConnection."""
|
||
chatService = services.chat
|
||
appIf = getattr(chatService, "interfaceDbApp", None)
|
||
if appIf and hasattr(appIf, "getUserConnectionById"):
|
||
class _Adapter:
|
||
def __init__(self, app):
|
||
self._app = app
|
||
def getUserConnection(self, connectionId: str):
|
||
return self._app.getUserConnectionById(connectionId)
|
||
return _Adapter(appIf)
|
||
return getattr(chatService, "interfaceDbComponent", None)
|
||
|
||
async def _listConnections(args: Dict[str, Any], context: Dict[str, Any]):
|
||
try:
|
||
chatService = services.chat
|
||
connections = chatService.getUserConnections() if hasattr(chatService, "getUserConnections") else []
|
||
if not connections:
|
||
return ToolResult(toolCallId="", toolName="listConnections", success=True, data="No connections available.")
|
||
lines = []
|
||
for conn in connections:
|
||
connId = conn.get("id", "?") if isinstance(conn, dict) else getattr(conn, "id", "?")
|
||
authority = conn.get("authority", "?") if isinstance(conn, dict) else getattr(conn, "authority", "?")
|
||
email = conn.get("externalEmail", "") if isinstance(conn, dict) else getattr(conn, "externalEmail", "")
|
||
lines.append(f"- {authority} ({email}) id: {connId}")
|
||
return ToolResult(toolCallId="", toolName="listConnections", success=True, data="\n".join(lines))
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="listConnections", success=False, error=str(e))
|
||
|
||
async def _externalBrowse(args: Dict[str, Any], context: Dict[str, Any]):
|
||
connectionId = args.get("connectionId", "")
|
||
service = args.get("service", "")
|
||
path = args.get("path", "/")
|
||
if not connectionId or not service:
|
||
return ToolResult(toolCallId="", toolName="externalBrowse", success=False, error="connectionId and service are required")
|
||
try:
|
||
from modules.connectors.connectorResolver import ConnectorResolver
|
||
resolver = ConnectorResolver(
|
||
services.getService("security"),
|
||
_buildResolverDb(),
|
||
)
|
||
adapter = await resolver.resolveService(connectionId, service)
|
||
entries = await adapter.browse(path, filter=args.get("filter"))
|
||
entryLines = "\n".join(
|
||
f"- {'[DIR]' if e.isFolder else '[FILE]'} {e.name} ({e.size or '?'} bytes)"
|
||
for e in entries
|
||
) if entries else "Empty directory."
|
||
return ToolResult(toolCallId="", toolName="externalBrowse", success=True, data=entryLines)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="externalBrowse", success=False, error=str(e))
|
||
|
||
async def _externalDownload(args: Dict[str, Any], context: Dict[str, Any]):
|
||
connectionId = args.get("connectionId", "")
|
||
service = args.get("service", "")
|
||
path = args.get("path", "")
|
||
if not connectionId or not service or not path:
|
||
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)
|
||
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")
|
||
|
||
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", "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: {fid}. {hint}"
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="externalDownload", success=False, error=str(e))
|
||
|
||
async def _externalUpload(args: Dict[str, Any], context: Dict[str, Any]):
|
||
connectionId = args.get("connectionId", "")
|
||
service = args.get("service", "")
|
||
path = args.get("path", "")
|
||
fileId = args.get("fileId", "")
|
||
if not connectionId or not service or not path or not fileId:
|
||
return ToolResult(toolCallId="", toolName="externalUpload", success=False, error="connectionId, service, path, and fileId are required")
|
||
try:
|
||
from modules.connectors.connectorResolver import ConnectorResolver
|
||
resolver = ConnectorResolver(
|
||
services.getService("security"),
|
||
_buildResolverDb(),
|
||
)
|
||
adapter = await resolver.resolveService(connectionId, service)
|
||
chatService = services.chat
|
||
fileContent = chatService.getFileContent(fileId)
|
||
if not fileContent:
|
||
return ToolResult(toolCallId="", toolName="externalUpload", success=False, error="File not found")
|
||
fileData = fileContent.get("data", b"") if isinstance(fileContent, dict) else b""
|
||
if isinstance(fileData, str):
|
||
fileData = fileData.encode("utf-8")
|
||
fileName = fileContent.get("fileName", "file") if isinstance(fileContent, dict) else "file"
|
||
result = await adapter.upload(path, fileData, fileName)
|
||
return ToolResult(toolCallId="", toolName="externalUpload", success=True, data=str(result))
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="externalUpload", success=False, error=str(e))
|
||
|
||
async def _externalSearch(args: Dict[str, Any], context: Dict[str, Any]):
|
||
connectionId = args.get("connectionId", "")
|
||
service = args.get("service", "")
|
||
query = args.get("query", "")
|
||
if not connectionId or not service or not query:
|
||
return ToolResult(toolCallId="", toolName="externalSearch", success=False, error="connectionId, service, and query are required")
|
||
try:
|
||
from modules.connectors.connectorResolver import ConnectorResolver
|
||
resolver = ConnectorResolver(
|
||
services.getService("security"),
|
||
_buildResolverDb(),
|
||
)
|
||
adapter = await resolver.resolveService(connectionId, service)
|
||
entries = await adapter.search(query, path=args.get("path"))
|
||
resultLines = "\n".join(
|
||
f"- {e.name} ({e.path})"
|
||
for e in entries
|
||
) if entries else "No results found."
|
||
return ToolResult(toolCallId="", toolName="externalSearch", success=True, data=resultLines)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="externalSearch", success=False, error=str(e))
|
||
|
||
async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]):
|
||
connectionId = args.get("connectionId", "")
|
||
to = args.get("to", [])
|
||
subject = args.get("subject", "")
|
||
body = args.get("body", "")
|
||
if not connectionId or not to or not subject:
|
||
return ToolResult(toolCallId="", toolName="sendMail", success=False, error="connectionId, to, and subject are required")
|
||
try:
|
||
from modules.connectors.connectorResolver import ConnectorResolver
|
||
resolver = ConnectorResolver(
|
||
services.getService("security"),
|
||
_buildResolverDb(),
|
||
)
|
||
adapter = await resolver.resolveService(connectionId, "outlook")
|
||
if hasattr(adapter, "sendMail"):
|
||
result = await adapter.sendMail(to=to, subject=subject, body=body, cc=args.get("cc"))
|
||
return ToolResult(toolCallId="", toolName="sendMail", success=True, data=str(result))
|
||
return ToolResult(toolCallId="", toolName="sendMail", success=False, error="Mail not supported by this adapter")
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="sendMail", success=False, error=str(e))
|
||
|
||
_connToolParams = {
|
||
"connectionId": {"type": "string", "description": "UserConnection ID"},
|
||
"service": {"type": "string", "description": "Service name (sharepoint, outlook, drive, etc.)"},
|
||
}
|
||
|
||
registry.register(
|
||
"listConnections", _listConnections,
|
||
description="List available external connections and their services.",
|
||
parameters={"type": "object", "properties": {}},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"externalBrowse", _externalBrowse,
|
||
description="Browse files in an external source by connectionId+service. For ATTACHED data sources, prefer browseDataSource instead.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
**_connToolParams,
|
||
"path": {"type": "string", "description": "Path to browse"},
|
||
"filter": {"type": "string", "description": "Filter pattern (e.g. '*.pdf')"},
|
||
},
|
||
"required": ["connectionId", "service"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"externalDownload", _externalDownload,
|
||
description="Download a file from an external source into local storage + auto-index.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
**_connToolParams,
|
||
"path": {"type": "string", "description": "File path to download"},
|
||
},
|
||
"required": ["connectionId", "service", "path"],
|
||
},
|
||
readOnly=False,
|
||
)
|
||
|
||
registry.register(
|
||
"externalUpload", _externalUpload,
|
||
description="Upload a local file to an external data source.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
**_connToolParams,
|
||
"path": {"type": "string", "description": "Destination path"},
|
||
"fileId": {"type": "string", "description": "Local file ID to upload"},
|
||
},
|
||
"required": ["connectionId", "service", "path", "fileId"],
|
||
},
|
||
readOnly=False,
|
||
)
|
||
|
||
registry.register(
|
||
"externalSearch", _externalSearch,
|
||
description="Search files in an external source by connectionId+service. For ATTACHED data sources, prefer searchDataSource instead.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
**_connToolParams,
|
||
"query": {"type": "string", "description": "Search query"},
|
||
"path": {"type": "string", "description": "Scope to a specific path"},
|
||
},
|
||
"required": ["connectionId", "service", "query"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"sendMail", _sendMail,
|
||
description="Send an email via a connected mail service (Outlook, Gmail).",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"connectionId": {"type": "string", "description": "UserConnection ID"},
|
||
"to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"},
|
||
"subject": {"type": "string", "description": "Email subject"},
|
||
"body": {"type": "string", "description": "Email body text"},
|
||
"cc": {"type": "array", "items": {"type": "string"}, "description": "CC addresses"},
|
||
},
|
||
"required": ["connectionId", "to", "subject", "body"],
|
||
},
|
||
readOnly=False,
|
||
)
|
||
|
||
# ---- DataSource convenience tools ----
|
||
_SOURCE_TYPE_TO_SERVICE = {
|
||
"sharepointFolder": "sharepoint",
|
||
"onedriveFolder": "onedrive",
|
||
"outlookFolder": "outlook",
|
||
"googleDriveFolder": "drive",
|
||
"gmailFolder": "gmail",
|
||
"ftpFolder": "files",
|
||
}
|
||
|
||
async def _resolveDataSource(dsId: str):
|
||
"""Resolve a DataSource record and return (connectionId, service, path) or raise."""
|
||
chatService = services.chat
|
||
ds = chatService.getDataSource(dsId) if hasattr(chatService, "getDataSource") else None
|
||
if not ds:
|
||
raise ValueError(f"DataSource '{dsId}' not found")
|
||
connectionId = ds.get("connectionId", "")
|
||
sourceType = ds.get("sourceType", "")
|
||
path = ds.get("path", "/")
|
||
label = ds.get("label", "")
|
||
service = _SOURCE_TYPE_TO_SERVICE.get(sourceType, sourceType)
|
||
if not connectionId:
|
||
raise ValueError(f"DataSource '{dsId}' has no connectionId")
|
||
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", "")
|
||
if not dsId:
|
||
return ToolResult(toolCallId="", toolName="browseDataSource", success=False, error="dataSourceId is required")
|
||
try:
|
||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
||
if subPath:
|
||
if subPath.startswith("/"):
|
||
browsePath = subPath
|
||
else:
|
||
browsePath = f"{basePath.rstrip('/')}/{subPath}"
|
||
else:
|
||
browsePath = basePath
|
||
from modules.connectors.connectorResolver import ConnectorResolver
|
||
resolver = ConnectorResolver(
|
||
services.getService("security"),
|
||
_buildResolverDb(),
|
||
)
|
||
adapter = await resolver.resolveService(connectionId, service)
|
||
entries = await adapter.browse(browsePath, filter=args.get("filter"))
|
||
if not entries:
|
||
return ToolResult(toolCallId="", toolName="browseDataSource", success=True, data="Empty directory.")
|
||
lines = []
|
||
for e in entries:
|
||
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}")
|
||
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))
|
||
|
||
async def _searchDataSource(args: Dict[str, Any], context: Dict[str, Any]):
|
||
dsId = args.get("dataSourceId", "")
|
||
query = args.get("query", "")
|
||
if not dsId or not query:
|
||
return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error="dataSourceId and query are required")
|
||
try:
|
||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
||
from modules.connectors.connectorResolver import ConnectorResolver
|
||
resolver = ConnectorResolver(
|
||
services.getService("security"),
|
||
_buildResolverDb(),
|
||
)
|
||
adapter = await resolver.resolveService(connectionId, service)
|
||
entries = await adapter.search(query, path=basePath)
|
||
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]
|
||
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))
|
||
|
||
async def _downloadFromDataSource(args: Dict[str, Any], context: Dict[str, Any]):
|
||
dsId = args.get("dataSourceId", "")
|
||
filePath = args.get("filePath", "")
|
||
fileName = args.get("fileName", "")
|
||
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}"
|
||
resolver = ConnectorResolver(
|
||
services.getService("security"),
|
||
_buildResolverDb(),
|
||
)
|
||
adapter = await resolver.resolveService(connectionId, service)
|
||
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
|
||
if "." not in fileName:
|
||
try:
|
||
entries = await adapter.browse(basePath)
|
||
for entry in entries:
|
||
if getattr(entry, "path", "") == filePath or getattr(entry, "path", "").endswith(filePath):
|
||
if "." in entry.name:
|
||
fileName = entry.name
|
||
break
|
||
except Exception:
|
||
pass
|
||
if "." not in fileName:
|
||
if fileBytes[:4] == b"%PDF":
|
||
fileName = f"{fileName}.pdf"
|
||
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", "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}"
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error=str(e))
|
||
|
||
registry.register(
|
||
"browseDataSource", _browseDataSource,
|
||
description="Browse files AND folders in an ATTACHED data source by its dataSourceId. This is the PRIMARY tool for listing data source contents.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"dataSourceId": {"type": "string", "description": "DataSource ID (from the attached data sources in the prompt)"},
|
||
"subPath": {"type": "string", "description": "Optional sub-path within the data source to browse"},
|
||
"filter": {"type": "string", "description": "Optional filter pattern (e.g. '*.pdf')"},
|
||
},
|
||
"required": ["dataSourceId"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"searchDataSource", _searchDataSource,
|
||
description="Search for files within an attached data source by query.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"dataSourceId": {"type": "string", "description": "DataSource ID"},
|
||
"query": {"type": "string", "description": "Search query"},
|
||
},
|
||
"required": ["dataSourceId", "query"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"downloadFromDataSource", _downloadFromDataSource,
|
||
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": {
|
||
"dataSourceId": {"type": "string", "description": "DataSource ID"},
|
||
"filePath": {"type": "string", "description": "Path of the file to download (as returned by browseDataSource)"},
|
||
"fileName": {"type": "string", "description": "Human-readable file name with extension (e.g. 'report.pdf'). Get this from browseDataSource results."},
|
||
},
|
||
"required": ["dataSourceId", "filePath"],
|
||
},
|
||
readOnly=False,
|
||
)
|
||
|
||
# ---- Document tools (Smart Documents / Container Handling) ----
|
||
|
||
async def _browseContainer(args: Dict[str, Any], context: Dict[str, Any]):
|
||
fileId = args.get("fileId", "")
|
||
if not fileId:
|
||
return ToolResult(toolCallId="", toolName="browseContainer", success=False, error="fileId is required")
|
||
try:
|
||
knowledgeService = services.getService("knowledge")
|
||
index = knowledgeService.getFileContentIndex(fileId)
|
||
if not index:
|
||
return ToolResult(toolCallId="", toolName="browseContainer", success=True, data="No content index available for this file. It may not have been indexed yet.")
|
||
structure = index.get("structure", {}) if isinstance(index, dict) else {}
|
||
objectSummary = index.get("objectSummary", []) if isinstance(index, dict) else []
|
||
totalObjects = index.get("totalObjects", 0) if isinstance(index, dict) else 0
|
||
|
||
result = f"File: {index.get('fileName', '?')} ({index.get('mimeType', '?')})\n"
|
||
result += f"Total content objects: {totalObjects}\n"
|
||
|
||
sections = structure.get("sections", [])
|
||
if sections:
|
||
result += "\nSections:\n"
|
||
for s in sections:
|
||
result += f" [{s.get('id', '?')}] {s.get('title', 'Untitled')} (pages {s.get('startPage', '?')}-{s.get('endPage', '?')})\n"
|
||
|
||
if structure.get("pageMap"):
|
||
pages = len(structure["pageMap"])
|
||
result += f"\nPages: {pages}\n"
|
||
imgCount = structure.get("imageCount", 0)
|
||
tableCount = structure.get("tableCount", 0)
|
||
if imgCount:
|
||
result += f"Images: {imgCount}\n"
|
||
if tableCount:
|
||
result += f"Tables: {tableCount}\n"
|
||
|
||
if structure.get("sheetMap"):
|
||
result += "\nSheets:\n"
|
||
for s in structure["sheetMap"]:
|
||
result += f" {s.get('sheetName', '?')} ({s.get('rows', '?')} rows x {s.get('columns', '?')} cols)\n"
|
||
|
||
if structure.get("slideMap"):
|
||
result += "\nSlides:\n"
|
||
for s in structure["slideMap"]:
|
||
result += f" Slide {s.get('slideIndex', 0) + 1}: {s.get('title', '(no title)')}\n"
|
||
|
||
return ToolResult(toolCallId="", toolName="browseContainer", success=True, data=result)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="browseContainer", success=False, error=str(e))
|
||
|
||
async def _readContentObjects(args: Dict[str, Any], context: Dict[str, Any]):
|
||
fileId = args.get("fileId", "")
|
||
if not fileId:
|
||
return ToolResult(toolCallId="", toolName="readContentObjects", success=False, error="fileId is required")
|
||
try:
|
||
knowledgeService = services.getService("knowledge")
|
||
filterDict = {}
|
||
if args.get("pageIndex") is not None:
|
||
filterDict["pageIndex"] = args["pageIndex"]
|
||
if args.get("contentType"):
|
||
filterDict["contentType"] = args["contentType"]
|
||
if args.get("sectionId"):
|
||
filterDict["sectionId"] = args["sectionId"]
|
||
|
||
objects = await knowledgeService.readContentObjects(fileId, filterDict)
|
||
if not objects:
|
||
return ToolResult(toolCallId="", toolName="readContentObjects", success=True, data="No content objects found with the given filter.")
|
||
|
||
result = f"Found {len(objects)} content objects:\n\n"
|
||
for obj in objects[:20]:
|
||
data = obj.get("data", "")
|
||
cType = obj.get("contentType", "?")
|
||
ref = obj.get("contextRef", {})
|
||
location = ref.get("location", "") if isinstance(ref, dict) else ""
|
||
preview = data[:300] if cType == "text" else f"[{cType} data, {len(data)} chars]"
|
||
result += f"[{cType}] {location}: {preview}\n\n"
|
||
|
||
if len(objects) > 20:
|
||
result += f"... and {len(objects) - 20} more objects"
|
||
|
||
return ToolResult(toolCallId="", toolName="readContentObjects", success=True, data=result)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="readContentObjects", success=False, error=str(e))
|
||
|
||
async def _extractContainerItem(args: Dict[str, Any], context: Dict[str, Any]):
|
||
fileId = args.get("fileId", "")
|
||
containerPath = args.get("containerPath", "")
|
||
if not fileId or not containerPath:
|
||
return ToolResult(toolCallId="", toolName="extractContainerItem", success=False, error="fileId and containerPath are required")
|
||
try:
|
||
knowledgeService = services.getService("knowledge")
|
||
result = await knowledgeService.extractContainerItem(fileId, containerPath)
|
||
if result:
|
||
return ToolResult(toolCallId="", toolName="extractContainerItem", success=True, data=str(result))
|
||
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))
|
||
|
||
async def _summarizeContent(args: Dict[str, Any], context: Dict[str, Any]):
|
||
fileId = args.get("fileId", "")
|
||
if not fileId:
|
||
return ToolResult(toolCallId="", toolName="summarizeContent", success=False, error="fileId is required")
|
||
try:
|
||
knowledgeService = services.getService("knowledge")
|
||
filterDict = {}
|
||
if args.get("sectionId"):
|
||
filterDict["sectionId"] = args["sectionId"]
|
||
if args.get("pageIndex") is not None:
|
||
filterDict["pageIndex"] = args["pageIndex"]
|
||
if args.get("contentType"):
|
||
filterDict["contentType"] = args["contentType"]
|
||
|
||
objects = await knowledgeService.readContentObjects(fileId, filterDict)
|
||
if not objects:
|
||
return ToolResult(toolCallId="", toolName="summarizeContent", success=True, data="No content found to summarize.")
|
||
|
||
textParts = [obj.get("data", "") for obj in objects if obj.get("contentType") != "image"]
|
||
combinedText = "\n\n".join(textParts)[:6000]
|
||
|
||
aiService = services.ai
|
||
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum
|
||
summaryRequest = AiCallRequest(
|
||
prompt=f"Summarize the following content concisely:\n\n{combinedText}",
|
||
options=AiCallOptions(operationType=OperationTypeEnum.DATA_ANALYSE),
|
||
)
|
||
response = await aiService.callAi(summaryRequest)
|
||
return ToolResult(toolCallId="", toolName="summarizeContent", success=True, data=response.content)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="summarizeContent", success=False, error=str(e))
|
||
|
||
registry.register(
|
||
"browseContainer", _browseContainer,
|
||
description="Browse the structural index of a file/container (pages, sections, sheets, slides).",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {"fileId": {"type": "string", "description": "The file ID to browse"}},
|
||
"required": ["fileId"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"readContentObjects", _readContentObjects,
|
||
description="Read content objects from a file with optional filters (page, section, type).",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"fileId": {"type": "string", "description": "The file ID"},
|
||
"pageIndex": {"type": "integer", "description": "Filter by page index"},
|
||
"sectionId": {"type": "string", "description": "Filter by section ID"},
|
||
"contentType": {"type": "string", "description": "Filter by content type (text, image, etc.)"},
|
||
},
|
||
"required": ["fileId"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"extractContainerItem", _extractContainerItem,
|
||
description="On-demand extraction of a specific item within a container (ZIP, nested file).",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"fileId": {"type": "string", "description": "The container file ID"},
|
||
"containerPath": {"type": "string", "description": "Path within the container"},
|
||
},
|
||
"required": ["fileId", "containerPath"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"summarizeContent", _summarizeContent,
|
||
description="AI-powered summary of content objects from a file, optionally filtered.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"fileId": {"type": "string", "description": "The file ID"},
|
||
"sectionId": {"type": "string", "description": "Optional: summarize only this section"},
|
||
"pageIndex": {"type": "integer", "description": "Optional: summarize only this page"},
|
||
"contentType": {"type": "string", "description": "Optional: filter by content type"},
|
||
},
|
||
"required": ["fileId"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
# ---- Vision tool ----
|
||
|
||
async def _describeImage(args: Dict[str, Any], context: Dict[str, Any]):
|
||
"""Analyse an image using AI vision. Uses Knowledge Store chunks produced by Extractors."""
|
||
fileId = args.get("fileId", "")
|
||
prompt = args.get("prompt", "Describe this image in detail. Extract all visible text, tables, and data.")
|
||
pageIndex = args.get("pageIndex")
|
||
|
||
if not fileId:
|
||
return ToolResult(toolCallId="", toolName="describeImage", success=False, error="fileId is required")
|
||
|
||
try:
|
||
import base64 as _b64
|
||
|
||
imageData = None
|
||
mimeType = "image/png"
|
||
|
||
knowledgeService = services.getService("knowledge") if hasattr(services, "getService") else None
|
||
|
||
# 1) Knowledge Store: image chunks already produced by PdfExtractor / ImageExtractor
|
||
if knowledgeService:
|
||
chunks = knowledgeService._knowledgeDb.getContentChunks(fileId)
|
||
imageChunks = [c for c in (chunks or []) if c.get("contentType") == "image"]
|
||
if pageIndex is not None:
|
||
imageChunks = [c for c in imageChunks if c.get("contextRef", {}).get("pageIndex") == pageIndex]
|
||
if imageChunks:
|
||
imageData = imageChunks[0].get("data", "")
|
||
chunkMime = imageChunks[0].get("contextRef", {}).get("mimeType")
|
||
if chunkMime:
|
||
mimeType = chunkMime
|
||
|
||
# 2) File not yet indexed -> trigger extraction via ExtractionService, then retry
|
||
if not imageData and knowledgeService and not knowledgeService.isFileIndexed(fileId):
|
||
try:
|
||
chatService = services.chat
|
||
fileInfo = chatService.getFileInfo(fileId)
|
||
fileContent = chatService.getFileContent(fileId)
|
||
if fileContent and fileInfo:
|
||
rawData = fileContent.get("data", "")
|
||
if isinstance(rawData, str) and len(rawData) > 100:
|
||
rawBytes = _b64.b64decode(rawData)
|
||
elif isinstance(rawData, bytes):
|
||
rawBytes = rawData
|
||
else:
|
||
rawBytes = None
|
||
|
||
if rawBytes:
|
||
from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry
|
||
from modules.serviceCenter.services.serviceExtraction.subPipeline import runExtraction
|
||
from modules.datamodels.datamodelExtraction import ExtractionOptions
|
||
|
||
fileMime = fileInfo.get("mimeType", "application/octet-stream")
|
||
fileName = fileInfo.get("fileName", fileId)
|
||
extracted = runExtraction(
|
||
ExtractorRegistry(), None,
|
||
rawBytes, fileName, fileMime, ExtractionOptions(),
|
||
)
|
||
|
||
contentObjects = []
|
||
for part in extracted.parts:
|
||
tg = (part.typeGroup or "").lower()
|
||
ct = "image" if tg == "image" else "text"
|
||
if not part.data or not part.data.strip():
|
||
continue
|
||
contentObjects.append({
|
||
"contentObjectId": part.id,
|
||
"contentType": ct,
|
||
"data": part.data,
|
||
"contextRef": {"containerPath": fileName, "location": part.label, **(part.metadata or {})},
|
||
})
|
||
|
||
if contentObjects:
|
||
await knowledgeService.indexFile(
|
||
fileId=fileId, fileName=fileName, mimeType=fileMime,
|
||
userId=context.get("userId", ""), contentObjects=contentObjects,
|
||
)
|
||
|
||
chunks = knowledgeService._knowledgeDb.getContentChunks(fileId)
|
||
imageChunks = [c for c in (chunks or []) if c.get("contentType") == "image"]
|
||
if pageIndex is not None:
|
||
imageChunks = [c for c in imageChunks if c.get("contextRef", {}).get("pageIndex") == pageIndex]
|
||
if imageChunks:
|
||
imageData = imageChunks[0].get("data", "")
|
||
except Exception as extractErr:
|
||
logger.warning(f"describeImage: on-demand extraction failed: {extractErr}")
|
||
|
||
# 3) Direct image file (not a container) - use raw file data
|
||
if not imageData:
|
||
chatService = services.chat
|
||
fileContent = chatService.getFileContent(fileId)
|
||
if fileContent:
|
||
fileMimeType = fileContent.get("mimeType", "")
|
||
if fileMimeType.startswith("image/"):
|
||
imageData = fileContent.get("data", "")
|
||
mimeType = fileMimeType
|
||
|
||
if not imageData:
|
||
chatService = services.chat
|
||
fileInfo = chatService.getFileInfo(fileId) if hasattr(chatService, "getFileInfo") else None
|
||
fileName = fileInfo.get("fileName", fileId) if fileInfo else fileId
|
||
fileMime = fileInfo.get("mimeType", "unknown") if fileInfo else "unknown"
|
||
return ToolResult(toolCallId="", toolName="describeImage", success=False,
|
||
error=f"No image data found in '{fileName}' (type: {fileMime}). "
|
||
f"This file likely contains text, not images. Use readFile(fileId=\"{fileId}\") to access its text content.")
|
||
|
||
try:
|
||
rawHead = _b64.b64decode(imageData[:32])
|
||
if rawHead[:3] == b"\xff\xd8\xff":
|
||
mimeType = "image/jpeg"
|
||
elif rawHead[:8] == b"\x89PNG\r\n\x1a\n":
|
||
mimeType = "image/png"
|
||
elif rawHead[:4] == b"GIF8":
|
||
mimeType = "image/gif"
|
||
elif rawHead[:4] == b"RIFF" and rawHead[8:12] == b"WEBP":
|
||
mimeType = "image/webp"
|
||
except Exception:
|
||
pass
|
||
dataUrl = f"data:{mimeType};base64,{imageData}"
|
||
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum as OTE
|
||
|
||
visionRequest = AiCallRequest(
|
||
prompt=prompt,
|
||
options=AiCallOptions(operationType=OTE.IMAGE_ANALYSE),
|
||
messages=[{"role": "user", "content": [
|
||
{"type": "text", "text": prompt},
|
||
{"type": "image_url", "image_url": {"url": dataUrl}},
|
||
]}],
|
||
)
|
||
visionResponse = await services.ai.callAi(visionRequest)
|
||
|
||
if visionResponse.errorCount > 0:
|
||
return ToolResult(toolCallId="", toolName="describeImage", success=False, error=visionResponse.content)
|
||
return ToolResult(toolCallId="", toolName="describeImage", success=True, data=visionResponse.content)
|
||
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="describeImage", success=False, error=str(e))
|
||
|
||
registry.register(
|
||
"describeImage", _describeImage,
|
||
description="Analyse an image using AI vision. Works with image files and images extracted from PDFs/DOCX/PPTX.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"fileId": {"type": "string", "description": "The file ID containing the image or document with images"},
|
||
"prompt": {"type": "string", "description": "What to look for in the image (default: describe everything)"},
|
||
"pageIndex": {"type": "integer", "description": "Filter images by page index (0-based, for multi-page documents)"},
|
||
},
|
||
"required": ["fileId"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
# ---- Document rendering tool ----
|
||
|
||
def _markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> Dict[str, Any]:
|
||
"""Convert markdown content to the standard document JSON format expected by renderers."""
|
||
import re as _re
|
||
|
||
sections = []
|
||
order = 0
|
||
lines = markdown.split("\n")
|
||
i = 0
|
||
|
||
def _nextId():
|
||
nonlocal order
|
||
order += 1
|
||
return f"s_{order}"
|
||
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
|
||
# --- Headings ---
|
||
headingMatch = _re.match(r'^(#{1,6})\s+(.+)', line)
|
||
if headingMatch:
|
||
level = len(headingMatch.group(1))
|
||
text = headingMatch.group(2).strip()
|
||
sections.append({
|
||
"id": _nextId(), "content_type": "heading", "order": order,
|
||
"elements": [{"content": {"text": text, "level": level}}],
|
||
})
|
||
i += 1
|
||
continue
|
||
|
||
# --- Fenced code blocks ---
|
||
codeMatch = _re.match(r'^```(\w*)', line)
|
||
if codeMatch:
|
||
lang = codeMatch.group(1) or "text"
|
||
codeLines = []
|
||
i += 1
|
||
while i < len(lines) and not lines[i].startswith("```"):
|
||
codeLines.append(lines[i])
|
||
i += 1
|
||
i += 1
|
||
sections.append({
|
||
"id": _nextId(), "content_type": "code_block", "order": order,
|
||
"elements": [{"content": {"code": "\n".join(codeLines), "language": lang}}],
|
||
})
|
||
continue
|
||
|
||
# --- Tables ---
|
||
tableMatch = _re.match(r'^\|(.+)\|$', line)
|
||
if tableMatch and (i + 1) < len(lines) and _re.match(r'^\|[\s\-:|]+\|$', lines[i + 1]):
|
||
headerCells = [c.strip() for c in tableMatch.group(1).split("|")]
|
||
i += 2
|
||
rows = []
|
||
while i < len(lines) and _re.match(r'^\|(.+)\|$', lines[i]):
|
||
rowCells = [c.strip() for c in lines[i][1:-1].split("|")]
|
||
rows.append(rowCells)
|
||
i += 1
|
||
sections.append({
|
||
"id": _nextId(), "content_type": "table", "order": order,
|
||
"elements": [{"content": {"headers": headerCells, "rows": rows}}],
|
||
})
|
||
continue
|
||
|
||
# --- Bullet / numbered lists ---
|
||
listMatch = _re.match(r'^(\s*)([-*+]|\d+[.)]) (.+)', line)
|
||
if listMatch:
|
||
isNumbered = bool(_re.match(r'\d+[.)]', listMatch.group(2)))
|
||
items = []
|
||
while i < len(lines) and _re.match(r'^(\s*)([-*+]|\d+[.)]) (.+)', lines[i]):
|
||
m = _re.match(r'^(\s*)([-*+]|\d+[.)]) (.+)', lines[i])
|
||
items.append({"text": m.group(3).strip()})
|
||
i += 1
|
||
sections.append({
|
||
"id": _nextId(), "content_type": "bullet_list", "order": order,
|
||
"elements": [{"content": {"items": items, "list_type": "numbered" if isNumbered else "bullet"}}],
|
||
})
|
||
continue
|
||
|
||
# --- Empty lines (skip) ---
|
||
if not line.strip():
|
||
i += 1
|
||
continue
|
||
|
||
# --- Images:  or  ---
|
||
imgMatch = _re.match(r'^!\[([^\]]*)\]\(([^)]+)\)', line)
|
||
if imgMatch:
|
||
altText = imgMatch.group(1).strip() or "Image"
|
||
src = imgMatch.group(2).strip()
|
||
fileId = ""
|
||
if src.startswith("file:"):
|
||
fileId = src[5:]
|
||
sections.append({
|
||
"id": _nextId(), "content_type": "image", "order": order,
|
||
"elements": [{
|
||
"content": {
|
||
"altText": altText,
|
||
"base64Data": "",
|
||
"_fileRef": fileId,
|
||
"_srcUrl": src if not fileId else "",
|
||
}
|
||
}],
|
||
})
|
||
i += 1
|
||
continue
|
||
|
||
# --- Paragraph (collect consecutive non-empty lines) ---
|
||
paraLines = []
|
||
while i < len(lines) and lines[i].strip() and not _re.match(r'^(#{1,6}\s|```|\|.+\||!\[|(\s*)([-*+]|\d+[.)]) )', lines[i]):
|
||
paraLines.append(lines[i])
|
||
i += 1
|
||
if paraLines:
|
||
sections.append({
|
||
"id": _nextId(), "content_type": "paragraph", "order": order,
|
||
"elements": [{"content": {"text": " ".join(paraLines)}}],
|
||
})
|
||
continue
|
||
|
||
i += 1
|
||
|
||
if not sections:
|
||
sections.append({
|
||
"id": _nextId(), "content_type": "paragraph", "order": order,
|
||
"elements": [{"content": {"text": markdown.strip() or "(empty)"}}],
|
||
})
|
||
|
||
return {
|
||
"metadata": {
|
||
"split_strategy": "single_document",
|
||
"source_documents": [],
|
||
"extraction_method": "agent_rendering",
|
||
"title": title,
|
||
"language": language,
|
||
},
|
||
"documents": [{
|
||
"id": "doc_1",
|
||
"title": title,
|
||
"sections": sections,
|
||
}],
|
||
}
|
||
|
||
async def _renderDocument(args: Dict[str, Any], context: Dict[str, Any]):
|
||
"""Render agent-produced markdown content into any document format via the RendererRegistry."""
|
||
import re as _re
|
||
content = args.get("content", "")
|
||
outputFormat = args.get("outputFormat", "pdf")
|
||
title = args.get("title", "Document")
|
||
language = args.get("language", "de")
|
||
|
||
if not content:
|
||
return ToolResult(toolCallId="", toolName="renderDocument", success=False, error="content is required")
|
||
|
||
try:
|
||
structuredContent = _markdownToDocumentJson(content, title, language)
|
||
|
||
# Resolve image file references (file:fileId) to base64 data from Knowledge Store
|
||
knowledgeService = None
|
||
try:
|
||
knowledgeService = services.getService("knowledge")
|
||
except Exception:
|
||
pass
|
||
resolvedImages = 0
|
||
for doc in structuredContent.get("documents", []):
|
||
for section in doc.get("sections", []):
|
||
if section.get("content_type") != "image":
|
||
continue
|
||
for element in section.get("elements", []):
|
||
contentObj = element.get("content", {})
|
||
fileRef = contentObj.get("_fileRef", "")
|
||
if not fileRef or contentObj.get("base64Data"):
|
||
continue
|
||
if knowledgeService:
|
||
chunks = knowledgeService._knowledgeDb.getContentChunks(fileRef)
|
||
imageChunks = [c for c in (chunks or []) if c.get("contentType") == "image"]
|
||
if imageChunks:
|
||
contentObj["base64Data"] = imageChunks[0].get("data", "")
|
||
chunkMime = imageChunks[0].get("contextRef", {}).get("mimeType", "image/png")
|
||
contentObj["mimeType"] = chunkMime
|
||
resolvedImages += 1
|
||
if not contentObj.get("base64Data"):
|
||
try:
|
||
rawBytes = services.chat.getFileData(fileRef)
|
||
if rawBytes:
|
||
import base64 as _b64
|
||
contentObj["base64Data"] = _b64.b64encode(rawBytes).decode("ascii")
|
||
contentObj["mimeType"] = "image/png"
|
||
resolvedImages += 1
|
||
except Exception:
|
||
pass
|
||
contentObj.pop("_fileRef", None)
|
||
contentObj.pop("_srcUrl", None)
|
||
|
||
sectionCount = len(structuredContent.get("documents", [{}])[0].get("sections", []))
|
||
logger.info(f"renderDocument: parsed {sectionCount} sections from markdown ({len(content)} chars), resolved {resolvedImages} image(s), format={outputFormat}")
|
||
|
||
generationService = services.getService("generation")
|
||
documents = await generationService.renderReport(
|
||
extractedContent=structuredContent,
|
||
outputFormat=outputFormat,
|
||
language=language,
|
||
title=title,
|
||
userPrompt=content,
|
||
)
|
||
|
||
if not documents:
|
||
return ToolResult(toolCallId="", toolName="renderDocument", success=False, error="Rendering produced no output")
|
||
|
||
savedFiles = []
|
||
sideEvents = []
|
||
chatService = services.chat
|
||
|
||
sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "document"
|
||
|
||
for doc in documents:
|
||
docData = doc.documentData if hasattr(doc, "documentData") else b""
|
||
docName = doc.filename if hasattr(doc, "filename") else f"{sanitizedTitle}.{outputFormat}"
|
||
docMime = doc.mimeType if hasattr(doc, "mimeType") else "application/octet-stream"
|
||
|
||
if not docName.lower().endswith(f".{outputFormat}"):
|
||
docName = f"{sanitizedTitle}.{outputFormat}"
|
||
|
||
fileItem = None
|
||
if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"):
|
||
fileItem = chatService.interfaceDbComponent.saveGeneratedFile(docData, docName, docMime)
|
||
else:
|
||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docData, docName)
|
||
|
||
if fileItem:
|
||
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})
|
||
savedFiles.append(f"- {docName} (id: {fid})")
|
||
sideEvents.append({
|
||
"type": "fileCreated",
|
||
"data": {
|
||
"fileId": fid,
|
||
"fileName": docName,
|
||
"mimeType": docMime,
|
||
"fileSize": len(docData),
|
||
},
|
||
})
|
||
|
||
result = f"Rendered {len(documents)} document(s):\n" + "\n".join(savedFiles)
|
||
return ToolResult(toolCallId="", toolName="renderDocument", success=True, data=result, sideEvents=sideEvents)
|
||
|
||
except Exception as e:
|
||
logger.error(f"renderDocument failed: {e}")
|
||
return ToolResult(toolCallId="", toolName="renderDocument", success=False, error=str(e))
|
||
|
||
registry.register(
|
||
"renderDocument", _renderDocument,
|
||
description=(
|
||
"Render markdown content into a document file (PDF, DOCX, XLSX, PPTX, CSV, HTML, MD, JSON, TXT). "
|
||
"You write the full document content as markdown, then this tool converts and renders it. "
|
||
"To embed images from uploaded files, use markdown image syntax with the file ID: . "
|
||
"The images will be resolved from the Knowledge Store and embedded in the output document."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"content": {"type": "string", "description": "Full document content as markdown (headings, tables, lists, code blocks, paragraphs, images via )"},
|
||
"outputFormat": {"type": "string", "description": "Target format: pdf, docx, xlsx, pptx, csv, html, md, json, txt", "default": "pdf"},
|
||
"title": {"type": "string", "description": "Document title", "default": "Document"},
|
||
"language": {"type": "string", "description": "Document language (ISO 639-1)", "default": "de"},
|
||
},
|
||
"required": ["content"],
|
||
},
|
||
readOnly=False,
|
||
)
|
||
|
||
# ── textToSpeech tool ──────────────────────────────────────────────
|
||
def _stripMarkdownForTts(text: str) -> str:
|
||
"""Strip markdown formatting so TTS reads clean speech text."""
|
||
import re as _re
|
||
t = text
|
||
t = _re.sub(r'\*\*(.+?)\*\*', r'\1', t)
|
||
t = _re.sub(r'\*(.+?)\*', r'\1', t)
|
||
t = _re.sub(r'__(.+?)__', r'\1', t)
|
||
t = _re.sub(r'_(.+?)_', r'\1', t)
|
||
t = _re.sub(r'`[^`]+`', lambda m: m.group(0)[1:-1], t)
|
||
t = _re.sub(r'^#{1,6}\s*', '', t, flags=_re.MULTILINE)
|
||
t = _re.sub(r'^\s*[-*+]\s+', '', t, flags=_re.MULTILINE)
|
||
t = _re.sub(r'^\s*\d+\.\s+', '', t, flags=_re.MULTILINE)
|
||
t = _re.sub(r'\[(.+?)\]\(.+?\)', r'\1', t)
|
||
t = _re.sub(r'!\[.*?\]\(.*?\)', '', t)
|
||
t = _re.sub(r'\n{3,}', '\n\n', t)
|
||
return t.strip()
|
||
|
||
async def _textToSpeech(args: Dict[str, Any], context: Dict[str, Any]):
|
||
"""Convert text to speech using Google Cloud TTS, deliver audio via SSE."""
|
||
import base64 as _b64
|
||
text = args.get("text", "")
|
||
language = args.get("language", "auto")
|
||
voiceName = args.get("voiceName")
|
||
|
||
if not text:
|
||
return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error="text is required")
|
||
|
||
cleanText = _stripMarkdownForTts(text)
|
||
if not cleanText:
|
||
return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error="text is empty after stripping markdown")
|
||
|
||
try:
|
||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||
mandateId = context.get("mandateId", "")
|
||
voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId)
|
||
|
||
_ISO_TO_BCP47 = {
|
||
"de": "de-DE", "en": "en-US", "fr": "fr-FR", "it": "it-IT",
|
||
"es": "es-ES", "pt": "pt-BR", "nl": "nl-NL", "pl": "pl-PL",
|
||
"ru": "ru-RU", "ja": "ja-JP", "zh": "zh-CN", "ko": "ko-KR",
|
||
"ar": "ar-XA", "hi": "hi-IN", "tr": "tr-TR", "sv": "sv-SE",
|
||
}
|
||
|
||
if language == "auto":
|
||
try:
|
||
snippet = cleanText[:500]
|
||
detectResult = await voiceInterface.detectLanguage(snippet)
|
||
if detectResult and detectResult.get("success"):
|
||
detected = detectResult.get("language", "de")
|
||
language = _ISO_TO_BCP47.get(detected, detected)
|
||
if "-" not in language:
|
||
language = _ISO_TO_BCP47.get(language, f"{language}-{language.upper()}")
|
||
logger.info(f"textToSpeech: auto-detected language '{detected}' -> '{language}'")
|
||
else:
|
||
language = "de-DE"
|
||
except Exception as detectErr:
|
||
logger.warning(f"textToSpeech: language detection failed: {detectErr}, defaulting to de-DE")
|
||
language = "de-DE"
|
||
|
||
if not voiceName:
|
||
try:
|
||
featureInstanceId = context.get("featureInstanceId", "")
|
||
userId = context.get("userId", "")
|
||
if featureInstanceId and userId:
|
||
dbMgmt = services.chat.interfaceDbApp if hasattr(services.chat, "interfaceDbApp") else None
|
||
if dbMgmt and hasattr(dbMgmt, "getVoiceSettings"):
|
||
vs = dbMgmt.getVoiceSettings(userId)
|
||
if vs:
|
||
voiceMap = {}
|
||
if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap:
|
||
voiceMap = vs.ttsVoiceMap if isinstance(vs.ttsVoiceMap, dict) else {}
|
||
if language in voiceMap:
|
||
voiceName = voiceMap[language].get("voiceName") if isinstance(voiceMap[language], dict) else voiceMap[language]
|
||
logger.info(f"textToSpeech: using configured voice '{voiceName}' for {language}")
|
||
elif hasattr(vs, "ttsVoice") and vs.ttsVoice and hasattr(vs, "ttsLanguage") and vs.ttsLanguage == language:
|
||
voiceName = vs.ttsVoice
|
||
except Exception as prefErr:
|
||
logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}")
|
||
|
||
ttsResult = await voiceInterface.textToSpeech(
|
||
text=cleanText,
|
||
languageCode=language,
|
||
voiceName=voiceName,
|
||
)
|
||
|
||
if not ttsResult or not ttsResult.get("success"):
|
||
errMsg = ttsResult.get("error", "TTS call failed") if ttsResult else "TTS returned None"
|
||
return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error=errMsg)
|
||
|
||
audioContent = ttsResult.get("audioContent", "")
|
||
if not audioContent:
|
||
return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error="TTS returned no audio")
|
||
|
||
if isinstance(audioContent, bytes):
|
||
audioB64 = _b64.b64encode(audioContent).decode("ascii")
|
||
elif isinstance(audioContent, str):
|
||
audioB64 = audioContent
|
||
else:
|
||
audioB64 = str(audioContent)
|
||
|
||
audioFormat = ttsResult.get("audioFormat", "mp3")
|
||
charCount = len(cleanText)
|
||
usedVoice = voiceName or "default"
|
||
logger.info(f"textToSpeech: generated {audioFormat} audio for {charCount} chars, language={language}, voice={usedVoice}")
|
||
|
||
return ToolResult(
|
||
toolCallId="", toolName="textToSpeech", success=True,
|
||
data=f"Audio generated ({charCount} characters, language={language}, voice={usedVoice}). Playing in chat.",
|
||
sideEvents=[{
|
||
"type": "voiceResponse",
|
||
"data": {
|
||
"audio": audioB64,
|
||
"format": audioFormat,
|
||
"language": language,
|
||
"charCount": charCount,
|
||
},
|
||
}],
|
||
)
|
||
|
||
except ImportError:
|
||
return ToolResult(toolCallId="", toolName="textToSpeech", success=False,
|
||
error="Voice interface not available (missing dependency)")
|
||
except Exception as e:
|
||
logger.error(f"textToSpeech failed: {e}")
|
||
return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error=str(e))
|
||
|
||
registry.register(
|
||
"textToSpeech", _textToSpeech,
|
||
description=(
|
||
"Convert text to speech audio. The audio is played directly in the chat. "
|
||
"Use this when the user asks you to read something aloud, narrate, or speak. "
|
||
"Language is auto-detected from the text content. You do NOT need to specify a language."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"text": {"type": "string", "description": "The text to convert to speech. Can include markdown (will be stripped automatically)."},
|
||
"language": {"type": "string", "description": "BCP-47 language code (e.g. de-DE, en-US) or 'auto' for automatic detection", "default": "auto"},
|
||
"voiceName": {"type": "string", "description": "Optional specific voice name. If omitted, uses the configured voice for the detected language."},
|
||
},
|
||
"required": ["text"],
|
||
},
|
||
readOnly=False,
|
||
)
|
||
|
||
# ── generateImage tool ─────────────────────────────────────────────
|
||
|
||
async def _generateImage(args: Dict[str, Any], context: Dict[str, Any]):
|
||
"""Generate an image from a text prompt using AI (DALL-E)."""
|
||
import re as _re
|
||
|
||
prompt = (args.get("prompt") or "").strip()
|
||
style = (args.get("style") or "").strip() or None
|
||
title = (args.get("title") or "").strip() or "Generated Image"
|
||
|
||
if not prompt:
|
||
return ToolResult(toolCallId="", toolName="generateImage", success=False, error="prompt is required")
|
||
|
||
try:
|
||
from modules.serviceCenter.services.serviceGeneration.paths.imagePath import ImageGenerationPath
|
||
|
||
imagePath = ImageGenerationPath(services)
|
||
aiResponse = await imagePath.generateImages(
|
||
userPrompt=prompt,
|
||
count=1,
|
||
style=style,
|
||
format="png",
|
||
title=title,
|
||
)
|
||
|
||
if not aiResponse.documents:
|
||
return ToolResult(toolCallId="", toolName="generateImage", success=False, error="Image generation returned no image data")
|
||
|
||
sideEvents = []
|
||
savedFiles = []
|
||
chatService = services.chat
|
||
sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "generated_image"
|
||
|
||
for doc in aiResponse.documents:
|
||
docData = doc.documentData if hasattr(doc, "documentData") else b""
|
||
docName = doc.documentName if hasattr(doc, "documentName") else f"{sanitizedTitle}.png"
|
||
docMime = doc.mimeType if hasattr(doc, "mimeType") else "image/png"
|
||
|
||
if not docName.lower().endswith(".png"):
|
||
docName = f"{sanitizedTitle}.png"
|
||
|
||
fileItem = None
|
||
if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"):
|
||
fileItem = chatService.interfaceDbComponent.saveGeneratedFile(docData, docName, docMime)
|
||
else:
|
||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docData, docName)
|
||
|
||
if fileItem:
|
||
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})
|
||
savedFiles.append(f"- {docName} (id: {fid})")
|
||
sideEvents.append({
|
||
"type": "fileCreated",
|
||
"data": {
|
||
"fileId": fid,
|
||
"fileName": docName,
|
||
"mimeType": docMime,
|
||
"fileSize": len(docData),
|
||
},
|
||
})
|
||
|
||
result = f"Generated {len(aiResponse.documents)} image(s):\n" + "\n".join(savedFiles)
|
||
return ToolResult(toolCallId="", toolName="generateImage", success=True, data=result, sideEvents=sideEvents)
|
||
|
||
except Exception as e:
|
||
logger.error(f"generateImage failed: {e}")
|
||
return ToolResult(toolCallId="", toolName="generateImage", success=False, error=str(e))
|
||
|
||
registry.register(
|
||
"generateImage", _generateImage,
|
||
description=(
|
||
"Generate an image from a text description using AI (DALL-E). "
|
||
"The generated image is saved as a file in the workspace. "
|
||
"Use this when the user asks to create, generate, draw, or design an image, illustration, icon, logo, diagram, or any visual content. "
|
||
"Provide a detailed, descriptive prompt for best results."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"prompt": {"type": "string", "description": "Detailed description of the image to generate. Be specific about subject, composition, colors, style, and mood."},
|
||
"style": {"type": "string", "description": "Optional style modifier (e.g. 'photorealistic', 'watercolor', 'digital art', 'minimalist', 'sketch')"},
|
||
"title": {"type": "string", "description": "Title/filename for the generated image", "default": "Generated Image"},
|
||
},
|
||
"required": ["prompt"],
|
||
},
|
||
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
|
||
)
|