Skip to content

Commit 6ac5256

Browse files
committed
feat: implement background processing for binary attachments in Obsidian plugin
- Added a new Celery task for indexing non-markdown attachments. - Enhanced the Obsidian plugin schema to support binary attachments. - Updated routes to enqueue binary attachments for background processing. - Improved metadata handling for binary attachments during indexing. - Added tests for binary attachment processing and validation.
1 parent 5047527 commit 6ac5256

11 files changed

Lines changed: 519 additions & 68 deletions

File tree

surfsense_backend/app/celery_app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def parse_schedule_interval(interval: str) -> dict:
9090
"app.tasks.celery_tasks.podcast_tasks",
9191
"app.tasks.celery_tasks.video_presentation_tasks",
9292
"app.tasks.celery_tasks.connector_tasks",
93+
"app.tasks.celery_tasks.obsidian_tasks",
9394
"app.tasks.celery_tasks.schedule_checker_task",
9495
"app.tasks.celery_tasks.document_reindex_tasks",
9596
"app.tasks.celery_tasks.stale_notification_cleanup_task",
@@ -153,6 +154,7 @@ def parse_schedule_interval(interval: str) -> dict:
153154
"index_crawled_urls": {"queue": CONNECTORS_QUEUE},
154155
"index_bookstack_pages": {"queue": CONNECTORS_QUEUE},
155156
"index_composio_connector": {"queue": CONNECTORS_QUEUE},
157+
"index_obsidian_attachment": {"queue": CONNECTORS_QUEUE},
156158
# Everything else (document processing, podcasts, reindexing,
157159
# schedule checker, cleanup) stays on the default fast queue.
158160
},

surfsense_backend/app/routes/obsidian_plugin_routes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
rename_note,
5050
upsert_note,
5151
)
52+
from app.tasks.celery_tasks.obsidian_tasks import index_obsidian_attachment_task
5253
from app.users import current_active_user
5354

5455
logger = logging.getLogger(__name__)
@@ -204,6 +205,17 @@ async def _resolve_vault_connector(
204205
)
205206

206207

