Coverage for src/local_deep_research/web_search_engines/engines/search_engine_paperless.py: 92%

312 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1""" 

2Paperless-ngx search engine implementation for Local Deep Research. 

3 

4This module provides a proper search engine implementation that connects to a Paperless-ngx 

5instance, allowing LDR to search and retrieve documents from your personal 

6document management system. 

7""" 

8 

9import re 

10from typing import Any, Dict, List, Optional 

11import requests 

12from urllib.parse import urljoin 

13 

14from langchain_core.language_models import BaseLLM 

15from ...security.secure_logging import logger 

16 

17from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

18from ...security import redact_url_for_log, safe_get 

19 

20 

21class PaperlessSearchEngine(BaseSearchEngine): 

22 """Paperless-ngx search engine implementation with full LDR integration.""" 

23 

24 is_local = True 

25 is_lexical = True 

26 needs_llm_relevance_filter = True 

27 # Egress (ADR-0007): a local document store — sensitive, contained. The 

28 # url_setting fail-up reclassifies exposure to EXPOSING when api_url is a 

29 # public host (quadrant 4: usable only by itself, contained inference). 

30 egress_sensitivity = Sensitivity.SENSITIVE 

31 egress_exposure = Exposure.CONTAINED 

32 # secrets to redact from error messages (see BaseSearchEngine._scrub_error) 

33 _secret_attrs = ("api_token",) 

34 # url_setting feeds the PDP's fail-up URL override: if the configured 

35 # api_url resolves to a PUBLIC host, the engine is reclassified public 

36 # so PRIVATE_ONLY denies it at selection time (queries would leave the 

37 # box). A local api_url keeps the static is_local classification — 

38 # the override only ever tightens, never relaxes. 

39 url_setting = "search.engine.web.paperless.default_params.api_url" 

40 

41 # Class constants for magic numbers 

42 MAX_SNIPPET_LENGTH = 3000 # Reasonable limit to avoid context window issues 

43 SNIPPET_CONTEXT_BEFORE = 500 # Characters before matched term in snippet 

44 SNIPPET_CONTEXT_AFTER = 2500 # Characters after matched term in snippet 

45 

46 def __init__( 

47 self, 

48 api_url: str | None = None, 

49 api_key: str | None = None, 

50 api_token: str 

51 | None = None, # Support both for backwards compatibility 

52 max_results: int = 10, 

53 timeout: int = 30, 

54 verify_ssl: bool = True, 

55 include_content: bool = True, 

56 llm: Optional[BaseLLM] = None, 

57 settings_snapshot: Optional[Dict[str, Any]] = None, 

58 **kwargs, 

59 ): 

60 """ 

61 Initialize the Paperless-ngx search engine. 

62 

63 Args: 

64 api_url: Base URL of Paperless-ngx instance (e.g., "http://localhost:8000") 

65 If not provided, will look for PAPERLESS_API_URL env var 

66 api_key: API token for authentication (preferred parameter name) 

67 api_token: API token for authentication (backwards compatibility) 

68 If not provided, will look for PAPERLESS_API_TOKEN env var 

69 max_results: Maximum number of search results 

70 timeout: Request timeout in seconds 

71 verify_ssl: Whether to verify SSL certificates 

72 include_content: Whether to include document content in results 

73 llm: Language model for relevance filtering (optional) 

74 settings_snapshot: Settings snapshot for thread context 

75 **kwargs: Additional parameters passed to parent 

76 """ 

77 super().__init__( 

78 max_results=max_results, 

79 llm=llm, 

80 settings_snapshot=settings_snapshot, 

81 **kwargs, 

82 ) 

83 

84 # Use provided configuration or get from settings 

85 self.api_url = api_url 

86 # Support both api_key and api_token for compatibility 

87 self.api_token = api_key or api_token 

88 

89 # If no API URL provided, try to get from settings_snapshot 

90 if not self.api_url and settings_snapshot: 

