diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 80141d1..6fcaaa4 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -6,13 +6,13 @@ from time import monotonic from typing import Any, cast +import pytz from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage, ToolMessage from langchain_core.runnables import RunnableConfig from nc_py_api import AsyncNextcloudApp from nc_py_api.ex_app import persistent_storage -import pytz - +from ex_app.lib.all_tools.context_chat import get_context_chat_providers from ex_app.lib.all_tools.lib.tz import user_timezone_async from ex_app.lib.all_tools.nextcloud_links import get_absolute_base_url from ex_app.lib.all_tools.skills import list_skills_metadata @@ -197,6 +197,10 @@ async def call_model( system_prompt_text += "Always check for the mail account id before requesting a folder list.\n" if tool_enabled("web_fetch"): system_prompt_text += "Use the web_fetch tool to fetch web content. You can fetch the complete page content of a duckduckgo search result using web_fetch as well.\n" + if tool_enabled("ask_context_chat"): + cc_providers = await get_context_chat_providers(nc, True) + if len(cc_providers) > 0: + system_prompt_text += f"Available providers in Context Chat are: {cc_providers}. Use these in the Context Chat tools to scope the search to specific types of documents. It is passed in the scope_list when scope_type is \"provider\"." if tool_enabled("parse_nextcloud_url"): # app_cfg.endpoint can be an instance-internal address, so it is only a last resort base_url = await get_absolute_base_url(nc) or nc.app_cfg.endpoint.rstrip('/') diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py index 4b4ef27..32b8740 100644 --- a/ex_app/lib/all_tools/context_chat.py +++ b/ex_app/lib/all_tools/context_chat.py @@ -1,12 +1,62 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later import json +import logging from typing import Literal + +import pydantic from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp -from ex_app.lib.all_tools.lib.task_processing import run_task +from ex_app.lib.all_tools.lib.decorator import timed_memoize from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse, read_only +from ex_app.lib.all_tools.lib.task_processing import run_task + +LOGGER = logging.getLogger(__name__) +CC_PROVIDERS_TTL = 60 * 60 # 1h +CONTEXT_CHAT_TASK_TYPE = "context_chat:context_chat" +CONTEXT_CHAT_SEARCH_TASK_TYPE = "context_chat:context_chat_search" + + +class CcProvider(pydantic.BaseModel): + id: str + label: str + icon: str + + +@timed_memoize(CC_PROVIDERS_TTL) +async def _fetch_context_chat_providers(nc: AsyncNextcloudApp) -> list[CcProvider]: + try: + response = await nc._session._create_adapter(False).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/context_chat/providers", headers={ + "Content-Type": "application/json", + "OCS-APIREQUEST": "true", + }) + except Exception as e: + LOGGER.warning(f"Network failure in fetching context chat content providers: {e}", exc_info=e) + return [] + + try: + return pydantic.TypeAdapter(list[CcProvider]).validate_python(response.json()) + except Exception as e: + LOGGER.warning(f"Validation of context_chat providers response failed: {e}", exc_info=e) + return [] + + +async def get_context_chat_providers(nc: AsyncNextcloudApp, pretty: bool = False) -> list[CcProvider] | list[str]: + result = await _fetch_context_chat_providers(nc) + + if not pretty: + return result + + return [f"{p.label}({p.id})" for p in result] + + +async def _check_provider_scope_valid(nc: AsyncNextcloudApp, scope_list: list[str]): + available_providers = await get_context_chat_providers(nc) + provider_keys = {p.id for p in available_providers} + for scope in scope_list: + if scope not in provider_keys: + raise RuntimeError(f"Provider {scope} is not an available provider. Available providers: {provider_keys}") async def get_tools(nc: AsyncNextcloudApp): @@ -14,17 +64,13 @@ async def get_tools(nc: AsyncNextcloudApp): @tool @impulse(ImpulseRadius.SELF) @read_only - async def list_context_chat_providers() -> str: + async def list_context_chat_providers() -> list[str]: """ - List the content providers available to context chat (e.g., files, mail). - Use this to discover valid provider keys for ask_context_chat's scope_list when scope_type is "provider". Note that the files__default provider is always available. - :return: JSON array of available providers + Refreshes the content providers list available to context chat (e.g., files, mail). + :return: list of available providers """ - response = await nc._session._create_adapter(False).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/context_chat/providers", headers={ - "Content-Type": "application/json", - "OCS-APIREQUEST": "true", - }) - return json.dumps(response.json()) + await _fetch_context_chat_providers.cache_invalidate(nc) + return await get_context_chat_providers(nc, True) @tool @impulse(ImpulseRadius.SELF) @@ -33,7 +79,7 @@ async def ask_context_chat( question: str, scope_type: Literal['none', 'source', 'provider'] = 'none', scope_list: list[str] | None = None, - ) -> str: + ) -> dict[str, str | list[str]]: """ Ask the context chat oracle a question about the user's documents. It knows the contents of all of the users documents. This is often easier than searching for documents and then fetching their contents when trying to answer questions. @@ -48,21 +94,72 @@ async def ask_context_chat( obtained from the file-related tools or unified search. - For scope_type "provider": a list of provider keys in the format "__" (e.g., "files__default" for files, "mail__messages" for mail). - :return: the answer from context chat + :return: the answer from context chat along with the sources used to get to it. Source icons can be displayed in the UI if presented as markdown images. + """ + + if scope_type != "none" and not scope_list: + raise RuntimeError("A set scope requires at least one item in the scope list") + + if scope_type == "provider": + await _check_provider_scope_valid(nc, scope_list) + + task_input = { + "prompt": question, + "scopeType": scope_type, + "scopeList": scope_list or [], + "scopeListMeta": "", + } + task_output = (await run_task(nc, CONTEXT_CHAT_TASK_TYPE, task_input)).output + return task_output + + @tool + @impulse(ImpulseRadius.SELF) + @read_only + async def search_context_chat( + prompt: str, + scope_type: Literal['none', 'source', 'provider'] = 'none', + scope_list: list[str] | None = None, + limit: int = 30, + ) -> list[str]: + """ + Search the context chat database for the user's documents. It uses semantic search to find similar documents. + This is for only searching the documents without an LLM explanation. + :param prompt: The text to find similar documents for + :param scope_type: Optional. Restricts which documents the oracle searches. + - "none" (default): search across all of the user's documents and content providers. + - "source": restrict the search to specific items listed in scope_list. + - "provider": restrict the search to specific content providers listed in scope_list. + :param scope_list: Required when scope_type is "source" or "provider"; ignored otherwise. + - For scope_type "source": a list of source IDs in the format "__: " + (e.g., "files__default: 12345" for the file with Nextcloud file id 12345). File ids can be + obtained from the file-related tools or unified search. + - For scope_type "provider": a list of provider keys in the format "__" + (e.g., "files__default" for files, "mail__messages" for mail). + :param limit: The max no. of documents to return, default 30, max 200. + :return: A list of JSON strings of sources containing the id, label, icon and an absolute URL to the source documents. + Icons can be displayed in the UI if presented as markdown images. """ + if scope_type != "none" and not scope_list: + raise RuntimeError("A set scope requires at least one item in the scope list") + + if scope_type == "provider": + await _check_provider_scope_valid(nc, scope_list) + task_input = { - 'prompt': question, - 'scopeType': scope_type, - 'scopeList': scope_list or [], - 'scopeListMeta': '', + "prompt": prompt, + "scopeType": scope_type, + "scopeList": scope_list or [], + "scopeListMeta": "", + "limit": min(200, max(1, limit)), } - task_output = (await run_task(nc, "context_chat:context_chat", task_input)).output - return task_output['output'] + task_output = (await run_task(nc, CONTEXT_CHAT_SEARCH_TASK_TYPE, task_input)).output + return task_output["sources"] return [ - ask_context_chat, list_context_chat_providers, + ask_context_chat, + search_context_chat, ] def get_category_name(): @@ -70,4 +167,4 @@ def get_category_name(): async def is_available(nc: AsyncNextcloudApp): tasktypes = (await nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes'))['types'].keys() - return 'context_chat:context_chat' in tasktypes \ No newline at end of file + return CONTEXT_CHAT_TASK_TYPE in tasktypes \ No newline at end of file