fplkit 2.6.0__tar.gz → 2.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. {fplkit-2.6.0 → fplkit-2.6.1}/CHANGELOG.md +13 -0
  2. {fplkit-2.6.0 → fplkit-2.6.1}/PKG-INFO +1 -1
  3. {fplkit-2.6.0 → fplkit-2.6.1}/docs/architecture.md +1 -1
  4. {fplkit-2.6.0 → fplkit-2.6.1}/docs/command-reference.md +8 -3
  5. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/_version.py +2 -2
  6. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_review_summarisation.py +52 -12
  7. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/doctor.py +7 -3
  8. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/config/defaults.yaml +1 -1
  9. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/team_ratings.py +61 -6
  10. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/templates/gw_review.md.j2 +2 -2
  11. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_doctor.py +95 -0
  12. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_review.py +84 -7
  13. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_providers.py +16 -0
  14. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_report_agent.py +6 -6
  15. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_team_ratings.py +124 -1
  16. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/README.md +0 -0
  17. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/TOOLS.md +0 -0
  18. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/build-test-plan/SKILL.md +0 -0
  19. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/build-test-plan/references/plan-template.md +0 -0
  20. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/build-test-plan/references/workspace-env.md +0 -0
  21. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/create-pr/SKILL.md +0 -0
  22. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/SKILL.md +0 -0
  23. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/references/entity-normalisation.md +0 -0
  24. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/references/output-template.md +0 -0
  25. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/references/rules.md +0 -0
  26. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/_bootstrap.py +0 -0
  27. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
  28. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
  29. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/normalise_entities.py +0 -0
  30. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
  31. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
  32. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
  33. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/preview-ingest/SKILL.md +0 -0
  34. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/release/SKILL.md +0 -0
  35. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/release-notes/SKILL.md +0 -0
  36. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/squad-builder/SKILL.md +0 -0
  37. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/squad-builder/references/output-template.md +0 -0
  38. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/squad-builder/references/rules.md +0 -0
  39. {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/update-gw-prep/SKILL.md +0 -0
  40. {fplkit-2.6.0 → fplkit-2.6.1}/.claude/output-styles/fpl-mate.md +0 -0
  41. {fplkit-2.6.0 → fplkit-2.6.1}/.claude/settings.json +0 -0
  42. {fplkit-2.6.0 → fplkit-2.6.1}/.env.example +0 -0
  43. {fplkit-2.6.0 → fplkit-2.6.1}/.github/workflows/ci.yml +0 -0
  44. {fplkit-2.6.0 → fplkit-2.6.1}/.github/workflows/pr-title.yml +0 -0
  45. {fplkit-2.6.0 → fplkit-2.6.1}/.github/workflows/release.yml +0 -0
  46. {fplkit-2.6.0 → fplkit-2.6.1}/.gitignore +0 -0
  47. {fplkit-2.6.0 → fplkit-2.6.1}/AGENTS.md +0 -0
  48. {fplkit-2.6.0 → fplkit-2.6.1}/CLAUDE.md +0 -0
  49. {fplkit-2.6.0 → fplkit-2.6.1}/CONTRIBUTING.md +0 -0
  50. {fplkit-2.6.0 → fplkit-2.6.1}/LICENSE +0 -0
  51. {fplkit-2.6.0 → fplkit-2.6.1}/README.md +0 -0
  52. {fplkit-2.6.0 → fplkit-2.6.1}/cliff.toml +0 -0
  53. {fplkit-2.6.0 → fplkit-2.6.1}/config/team_ratings_overrides.yaml +0 -0
  54. {fplkit-2.6.0 → fplkit-2.6.1}/docs/custom-analysis.md +0 -0
  55. {fplkit-2.6.0 → fplkit-2.6.1}/docs/fpl-rules.md +0 -0
  56. {fplkit-2.6.0 → fplkit-2.6.1}/docs/images/fpl-player-demo-v1-0.png +0 -0
  57. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/__init__.py +0 -0
  58. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/__init__.py +0 -0
  59. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/action/__init__.py +0 -0
  60. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/action/waiver.py +0 -0
  61. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/__init__.py +0 -0
  62. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/bench_order.py +0 -0
  63. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/captain.py +0 -0
  64. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
  65. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/starting_xi.py +0 -0
  66. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/stats.py +0 -0
  67. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
  68. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/base.py +0 -0
  69. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/common.py +0 -0
  70. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/data/__init__.py +0 -0
  71. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/data/fixture.py +0 -0
  72. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/data/price.py +0 -0
  73. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/data/scout.py +0 -0
  74. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/orchestration/__init__.py +0 -0
  75. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/orchestration/report.py +0 -0
  76. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/__init__.py +0 -0
  77. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/contract.py +0 -0
  78. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/core_insights.py +0 -0
  79. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/dataset_fetcher.py +0 -0
  80. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/football_data.py +0 -0
  81. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/fpl.py +0 -0
  82. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/fpl_draft.py +0 -0
  83. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/historical.py +0 -0
  84. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/historical_types.py +0 -0
  85. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/__init__.py +0 -0
  86. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/_http.py +0 -0
  87. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/_models.py +0 -0
  88. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/anthropic.py +0 -0
  89. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/openai_compat.py +0 -0
  90. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/perplexity.py +0 -0
  91. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/understat.py +0 -0
  92. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/vaastav.py +0 -0
  93. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/__init__.py +0 -0
  94. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_banner.py +0 -0
  95. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_context.py +0 -0
  96. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_fines.py +0 -0
  97. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_fines_config.py +0 -0
  98. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_helpers.py +0 -0
  99. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_json.py +0 -0
  100. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_league_recap_data.py +0 -0
  101. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_league_recap_history.py +0 -0
  102. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_league_recap_types.py +0 -0
  103. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_plan_grid.py +0 -0
  104. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_review_analysis.py +0 -0
  105. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_review_classic.py +0 -0
  106. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_review_draft.py +0 -0
  107. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/allocate.py +0 -0
  108. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/captain.py +0 -0
  109. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/chips.py +0 -0
  110. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/credentials.py +0 -0
  111. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/differentials.py +0 -0
  112. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/doctor_providers.py +0 -0
  113. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/fdr.py +0 -0
  114. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/fixtures.py +0 -0
  115. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/history.py +0 -0
  116. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/init.py +0 -0
  117. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/intel.py +0 -0
  118. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/league.py +0 -0
  119. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/league_fines.py +0 -0
  120. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/league_recap.py +0 -0
  121. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/player.py +0 -0
  122. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/preview.py +0 -0
  123. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/price_changes.py +0 -0
  124. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/price_history.py +0 -0
  125. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/ratings.py +0 -0
  126. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/returnees.py +0 -0
  127. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/review.py +0 -0
  128. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/sell_prices.py +0 -0
  129. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/squad.py +0 -0
  130. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/stats.py +0 -0
  131. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/status.py +0 -0
  132. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/targets.py +0 -0
  133. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/transfer_eval.py +0 -0
  134. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/waivers.py +0 -0
  135. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/xg.py +0 -0
  136. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/config/fixture_predictions.yaml +0 -0
  137. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/config/previews/EXAMPLE.yaml +0 -0
  138. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/config/team_managers.yaml +0 -0
  139. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/constants.py +0 -0
  140. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/__init__.py +0 -0
  141. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/chip_plan.py +0 -0
  142. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/fixture.py +0 -0
  143. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/league_history.py +0 -0
  144. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/player.py +0 -0
  145. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/team.py +0 -0
  146. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/types.py +0 -0
  147. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/parsers/__init__.py +0 -0
  148. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/parsers/recommendations.py +0 -0
  149. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/paths.py +0 -0
  150. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/__init__.py +0 -0
  151. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/league_recap.py +0 -0
  152. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/returnees.py +0 -0
  153. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/review.py +0 -0
  154. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/scout.py +0 -0
  155. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/scraper/__init__.py +0 -0
  156. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/scraper/fpl_prices.py +0 -0
  157. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/season.py +0 -0
  158. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/__init__.py +0 -0
  159. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/fixture_predictions.py +0 -0
  160. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/league_history.py +0 -0
  161. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/league_history_counters.py +0 -0
  162. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/league_history_fines.py +0 -0
  163. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/league_history_notes.py +0 -0
  164. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/matchup.py +0 -0
  165. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/player_clubs.py +0 -0
  166. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/player_prior.py +0 -0
  167. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/returnee_radar.py +0 -0
  168. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/__init__.py +0 -0
  169. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/constants.py +0 -0
  170. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/data_prep.py +0 -0
  171. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/display.py +0 -0
  172. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/evaluation.py +0 -0
  173. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/ownership.py +0 -0
  174. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/shrinkage.py +0 -0
  175. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/signals.py +0 -0
  176. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/single_gw.py +0 -0
  177. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/value_quality.py +0 -0
  178. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/season_previews.py +0 -0
  179. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/squad_allocator.py +0 -0
  180. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/team_form.py +0 -0
  181. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/team_ratings_prior.py +0 -0
  182. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
  183. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/templates/gw_preview.md.j2 +0 -0
  184. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/__init__.py +0 -0
  185. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/files.py +0 -0
  186. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/gameweek.py +0 -0
  187. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/markdown.py +0 -0
  188. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/teams.py +0 -0
  189. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/text.py +0 -0
  190. {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/time.py +0 -0
  191. {fplkit-2.6.0 → fplkit-2.6.1}/pyproject.toml +0 -0
  192. {fplkit-2.6.0 → fplkit-2.6.1}/pyrightconfig.json +0 -0
  193. {fplkit-2.6.0 → fplkit-2.6.1}/scripts/backtest_early_season_prior_blend.py +0 -0
  194. {fplkit-2.6.0 → fplkit-2.6.1}/scripts/calibrate_promoted_prior.py +0 -0
  195. {fplkit-2.6.0 → fplkit-2.6.1}/scripts/calibrate_quality_ceilings.py +0 -0
  196. {fplkit-2.6.0 → fplkit-2.6.1}/tests/__init__.py +0 -0
  197. {fplkit-2.6.0 → fplkit-2.6.1}/tests/conftest.py +0 -0
  198. {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/classic_squad_fixture.md +0 -0
  199. {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/recommendations_fixture.md +0 -0
  200. {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
  201. {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
  202. {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
  203. {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/waiver_ranked_golden.json +0 -0
  204. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_action.py +0 -0
  205. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_analysis.py +0 -0
  206. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_analysis_squad.py +0 -0
  207. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_base.py +0 -0
  208. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_data.py +0 -0
  209. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_data_price.py +0 -0
  210. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_orchestration.py +0 -0
  211. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_allocate.py +0 -0
  212. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_client.py +0 -0
  213. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_core_insights.py +0 -0
  214. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_dataset_fetcher.py +0 -0
  215. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_draft.py +0 -0
  216. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_football_data.py +0 -0
  217. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_historical.py +0 -0
  218. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_understat.py +0 -0
  219. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_vaastav.py +0 -0
  220. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_bench_order_script.py +0 -0
  221. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_calibrate_quality_ceilings.py +0 -0
  222. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_classic_squad_extraction.py +0 -0
  223. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_allocate.py +0 -0
  224. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_banner.py +0 -0
  225. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_captain.py +0 -0
  226. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_chips.py +0 -0
  227. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_context_settings.py +0 -0
  228. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_credentials.py +0 -0
  229. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_custom_analysis.py +0 -0
  230. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_differentials.py +0 -0
  231. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_doctor_providers.py +0 -0
  232. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_failure_parity.py +0 -0
  233. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_failure_streams.py +0 -0
  234. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_fdr.py +0 -0
  235. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_fdr_blanks.py +0 -0
  236. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_fixtures.py +0 -0
  237. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_format.py +0 -0
  238. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_format_suppression.py +0 -0
  239. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_fpl_block.py +0 -0
  240. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_history.py +0 -0
  241. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_init.py +0 -0
  242. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_intel.py +0 -0
  243. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_json.py +0 -0
  244. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_json_contract.py +0 -0
  245. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_league.py +0 -0
  246. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_league_fines.py +0 -0
  247. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_league_recap.py +0 -0
  248. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_plan_grid.py +0 -0
  249. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_player.py +0 -0
  250. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_preview.py +0 -0
  251. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_price_changes.py +0 -0
  252. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_price_history.py +0 -0
  253. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_returnees.py +0 -0
  254. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_sell_prices.py +0 -0
  255. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_settings_warning.py +0 -0
  256. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_squad.py +0 -0
  257. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_stats.py +0 -0
  258. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_status.py +0 -0
  259. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_targets.py +0 -0
  260. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_transfer_eval.py +0 -0
  261. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_waivers.py +0 -0
  262. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_warnings_stderr.py +0 -0
  263. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_xg.py +0 -0
  264. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_common.py +0 -0
  265. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_common_scoring.py +0 -0
  266. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_data_prep_harmonisation.py +0 -0
  267. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_dependencies.py +0 -0
  268. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_doc_consistency.py +0 -0
  269. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_fines.py +0 -0
  270. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_fines_config.py +0 -0
  271. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_fixture_predictions.py +0 -0
  272. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_fpl_prices.py +0 -0
  273. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_gw_prep_script_bootstrap.py +0 -0
  274. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_init.py +0 -0
  275. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_init_fines.py +0 -0
  276. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_history_counters.py +0 -0
  277. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_history_fines.py +0 -0
  278. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_history_notes.py +0 -0
  279. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_history_store.py +0 -0
  280. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_recap.py +0 -0
  281. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_matchup_service.py +0 -0
  282. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_models.py +0 -0
  283. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_normalise_entities.py +0 -0
  284. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_output_style_sync.py +0 -0
  285. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_paths.py +0 -0
  286. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_player_clubs.py +0 -0
  287. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_player_prior.py +0 -0
  288. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_player_resolution.py +0 -0
  289. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_preseason.py +0 -0
  290. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_prompt_tables.py +0 -0
  291. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_recommendations_parser.py +0 -0
  292. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_report_paths.py +0 -0
  293. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_returnee_radar.py +0 -0
  294. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_review.py +0 -0
  295. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_rolling_pts_per_m.py +0 -0
  296. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_scoring.py +0 -0
  297. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_scraper.py +0 -0
  298. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_season.py +0 -0
  299. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_season_awareness.py +0 -0
  300. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_season_previews.py +0 -0
  301. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_sell_prices.py +0 -0
  302. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_skill_fdr_quality_signals.py +0 -0
  303. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_skill_stats_thresholds.py +0 -0
  304. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_squad_allocator.py +0 -0
  305. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_starting_xi_agent.py +0 -0
  306. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_starting_xi_script.py +0 -0
  307. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_stats.py +0 -0
  308. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_team_ratings_prior.py +0 -0
  309. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_transfer_eval_agent.py +0 -0
  310. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_transfer_eval_script.py +0 -0
  311. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_utils_gameweek.py +0 -0
  312. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_utils_markdown.py +0 -0
  313. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_utils_text.py +0 -0
  314. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_utils_time.py +0 -0
  315. {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_validate_draft_waivers.py +0 -0
@@ -1,6 +1,19 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
+ ## [2.6.0] - 2026-09-07
5
+
6
+ ### Bug Fixes
7
+
8
+ - drop ratings update's dead -v hint for a kept prior (#311)
9
+ - carry the --format json envelope through a relative FPL_CLI_* rejection (#312)
10
+ - derive `fpl doctor`'s season from GW1's deadline like `fpl status` (#314)
11
+ - warn in the saved review when the summary came back empty (#313)
12
+
13
+ ### Features
14
+
15
+ - join Understat rows for players the two sources spell differently (#315)
16
+
4
17
  ## [2.5.0] - 2026-09-07
5
18
 
6
19
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fplkit
3
- Version: 2.6.0
3
+ Version: 2.6.1
4
4
  Summary: CLI tool for Fantasy Premier League analysis - classic and draft formats
5
5
  Project-URL: Homepage, https://github.com/rossgroomio/fpl-cli
6
6
  Project-URL: Repository, https://github.com/rossgroomio/fpl-cli
@@ -572,7 +572,7 @@ fpl_cli/
572
572
  │ │ ├── shrinkage.py # Single-GW early-season shrinkage + the shared hold-out helpers
573
573
  │ │ └── data_prep.py # ScoringContext / ScoringData + prepare_scoring_data()
574
574
  │ ├── player_prior.py # Player prior (Bayesian early-season confidence)
575
- │ ├── team_ratings.py # TeamRatingsService + Calculator (1-7 scale)
575
+ │ ├── team_ratings.py # TeamRatingsService + Calculator (1-7 scale). The service judges the file's `metadata.season` stamp against the season it is told is in progress, discarding a previous season's ratings outright (they rate relegated clubs, and serving them is worse than serving none) — which means the season it uses has to be the resolved one, not the clock: an overrunning season had its own live ratings thrown away from 1 July, dropping every fixture to a neutral 4.0 mid-season (#318). `ensure_fresh(client)` resolves it from GW1's deadline before anything else and re-reads if a property access already forced a load on the clock; `fpl doctor` passes the label it resolved; writes are stamped with the same season, so a file written during an overrun is not stamped into the future
576
576
  │ ├── team_ratings_prior.py # Previous-season prior + Bayesian blending (cutoff GW12); every pool it reads (the PL pool from either source, the Championship division) takes full-season records only, and every club's basis is written to the cache as `inputs`. The cache is invalidated by the methodology version, the league's club list, and a one-directional provenance check (`_inputs_have_improved`) that rebuilds only when an input the cached run lacked is available now — so setting `FOOTBALL_DATA_API_KEY` reaches the promoted sides without the file being deleted (#112). A rebuild is then held to `_is_worse_than_cache` (source rank and count of clubs on an estimate) before it may replace the cached prior, and `_record_attempt` stamps the file either way so a down provider costs one retry, not one per command
577
577
  │ ├── matchup.py # Fixture matchup scoring (0-10)
578
578
  │ ├── fixture_predictions.py # BGW/DGW predictions from YAML + live detection, plus the point-in-time "did his club have a fixture, and how many" read a past gameweek needs
@@ -412,7 +412,7 @@ A club that has played only one venue is rated on it. Once GW1 finishes, every c
412
412
 
413
413
  Early-season results are shrunk toward that previous-season prior, by the automatic refresh and by `fpl ratings update` alike: a one-gameweek sample carries 1/7 of the weight, six gameweeks half, and the prior drops out entirely once GW12 has completed. Shrinkage is gated on how far the season has run, so a narrow window late on is not blended -- `--since-gw 30` at GW34 saves recent form alone. Inside the early-season window the weight follows the size of that window rather than the gameweek number, so `--since-gw 8` at GW10 is weighted as three gameweeks of evidence. Blended files are stamped `calculated_blended` / `understat_xg_blended` / `auto_calculated_blended` so `fpl ratings` shows that shrinkage was applied. While the sample is shorter than six gameweeks the prior still carries most of the weight, and every fixture-difficulty view says so - `Ratings are mostly last season's prior - 1 gameweek of results carries 14% of the weight`. That replaces the pre-season estimate warning, which stops applying the moment a gameweek completes.
414
414
 
415
- Ratings are tied to the season that produced them. A file carried across a season boundary is ignored rather than served, because it rates the three relegated clubs and knows nothing about the three promoted ones. Ratings that cover the wrong set of clubs are called out by name — `team_ratings.yaml is missing COV, HUL, IPS and still rates BUR, WHU, WOL` — which catches a rollover that a "days old" check cannot: a file rebuilt in early August is new by date and still describes last season's league.
415
+ Ratings are tied to the season that produced them. A file carried across a season boundary is ignored rather than served, because it rates the three relegated clubs and knows nothing about the three promoted ones. Which season is "in progress" comes from GW1's deadline, not the July clock, so a season overrunning 1 July keeps its own ratings instead of having them discarded with a third of the run-in still to play. Ratings that cover the wrong set of clubs are called out by name — `team_ratings.yaml is missing COV, HUL, IPS and still rates BUR, WHU, WOL` — which catches a rollover that a "days old" check cannot: a file rebuilt in early August is new by date and still describes last season's league.
416
416
 
417
417
  See [Team Ratings](custom-analysis.md#team-ratings) for calculation methodology, axes, early-season blending, pre-season estimates, and manual overrides.
418
418
 
@@ -791,6 +791,11 @@ most needs explaining, and is reported as a single "the response is empty" findi
791
791
  as every section it happens to be short of. A verdict for a format the run has no squad data for is not counted as
792
792
  missing — the prompt tells the model to analyse only the format it was given.
793
793
 
794
+ A call that never lands at all — a timeout, a 500, a 429 that outlasted its retries, a revoked
795
+ key — is reported through the same callout, naming the failure. The run still exits **0** with
796
+ the rest of the review intact, but the saved report says the analysis was attempted and lost
797
+ rather than leaving a reader to wonder whether the template ever had one.
798
+
794
799
  **Blanks and doubles:** a zero from a player whose club had no fixture is marked `[BGW]` rather
795
800
  than read as a choice that failed, and such a player is kept off the Blankers list entirely; a
796
801
  player whose club played twice is marked `[DGW]`. Which clubs those were is read off the
@@ -1291,7 +1296,7 @@ Rolling a setup into a new season silently invalidates IDs and per-team files: a
1291
1296
 
1292
1297
  **Data files:**
1293
1298
 
1294
- - `team_ratings.yaml` — season stamp and team set vs the live league (problems here are stale, not broken: the ratings service already ignores or rebuilds bad files)
1299
+ - `team_ratings.yaml` — season stamp and team set vs the live league, judged against the same resolved season the report names (problems here are stale, not broken: the ratings service already ignores or rebuilds bad files)
1295
1300
  - `team_managers.yaml` — merged shipped + user copy covers exactly the current twenty clubs
1296
1301
  - `previews/` — optional season preview intel: a file for a club not in the current league is flagged (it loads and inflates the coverage gate), and files the loader skipped (previous season, malformed) are surfaced with `fpl intel` as the follow-up
1297
1302
  - `team_finances.json` — `scraped_at` falls within the current season
@@ -1413,7 +1418,7 @@ llm:
1413
1418
  model: claude-sonnet-5
1414
1419
  timeout: 60
1415
1420
  query_defaults:
1416
- max_tokens: 4096
1421
+ max_tokens: 8000
1417
1422
  ```
1418
1423
 
1419
1424
  ### LLM Providers
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.6.0'
22
- __version_tuple__ = version_tuple = (2, 6, 0)
21
+ __version__ = version = '2.6.1'
22
+ __version_tuple__ = version_tuple = (2, 6, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -208,6 +208,21 @@ def _report_synthesis_completeness(
208
208
  if debug_dir is None:
209
209
  error_console.print("[dim] Re-run with --debug to save the detail[/dim]")
210
210
  return None
211
+ return _write_synthesis_corrections(problems, debug_dir)
212
+
213
+
214
+ def _write_synthesis_corrections(
215
+ problems: Sequence[str], debug_dir: Path | None,
216
+ ) -> str | None:
217
+ """Save the problem lines to `synthesis_corrections.txt`; return its path.
218
+
219
+ Shared by both paths that produce problems, so a `--debug` bundle carries
220
+ the same artefact whether the response came back damaged or the call never
221
+ returned at all -- an asymmetry there is one more thing to reason about
222
+ when reading someone's bug report. `None` when the run has no debug dir.
223
+ """
224
+ if debug_dir is None:
225
+ return None
211
226
  corrections_file = debug_dir / "synthesis_corrections.txt"
212
227
  corrections_file.write_text("\n".join(problems) + "\n", encoding="utf-8")
213
228
  error_console.print("[dim] → Saved synthesis_corrections.txt[/dim]")
@@ -1142,22 +1157,50 @@ async def _review_llm_summarise(
1142
1157
  synthesis_summary = ""
1143
1158
  console.print("[green] ✓[/green] Prompts saved to data/debug/")
1144
1159
  else:
1160
+ console.print("[dim] Generating personal analysis...[/dim]")
1161
+ # The prompt asks for both formats' verdicts unconditionally, but
1162
+ # it also tells the model to analyse only the format it was given
1163
+ # data for -- so a verdict the run has no squad behind is an
1164
+ # instructed omission, not a section the guard should chase.
1165
+ omit_sections = [
1166
+ *([] if team_points_data else ["Classic Verdict"]),
1167
+ *([] if draft_squad_points_data else ["Draft Verdict"]),
1168
+ ]
1169
+ # The call, and only the call. Graceful degradation is for the
1170
+ # things a provider does to us -- a timeout, a 500, a spent 429, a
1171
+ # revoked key -- and everything downstream of the response is our
1172
+ # own code: a bug in the grounding checker or an unwritable debug
1173
+ # dir surfaces as a traceback rather than being reported as a
1174
+ # failure of a call that in fact returned. A wider `try` would also
1175
+ # throw away the good summary it had already been handed (#322
1176
+ # review).
1145
1177
  try:
1146
- console.print("[dim] Generating personal analysis...[/dim]")
1147
- # The prompt asks for both formats' verdicts unconditionally, but
1148
- # it also tells the model to analyse only the format it was given
1149
- # data for -- so a verdict the run has no squad behind is an
1150
- # instructed omission, not a section the guard should chase.
1151
- omit_sections = [
1152
- *([] if team_points_data else ["Classic Verdict"]),
1153
- *([] if draft_squad_points_data else ["Draft Verdict"]),
1154
- ]
1155
1178
  synthesis_summary, completeness, attempts = await _synthesise_with_completeness_check(
1156
1179
  synthesis_provider,
1157
1180
  prompt=synthesis_prompt,
1158
1181
  system_prompt=synthesis_system,
1159
1182
  omit_sections=omit_sections,
1160
1183
  )
1184
+ except Exception as e: # noqa: BLE001 — graceful degradation
1185
+ error_console.print(f"[red] ✗ Synthesis failed: {rich_escape(str(e))}[/red]")
1186
+ synthesis_summary = ""
1187
+ # The report has to carry it too. Degrading gracefully ends at
1188
+ # stderr, which is gone by the time the file is read: with no
1189
+ # summary and no callout, a call that never landed reads as a
1190
+ # report that never had a personal analysis in it. That is the
1191
+ # silence #306 closed for a call that returned nothing, reached
1192
+ # by the other route (#317). Whitespace is collapsed because
1193
+ # the callout renders each line as a blockquote bullet, and a
1194
+ # provider error wrapped across lines would break out of it.
1195
+ message = " ".join(str(e).split())
1196
+ # A bare `raise SomeError()` has no message, and "SomeError:"
1197
+ # with nothing after the colon reads as truncated output.
1198
+ detail = f"{type(e).__name__}: {message}" if message else type(e).__name__
1199
+ synthesis_problems = [f"the synthesis call failed ({detail})"]
1200
+ synthesis_corrections_path = _write_synthesis_corrections(
1201
+ synthesis_problems, debug_dir if debug else None,
1202
+ )
1203
+ else:
1161
1204
  # The grounding guard rides the same channel as the completeness
1162
1205
  # one: both answer "is this response usable as written", and a
1163
1206
  # reader weeks later needs them in the same place -- stderr now,
@@ -1179,9 +1222,6 @@ async def _review_llm_summarise(
1179
1222
  (debug_dir / "synthesis_prompt.txt").write_text(synthesis_prompt, encoding="utf-8")
1180
1223
  (debug_dir / "synthesis_response.txt").write_text(synthesis_summary, encoding="utf-8")
1181
1224
  console.print("[dim] → Saved synthesis_*.txt[/dim]")
1182
- except Exception as e: # noqa: BLE001 — graceful degradation
1183
- error_console.print(f"[red] ✗ Synthesis failed: {rich_escape(str(e))}[/red]")
1184
- synthesis_summary = ""
1185
1225
 
1186
1226
  return {
1187
1227
  "research_summary": research_summary,
@@ -375,11 +375,15 @@ async def _draft_entry_check(
375
375
  # ---------------------------------------------------------------------------
376
376
 
377
377
 
378
- def _team_ratings_check(teams: list[str] | None) -> CheckResult:
378
+ def _team_ratings_check(teams: list[str] | None, season: str) -> CheckResult:
379
379
  from fpl_cli.services.team_ratings import TeamRatingsService
380
380
 
381
381
  name = "team_ratings.yaml"
382
- service = TeamRatingsService()
382
+ # The resolved season, not the clock (#318): doctor mirrors the service's
383
+ # own verdict, so handing it the label the rest of the report uses is what
384
+ # keeps the row from disagreeing with `metadata.season` -- and the service
385
+ # reaches the same verdict here as it does under a live client.
386
+ service = TeamRatingsService(season=season)
383
387
  if not service.config_path.exists():
384
388
  return CheckResult(
385
389
  name,
@@ -670,7 +674,7 @@ def _file_checks(teams: list[str] | None, season_year: int) -> list[CheckResult]
670
674
  that diagnosed the override) and, under --format json, the envelope.
671
675
  """
672
676
  checks: list[tuple[str, Callable[[], CheckResult]]] = [
673
- ("team_ratings.yaml", lambda: _team_ratings_check(teams)),
677
+ ("team_ratings.yaml", lambda: _team_ratings_check(teams, season_label(season_year))),
674
678
  ("team_managers.yaml", lambda: _team_managers_check(teams)),
675
679
  ("previews/", lambda: _previews_check(teams)),
676
680
  ("team_finances.json", lambda: _team_finances_check(season_year)),
@@ -48,7 +48,7 @@ llm:
48
48
  model: claude-sonnet-5
49
49
  timeout: 60
50
50
  query_defaults:
51
- max_tokens: 4096
51
+ max_tokens: 8000 # thinking + output share this ceiling (#316); 4096 left no room for prose once the model reasoned first
52
52
 
53
53
  # Fines: opt-in via settings.yaml or `fpl init`. Example config:
54
54
  #
@@ -224,16 +224,59 @@ class TeamRatingsService:
224
224
 
225
225
  _refreshed_this_session: ClassVar[bool] = False
226
226
 
227
- def __init__(self, config_path: Path | str | None = None) -> None:
227
+ def __init__(
228
+ self, config_path: Path | str | None = None, season: str | None = None
229
+ ) -> None:
228
230
  # Left unresolved when not supplied so the default follows FPL_CLI_DATA_DIR
229
231
  # even if it is set after this module is imported.
230
232
  self._config_path = Path(config_path) if config_path else None
233
+ self._season = season
231
234
  self._ratings: dict[str, TeamRating] = {}
232
235
  self._metadata: RatingsMetadata | None = None
233
236
  self._loaded = False
234
237
  self._stale_season: str | None = None
235
238
  self._team_set_warning: str | None = None
236
239
 
240
+ @property
241
+ def season(self) -> str:
242
+ """The season this service treats as the one in progress.
243
+
244
+ The July-cutover clock unless a caller supplied a label resolved from
245
+ GW1's deadline. The distinction is load-bearing rather than cosmetic:
246
+ a season overrunning the cutover (2019-20, delayed into July 2020) has
247
+ the clock naming the *following* season from 1 July, so a ratings file
248
+ stamped with the live season looked like a previous season's and was
249
+ discarded -- taking every fixture down to a neutral 4.0 mid-season
250
+ (#318). `ensure_fresh` resolves it from the API; `fpl doctor` passes
251
+ the label it already resolved.
252
+ """
253
+ return self._season or season_label()
254
+
255
+ def _adopt_season(self, season: str) -> None:
256
+ """Adopt a resolved season, re-reading the file if it was judged early.
257
+
258
+ `_load_ratings` may already have run against the clock -- a property
259
+ read before `ensure_fresh` is enough -- and if it discarded the
260
+ ratings on that basis the verdict has to be retaken, not just
261
+ relabelled.
262
+
263
+ Everything derived from the discarded load goes with it, the team-set
264
+ warning included: `check_team_set` diffs the live league against
265
+ `self._ratings`, so a warning cached under the old season describes a
266
+ rating set that is no longer loaded. `ensure_fresh` happens to
267
+ recompute it immediately afterwards, which is not something a reset
268
+ contract should depend on.
269
+ """
270
+ if season == self._season:
271
+ return
272
+ self._season = season
273
+ if self._loaded:
274
+ self._ratings = {}
275
+ self._metadata = None
276
+ self._stale_season = None
277
+ self._team_set_warning = None
278
+ self._loaded = False
279
+
237
280
  @property
238
281
  def config_path(self) -> Path:
239
282
  """Path of the ratings YAML file this service reads and writes."""
@@ -273,7 +316,7 @@ class TeamRatingsService:
273
316
  based_on_gws = None
274
317
 
275
318
  file_season = _season_of(meta.get("season"), last_updated)
276
- if file_season and file_season != season_label():
319
+ if file_season and file_season != self.season:
277
320
  # A file from a previous season describes a different league: it
278
321
  # still rates the relegated clubs and knows nothing about the
279
322
  # promoted ones. Serving those numbers is worse than serving none,
@@ -282,7 +325,7 @@ class TeamRatingsService:
282
325
  logger.info(
283
326
  "Team ratings stale (season %s != %s) - ignoring",
284
327
  file_season,
285
- season_label(),
328
+ self.season,
286
329
  )
287
330
  self._stale_season = file_season
288
331
  self._metadata = _empty_metadata()
@@ -346,7 +389,19 @@ class TeamRatingsService:
346
389
  The team-set check runs whether or not a refresh happened: a file that
347
390
  is fresh by date can still describe last season's twenty clubs, and
348
391
  that is the mismatch a date can never catch.
392
+
393
+ Resolving the season is the first thing done, and is why every async
394
+ consumer routes through here: the file's own stamp is judged against
395
+ GW1's deadline rather than the clock, so a season overrunning 1 July
396
+ keeps its ratings instead of having them thrown away mid-season
397
+ (#318). A client that cannot answer leaves the clock in place, which
398
+ is the answer the service had before.
349
399
  """
400
+ try:
401
+ self._adopt_season(season_label(await client.get_season_year()))
402
+ except Exception: # noqa: BLE001 — the clock is a usable fallback
403
+ logger.debug("Season resolution skipped; using the clock", exc_info=True)
404
+
350
405
  if not TeamRatingsService._refreshed_this_session:
351
406
  try:
352
407
  await self._refresh(client)
@@ -538,7 +593,7 @@ class TeamRatingsService:
538
593
  """
539
594
  data = {
540
595
  "metadata": {
541
- "season": season_label(),
596
+ "season": self.season,
542
597
  "last_updated": datetime.now().strftime("%Y-%m-%d"),
543
598
  "source": source,
544
599
  "staleness_threshold_days": 30,
@@ -582,7 +637,7 @@ class TeamRatingsService:
582
637
  staleness_threshold_days=30,
583
638
  based_on_gws=based_on_gws,
584
639
  calculation_method=calculation_method,
585
- season=season_label(),
640
+ season=self.season,
586
641
  )
587
642
  self._loaded = True
588
643
  # Whatever was wrong with the old file has just been replaced.
@@ -870,7 +925,7 @@ class TeamRatingsService:
870
925
 
871
926
  if self._stale_season:
872
927
  return (
873
- f"⚠️ Team ratings are from {self._stale_season}, not {season_label()} - "
928
+ f"⚠️ Team ratings are from {self._stale_season}, not {self.season} - "
874
929
  "they describe a different league, so they were ignored and every "
875
930
  "fixture will score a neutral 4.0. Run `fpl ratings update`."
876
931
  )
@@ -13,8 +13,8 @@
13
13
  {% if synthesis_problems %}
14
14
 
15
15
  > [!WARNING]
16
- > **This summary failed its completeness check.** Whatever is named below was lost in
17
- > generation, not deliberately left out:
16
+ > **The personal analysis did not come through intact.** Whatever is named below was
17
+ > lost on the way, not deliberately left out:
18
18
  {% for problem in synthesis_problems %}
19
19
  > - {{ problem }}
20
20
  {% endfor %}
@@ -258,6 +258,101 @@ class TestSeasonResolution:
258
258
  assert "not this season's league" not in _flat(result)
259
259
 
260
260
 
261
+ class TestDataFileRowsShareTheReportedSeason:
262
+ """No `data_files` row may quietly judge on the clock (#318).
263
+
264
+ #314 threaded the resolved season through the checks it touched and
265
+ missed `team_ratings.yaml`, which went on comparing against the clock —
266
+ so in an overrunning season doctor called a live file stale and told the
267
+ user their fixture difficulty had collapsed. This asserts the property
268
+ rather than the row, so the next check added on the wrong basis fails
269
+ here instead of shipping.
270
+
271
+ Two rows are on the clock deliberately and are listed, not exempted by
272
+ accident: their loaders really do discard on a clock-derived label
273
+ mismatch, so the row is predicting runtime behaviour.
274
+ """
275
+
276
+ CLOCK_DERIVED = {"player_prior.yaml", "returnee_snapshot.json"}
277
+
278
+ def test_every_row_agrees_with_metadata_season_or_is_listed(self):
279
+ """The API names a season a year behind the clock — an overrun."""
280
+ live = PREVIOUS_SEASON
281
+ (_data_dir() / "team_finances.json").write_text(
282
+ json.dumps({"scraped_at": f"{CURRENT_YEAR}-07-05T12:00:00"}), encoding="utf-8"
283
+ )
284
+ _write_snapshot(live)
285
+ (_data_dir() / "player_prior.yaml").write_text(
286
+ yaml.dump({"metadata": {"season": live, "gameweek": 38}, "priors": {}}),
287
+ encoding="utf-8",
288
+ )
289
+ (_data_dir() / "team_ratings.yaml").write_text(
290
+ yaml.dump({
291
+ "metadata": {
292
+ "season": live,
293
+ "last_updated": f"{CURRENT_YEAR}-07-01",
294
+ "source": "calculated",
295
+ "staleness_threshold_days": 30,
296
+ "based_on_gws": [1, 36],
297
+ "calculation_method": "full_season",
298
+ },
299
+ "ratings": {
300
+ t: {"atk_home": 1, "atk_away": 2, "def_home": 3, "def_away": 4}
301
+ for t in ("ARS", "MCI")
302
+ },
303
+ }),
304
+ encoding="utf-8",
305
+ )
306
+
307
+ result = _run(
308
+ _mock_client(season_year=CURRENT_YEAR - 1), args=["--format", "json"]
309
+ )
310
+ payload = json.loads(result.output)
311
+ assert payload["metadata"]["season"] == live
312
+
313
+ offenders = [
314
+ row["name"]
315
+ for row in payload["data"]["data_files"]
316
+ if row["name"] not in self.CLOCK_DERIVED
317
+ and CURRENT_SEASON in row["detail"]
318
+ ]
319
+ assert offenders == [], (
320
+ f"{offenders} named the clock's season {CURRENT_SEASON!r} while the "
321
+ f"report is for {live!r}"
322
+ )
323
+
324
+ def test_the_ratings_row_does_not_call_a_live_file_stale(self):
325
+ """The #318 row itself, from the outside."""
326
+ live = PREVIOUS_SEASON
327
+ (_data_dir() / "team_ratings.yaml").write_text(
328
+ yaml.dump({
329
+ "metadata": {
330
+ "season": live,
331
+ "last_updated": f"{CURRENT_YEAR}-07-01",
332
+ "source": "calculated",
333
+ "staleness_threshold_days": 30,
334
+ "based_on_gws": [1, 36],
335
+ "calculation_method": "full_season",
336
+ },
337
+ "ratings": {
338
+ t: {"atk_home": 1, "atk_away": 2, "def_home": 3, "def_away": 4}
339
+ for t in ("ARS", "MCI")
340
+ },
341
+ }),
342
+ encoding="utf-8",
343
+ )
344
+ result = _run(
345
+ _mock_client(season_year=CURRENT_YEAR - 1), args=["--format", "json"]
346
+ )
347
+ row = next(
348
+ r
349
+ for r in json.loads(result.output)["data"]["data_files"]
350
+ if r["name"] == "team_ratings.yaml"
351
+ )
352
+ assert "different league" not in row["detail"]
353
+ assert "neutral 4.0" not in row["detail"]
354
+
355
+
261
356
  class TestEnvironmentSection:
262
357
  def test_reports_dirs_and_override_source(self):
263
358
  result = _run(_mock_client())
@@ -2167,6 +2167,13 @@ class TestReportSynthesisCompleteness:
2167
2167
  assert "## Draft Verdict" in err
2168
2168
 
2169
2169
 
2170
+ class _Boom:
2171
+ """A synthesis provider whose call never lands (timeout, 500, spent 429)."""
2172
+
2173
+ async def query(self, *a, **k):
2174
+ raise RuntimeError("provider exploded")
2175
+
2176
+
2170
2177
  class TestReviewLlmSummariseSurfacesAnIncompleteSynthesis:
2171
2178
  """#266: end to end, a truncated synthesis must not reach the report silently."""
2172
2179
 
@@ -2227,16 +2234,86 @@ class TestReviewLlmSummariseSurfacesAnIncompleteSynthesis:
2227
2234
  assert pathlib.Path(result["synthesis_corrections_path"]).resolve() == written.resolve()
2228
2235
  assert "## Draft Verdict" in written.read_text(encoding="utf-8")
2229
2236
 
2230
- async def test_a_synthesis_failure_still_returns_no_problems(self, capsys):
2231
- class _Boom:
2237
+ async def test_a_call_that_never_landed_is_reported_like_one_that_did(self, capsys):
2238
+ # #317: this used to assert the opposite, on the reasoning that an
2239
+ # empty summary was visible on its own. #306 disproved that -- an empty
2240
+ # summary renders as nothing at all -- so an exception has to reach the
2241
+ # report through the same channel a damaged response does.
2242
+ result = await _review_llm_summarise(**self._kwargs(_Boom()))
2243
+
2244
+ assert result["synthesis_summary"] == ""
2245
+ problems = result["synthesis_problems"]
2246
+ assert len(problems) == 1
2247
+ assert "the synthesis call failed" in problems[0]
2248
+ assert "RuntimeError: provider exploded" in problems[0]
2249
+ assert "Synthesis failed: provider exploded" in capsys.readouterr().err
2250
+
2251
+ async def test_a_multiline_provider_error_stays_one_callout_bullet(self):
2252
+ # Each problem renders as `> - {{ problem }}`, so an error wrapped
2253
+ # across lines would break out of the blockquote mid-callout.
2254
+ class _WordyBoom:
2255
+ async def query(self, *a, **k):
2256
+ raise ConnectionError("connection refused\n while posting to /v1/messages")
2257
+
2258
+ result = await _review_llm_summarise(**self._kwargs(_WordyBoom()))
2259
+ problem = result["synthesis_problems"][0]
2260
+ assert "\n" not in problem
2261
+ assert "connection refused while posting to /v1/messages" in problem
2262
+
2263
+ async def test_a_failure_after_the_call_returned_is_not_blamed_on_the_call(self, monkeypatch):
2264
+ # The `try` used to cover the grounding check and the debug writes as
2265
+ # well, so a defect in any of them discarded a summary the provider had
2266
+ # already handed over and reported "the synthesis call failed" -- a
2267
+ # confident lie about which half broke. Only the call is guarded now,
2268
+ # so our own bug surfaces as itself (#322 review).
2269
+ def _boom(*a, **k):
2270
+ raise ZeroDivisionError("a bug in the grounding checker")
2271
+
2272
+ # Imported inside the function, so the source module is what to patch.
2273
+ monkeypatch.setattr("fpl_cli.prompts.review.check_next_week_grounding", _boom)
2274
+ provider = _StubSynthesisProvider(_reply(_WHOLE, "end_turn"))
2275
+
2276
+ with pytest.raises(ZeroDivisionError):
2277
+ await _review_llm_summarise(**self._kwargs(provider))
2278
+
2279
+ async def test_an_exception_carrying_no_message_names_only_its_type(self):
2280
+ class _Silent:
2232
2281
  async def query(self, *a, **k):
2233
- raise RuntimeError("provider exploded")
2282
+ raise RuntimeError
2283
+
2284
+ result = await _review_llm_summarise(**self._kwargs(_Silent()))
2285
+ # Not "RuntimeError:" with nothing after the colon, which reads as
2286
+ # output that was itself cut off.
2287
+ assert result["synthesis_problems"] == ["the synthesis call failed (RuntimeError)"]
2288
+
2289
+ async def test_debug_saves_the_failure_beside_the_other_corrections(self, tmp_path, monkeypatch):
2290
+ # Same artefact whichever way the analysis was lost, so a `--debug`
2291
+ # bundle does not need explaining.
2292
+ monkeypatch.chdir(tmp_path)
2293
+ result = await _review_llm_summarise(**self._kwargs(_Boom(), debug=True))
2294
+
2295
+ written = tmp_path / "data" / "debug" / "synthesis_corrections.txt"
2296
+ assert pathlib.Path(result["synthesis_corrections_path"]).resolve() == written.resolve()
2297
+ assert "the synthesis call failed (RuntimeError: provider exploded)" in written.read_text(
2298
+ encoding="utf-8",
2299
+ )
2300
+
2301
+ async def test_the_failure_reaches_the_saved_report_as_a_callout(self):
2302
+ # The two halves pinned against each other: whatever the guard puts in
2303
+ # `synthesis_problems`, the template has to render it -- which is the
2304
+ # branch #306 fixed and this failure path now shares.
2305
+ from fpl_cli.agents.orchestration.report import ReportAgent
2234
2306
 
2235
2307
  result = await _review_llm_summarise(**self._kwargs(_Boom()))
2236
- # The existing graceful-degradation path owns this case: an empty
2237
- # summary is already visible, so the guard adds nothing to it.
2238
- assert result["synthesis_summary"] == ""
2239
- assert result["synthesis_problems"] == []
2308
+ report = ReportAgent()._generate_review_report(7, {
2309
+ "generated_at": "Sun 7 Sep 2026, 08:00 BST",
2310
+ "synthesis_summary": result["synthesis_summary"],
2311
+ "synthesis_problems": result["synthesis_problems"],
2312
+ })
2313
+
2314
+ assert "[!WARNING]" in report
2315
+ assert "did not come through intact" in report
2316
+ assert "> - the synthesis call failed (RuntimeError: provider exploded)" in report
2240
2317
 
2241
2318
 
2242
2319
  # ---------------------------------------------------------------------------
@@ -1024,3 +1024,19 @@ class TestGetLlmProvider:
1024
1024
  """No provider configured at all."""
1025
1025
  with pytest.raises(UnknownProviderError):
1026
1026
  get_llm_provider("research", {})
1027
+
1028
+
1029
+ class TestShippedSynthesisMaxTokens:
1030
+ """#316: a synthesis `max_tokens` cap shares its ceiling with the model's
1031
+ thinking, so a cap too small produces a structurally empty response no
1032
+ retry can fix (see AnthropicProvider.query). 4096 was that cap; 8000 was
1033
+ the smallest value the issue's own repro table found clean. Pin a floor
1034
+ on the shipped default so it cannot silently regress back below it.
1035
+ """
1036
+
1037
+ def test_shipped_default_leaves_headroom_for_a_full_review(self):
1038
+ from fpl_cli.cli._context import load_settings
1039
+
1040
+ settings = load_settings()
1041
+ max_tokens = settings["llm"]["synthesis"]["query_defaults"]["max_tokens"]
1042
+ assert max_tokens >= 8000
@@ -622,7 +622,7 @@ class TestSynthesisProblemsCallout:
622
622
  "missing section(s): ## Draft Verdict, ## Next Week",
623
623
  "response ends without terminal punctuation (likely cut off mid-sentence)",
624
624
  ])
625
- assert "failed its completeness check" in output
625
+ assert "did not come through intact" in output
626
626
  assert "## Draft Verdict" in output
627
627
  assert "terminal punctuation" in output
628
628
 
@@ -631,10 +631,10 @@ class TestSynthesisProblemsCallout:
631
631
  assert "A shrug of a week." in output
632
632
 
633
633
  def test_a_clean_run_adds_no_callout(self):
634
- assert "completeness check" not in self._render(synthesis_problems=[])
634
+ assert "did not come through intact" not in self._render(synthesis_problems=[])
635
635
 
636
636
  def test_a_run_that_never_set_the_key_adds_no_callout(self):
637
- assert "completeness check" not in self._render()
637
+ assert "did not come through intact" not in self._render()
638
638
 
639
639
  def test_an_empty_summary_still_carries_the_callout(self):
640
640
  # The worst truncation is the one that returns nothing at all, and it
@@ -648,7 +648,7 @@ class TestSynthesisProblemsCallout:
648
648
  "the response is empty (the provider returned no text)",
649
649
  ],
650
650
  )
651
- assert "failed its completeness check" in output
651
+ assert "did not come through intact" in output
652
652
  assert "max_tokens" in output
653
653
  assert "the provider returned no text" in output
654
654
 
@@ -656,7 +656,7 @@ class TestSynthesisProblemsCallout:
656
656
  # A reader needs to know the text is incomplete before reading it, not
657
657
  # after -- and the missing-key callout above it already reads that way.
658
658
  output = self._render(synthesis_problems=["missing section(s): ## Draft Verdict"])
659
- assert output.index("failed its completeness check") < output.index("A shrug of a week.")
659
+ assert output.index("did not come through intact") < output.index("A shrug of a week.")
660
660
 
661
661
  def test_a_clean_run_is_spaced_exactly_as_it_was(self):
662
662
  # The callout moving above the summary must cost the ordinary report
@@ -681,7 +681,7 @@ class TestSynthesisProblemsCallout:
681
681
 
682
682
  def test_a_whitespace_only_summary_alone_writes_no_block_at_all(self):
683
683
  output = self._render(synthesis_summary=" ", synthesis_problems=[])
684
- assert "completeness check" not in output
684
+ assert "did not come through intact" not in output
685
685
  assert "*Generated:" in output
686
686
  assert output.split("\n")[1].startswith("#")
687
687