Coverage for src/local_deep_research/advanced_search_system/filters/followup_relevance_filter.py: 98%

63 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Follow-up Relevance Filter 

3 

4Filters and ranks past research sources based on their relevance 

5to follow-up questions. 

6""" 

7 

8from typing import Dict, List 

9from loguru import logger 

10 

11from .base_filter import BaseFilter 

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

13from ...utilities.llm_utils import invoke_llm_sync 

14 

15 

16class FollowUpRelevanceFilter(BaseFilter): 

17 """ 

18 Filters past research sources by relevance to follow-up questions. 

19 

20 This filter analyzes sources from previous research and determines 

21 which ones are most relevant to the new follow-up question. 

22 """ 

23 

24 def filter_results( 

25 self, results: List[Dict], query: str, max_results: int = 10, **kwargs 

26 ) -> List[Dict]: 

27 """ 

28 Filter search results by relevance to the follow-up query. 

29 

30 Args: 

31 results: List of source dictionaries from past research 

32 query: The follow-up query 

33 max_results: Maximum number of results to return (default: 10) 

34 **kwargs: Additional parameters: 

35 - past_findings: Summary of past findings for context 

36 - original_query: The original research query 

37 

38 Returns: 

39 Filtered list of relevant sources 

40 """ 

41 if not results: 

42 return [] 

43 

44 past_findings = kwargs.get("past_findings", "") 

45 original_query = kwargs.get("original_query", "") 

46 

47 # Use LLM to select relevant sources 

48 relevant_indices = self._select_relevant_sources( 

49 results, query, past_findings, max_results, original_query 

50 ) 

51 

52 # Return selected sources. Indices are already validated and 

53 # deduplicated by _select_relevant_sources, so each is in range. 

54 filtered = [results[i] for i in relevant_indices] 

55 

56 logger.info( 

57 f"Filtered {len(results)} sources to {len(filtered)} relevant ones " 

58 f"for follow-up query. Kept indices: {relevant_indices}" 

59 ) 

60 

61 return filtered 

62 

63 def _valid_unique_indices(self, indices, upper_bound): 

64 """Yield in-range source indices once each, preserving first-seen order. 

65 

66 Accepts ints and floats (floats are truncated toward zero, e.g. 

67 ``1.9`` becomes ``1``; some models emit ``0.0``) and 

68 rejects booleans, non-numeric values, negative indices, and indices at 

69 or beyond ``upper_bound``. Deduplication prevents the same source from 

70 being selected more than once. 

71 """ 

72 seen = set() 

73 for raw in indices: 

74 if isinstance(raw, bool): 

75 continue 

76 if isinstance(raw, int): 

77 idx = raw 

78 elif isinstance(raw, float): 78 ↛ 81line 78 didn't jump to line 81 because the condition on line 78 was always true

79 idx = int(raw) 

80 else: 

81 continue 

82 if idx in seen: 

83 continue 

84 if 0 <= idx < upper_bound: 

85 seen.add(idx) 

86 yield idx 

87 

88 def _select_relevant_sources( 

89 self, 

90 sources: List[Dict], 

91 query: str, 

92 context: str, 

93 max_results: int, 

94 original_query: str = "", 

95 ) -> List[int]: 

96 """ 

97 Select relevant sources using LLM. 

98 

99 Args: 

100 sources: List of source dictionaries 

101 query: The follow-up query 

102 context: Past findings context 

103 max_results: Maximum number of sources to select 

104 original_query: The original research query 

105 

106 Returns: 

107 List of indices of relevant sources 

108 """ 

109 if not self.model: 

110 # If no model available, return first max_results 

111 return list(range(min(max_results, len(sources)))) 

112 

113 # Build source list for LLM 

114 source_list = [] 

115 for i, source in enumerate(sources): 

116 title = source.get("title") or "Unknown" 

117 url = source.get("url") or "" 

118 snippet = ( 

119 source.get("snippet") or source.get("content_preview") or "" 

120 )[:150] 

121 source_list.append( 

122 f"{i}. {title}\n URL: {url}\n Content: {snippet}" 

123 ) 

124 

125 sources_text = "\n\n".join(source_list) 

126 

127 # Include context if available for better selection 

128 context_section = "" 

129 if context or original_query: 

130 parts = [] 

131 if original_query: 

132 parts.append(f"Original research question: {original_query}") 

133 if context: 

134 parts.append(f"Previous research findings:\n{context}") 

135 

136 context_section = f""" 

137Previous Research Context: 

138{chr(10).join(parts)} 

139 

140--- 

141""" 

142 

143 # This text is an LLM source-selection prompt; it is never executed as SQL. 

144 prompt = f""" 

145Select the most relevant sources for answering this follow-up question based on the previous research context. 

146{context_section} 

147Follow-up question: "{query}" 

148 

149Available sources from previous research: 

150{sources_text} 

151 

152Instructions: 

153- Select sources that are most relevant to the follow-up question given the context 

154- Consider which sources directly address the question or provide essential information 

155- Think about what the user is asking for in relation to the previous findings 

156- Return ONLY a JSON array of source numbers (e.g., [0, 2, 5, 7]) 

157- Do not include any explanation or other text 

158 

159Return the indices of relevant sources as a JSON array:""" # nosec B608 

160 

161 try: 

162 response = invoke_llm_sync(self.model, prompt) 

163 content = get_llm_response_text(response) 

164 

165 # Parse JSON response 

166 indices = extract_json(content, expected_type=list) 

167 

168 if indices is not None: 

169 # Validate, bound-check, and deduplicate the parsed indices 

170 indices = list( 

171 self._valid_unique_indices(indices, len(sources)) 

172 ) 

173 else: 

174 logger.debug("Failed to parse JSON, attempting regex fallback") 

175 # Fallback to regex extraction 

176 import re 

177 

178 numbers = re.findall(r"\d+", content) 

179 indices = list( 

180 self._valid_unique_indices( 

181 (int(n) for n in numbers), len(sources) 

182 ) 

183 ) 

184 

185 return indices 

186 except Exception as e: 

187 logger.debug(f"LLM source selection failed: {e}") 

188 # Fallback to first max_results sources 

189 return list(range(min(max_results, len(sources))))