Coverage for src/local_deep_research/web_search_engines/engines/full_search.py: 98%

129 statements  

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

1from datetime import datetime, UTC 

2from typing import Any, Dict, List, Optional, Protocol, runtime_checkable 

3 

4from langchain_core.language_models import BaseLLM 

5 

6from ...config.search_config import QUALITY_CHECK_DDG_URLS 

7from ...research_library.downloaders.extraction import ( 

8 batch_fetch_and_extract, 

9) 

10from ...security.egress.policy import PolicyDeniedError 

11from ...security.log_sanitizer import scrub_error 

12from ...security.secure_logging import logger 

13from ...security.ssrf_validator import redact_url_for_log, validate_url 

14from ...utilities.js_rendering import ( 

15 read_js_rendering_setting as _read_js_rendering_setting, 

16) 

17from ...utilities.json_utils import extract_json, get_llm_response_text 

18 

19 

20@runtime_checkable 

21class _Invokable(Protocol): 

22 def invoke(self, query: str) -> Any: ... 22 ↛ exitline 22 didn't return from function 'invoke' because

23 

24 

25class FullSearchResults: 

26 def __init__( 

27 self, 

28 llm: Optional[BaseLLM], 

29 web_search: _Invokable, 

30 output_format: str = "list", 

31 language: str = "English", 

32 max_results: int = 10, 

33 region: str = "wt-wt", 

34 time: Optional[str] = "y", 

35 safesearch: str | int = "Moderate", 

36 settings_snapshot: Optional[Dict] = None, 

37 egress_context: Optional[Any] = None, 

38 ): 

39 self.llm = llm 

40 self.output_format = output_format 

41 self.language = language 

42 self.max_results = max_results 

43 self.region = region 

44 self.time = time 

45 self.safesearch = safesearch 

46 self.web_search = web_search 

47 self.settings_snapshot = settings_snapshot 

48 # Set by the factory when the parent engine is gated against a 

49 # specific scope; used to evaluate per-URL fetches below. 

50 self.egress_context = egress_context 

51 

52 def check_urls(self, results: List[Dict], query: str) -> List[Dict]: 

53 if not results: 

54 return results 

55 

56 now = datetime.now(UTC) 

57 current_time = now.strftime("%Y-%m-%d") 

58 prompt = f"""ONLY Return a JSON array. The response contains no letters. Evaluate these URLs for: 

59 1. Timeliness (today: {current_time}) 

60 2. Factual accuracy (cross-reference major claims) 

61 3. Source reliability (prefer official company websites, established news outlets) 

62 4. Direct relevance to query: {query} 

63 

64 URLs to evaluate: 

65 {results} 

66 

67 Return a JSON array of indices (0-based) for sources that meet ALL criteria. 

68 ONLY Return a JSON array of indices (0-based) and nothing else. No letters. 

69 Example response: \n[0, 2, 4]\n\n""" 

70 

71 try: 

72 if self.llm is None: 

73 return results 

74 response = self.llm.invoke(prompt) 

75 response_text = get_llm_response_text(response) 

76 good_indices = extract_json(response_text, expected_type=list) 

77 

78 if good_indices is None: 

79 good_indices = [] 

80 

81 return [r for i, r in enumerate(results) if i in good_indices] 

82 except PolicyDeniedError: 

83 # The URL-quality LLM was denied by egress policy (e.g. a cloud 

84 # LLM under require_local / PRIVATE_ONLY). Do NOT fall through to 

85 # the unfiltered-results fallback below — that would let the run 

86 # proceed despite the user's policy refusing the LLM. Fail closed. 

87 raise 

88 except Exception as e: 

89 safe_msg = scrub_error(e) 

90 logger.exception( 

91 f"URL filtering error ({type(e).__name__}): {safe_msg}" 

92 ) 

93 logger.warning( 

94 "URL quality filter unavailable — returning {} unfiltered " 

95 "results as fallback", 

96 len(results), 

97 ) 

98 return results # Fall back to original results on LLM error 

99 

100 def run(self, query: str): 

101 # Step 1: Get search results 

102 search_results = self.web_search.invoke(query) 

103 if not isinstance(search_results, list): 

104 raise ValueError("Expected the search results in list format.") 

105 

106 # Step 2: Filter URLs using LLM 

107 if QUALITY_CHECK_DDG_URLS: 

108 filtered_results = self.check_urls(search_results, query) 

109 else: 

110 filtered_results = search_results 

111 

112 # Extract URLs from filtered results 

113 urls = [ 

114 result.get("link") 

115 for result in filtered_results 

116 if result.get("link") 

117 ] 