91 self.api_url = settings_snapshot.get( 

92 "search.engine.web.paperless.default_params.api_url", 

93 "http://localhost:8000", 

94 ) 

95 

96 # If no API token provided, try to get from settings_snapshot 

97 if not self.api_token and settings_snapshot: 

98 self.api_token = settings_snapshot.get( 

99 "search.engine.web.paperless.api_key", "" 

100 ) 

101 

102 # Fix AttributeError: Check if api_url is None before calling rstrip 

103 if self.api_url: 

104 # Remove trailing slash from API URL 

105 self.api_url = self.api_url.rstrip("/") 

106 else: 

107 # Default to localhost if nothing provided 

108 self.api_url = "http://localhost:8000" 

109 logger.warning( 

110 "No Paperless API URL provided, using default: http://localhost:8000" 

111 ) 

112 

113 self.timeout = timeout 

114 self.verify_ssl = verify_ssl 

115 self.include_content = include_content 

116 

117 # Set up headers for authentication 

118 self.headers = {} 

119 if self.api_token: 

120 self.headers["Authorization"] = f"Token {self.api_token}" 

121 

122 logger.info( 

123 f"Initialized Paperless-ngx search engine for {redact_url_for_log(self.api_url)}" 

124 ) 

125 

126 def _make_request( 

127 self, endpoint: str, params: Optional[Dict] = None 

128 ) -> Dict[str, Any]: 

129 """ 

130 Make a request to the Paperless-ngx API. 

131 

132 Args: 

133 endpoint: API endpoint path 

134 params: Query parameters 

135 

136 Returns: 

137 JSON response from the API 

138 """ 

139 url = urljoin(self.api_url or "", endpoint) 

140 

141 logger.debug(f"Making request to: {redact_url_for_log(url)}") 

142 logger.debug(f"Request params: {params}") 

143 logger.debug( 

144 f"Headers: {self.headers.keys() if self.headers else 'None'}" 

145 ) 

146 

147 try: 

148 # Paperless is typically a local/private network service 

149 response = safe_get( 

150 url, 

151 params=params, 

152 headers=self.headers, 

153 timeout=self.timeout, 

154 verify=self.verify_ssl, 

155 allow_private_ips=True, 

156 allow_localhost=True, 

157 ) 

158 response.raise_for_status() 

159 result = response.json() 

160 

161 # Log response details 

162 if isinstance(result, dict): 162 ↛ 180line 162 didn't jump to line 180 because the condition on line 162 was always true

163 if "results" in result: 

164 logger.info( 

165 f"API returned {len(result.get('results', []))} results, total count: {result.get('count', 'unknown')}" 

166 ) 

167 # Log first result details if available 

168 if result.get("results"): 

169 first = result["results"][0] 

170 logger.debug( 

171 f"First result: id={first.get('id')}, title='{first.get('title', 'No title')[:50]}...'" 

172 ) 

173 if "__search_hit__" in first: 

174 logger.debug( 

175 f"Has search hit data with score={first['__search_hit__'].get('score')}" 

176 ) 

177 else: 

178 logger.debug(f"API response keys: {result.keys()}") 

179 

180 return result # type: ignore[no-any-return] 

181 except requests.exceptions.RequestException as e: 

182 safe_msg = self._scrub_error(e) 

183 logger.warning(f"Error making request to Paperless-ngx: {safe_msg}") 

184 logger.debug( 

185 f"Failed URL: {redact_url_for_log(url)}, params: {params}" 

186 ) 

187 return {} 

188 

189 def _expand_query_with_llm(self, query: str) -> str: 

190 """ 

191 Use LLM to expand query with relevant keywords and synonyms. 

192 

193 Args: 

194 query: Original search query 

195 

196 Returns: 

197 Expanded query with keywords 

198 """ 

199 if not self.llm: 

200 logger.info( 

201 f"No LLM available for query expansion, using original: '{query}'" 

202 ) 

