diff --git a/ex_app/lib/all_tools/doc_gen.py b/ex_app/lib/all_tools/doc_gen.py index e95a4ab..6f61e05 100644 --- a/ex_app/lib/all_tools/doc_gen.py +++ b/ex_app/lib/all_tools/doc_gen.py @@ -3,69 +3,115 @@ 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.impulse import ImpulseRadius, impulse +from ex_app.lib.all_tools.lib.task_processing import run_task + +# Which registered task type(s) can serve each output format. The taskprocessing +# registry is the authoritative source for this: an app capability alone does +# not prove that the app's provider registered its task types on this instance. +FORMAT_TASK_TYPES = { + "text document": ("richdocuments:text_to_text_document",), + "pdf": ("richdocuments:text_to_text_document",), + "spreadsheet": ("richdocuments:text_to_spreadsheet_document",), + "excel spreadsheet": ("richdocuments:text_to_spreadsheet_document",), + "slides": ("marp_slides:slide_deck_generation", "richdocuments:slide_deck_generation"), +} + + +def servable_formats(tasktypes): + """The formats for which at least one of the required task types is registered.""" + return [ + name + for name, task_types in FORMAT_TASK_TYPES.items() + if any(task_type in tasktypes for task_type in task_types) + ] + + +def quoted_formats(formats): + """The formats as a quoted list: "text document", "pdf" and "slides".""" + if len(formats) < 2: + return f'"{formats[0]}"' if formats else "" + return ", ".join(f'"{name}"' for name in formats[:-1]) + f' and "{formats[-1]}"' async def get_tools(nc: AsyncNextcloudApp): + tasktypes = (await nc.ocs("GET", "/ocs/v2.php/taskprocessing/tasktypes"))["types"].keys() + available_formats = servable_formats(tasktypes) + if not available_formats: + # No task type on this instance can generate any of the formats. + return [] + + async def generate_document(input: str, format: str) -> str: + if format not in available_formats: + return ( + f'The requested format "{format}" is not available for document generation on this instance. ' + f"Available formats: {quoted_formats(available_formats)}." + ) + + url = ( + await nc.ocs( + "GET", + "/ocs/v2.php/apps/app_api/api/v1/info/nextcloud_url/absolute", + json={"url": "ocs/v2.php/apps/assistant/api/v1/task"}, + ) + )["absolute_url"] + + match format: + case "text document": + tasktype = "richdocuments:text_to_text_document" + task_input = {"text": input, "target_format": "docx"} + + case "pdf": + tasktype = "richdocuments:text_to_text_document" + task_input = {"text": input, "target_format": "pdf"} + + case "spreadsheet": + tasktype = "richdocuments:text_to_spreadsheet_document" + task_input = {"text": input, "target_format": "ods"} + + case "excel spreadsheet": + tasktype = "richdocuments:text_to_spreadsheet_document" + task_input = {"text": input, "target_format": "xlsx"} + + case "slides": + # Prefer the slides ExApp's deck task when its type is registered + # server-side, fall back to richdocuments' deck task otherwise. + # Both answer with the "slide_deck" output key. + tasktypes = (await nc.ocs("GET", "/ocs/v2.php/taskprocessing/tasktypes"))["types"].keys() + if "marp_slides:slide_deck_generation" in tasktypes: + tasktype = "marp_slides:slide_deck_generation" + task_input = { + "prompt": input, + } + else: + tasktype = "richdocuments:slide_deck_generation" + task_input = { + "text": input, + } + task = await run_task(nc, tasktype, task_input) + return f"{url}/{task.id}/output-file/{task.output['slide_deck']}/download" + + task = await run_task(nc, tasktype, task_input) + return f"{url}/{task.id}/output-file/{task.output['file']}/download" + + # The docstring is the model-facing contract, so it only offers the formats + # this instance can actually generate. + generate_document.__doc__ = ( + f"Generate an office document based on a description of what it should contain\n" + f":param input: the instructions for what the document should contain\n" + f":param format: the format of the generated file, allowed values are {quoted_formats(available_formats)}\n" + f":return: a download link to the generated document" + ) + + return [ + tool(impulse(ImpulseRadius.SELF)(generate_document)), + ] - @tool - @impulse(ImpulseRadius.SELF) - async def generate_document(input: str, format: str) -> str: - """ - Generate an office document based on a description of what it should contain - :param input: the instructions for what the document should contain - :param format: the format of the generated file, allowed values are "text document", "pdf", "spreadsheet", "excel spreadsheet" and "slides" - :return: a download link to the generated document - """ - url = (await nc.ocs('GET', '/ocs/v2.php/apps/app_api/api/v1/info/nextcloud_url/absolute', json={'url': 'ocs/v2.php/apps/assistant/api/v1/task'}))["absolute_url"] - - match format: - case "text document": - tasktype = "richdocuments:text_to_text_document" - task_input = { - 'text': input, - 'target_format': 'docx' - } - - case "pdf": - tasktype = "richdocuments:text_to_text_document" - task_input = { - 'text': input, - 'target_format': 'pdf' - } - - case "spreadsheet": - tasktype = "richdocuments:text_to_spreadsheet_document" - task_input = { - 'text': input, - 'target_format': 'ods' - } - - case "excel spreadsheet": - tasktype = "richdocuments:text_to_spreadsheet_document" - task_input = { - 'text': input, - 'target_format': 'xlsx' - } - - case "slides": - tasktype = "richdocuments:slide_deck_generation" - task_input = { - 'text': input, - } - task = await run_task(nc, tasktype, task_input) - return f"{url}/{task.id}/output-file/{task.output['slide_deck']}/download" - - task = await run_task(nc, tasktype, task_input) - return f"{url}/{task.id}/output-file/{task.output['file']}/download" - - return [ - generate_document, - ] def get_category_name(): - return "Office document generation" + return "Office document generation" + async def is_available(nc: AsyncNextcloudApp): - return 'richdocuments' in await nc.capabilities \ No newline at end of file + tasktypes = (await nc.ocs("GET", "/ocs/v2.php/taskprocessing/tasktypes"))["types"].keys() + return bool(servable_formats(tasktypes)) diff --git a/tests/test_doc_gen_links.py b/tests/test_doc_gen_links.py index 6b1ab8c..d7682e0 100644 --- a/tests/test_doc_gen_links.py +++ b/tests/test_doc_gen_links.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""Download-link construction in the doc_gen and image_gen tools. +"""Download-link construction and slides dispatch in the doc_gen and image_gen tools. Run standalone: @@ -10,6 +10,11 @@ the app_api nextcloud_url endpoint with the dict the real OCS controller returns ({'absolute_url': ...}), not a bare string. A fake that answered with a bare string could not see the dict-repr mangling these tests guard against. +The fake also answers the taskprocessing tasktypes endpoint and mirrors +AsyncNextcloudApp's async capabilities property. doc_gen derives its format +menu, its runtime guard for unservable formats and its availability from the +registered task types alone: a capability without a registered task type must +not advertise the formats of that app. """ import asyncio import os @@ -21,26 +26,52 @@ NEXTCLOUD_URL = 'https://nc.example.test' +MARP_SLIDES_TASK_TYPE = 'marp_slides:slide_deck_generation' + +RICHDOCUMENTS_TEXT_TASK_TYPE = 'richdocuments:text_to_text_document' +RICHDOCUMENTS_SPREADSHEET_TASK_TYPE = 'richdocuments:text_to_spreadsheet_document' +RICHDOCUMENTS_SLIDES_TASK_TYPE = 'richdocuments:slide_deck_generation' + +# A full richdocuments instance: every format the tool knows is servable. +RICHDOCUMENTS_TASK_TYPES = { + RICHDOCUMENTS_TEXT_TASK_TYPE: {}, + RICHDOCUMENTS_SPREADSHEET_TASK_TYPE: {}, + RICHDOCUMENTS_SLIDES_TASK_TYPE: {}, +} + class _FakeNextcloudApp: """Records ocs() calls; answers the endpoints the two tools touch.""" - def __init__(self, task_id=42, file_id=1337): + def __init__(self, task_id=42, file_id=1337, tasktypes=None, capabilities=None): self.task_id = task_id self.file_id = file_id + # Defaults to a full richdocuments instance, so the default fake serves + # every format; pass tasktypes explicitly for other instance shapes. + self.tasktypes = dict(tasktypes if tasktypes is not None else RICHDOCUMENTS_TASK_TYPES) + self._capabilities = dict(capabilities or {}) self.ocs_calls = [] + @property + async def capabilities(self): + # AsyncNextcloudApp.capabilities is an async property, so the fake must + # return an awaitable here rather than the dict itself. + return self._capabilities + async def log(self, level, content): """Sink for run_task's debug logging.""" async def ocs(self, method, endpoint, json=None, **_kwargs): self.ocs_calls.append((method, endpoint, json)) + if endpoint == '/ocs/v2.php/taskprocessing/tasktypes': + return {'types': self.tasktypes} if endpoint == '/ocs/v2.php/apps/app_api/api/v1/info/nextcloud_url/absolute': # The real endpoint (app_api OCSApiController) answers with a dict, not a bare string return {'absolute_url': NEXTCLOUD_URL} if endpoint == '/ocs/v1.php/taskprocessing/schedule': output_keys_by_type = { 'richdocuments:slide_deck_generation': 'slide_deck', + MARP_SLIDES_TASK_TYPE: 'slide_deck', 'core:text2image': 'images', } output_key = output_keys_by_type.get(json['type'], 'file') @@ -56,6 +87,16 @@ def _tool_coroutine(module, nc, name): return by_name[name].coroutine +def _tool_description(module, nc, name): + tools = asyncio.run(module.get_tools(nc)) + by_name = {tool.name: tool for tool in tools} + return by_name[name].description + + +def _schedule_calls(nc): + return [call for call in nc.ocs_calls if call[1] == '/ocs/v1.php/taskprocessing/schedule'] + + def _assert_clean_link(link, expected): assert link == expected, link # The old bug embedded the whole OCS response dict in the f-string, so links @@ -78,6 +119,104 @@ def test_slides_download_link_built_from_bare_absolute_url(): _assert_clean_link(link, f'{NEXTCLOUD_URL}/57/output-file/909/download') +def test_slides_dispatch_marp_task_type_when_registered(): + nc = _FakeNextcloudApp(task_id=3, file_id=77, tasktypes={MARP_SLIDES_TASK_TYPE: {}}) + generate_document = _tool_coroutine(doc_gen, nc, 'generate_document') + link = asyncio.run(generate_document(input='Deck brief', format='slides')) + ((method, endpoint, payload),) = _schedule_calls(nc) + assert method == 'POST' + assert endpoint == '/ocs/v1.php/taskprocessing/schedule' + assert payload['appId'] == 'context_agent' + assert payload['type'] == MARP_SLIDES_TASK_TYPE + assert payload['input'] == {'prompt': 'Deck brief'} + # The task type's default output format applies, so none is sent + assert 'output_format' not in payload['input'] + _assert_clean_link(link, f'{NEXTCLOUD_URL}/3/output-file/77/download') + + +def test_slides_fall_back_to_richdocuments_without_marp_task_type(): + # Slides must be servable for the fallback to run at all, so the + # richdocuments deck task type is registered while marp's is not. + nc = _FakeNextcloudApp(tasktypes={'core:text2text': {}, RICHDOCUMENTS_SLIDES_TASK_TYPE: {}}) + generate_document = _tool_coroutine(doc_gen, nc, 'generate_document') + link = asyncio.run(generate_document(input='Deck brief', format='slides')) + ((_, _, payload),) = _schedule_calls(nc) + assert payload['type'] == 'richdocuments:slide_deck_generation' + assert payload['input'] == {'text': 'Deck brief'} + _assert_clean_link(link, f'{NEXTCLOUD_URL}/42/output-file/1337/download') + + +def test_is_available_true_with_richdocuments_only(): + # Availability comes from the registered task types, not from capabilities. + nc = _FakeNextcloudApp(tasktypes=RICHDOCUMENTS_TASK_TYPES) + assert asyncio.run(doc_gen.is_available(nc)) is True + + +def test_is_available_false_with_capability_but_no_registered_task_types(): + # Marcel's concern, encoded: the richdocuments capability alone proves + # nothing. With none of its task types registered there is nothing the + # tool could run, so it must not be offered. + nc = _FakeNextcloudApp(capabilities={'richdocuments': {}}, tasktypes={}) + assert asyncio.run(doc_gen.is_available(nc)) is False + + +def test_is_available_true_with_marp_task_type_only(): + nc = _FakeNextcloudApp(tasktypes={MARP_SLIDES_TASK_TYPE: {}}) + assert asyncio.run(doc_gen.is_available(nc)) is True + + +def test_is_available_true_with_richdocuments_and_marp(): + nc = _FakeNextcloudApp(tasktypes={**RICHDOCUMENTS_TASK_TYPES, MARP_SLIDES_TASK_TYPE: {}}) + assert asyncio.run(doc_gen.is_available(nc)) is True + + +def test_is_available_false_with_neither(): + nc = _FakeNextcloudApp(capabilities={}, tasktypes={'core:text2text': {}}) + assert asyncio.run(doc_gen.is_available(nc)) is False + + +def test_marp_only_instance_offers_only_the_slides_format(): + nc = _FakeNextcloudApp(tasktypes={MARP_SLIDES_TASK_TYPE: {}}) + description = _tool_description(doc_gen, nc, 'generate_document') + assert 'allowed values are "slides"' in description + for unservable in ('"text document"', '"pdf"', '"spreadsheet"', '"excel spreadsheet"'): + assert unservable not in description + # The rest of the contract is untouched by the format filtering. + assert ':param input:' in description + assert ':return:' in description + + +def test_richdocuments_instance_offers_all_formats(): + nc = _FakeNextcloudApp() + description = _tool_description(doc_gen, nc, 'generate_document') + assert 'allowed values are "text document", "pdf", "spreadsheet", "excel spreadsheet" and "slides"' in description + + +def test_marp_only_instance_rejects_spreadsheet_request_softly(): + nc = _FakeNextcloudApp(tasktypes={MARP_SLIDES_TASK_TYPE: {}}) + generate_document = _tool_coroutine(doc_gen, nc, 'generate_document') + reply = asyncio.run(generate_document(input='Budget plan', format='spreadsheet')) + assert 'not available' in reply + # The guard names what the instance can do instead. + assert '"slides"' in reply + # Nothing was scheduled for a format no provider can handle. + assert _schedule_calls(nc) == [] + + +def test_unknown_format_request_names_the_available_formats(): + nc = _FakeNextcloudApp() + generate_document = _tool_coroutine(doc_gen, nc, 'generate_document') + reply = asyncio.run(generate_document(input='Budget plan', format='word document')) + assert 'not available' in reply + assert '"slides"' in reply + assert _schedule_calls(nc) == [] + + +def test_no_servable_formats_means_no_tools(): + nc = _FakeNextcloudApp(tasktypes={'core:text2text': {}}) + assert asyncio.run(doc_gen.get_tools(nc)) == [] + + def test_image_download_link_built_from_bare_absolute_url(): nc = _FakeNextcloudApp(task_id=9, file_id=61) generate_image = _tool_coroutine(image_gen, nc, 'generate_image')