From 9dd8a163c8ec0eeee0b4f892bd29b2980617327a Mon Sep 17 00:00:00 2001 From: MichelleAntunes Date: Tue, 11 Aug 2026 15:02:17 +0200 Subject: [PATCH 1/7] Register context_chat:context_chat_multi task type Adds a new TaskType/TaskProcessingProvider registration in enabled_handler(), alongside the existing context_chat and context_chat_search ones. This new task type ('context_chat:context_chat_multi') accepts the same input as a regular question (prompt, scopeType, scopeList, scopeListMeta), but is meant to receive several questions at once, one per line. Its output_shape is different from the single-question task type: instead of a single 'output' text field, it declares 'questions' and 'answers' as lists (LIST_OF_TEXTS), so the caller gets back one answer per question, in order, plus a combined 'sources' list. expected_runtime is set to 30 * MAX_MULTI_QUESTIONS, since answering multiple questions sequentially takes proportionally longer than a single question. Also unregisters the new provider in the disabled branch, mirroring the existing search/normal providers. Signed-off-by: MichelleAntunes --- context_chat_backend/controller.py | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/context_chat_backend/controller.py b/context_chat_backend/controller.py index 12d86fa3..bac810b8 100644 --- a/context_chat_backend/controller.py +++ b/context_chat_backend/controller.py @@ -101,11 +101,16 @@ def get_enabled_state() -> bool: return app_enabled.is_set() +MAX_MULTI_QUESTIONS = 20 + + def enabled_handler(enabled: bool, nc: NextcloudApp | AsyncNextcloudApp) -> str: SEARCH_TASKTYPE_ID = 'context_chat:context_chat_search' SEARCH_PROVIDER_ID = 'context_chat-context_chat_search' CC_TASKTYPE_ID = 'context_chat:context_chat' CC_PROVIDER_ID = 'context_chat-context_chat' + MULTI_TASKTYPE_ID = 'context_chat:context_chat_multi' + MULTI_PROVIDER_ID = 'context_chat-context_chat_multi' # todo: translate user-facing texts try: @@ -209,6 +214,60 @@ def enabled_handler(enabled: bool, nc: NextcloudApp | AsyncNextcloudApp) -> str: ) nc.providers.task_processing.register(cc_provider, cc_tasktype) + multi_tasktype = TaskType( + id=MULTI_TASKTYPE_ID, + name='Context Chat Multi', + description='Ask several questions about your data at once, one per line.', + input_shape=[ + ShapeDescriptor( + name='prompt', + description='Ask one or more questions about your documents, files and more. Put each question on its own line.', + shape_type=ShapeType.TEXT, + ), + ShapeDescriptor( + name='scopeType', + description='Any of the following values: "none", "source", "provider".', + shape_type=ShapeType.TEXT, + ), + ShapeDescriptor( + name='scopeList', + description='List of sources or providers', + shape_type=ShapeType.LIST_OF_TEXTS, + ), + ShapeDescriptor( + name='scopeListMeta', + description='Required to nicely render the scope list in assistant', + shape_type=ShapeType.TEXT, + ), + ], + output_shape=[ + ShapeDescriptor( + name='questions', + description='The individual questions that were asked, in order', + shape_type=ShapeType.LIST_OF_TEXTS, + ), + ShapeDescriptor( + name='answers', + description='The answers generated by the model, in the same order as the questions', + shape_type=ShapeType.LIST_OF_TEXTS, + ), + ShapeDescriptor( + name='sources', + description='The sources referenced across all answers', + shape_type=ShapeType.LIST_OF_TEXTS, + ), + ], + ) + + multi_provider = TaskProcessingProvider( + id=MULTI_PROVIDER_ID, + name='Context Chat', + task_type=MULTI_TASKTYPE_ID, + # multiple sequential LLM calls, so allow more time than a single question + expected_runtime=30 * MAX_MULTI_QUESTIONS, + ) + nc.providers.task_processing.register(multi_provider, multi_tasktype) + app_enabled.set() if THREAD_STOP_EVENT.is_set(): # If the threads were previously stopped, we start them again @@ -219,6 +278,7 @@ def enabled_handler(enabled: bool, nc: NextcloudApp | AsyncNextcloudApp) -> str: app_enabled.clear() nc.providers.task_processing.unregister(SEARCH_PROVIDER_ID) nc.providers.task_processing.unregister(CC_PROVIDER_ID) + nc.providers.task_processing.unregister(MULTI_PROVIDER_ID) wait_for_bg_threads() except Exception as e: From a0ca9a4784ef18fcf921037c458e94c8a11ef742 Mon Sep 17 00:00:00 2001 From: MichelleAntunes Date: Tue, 11 Aug 2026 15:03:15 +0200 Subject: [PATCH 2/7] Process context_chat:context_chat_multi tasks Teaches the request processing loop to recognize the new 'context_chat:context_chat_multi' task type (added to the next_task() polling call) and route it to a new process_multi_task() function. process_multi_task(): - Splits the task's 'prompt' input into individual questions, one per non-empty line, via the new _split_questions() helper. - Caps the number of questions at MAX_MULTI_QUESTIONS (20) to avoid unbounded processing time on a single task. - Calls the existing process_context_query() once per question, reusing the same vector search + LLM logic used for single questions, instead of duplicating that logic. - Collects all answers in order, and deduplicates sources across answers (the same document may be relevant to more than one question) before returning them. - Raises ValueError if no valid question was found in the prompt, so the task fails clearly instead of silently returning nothing. The result (questions, answers, sources) is returned to Nextcloud via return_result_to_nextcloud(), matching the output_shape declared for this task type in controller.py. Signed-off-by: MichelleAntunes --- context_chat_backend/task_fetcher.py | 96 +++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/context_chat_backend/task_fetcher.py b/context_chat_backend/task_fetcher.py index 69523979..aa48e222 100644 --- a/context_chat_backend/task_fetcher.py +++ b/context_chat_backend/task_fetcher.py @@ -513,8 +513,16 @@ def request_processing_thread(app_config: TConfig, get_enabled_state) -> None: # Fetch pending task try: response = nc.providers.task_processing.next_task( - ['context_chat-context_chat', 'context_chat-context_chat_search'], - ['context_chat:context_chat', 'context_chat:context_chat_search'], + [ + 'context_chat-context_chat', + 'context_chat-context_chat_search', + 'context_chat-context_chat_multi', + ], + [ + 'context_chat:context_chat', + 'context_chat:context_chat_search', + 'context_chat:context_chat_multi', + ], ) if not response: wait_for_tasks() @@ -548,6 +556,14 @@ def request_processing_thread(app_config: TConfig, get_enabled_state) -> None: success = return_result_to_nextcloud(task['id'], userId, { 'sources': enrich_sources(search_result, userId), }) + elif task['type'] == 'context_chat:context_chat_multi': + multi_result = process_multi_task(task, vectordb_loader, llm, app_config) + # Return result to Nextcloud + success = return_result_to_nextcloud(task['id'], userId, { + 'questions': multi_result['questions'], + 'answers': multi_result['answers'], + 'sources': enrich_sources(multi_result['sources'], userId), + }) else: LOGGER.error(f'Unknown task type {task["type"]}') success = return_error_to_nextcloud(task['id'], Exception(f'Unknown task type {task["type"]}')) @@ -693,6 +709,82 @@ def process_normal_task( ) ) +# Keep in sync with MAX_MULTI_QUESTIONS in controller.py +MAX_MULTI_QUESTIONS = 20 + + +def _split_questions(raw_prompt: str) -> list[str]: + """Split a multi-line prompt into individual, non-empty questions.""" + questions = [line.strip() for line in (raw_prompt or '').splitlines()] + questions = [q for q in questions if q] + return questions[:MAX_MULTI_QUESTIONS] + + +def process_multi_task( + task: dict[str, Any], + vectordb_loader: VectorDBLoader, + llm: LLM, + app_config: TConfig, +) -> dict[str, Any]: + """ + Process a task containing several questions (one per line), answering each + sequentially against the same scope, and collecting all results. + + Args: + task: Task dictionary from fetch_query_tasks_from_nextcloud + vectordb_loader: Vector database loader instance + llm: Language model instance + app_config: Application configuration + + Returns: + dict with 'questions' (as asked), 'answers' (in the same order) and 'sources' (deduplicated across all answers) + + Raises: + ValueError: if no valid question was found in the prompt + Various exceptions from query execution + """ + user_id = task['userId'] + task_input = task['input'] + if task_input.get('scopeType') == 'none': + task_input['scopeType'] = None + + questions = _split_questions(task_input.get('prompt')) + if not questions: + raise ValueError('No questions found. Please provide at least one question, one per line.') + + answers: list[str] = [] + all_sources: list[SearchResult] = [] + seen_sources: set[str] = set() + + for question in questions: + result: LLMOutput = exec_in_proc(target=process_context_query, + args=( + user_id, + vectordb_loader, + llm, + app_config, + question, + CONTEXT_LIMIT, + task_input.get('scopeType'), + task_input.get('scopeList'), + app_config.llm[1].get('template'), + ) + ) + answers.append(result['output']) + for source in result['sources']: + # avoid duplicate sources across answers while preserving order + key = getattr(source, 'id', None) or str(source) + if key not in seen_sources: + seen_sources.add(key) + all_sources.append(source) + + return { + 'questions': questions, + 'answers': answers, + 'sources': all_sources, + } + + def process_search_task( task: dict[str, Any], vectordb_loader: VectorDBLoader, From 9968645bf8014a0a1e4e6603dc57c8ddfe07a538 Mon Sep 17 00:00:00 2001 From: MichelleAntunes Date: Thu, 13 Aug 2026 13:52:02 +0200 Subject: [PATCH 3/7] Group sources per question in Multi output instead of merging across all answers Signed-off-by: MichelleAntunes --- context_chat_backend/controller.py | 10 ++++---- context_chat_backend/task_fetcher.py | 37 ++++++++++++++++++---------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/context_chat_backend/controller.py b/context_chat_backend/controller.py index bac810b8..c48611dd 100644 --- a/context_chat_backend/controller.py +++ b/context_chat_backend/controller.py @@ -217,12 +217,12 @@ def enabled_handler(enabled: bool, nc: NextcloudApp | AsyncNextcloudApp) -> str: multi_tasktype = TaskType( id=MULTI_TASKTYPE_ID, name='Context Chat Multi', - description='Ask several questions about your data at once, one per line.', + description='Ask several questions about your data at once.', input_shape=[ ShapeDescriptor( - name='prompt', - description='Ask one or more questions about your documents, files and more. Put each question on its own line.', - shape_type=ShapeType.TEXT, + name='questions', + description='Ask one or more questions about your documents, files and more.', + shape_type=ShapeType.LIST_OF_TEXTS, ), ShapeDescriptor( name='scopeType', @@ -253,7 +253,7 @@ def enabled_handler(enabled: bool, nc: NextcloudApp | AsyncNextcloudApp) -> str: ), ShapeDescriptor( name='sources', - description='The sources referenced across all answers', + description='The sources referenced to generate this answer', shape_type=ShapeType.LIST_OF_TEXTS, ), ], diff --git a/context_chat_backend/task_fetcher.py b/context_chat_backend/task_fetcher.py index aa48e222..cfa649d1 100644 --- a/context_chat_backend/task_fetcher.py +++ b/context_chat_backend/task_fetcher.py @@ -558,11 +558,22 @@ def request_processing_thread(app_config: TConfig, get_enabled_state) -> None: }) elif task['type'] == 'context_chat:context_chat_multi': multi_result = process_multi_task(task, vectordb_loader, llm, app_config) + # enrich every source in a single API call, then regroup the + # enriched results back into one list per question + sources_per_question = multi_result['sources_per_question'] + counts = [len(group) for group in sources_per_question] + flat_sources = [source for group in sources_per_question for source in group] + enriched_flat = enrich_sources(flat_sources, userId) + grouped_sources: list[str] = [] + i = 0 + for count in counts: + grouped_sources.append('[' + ','.join(enriched_flat[i:i + count]) + ']') + i += count # Return result to Nextcloud success = return_result_to_nextcloud(task['id'], userId, { 'questions': multi_result['questions'], 'answers': multi_result['answers'], - 'sources': enrich_sources(multi_result['sources'], userId), + 'sources': grouped_sources, }) else: LOGGER.error(f'Unknown task type {task["type"]}') @@ -713,13 +724,12 @@ def process_normal_task( MAX_MULTI_QUESTIONS = 20 -def _split_questions(raw_prompt: str) -> list[str]: - """Split a multi-line prompt into individual, non-empty questions.""" - questions = [line.strip() for line in (raw_prompt or '').splitlines()] +def _normalize_questions(raw_questions: list[str] | None) -> list[str]: + """Clean up a list of questions: strip whitespace, drop empty entries, cap the count.""" + questions = [q.strip() for q in (raw_questions or [])] questions = [q for q in questions if q] return questions[:MAX_MULTI_QUESTIONS] - def process_multi_task( task: dict[str, Any], vectordb_loader: VectorDBLoader, @@ -748,13 +758,12 @@ def process_multi_task( if task_input.get('scopeType') == 'none': task_input['scopeType'] = None - questions = _split_questions(task_input.get('prompt')) + questions = _normalize_questions(task_input.get('questions')) if not questions: - raise ValueError('No questions found. Please provide at least one question, one per line.') + raise ValueError('No questions found. Please provide at least one questione.') answers: list[str] = [] - all_sources: list[SearchResult] = [] - seen_sources: set[str] = set() + sources_per_question: list[list[SearchResult]] = [] for question in questions: result: LLMOutput = exec_in_proc(target=process_context_query, @@ -771,20 +780,22 @@ def process_multi_task( ) ) answers.append(result['output']) + # de-duplicate sources within this single question's own answer + seen_sources: set[str] = set() + question_sources: list[SearchResult] = [] for source in result['sources']: - # avoid duplicate sources across answers while preserving order key = getattr(source, 'id', None) or str(source) if key not in seen_sources: seen_sources.add(key) - all_sources.append(source) + question_sources.append(source) + sources_per_question.append(question_sources) return { 'questions': questions, 'answers': answers, - 'sources': all_sources, + 'sources_per_question': sources_per_question, } - def process_search_task( task: dict[str, Any], vectordb_loader: VectorDBLoader, From efcc8a7cbd1e5f42740e678d29000929a1c1011f Mon Sep 17 00:00:00 2001 From: MichelleAntunes Date: Wed, 26 Aug 2026 10:58:06 +0200 Subject: [PATCH 4/7] Simplify Multi questions field placeholder text Signed-off-by: MichelleAntunes --- context_chat_backend/controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context_chat_backend/controller.py b/context_chat_backend/controller.py index c48611dd..523a0b24 100644 --- a/context_chat_backend/controller.py +++ b/context_chat_backend/controller.py @@ -221,7 +221,7 @@ def enabled_handler(enabled: bool, nc: NextcloudApp | AsyncNextcloudApp) -> str: input_shape=[ ShapeDescriptor( name='questions', - description='Ask one or more questions about your documents, files and more.', + description='Ask questions about your documents, files and more.', shape_type=ShapeType.LIST_OF_TEXTS, ), ShapeDescriptor( From 94462b95d94240a3ba4919849f9a178cd8ffcac6 Mon Sep 17 00:00:00 2001 From: MichelleAntunes Date: Wed, 26 Aug 2026 11:44:16 +0200 Subject: [PATCH 5/7] Add MultiOutput type for process_multi_task's return value Signed-off-by: MichelleAntunes --- context_chat_backend/chain/types.py | 7 +++++++ context_chat_backend/task_fetcher.py | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/context_chat_backend/chain/types.py b/context_chat_backend/chain/types.py index 3afdf297..f995e493 100644 --- a/context_chat_backend/chain/types.py +++ b/context_chat_backend/chain/types.py @@ -12,6 +12,7 @@ 'ContextException', 'InDocument', 'LLMOutput', + 'MultiOutput', 'ScopeType', ] @@ -43,6 +44,12 @@ class LLMOutput(TypedDict): sources: list[SearchResult] +class MultiOutput(TypedDict): + questions: list[str] + answers: list[str] + sources_per_question: list[list[SearchResult]] + + class EnrichedSource(BaseModel): id: str label: str diff --git a/context_chat_backend/task_fetcher.py b/context_chat_backend/task_fetcher.py index cfa649d1..901168a9 100644 --- a/context_chat_backend/task_fetcher.py +++ b/context_chat_backend/task_fetcher.py @@ -22,7 +22,7 @@ from .chain.context import do_doc_search from .chain.ingest.injest import embed_sources from .chain.one_shot import process_context_query -from .chain.types import ContextException, EnrichedSourceList, LLMOutput, ScopeList, SearchResult +from .chain.types import ContextException, EnrichedSourceList, LLMOutput, MultiOutput, ScopeList, SearchResult from .dyn_loader import LLMModelLoader, VectorDBLoader from .network_em import NetworkEmbeddings from .types import ( @@ -735,7 +735,7 @@ def process_multi_task( vectordb_loader: VectorDBLoader, llm: LLM, app_config: TConfig, -) -> dict[str, Any]: +) -> MultiOutput: """ Process a task containing several questions (one per line), answering each sequentially against the same scope, and collecting all results. From b7670f0d67bcf9ac64d9acbb8a3b8e34033f1c91 Mon Sep 17 00:00:00 2001 From: MichelleAntunes Date: Wed, 26 Aug 2026 13:19:31 +0200 Subject: [PATCH 6/7] Deduplicate MAX_MULTI_QUESTIONS declaration, single source in task_fetcher.py Signed-off-by: MichelleAntunes --- context_chat_backend/controller.py | 3 +-- context_chat_backend/task_fetcher.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/context_chat_backend/controller.py b/context_chat_backend/controller.py index 523a0b24..de98b96b 100644 --- a/context_chat_backend/controller.py +++ b/context_chat_backend/controller.py @@ -37,7 +37,7 @@ from .models.types import LlmException from nc_py_api.ex_app import AppAPIAuthMiddleware from .utils import JSONResponse, exec_in_proc, get_app_role, is_k8s_env -from .task_fetcher import THREAD_STOP_EVENT, start_bg_threads, trigger_handler, wait_for_bg_threads +from .task_fetcher import MAX_MULTI_QUESTIONS, THREAD_STOP_EVENT, start_bg_threads, trigger_handler, wait_for_bg_threads from .vectordb.service import count_documents_by_provider # setup @@ -101,7 +101,6 @@ def get_enabled_state() -> bool: return app_enabled.is_set() -MAX_MULTI_QUESTIONS = 20 def enabled_handler(enabled: bool, nc: NextcloudApp | AsyncNextcloudApp) -> str: diff --git a/context_chat_backend/task_fetcher.py b/context_chat_backend/task_fetcher.py index 901168a9..6643c82c 100644 --- a/context_chat_backend/task_fetcher.py +++ b/context_chat_backend/task_fetcher.py @@ -62,6 +62,7 @@ TP_CHECK_INTERVAL_WITH_TRIGGER = 5 * 60 TP_CHECK_INTERVAL_ON_ERROR = 15 CONTEXT_LIMIT = 30 +MAX_MULTI_QUESTIONS = 20 class ThreadType(Enum): @@ -720,8 +721,7 @@ def process_normal_task( ) ) -# Keep in sync with MAX_MULTI_QUESTIONS in controller.py -MAX_MULTI_QUESTIONS = 20 + def _normalize_questions(raw_questions: list[str] | None) -> list[str]: From 4bbbd134edf01a62c61de7146b45012208970e86 Mon Sep 17 00:00:00 2001 From: MichelleAntunes Date: Wed, 26 Aug 2026 13:27:22 +0200 Subject: [PATCH 7/7] Expose MAX_MULTI_QUESTIONS to frontend via input_shape_defaults Signed-off-by: MichelleAntunes --- context_chat_backend/controller.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/context_chat_backend/controller.py b/context_chat_backend/controller.py index de98b96b..61d53e66 100644 --- a/context_chat_backend/controller.py +++ b/context_chat_backend/controller.py @@ -264,6 +264,9 @@ def enabled_handler(enabled: bool, nc: NextcloudApp | AsyncNextcloudApp) -> str: task_type=MULTI_TASKTYPE_ID, # multiple sequential LLM calls, so allow more time than a single question expected_runtime=30 * MAX_MULTI_QUESTIONS, + input_shape_defaults={ + 'maxQuestions': MAX_MULTI_QUESTIONS, + }, ) nc.providers.task_processing.register(multi_provider, multi_tasktype)