203 return query 

204 

205 try: 

206 prompt = f"""Paperless-ngx uses TF-IDF keyword search, not semantic search. 

207Convert this query into keywords that would appear in documents. 

208 

209Query: "{query}" 

210 

211Output format: keyword1 OR keyword2 OR "multi word phrase" OR keyword3 

212Include synonyms, plural forms, and technical terms. 

213 

214IMPORTANT: Output ONLY the search query. No explanations, no additional text.""" 

215 

216 logger.debug( 

217 f"Sending query expansion prompt to LLM for: '{query}'" 

218 ) 

219 response = self.llm.invoke(prompt) 

220 expanded = ( 

221 str(response.content) 

222 if hasattr(response, "content") 

223 else str(response) 

224 ).strip() 

225 

226 logger.debug( 

227 f"Raw LLM response (first 500 chars): {expanded[:500]}" 

228 ) 

229 

230 # Clean up the response - remove any explanatory text 

231 if "\n" in expanded: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 expanded = expanded.split("\n")[0] 

233 logger.debug("Took first line of LLM response") 

234 

235 # Always trust the LLM's expansion - it knows better than hard-coded rules 

236 logger.info( 

237 f"LLM expanded query from '{query}' to {len(expanded)} chars with {expanded.count('OR')} ORs" 

238 ) 

239 logger.debug( 

240 f"Expanded query preview (first 200 chars): {expanded[:200]}..." 

241 ) 

242 return expanded 

243 

244 except Exception as e: 

245 safe_msg = self._scrub_error(e) 

246 logger.warning(f"Failed to expand query with LLM: {safe_msg}") 

247 return query 

248 

249 def _multi_pass_search(self, query: str) -> List[Dict[str, Any]]: 

250 """ 

251 Perform multiple search passes with different strategies. 

252 

253 Args: 

254 query: Original search query 

255 

256 Returns: 

257 Combined and deduplicated results 

258 """ 

259 logger.info(f"Starting multi-pass search for query: '{query}'") 

260 all_results = {} # Use dict to deduplicate by doc_id 

261 

262 # Pass 1: Original query 

263 params = { 

264 "query": query, 

265 "page_size": self.max_results, 

266 "ordering": "-score", 

267 } 

268 

269 logger.info( 

270 f"Pass 1 - Original query: '{query}' (max_results={self.max_results})" 

271 ) 

272 response = self._make_request("/api/documents/", params=params) 

273 

274 if response and "results" in response: 

275 pass1_count = len(response["results"]) 

276 logger.info(f"Pass 1 returned {pass1_count} documents") 

277 for doc in response["results"]: 

278 doc_id = doc.get("id") 

279 if doc_id and doc_id not in all_results: 279 ↛ 277line 279 didn't jump to line 277 because the condition on line 279 was always true

280 all_results[doc_id] = doc 

281 logger.debug( 

282 f"Added doc {doc_id}: {doc.get('title', 'No title')}" 

283 ) 

284 else: 

285 logger.warning( 

286 f"Pass 1 returned no results or invalid response: {response}" 

287 ) 

288 

289 # Pass 2: LLM-expanded keywords (if LLM available) 

290 if self.llm: 

291 expanded_query = self._expand_query_with_llm(query) 

292 if expanded_query != query: 292 ↛ 323line 292 didn't jump to line 323 because the condition on line 292 was always true

293 params["query"] = expanded_query 

294 params["page_size"] = self.max_results * 2 # Get more results 

295 

296 logger.info( 

297 f"Pass 2 - Using expanded query with {expanded_query.count('OR')} ORs" 

298 ) 

299 logger.debug( 

300 f"Pass 2 - Full expanded query (first 500 chars): '{expanded_query[:500]}...'" 

301 ) 

302 logger.info( 

303 f"Pass 2 - Max results set to: {params['page_size']}" 

304 ) 

305 response = self._make_request("/api/documents/", params=params) 

