Coverage for lintro / ai / display / summary.py: 93%

130 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Summary rendering for terminal, GitHub Actions, and Markdown.""" 

2 

3from __future__ import annotations 

4 

5import io 

6 

7from rich.console import Console, Group, RenderableType 

8from rich.markup import escape 

9from rich.panel import Panel 

10 

11from lintro.ai.cost import format_cost 

12from lintro.ai.display.shared import ( 

13 LEADING_NUMBER_RE, 

14 cost_str, 

15 is_github_actions, 

16 print_section_header, 

17) 

18from lintro.ai.models import AISummary 

19from lintro.utils.console.constants import BORDER_LENGTH 

20 

21 

22def render_summary_terminal( 

23 summary: AISummary, 

24 *, 

25 show_cost: bool = True, 

26) -> str: 

27 """Render AI summary for terminal output. 

28 

29 Uses Rich Panels with a structured layout for overview, 

30 key patterns, priority actions, and effort estimate. 

31 

32 Args: 

33 summary: AI summary to render. 

34 show_cost: Whether to show cost estimates. 

35 

36 Returns: 

37 Formatted string for terminal display. 

38 """ 

39 if not summary.overview: 

40 return "" 

41 

42 buf = io.StringIO() 

43 console = Console( 

44 file=buf, 

45 force_terminal=True, 

46 highlight=False, 

47 width=BORDER_LENGTH, 

48 ) 

49 

50 cost_info = ( 

51 cost_str(summary.input_tokens, summary.output_tokens, summary.cost_estimate) 

52 if show_cost 

53 else "" 

54 ) 

55 

56 print_section_header( 

57 console, 

58 "\U0001f9e0", 

59 "AI SUMMARY", 

60 "actionable insights", 

61 cost_info=cost_info, 

62 ) 

63 

64 parts: list[RenderableType] = [] 

65 

66 # Overview 

67 parts.append(f"[cyan]{escape(summary.overview)}[/cyan]") 

68 

69 # Key patterns 

70 if summary.key_patterns: 

71 parts.append("") 

72 parts.append("[bold yellow]Key Patterns:[/bold yellow]") 

73 for pattern in summary.key_patterns: 

74 parts.append(f" [yellow]\u2022[/yellow] {escape(pattern)}") 

75 

76 # Priority actions 

77 if summary.priority_actions: 

78 parts.append("") 

79 parts.append("[bold green]Priority Actions:[/bold green]") 

80 for i, action in enumerate(summary.priority_actions, 1): 

81 clean = LEADING_NUMBER_RE.sub("", action) 

82 parts.append(f" [green]{i}.[/green] {escape(clean)}") 

83 

84 # Triage suggestions 

85 if summary.triage_suggestions: 

86 parts.append("") 

87 parts.append("[bold magenta]Triage \u2014 Consider Suppressing:[/bold magenta]") 

88 for suggestion in summary.triage_suggestions: 

89 clean = LEADING_NUMBER_RE.sub("", suggestion) 

90 parts.append(f" [magenta]~[/magenta] {escape(clean)}") 

91 

92 # Effort estimate 

93 if summary.estimated_effort: 

94 parts.append("") 

95 parts.append( 

96 f"[dim]Estimated effort: {escape(summary.estimated_effort)}[/dim]", 

97 ) 

98 

99 content: RenderableType = Group(*parts) if len(parts) > 1 else parts[0] 

100 console.print( 

101 Panel( 

102 content, 

103 border_style="cyan", 

104 padding=(0, 1), 

105 ), 

106 ) 

107 

108 return buf.getvalue() 

109 

110 

111def render_summary_github( 

112 summary: AISummary, 

113 *, 

114 show_cost: bool = True, 

115) -> str: 

116 """Render AI summary for GitHub Actions logs. 

117 

118 Args: 

119 summary: AI summary to render. 

120 show_cost: Whether to show cost estimates. 

121 

122 Returns: 

123 Formatted string with GitHub Actions group markers. 

124 """ 

125 if not summary.overview: 

126 return "" 

127 

128 lines: list[str] = [] 

129 lines.append("::group::AI Summary \u2014 actionable insights") 

130 lines.append("") 

131 lines.append(summary.overview) 

132 

133 if summary.key_patterns: 

134 lines.append("") 

135 lines.append("Key Patterns:") 

136 for pattern in summary.key_patterns: 

137 lines.append(f" \u2022 {pattern}") 

138 

139 if summary.priority_actions: 

140 lines.append("") 

141 lines.append("Priority Actions:") 

142 for i, action in enumerate(summary.priority_actions, 1): 

143 clean = LEADING_NUMBER_RE.sub("", action) 

144 lines.append(f" {i}. {clean}") 

145 

146 if summary.triage_suggestions: 

147 lines.append("") 

148 lines.append("Triage \u2014 Consider Suppressing:") 

149 for suggestion in summary.triage_suggestions: 

150 clean = LEADING_NUMBER_RE.sub("", suggestion) 

151 lines.append(f" ~ {clean}") 

152 

153 if summary.estimated_effort: 

154 lines.append("") 

155 lines.append(f"Estimated effort: {summary.estimated_effort}") 

156 

157 if show_cost and summary.cost_estimate > 0: 

158 lines.append("") 

159 lines.append(f"AI cost: {format_cost(summary.cost_estimate)}") 

160 

161 lines.append("::endgroup::") 

162 

163 return "\n".join(lines) 

164 

165 

166def render_summary_markdown( 

167 summary: AISummary, 

168 *, 

169 show_cost: bool = True, 

170) -> str: 

171 """Render AI summary as Markdown with collapsible section. 

