3232
3333from __future__ import annotations
3434
35+ import base64
36+ import contextlib
3537import logging
38+ import os
39+ import tempfile
3640from datetime import UTC , datetime
3741from typing import Any
3842from 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+
143263async 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
0 commit comments