Coverage for src/epl/adapters/inbound/formatters.py: 91%

121 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-05 10:11 +0000

1"""Pure formatters that turn domain models into LLM-readable text. 

2 

3Extracted from mcp_adapter.py so the adapter focuses on tool wiring while the 

4presentation layer stays a side-effect-free, easily testable concern. 

5""" 

6 

7from ...domain.models import ( 

8 AdjustedPointsPerGame, 

9 Match, 

10 MatchCompetitor, 

11 MatchDetails, 

12 MatchEvent, 

13 NewsArticle, 

14 OpponentPPG, 

15 Player, 

16 PulselivePlayer, 

17 ResultsByOpponentTier, 

18 Standing, 

19 StrengthOfSchedule, 

20 Team, 

21 TierRecord, 

22) 

23 

24 

25def _fmt_pulselive_player(i: int, p: PulselivePlayer) -> str: 

26 """Format one Pulselive player row as a compact one-liner.""" 

27 shirt = f"#{p.shirt_number}" if p.shirt_number is not None else " " 

28 pos = f" ({p.position})" if p.position else "" 

29 club = f" — {p.current_team_name}" if p.current_team_name else "" 

30 loan = " [loan]" if p.on_loan else "" 

31 nat = f", {p.nationality}" if p.nationality else "" 

32 return f"{i}. {shirt} {p.full_name}{pos}{club}{loan}{nat}" 

33 

34 

35def _fmt_pulselive_players(players: list[PulselivePlayer]) -> str: 

36 """Format a list of Pulselive players as a numbered list.""" 

37 if not players: 

38 return "No players found." 

39 return "\n".join(_fmt_pulselive_player(i, p) for i, p in enumerate(players, 1)) 

40 

41 

42def _fmt_team(team: Team) -> str: 

43 """Format a single Team as a labeled key-value block.""" 

44 lines = [ 

45 f"ID: {team.id}", 

46 f"Name: {team.display_name}", 

47 f"Abbreviation: {team.abbreviation}", 

48 f"Location: {team.location}", 

49 ] 

50 if team.logo_url: 

51 lines.append(f"Logo: {team.logo_url}") 

52 return "\n".join(lines) 

53 

54 

55def _fmt_teams(teams: list[Team]) -> str: 

56 """Format a list of teams as a numbered list.""" 

57 if not teams: 

58 return "No teams found." 

59 entries = [f"{i}. {t.display_name} ({t.abbreviation}) — ID: {t.id}" for i, t in enumerate(teams, 1)] 

60 return "\n".join(entries) 

61 

62 

63def _fmt_competitor(comp: MatchCompetitor) -> str: 

64 """Format one side of a match: team name, score, and home/away label.""" 

65 score_str = f" {comp.score}" if comp.score is not None else "" 

66 winner_str = " ✓" if comp.winner else "" 

67 return f"{comp.team.display_name}{score_str}{winner_str} ({comp.home_away})" 

68 

69 

70def _fmt_match(match: Match) -> str: 

71 """Format a single Match as a readable summary.""" 

72 competitor_lines = "\n ".join(_fmt_competitor(c) for c in match.competitors) 

73 return ( 

74 f"Match: {match.name}\n" 

75 f" ID: {match.id}\n" 

76 f" Date: {match.date}\n" 

77 f" Status: {match.status_detail}\n" 

78 f" Competitors:\n {competitor_lines}" 

79 ) 

80 

81 

82def _fmt_scoreboard(matches: list[Match]) -> str: 

83 """Format a list of matches for the scoreboard tool.""" 

84 if not matches: 

85 return "No matches found for the requested date." 

86 return "\n\n".join(_fmt_match(m) for m in matches) 

87 

88 

89def _fmt_team_schedule(matches: list[Match]) -> str: 

90 """Format a list of matches for the team-schedule tool.""" 

91 if not matches: 

92 return "No scheduled matches found for this team." 

93 return "\n\n".join(_fmt_match(m) for m in matches) 

94 

95 

96def _fmt_player(i: int, player: Player) -> str: 

97 """Format a single roster row.""" 

98 jersey = f"#{player.jersey}" if player.jersey else " " 

99 pos = f" ({player.position_abbr})" if player.position_abbr else "" 

100 extras = [] 

101 if player.position: 

102 extras.append(player.position) 

103 if player.citizenship: 

104 extras.append(player.citizenship) 

105 if player.age is not None: 

106 extras.append(f"age {player.age}") 

107 suffix = f" — {', '.join(extras)}" if extras else "" 

108 return f"{i}. {jersey} {player.full_name}{pos}{suffix}" 

109 

110 

111def _fmt_roster(players: list[Player]) -> str: 

112 """Format a roster as a numbered list.""" 

113 if not players: 

114 return "No players found for this team." 

115 return "\n".join(_fmt_player(i, p) for i, p in enumerate(players, 1)) 

116 

117 

118def _fmt_event(event: MatchEvent) -> str: 

119 """Format a single key event as a one-line summary.""" 

120 marker = "⚽" if event.scoring else "•" 

121 parts = [f" {marker} {event.clock}"] 

122 if event.team_name: 

123 parts.append(f"({event.team_name})") 

124 parts.append(event.text or event.type) 

125 return " ".join(parts) 

126 

127 

128def _fmt_venue_line(details: MatchDetails) -> str | None: 

129 """Build the venue line, or return None if no venue is set.""" 

130 if not details.venue: 

131 return None 

132 if details.venue_city: 