172 

173 Args: 

174 summary: AI summary to render. 

175 show_cost: Whether to show cost estimates. 

176 

177 Returns: 

178 Markdown-formatted string. 

179 """ 

180 if not summary.overview: 

181 return "" 

182 

183 lines: list[str] = [] 

184 lines.append("### AI Summary") 

185 lines.append("") 

186 lines.append("<details>") 

187 lines.append("<summary><b>Actionable insights</b></summary>") 

188 lines.append("") 

189 lines.append(summary.overview) 

190 

191 if summary.key_patterns: 

192 lines.append("") 

193 lines.append("**Key Patterns:**") 

194 lines.append("") 

195 for pattern in summary.key_patterns: 

196 lines.append(f"- {pattern}") 

197 

198 if summary.priority_actions: 

199 lines.append("") 

200 lines.append("**Priority Actions:**") 

201 lines.append("") 

202 for i, action in enumerate(summary.priority_actions, 1): 

203 clean = LEADING_NUMBER_RE.sub("", action) 

204 lines.append(f"{i}. {clean}") 

205 

206 if summary.triage_suggestions: 

207 lines.append("") 

208 lines.append("**Triage \u2014 Consider Suppressing:**") 

209 lines.append("") 

210 for suggestion in summary.triage_suggestions: 

211 clean = LEADING_NUMBER_RE.sub("", suggestion) 

212 lines.append(f"- {clean}") 

213 

214 if summary.estimated_effort: 

215 lines.append("") 

216 lines.append(f"*Estimated effort: {summary.estimated_effort}*") 

217 

218 lines.append("") 

219 lines.append("</details>") 

220 

221 if show_cost and summary.cost_estimate > 0: 

222 lines.append("") 

223 lines.append(f"*AI cost: {format_cost(summary.cost_estimate)}*") 

224 

225 return "\n".join(lines) 

226 

227 

228def render_summary_annotations(summary: AISummary) -> str: 

229 """Emit GitHub Actions annotation commands for AI summary insights. 

230 

231 Key patterns are emitted as ``::warning`` and priority actions as 

232 ``::notice`` annotations so they surface in the Actions UI. 

233 

234 Args: 

235 summary: AI summary to annotate. 

236 

237 Returns: 

238 Newline-joined annotation commands, or empty string if the 

239 summary has no actionable insights. 

240 """ 

241 if not summary.overview: 

242 return "" 

243 

244 lines: list[str] = [] 

245 

246 for pattern in summary.key_patterns: 

247 escaped = ( 

248 pattern.replace("%", "%25") 

249 .replace("\r", "%0D") 

250 .replace("\n", "%0A") 

251 .replace("::", ":\u200b:") 

252 ) 

253 lines.append(f"::warning title=AI Pattern::{escaped}") 

254 

255 for action in summary.priority_actions: 

256 clean = LEADING_NUMBER_RE.sub("", action) 

257 escaped = ( 

258 clean.replace("%", "%25") 

259 .replace("\r", "%0D") 

260 .replace("\n", "%0A") 

261 .replace("::", ":\u200b:") 

262 ) 

263 lines.append(f"::notice title=AI Priority::{escaped}") 

264 

265 return "\n".join(lines) 

266 

267 

268def render_summary( 

269 summary: AISummary, 

270 *, 

271 show_cost: bool = True, 

272 output_format: str = "auto", 

273) -> str: 

274 """Render summary using the appropriate format for the environment. 

275 

276 Args: 

277 summary: AI summary to render. 

278 show_cost: Whether to show cost estimates. 

279 output_format: Output format -- ``"auto"`` (default) selects 

280 terminal or GitHub Actions based on environment, 

281 ``"markdown"`` uses Markdown with collapsible section. 

282 

283 Returns: 

284 Formatted summary string. 

285 """ 

286 if output_format == "markdown": 

287 return render_summary_markdown(summary, show_cost=show_cost) 

288 if is_github_actions(): 

289 return render_summary_github(summary, show_cost=show_cost) 

290 return render_summary_terminal(summary, show_cost=show_cost)