306 

307 if response and "results" in response: 307 ↛ 321line 307 didn't jump to line 321 because the condition on line 307 was always true

308 pass2_new = 0 

309 for doc in response["results"]: 

310 doc_id = doc.get("id") 

311 if doc_id and doc_id not in all_results: 

312 all_results[doc_id] = doc 

313 pass2_new += 1 

314 logger.debug( 

315 f"Pass 2 added new doc {doc_id}: {doc.get('title', 'No title')}" 

316 ) 

317 logger.info( 

318 f"Pass 2 found {len(response['results'])} docs, added {pass2_new} new" 

319 ) 

320 else: 

321 logger.warning("Pass 2 returned no results") 

322 else: 

323 logger.info("Pass 2 skipped - expanded query same as original") 

324 else: 

325 logger.info("Pass 2 skipped - no LLM available") 

326 

327 # Sort by relevance score if available 

328 logger.info(f"Total unique documents collected: {len(all_results)}") 

329 sorted_results = sorted( 

330 all_results.values(), 

331 key=lambda x: x.get("__search_hit__", {}).get("score", 0), 

332 reverse=True, 

333 ) 

334 

335 final_results = sorted_results[: self.max_results] 

336 logger.info( 

337 f"Returning top {len(final_results)} documents after sorting by score" 

338 ) 

339 

340 # Log titles and scores of final results 

341 for i, doc in enumerate(final_results[:5], 1): # Log first 5 

342 score = doc.get("__search_hit__", {}).get("score", 0) 

343 logger.debug( 

344 f"Result {i}: '{doc.get('title', 'No title')}' (score={score})" 

345 ) 

346 

347 return final_results 

348 

349 def _get_previews(self, query: str) -> List[Dict[str, Any]]: 

350 """ 

351 Get preview results from Paperless-ngx using multi-pass strategy. 

352 

353 Args: 

354 query: Search query 

355 

356 Returns: 

357 List of preview dictionaries 

358 """ 

359 try: 

360 # Use multi-pass search strategy 

361 results = self._multi_pass_search(query) 

362 

363 if not results: 

364 return [] 

365 

366 # Convert documents to preview format 

367 # Note: Each document may return multiple previews (one per highlight) 

368 previews = [] 

369 for doc_data in results: 

370 doc_previews = self._convert_document_to_preview( 

371 doc_data, query 

372 ) 

373 # Handle both single preview and list of previews 

374 if isinstance(doc_previews, list): 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true

375 previews.extend(doc_previews) 

376 else: 

377 previews.append(doc_previews) 

378 

379 logger.info( 

380 f"Found {len(previews)} documents in Paperless-ngx for query: {query}" 

381 ) 

382 return previews 

383 

384 except Exception as e: 

385 safe_msg = self._scrub_error(e) 

386 logger.warning( 

387 f"Error getting previews from Paperless-ngx: {safe_msg}" 

388 ) 

389 return [] 

390 

391 def _convert_document_to_preview( 

392 self, doc_data: Dict[str, Any], query: str = "" 

393 ) -> Dict[str, Any] | List[Dict[str, Any]]: 

394 """ 

395 Convert a Paperless-ngx document to LDR preview format. 

396 

397 Args: 

398 doc_data: Document data from the API 

399 query: Original search query (for context) 

400 

401 Returns: 

402 Preview dictionary in LDR format 

403 """ 

404 # Extract title 

405 title = doc_data.get("title", f"Document {doc_data.get('id')}") 

406 doc_id = doc_data.get("id") 

407 

408 logger.info( 

409 f"Converting document {doc_id}: '{title}' to preview format" 

410 ) 

411 

412 # Build URL - use the web interface URL for user access 

413 url = f"{self.api_url}/documents/{doc_id}/details" 

414 logger.debug( 

415 f"Generated URL for doc {doc_id}: {redact_url_for_log(url)}" 

416 ) 