208+
def _queue_obsidian_attachment(
209+
*, connector_id: int, note_payload: dict, user_id: str
210+
) -> None:
211+
"""Enqueue one non-markdown Obsidian note for background ETL/indexing."""
212+
index_obsidian_attachment_task.delay(
213+
connector_id=connector_id,
214+
payload_data=note_payload,
215+
user_id=user_id,
216+
)
217+
218+
207219
async def _ensure_search_space_access(
208220
session: AsyncSession,
209221
*,
@@ -452,6 +464,16 @@ async def obsidian_sync(
452464

453465
for note in payload.notes:
454466
try:
467+
if note.is_binary:
468+
_queue_obsidian_attachment(
469+
connector_id=connector.id,
470+
note_payload=note.model_dump(mode="json"),
471+
user_id=str(user.id),
472+
)
473+
indexed += 1
474+
items.append(SyncAckItem(path=note.path, status="queued"))
475+
continue
476+
455477
doc = await upsert_note(
456478
session, connector=connector, payload=note, user_id=str(user.id)
457479
)

surfsense_backend/app/schemas/obsidian_plugin.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ class NotePayload(_PluginBase):
5252
content_hash: str = Field(
5353
..., description="Plugin-computed SHA-256 of the raw content"
5454
)
55+
is_binary: bool = Field(
56+
default=False,
57+
description=(
58+
"True when payload represents a non-markdown attachment. "
59+
"If set, the plugin may include binary_base64 for ETL extraction."
60+
),
61+
)
62+
binary_base64: str | None = Field(
63+
default=None,
64+
description=(
65+
"Base64-encoded raw file bytes for binary attachments. "
66+
"Used by the backend ETL pipeline."
67+
),
68+
)
69+
mime_type: str | None = Field(
70+
default=None,
71+
description="Optional MIME type hint for binary attachments.",
72+
)
5573
size: int | None = Field(
5674
default=None,
5775
ge=0,
@@ -138,7 +156,7 @@ class HealthResponse(_PluginBase):
138156

139157
class SyncAckItem(_PluginBase):
140158
path: str
141-
status: Literal["ok", "error"]
159+
status: Literal["ok", "queued", "error"]
142160
document_id: int | None = None
143161
error: str | None = None
144162

surfsense_backend/app/services/obsidian_plugin_indexer.py

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232

3333
from __future__ import annotations
3434

35+
import base64
36+
import contextlib
3537
import logging
38+
import os
39+
import tempfile
3640
from datetime import UTC, datetime
3741
from typing import Any
3842
from urllib.parse import quote
@@ -113,19 +117,26 @@ def _build_metadata(
113117
"connector_id": connector_id,
114118
"url": _build_source_url(vault_name, payload.path),
115119
}
120+
if payload.is_binary:
121+
meta["is_binary"] = True
122+
if payload.mime_type:
123+
meta["mime_type"] = payload.mime_type
116124
if extra:
117125
meta.update(extra)
118126
return meta
119127

120128

121-
def _build_document_string(payload: NotePayload, vault_name: str) -> str:
129+
def _build_document_string(
130+
payload: NotePayload, vault_name: str, *, content_override: str | None = None
131+
) -> str:
122132
"""Compose the indexable string the pipeline embeds and chunks.
123133
124134
Mirrors the legacy obsidian indexer's METADATA + CONTENT framing so
125135
existing search relevance heuristics keep working unchanged.
126136
"""
127137
tags_line = ", ".join(payload.tags) if payload.tags else "None"
128138
links_line = ", ".join(payload.resolved_links) if payload.resolved_links else "None"
139+
body = payload.content if content_override is None else content_override
129140
return (
130141
"<METADATA>\n"
131142
f"Title: {payload.name}\n"
@@ -135,11 +146,120 @@ def _build_document_string(payload: NotePayload, vault_name: str) -> str:
135146
f"Links to: {links_line}\n"
136147
"</METADATA>\n\n"
137148
"<CONTENT>\n"
138-
f"{payload.content}\n"
149+
f"{body}\n"
139150
"</CONTENT>\n"
140151
)
141152

142153

154+
async def _extract_binary_attachment_markdown(
155+
payload: NotePayload, *, vision_llm
156+
) -> tuple[str, dict[str, Any]]:
157+
if not payload.binary_base64:
158+
return "", {"attachment_extraction_status": "missing_binary_payload"}
159+
160+
try:
161+
raw_bytes = base64.b64decode(payload.binary_base64, validate=True)
162+
except Exception:
163+
logger.warning("obsidian attachment payload had invalid base64: %s", payload.path)
164+
return "", {"attachment_extraction_status": "invalid_binary_payload"}
165+
166+
suffix = f".{payload.extension.lstrip('.')}" if payload.extension else ""
167+
temp_path: str | None = None
168+
filename = payload.path.rsplit("/", 1)[-1] or payload.name
169+
try:
170+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
171+
tmp.write(raw_bytes)
172+
temp_path = tmp.name
173+
174+
result = await _run_etl_extract(
175+
file_path=temp_path,
176+
filename=filename,
177+
vision_llm=vision_llm,
178+
)
179+
metadata: dict[str, Any] = {
180+
"attachment_extraction_status": "ok",
181+
"attachment_etl_service": result.etl_service,
182+
"attachment_content_type": result.content_type,
183+
}
184+
return result.markdown_content, metadata
185+
except Exception as exc:
186+
logger.warning(
187+
"obsidian attachment ETL failed for %s: %s", payload.path, exc, exc_info=True
188+
)
189+
return "", {
190+
"attachment_extraction_status": "etl_failed",
191+
"attachment_extraction_error": str(exc)[:300],
192+
}
193+
finally:
194+
if temp_path and os.path.exists(temp_path):
195+
with contextlib.suppress(Exception):
196+
os.unlink(temp_path)
197+
198+
199+
async def _run_etl_extract(*, file_path: str, filename: str, vision_llm):
200+
"""Lazy-load ETL dependencies to avoid module-import cycles."""
201+
from app.etl_pipeline.etl_document import EtlRequest
202+
from app.etl_pipeline.etl_pipeline_service import EtlPipelineService
203+
204+
return await EtlPipelineService(vision_llm=vision_llm).extract(
205+
EtlRequest(file_path=file_path, filename=filename)
206+
)
207+
208+
209+
def _is_image_attachment(payload: NotePayload) -> bool:
210+
ext = payload.extension.lower().lstrip(".")
211+
return ext in {"png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "svg"}
212+
213+
214+
async def _resolve_attachment_vision_llm(
215+
session: AsyncSession,
216+
*,
217+
connector: SearchSourceConnector,
218+
search_space_id: int,
219+
payload: NotePayload,
220+
):
221+
"""Match connector indexers: only fetch vision LLM for image attachments
222+
when the connector has vision indexing enabled."""
223+
if not payload.is_binary:
224+
return None
225+
if not _is_image_attachment(payload):
226+
return None
227+
if not getattr(connector, "enable_vision_llm", False):
228+
return None
229+
230+
from app.services.llm_service import get_vision_llm
231+
232+
return await get_vision_llm(session, search_space_id)
233+
234+
235+
async def _resolve_summary_llm(
236+
session: AsyncSession, *, user_id: str, search_space_id: int, should_summarize: bool
237+
):
238+
"""Fetch summary LLM only when indexing summary is enabled."""
239+
if not should_summarize:
240+
return None
241+
242+
from app.services.llm_service import get_user_long_context_llm
243+
244+
return await get_user_long_context_llm(session, user_id, search_space_id)
245+
246+
247+
def _require_extracted_attachment_content(
248+
*, content: str, etl_meta: dict[str, Any], path: str
249+
) -> str:
250+
extracted = content.strip()
251+
if extracted:
252+
return extracted
253+
254+
status = etl_meta.get("attachment_extraction_status", "unknown")
255+
reason = etl_meta.get("attachment_extraction_error")
256+
if reason:
257+
raise RuntimeError(
258+
f"Attachment extraction failed for {path} ({status}): {reason}"
259+
)
260+
raise RuntimeError(f"Attachment extraction failed for {path} ({status})")
261+
262+
143263
async def _find_existing_document(
144264
session: AsyncSession,
145265
*,
@@ -207,11 +327,42 @@ async def upsert_note(
207327
exc_info=True,
208328
)
209329

210-
document_string = _build_document_string(payload, vault_name)
330+
content_for_index = payload.content
331+
extra_meta: dict[str, Any] = {}
332+
vision_llm = None
333+
if payload.is_binary:
334+
vision_llm = await _resolve_attachment_vision_llm(
335+
session,
336+
connector=connector,
337+
search_space_id=search_space_id,
338+
payload=payload,
339+
)
340+
content_for_index, etl_meta = await _extract_binary_attachment_markdown(
341+
payload, vision_llm=vision_llm
342+
)
343+
extra_meta.update(etl_meta)
344+
# Strict KB behavior: do not index metadata-only attachments.
345+
content_for_index = _require_extracted_attachment_content(
346+
content=content_for_index,
347+
etl_meta=etl_meta,
348+
path=payload.path,
349+
)
350+
351+
llm = await _resolve_summary_llm(
352+
session,
353+
user_id=str(user_id),
354+
search_space_id=search_space_id,
355+
should_summarize=connector.enable_summary,
356+
)
357+
358+
document_string = _build_document_string(
359+
payload, vault_name, content_override=content_for_index
360+
)
211361
metadata = _build_metadata(
212362
payload,
213363
vault_name=vault_name,
214364
connector_id=connector.id,
365+
extra=extra_meta,
215366
)
216367

217368
connector_doc = ConnectorDocument(
@@ -223,7 +374,7 @@ async def upsert_note(
223374
connector_id=connector.id,
224375
created_by_id=str(user_id),
225376
should_summarize=connector.enable_summary,
226-
fallback_summary=f"Obsidian Note: {payload.name}\n\n{payload.content}",
377+
fallback_summary=f"Obsidian Note: {payload.name}\n\n{content_for_index}",
227378
metadata=metadata,
228379
)
229380

@@ -236,9 +387,6 @@ async def upsert_note(
236387

237388
document = prepared[0]
238389

239-
from app.services.llm_service import get_user_long_context_llm
240-
241-
llm = await get_user_long_context_llm(session, str(user_id), search_space_id)
242390
return await pipeline.index(document, connector_doc, llm)
243391

244392

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Celery tasks for Obsidian plugin background processing."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
8+
from app.celery_app import celery_app
9+
from app.db import SearchSourceConnector
10+
from app.schemas.obsidian_plugin import NotePayload
11+
from app.services.obsidian_plugin_indexer import upsert_note
12+
from app.tasks.celery_tasks import get_celery_session_maker
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
@celery_app.task(name="index_obsidian_attachment", bind=True)
18+
def index_obsidian_attachment_task(
19+
self,
20+
connector_id: int,
21+
payload_data: dict,
22+
user_id: str,
23+
) -> None:
24+
"""Process one Obsidian non-markdown attachment asynchronously."""
25+
loop = asyncio.new_event_loop()
26+
asyncio.set_event_loop(loop)
27+
try:
28+
loop.run_until_complete(
29+
_index_obsidian_attachment(
30+
connector_id=connector_id,
31+
payload_data=payload_data,
32+
user_id=user_id,
33+
)
34+
)
35+
finally:
36+
loop.close()
37+
38+
39+
async def _index_obsidian_attachment(
40+
*,
41+
connector_id: int,
42+
payload_data: dict,
43+
user_id: str,
44+
) -> None:
45+
async with get_celery_session_maker()() as session:
46+
connector = await session.get(SearchSourceConnector, connector_id)
47+
if connector is None:
48+
logger.warning(
49+
"obsidian attachment task skipped: connector %s not found", connector_id
50+
)
51+
return
52+
53+
payload = NotePayload.model_validate(payload_data)
54+
await upsert_note(
55+
session,
56+
connector=connector,
57+
payload=payload,
58+
user_id=user_id,
59+
)

0 commit comments

Comments
 (0)