Coverage for src/local_deep_research/followup_research/service.py: 97%

54 statements  

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

1""" 

2Service layer for follow-up research functionality. 

3 

4This service handles the business logic for follow-up research, 

5including loading parent research context and orchestrating the search. 

6""" 

7 

8from typing import Dict, Any 

9from loguru import logger 

10 

11from ..database.models import ResearchHistory 

12from ..database.session_context import get_user_db_session 

13from ..web.services.research_sources_service import ResearchSourcesService 

14from .models import FollowUpRequest 

15 

16 

17class FollowUpResearchService: 

18 """Service for handling follow-up research operations.""" 

19 

20 def __init__(self, username: str | None = None): 

21 """ 

22 Initialize the follow-up research service. 

23 

24 Args: 

25 username: Username for database access 

26 """ 

27 self.username = username 

28 

29 def load_parent_research(self, parent_research_id: str) -> Dict[str, Any]: 

30 """ 

31 Load parent research data from the database. 

32 

33 Args: 

34 parent_research_id: ID of the parent research 

35 

36 Returns: 

37 Dictionary containing parent research data including: 

38 - report_content: The generated report 

39 - resources: List of research resources/links 

40 - query: Original research query 

41 - strategy: Strategy used 

42 """ 

43 try: 

44 with get_user_db_session(self.username) as session: 

45 # Load research history 

46 research = ( 

47 session.query(ResearchHistory) 

48 .filter_by(id=parent_research_id) 

49 .first() 

50 ) 

51 

52 if not research: 

53 # (#6938: on this not-found path the id is raw, 

54 # unchecked request input — both followup routes call 

55 # here with the body's parent_research_id before any 

56 # other check — so it previews instead of f-stringing. 

57 # Imported at call time like perform_followup below.) 

58 from ..web.routers.notes import _log_value_preview 

59 

60 logger.warning( 

61 "Parent research not found: {}", 

62 _log_value_preview(parent_research_id), 

63 ) 

64 return {} 

65 

66 logger.info( 

67 f"Found research: {research.id}, has meta: {research.research_meta is not None}" 

68 ) 

69 

70 # Use the ResearchSourcesService to get sources properly from database 

71 sources_service = ResearchSourcesService() 

72 resource_list = sources_service.get_research_sources( 

73 parent_research_id, username=self.username 

74 ) 

75 

76 logger.info( 

77 f"Found {len(resource_list)} sources from ResearchResource table" 

78 ) 

79 

80 # If no sources in database, try to get from research_meta as fallback 

81 if not resource_list and research.research_meta: 

82 logger.info( 

83 "No sources in database, checking research_meta" 

84 ) 

85 logger.info( 

86 f"Research meta keys: {list(research.research_meta.keys()) if isinstance(research.research_meta, dict) else 'Not a dict'}" 

87 ) 

88 

89 # Try different possible locations for sources in research_meta 

90 meta_sources = ( 

91 research.research_meta.get("all_links_of_system", []) 

92 or research.research_meta.get("sources", []) 

93 or research.research_meta.get("links", []) 

94 or [] 

95 ) 

96 

97 if meta_sources: 

98 logger.info( 

99 f"Found {len(meta_sources)} sources in research_meta, saving to database" 

100 ) 

101 # Source migration is non-fatal: the followup can 

102 # still proceed using meta_sources directly even if 

103 # saving to the ResearchResource table fails (e.g. 

104 # encrypted DB password expired mid-session). 

105 try: 

106 saved = sources_service.save_research_sources( 

107 parent_research_id, 

108 meta_sources, 

109 username=self.username, 

110 ) 

111 logger.info(f"Saved {saved} sources to database") 

112 

113 # Now retrieve them properly formatted 

114 resource_list = ( 

115 sources_service.get_research_sources( 

116 parent_research_id, 

117 username=self.username, 

118 ) 

119 ) 

120 except Exception: 

