Coverage for src/ecnl/adapters/inbound/formatters.py: 96%

78 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-28 08:19 +0000

1"""Formatters — domain models -> human/LLM-readable strings. 

2 

3Each ``_fmt_*`` takes a domain result and returns a compact text block. Keeping 

4formatting here (out of the tool functions) means the tool bodies stay one line 

5and the wire format is consistent across tools. 

6""" 

7 

8import json 

9from typing import Any 

10 

11from ...domain.models import ( 

12 Club, 

13 Event, 

14 EventOverview, 

15 Match, 

16 MatchResult, 

17 Standings, 

18 Team, 

19 TeamRPI, 

20) 

21 

22 

23def _fmt_events(events: list[Event]) -> str: 

24 """Format a list of discovered events.""" 

25 if not events: 

26 return "No matching events found." 

27 lines = [f"{len(events)} event(s):"] 

28 for e in events: 

29 loc = f"{e.location}" if e.location else "" 

30 lines.append(f" [{e.event_id}] {e.league} {e.gender} · {e.conference} · {e.season}{loc}") 

31 return "\n".join(lines) 

32 

33 

34def _fmt_event_overview(overview: EventOverview) -> str: 

35 """Format an event's division/flight tree.""" 

36 e = overview.event 

37 lines = [ 

38 f"{e.name} [event {e.event_id}]", 

39 f" league={e.league} gender={e.gender} conference={e.conference} season={e.season}", 

40 ] 

41 if not overview.divisions: 

42 lines.append(" (no divisions published)") 

43 for d in overview.divisions: 

44 lines.append(f" Division {d.name} [{d.division_id}] ({d.gender}):") 

45 for f in d.flights: 

46 sched = "schedule active" if f.has_active_schedule else "no schedule yet" 

47 lines.append(f" flight [{f.flight_id}] {f.name}{f.teams_count} teams, {sched}") 

48 return "\n".join(lines) 

49 

50 

51def _fmt_standings(standings: Standings) -> str: 

52 """Format a flight standings table.""" 

53 if not standings.rows: 

54 return f"No standings available for flight {standings.flight_id}." 

55 lines = [ 

56 f"Standings — event {standings.event_id}, division {standings.division_id}, flight {standings.flight_id}:", 

57 f" {'#':>2} {'Team':<34} {'W-L-D':>8} {'Pts':>4} {'PPG':>5}", 

58 ] 

59 for i, r in enumerate(standings.rows, start=1): 

60 rank = r.rank if r.rank is not None else i 

61 record = f"{r.wins}-{r.losses}-{r.draws}" 

62 lines.append(f" {rank:>2} {r.team_name[:34]:<34} {record:>8} {r.points:>4} {r.points_per_game:>5.2f}") 

63 return "\n".join(lines) 

64 

65 

66def _fmt_match_line(m: Match) -> str: 

67 """Format a single match as one line.""" 

68 when = " ".join(x for x in (m.date, m.time) if x) or "TBD" 

69 score = f"{m.home_score}-{m.away_score}" if m.is_played else "vs" 

70 venue = f" @ {m.venue}" if m.venue else "" 

71 return f" {when}: {m.home_team} {score} {m.away_team}{venue}" 

72 

73 

74def _fmt_schedule(matches: list[Match]) -> str: 

75 """Format a flight or team schedule.""" 

76 if not matches: 

77 return "No matches scheduled." 

78 return "\n".join([f"{len(matches)} match(es):", *(_fmt_match_line(m) for m in matches)]) 

79 

80 

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

82 """Format a team list.""" 

83 if not teams: 

84 return "No teams found." 

85 lines = [f"{len(teams)} team(s):"] 

86 for t in teams: 

87 coach = f" — coach {t.head_coach}" if t.head_coach else "" 

88 lines.append(f" [{t.team_id}] {t.name}{coach}") 

89 return "\n".join(lines) 

90 

91 

92def _fmt_clubs(clubs: list[Club]) -> str: 

93 """Format a club list.""" 

94 if not clubs: 

95 return "No clubs found." 

96 lines = [f"{len(clubs)} club(s):"] 

97 for c in clubs: 

98 where = ", ".join(x for x in (c.city, c.state_code) if x) 

99 where = f" ({where})" if where else "" 

100 lines.append(f" [{c.club_id}] {c.name}{where}") 

101 return "\n".join(lines) 

102 

103 

104def _fmt_results(results: list[MatchResult]) -> str: 

105 """Format extracted completed results.""" 

106 if not results: 

107 return "No completed matches yet." 

108 lines = [f"{len(results)} completed match(es):"] 

109 for r in results: 

110 lines.append(f" {r.home_team} {r.home_score}-{r.away_score} {r.away_team}") 

111 return "\n".join(lines) 

112 

113 

114def _fmt_rpi(table: list[TeamRPI]) -> str: 

115 """Format a full RPI table.""" 

116 if not table: 

117 return "No completed matches — RPI cannot be computed yet." 

118 lines = [ 

119 "RPI = 0.25·WP + 0.50·OWP + 0.25·OOWP", 

120 f" {'#':>2} {'Team':<34} {'W-L-D':>8} {'WP':>6} {'OWP':>6} {'OOWP':>6} {'RPI':>6}", 

121 ] 

122 for r in table: 

123 record = f"{r.wins}-{r.losses}-{r.draws}" 

124 lines.append( 

125 f" {r.rank:>2} {r.team[:34]:<34} {record:>8} {r.wp:>6.3f} {r.owp:>6.3f} {r.oowp:>6.3f} {r.rpi:>6.3f}" 

126 ) 

127 return "\n".join(lines) 

128 

129 

130def _fmt_team_rpi(r: TeamRPI) -> str: 

131 """Format a single team's RPI breakdown.""" 

132 return ( 

133 f"{r.team} — RPI {r.rpi:.4f} (rank {r.rank})\n" 

134 f" record: {r.wins}-{r.losses}-{r.draws}\n" 

135 f" WP (0.25): {r.wp:.4f}\n" 

136 f" OWP (0.50): {r.owp:.4f}\n" 

137 f" OOWP (0.25): {r.oowp:.4f}" 

138 ) 

139 

140 

141def _fmt_raw(data: Any) -> str: 

142 """Format an unstructured payload (match detail, brackets) as pretty JSON.""" 

143 if not data: 

144 return "No data available." 

145 return json.dumps(data, indent=2, default=str)