Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 103 additions & 57 deletions ex_app/lib/all_tools/doc_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
tasktypes = (await nc.ocs("GET", "/ocs/v2.php/taskprocessing/tasktypes"))["types"].keys()
return bool(servable_formats(tasktypes))
143 changes: 141 additions & 2 deletions tests/test_doc_gen_links.py
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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
Expand All @@ -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')
Expand All @@ -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
Expand All @@ -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')
Expand Down
Loading