133 return f" Venue: {details.venue} ({details.venue_city})" 

134 return f" Venue: {details.venue}" 

135 

136 

137def _fmt_match_details(details: MatchDetails) -> str: 

138 """Format a MatchDetails as a readable multi-line summary.""" 

139 score = f"{details.home_score or '?'} - {details.away_score or '?'}" 

140 lines = [ 

141 f"{details.home_team} {score} {details.away_team}", 

142 f" Date: {details.date}", 

143 f" Status: {details.status_detail}", 

144 ] 

145 venue_line = _fmt_venue_line(details) 

146 if venue_line: 

147 lines.append(venue_line) 

148 if details.attendance is not None: 

149 lines.append(f" Attendance: {details.attendance:,}") 

150 if details.key_events: 

151 lines.append(" Key events:") 

152 lines.extend(_fmt_event(e) for e in details.key_events) 

153 return "\n".join(lines) 

154 

155 

156def _fmt_article(i: int, article: NewsArticle) -> str: 

157 """Format a single news article as a multi-line entry.""" 

158 lines = [f"{i}. {article.headline}"] 

159 if article.published: 

160 lines.append(f" Published: {article.published}") 

161 if article.description: 

162 lines.append(f" {article.description}") 

163 if article.link: 

164 lines.append(f" {article.link}") 

165 return "\n".join(lines) 

166 

167 

168def _fmt_news(articles: list[NewsArticle]) -> str: 

169 """Format a list of news articles.""" 

170 if not articles: 

171 return "No news articles available." 

172 return "\n\n".join(_fmt_article(i, a) for i, a in enumerate(articles, 1)) 

173 

174 

175def _fmt_standing(i: int, standing: Standing) -> str: 

176 """Format a single standings row.""" 

177 return ( 

178 f"{i}. {standing.team.display_name} ({standing.team.abbreviation})" 

179 f" — {standing.points} pts" 

180 f" | W:{standing.wins} L:{standing.losses} T:{standing.ties}" 

181 f" | GF:{standing.goals_for} GA:{standing.goals_against} GD:{standing.goal_difference:+d}" 

182 ) 

183 

184 

185def _fmt_standings(standings: list[Standing]) -> str: 

186 """Format the Premier League standings as a single numbered list.""" 

187 if not standings: 

188 return "No standings data available." 

189 return "\n".join(_fmt_standing(i, s) for i, s in enumerate(standings, 1)) 

190 

191 

192def _fmt_opponent_row(opp: OpponentPPG, meetings: int) -> str: 

193 """Format one opponent row, marking repeat fixtures as 'x2', 'x3', etc.""" 

194 suffix = f" x{meetings}" if meetings > 1 else "" 

195 return f" - {opp.team.display_name}{suffix}: {opp.points_per_game:.2f} ({opp.points} pts in {opp.matches_played} GP)" 

196 

197 

198def _fmt_strength_of_schedule(sos: StrengthOfSchedule) -> str: 

199 """Format a StrengthOfSchedule as a labeled summary plus opponent breakdown. 

200 

201 Repeat fixtures (an opponent met twice via home + away) collapse into a 

202 single row marked ``x2`` — the average PPG calculation already weights 

203 them correctly, so the output stays tidy without losing information. 

204 """ 

205 if sos.matches_played == 0: 

206 return f"{sos.team.display_name}: no matches played yet — strength of schedule unavailable." 

207 counts: dict[str, int] = {} 

208 unique: list[OpponentPPG] = [] 

209 for opp in sos.opponents: 

210 if opp.team.id in counts: 

211 counts[opp.team.id] += 1 

212 else: 

213 counts[opp.team.id] = 1 

214 unique.append(opp) 

215 lines = [ 

216 f"{sos.team.display_name} — Strength of Schedule", 

217 f" Matches played: {sos.matches_played}", 

218 f" Average opponent PPG: {sos.average_opponent_ppg:.2f}", 

219 " Opponents faced (current PPG):", 

220 ] 

221 lines.extend(_fmt_opponent_row(opp, counts[opp.team.id]) for opp in unique) 

222 return "\n".join(lines) 

223 

224 

225def _fmt_tier_record(t: TierRecord) -> str: 

226 """Format a single TierRecord row as 'Label (ranks N-M): W-L-T'.""" 

227 return f" {t.label} (ranks {t.rank_low}-{t.rank_high}): {t.wins}-{t.losses}-{t.ties}" 

228 

229 

230def _fmt_results_by_tier(rbt: ResultsByOpponentTier) -> str: 

231 """Format a ResultsByOpponentTier as a labeled W-L-T breakdown by tier.""" 

232 lines = [f"{rbt.team.display_name} — Results by Opponent Tier (tier size: {rbt.tier_size})"] 

233 lines.extend(_fmt_tier_record(t) for t in rbt.tiers) 

234 return "\n".join(lines) 

235 

236 

237def _fmt_adjusted_ppg(a: AdjustedPointsPerGame) -> str: 

238 """Format an AdjustedPointsPerGame as a labeled summary.""" 

239 return ( 

240 f"{a.team.display_name} — Adjusted Points Per Game\n" 

241 f" Record: {a.points} pts in {a.matches_played} GP\n" 

242 f" Raw PPG: {a.raw_ppg:.2f}\n" 

243 f" Average opponent PPG: {a.average_opponent_ppg:.2f}\n" 

244 f" League average PPG: {a.league_average_ppg:.2f}\n" 

245 f" Adjusted PPG: {a.adjusted_ppg:.2f}" 

246 )