Coverage for src/usls/adapters/inbound/formatters.py: 98%

110 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 ResultsByOpponentTier, 

17 Standing, 

18 StrengthOfSchedule, 

19 Team, 

20 TierRecord, 

21) 

22 

23 

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

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

26 lines = [ 

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

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

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

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

31 ] 

32 if team.logo_url: 

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

34 return "\n".join(lines) 

35 

36 

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

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

39 if not teams: 

40 return "No teams found." 

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

42 return "\n".join(entries) 

43 

44 

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

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

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

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

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

50 

51 

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

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

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

55 return ( 

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

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

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

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

60 f" Competitors:\n {competitor_lines}" 

61 ) 

62 

63 

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

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

66 if not matches: 

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

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

69 

70 

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

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

73 if not matches: 

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

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

76 

77 

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

79 """Format a single roster row.""" 

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

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

82 extras = [] 

83 if player.position: 

84 extras.append(player.position) 

85 if player.citizenship: 

86 extras.append(player.citizenship) 

87 if player.age is not None: 

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

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

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

91 

92 

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

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

95 if not players: 

96 return "No players found for this team." 

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

98 

99 

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

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

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

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

104 if event.team_name: 

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

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

107 return " ".join(parts) 

108 

109 

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

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

112 if not details.venue: 

113 return None 

114 if details.venue_city: 

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

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

117 

118 

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

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

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

122 lines = [ 

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

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

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

126 ] 

127 venue_line = _fmt_venue_line(details) 

128 if venue_line: 

129 lines.append(venue_line) 

130 if details.attendance is not None: 

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

132 if details.key_events: 

133 lines.append(" Key events:") 

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

135 return "\n".join(lines) 

136 

137 

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

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

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

141 if article.published: 

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

143 if article.description: 

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

145 if article.link: 

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

147 return "\n".join(lines) 

148 

149 

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

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

152 if not articles: 

153 return "No news articles available." 

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

155 

156 

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

158 """Format a single standings row.""" 

159 return ( 

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

161 f"{standing.points} pts" 

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

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

164 ) 

165 

166 

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

168 """Format the USL Super League standings as a single numbered list.""" 

169 if not standings: 

170 return "No standings data available." 

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

172 

173 

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

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

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

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

178 

179 

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

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

182 

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

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

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

186 """ 

187 if sos.matches_played == 0: 

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

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

190 unique: list[OpponentPPG] = [] 

191 for opp in sos.opponents: 

192 if opp.team.id in counts: 

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

194 else: 

195 counts[opp.team.id] = 1 

196 unique.append(opp) 

197 lines = [ 

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

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

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

201 " Opponents faced (current PPG):", 

202 ] 

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

204 return "\n".join(lines) 

205 

206 

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

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

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

210 

211 

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

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

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

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

216 return "\n".join(lines) 

217 

218 

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

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

221 return ( 

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

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

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

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

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

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

228 )