Skip to content

feat(file-action): summarize a whole file by sending it to the AI provider - #616

Open
cbcoutinho wants to merge 2 commits into
nextcloud:mainfrom
cbcoutinho:feat/summarize-file-attachment
Open

feat(file-action): summarize a whole file by sending it to the AI provider#616
cbcoutinho wants to merge 2 commits into
nextcloud:mainfrom
cbcoutinho:feat/summarize-file-attachment

Conversation

@cbcoutinho

Copy link
Copy Markdown

Summary

Today the Summarize file action never gives the LLM the file. TaskProcessingService::runFileAction extracts the text with AssistantService::parseTextFromFile and sends that string to core:text2text:summary, whose input slot is EShapeType::Text — capped at 512 kB by core.

That extraction is the weak link:

  • smalot/pdfparser returns nothing for scanned PDFs and garbage for complex layouts (cf. AI summarization doesn't work for the PDFs shipping with Nextcloud ("Nextcloud Manual.pdf") #499, where the reporter's own summary was "the PDF parser is not reliable enough for in-the-wild PDFs")
  • PhpWord drops tables, headers and footers
  • the default: branch returns raw bytes as "text" for unsupported binaries
  • any file whose extracted text exceeds 512 kB fails validation outright, surfacing as "Failed to launch the AI file action"

When the configured provider can accept files, Summarize now hands over the document untouched and lets the model read it.

How it works

A provider advertises an optional input_attachments (ListOfFiles) slot. The new AttachmentService feature-detects it via getAvailableTaskTypes()[…]['optionalInputShape'], exactly like the existing memories pattern, and runFileAction then sends:

['input' => '', 'input_attachments' => [$fileId]]

The mandatory text input stays empty on purpose: the provider builds its own instructions around the attachment, and anything we put there would be untranslated and fight that prompt. Core accepts '' for a mandatory Text slot, and this matches the convention already used by MultimodalChatWithToolsProvider.

Nothing changes until a provider advertises the slot, so this is inert on its own.

Eligibility is deliberately narrow

Type Behaviour Why
application/pdf always attached this is the actual pain point
text/plain, text/markdown, text/csv attached only above 256 kB extraction is lossless and cheap below that; above it, the extracted text risks blowing core's 512 kB Text cap, which fails today
doc / docx / odt / rtf never attached providers reject these outright; only our own parsers can read them
anything > 50 MB never attached mirrors the provider-side ceiling, so we fail fast rather than after a 50 MB base64 round-trip

All three thresholds are readable from app config.

Also included

A drive-by fix in the two file-action listeners, which both used getFirstNodeById() without a null check. That returns null when the source file is moved or deleted while the task runs — increasingly likely as tasks get longer.

In FileActionTaskSuccessfulListener this was worse than a plain fatal: the null deref happened inside the try, and the catch handler then dereferenced the same node again to send a notification. That second throw escaped the handler and aborted the whole TaskSuccessfulEvent dispatch, so unrelated listeners never ran.

Depends on

nextcloud/integration_openai#419, which adds the input_attachments slot to SummaryProvider (and fixes document attachments on Mistral). This PR is safe to merge first — without a provider that advertises the slot, behaviour is byte-identical to today.

Relationship to #602

No overlap. #602 covers chat attachments via MultimodalChatWithTools; this covers the Files → Summarize action, which #602 does not touch (no TaskProcessingService, no fileActions.js). The only shared surface is the initial-state listener.

Testing

tests/unit/Service/AttachmentServiceTest.php — 21 tests covering the capability-detection matrix (slot absent / correct ListOfFiles / wrong shape type / manager throwing) and the full eligibility matrix. Passing locally.

Gates run locally: lint, cs:check (0/98), psalm (no errors), composer run openapi (openapi.json byte-identical — no API surface changed), eslint, npm run build.

Manual E2E still to do against a live provider: scanned PDF, Nextcloud Manual.pdf (#499's reproducer), a 2 MB .md, .docx still falling back to extraction, and a backend without document support falling back cleanly.


This PR was generated with the help of AI, and reviewed by a Human

Both file action listeners looked the source file up with
getFirstNodeById() and used the result without checking it. That call
returns null when the file was moved or deleted while the task ran,
which is easy to hit now that tasks can take a while.

In the successful listener this was worse than a plain fatal: the null
deref happened inside the try block, and the catch handler then
dereferenced the same node again to send a notification. That second
throw escaped the handler and aborted the whole TaskSuccessfulEvent
dispatch, so unrelated listeners never ran.

Bail out with a warning in both listeners instead.

Signed-off-by: Chris Coutinho <chris@coutinho.io>
The summarize file action always extracted the text itself and passed
the result to core:text2text:summary as a Text input. That extraction is
the weak part of the flow: the PDF parser returns nothing for scanned
documents and garbage for complex layouts, and core caps Text inputs at
512 kB, so a large file fails outright before it ever reaches a model.

Providers can now advertise an optional input_attachments slot, and when
one does we hand it the file untouched instead. The mandatory text input
stays empty in that case; the provider builds its own instructions
around the attachment, and anything we put there would be untranslated
and fight that prompt.

Eligibility is deliberately narrow. PDFs always go to the provider.
Text, markdown and csv keep using local extraction unless they are big
enough that the extracted text would not fit in a Text input. Office
formats are never attached, since providers reject them and only our own
parsers can read them. The thresholds are readable from app config.

Nothing changes when no provider advertises the slot, so this is inert
until a provider supports it.

Signed-off-by: Chris Coutinho <chris@coutinho.io>
@cbcoutinho

Copy link
Copy Markdown
Author

One caveat surfaced while verifying the provider side (nextcloud/integration_openai#419) against live endpoints, because it affects what this action produces rather than whether it works.

Some backends drop a document attachment without erroring, and the model then answers from nothing. Tested with a 659-byte PDF containing one invented fact ("annual rainfall in Zenda is 812 millimetres"), so a wrong answer is unambiguous:

Backend Result (correct = 812)
Mistral 812
OpenRouter → OpenAI 812
OpenRouter → Azure 600, 500 ❌ silently fabricated
OpenRouter → Azure, with the file-parser plugin 812

Unpinned, the same OpenRouter request alternated between routes and answers.

This is not caused by either PR — it is how those upstreams handle the (unchanged) OpenAI type: file part. But it is worth knowing here, because a fabricated summary is indistinguishable from a real one in <name> - summarized.md, whereas today's extraction path at least fails loudly.

It does not change this PR's design. The attachment path is only taken when a provider advertises input_attachments, so the fix belongs provider-side: either not advertising the slot for backends known to drop documents, or forcing pre-parsing. Details and a no-code-change admin workaround are in nextcloud/integration_openai#419.


This comment was generated with the help of AI, and reviewed by a Human

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant