Skip to content

Commit 0c8b47e

Browse files
committed
Add ocr task type and add integration test for multimodality
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
1 parent 9aebdad commit 0c8b47e

5 files changed

Lines changed: 112 additions & 1 deletion

File tree

.github/workflows/integration_test.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,51 @@ jobs:
214214
[ "$TASK_STATUS" == '"STATUS_SUCCESSFUL"' ]
215215
[ $STREAMING_UPDATES -gt 0 ]
216216
217+
# core:image2text:ocr exists since Nextcloud 33
218+
- name: Run multimodal OCR task
219+
if: contains(fromJSON('["master", "stable34", "stable33"]'), matrix.server-versions)
220+
env:
221+
CREDS: "admin:password"
222+
EXPECTED_TEXT: "Nextcloud Image"
223+
run: |
224+
set -x
225+
IMAGE_PATH="${{ env.APP_NAME }}/tests/multimodal-ocr-text.png"
226+
REMOTE_PATH="multimodal-ocr-text.png"
227+
228+
curl -u "$CREDS" -T "$IMAGE_PATH" "http://localhost:8080/remote.php/dav/files/admin/$REMOTE_PATH"
229+
230+
FILE_ID=$(curl -s -u "$CREDS" -X PROPFIND \
231+
-H "Depth: 0" \
232+
-H "Content-Type: application/xml" \
233+
--data '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><oc:fileid/></d:prop></d:propfind>' \
234+
"http://localhost:8080/remote.php/dav/files/admin/$REMOTE_PATH" \
235+
| tr '\n' ' ' \
236+
| grep -oE '<oc:fileid>[0-9]+</oc:fileid>' \
237+
| head -1 \
238+
| grep -oE '[0-9]+')
239+
echo "Uploaded image file id: $FILE_ID"
240+
[ -n "$FILE_ID" ]
241+
242+
TASK=$(curl -X POST -u "$CREDS" -H "oCS-APIRequest: true" -H "Content-type: application/json" \
243+
"http://localhost:8080/ocs/v2.php/taskprocessing/schedule?format=json" \
244+
--data-raw "{\"input\": {\"input\": [$FILE_ID]}, \"type\": \"core:image2text:ocr\", \"appId\": \"test\", \"customId\": \"\"}")
245+
echo $TASK
246+
TASK_ID=$(echo $TASK | jq '.ocs.data.task.id')
247+
NEXT_WAIT_TIME=0
248+
TASK_STATUS='"STATUS_SCHEDULED"'
249+
until [ $NEXT_WAIT_TIME -eq 35 ] || [ "$TASK_STATUS" == '"STATUS_SUCCESSFUL"' ] || [ "$TASK_STATUS" == '"STATUS_FAILED"' ]; do
250+
TASK=$(curl -u "$CREDS" -H "oCS-APIRequest: true" "http://localhost:8080/ocs/v2.php/taskprocessing/task/$TASK_ID?format=json")
251+
echo $TASK
252+
TASK_STATUS=$(echo $TASK | jq '.ocs.data.task.status')
253+
echo $TASK_STATUS
254+
sleep $(( NEXT_WAIT_TIME++ ))
255+
done
256+
curl -u "$CREDS" -H "oCS-APIRequest: true" "http://localhost:8080/ocs/v2.php/taskprocessing/task/$TASK_ID?format=json"
257+
[ "$TASK_STATUS" == '"STATUS_SUCCESSFUL"' ]
258+
TASK_OUTPUT=$(echo $TASK | jq -r '.ocs.data.task.output.output | join("\n")')
259+
echo "Model output: $TASK_OUTPUT"
260+
echo "$TASK_OUTPUT" | grep -qi "$EXPECTED_TEXT"
261+
217262
- name: Show logs
218263
if: always()
219264
run: |

