3418 lines
165 KiB
Python
3418 lines
165 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 _resolveFileScope(fileId: str, context: dict) -> tuple:
|
||
"""Resolve featureInstanceId and mandateId for a file from context or management DB.
|
||
|
||
Returns (featureInstanceId, mandateId) — never None, always strings.
|
||
"""
|
||
fiId = context.get("featureInstanceId", "") or ""
|
||
mId = context.get("mandateId", "") or ""
|
||
if fiId and mId:
|
||
return fiId, mId
|
||
try:
|
||
from modules.datamodels.datamodelFiles import FileItem
|
||
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||
fm = ComponentObjects().db._loadRecord(FileItem, fileId)
|
||
if fm:
|
||
_get = (lambda k: fm.get(k, "")) if isinstance(fm, dict) else (lambda k: getattr(fm, k, ""))
|
||
fiId = fiId or str(_get("featureInstanceId") or "")
|
||
mId = mId or str(_get("mandateId") or "")
|
||
except Exception:
|
||
pass
|
||
return fiId, mId
|
||
|
||
|
||
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()
|
||
persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId)
|
||
getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId)
|
||
|
||
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,
|
||
persistRoundMemoryFn=persistRoundMemoryFn,
|
||
getExternalMemoryKeysFn=getExternalMemoryKeysFn,
|
||
):
|
||
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"
|
||
"For large PDFs/DOCX, avoid huge `renderDocument` tool JSON: build markdown with "
|
||
"`writeFile` (create + append), then `renderDocument(sourceFileId=that file id, outputFormat=...)`.\n"
|
||
"For small docs you may pass `content` inline. Embed images with `` in markdown.\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 and workflow artifacts 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",
|
||
)
|
||
|
||
artifacts = summaryData.get("artifacts", "")
|
||
if artifacts:
|
||
await knowledgeService.storeEntity(
|
||
workflowId=workflowId,
|
||
userId=userId,
|
||
featureInstanceId=featureInstanceId,
|
||
key="_workflowArtifacts",
|
||
value=artifacts,
|
||
source="agent",
|
||
)
|
||
logger.info(f"Persisted workflow artifacts for workflow {workflowId}")
|
||
|
||
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."""
|
||
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
|
||
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
|
||
if ctxNeutralization is not None and request.requireNeutralization is None:
|
||
request.requireNeutralization = ctxNeutralization
|
||
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."""
|
||
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
|
||
async def _aiCallStreamFn(request: AiCallRequest):
|
||
if ctxNeutralization is not None and request.requireNeutralization is None:
|
||
request.requireNeutralization = ctxNeutralization
|
||
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")
|
||
workflowHintItems = _buildWorkflowHintItems(
|
||
self.services, workflowId
|
||
)
|
||
return await knowledgeService.buildAgentContext(
|
||
currentPrompt=currentPrompt,
|
||
workflowId=workflowId,
|
||
userId=userId,
|
||
featureInstanceId=featureInstanceId,
|
||
mandateId=mandateId,
|
||
workflowHintItems=workflowHintItems,
|
||
isSysAdmin=getattr(self.services.user, "isSysAdmin", False),
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"RAG context not available: {e}")
|
||
return ""
|
||
return _buildRagContext
|
||
|
||
def _createPersistRoundMemoryFn(self, workflowId: str):
|
||
"""Create callback that persists RoundMemory entries after tool execution."""
|
||
from modules.serviceCenter.services.serviceAgent.agentLoop import _classifyToolResult
|
||
from modules.datamodels.datamodelKnowledge import RoundMemory
|
||
|
||
async def _persistRoundMemory(
|
||
toolCalls, results, textContent: str, roundNumber: int
|
||
):
|
||
try:
|
||
knowledgeService = self.services.getService("knowledge")
|
||
except Exception:
|
||
return
|
||
knowledgeDb = knowledgeService._knowledgeDb
|
||
|
||
for tc, result in zip(toolCalls, results):
|
||
if not result.success:
|
||
continue
|
||
classified = _classifyToolResult(tc, result)
|
||
if not classified:
|
||
continue
|
||
|
||
summary = classified["summary"]
|
||
embedding = await knowledgeService._embedSingle(summary[:500]) if summary else []
|
||
|
||
mem = RoundMemory(
|
||
workflowId=workflowId,
|
||
roundNumber=roundNumber,
|
||
memoryType=classified["memoryType"],
|
||
key=classified["key"],
|
||
summary=summary,
|
||
fullData=classified.get("fullData"),
|
||
fileIds=classified.get("fileIds", []),
|
||
embedding=embedding if embedding else None,
|
||
)
|
||
knowledgeDb.storeRoundMemory(mem)
|
||
|
||
return _persistRoundMemory
|
||
|
||
def _createGetExternalMemoryKeysFn(self, workflowId: str):
|
||
"""Create callback that returns RoundMemory keys for summarization hints."""
|
||
def _getKeys() -> List[str]:
|
||
try:
|
||
knowledgeService = self.services.getService("knowledge")
|
||
memories = knowledgeService._knowledgeDb.getRoundMemories(workflowId)
|
||
return [m.get("key", "") for m in memories if m.get("key")]
|
||
except Exception:
|
||
return []
|
||
return _getKeys
|
||
|
||
|
||
def _buildWorkflowHintItems(
|
||
services, currentWorkflowId: str
|
||
) -> List[Dict[str, Any]]:
|
||
"""Build a compact list of other workflows for the RAG cross-workflow hint.
|
||
|
||
Returns key-value items like:
|
||
key="Pendenzenliste Excel (3 msgs)" value="last: 2h ago"
|
||
Limited to 10 most recent other workflows to keep the hint small.
|
||
"""
|
||
try:
|
||
chatInterface = services.chat.interfaceDbChat
|
||
allWorkflows = chatInterface.getWorkflows() or []
|
||
except Exception:
|
||
return []
|
||
|
||
others = [w for w in allWorkflows if w.get("id") != currentWorkflowId]
|
||
if not others:
|
||
return []
|
||
|
||
import time as _time
|
||
now = _time.time()
|
||
others.sort(key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0, reverse=True)
|
||
others = others[:10]
|
||
|
||
items = []
|
||
for wf in others:
|
||
name = wf.get("name") or "(unnamed)"
|
||
createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
|
||
ageSec = now - createdAt if createdAt else 0
|
||
if ageSec < 3600:
|
||
ageStr = f"{int(ageSec / 60)}m ago"
|
||
elif ageSec < 86400:
|
||
ageStr = f"{int(ageSec / 3600)}h ago"
|
||
else:
|
||
ageStr = f"{int(ageSec / 86400)}d ago"
|
||
|
||
wfId = wf.get("id", "")
|
||
items.append({
|
||
"key": f"{name} (id: {wfId})",
|
||
"value": ageStr,
|
||
})
|
||
|
||
countLabel = f"{len(allWorkflows) - 1} other conversation(s)"
|
||
if len(allWorkflows) - 1 > 10:
|
||
countLabel += f" (showing 10 newest)"
|
||
items.insert(0, {"key": countLabel, "value": "use listWorkflowHistory to browse"})
|
||
return items
|
||
|
||
|
||
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 ----
|
||
|
||
def _applyOffsetLimit(text: str, offset: int = None, limit: int = None) -> str:
|
||
"""Apply line-based offset/limit to text content, returning numbered lines."""
|
||
if offset is None and limit is None:
|
||
return None
|
||
lines = text.split("\n")
|
||
totalLines = len(lines)
|
||
startLine = max(0, (offset or 1) - 1)
|
||
endLine = min(totalLines, startLine + (limit or 200))
|
||
selected = lines[startLine:endLine]
|
||
numbered = "\n".join(f"{i + startLine + 1}|{line}" for i, line in enumerate(selected))
|
||
header = f"[Lines {startLine + 1}-{endLine} of {totalLines} total]\n"
|
||
return header + numbered
|
||
|
||
async def _readFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||
fileId = args.get("fileId", "")
|
||
offset = args.get("offset")
|
||
limit = args.get("limit")
|
||
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)
|
||
chunked = _applyOffsetLimit(assembled, offset, limit)
|
||
if chunked is not None:
|
||
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
||
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)}. Use offset/limit to read specific sections.]"
|
||
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", "")
|
||
_fiId, _mId = _resolveFileScope(fileId, context)
|
||
await knowledgeService.indexFile(
|
||
fileId=fileId, fileName=fileName, mimeType=mimeType,
|
||
userId=userId, contentObjects=contentObjects,
|
||
featureInstanceId=_fiId,
|
||
mandateId=_mId,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
joined = ""
|
||
if knowledgeService:
|
||
_chunks = knowledgeService._knowledgeDb.getContentChunks(fileId)
|
||
_textChunks = [
|
||
c for c in (_chunks or [])
|
||
if c.get("contentType") != "image" and c.get("data")
|
||
]
|
||
if _textChunks:
|
||
joined = "\n\n".join(c["data"] for c in _textChunks)
|
||
if not joined:
|
||
textParts = [o["data"] for o in contentObjects if o["contentType"] != "image"]
|
||
joined = "\n\n".join(textParts) if textParts else ""
|
||
if joined:
|
||
chunked = _applyOffsetLimit(joined, offset, limit)
|
||
if chunked is not None:
|
||
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
||
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)}. Use offset/limit to read specific sections.]"
|
||
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():
|
||
_fileNeedNeutralize = False
|
||
try:
|
||
from modules.datamodels.datamodelFiles import FileItem as _FI
|
||
from modules.interfaces.interfaceDbManagement import ComponentObjects as _CO
|
||
_fRec = _CO().db._loadRecord(_FI, fileId)
|
||
if _fRec:
|
||
_fG = (lambda k, d=None: _fRec.get(k, d)) if isinstance(_fRec, dict) else (lambda k, d=None: getattr(_fRec, k, d))
|
||
_fileNeedNeutralize = bool(_fG("neutralize", False))
|
||
except Exception:
|
||
pass
|
||
if _fileNeedNeutralize:
|
||
try:
|
||
_nSvc = services.getService("neutralization") if hasattr(services, "getService") else None
|
||
if _nSvc and hasattr(_nSvc, 'processTextAsync'):
|
||
_nResult = await _nSvc.processTextAsync(text, fileId)
|
||
if _nResult and _nResult.get("neutralized_text"):
|
||
text = _nResult["neutralized_text"]
|
||
logger.debug(f"readFile: neutralized text for file {fileId}")
|
||
else:
|
||
logger.warning(f"readFile: neutralization failed for file {fileId}, blocking text (fail-safe)")
|
||
return ToolResult(toolCallId="", toolName="readFile", success=True,
|
||
data="[File requires neutralization but neutralization failed. Content blocked for data protection.]")
|
||
else:
|
||
logger.warning(f"readFile: neutralization required but service unavailable for file {fileId}")
|
||
return ToolResult(toolCallId="", toolName="readFile", success=True,
|
||
data="[File requires neutralization but service unavailable. Content blocked for data protection.]")
|
||
except Exception as _nErr:
|
||
logger.error(f"readFile: neutralization error for file {fileId}: {_nErr}")
|
||
return ToolResult(toolCallId="", toolName="readFile", success=True,
|
||
data="[File requires neutralization but an error occurred. Content blocked for data protection.]")
|
||
chunked = _applyOffsetLimit(text, offset, limit)
|
||
if chunked is not None:
|
||
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
||
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)}. Use offset/limit to read specific sections.]"
|
||
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 _searchInFileContent(args: Dict[str, Any], context: Dict[str, Any]):
|
||
import re as _re
|
||
fileId = args.get("fileId", "")
|
||
query = args.get("query", "")
|
||
contextLines = args.get("contextLines", 2)
|
||
if not fileId or not query:
|
||
return ToolResult(toolCallId="", toolName="searchInFileContent", success=False, error="fileId and query are required")
|
||
try:
|
||
chatService = services.chat
|
||
rawBytes = chatService.getFileData(fileId)
|
||
if not rawBytes:
|
||
return ToolResult(toolCallId="", toolName="searchInFileContent", success=False, error="File data not accessible")
|
||
try:
|
||
content = rawBytes.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
content = rawBytes.decode("latin-1", errors="replace")
|
||
|
||
lines = content.split("\n")
|
||
pattern = _re.compile(_re.escape(query), _re.IGNORECASE)
|
||
matches = []
|
||
for i, line in enumerate(lines):
|
||
if pattern.search(line):
|
||
start = max(0, i - contextLines)
|
||
end = min(len(lines), i + contextLines + 1)
|
||
snippet = "\n".join(f"{j + 1}|{lines[j]}" for j in range(start, end))
|
||
matches.append(snippet)
|
||
|
||
if not matches:
|
||
return ToolResult(toolCallId="", toolName="searchInFileContent", success=True,
|
||
data=f"No matches for '{query}' in file.")
|
||
|
||
shown = matches[:20]
|
||
resultText = f"Found {len(matches)} match(es) for '{query}':\n\n" + "\n---\n".join(shown)
|
||
if len(matches) > 20:
|
||
resultText += f"\n\n... and {len(matches) - 20} more matches"
|
||
return ToolResult(toolCallId="", toolName="searchInFileContent", success=True, data=resultText)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="searchInFileContent", 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]):
|
||
content = args.get("content", "")
|
||
mode = args.get("mode", "create")
|
||
fileId = args.get("fileId", "")
|
||
name = args.get("name", "")
|
||
|
||
if not content:
|
||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="content is required")
|
||
|
||
try:
|
||
chatService = services.chat
|
||
dbMgmt = chatService.interfaceDbComponent
|
||
|
||
if mode == "append":
|
||
if not fileId:
|
||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=append")
|
||
file = dbMgmt.getFile(fileId)
|
||
if not file:
|
||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found")
|
||
existingData = dbMgmt.getFileData(fileId) or b""
|
||
try:
|
||
existingText = existingData.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
existingText = existingData.decode("latin-1", errors="replace")
|
||
newContent = existingText + content
|
||
dbMgmt.updateFileData(fileId, newContent.encode("utf-8"))
|
||
dbMgmt.updateFile(fileId, {"fileSize": len(newContent.encode("utf-8"))})
|
||
return ToolResult(
|
||
toolCallId="", toolName="writeFile", success=True,
|
||
data=f"Appended {len(content)} chars to '{file.fileName}' (id: {fileId}, total: {len(newContent)} chars)",
|
||
sideEvents=[{"type": "fileUpdated", "data": {"fileId": fileId, "fileName": file.fileName}}],
|
||
)
|
||
|
||
if mode == "overwrite":
|
||
if not fileId:
|
||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=overwrite")
|
||
file = dbMgmt.getFile(fileId)
|
||
if not file:
|
||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found")
|
||
dbMgmt.updateFileData(fileId, content.encode("utf-8"))
|
||
dbMgmt.updateFile(fileId, {"fileSize": len(content.encode("utf-8"))})
|
||
return ToolResult(
|
||
toolCallId="", toolName="writeFile", success=True,
|
||
data=f"Overwritten '{file.fileName}' (id: {fileId}, {len(content)} chars)",
|
||
sideEvents=[{"type": "fileUpdated", "data": {"fileId": fileId, "fileName": file.fileName}}],
|
||
)
|
||
|
||
# mode == "create" (default)
|
||
if not name:
|
||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required for mode=create")
|
||
fileItem, _ = dbMgmt.saveUploadedFile(content.encode("utf-8"), name)
|
||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||
if fiId:
|
||
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
||
if args.get("folderId"):
|
||
dbMgmt.updateFile(fileItem.id, {"folderId": args["folderId"]})
|
||
if args.get("tags"):
|
||
dbMgmt.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. Returns full content by default. "
|
||
"For large files, use offset and limit to read specific line ranges. "
|
||
"When truncated, the response tells the total line count so you can paginate."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"fileId": {"type": "string", "description": "The file ID to read"},
|
||
"offset": {"type": "integer", "description": "Start reading from this line number (1-based). Omit for full file."},
|
||
"limit": {"type": "integer", "description": "Max number of lines to return (default: all). Use with offset for chunked reading."},
|
||
},
|
||
"required": ["fileId"]
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
registry.register(
|
||
"listFiles", _listFiles,
|
||
description=(
|
||
"List files in the local workspace. Filter by folder, tags, or search term. "
|
||
"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(
|
||
"searchInFileContent", _searchInFileContent,
|
||
description=(
|
||
"Search for text within a file's content. Returns matching lines with context. "
|
||
"Case-insensitive. Use to locate specific text before using replaceInFile, "
|
||
"or to find relevant sections in a large file before reading with offset/limit."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"fileId": {"type": "string", "description": "The file ID to search in"},
|
||
"query": {"type": "string", "description": "Text to search for (case-insensitive)"},
|
||
"contextLines": {"type": "integer", "description": "Number of context lines around each match (default: 2)"},
|
||
},
|
||
"required": ["fileId", "query"]
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
registry.register(
|
||
"listFolders", _listFolders,
|
||
description="List folders in the local workspace. 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 general information. Use readUrl to fetch content from a known URL instead.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {"query": {"type": "string", "description": "Search query"}},
|
||
"required": ["query"]
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
registry.register(
|
||
"tagFile", _tagFile,
|
||
description="Set or update tags on a file for categorization and filtering via listFiles.",
|
||
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 in the local workspace.",
|
||
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 folder in the local workspace.",
|
||
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, append, or overwrite a file. Modes:\n"
|
||
"- create (default): create a new file (name required).\n"
|
||
"- append: append content to an existing file (fileId required). "
|
||
"Use for large content that exceeds a single tool call (~8000 chars per call).\n"
|
||
"- overwrite: replace entire file content (fileId required)."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string", "description": "File name (required for mode=create)"},
|
||
"content": {"type": "string", "description": "Content to write/append"},
|
||
"mode": {"type": "string", "enum": ["create", "append", "overwrite"], "description": "Write mode (default: create)"},
|
||
"fileId": {"type": "string", "description": "File ID (required for mode=append/overwrite)"},
|
||
"folderId": {"type": "string", "description": "Target folder ID (mode=create only)"},
|
||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags (mode=create only)"},
|
||
},
|
||
"required": ["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="Permanently delete a file from the local workspace.",
|
||
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 local workspace. Include the file extension in the new name.",
|
||
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 _replaceInFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||
fileId = args.get("fileId", "")
|
||
oldText = args.get("oldText", "")
|
||
newText = args.get("newText", "")
|
||
replaceAll = args.get("replaceAll", False)
|
||
if not fileId or not oldText:
|
||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="fileId and oldText are required")
|
||
try:
|
||
chatService = services.chat
|
||
dbMgmt = chatService.interfaceDbComponent
|
||
file = dbMgmt.getFile(fileId)
|
||
if not file:
|
||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error=f"File {fileId} not found")
|
||
if not dbMgmt.isTextMimeType(file.mimeType):
|
||
return ToolResult(
|
||
toolCallId="", toolName="replaceInFile", success=False,
|
||
error=f"Cannot edit binary file ({file.mimeType}). Only text-based files are supported."
|
||
)
|
||
rawData = dbMgmt.getFileData(fileId)
|
||
if not rawData:
|
||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="File has no content")
|
||
try:
|
||
oldContent = rawData.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="File content is not valid UTF-8 text")
|
||
|
||
count = oldContent.count(oldText)
|
||
if count == 0:
|
||
return ToolResult(
|
||
toolCallId="", toolName="replaceInFile", success=False,
|
||
error="oldText not found in file. Use readFile or searchInFileContent to verify the exact text."
|
||
)
|
||
if count > 1 and not replaceAll:
|
||
return ToolResult(
|
||
toolCallId="", toolName="replaceInFile", success=False,
|
||
error=f"oldText found {count} times. Set replaceAll=true or provide more surrounding context to make it unique."
|
||
)
|
||
|
||
newContent = oldContent.replace(oldText, newText) if replaceAll else oldContent.replace(oldText, newText, 1)
|
||
|
||
editId = str(_uuid.uuid4())
|
||
label = f"all {count} occurrences" if replaceAll else "1 occurrence"
|
||
return ToolResult(
|
||
toolCallId="", toolName="replaceInFile", success=True,
|
||
data=f"Edit proposed for '{file.fileName}': replaced {label}. Waiting for user review.",
|
||
sideEvents=[{
|
||
"type": "fileEditProposal",
|
||
"data": {
|
||
"id": editId,
|
||
"fileId": fileId,
|
||
"fileName": file.fileName,
|
||
"mimeType": file.mimeType,
|
||
"oldContent": oldContent,
|
||
"newContent": newContent,
|
||
},
|
||
}],
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error=str(e))
|
||
|
||
registry.register(
|
||
"deleteFolder", _deleteFolder,
|
||
description="Delete a folder from the local workspace. Set recursive=true to delete 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 in the local workspace.",
|
||
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 in the local workspace.",
|
||
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 an independent copy of a file in the local workspace.",
|
||
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(
|
||
"replaceInFile", _replaceInFile,
|
||
description=(
|
||
"Replace specific text in an existing file. The edit is shown to the user for "
|
||
"review (accept/reject) before being applied. Provide enough surrounding context "
|
||
"in oldText to make the match unique (at least 2-3 lines). "
|
||
"Use readFile or searchInFileContent first to identify the exact text to replace."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"fileId": {"type": "string", "description": "The file ID to edit"},
|
||
"oldText": {"type": "string", "description": "Exact text to find and replace (must be unique unless replaceAll=true)"},
|
||
"newText": {"type": "string", "description": "The replacement text"},
|
||
"replaceAll": {"type": "boolean", "description": "Replace all occurrences (default: false)"},
|
||
},
|
||
"required": ["fileId", "oldText", "newText"]
|
||
},
|
||
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 _uploadToExternal(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="uploadToExternal", 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="uploadToExternal", 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="uploadToExternal", success=True, data=str(result))
|
||
except Exception as e:
|
||
return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e))
|
||
|
||
async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]):
|
||
import base64 as _b64
|
||
|
||
connectionId = args.get("connectionId", "")
|
||
to = args.get("to", [])
|
||
subject = args.get("subject", "")
|
||
body = args.get("body", "")
|
||
bodyType = "HTML" if args.get("bodyType", "text").lower() == "html" else "Text"
|
||
draft = args.get("draft", False)
|
||
attachmentFileIds = args.get("attachmentFileIds") or []
|
||
|
||
if not connectionId or not to or not subject:
|
||
return ToolResult(toolCallId="", toolName="sendMail", success=False, error="connectionId, to, and subject are required")
|
||
try:
|
||
graphAttachments: List[Dict[str, Any]] = []
|
||
if attachmentFileIds:
|
||
chatService = services.chat
|
||
dbMgmt = chatService.interfaceDbComponent
|
||
for fid in attachmentFileIds:
|
||
fileRow = dbMgmt.getFile(fid)
|
||
if not fileRow:
|
||
return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file not found: {fid}")
|
||
rawBytes = dbMgmt.getFileData(fid)
|
||
if not rawBytes:
|
||
return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file has no data: {fid}")
|
||
graphAttachments.append({
|
||
"name": fileRow.fileName,
|
||
"contentBytes": _b64.b64encode(rawBytes).decode("ascii"),
|
||
"contentType": getattr(fileRow, "mimeType", "application/octet-stream"),
|
||
})
|
||
|
||
from modules.connectors.connectorResolver import ConnectorResolver
|
||
resolver = ConnectorResolver(
|
||
services.getService("security"),
|
||
_buildResolverDb(),
|
||
)
|
||
adapter = await resolver.resolveService(connectionId, "outlook")
|
||
|
||
if draft and hasattr(adapter, "createDraft"):
|
||
result = await adapter.createDraft(
|
||
to=to, subject=subject, body=body, bodyType=bodyType,
|
||
cc=args.get("cc"), attachments=graphAttachments or None,
|
||
)
|
||
return ToolResult(toolCallId="", toolName="sendMail", success=True, data=str(result))
|
||
|
||
if hasattr(adapter, "sendMail"):
|
||
result = await adapter.sendMail(
|
||
to=to, subject=subject, body=body, bodyType=bodyType,
|
||
cc=args.get("cc"), attachments=graphAttachments or None,
|
||
)
|
||
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 the user's external connections (SharePoint, OneDrive, Outlook, etc.) and their IDs. Use with browseDataSource/uploadToExternal.",
|
||
parameters={"type": "object", "properties": {}},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"uploadToExternal", _uploadToExternal,
|
||
description=(
|
||
"Upload a local file to an external storage via connectionId+service. "
|
||
"Use listConnections to find available connections."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
**_connToolParams,
|
||
"path": {"type": "string", "description": "Destination path on the external service"},
|
||
"fileId": {"type": "string", "description": "Local file ID to upload"},
|
||
},
|
||
"required": ["connectionId", "service", "path", "fileId"],
|
||
},
|
||
readOnly=False,
|
||
)
|
||
|
||
registry.register(
|
||
"sendMail", _sendMail,
|
||
description=(
|
||
"Send or draft an email via a connected mail service (Outlook). "
|
||
"Supports HTML body and file attachments from the workspace. "
|
||
"Set draft=true to save as draft without sending. "
|
||
"Use listConnections to find the connectionId."
|
||
),
|
||
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 — plain text or HTML markup"},
|
||
"bodyType": {"type": "string", "enum": ["text", "html"], "description": "Body format: 'text' (default) or 'html'"},
|
||
"cc": {"type": "array", "items": {"type": "string"}, "description": "CC addresses"},
|
||
"attachmentFileIds": {
|
||
"type": "array", "items": {"type": "string"},
|
||
"description": "File IDs from the workspace to attach (use listFiles to find IDs)",
|
||
},
|
||
"draft": {"type": "boolean", "description": "If true, save as draft in Drafts folder instead of sending"},
|
||
},
|
||
"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",
|
||
"clickupList": "clickup",
|
||
}
|
||
|
||
async def _resolveDataSource(dsId: str):
|
||
"""Resolve a DataSource record and return (connectionId, service, path, neutralize) 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", "")
|
||
neutralize = bool(ds.get("neutralize", False))
|
||
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]}, neutralize={neutralize}")
|
||
return connectionId, service, path, neutralize
|
||
|
||
_MAIL_SERVICES = {"outlook", "gmail"}
|
||
|
||
async def _browseDataSource(args: Dict[str, Any], context: Dict[str, Any]):
|
||
dsId = args.get("dataSourceId", "")
|
||
subPath = args.get("subPath", "")
|
||
directConnId = args.get("connectionId", "")
|
||
directService = args.get("service", "")
|
||
if not dsId and not (directConnId and directService):
|
||
return ToolResult(toolCallId="", toolName="browseDataSource", success=False,
|
||
error="Provide either dataSourceId OR connectionId+service")
|
||
try:
|
||
if dsId:
|
||
connectionId, service, basePath, _neutralize = await _resolveDataSource(dsId)
|
||
else:
|
||
connectionId, service, basePath = directConnId, directService, args.get("path", "/")
|
||
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", "")
|
||
directConnId = args.get("connectionId", "")
|
||
directService = args.get("service", "")
|
||
query = args.get("query", "")
|
||
if not query:
|
||
return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error="query is required")
|
||
if not dsId and not (directConnId and directService):
|
||
return ToolResult(toolCallId="", toolName="searchDataSource", success=False,
|
||
error="Provide either dataSourceId OR connectionId+service")
|
||
try:
|
||
if dsId:
|
||
connectionId, service, basePath, _neutralize = await _resolveDataSource(dsId)
|
||
else:
|
||
connectionId, service, basePath = directConnId, directService, args.get("path", "/")
|
||
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", "")
|
||
directConnId = args.get("connectionId", "")
|
||
directService = args.get("service", "")
|
||
filePath = args.get("filePath", "")
|
||
fileName = args.get("fileName", "")
|
||
if not filePath:
|
||
return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error="filePath is required")
|
||
if not dsId and not (directConnId and directService):
|
||
return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False,
|
||
error="Provide either dataSourceId OR connectionId+service")
|
||
try:
|
||
from modules.connectors.connectorResolver import ConnectorResolver
|
||
from modules.connectors.connectorProviderBase import DownloadResult as _DR
|
||
_sourceNeutralize = False
|
||
if dsId:
|
||
connectionId, service, basePath, _sourceNeutralize = await _resolveDataSource(dsId)
|
||
else:
|
||
connectionId, service, basePath = directConnId, directService, "/"
|
||
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})
|
||
if _sourceNeutralize:
|
||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"neutralize": True})
|
||
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 a data source. Accepts either:\n"
|
||
"- dataSourceId (for attached data sources shown in the prompt), OR\n"
|
||
"- connectionId + service (for direct connection access via listConnections)."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"dataSourceId": {"type": "string", "description": "DataSource ID (from attached data sources)"},
|
||
"connectionId": {"type": "string", "description": "UserConnection ID (alternative to dataSourceId)"},
|
||
"service": {"type": "string", "description": "Service name (alternative to dataSourceId, e.g. sharepoint, onedrive)"},
|
||
"path": {"type": "string", "description": "Root path (used with connectionId+service)"},
|
||
"subPath": {"type": "string", "description": "Sub-path within the data source to browse"},
|
||
"filter": {"type": "string", "description": "Filter pattern (e.g. '*.pdf')"},
|
||
},
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"searchDataSource", _searchDataSource,
|
||
description=(
|
||
"Search for files within a data source. Accepts either dataSourceId OR connectionId+service."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"dataSourceId": {"type": "string", "description": "DataSource ID"},
|
||
"connectionId": {"type": "string", "description": "UserConnection ID (alternative to dataSourceId)"},
|
||
"service": {"type": "string", "description": "Service name (alternative to dataSourceId)"},
|
||
"path": {"type": "string", "description": "Scope path (used with connectionId+service)"},
|
||
"query": {"type": "string", "description": "Search query"},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"downloadFromDataSource", _downloadFromDataSource,
|
||
description=(
|
||
"Download a file or email from a data source into local storage. Returns a local file ID "
|
||
"to read with readFile. Accepts either dataSourceId OR connectionId+service. "
|
||
"For email sources (Outlook, Gmail), browse/search only return subjects -- use this to get full content."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"dataSourceId": {"type": "string", "description": "DataSource ID"},
|
||
"connectionId": {"type": "string", "description": "UserConnection ID (alternative to dataSourceId)"},
|
||
"service": {"type": "string", "description": "Service name (alternative to dataSourceId)"},
|
||
"filePath": {"type": "string", "description": "Path of the file to download (from browseDataSource results)"},
|
||
"fileName": {"type": "string", "description": "File name with extension (e.g. 'report.pdf')"},
|
||
},
|
||
"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 document (pages, sections, sheets, slides). Use before readContentObjects for targeted reading.",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {"fileId": {"type": "string", "description": "The file ID to browse"}},
|
||
"required": ["fileId"],
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
registry.register(
|
||
"readContentObjects", _readContentObjects,
|
||
description="Read extracted content objects from a file, optionally filtered by page, section, or type. Use browseContainer first to see the structure.",
|
||
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="Extract a specific item from a container file (ZIP, nested file). Use browseContainer to see available items.",
|
||
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="Generate an AI-powered summary of a file's content. Optionally filter by section, page, or content type.",
|
||
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:
|
||
_diFiId, _diMId = _resolveFileScope(fileId, context)
|
||
await knowledgeService.indexFile(
|
||
fileId=fileId, fileName=fileName, mimeType=fileMime,
|
||
userId=context.get("userId", ""), contentObjects=contentObjects,
|
||
featureInstanceId=_diFiId,
|
||
mandateId=_diMId,
|
||
)
|
||
|
||
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
|
||
|
||
_opType = OTE.IMAGE_ANALYSE
|
||
try:
|
||
from modules.datamodels.datamodelFiles import FileItem as _FileItemModel
|
||
from modules.interfaces.interfaceDbManagement import ComponentObjects as _CO
|
||
_fRow = _CO().db._loadRecord(_FileItemModel, fileId)
|
||
if _fRow:
|
||
_fGet = (lambda k, d=None: _fRow.get(k, d)) if isinstance(_fRow, dict) else (lambda k, d=None: getattr(_fRow, k, d))
|
||
if bool(_fGet("neutralize", False)):
|
||
_opType = OTE.NEUTRALIZATION_IMAGE
|
||
logger.info(f"describeImage: file {fileId} has neutralize=True, using NEUTRALIZATION_IMAGE (internal models only)")
|
||
except Exception:
|
||
pass
|
||
|
||
visionRequest = AiCallRequest(
|
||
prompt=prompt,
|
||
options=AiCallOptions(operationType=_opType),
|
||
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="Analyze an image using AI vision. Works with image files and images extracted from PDFs/DOCX/PPTX. Use for OCR, data extraction, and visual analysis.",
|
||
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
|
||
sourceFileId = (args.get("sourceFileId") or "").strip()
|
||
content = args.get("content", "")
|
||
if not isinstance(content, str):
|
||
content = str(content) if content is not None else ""
|
||
outputFormat = args.get("outputFormat", "pdf")
|
||
title = args.get("title", "Document")
|
||
language = args.get("language", "de")
|
||
|
||
if sourceFileId:
|
||
try:
|
||
dbMgmt = services.chat.interfaceDbComponent
|
||
fileRow = dbMgmt.getFile(sourceFileId)
|
||
if not fileRow:
|
||
return ToolResult(
|
||
toolCallId="",
|
||
toolName="renderDocument",
|
||
success=False,
|
||
error=f"sourceFileId not found: {sourceFileId}",
|
||
)
|
||
rawBytes = dbMgmt.getFileData(sourceFileId)
|
||
if not rawBytes:
|
||
return ToolResult(
|
||
toolCallId="",
|
||
toolName="renderDocument",
|
||
success=False,
|
||
error=f"sourceFileId has no data: {sourceFileId}",
|
||
)
|
||
try:
|
||
content = rawBytes.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
content = rawBytes.decode("latin-1", errors="replace")
|
||
except Exception as e:
|
||
return ToolResult(
|
||
toolCallId="",
|
||
toolName="renderDocument",
|
||
success=False,
|
||
error=f"Could not read sourceFileId: {e}",
|
||
)
|
||
|
||
if not (content or "").strip():
|
||
return ToolResult(
|
||
toolCallId="",
|
||
toolName="renderDocument",
|
||
success=False,
|
||
error=(
|
||
"Provide non-empty `content` (markdown) or `sourceFileId` (id of a .md/.txt from writeFile). "
|
||
"For long documents use writeFile create+append, then renderDocument(sourceFileId=...)."
|
||
),
|
||
)
|
||
|
||
modelMaxTokens = context.get("modelMaxOutputTokens", 0)
|
||
_inlineCharLimit = int(modelMaxTokens * 3 * 0.5) if modelMaxTokens > 0 else 6000
|
||
_inlineCharLimit = max(_inlineCharLimit, 3000)
|
||
|
||
if not sourceFileId and len(content) > _inlineCharLimit:
|
||
return ToolResult(
|
||
toolCallId="",
|
||
toolName="renderDocument",
|
||
success=False,
|
||
error=(
|
||
f"Inline `content` is {len(content)} chars — over the {_inlineCharLimit} char limit "
|
||
f"(derived from model output budget of {modelMaxTokens} tokens). "
|
||
"Large documents must use the file path:\n"
|
||
"1. writeFile(mode='create', name='draft.md', content=<first ~5000 chars>)\n"
|
||
"2. writeFile(mode='append', fileId=<id>, content=<next chunk>) — repeat as needed\n"
|
||
"3. renderDocument(sourceFileId=<id>, outputFormat='pdf', title='...')\n"
|
||
"This avoids output truncation entirely."
|
||
),
|
||
)
|
||
|
||
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 into a document file (PDF, DOCX, XLSX, PPTX, CSV, HTML, MD, JSON, TXT). "
|
||
"For long documents: write markdown with writeFile (mode=create then append chunks), then call this tool with "
|
||
"`sourceFileId` only (tiny JSON — avoids model output truncation). For short docs you may pass `content` inline. "
|
||
"Images:  in the markdown."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"content": {
|
||
"type": "string",
|
||
"description": "Full markdown inline. Prefer `sourceFileId` when the document is large (many KB).",
|
||
},
|
||
"sourceFileId": {
|
||
"type": "string",
|
||
"description": "Chat file id of markdown saved via writeFile (create+append). Use this instead of `content` for long PDFs.",
|
||
},
|
||
"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"},
|
||
},
|
||
},
|
||
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:
|
||
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||
from modules.security.rootAccess import getRootInterface
|
||
userId = context.get("userId", "")
|
||
if userId:
|
||
rootIf = getRootInterface()
|
||
prefRecords = rootIf.db.getRecordset(
|
||
UserVoicePreferences,
|
||
recordFilter={"userId": userId, "mandateId": mandateId}
|
||
)
|
||
if not prefRecords and mandateId:
|
||
prefRecords = rootIf.db.getRecordset(
|
||
UserVoicePreferences,
|
||
recordFilter={"userId": userId}
|
||
)
|
||
if prefRecords:
|
||
vs = prefRecords[0] if isinstance(prefRecords[0], dict) else prefRecords[0].model_dump() if hasattr(prefRecords[0], "model_dump") else prefRecords[0]
|
||
voiceMap = vs.get("ttsVoiceMap", {}) or {}
|
||
if isinstance(voiceMap, dict) and voiceMap:
|
||
selectedKey = None
|
||
selectedVoiceEntry = None
|
||
baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else ""
|
||
|
||
if isinstance(language, str) and language in voiceMap:
|
||
selectedKey = language
|
||
selectedVoiceEntry = voiceMap[language]
|
||
|
||
if selectedVoiceEntry is None and baseLanguage and baseLanguage in voiceMap:
|
||
selectedKey = baseLanguage
|
||
selectedVoiceEntry = voiceMap[baseLanguage]
|
||
|
||
if selectedVoiceEntry is None and baseLanguage:
|
||
for mapKey, mapValue in voiceMap.items():
|
||
mapKeyNorm = str(mapKey).lower()
|
||
if mapKeyNorm == baseLanguage or mapKeyNorm.startswith(f"{baseLanguage}-"):
|
||
selectedKey = str(mapKey)
|
||
selectedVoiceEntry = mapValue
|
||
break
|
||
|
||
if selectedVoiceEntry is not None:
|
||
voiceName = (
|
||
selectedVoiceEntry.get("voiceName")
|
||
if isinstance(selectedVoiceEntry, dict)
|
||
else selectedVoiceEntry
|
||
)
|
||
logger.info(
|
||
f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')"
|
||
)
|
||
if not voiceName and vs.get("ttsVoice") and vs.get("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,
|
||
)
|
||
|
||
# ── createChart tool ─────────────────────────────────────────────────
|
||
|
||
async def _createChart(args: Dict[str, Any], context: Dict[str, Any]):
|
||
"""Create a data chart as PNG image using matplotlib."""
|
||
import re as _re
|
||
|
||
chartType = (args.get("chartType") or "bar").strip().lower()
|
||
title = (args.get("title") or "Chart").strip()
|
||
labels = args.get("labels") or []
|
||
datasets = args.get("datasets") or []
|
||
xLabel = (args.get("xLabel") or "").strip()
|
||
yLabel = (args.get("yLabel") or "").strip()
|
||
width = min(max(args.get("width") or 10, 4), 20)
|
||
height = min(max(args.get("height") or 6, 3), 14)
|
||
colors = args.get("colors") or None
|
||
|
||
if not datasets:
|
||
return ToolResult(toolCallId="", toolName="createChart", success=False, error="datasets is required (list of {label, values})")
|
||
|
||
try:
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
import logging as _mpllog
|
||
_mpllog.getLogger("matplotlib").setLevel(_mpllog.WARNING)
|
||
import matplotlib.pyplot as plt
|
||
import io
|
||
|
||
_DEFAULT_COLORS = [
|
||
"#4285F4", "#EA4335", "#FBBC04", "#34A853", "#FF6D01",
|
||
"#46BDC6", "#7B61FF", "#F538A0", "#00ACC1", "#AB47BC",
|
||
]
|
||
usedColors = colors if colors and len(colors) >= len(datasets) else _DEFAULT_COLORS
|
||
|
||
fig, ax = plt.subplots(figsize=(width, height))
|
||
fig.patch.set_facecolor("#FFFFFF")
|
||
ax.set_facecolor("#FAFAFA")
|
||
|
||
if chartType in ("pie", "donut"):
|
||
values = datasets[0].get("values", []) if datasets else []
|
||
explode = [0.02] * len(values)
|
||
wedges, texts, autotexts = ax.pie(
|
||
values, labels=labels, autopct="%1.1f%%",
|
||
colors=usedColors[:len(values)], explode=explode,
|
||
textprops={"fontsize": 9},
|
||
)
|
||
if chartType == "donut":
|
||
ax.add_artist(plt.Circle((0, 0), 0.55, fc="white"))
|
||
ax.set_title(title, fontsize=14, fontweight="bold", pad=16)
|
||
|
||
else:
|
||
import numpy as _np
|
||
x = _np.arange(len(labels)) if labels else _np.arange(max(len(d.get("values", [])) for d in datasets))
|
||
barWidth = 0.8 / max(len(datasets), 1)
|
||
|
||
for i, ds in enumerate(datasets):
|
||
dsLabel = ds.get("label", f"Series {i+1}")
|
||
values = ds.get("values", [])
|
||
color = usedColors[i % len(usedColors)]
|
||
|
||
if chartType == "bar":
|
||
offset = (i - len(datasets) / 2 + 0.5) * barWidth
|
||
ax.bar(x + offset, values, barWidth, label=dsLabel, color=color, edgecolor="white", linewidth=0.5)
|
||
elif chartType == "horizontalbar":
|
||
offset = (i - len(datasets) / 2 + 0.5) * barWidth
|
||
ax.barh(x + offset, values, barWidth, label=dsLabel, color=color, edgecolor="white", linewidth=0.5)
|
||
elif chartType == "line":
|
||
ax.plot(x[:len(values)], values, marker="o", markersize=5, label=dsLabel, color=color, linewidth=2)
|
||
elif chartType == "area":
|
||
ax.fill_between(x[:len(values)], values, alpha=0.3, color=color)
|
||
ax.plot(x[:len(values)], values, label=dsLabel, color=color, linewidth=2)
|
||
elif chartType == "scatter":
|
||
ax.scatter(x[:len(values)], values, label=dsLabel, color=color, s=50, edgecolors="white", linewidth=0.5)
|
||
else:
|
||
ax.bar(x, values, label=dsLabel, color=color)
|
||
|
||
if labels:
|
||
if chartType == "horizontalbar":
|
||
ax.set_yticks(x)
|
||
ax.set_yticklabels(labels, fontsize=9)
|
||
else:
|
||
ax.set_xticks(x)
|
||
ax.set_xticklabels(labels, fontsize=9, rotation=45 if len(labels) > 6 else 0, ha="right" if len(labels) > 6 else "center")
|
||
|
||
ax.set_title(title, fontsize=14, fontweight="bold", pad=12)
|
||
if xLabel:
|
||
ax.set_xlabel(xLabel, fontsize=10)
|
||
if yLabel:
|
||
ax.set_ylabel(yLabel, fontsize=10)
|
||
if len(datasets) > 1:
|
||
ax.legend(fontsize=9, framealpha=0.9)
|
||
ax.grid(axis="y", alpha=0.3, linestyle="--")
|
||
ax.spines["top"].set_visible(False)
|
||
ax.spines["right"].set_visible(False)
|
||
|
||
plt.tight_layout()
|
||
buf = io.BytesIO()
|
||
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
|
||
plt.close(fig)
|
||
pngData = buf.getvalue()
|
||
|
||
chatService = services.chat
|
||
sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "chart"
|
||
fileName = f"{sanitizedTitle}.png"
|
||
|
||
if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"):
|
||
fileItem = chatService.interfaceDbComponent.saveGeneratedFile(pngData, fileName, "image/png")
|
||
else:
|
||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName)
|
||
|
||
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?"
|
||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||
if fiId and fid != "?":
|
||
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
|
||
tempFolderId = _getOrCreateTempFolder(chatService)
|
||
if tempFolderId and fid != "?":
|
||
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
|
||
|
||
sideEvents = [{"type": "fileCreated", "data": {
|
||
"fileId": fid, "fileName": fileName,
|
||
"mimeType": "image/png", "fileSize": len(pngData),
|
||
}}]
|
||
return ToolResult(
|
||
toolCallId="", toolName="createChart", success=True,
|
||
data=f"Chart saved as '{fileName}' (id: {fid}, {len(pngData)} bytes). "
|
||
f"Embed in documents with: ",
|
||
sideEvents=sideEvents,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"createChart failed: {e}", exc_info=True)
|
||
return ToolResult(toolCallId="", toolName="createChart", success=False, error=str(e))
|
||
|
||
registry.register(
|
||
"createChart", _createChart,
|
||
description=(
|
||
"Create a data chart/graph as a PNG image using matplotlib. "
|
||
"Supported types: bar, horizontalBar, line, area, scatter, pie, donut. "
|
||
"The chart is saved as a file in the workspace. "
|
||
"Use the returned fileId to embed in documents via renderDocument: . "
|
||
"Provide structured data with labels and datasets."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"chartType": {
|
||
"type": "string",
|
||
"enum": ["bar", "horizontalBar", "line", "area", "scatter", "pie", "donut"],
|
||
"description": "Chart type (default: bar)",
|
||
},
|
||
"title": {"type": "string", "description": "Chart title"},
|
||
"labels": {
|
||
"type": "array", "items": {"type": "string"},
|
||
"description": "X-axis labels / category names",
|
||
},
|
||
"datasets": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"label": {"type": "string", "description": "Series name (legend)"},
|
||
"values": {"type": "array", "items": {"type": "number"}, "description": "Data values"},
|
||
},
|
||
"required": ["values"],
|
||
},
|
||
"description": "Data series to plot",
|
||
},
|
||
"xLabel": {"type": "string", "description": "X-axis label"},
|
||
"yLabel": {"type": "string", "description": "Y-axis label"},
|
||
"colors": {
|
||
"type": "array", "items": {"type": "string"},
|
||
"description": "Custom hex colors for series (e.g. ['#4285F4', '#EA4335'])",
|
||
},
|
||
"width": {"type": "number", "description": "Figure width in inches (4-20, default 10)"},
|
||
"height": {"type": "number", "description": "Figure height in inches (3-14, default 6)"},
|
||
},
|
||
"required": ["datasets"],
|
||
},
|
||
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 not neutralizationService.interfaceDbComponent:
|
||
neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent
|
||
if text:
|
||
result = await neutralizationService.processTextAsync(text, fileId or None)
|
||
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 using speech recognition. Returns the transcript with confidence score.",
|
||
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 snippet. Returns ISO 639-1 code (e.g. 'de', 'en').",
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"text": {"type": "string", "description": "Text to analyze"},
|
||
},
|
||
"required": ["text"]
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
registry.register(
|
||
"neutralizeData", _neutralizeData,
|
||
description="Anonymize text or file content by replacing personal data (names, addresses, etc.) with placeholders. Non-destructive -- returns the anonymized copy.",
|
||
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
|
||
)
|
||
|
||
# ---- Feature Data Sub-Agent tool ----
|
||
|
||
async def _queryFeatureInstance(args: Dict[str, Any], context: Dict[str, Any]):
|
||
"""Delegate a question to the Feature Data Sub-Agent."""
|
||
featureInstanceId = args.get("featureInstanceId", "")
|
||
question = args.get("question", "")
|
||
if not featureInstanceId or not question:
|
||
return ToolResult(
|
||
toolCallId="", toolName="queryFeatureInstance",
|
||
success=False, error="featureInstanceId and question are required",
|
||
)
|
||
try:
|
||
from modules.serviceCenter.services.serviceAgent.featureDataAgent import runFeatureDataAgent
|
||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||
|
||
rootIf = getRootInterface()
|
||
instance = rootIf.getFeatureInstance(featureInstanceId)
|
||
if not instance:
|
||
return ToolResult(
|
||
toolCallId="", toolName="queryFeatureInstance",
|
||
success=False, error=f"Feature instance {featureInstanceId} not found",
|
||
)
|
||
|
||
featureCode = instance.featureCode
|
||
mandateId = instance.mandateId or ""
|
||
instanceLabel = instance.label or ""
|
||
userId = context.get("userId", "")
|
||
workspaceInstanceId = context.get("featureInstanceId", "")
|
||
|
||
rootDbConn = rootIf.db if hasattr(rootIf, "db") else None
|
||
if rootDbConn is None:
|
||
return ToolResult(
|
||
toolCallId="", toolName="queryFeatureInstance",
|
||
success=False, error="No database connector available",
|
||
)
|
||
|
||
featureDataSources = rootDbConn.getRecordset(
|
||
FeatureDataSource,
|
||
recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId},
|
||
)
|
||
|
||
_anySourceNeutralize = any(
|
||
bool(ds.get("neutralize", False) if isinstance(ds, dict) else getattr(ds, "neutralize", False))
|
||
for ds in (featureDataSources or [])
|
||
)
|
||
|
||
from modules.security.rbacCatalog import getCatalogService
|
||
catalog = getCatalogService()
|
||
tableFilters = {}
|
||
if not featureDataSources:
|
||
selectedTables = catalog.getDataObjects(featureCode)
|
||
else:
|
||
allObjs = {o["meta"]["table"]: o for o in catalog.getDataObjects(featureCode) if "meta" in o and "table" in o.get("meta", {})}
|
||
selectedTables = [allObjs[ds["tableName"]] for ds in featureDataSources if ds.get("tableName") in allObjs]
|
||
for ds in featureDataSources:
|
||
rf = ds.get("recordFilter")
|
||
if rf and isinstance(rf, dict) and ds.get("tableName"):
|
||
tableFilters[ds["tableName"]] = rf
|
||
|
||
if not selectedTables:
|
||
return ToolResult(
|
||
toolCallId="", toolName="queryFeatureInstance",
|
||
success=False, error=f"No data tables available for feature '{featureCode}'",
|
||
)
|
||
|
||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||
from modules.shared.configuration import APP_CONFIG
|
||
featureDbName = f"poweron_{featureCode.lower()}"
|
||
featureDbConn = DatabaseConnector(
|
||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||
dbDatabase=featureDbName,
|
||
dbUser=APP_CONFIG.get("DB_USER"),
|
||
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
|
||
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||
userId=userId or "agent",
|
||
)
|
||
|
||
aiService = services.ai if hasattr(services, "ai") else None
|
||
if aiService is None:
|
||
return ToolResult(
|
||
toolCallId="", toolName="queryFeatureInstance",
|
||
success=False, error="AI service not available for sub-agent",
|
||
)
|
||
|
||
async def _subAgentAiCall(req):
|
||
if _anySourceNeutralize:
|
||
req.requireNeutralization = True
|
||
return await aiService.callAi(req)
|
||
|
||
try:
|
||
answer = await runFeatureDataAgent(
|
||
question=question,
|
||
featureInstanceId=featureInstanceId,
|
||
featureCode=featureCode,
|
||
selectedTables=selectedTables,
|
||
mandateId=mandateId,
|
||
userId=userId,
|
||
aiCallFn=_subAgentAiCall,
|
||
dbConnector=featureDbConn,
|
||
instanceLabel=instanceLabel,
|
||
tableFilters=tableFilters,
|
||
)
|
||
finally:
|
||
try:
|
||
featureDbConn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
return ToolResult(
|
||
toolCallId="", toolName="queryFeatureInstance",
|
||
success=True, data=answer,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"queryFeatureInstance failed: {e}", exc_info=True)
|
||
return ToolResult(
|
||
toolCallId="", toolName="queryFeatureInstance",
|
||
success=False, error=str(e),
|
||
)
|
||
|
||
registry.register(
|
||
"queryFeatureInstance", _queryFeatureInstance,
|
||
description=(
|
||
"Query data from a feature instance (e.g. Trustee, CommCoach). "
|
||
"Delegates to a specialized sub-agent that knows the feature's data schema "
|
||
"and can browse/query its tables. Use this when the user has attached "
|
||
"feature data sources or asks about feature-specific data."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"featureInstanceId": {"type": "string", "description": "ID of the feature instance to query"},
|
||
"question": {"type": "string", "description": "What data to find or analyze from this feature instance"},
|
||
},
|
||
"required": ["featureInstanceId", "question"]
|
||
},
|
||
readOnly=True
|
||
)
|
||
|
||
# ---- Cross-workflow tools ----
|
||
|
||
async def _listWorkflowHistory(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
|
||
"""List all chat workflows in this workspace with metadata."""
|
||
import json as _json
|
||
try:
|
||
chatService = services.chat
|
||
chatInterface = chatService.interfaceDbChat
|
||
allWorkflows = chatInterface.getWorkflows() or []
|
||
|
||
allWorkflows.sort(
|
||
key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0,
|
||
reverse=True,
|
||
)
|
||
allWorkflows = allWorkflows[:50]
|
||
|
||
items = []
|
||
for wf in allWorkflows:
|
||
wfId = wf.get("id", "")
|
||
name = wf.get("name") or "(unnamed)"
|
||
createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
|
||
lastActivity = wf.get("lastActivity") or createdAt
|
||
|
||
msgs = chatInterface.getMessages(wfId) or []
|
||
messageCount = len(msgs)
|
||
lastPreview = ""
|
||
if msgs:
|
||
lastMsg = msgs[-1] if isinstance(msgs[-1], dict) else (
|
||
msgs[-1].model_dump() if hasattr(msgs[-1], "model_dump") else {}
|
||
)
|
||
content = lastMsg.get("message") or lastMsg.get("content") or ""
|
||
lastPreview = content[:150]
|
||
|
||
items.append({
|
||
"id": wfId,
|
||
"name": name,
|
||
"createdAt": createdAt,
|
||
"lastActivity": lastActivity,
|
||
"messageCount": messageCount,
|
||
"lastMessagePreview": lastPreview,
|
||
})
|
||
|
||
return ToolResult(
|
||
toolCallId="", toolName="listWorkflowHistory",
|
||
success=True, data=_json.dumps(items, ensure_ascii=False),
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(
|
||
toolCallId="", toolName="listWorkflowHistory",
|
||
success=False, error=str(e),
|
||
)
|
||
|
||
registry.register(
|
||
"listWorkflowHistory", _listWorkflowHistory,
|
||
description=(
|
||
"List all chat conversations/workflows in this workspace. "
|
||
"Returns id, name, createdAt, lastActivity, messageCount, and a preview "
|
||
"of the last message for each workflow. Use this to discover previous "
|
||
"conversations when the user asks about past chats or wants a summary "
|
||
"across conversations."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {},
|
||
},
|
||
readOnly=True,
|
||
)
|
||
|
||
async def _readWorkflowMessages(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
|
||
"""Read messages from a specific workflow."""
|
||
import json as _json
|
||
targetWorkflowId = args.get("workflowId", "")
|
||
limit = int(args.get("limit", 20))
|
||
offset = int(args.get("offset", 0))
|
||
|
||
if not targetWorkflowId:
|
||
return ToolResult(
|
||
toolCallId="", toolName="readWorkflowMessages",
|
||
success=False, error="workflowId is required",
|
||
)
|
||
|
||
try:
|
||
chatService = services.chat
|
||
chatInterface = chatService.interfaceDbChat
|
||
allMsgs = chatInterface.getMessages(targetWorkflowId) or []
|
||
|
||
sliced = allMsgs[offset:offset + limit]
|
||
items = []
|
||
for msg in sliced:
|
||
raw = msg if isinstance(msg, dict) else (
|
||
msg.model_dump() if hasattr(msg, "model_dump") else {}
|
||
)
|
||
content = raw.get("message") or raw.get("content") or ""
|
||
if len(content) > 2000:
|
||
content = content[:2000] + "..."
|
||
items.append({
|
||
"role": raw.get("role", ""),
|
||
"message": content,
|
||
"publishedAt": raw.get("publishedAt") or raw.get("sysCreatedAt") or 0,
|
||
})
|
||
|
||
header = f"Workflow {targetWorkflowId}: {len(allMsgs)} total messages"
|
||
if offset > 0 or len(allMsgs) > offset + limit:
|
||
header += f" (showing {offset + 1}-{offset + len(sliced)})"
|
||
|
||
return ToolResult(
|
||
toolCallId="", toolName="readWorkflowMessages",
|
||
success=True,
|
||
data=header + "\n" + _json.dumps(items, ensure_ascii=False),
|
||
)
|
||
except Exception as e:
|
||
return ToolResult(
|
||
toolCallId="", toolName="readWorkflowMessages",
|
||
success=False, error=str(e),
|
||
)
|
||
|
||
registry.register(
|
||
"readWorkflowMessages", _readWorkflowMessages,
|
||
description=(
|
||
"Read messages from a specific chat workflow/conversation. "
|
||
"Use this after listWorkflowHistory to read the content of a "
|
||
"specific past conversation. Supports pagination via offset/limit."
|
||
),
|
||
parameters={
|
||
"type": "object",
|
||
"properties": {
|
||
"workflowId": {"type": "string", "description": "ID of the workflow to read messages from"},
|
||
"limit": {"type": "integer", "description": "Max messages to return (default 20)"},
|
||
"offset": {"type": "integer", "description": "Skip first N messages (default 0)"},
|
||
},
|
||
"required": ["workflowId"],
|
||
},
|
||
readOnly=True,
|
||
)
|