121 logger.exception( 

122 f"Failed to save/retrieve sources for parent research " 

123 f"{parent_research_id} (continuing with raw meta_sources)" 

124 ) 

125 

126 # Convert to dictionary format 

127 parent_data = { 

128 "research_id": research.id, 

129 "query": research.query, 

130 "report_content": research.report_content, 

131 "formatted_findings": research.research_meta.get( 

132 "formatted_findings", "" 

133 ) 

134 if research.research_meta 

135 else "", 

136 "strategy": research.research_meta.get("strategy_name", "") 

137 if research.research_meta 

138 else "", 

139 "resources": resource_list, 

140 "all_links_of_system": resource_list, 

141 } 

142 

143 logger.info( 

144 f"Loaded parent research {parent_research_id} with " 

145 f"{len(resource_list)} sources" 

146 ) 

147 

148 return parent_data 

149 

150 except Exception: 

151 logger.exception("Error loading parent research") 

152 return {} 

153 

154 def prepare_research_context( 

155 self, parent_research_id: str 

156 ) -> Dict[str, Any]: 

157 """ 

158 Prepare the research context for the contextual follow-up strategy. 

159 

160 Args: 

161 parent_research_id: ID of the parent research 

162 

163 Returns: 

164 Research context dictionary for the strategy 

165 """ 

166 parent_data = self.load_parent_research(parent_research_id) 

167 

168 if not parent_data: 

169 logger.warning("No parent data found, returning empty context") 

170 return {} 

171 

172 # Format context for the strategy 

173 return { 

174 "parent_research_id": parent_research_id, 

175 "past_links": parent_data.get("all_links_of_system", []), 

176 "past_findings": parent_data.get("formatted_findings", ""), 

177 "report_content": parent_data.get("report_content", ""), 

178 "resources": parent_data.get("resources", []), 

179 "all_links_of_system": parent_data.get("all_links_of_system", []), 

180 "original_query": parent_data.get("query", ""), 

181 } 

182 

183 def perform_followup(self, request: FollowUpRequest) -> Dict[str, Any]: 

184 """ 

185 Perform a follow-up research based on parent research. 

186 

187 This method prepares the context and parameters for the research system 

188 to use the contextual follow-up strategy. 

189 

190 Args: 

191 request: FollowUpRequest with question and parent research ID 

192 

193 Returns: 

194 Dictionary with research parameters for the research system 

195 """ 

196 # Prepare the research context from parent 

197 research_context = self.prepare_research_context( 

198 request.parent_research_id 

199 ) 

200 

201 # (#6938: the question and parent id are raw request input, so 

202 # every line below that logs them previews instead of 

203 # f-stringing — same conversion as the followup router's 

204 # params/query lines. Imported at call time like the research 

205 # thread's sites, so this service module never imports the notes 

206 # router at import time.) 

207 from ..web.routers.notes import _log_value_preview 

208 

209 if not research_context: 

210 logger.warning( 

211 "Parent research not found: {}, using empty context", 

212 _log_value_preview(request.parent_research_id), 

213 ) 

214 # Use empty context to allow follow-up without parent 

215 research_context = { 

216 "parent_research_id": request.parent_research_id, 

217 "past_links": [], 

218 "past_findings": "", 

219 "report_content": "", 

220 "resources": [], 

221 "all_links_of_system": [], 

222 "original_query": "", 

223 } 

224 

225 # Prepare parameters for the research system 

226 research_params = { 

227 "query": request.question, 

228 "strategy": "contextual-followup", 

229 "delegate_strategy": request.strategy, 

230 "max_iterations": request.max_iterations, 

231 "questions_per_iteration": request.questions_per_iteration, 

232 "research_context": research_context, 

233 "parent_research_id": request.parent_research_id, 

234 } 

235 

236 logger.info( 

237 "Prepared follow-up research for question: '{}' " 

238 "based on parent: {}", 

239 _log_value_preview(request.question), 

240 _log_value_preview(request.parent_research_id), 

241 ) 

242 

243 return research_params