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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Service layer for follow-up research functionality.
4This service handles the business logic for follow-up research,
5including loading parent research context and orchestrating the search.
6"""
8from typing import Dict, Any
9from loguru import logger
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
17class FollowUpResearchService:
18 """Service for handling follow-up research operations."""
20 def __init__(self, username: str | None = None):
21 """
22 Initialize the follow-up research service.
24 Args:
25 username: Username for database access
26 """
27 self.username = username
29 def load_parent_research(self, parent_research_id: str) -> Dict[str, Any]:
30 """
31 Load parent research data from the database.
33 Args:
34 parent_research_id: ID of the parent research
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 )
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
60 logger.warning(
61 "Parent research not found: {}",
62 _log_value_preview(parent_research_id),
63 )
64 return {}
66 logger.info(
67 f"Found research: {research.id}, has meta: {research.research_meta is not None}"
68 )
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 )
76 logger.info(
77 f"Found {len(resource_list)} sources from ResearchResource table"
78 )
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 )
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 )
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")
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 )
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 }
143 logger.info(
144 f"Loaded parent research {parent_research_id} with "
145 f"{len(resource_list)} sources"
146 )
148 return parent_data
150 except Exception:
151 logger.exception("Error loading parent research")
152 return {}
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.
160 Args:
161 parent_research_id: ID of the parent research
163 Returns:
164 Research context dictionary for the strategy
165 """
166 parent_data = self.load_parent_research(parent_research_id)
168 if not parent_data:
169 logger.warning("No parent data found, returning empty context")
170 return {}
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 }
183 def perform_followup(self, request: FollowUpRequest) -> Dict[str, Any]:
184 """
185 Perform a follow-up research based on parent research.
187 This method prepares the context and parameters for the research system
188 to use the contextual follow-up strategy.
190 Args:
191 request: FollowUpRequest with question and parent research ID
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 )
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
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 }
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 }
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 )
243 return research_params