417 

418 # Extract snippet - prefer highlighted content from search 

419 snippet = "" 

420 search_score = 0.0 

421 search_rank = None 

422 all_highlights = [] # Initialize empty highlights list 

423 

424 if "__search_hit__" in doc_data: 

425 search_hit = doc_data["__search_hit__"] 

426 logger.debug( 

427 f"Found __search_hit__ data for doc {doc_id}: score={search_hit.get('score')}, rank={search_hit.get('rank')}" 

428 ) 

429 

430 # Get highlights - this is the search snippet with matched terms 

431 if search_hit.get("highlights"): 431 ↛ 484line 431 didn't jump to line 484 because the condition on line 431 was always true

432 # Highlights can be a string or list 

433 highlights = search_hit.get("highlights") 

434 logger.info( 

435 f"Found highlights for doc {doc_id}: type={type(highlights).__name__}, length={len(str(highlights))}" 

436 ) 

437 

438 if isinstance(highlights, list): 

439 logger.debug( 

440 f"Highlights is list with {len(highlights)} items" 

441 ) 

442 # IMPORTANT: Store highlights list for processing later 

443 # Each highlight will become a separate search result for proper citation 

444 all_highlights = highlights 

445 # Use first highlight for the default snippet 

446 snippet = highlights[0] if highlights else "" 

447 logger.info( 

448 f"Will create {len(highlights)} separate results from highlights" 

449 ) 

450 else: 

451 all_highlights = [ 

452 str(highlights) 

453 ] # Single highlight as list 

454 snippet = str(highlights) 

455 

456 logger.debug( 

457 f"Raw snippet before cleaning (first 200 chars): {snippet[:200]}" 

458 ) 

459 

460 # Clean HTML tags but preserve the matched text 

461 snippet = re.sub(r"<span[^>]*>", "**", snippet) 

462 snippet = re.sub(r"</span>", "**", snippet) 

463 snippet = re.sub(r"<[^>]+>", "", snippet) 

464 

465 logger.debug( 

466 f"Cleaned snippet (first 200 chars): {snippet[:200]}" 

467 ) 

468 

469 # Limit snippet length to avoid context window issues 

470 if ( 470 ↛ 475line 470 didn't jump to line 475 because the condition on line 470 was never true

471 self.MAX_SNIPPET_LENGTH 

472 and len(snippet) > self.MAX_SNIPPET_LENGTH 

473 ): 

474 # Cut at word boundary to avoid mid-word truncation 

475 snippet = ( 

476 snippet[: self.MAX_SNIPPET_LENGTH].rsplit(" ", 1)[0] 

477 + "..." 

478 ) 

479 logger.debug( 

480 f"Truncated snippet to {self.MAX_SNIPPET_LENGTH} chars" 

481 ) 

482 

483 # Get search relevance metadata 

484 search_score = search_hit.get("score", 0.0) 

485 search_rank = search_hit.get("rank") 

486 logger.info( 

487 f"Search metadata for doc {doc_id}: score={search_score}, rank={search_rank}" 

488 ) 

489 else: 

490 logger.warning( 

491 f"No __search_hit__ data for doc {doc_id}, will use content fallback" 

492 ) 

493 

494 if not snippet: 

495 logger.info( 

496 f"No snippet from highlights for doc {doc_id}, using content fallback" 

497 ) 

498 # Fallback to content preview if no highlights available 

499 content = doc_data.get("content", "") 

500 if content: 500 ↛ 543line 500 didn't jump to line 543 because the condition on line 500 was always true

501 logger.debug(f"Document has content of length {len(content)}") 

502 # Try to find context around query terms if possible 

503 if query: 503 ↛ 535line 503 didn't jump to line 535 because the condition on line 503 was always true

504 query_terms = query.lower().split() 

505 content_lower = content.lower() 

506 logger.debug( 

507 f"Searching for query terms in content: {query_terms}" 

508 ) 

