Coverage for src/local_deep_research/news/utils/headline_generator.py: 97%

57 statements  

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

1""" 

2Headline generation utilities for news items. 

3Uses LLM to generate concise, meaningful headlines from long queries and findings. 

4""" 

5 

6from typing import Any, Optional 

7from loguru import logger 

8 

9from ...utilities.json_utils import get_llm_response_text 

10 

11 

12def generate_headline( 

13 query: str, 

14 findings: str = "", 

15 max_length: int = 100, 

16 settings_snapshot: Optional[dict] = None, 

17) -> str: 

18 """ 

19 Generate a concise headline from a query and optional findings. 

20 

21 The synchronous entry point uses ``invoke`` without running the async 

22 core on a temporary event loop. Callers already on an event loop can 

23 await the corresponding ``_async`` entry point. Resource cleanup uses 

24 the shared close helper; it is separate from invocation dispatch. 

25 

26 Args: 

27 query: The search query or research question 

28 findings: Optional findings/content to help generate better headline 

29 max_length: Maximum length for the headline 

30 settings_snapshot: Optional settings snapshot so the LLM call 

31 picks up the active egress policy. Background callers 

32 should pass this through; without it ``get_llm`` takes its 

33 snapshot-less path, which fails closed with 

34 ``PolicyDeniedError`` for any non-local provider, so 

35 headline generation fails rather than reaching a cloud LLM. 

36 

37 Returns: 

38 A concise headline string 

39 """ 

40 # Always try LLM generation first for dynamic headlines based on actual content 

41 llm_headline = _generate_with_llm( 

42 query, findings, max_length, settings_snapshot 

43 ) 

44 return _finalize_headline(llm_headline) 

45 

46 

47async def generate_headline_async( 

48 query: str, 

49 findings: str = "", 

50 max_length: int = 100, 

51 settings_snapshot: Optional[dict] = None, 

52) -> str: 

53 """Async core of generate_headline: one awaited ainvoke (#5854). 

54 

55 For callers that already run on an event loop. Applies the same 

56 failure sentinel as :func:`generate_headline` via 

57 :func:`_finalize_headline`. 

58 """ 

59 llm_headline = await _generate_with_llm_async( 

60 query, findings, max_length, settings_snapshot 

61 ) 

62 return _finalize_headline(llm_headline) 

63 

64 

65def _finalize_headline(llm_headline: Optional[str]) -> str: 

66 """Apply the failure sentinel shared by both generate_headline paths.""" 

67 # No fallback - if LLM fails, indicate failure 

68 if llm_headline: 

69 return llm_headline 

70 

71 return "[Headline generation failed]" 

72 

73 

74def _build_headline_prompt(findings: str) -> Optional[str]: 

75 """Build the headline prompt, or None when there is nothing to summarize.""" 

76 # Focus only on the findings/report content, not the query 

77 if not findings: 

78 logger.debug("No findings provided for headline generation") 

79 return None 

80 

81 # Use the COMPLETE findings - no character limit 

82 findings_preview = findings 

83 logger.debug(f"Generating headline with {len(findings)} chars of findings") 

84 

85 return f"""Generate a comprehensive news headline that captures the key events from the research report below. 

86 

87Research Findings: 

88{findings_preview} 

89 

90Requirements: 

91- Include MULTIPLE major events if several important things happened (e.g., "Earthquake Strikes California While Wildfires Rage; Global Markets Tumble Amid Political Tensions") 

92- Capture as much important information as possible in the headline 

93- Be specific about locations, impacts, and key details 

94- Professional news headline style but can be longer to include more information 

95- Focus on the most impactful findings from the report 

96- Use semicolons or commas to separate multiple major events 

97- No quotes or punctuation at start/end 

98- Base the headline ONLY on the actual findings in the report 

99 

100Generate only the headline text, nothing else.""" 

101 

102 

103def _clean_headline(response: Any) -> Optional[str]: 

104 """Clean and validate the headline the LLM returned.""" 

105 headline = get_llm_response_text(response).strip() 

106 

107 # Clean up the generated headline 

108 headline = headline.strip("\"'.,!?") 

109 

110 # Validate the headline 

111 if headline: 

112 logger.debug(f"Generated headline: {headline}") 

113 return headline 

114 

115 return None 

116 

117 

118def _generate_with_llm( 

119 query: str, 

120 findings: str, 

121 max_length: int, 

122 settings_snapshot: Optional[dict] = None, 

123) -> Optional[str]: 

124 """Generate headline using LLM (sync path).""" 

125 try: 

126 from ...config.llm_config import get_llm 

127 

128 # Use the configured model for headline generation 

129 llm = get_llm(temperature=0.3, settings_snapshot=settings_snapshot) 

130 

131 try: 

132 prompt = _build_headline_prompt(findings) 

133 if prompt is None: 

134 return None 

135 

136 response = llm.invoke(prompt) 

137 return _clean_headline(response) 

138 finally: 

139 from ...utilities.resource_utils import safe_close 

140 

141 safe_close(llm, "headline LLM") 

142 

143 except Exception as e: 

144 logger.debug(f"LLM headline generation failed: {e}") 

145 

146 return None 

147 

148 

149async def _generate_with_llm_async( 

150 query: str, 

151 findings: str, 

152 max_length: int, 

153 settings_snapshot: Optional[dict] = None, 

154) -> Optional[str]: 

155 """Generate headline using LLM (async path). 

156 

157 Mirrors :func:`_generate_with_llm`; only the LLM call line differs. 

158 """ 

159 try: 

160 from ...config.llm_config import get_llm 

161 

162 # Use the configured model for headline generation 

163 llm = get_llm(temperature=0.3, settings_snapshot=settings_snapshot) 

164 

165 try: 

166 prompt = _build_headline_prompt(findings) 

167 if prompt is None: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 return None 

169 

170 response = await llm.ainvoke(prompt) 

171 return _clean_headline(response) 

172 finally: 

173 from ...utilities.resource_utils import safe_close 

174 

175 safe_close(llm, "headline LLM") 

176 

177 except Exception as e: 

178 logger.debug(f"LLM headline generation failed: {e}") 

179 

180 return None