118 

119 if not urls: 

120 logger.error("\n === NO VALID LINKS ===\n") 

121 return [] 

122 

123 # SSRF-validate + egress-scope-validate each URL. SSRF only checks 

124 # IP class; scope enforcement is the separate axis that blocks 

125 # public hosts under PRIVATE_ONLY / STRICT. 

126 evaluate_url_fn = None 

127 egress_ctx = self.egress_context 

128 if egress_ctx is not None: 

129 from ...security.egress.policy import evaluate_url as _ev_url 

130 

131 evaluate_url_fn = _ev_url 

132 safe_urls: List[str] = [] 

133 for url in urls: 

134 if url is None: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

135 continue 

136 if not validate_url(url): 

137 logger.warning( 

138 "SSRF validation blocked URL from full content fetch: " 

139 f"{redact_url_for_log(url)}." 

140 ) 

141 continue 

142 if evaluate_url_fn is not None and egress_ctx is not None: 

143 url_decision = evaluate_url_fn(url, egress_ctx) 

144 if not url_decision.allowed: 

145 logger.bind(policy_audit=True).warning( 

146 "full_search URL denied by egress policy", 

147 url=redact_url_for_log(url), 

148 scope=egress_ctx.scope.value, 

149 reason=url_decision.reason, 

150 ) 

151 continue 

152 safe_urls.append(url) 

153 

154 if not safe_urls: 

155 logger.warning( 

156 "All URLs were blocked by SSRF validation — returning results " 

157 "without full content. This can happen when search results " 

158 "point to internal/private network addresses." 

159 ) 

160 for result in filtered_results: 

161 result["full_content"] = None 

162 return filtered_results 

163 

164 # Fetch and extract all pages — specialized downloaders (arXiv, 

165 # PubMed, etc.) are tried first, with HTML crawling as fallback. 

166 url_to_content = batch_fetch_and_extract( 

167 safe_urls, 

168 language=self.language, 

169 enable_js_rendering=_read_js_rendering_setting( 

170 self.settings_snapshot 

171 ), 

172 ) 

173 

174 nr_full_text = sum(1 for v in url_to_content.values() if v) 

175 for result in filtered_results: 

176 link = result.get("link") 

177 result["full_content"] = url_to_content.get(link) if link else None 

178 

179 logger.info(f"Full search: retrieved content from {nr_full_text} pages") 

180 return filtered_results 

181 

182 def _get_full_content( 

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

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

185 """Fetch and attach full content to an existing list of items.""" 

186 evaluate_url_fn = None 

187 egress_ctx = self.egress_context 

188 if egress_ctx is not None: 

189 from ...security.egress.policy import evaluate_url as _ev_url 

190 

191 evaluate_url_fn = _ev_url 

192 urls: List[str] = [] 

193 for item in relevant_items: 

194 link = item.get("link") 

195 if link is None: 

196 continue 

197 if not validate_url(link): 

198 logger.warning( 

199 "SSRF validation blocked URL from full content fetch: " 

200 f"{redact_url_for_log(link)}." 

201 ) 

202 continue 

203 if evaluate_url_fn is not None and egress_ctx is not None: 

204 url_decision = evaluate_url_fn(link, egress_ctx) 

205 if not url_decision.allowed: 

206 logger.bind(policy_audit=True).warning( 

207 "full_search _get_full_content URL denied by egress policy", 

208 url=redact_url_for_log(link), 

209 scope=egress_ctx.scope.value, 

210 reason=url_decision.reason, 

211 ) 

212 continue 

213 urls.append(link) 

214 

215 if not urls: 

216 for item in relevant_items: 

217 item["full_content"] = None 

218 return relevant_items 

219 

220 try: 

221 url_to_content = batch_fetch_and_extract( 

222 urls, 

223 language=self.language, 

224 enable_js_rendering=_read_js_rendering_setting( 

225 self.settings_snapshot 

226 ), 

227 ) 

228 except Exception as e: 

229 safe_msg = scrub_error(e) 

230 logger.exception( 

231 f"Error fetching full content ({type(e).__name__}): {safe_msg}" 

232 ) 

233 for item in relevant_items: 

234 item["full_content"] = None 

235 return relevant_items 

236 

237 for item in relevant_items: 

238 link = item.get("link") 

239 item["full_content"] = url_to_content.get(link) if link else None 

240 

241 return relevant_items 

242 

243 def invoke(self, query: str) -> Any: 

244 return self.run(query) 

245 

246 def __call__(self, query: str) -> Any: 

247 return self.invoke(query)