509 

510 # Find first occurrence of any query term 

511 best_pos = -1 

512 for term in query_terms: 

513 pos = content_lower.find(term) 

514 if pos != -1 and (best_pos == -1 or pos < best_pos): 514 ↛ 512line 514 didn't jump to line 512 because the condition on line 514 was always true

515 best_pos = pos 

516 logger.debug( 

517 f"Found term '{term}' at position {pos}" 

518 ) 

519 

520 if best_pos != -1: 520 ↛ 530line 520 didn't jump to line 530 because the condition on line 520 was always true

521 # Extract context around the found term - much larger context for research 

522 start = max(0, best_pos - 2000) 

523 end = min(len(content), best_pos + 8000) 

524 snippet = "..." + content[start:end] + "..." 

525 logger.info( 

526 f"Extracted snippet around query term at position {best_pos}" 

527 ) 

528 else: 

529 # Just take the beginning - use 10000 chars for research 

530 snippet = content[:10000] 

531 logger.info( 

532 "No query terms found, using first 10000 chars of content" 

533 ) 

534 else: 

535 snippet = content[:10000] 

536 logger.info( 

537 "No query provided, using first 10000 chars of content" 

538 ) 

539 

540 if len(content) > 10000: 540 ↛ 541line 540 didn't jump to line 541 because the condition on line 540 was never true

541 snippet += "..." 

542 else: 

543 logger.warning(f"No content available for doc {doc_id}") 

544 

545 logger.info(f"Final snippet for doc {doc_id} has length {len(snippet)}") 

546 

547 # Build metadata 

548 metadata = { 

549 "doc_id": str(doc_id), 

550 "correspondent": doc_data.get("correspondent_name", ""), 

551 "document_type": doc_data.get("document_type_name", ""), 

552 "created": doc_data.get("created", ""), 

553 "modified": doc_data.get("modified", ""), 

554 "archive_serial_number": doc_data.get("archive_serial_number"), 

555 "search_score": search_score, 

556 "search_rank": search_rank, 

557 } 

558 

559 # Add tags if present 

560 tags = doc_data.get("tags_list", []) 

561 if isinstance(tags, list) and tags: 

562 metadata["tags"] = ", ".join(str(tag) for tag in tags) 

563 

564 # Build enhanced title with available metadata for better citations 

565 title_parts = [] 

566 

567 # Add correspondent/author if available 

568 correspondent = doc_data.get("correspondent_name", "") 

569 if correspondent: 

570 title_parts.append(f"{correspondent}.") 

571 logger.debug(f"Added correspondent to title: {correspondent}") 

572 

573 # Add the document title 

574 title_parts.append(title) 

575 

576 # Add document type if it's meaningful (not just generic types) 

577 doc_type = doc_data.get("document_type_name", "") 

578 if doc_type and doc_type not in ["Letter", "Other", "Document", ""]: 

579 title_parts.append(f"({doc_type})") 

580 logger.debug(f"Added document type to title: {doc_type}") 

581 

582 # Add year from created date if available 

583 created_date = doc_data.get("created", "") 

584 if created_date and len(created_date) >= 4: 

585 year = created_date[:4] 

586 title_parts.append(year) 

587 logger.debug(f"Added year to title: {year}") 

588 

589 # Format the enhanced title for display in sources list 

590 if title_parts: 590 ↛ 593line 590 didn't jump to line 593 because the condition on line 590 was always true

591 enhanced_title = " ".join(title_parts) 

592 else: 

593 enhanced_title = title 

594 

595 logger.info(f"Enhanced title for doc {doc_id}: '{enhanced_title}'") 

596 

597 # Build the preview 

598 preview = { 

599 "title": enhanced_title, # Use enhanced title with bibliographic info 

600 "url": url, 

601 "link": url, # Add 'link' key for compatibility with search utilities 

602 "snippet": snippet, 

603 "author": doc_data.get("correspondent_name", ""), 

604 "date": doc_data.get("created", ""), 

605 "source": "Paperless", # Keep source as the system name like other engines 

606 "metadata": metadata, 

607 "_raw_data": doc_data, # Store raw data for full content retrieval 

608 } 