lib/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ async def enabled_handler(enabled: bool, nc: AsyncNextcloudApp) -> str:
373373
optional_input_defaults=get_optional_input_defaults(task),
374374
optional_output_shape=[
375375
ShapeDescriptor(name="reasoning", description="Reasoning trace produced by the model, if any", shape_type=ShapeType.TEXT)
376-
] if task != "core:text2text:summary" else [],
376+
] if task != "core:text2text:summary" and task != "core:image2text:ocr" else [],
377377
)
378378
await nc.providers.task_processing.register(provider)
379379
await log(nc, LogLvl.INFO, f"Registered {task_processor_name}")

lib/ocr.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
"""Processor for core:image2text:ocr — vision-based OCR."""
4+
from typing import Any
5+
6+
from langchain_core.messages import HumanMessage, SystemMessage
7+
from langchain_core.runnables import Runnable
8+
9+
from task_files import fetch_file_bytes
10+
from streaming import StreamContext, run_runnable_with_streaming
11+
from multimodal_chatwithtools import MAX_ATTACHMENTS_COUNT
12+
13+
14+
class OcrProcessor:
15+
"""Extract text from one or more images using a vision-capable chat model."""
16+
17+
runnable: Runnable
18+
system_prompt: str = (
19+
"You're an OCR assistant. "
20+
"Extract all text visible in the provided image. "
21+
"Output only the extracted text, nothing else."
22+
)
23+
user_prompt: str = "Extract all text from this image. Reply with only the extracted text."
24+
25+
def __init__(self, runnable: Runnable):
26+
self.runnable = runnable
27+
28+
async def __call__(
29+
self,
30+
inputs: dict[str, Any],
31+
context: StreamContext | None = None,
32+
) -> dict[str, Any]:
33+
if context is None or context.nc is None:
34+
raise ValueError("StreamContext with Nextcloud client is required for OCR")
35+
36+
files = inputs.get("input") or []
37+
if not files:
38+
raise ValueError("core:image2text:ocr requires at least one file")
39+
if len(files) > MAX_ATTACHMENTS_COUNT:
40+
raise ValueError(f"Too many files (max {MAX_ATTACHMENTS_COUNT})")
41+
42+
texts: list[str] = []
43+
for file_id in files:
44+
fetched = await fetch_file_bytes(context.nc, file_id)
45+
if not fetched["mime"].startswith("image/"):
46+
raise ValueError(f"File MIME type {fetched['mime']} is not supported for OCR")
47+
48+
output = await run_runnable_with_streaming(
49+
self.runnable,
50+
[
51+
SystemMessage(self.system_prompt),
52+
HumanMessage(content=[
53+
{"type": "text", "text": self.user_prompt},
54+
{"type": "image_url", "image_url": {"url": fetched["data_url"]}},
55+
]),
56+
],
57+
context,
58+
stream_payload_transform=lambda partial: {"output": [*texts, partial]},
59+
)
60+
texts.append(output)
61+
62+
return {
63+
"output": texts,
64+
}

lib/task_processors.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from summarize import SummarizeProcessor
3333
from reformat_paragraphs import ReformatParagraphsProcessor
3434
from analyze_images import AnalyzeImagesProcessor
35+
from ocr import OcrProcessor
3536
from multimodal_chatwithtools import MultimodalChatWithToolsProcessor
3637

3738
dir_path = os.path.dirname(os.path.realpath(__file__))
@@ -263,3 +264,4 @@ def generate_task_processors_for_model(file_name, task_processors):
263264
task_processors[model_name + ":core:text2text:reformatparagraphs"] = lambda: ReformatParagraphsProcessor(generate_chat_model(file_name))
264265
if "vision" in modalities:
265266
task_processors[model_name + ":core:analyze-images"] = lambda: AnalyzeImagesProcessor(generate_chat_model(file_name))
267+
task_processors[model_name + ":core:image2text:ocr"] = lambda: OcrProcessor(generate_chat_model(file_name))

tests/multimodal-ocr-text.png

11.5 KB
Loading

0 commit comments

Comments
 (0)