diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index c0ddded..5b0748f 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -18,7 +18,6 @@ from ex_app.lib.tools import get_tools from ex_app.lib.memorysaver import MemorySaver -from langchain_community.tools import YouTubeSearchTool, DuckDuckGoSearchResults # Dummy thread id as we return the whole state thread = {"configurable": {"thread_id": "thread-1"}} @@ -47,8 +46,6 @@ def export_conversation(checkpointer): def react(task, nc: Nextcloud): safe_tools, dangerous_tools = get_tools(nc) - safe_tools.append(YouTubeSearchTool()) - safe_tools.append(DuckDuckGoSearchResults(output_format="list")) model.bind_nextcloud(nc) diff --git a/ex_app/lib/all_tools/ai.py b/ex_app/lib/all_tools/ai.py deleted file mode 100644 index 07d839b..0000000 --- a/ex_app/lib/all_tools/ai.py +++ /dev/null @@ -1,66 +0,0 @@ -# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: AGPL-3.0-or-later -from langchain_core.tools import tool -from nc_py_api import Nextcloud - -from ex_app.lib.all_tools.lib.files import get_file_id_from_file_url -from ex_app.lib.all_tools.lib.task_processing import run_task -from ex_app.lib.all_tools.lib.decorator import safe_tool - - -def get_tools(nc: Nextcloud): - - @tool - @safe_tool - def ask_context_chat(question: str) -> str: - """ - Ask the context chat oracle, which knows all of the user's documents, a question about them - :param question: The question to ask - :return: the answer from context chat - """ - - task_input = { - 'prompt': question, - 'scopeType': 'none', - 'scopeList': [], - 'scopeListMeta': '', - } - task_output = run_task(nc, "context_chat:context_chat", task_input).output - return task_output['output'] - - @tool - @safe_tool - def transcribe_file(file_url: str) -> str: - """ - Transcribe a media file stored inside the nextcloud - :param file_url: The file URL to the media file in nextcloud (The user can input this using the smart picker for example) - :return: the transcription result - """ - task_input = { - 'input': get_file_id_from_file_url(file_url), - } - task_output = run_task(nc, "core:audio2text", task_input).output - return task_output['output'] - - - @tool - @safe_tool - def generate_document(input: str, format: str) -> str: - """ - Generate a document with the input string as description - :param text: the instructions for the document - :param format: the format of the generated file, available are "text_document" and "spreadsheet_document" - :return: a download link to the generated document - """ - tasktype = "richdocuments:text_to_" + format - task_input = { - 'text': input, - } - task = run_task(nc, tasktype, task_input) - return f"https://nextcloud.local/ocs/v2.php/apps/assistant/api/v1/task/{task.id}/output-file/{task.output['file']}/download" - - return [ - ask_context_chat, - transcribe_file, - generate_document, - ] \ No newline at end of file diff --git a/ex_app/lib/all_tools/audio2text.py b/ex_app/lib/all_tools/audio2text.py new file mode 100644 index 0000000..86d614a --- /dev/null +++ b/ex_app/lib/all_tools/audio2text.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +from langchain_core.tools import tool +from nc_py_api import Nextcloud + +from ex_app.lib.all_tools.lib.files import get_file_id_from_file_url +from ex_app.lib.all_tools.lib.task_processing import run_task +from ex_app.lib.all_tools.lib.decorator import safe_tool + + +def get_tools(nc: Nextcloud): + + @tool + @safe_tool + def transcribe_file(file_url: str) -> str: + """ + Transcribe a media file stored inside the nextcloud + :param file_url: The file URL to the media file in nextcloud (The user can input this using the smart picker for example) + :return: the transcription result + """ + task_input = { + 'input': get_file_id_from_file_url(file_url), + } + task_output = run_task(nc, "core:audio2text", task_input).output + return task_output['output'] + + return [ + transcribe_file, + ] + +def get_category_name(): + return "Audio transcription" + +def is_available(nc: Nextcloud): + tasktypes = nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes')['types'].keys() + return 'core:audio2text' in tasktypes \ No newline at end of file diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index b9987d4..6b4f06d 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -13,7 +13,7 @@ import xml.etree.ElementTree as ET import vobject -from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool, timed_memoize from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, round_to_nearest_half_hour from ex_app.lib.logger import log @@ -239,4 +239,12 @@ def add_task(calendar_name: str, title: str, description: str, due_date: Optiona schedule_event, find_free_time_slot_in_calendar, add_task - ] \ No newline at end of file + ] + +def get_category_name(): + return "Calendar and Tasks" + +@timed_memoize(5*60) +def is_available(nc: Nextcloud): + print('SHOULD DISAPPEAR') + return 'calendar' in nc.apps.get_list() \ No newline at end of file diff --git a/ex_app/lib/all_tools/contacts.py b/ex_app/lib/all_tools/contacts.py index 0225fb6..c1b0a49 100644 --- a/ex_app/lib/all_tools/contacts.py +++ b/ex_app/lib/all_tools/contacts.py @@ -89,4 +89,10 @@ def find_details_of_current_user() -> dict[str, typing.Any]: return [ find_person_in_contacts, find_details_of_current_user - ] \ No newline at end of file + ] + +def get_category_name(): + return "Contacts" + +def is_available(nc: Nextcloud): + return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py new file mode 100644 index 0000000..d7a25fb --- /dev/null +++ b/ex_app/lib/all_tools/context_chat.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +from langchain_core.tools import tool +from nc_py_api import Nextcloud + +from ex_app.lib.all_tools.lib.task_processing import run_task +from ex_app.lib.all_tools.lib.decorator import safe_tool + + +def get_tools(nc: Nextcloud): + + @tool + @safe_tool + def ask_context_chat(question: str) -> str: + """ + Ask the context chat oracle, which knows all of the user's documents, a question about them + :param question: The question to ask + :return: the answer from context chat + """ + + task_input = { + 'prompt': question, + 'scopeType': 'none', + 'scopeList': [], + 'scopeListMeta': '', + } + task_output = run_task(nc, "context_chat:context_chat", task_input).output + return task_output['output'] + + return [ + ask_context_chat, + ] + +def get_category_name(): + return "Context chat" + +def is_available(nc: Nextcloud): + return 'context_chat' in nc.apps.get_list() \ No newline at end of file diff --git a/ex_app/lib/all_tools/deck.py b/ex_app/lib/all_tools/deck.py index 8243c45..cffff88 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -60,4 +60,10 @@ def add_card(deck_id: int, stack_id: int, title: str): return [ list_decks, add_card - ] \ No newline at end of file + ] + +def get_category_name(): + return "Deck" + +def is_available(nc: Nextcloud): + return 'deck' in nc.capabilities \ No newline at end of file diff --git a/ex_app/lib/all_tools/doc-gen.py b/ex_app/lib/all_tools/doc-gen.py new file mode 100644 index 0000000..0bc6ba1 --- /dev/null +++ b/ex_app/lib/all_tools/doc-gen.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +from langchain_core.tools import tool +from nc_py_api import Nextcloud + +from ex_app.lib.all_tools.lib.task_processing import run_task +from ex_app.lib.all_tools.lib.decorator import safe_tool + + +def get_tools(nc: Nextcloud): + + @tool + @safe_tool + def generate_document(input: str, format: str) -> str: + """ + Generate a document with the input string as description + :param text: the instructions for the document + :param format: the format of the generated file, available are "text_document" and "spreadsheet_document" + :return: a download link to the generated document + """ + tasktype = "richdocuments:text_to_" + format + task_input = { + 'text': input, + } + task = run_task(nc, tasktype, task_input) + return f"https://nextcloud.local/ocs/v2.php/apps/assistant/api/v1/task/{task.id}/output-file/{task.output['file']}/download" + + return [ + generate_document, + ] + +def get_category_name(): + return "Office document generation" + +def is_available(nc: Nextcloud): + return 'richdocuments' in nc.capabilities \ No newline at end of file diff --git a/ex_app/lib/all_tools/duckduckgo.py b/ex_app/lib/all_tools/duckduckgo.py new file mode 100644 index 0000000..cb78313 --- /dev/null +++ b/ex_app/lib/all_tools/duckduckgo.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +from langchain_core.tools import tool +from nc_py_api import Nextcloud +from langchain_community.tools import DuckDuckGoSearchResults + +from ex_app.lib.all_tools.lib.decorator import safe_tool + + +def get_tools(nc: Nextcloud): + + web_search = DuckDuckGoSearchResults(output_format="list") + return [ + web_search, + ] + +def get_category_name(): + return "DuckDuckGo" + +def is_available(nc: Nextcloud): + return True diff --git a/ex_app/lib/all_tools/files.py b/ex_app/lib/all_tools/files.py index 2f3a39b..75da7b8 100644 --- a/ex_app/lib/all_tools/files.py +++ b/ex_app/lib/all_tools/files.py @@ -58,4 +58,10 @@ def create_public_sharing_link(path: str): get_file_content, get_folder_tree, create_public_sharing_link, - ] \ No newline at end of file + ] + +def get_category_name(): + return "Files" + +def is_available(nc: Nextcloud): + return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/here.py b/ex_app/lib/all_tools/here.py new file mode 100644 index 0000000..4f85ea0 --- /dev/null +++ b/ex_app/lib/all_tools/here.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +import typing +import datetime +import urllib.parse + +import httpx +from langchain_core.tools import tool +from nc_py_api import Nextcloud + +from ex_app.lib.all_tools.lib.decorator import safe_tool + + +def get_tools(nc: Nextcloud): + + @tool + @safe_tool + def get_public_transport_route_for_coordinates(origin_lat: str, origin_lon: str, destination_lat: str, destination_lon: str, routes: int, departure_time: str | None = None): + """ + Retrieve a public transport route between two coordinates + :param origin_lat: Latitude of the starting point + :param origin_lon: Longitude of the starting point + :param destination_lat: Latitude of the destination + :param destination_lon: Longitude of the destination + :param routes: the number of routes returned + :param departure_time: time of departure, formatted like '2019-06-24T01:23:45'. Optional, leave empty for the next routes from now + :return: the routes, times are given in local time according to origin and destination + """ + + if departure_time is None: + departure_time = urllib.parse.quote_plus(datetime.datetime.now(datetime.UTC).isoformat()) + api_key = nc.appconfig_ex.get_value('here_api') + res = httpx.get('https://transit.hereapi.com/v8/routes?transportMode=car&origin=' + + origin_lat + ',' + origin_lon + '&destination=' + destination_lat + ',' + destination_lon + + '&alternatives=' + str(routes-1) + '&departureTime=' + departure_time + '&apikey=' + api_key) + json = res.json() + return json + + return [ + get_public_transport_route_for_coordinates, + ] + +def get_category_name(): + return "Public transport" + +def is_available(nc: Nextcloud): + return nc.appconfig_ex.get_value('here_api') != '' \ No newline at end of file diff --git a/ex_app/lib/all_tools/lib/decorator.py b/ex_app/lib/all_tools/lib/decorator.py index 9742e91..2fe9ea3 100644 --- a/ex_app/lib/all_tools/lib/decorator.py +++ b/ex_app/lib/all_tools/lib/decorator.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later import functools +import time +from functools import wraps def safe_tool(tool): setattr(tool, 'safe', True) @@ -8,4 +10,30 @@ def safe_tool(tool): def dangerous_tool(tool): setattr(tool, 'safe', False) - return tool \ No newline at end of file + return tool + +def timed_memoize(timeout): + def decorator(func): + cached_result = None + timestamp = 0 + + @wraps(func) + def wrapper(*args): + nonlocal cached_result + nonlocal timestamp + current_time = time.time() + if cached_result != None: + if current_time - timestamp < timeout: + return cached_result + else: + # Cache expired + cached_result = None + timestamp = 0 + # Call the function and cache the result + result = func(*args) + cached_result = result + timestamp = current_time + return result + + return wrapper + return decorator diff --git a/ex_app/lib/all_tools/mail.py b/ex_app/lib/all_tools/mail.py index afe468b..7b30662 100644 --- a/ex_app/lib/all_tools/mail.py +++ b/ex_app/lib/all_tools/mail.py @@ -61,4 +61,10 @@ def get_mail_account_list(): return [ send_email, get_mail_account_list - ] \ No newline at end of file + ] + +def get_category_name(): + return "Mail" + +def is_available(nc: Nextcloud): + return 'mail' in nc.apps.get_list() \ No newline at end of file diff --git a/ex_app/lib/all_tools/openproject.py b/ex_app/lib/all_tools/openproject.py index 9fcf08a..c14ba5c 100644 --- a/ex_app/lib/all_tools/openproject.py +++ b/ex_app/lib/all_tools/openproject.py @@ -84,4 +84,10 @@ def create_work_package(project_id: int, title: str, description: Optional[str], list_projects, list_assignees, create_work_package - ] \ No newline at end of file + ] + +def get_category_name(): + return "OpenProject" + +def is_available(nc: Nextcloud): + return 'integration_openproject' in nc.capabilities \ No newline at end of file diff --git a/ex_app/lib/all_tools/external.py b/ex_app/lib/all_tools/openstreetmap.py similarity index 58% rename from ex_app/lib/all_tools/external.py rename to ex_app/lib/all_tools/openstreetmap.py index 546281d..7e01131 100644 --- a/ex_app/lib/all_tools/external.py +++ b/ex_app/lib/all_tools/openstreetmap.py @@ -31,52 +31,6 @@ def get_coordinates_for_address(address: str) -> (str, str): return json[0]['lat'], json[0]['lon'] - @tool - @safe_tool - def get_current_weather_for_coordinates(lat: str, lon: str) -> dict[str, typing.Any]: - """ - Retrieve the current weather for a given latitude and longitude - :param lat: Latitude - :param lon: Longitude - :return: - """ - res = httpx.get('https://api.met.no/weatherapi/locationforecast/2.0/compact', params={ - 'lat': lat, - 'lon': lon, - }, - headers={ - 'User-Agent': 'NextcloudWeatherStatus/ContextAgent nextcloud.com' - }) - json = res.json() - if not 'properties' in json or not 'timeseries' in json['properties'] or not json['properties']['timeseries']: - raise Exception('Could not retrieve weather for coordinates') - return json['properties']['timeseries'][0]['data']['instant']['details'] - - - - @tool - @safe_tool - def get_public_transport_route_for_coordinates(origin_lat: str, origin_lon: str, destination_lat: str, destination_lon: str, routes: int, departure_time: str | None = None): - """ - Retrieve a public transport route between two coordinates - :param origin_lat: Latitude of the starting point - :param origin_lon: Longitude of the starting point - :param destination_lat: Latitude of the destination - :param destination_lon: Longitude of the destination - :param routes: the number of routes returned - :param departure_time: time of departure, formatted like '2019-06-24T01:23:45'. Optional, leave empty for the next routes from now - :return: the routes, times are given in local time according to origin and destination - """ - - if departure_time is None: - departure_time = urllib.parse.quote_plus(datetime.datetime.now(datetime.UTC).isoformat()) - api_key = nc.appconfig_ex.get_value('here_api') - res = httpx.get('https://transit.hereapi.com/v8/routes?transportMode=car&origin=' - + origin_lat + ',' + origin_lon + '&destination=' + destination_lat + ',' + destination_lon - + '&alternatives=' + str(routes-1) + '&departureTime=' + departure_time + '&apikey=' + api_key) - json = res.json() - return json - @tool @safe_tool def get_osm_route(profile: str, origin_lat: str, origin_lon: str, destination_lat: str, destination_lon: str,): @@ -130,8 +84,12 @@ def get_osm_link(location: str): return [ get_coordinates_for_address, - get_current_weather_for_coordinates, - get_public_transport_route_for_coordinates, get_osm_route, get_osm_link, - ] \ No newline at end of file + ] + +def get_category_name(): + return "OpenStreetMap" + +def is_available(nc: Nextcloud): + return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/talk.py b/ex_app/lib/all_tools/talk.py index 8001249..f833617 100644 --- a/ex_app/lib/all_tools/talk.py +++ b/ex_app/lib/all_tools/talk.py @@ -65,4 +65,10 @@ def list_messages_in_conversation(conversation_name: str, n_messages: int = 30): list_messages_in_conversation, send_message_to_conversation, create_public_conversation, - ] \ No newline at end of file + ] + +def get_category_name(): + return "Talk" + +def is_available(nc: Nextcloud): + return 'spreed' in nc.capabilities diff --git a/ex_app/lib/all_tools/weather.py b/ex_app/lib/all_tools/weather.py new file mode 100644 index 0000000..8df6728 --- /dev/null +++ b/ex_app/lib/all_tools/weather.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +import typing +import urllib.parse + +import httpx +from langchain_core.tools import tool +from nc_py_api import Nextcloud + +from ex_app.lib.all_tools.lib.decorator import safe_tool + + +def get_tools(nc: Nextcloud): + @tool + @safe_tool + def get_current_weather_for_coordinates(lat: str, lon: str) -> dict[str, typing.Any]: + """ + Retrieve the current weather for a given latitude and longitude + :param lat: Latitude + :param lon: Longitude + :return: + """ + res = httpx.get('https://api.met.no/weatherapi/locationforecast/2.0/compact', params={ + 'lat': lat, + 'lon': lon, + }, + headers={ + 'User-Agent': 'NextcloudWeatherStatus/ContextAgent nextcloud.com' + }) + json = res.json() + if not 'properties' in json or not 'timeseries' in json['properties'] or not json['properties']['timeseries']: + raise Exception('Could not retrieve weather for coordinates') + return json['properties']['timeseries'][0]['data']['instant']['details'] + + return [ + get_current_weather_for_coordinates, + ] + +def get_category_name(): + return "Weather" + +def is_available(nc: Nextcloud): + return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/youtube.py b/ex_app/lib/all_tools/youtube.py new file mode 100644 index 0000000..41f30a3 --- /dev/null +++ b/ex_app/lib/all_tools/youtube.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +from langchain_core.tools import tool +from nc_py_api import Nextcloud +from langchain_community.tools import YouTubeSearchTool + +from ex_app.lib.all_tools.lib.decorator import safe_tool + + +def get_tools(nc: Nextcloud): + + yt_search = YouTubeSearchTool() + return [ + yt_search, + ] + +def get_category_name(): + return "YouTube" + +def is_available(nc: Nextcloud): + return True diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 3e71a78..264d4fb 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -8,6 +8,7 @@ from time import sleep import httpx +import json from fastapi import FastAPI from nc_py_api import NextcloudApp, NextcloudException from nc_py_api.ex_app import ( @@ -22,6 +23,7 @@ from ex_app.lib.agent import react from ex_app.lib.logger import log from ex_app.lib.provider import provider +from ex_app.lib.tools import get_categories from contextvars import ContextVar from gettext import translation @@ -48,14 +50,22 @@ async def lifespan(app: FastAPI): APP = FastAPI(lifespan=lifespan) APP.add_middleware(AppAPIAuthMiddleware) # set global AppAPI authentication middleware +categories=get_categories() SETTINGS = SettingsForm( id="settings_context_agent", section_type="admin", section_id="ai", - title=_("Context Agent"), - description=_("Find more details on how to set up Context Agent in the Administration documentation."), + title=_("Context agent"), + description=_("Find more details on how to set up Context agent in the Administration documentation."), fields=[ + SettingsField( + id="tool_status", + title=_("Activate all tools that Context agent should use"), + type=SettingsFieldType.MULTI_CHECKBOX, + default=dict.fromkeys(categories, True), + options={v: k for k, v in categories.items()}, + ), SettingsField( id="here_api", title=_("API Key HERE"), @@ -64,7 +74,7 @@ async def lifespan(app: FastAPI): default="", placeholder=_("API key"), ), - ], + ] ) @@ -78,6 +88,12 @@ def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: log(nc, LogLvl.WARNING, f"App enabled: {nc.app_cfg.app_name}") nc.ui.settings.register_form(SETTINGS) + pref_settings = json.loads(nc.appconfig_ex.get_value('tool_status', default = "{}")) + for key in categories.keys(): # populate new settings values + if key not in pref_settings: + pref_settings[key] = True + nc.appconfig_ex.set_value('tool_status', json.dumps(pref_settings)) + else: nc.providers.task_processing.unregister(provider.id) app_enabled.clear() diff --git a/ex_app/lib/tools.py b/ex_app/lib/tools.py index 291a428..eef8d23 100644 --- a/ex_app/lib/tools.py +++ b/ex_app/lib/tools.py @@ -3,10 +3,13 @@ import importlib import os import pathlib +import json from os.path import dirname from nc_py_api import Nextcloud +from ex_app.lib.all_tools.lib.decorator import timed_memoize +@timed_memoize(1*60) def get_tools(nc: Nextcloud): directory = dirname(__file__) + '/all_tools' function_name = "get_tools" @@ -15,23 +18,29 @@ def get_tools(nc: Nextcloud): safe_tools = [] py_files = [f for f in os.listdir(directory) if f.endswith(".py") and f != "__init__.py"] + is_activated = json.loads(nc.appconfig_ex.get_value('tool_status')) for file in py_files: - module_name = pathlib.Path(file).stem # Extract module name without .py - module_path = os.path.join(directory, file) - # Load module dynamically - spec = importlib.util.spec_from_file_location(module_name, module_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + module_name, spec, module = get_tool_module(file, directory) # Call function if it exists if hasattr(module, function_name): get_tools_from_import = getattr(module, function_name) + available_from_import = getattr(module, "is_available") + if not is_activated[module_name]: + print(f"{module_name} tools deactivated") + continue + if not available_from_import(nc): + print(f"{module_name} not available") + continue if callable(get_tools_from_import): print(f"Invoking {function_name} from {module_name}") imported_tools = get_tools_from_import(nc) for tool in imported_tools: + if not hasattr(tool, 'func'): + safe_tools.append(tool) # external tools cannot be decorated and should always be safe + continue if not tool.func.safe: dangerous_tools.append(tool) else: @@ -42,3 +51,36 @@ def get_tools(nc: Nextcloud): print(f"{function_name} not found in {module_name}.") return safe_tools, dangerous_tools + +def get_categories(): + directory = dirname(__file__) + '/all_tools' + function_name = "get_category_name" + + categories = {} + + py_files = [f for f in os.listdir(directory) if f.endswith(".py") and f != "__init__.py"] + + for file in py_files: + # Load module dynamically + module_name, spec, module = get_tool_module(file, directory) + + # Call function if it exists + if hasattr(module, function_name): + category_from_import = getattr(module, function_name) + if callable(category_from_import): + categories[module_name] = category_from_import() + else: + print(f"{function_name} in {module_name} is not callable.") + + return categories + + +def get_tool_module(file, directory): + module_name = pathlib.Path(file).stem # Extract module name without .py + module_path = os.path.join(directory, file) + + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + return module_name, spec, module \ No newline at end of file