609 

610 logger.info( 

611 f"Built preview for doc {doc_id}: URL={url}, snippet_len={len(snippet)}, has_author={bool(preview['author'])}, has_date={bool(preview['date'])}" 

612 ) 

613 

614 # Check if we have multiple highlights to return as separate results 

615 if len(all_highlights) > 1: 

616 # Create multiple previews, one for each highlight 

617 previews = [] 

618 for i, highlight in enumerate(all_highlights): 

619 # Clean each highlight 

620 clean_snippet = re.sub(r"<span[^>]*>", "**", str(highlight)) 

621 clean_snippet = re.sub(r"</span>", "**", clean_snippet) 

622 clean_snippet = re.sub(r"<[^>]+>", "", clean_snippet) 

623 

624 # Create a preview for this highlight 

625 highlight_preview = { 

626 "title": f"{enhanced_title} (excerpt {i + 1})", # Differentiate each excerpt 

627 "url": url, 

628 "link": url, 

629 "snippet": clean_snippet, 

630 "author": doc_data.get("correspondent_name", ""), 

631 "date": doc_data.get("created", ""), 

632 "source": "Paperless", 

633 "metadata": { 

634 **metadata, 

635 "excerpt_number": i + 1, 

636 "total_excerpts": len(all_highlights), 

637 }, 

638 "_raw_data": doc_data, 

639 } 

640 previews.append(highlight_preview) 

641 

642 logger.info( 

643 f"Created {len(previews)} separate previews from highlights for doc {doc_id}" 

644 ) 

645 return previews 

646 # Single preview (original behavior) 

647 return preview 

648 

649 def _get_full_content( 

650 self, relevant_items: List[Dict[str, Any]] 

651 ) -> List[Dict[str, Any]]: 

652 """ 

653 Get full content for relevant documents. 

654 

655 Args: 

656 relevant_items: List of relevant preview dictionaries 

657 

658 Returns: 

659 List of dictionaries with full content 

660 """ 

661 if not self.include_content: 

662 # If content inclusion is disabled, just return previews 

663 return relevant_items 

664 

665 logger.info(f"Getting full content for {len(relevant_items)} documents") 

666 results = [] 

667 for idx, item in enumerate(relevant_items): 

668 try: 

669 logger.info( 

670 f"Processing document {idx + 1}: title='{item.get('title', 'No title')[:50]}...', url={item.get('url', 'No URL')}" 

671 ) 

672 logger.debug(f"Document {idx + 1} keys: {item.keys()}") 

673 logger.debug( 

674 f"Document {idx + 1} has snippet of length: {len(item.get('snippet', ''))}" 

675 ) 

676 

677 # Get the full document content if we have the raw data 

678 if "_raw_data" in item: 

679 doc_data = item["_raw_data"] 

680 full_content = doc_data.get("content", "") 

681 

682 if not full_content: 

683 # Try to fetch the document details 

684 doc_id = item["metadata"].get("doc_id") 

685 if doc_id: 685 ↛ 694line 685 didn't jump to line 694 because the condition on line 685 was always true

686 detail_response = self._make_request( 

687 f"/api/documents/{doc_id}/" 

688 ) 

689 if detail_response: 

690 full_content = detail_response.get( 

691 "content", "" 

692 ) 

693 

694 item["full_content"] = full_content or item["snippet"] 

695 logger.info( 

696 f"Document {idx + 1} full content length: {len(item['full_content'])}" 

697 ) 

698 else: 

699 # Fallback to snippet if no raw data 

700 item["full_content"] = item["snippet"] 

701 logger.info( 

702 f"Document {idx + 1} using snippet as full content (no raw data)" 

703 ) 

