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
« 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.
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"""
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)
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}"
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))
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)
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)
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})"
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 )
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)
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)
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}"
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))
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)
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}"
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)
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)
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))
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 )
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))
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)"
198def _fmt_strength_of_schedule(sos: StrengthOfSchedule) -> str:
199 """Format a StrengthOfSchedule as a labeled summary plus opponent breakdown.
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)
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}"
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)
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 )