704 

705 # Log the final document structure for debugging citation issues 

706 logger.info( 

707 f"Document {idx + 1} final structure: title='{item.get('title', '')[:50]}...', has_link={bool(item.get('link'))}, has_url={bool(item.get('url'))}, source='{item.get('source', 'Unknown')}'" 

708 ) 

709 

710 # Remove the raw data from the result 

711 item.pop("_raw_data", None) 

712 results.append(item) 

713 

714 except Exception as e: 

715 safe_msg = self._scrub_error(e) 

716 logger.warning( 

717 f"Error getting full content for document: {safe_msg}" 

718 ) 

719 item["full_content"] = item["snippet"] 

720 item.pop("_raw_data", None) 

721 results.append(item) 

722 

723 return results 

724 

725 def run( 

726 self, query: str, research_context: Dict[str, Any] | None = None 

727 ) -> List[Dict[str, Any]]: 

728 """ 

729 Execute search on Paperless-ngx. 

730 

731 Args: 

732 query: Search query 

733 research_context: Context from previous research 

734 

735 Returns: 

736 List of search results in LDR format 

737 """ 

738 try: 

739 # Get previews 

740 previews = self._get_previews(query) 

741 

742 if not previews: 

743 return [] 

744 

745 # Apply LLM relevance filtering if enabled by the factory 

746 enable_llm_filter = getattr( 

747 self, "enable_llm_relevance_filter", False 

748 ) 

749 if enable_llm_filter and self.llm: 749 ↛ 750line 749 didn't jump to line 750 because the condition on line 749 was never true

750 filtered_previews = self._filter_for_relevance(previews, query) 

751 if not filtered_previews: 

752 logger.info( 

753 f"LLM relevance filter returned no results " 

754 f"from {len(previews)} previews for query: {query}" 

755 ) 

756 else: 

757 filtered_previews = previews 

758 

759 # Get full content for relevant items 

760 results = self._get_full_content(filtered_previews) 

761 

762 logger.info( 

763 f"Search completed successfully, returning {len(results)} results" 

764 ) 

765 # Enhanced logging to track document structure for citation debugging 

766 for i, r in enumerate(results[:3], 1): 

767 logger.info( 

768 f"Result {i}: title='{r.get('title', '')[:50]}...', " 

769 f"has_full_content={bool(r.get('full_content'))}, " 

770 f"full_content_len={len(r.get('full_content', ''))}, " 

771 f"snippet_len={len(r.get('snippet', ''))}, " 

772 f"url={r.get('url', '')[:50]}" 

773 ) 

774 

775 return results 

776 

777 except Exception as e: 

778 safe_msg = self._scrub_error(e) 

779 logger.warning(f"Error in Paperless-ngx search: {safe_msg}") 

780 return [] 

781 

782 async def arun(self, query: str) -> List[Dict[str, Any]]: 

783 """ 

784 Async version of search. 

785 

786 Currently falls back to sync version. 

787 """ 

788 return self.run(query) 

789 

790 def test_connection(self) -> bool: 

791 """ 

792 Test the connection to Paperless-ngx. 

793 

794 Returns: 

795 True if connection successful, False otherwise 

796 """ 

797 try: 

798 response = self._make_request("/api/") 

799 return bool(response) 

800 except Exception as e: 

801 safe_msg = self._scrub_error(e) 

802 logger.warning(f"Failed to connect to Paperless-ngx: {safe_msg}") 

803 return False 

804 

805 def get_document_count(self) -> int: 

806 """ 

807 Get the total number of documents in Paperless-ngx. 

808 

809 Returns: 

810 Number of documents, or -1 if error 

811 """ 

812 try: 

813 response = self._make_request( 

814 "/api/documents/", params={"page_size": 1} 

815 ) 

816 return int(response.get("count", -1)) 

817 except Exception: 

818 logger.debug("Failed to fetch document count", exc_info=True) 

819 return -1