fplkit 2.7.3__tar.gz → 2.7.4__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.
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/TOOLS.md +1 -1
- {fplkit-2.7.3 → fplkit-2.7.4}/CHANGELOG.md +15 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/PKG-INFO +1 -1
- {fplkit-2.7.3 → fplkit-2.7.4}/docs/architecture.md +1 -1
- {fplkit-2.7.3 → fplkit-2.7.4}/docs/command-reference.md +16 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/_version.py +2 -2
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_helpers.py +155 -1
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_review_classic.py +23 -86
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_review_draft.py +18 -59
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/league.py +16 -79
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/league_recap_checks.py +99 -64
- fplkit-2.7.4/tests/test_cli_league.py +376 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_review.py +142 -7
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_recap.py +85 -6
- fplkit-2.7.3/tests/test_cli_league.py +0 -161
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/README.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/build-test-plan/SKILL.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/build-test-plan/references/plan-template.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/build-test-plan/references/workspace-env.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/create-pr/SKILL.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/SKILL.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/references/entity-normalisation.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/references/output-template.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/references/rules.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/_bootstrap.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/normalise_entities.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/preview-ingest/SKILL.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/release/SKILL.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/release-notes/SKILL.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/squad-builder/SKILL.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/squad-builder/references/output-template.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/squad-builder/references/rules.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/update-gw-prep/SKILL.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.claude/output-styles/fpl-mate.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.claude/settings.json +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.env.example +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.github/workflows/ci.yml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.github/workflows/pr-title.yml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.github/workflows/release.yml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/.gitignore +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/AGENTS.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/CLAUDE.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/CONTRIBUTING.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/LICENSE +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/README.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/cliff.toml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/config/team_ratings_overrides.yaml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/docs/custom-analysis.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/docs/fpl-rules.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/docs/images/fpl-player-demo-v1-0.png +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/action/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/action/waiver.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/bench_order.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/captain.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/starting_xi.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/stats.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/base.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/common.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/data/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/data/fixture.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/data/price.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/data/scout.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/orchestration/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/orchestration/report.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/contract.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/core_insights.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/dataset_fetcher.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/football_data.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/fpl.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/fpl_draft.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/historical.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/historical_types.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/_http.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/_models.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/anthropic.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/openai_compat.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/perplexity.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/understat.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/vaastav.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_banner.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_context.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_fines.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_fines_config.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_json.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_league_recap_data.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_league_recap_history.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_league_recap_types.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_plan_grid.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_review_analysis.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_review_summarisation.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/allocate.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/captain.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/chips.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/credentials.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/differentials.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/doctor.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/doctor_providers.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/fdr.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/fixtures.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/history.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/init.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/intel.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/league_fines.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/league_recap.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/player.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/preview.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/price_changes.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/price_history.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/ratings.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/returnees.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/review.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/sell_prices.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/squad.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/stats.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/status.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/targets.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/transfer_eval.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/waivers.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/xg.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/config/defaults.yaml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/config/fixture_predictions.yaml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/config/previews/EXAMPLE.yaml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/config/team_managers.yaml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/constants.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/chip_plan.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/fixture.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/league_history.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/player.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/team.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/types.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/parsers/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/parsers/recommendations.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/paths.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/league_recap.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/returnees.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/review.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/scout.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/scraper/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/scraper/fpl_prices.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/season.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/fixture_predictions.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/league_history.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/league_history_counters.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/league_history_fines.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/league_history_notes.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/matchup.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/player_clubs.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/player_prior.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/returnee_radar.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/constants.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/data_prep.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/display.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/evaluation.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/ownership.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/shrinkage.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/signals.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/single_gw.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/value_quality.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/season_previews.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/squad_allocator.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/team_form.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/team_ratings.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/team_ratings_prior.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/templates/gw_preview.md.j2 +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/templates/gw_review.md.j2 +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/files.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/gameweek.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/markdown.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/teams.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/text.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/time.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/pyproject.toml +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/pyrightconfig.json +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/scripts/backtest_early_season_prior_blend.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/scripts/calibrate_promoted_prior.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/scripts/calibrate_quality_ceilings.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/__init__.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/conftest.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/classic_squad_fixture.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/recommendations_fixture.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/waiver_ranked_golden.json +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_action.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_analysis.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_analysis_squad.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_base.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_data.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_data_price.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_orchestration.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_allocate.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_client.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_core_insights.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_dataset_fetcher.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_draft.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_football_data.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_historical.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_understat.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_vaastav.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_bench_order_script.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_calibrate_quality_ceilings.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_classic_squad_extraction.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_allocate.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_banner.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_captain.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_chips.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_context_settings.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_credentials.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_custom_analysis.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_differentials.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_doctor.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_doctor_providers.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_failure_parity.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_failure_streams.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_fdr.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_fdr_blanks.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_fixtures.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_format.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_format_suppression.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_fpl_block.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_history.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_init.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_intel.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_json.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_json_contract.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_league_fines.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_league_recap.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_plan_grid.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_player.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_preview.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_price_changes.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_price_history.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_returnees.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_sell_prices.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_settings_warning.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_squad.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_stats.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_status.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_targets.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_transfer_eval.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_waivers.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_warnings_stderr.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_xg.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_common.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_common_scoring.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_data_prep_harmonisation.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_dependencies.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_doc_consistency.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_fines.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_fines_config.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_fixture_predictions.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_formation_guidance_sync.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_fpl_prices.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_gw_prep_script_bootstrap.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_init.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_init_fines.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_history_counters.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_history_fines.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_history_notes.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_history_store.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_matchup_service.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_models.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_normalise_entities.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_output_style_sync.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_paths.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_player_clubs.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_player_prior.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_player_resolution.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_preseason.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_prompt_tables.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_providers.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_recommendations_parser.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_report_agent.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_report_paths.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_returnee_radar.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_review.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_rolling_pts_per_m.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_scoring.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_scraper.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_season.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_season_awareness.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_season_previews.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_sell_prices.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_skill_fdr_quality_signals.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_skill_stats_thresholds.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_squad_allocator.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_starting_xi_agent.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_starting_xi_script.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_stats.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_team_ratings.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_team_ratings_prior.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_transfer_eval_agent.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_transfer_eval_script.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_utils_gameweek.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_utils_markdown.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_utils_text.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_utils_time.py +0 -0
- {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_validate_draft_waivers.py +0 -0
|
@@ -39,7 +39,7 @@ with `2>/dev/null` without losing the table, whichever command it runs.
|
|
|
39
39
|
| `player` | Look up a player's stats, xG, ownership and fixture run. Before GW10 `quality_score` blends last season's pedigree in and JSON `metadata.warnings` says so (`early_season_prior_informed`, or `early_season_small_sample` when history could not be loaded), same as `fpl stats --value`; `understat_team_unmatched` joins it for any club Understat carried no players for, whose whole squad loses xG enrichment. JSON includes `ep_next` (predicted pts next GW) and `ep_this` (current GW), emitting `null` when FPL has no projection; panel shows `xPts` (ep_next) when available, omits the segment when FPL has no projection. `--detail` (`-d`) shows GW-by-GW performance and, for FWD/MID with sufficient history, xGI sustainability (per-match GI-xGI divergence -> form modifier). With `custom_analysis`: JSON adds `info.adjusted_npxg_per_90` (fixture-adjusted) and `info.raw_npxg_per_90` (Understat season avg); panel shows `adj. npxG/90: X.XXX (raw: Y.YYY)` | Yes | General | No | direct-api |
|
|
40
40
|
| `stats` | List players with filtering and sorting. `--value` adds quality/value per £m columns; `--window N` sets rolling lookback (3-10) for `rolling_pts_per_m`. Before GW10, `--value` quality scores blend last season's pts/90 pedigree with this season's observation and the command says so (stderr notice quoting the observation's weight band; JSON `metadata.warnings` code `early_season_prior_informed`); when last season's history cannot be loaded they are pure observation and the notice says that instead (`early_season_small_sample`, before GW6). `understat_team_unmatched` is raised once per club Understat carried no players for, whose whole squad scores without xG. GK ceilings scale with the calendar-possible sample, reaching full scale at GW6. Sortable by `ep_next`/`ep_this` (FPL predicted points; `ep_this` is only meaningful before the gameweek's first kickoff — FPL rolls it forward to `ep_next` as each player's match finishes); both in JSON output (emit `null` when FPL has no projection; table renders `—`). Players with no projection sort to the bottom in either direction | Yes | General | No | direct-api |
|
|
41
41
|
| `history` | Show historical player performance across seasons. A provider that refuses exits 1 in both output formats, carrying the upstream's own reason: the error envelope used to say only `Failed to fetch historical data` while the cause went to stderr, and table mode printed the cause to stdout and exited 0 | Yes | General | No | direct-api |
|
|
42
|
-
| `league` | Show live league standings for Classic and Draft leagues. Classic positions are competition-ranked from cumulative totals — managers level on points share a place and the next distinct total skips the places they consumed — matching `fpl review` and the league history ledger rather than the standings API's sequential rank, which splits such a tie on fewest transfers season-to-date, a figure no column shows. Draft keeps the league's own rank, which already applies the head-to-head points-for tie-break | No | General | No | direct-api |
|
|
42
|
+
| `league` | Show live league standings for Classic and Draft leagues. Classic positions are competition-ranked from cumulative totals — managers level on points share a place and the next distinct total skips the places they consumed — matching `fpl review` and the league history ledger rather than the standings API's sequential rank, which splits such a tie on fewest transfers season-to-date, a figure no column shows. Draft keeps the league's own rank, which already applies the head-to-head points-for tie-break. The Best/Worst GW Performers lists (top three; bottom five classic, bottom three draft) are the ones `fpl review` prints: managers level on the gameweek share a place (`3=`) and a tie at the cut keeps every manager in it, a hit reads `29 net pts (33 gross, -4 hit)`, and a classic user above the bottom five gets a `Your GW rank: R of N` line beside the list | No | General | No | direct-api |
|
|
43
43
|
| `fdr` | Analyse fixture difficulty - easy runs, blanks, doubles. Three payloads, not one: the default call runs the agent and returns the full analysis plus `data.ratings_warning` (the team-ratings quality verdict, and with `--my-squad` also `data.predictions_stale` / `data.prediction_warnings` / `data.squad_exposure`); with custom analysis off it returns raw 1-5 FPL API difficulty (`data.easy_fixture_runs` only, `metadata.custom_analysis: false`, no ratings and so no warning); `--blanks` bypasses the agent for a schedule-only payload carrying neither the FDR analysis nor `ratings_warning`. `--blanks` mixes confirmed fixtures with predictions; when those predictions are stale, table mode warns on stderr and JSON adds a `fixture_predictions_stale` entry to `metadata.warnings` | Yes | General | No | via-agent |
|
|
44
44
|
| `xg` | Analyse underlying stats: xG, xA, overperformers. The minutes floor and the window scale to the gameweeks played (`min(60 x window, 45 x gameweeks played)`, so 90 minutes over 2 GWs rather than an unreachable 360 over 6); the configured bar binds from GW9, or GW11 for `--all`. JSON `metadata` carries `window_label`, `gameweeks_played` and `min_minutes`, and `metadata.warnings` an `early_season_minutes_floor` notice while the bar is scaled, plus `understat_team_unmatched` for any club Understat carried no players for. When nothing qualifies, `data.empty_reason` says whether the floor (`below_minutes_floor`) or the data (`no_minutes_played`) caused it | Yes | General | No | via-agent |
|
|
45
45
|
| `price-changes` | Show price changes and transfer activity | No | General | No | via-agent |
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
|
+
## [2.7.3] - 2026-10-09
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- default synthesis model moves to claude-sonnet-5-5 (#362)
|
|
9
|
+
- stop streak notes calling a fully recorded gameweek "not recorded" (#363)
|
|
10
|
+
- show correct ranks and scores in the gameweek review report (#365)
|
|
11
|
+
- review no longer drops the transfers summary on a net-zero total (#366)
|
|
12
|
+
- draft review no longer misreports rendering errors as a failed fetch (#369)
|
|
13
|
+
- stop gw-prep, squad-builder and FPL Mate ruling out a legal 5-2-3 (#370)
|
|
14
|
+
- flag a recap editorial that misnames who lost a contested waiver (#368)
|
|
15
|
+
- draft review no longer crashes on bracketed player or manager names (#373)
|
|
16
|
+
- classic review no longer crashes on bracketed names (#378)
|
|
17
|
+
- flag recap editorials that misplace a transfer net or invent a tie (#374)
|
|
18
|
+
|
|
4
19
|
## [2.7.2] - 2026-09-16
|
|
5
20
|
|
|
6
21
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fplkit
|
|
3
|
-
Version: 2.7.
|
|
3
|
+
Version: 2.7.4
|
|
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
|
|
@@ -597,7 +597,7 @@ fpl_cli/
|
|
|
597
597
|
│ ├── review.py # Review research + synthesis prompts. Squad and transfer lines in the synthesis data block carry each player's full club name, and the hard constraints bind club affiliation and blanket scored/blanked claims to that supplied data — an LLM's own club knowledge goes stale every transfer window. The synthesis prompt also carries a `<next_gameweek>` block — every club's next fixture and each squad player's positional FDR, built by `_review_next_gameweek` / `_format_next_gameweek` — and the "Next Week" instruction switches on whether it is there: grounded advice when it is, fixture-independent observations pointing at `fpl gw-prep` when it is not, because a results-only review was issuing confident fixture-dependent calls with no fixture data behind them (#191), with `check_next_week_grounding` checking the answer back against that block -- a cited FDR figure the block never printed, or any fixture claim on a run that had no block -- so the grounding claim is not left resting on prompt prose alone, the way the research half's rules are backed by `validate_research_teams`, which folds both the supplied name and the model's cell through `normalise_name` before the allowlist search so a reformat is not read as an invention, and leaves a `[table trimmed: N unlisted row(s) removed]` line under a table it did shorten — the strip notice otherwise reaching stderr and `--debug` only, both gone by the time the markdown is read (#343). Also the synthesis half's post-generation guard (`check_synthesis_completeness`, `required_synthesis_sections`), which reads the required `## ` headings back off the prompt that was actually sent rather than restating them as a constant. The research half's counts are grounded the same way: `fixture_aggregates()` computes the fixture, goal, clean-sheet and goalless-draw totals once, `FixtureAggregates.summary_line()` pins them at the top of the GW Results block as the narrative's only permitted source, and `validate_research_counts` checks the narrative back against the same object — a count the prompt pins cannot drift from the count the check enforces. It rewrites a contradicting figure in place rather than scrubbing the sentence, and corrects one only when neither its sentence nor the one before it names anyone the count could belong to — a club, a nickname, a player, a pronoun standing in for one — because a round-wide total is exactly the sentence that names nobody. The guard errs towards leaving prose alone: a missed correction ships the model's own words, while a wrong one fabricates a claim in prose that was true. That blind spot is why the check was needed at all — the narrative that reported six clean sheets as four (#324) named no player, so all three name-based validators passed it
|
|
598
598
|
│ ├── returnees.py # Return-intel search prompt for `fpl returnees --enrich` (one player per query, so a citation list belongs to a single player)
|
|
599
599
|
│ ├── league_recap.py # League recap synthesis prompts, including the anchored League History section, its never-infer-history rule (emitted even when the pack is empty), and the season-phase framing instruction. The GW Standings section leads with an explicit "Previous gameweek's leader" fact (issue #189) so a "topped the table"/"fell from the top" claim is checkable against a stated name instead of inferred from the Prev column or the size of a fall, and the rules forbid merging two managers' separate League History entries into a shared record or "club". `collect_player_clubs()` regroups the club each squad/transfer row already resolved off its `team_id` by the name the prose uses, feeding both a `## Player Clubs` roster and the club printed inline on each captain group, so club claims bind to supplied data instead of recall; a name the data gives two clubs for is dropped rather than guessed at, since the prose can't tell the two players apart. Transfers get the same full-roster treatment as captains and chips (issue #71): `format_recap_transfers_context()` lists every manager who transferred with each move, the hit and the post-hit net, names who made none, and keeps a manager whose moves the collector could not capture among the movers with the moves marked unknown, so the rule pins transfer narration to that section rather than to the two award winners — the only transfer data the model used to see, which it read as the whole story. `format_recap_waivers_context()` is the draft half (issue #301): every waiver and free-agent move as it was made, never chain-contracted (that is the awards' display compression, not a record of activity), each tagged by kind through the shared `draft_transaction_kind_label()` / `draft_transaction_kind_counts()` in `_league_recap_types.py` so the roster and the waiver awards never describe one move in two vocabularies; the rule names that section as draft's source of truth and only falls back to "say nothing about moves" when neither roster is present. It states three groups rather than two (issue #329): movers, the managers who claimed a player and lost him to a rival, and the managers who did neither. A waiver is a competition, so an outbid manager ends the gameweek with no accepted move and used to be indistinguishable from one who submitted nothing -- the editorial read that absence as inactivity and assigned a motive for it, so the rule now forbids calling anyone in the outbid group idle. The section closes with the gameweek's contested players (issue #330) — one sentence per player more than one manager claimed, from `contested_draft_claims()` / `format_contested_claim()` in `_league_recap_types.py`, the same sentence the Most Contested award prints for a pile-up of `MOST_CONTESTED_MIN_CLAIMANTS` or more that a recapped manager won (a race nobody can be seen to have won is context, never the award), with a rule pinning who-wanted-whom to those lines and limiting what may be said of anyone's waiver order to the winner having stood higher in it. `format_recap_prior_seasons_context()` (issue #131) is the opener's one source of history: each classic manager's FPL-wide seasons before this one, folded once by `summarise_prior_seasons()` (`_league_recap_types.py`) so the saved report and the prompt read the same lines, with an explicit count, the no-record and unfetched managers named rather than omitted, and a rule pinning tenure to FPL-wide phrasing — entry and league IDs are reissued each season, so "Nth season in this league" is unknowable and the section says so. The report's title is not the model's (issue #349): `recap_title()` (`_league_recap_types.py`) is the one H1, read by the template that renders it and the prompt that names it as already written; the prompt asks for a `## ` headline beneath it and no title, and `normalise_recap_editorial()` holds the reply to that shape in one fence-aware pass over every heading — one that only restates the title is dropped wherever it sits, the opening one is kept at H2 with the title's parts taken off (emphasis unwrapped through `unwrap_emphasis()` in `utils/markdown.py`, balanced pairs only, so what is printed again stays well-formed), a later `# ` is demoted, and a title-shaped part is removed only behind a colon, pipe or spaced dash so a hook's own words are never edited — since an instruction is not a contract and the model had mirrored, restyled, renamed and skipped the heading in turn. For the same reason the finished editorial is checked against its own data afterwards, by `league_recap_checks.py` beside this module; what the checks hold "joint" to is stated here, in `_standings_tie_lines()`: the week's highest and lowest scores, every gameweek score and Pos position more than one manager holds, any shared Prev position, and any season totals the league's tie-break placed apart (a head-to-head draft table ranks managers level on league points by points scored)
|
|
600
|
-
│ └── league_recap_checks.py # Post-generation checks on the league-recap editorial, kept out of the prompt module so it stays about prompts. `EDITORIAL_CHECKS` is the one registry -- each check's `RECAP_WARNING_*` code, the lead of its JSON warning (`editorial_warning_message()`, one sentence per claim), its retry instruction and the checker -- so a new check is one record. `check_contested_attributions()` (issue #357) reads who a sentence says won or was beaten to a contested player; `check_net_attributions()` (issue #359) reads a "net" figure said of one named move and flags it when it is another figure on that manager's roster line, attaching it to whichever of the move and its manager is named nearer before it; `check_tie_claims()` (issue #359) reads a tie word fused to a rank ("joint-lowest", "tied for last", "joint-seventh") against the GW Pts column, or the table's Pos, Prev and Total columns. Each reads only the clauses it can pin down and lets the rest pass, so a problem is a sentence the data contradicts and a false alarm (which costs a retry and a warning above a correct editorial) is the failure it is built against. `check_recap_editorial()` runs them all; the command regenerates once with `get_recap_editorial_retry_prompt()` (the wrong claims plus an instruction per kind that failed), saves a still-failing editorial as written with one JSON warning per code and the claims in a callout above it, and ships the editorial unchecked if a check itself raises
|
|
600
|
+
│ └── league_recap_checks.py # Post-generation checks on the league-recap editorial, kept out of the prompt module so it stays about prompts. `EDITORIAL_CHECKS` is the one registry -- each check's `RECAP_WARNING_*` code, the lead of its JSON warning (`editorial_warning_message()`, one sentence per claim), its retry instruction and the checker -- so a new check is one record. `check_contested_attributions()` (issue #357) reads who a sentence says won or was beaten to a contested player; `check_net_attributions()` (issue #359) reads a "net" figure said of one named move and flags it when it is another figure on that manager's roster line, attaching it to whichever of the move and its manager is named nearer before it; `check_tie_claims()` (issue #359) reads a tie word fused to a rank ("joint-lowest", "tied for last", "joint-seventh") against the GW Pts column, or the table's Pos, Prev and Total columns. All three read names the same way: `check_recap_editorial()` builds one `RecapNames` per editorial -- a longest-first alternation over every name the data carries (every squad, captain, move and lost claim's players, the managers and their unique short forms) -- and each check takes it as a required argument and keeps only the matches it is after, so a manager's surname is never read inside a hyphenated player ("White" in "Gibbs-White") nor a player inside a manager's full name (issue #379). Each reads only the clauses it can pin down and lets the rest pass, so a problem is a sentence the data contradicts and a false alarm (which costs a retry and a warning above a correct editorial) is the failure it is built against. `check_recap_editorial()` runs them all; the command regenerates once with `get_recap_editorial_retry_prompt()` (the wrong claims plus an instruction per kind that failed), saves a still-failing editorial as written with one JSON warning per code and the claims in a callout above it, and ships the editorial unchecked if a check itself raises
|
|
601
601
|
├── parsers/
|
|
602
602
|
│ └── recommendations.py # Parse gw{N}-recommendations.md into structured decisions
|
|
603
603
|
├── scraper/
|
|
@@ -760,6 +760,15 @@ A waiver claim a rival won is reported under **Claims Lost** rather than discard
|
|
|
760
760
|
|
|
761
761
|
**LLM summary** (`--summarise`): Community narrative via research provider, personal analysis via synthesis provider. The summary is an add-on: the review itself needs no key, so a role whose provider has no usable key is skipped with the reason on stderr (`Community narrative skipped: ...` / `Personal analysis skipped: ...`) and the review still prints, still saves its report and still exits **0**. The two roles resolve independently, so one key buys the half it belongs to rather than nothing. A saved report generated from such a run names each skipped half and why in a warning callout above the summary, so a section absent for want of a key never reads as one deliberately left out.
|
|
762
762
|
|
|
763
|
+
**Trimmed tables:** the community narrative's Standout Performers and Disappointments tables are
|
|
764
|
+
checked against the player lists the research prompt was given. A row naming a player who is not
|
|
765
|
+
on those lists is removed, and the table then carries `[table trimmed: N unlisted row(s)
|
|
766
|
+
removed]` beneath it, counted per table. "Unlisted" means not on the supplied lists, and the
|
|
767
|
+
guard removed the rows, not the model, so a shortened table is not the model's own choice.
|
|
768
|
+
Style differences (spacing, apostrophes, accents, case) are never grounds for removal. Under
|
|
769
|
+
`--debug`, each removed row is named in `data/debug/research_corrections.txt` as
|
|
770
|
+
`<player>: stripped (not in provided list)`.
|
|
771
|
+
|
|
763
772
|
**Next Week:** the personal analysis is given next gameweek's fixtures — every club's opponent
|
|
764
773
|
and venue, and each of your own players' FDR for their own position — so the section's start,
|
|
765
774
|
bench and transfer calls are grounded in what the squad actually plays rather than extrapolated
|
|
@@ -826,6 +835,13 @@ a season with nothing finished yet — is refused with the reason on **stderr**
|
|
|
826
835
|
with the reason on stdout, as did `league-recap`, which shares the resolver. A missing provider
|
|
827
836
|
key was refused the same way until it stopped being a refusal at all — see the LLM summary above.
|
|
828
837
|
|
|
838
|
+
**Notices** — the line that says an entry id is not configured or not found in the draft league,
|
|
839
|
+
the pair that says why a past gameweek has no league table, the note that a new mini-league's
|
|
840
|
+
standings are not published yet, and the `Fetching your team data...` progress line — go to
|
|
841
|
+
**stderr**, for classic and draft alike. A section heading stays on stdout only with the content it
|
|
842
|
+
introduces: for a past gameweek, or a mini-league whose table is not published yet, there is no
|
|
843
|
+
League section on stdout at all and stderr says why.
|
|
844
|
+
|
|
829
845
|
### League Recap
|
|
830
846
|
|
|
831
847
|
Entertainment-first post-gameweek report for the whole league.
|
|
@@ -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.7.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 7,
|
|
21
|
+
__version__ = version = '2.7.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 7, 4)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -6,9 +6,12 @@ import asyncio
|
|
|
6
6
|
import logging
|
|
7
7
|
from collections import Counter
|
|
8
8
|
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
9
10
|
from typing import TYPE_CHECKING, Any
|
|
10
11
|
|
|
11
|
-
from
|
|
12
|
+
from rich.markup import escape as rich_escape
|
|
13
|
+
|
|
14
|
+
from fpl_cli.cli._context import console, error_console, fpl_config
|
|
12
15
|
|
|
13
16
|
if TYPE_CHECKING:
|
|
14
17
|
from fpl_cli.api.fpl import FPLClient
|
|
@@ -121,6 +124,157 @@ def your_gw_rank_line(gw_rank: str | None, field_size: int | None, row: Mapping[
|
|
|
121
124
|
return f"Your GW rank: {gw_rank or '?'}{field} - {performer_score(row)}"
|
|
122
125
|
|
|
123
126
|
|
|
127
|
+
def print_historical_league_notice(gw: int) -> None:
|
|
128
|
+
"""Say why a past gameweek's review carries no league table (#377, #382).
|
|
129
|
+
|
|
130
|
+
The classic and draft blocks print the same two lines under their own
|
|
131
|
+
`## League` heading. They are commentary on the review, not part of it, so
|
|
132
|
+
they go to stderr and the heading stays on stdout with the content it
|
|
133
|
+
introduces.
|
|
134
|
+
"""
|
|
135
|
+
error_console.print(f"[dim]League standings not shown for historical GW{gw} review[/dim]")
|
|
136
|
+
error_console.print("[dim]Use 'fpl league' for current standings[/dim]")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _performer_row(e: Mapping[str, Any]) -> dict[str, Any]:
|
|
140
|
+
"""A classic standings entry as a Best/Worst GW Performers row.
|
|
141
|
+
|
|
142
|
+
Takes an entry from `_fetch_standings_with_costs` after `_assign_tie_ranks`.
|
|
143
|
+
`points` is the score the row was ranked on (net when the league plays
|
|
144
|
+
net), which is what `performer_score` and the last-place fine read.
|
|
145
|
+
"""
|
|
146
|
+
return {
|
|
147
|
+
"name": e["name"],
|
|
148
|
+
"points": e["net_points"],
|
|
149
|
+
"gross_points": e["gross_points"],
|
|
150
|
+
"transfer_cost": e["transfer_cost"],
|
|
151
|
+
"rank_str": e["rank_str"],
|
|
152
|
+
"is_user": e.get("is_user", False),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _draft_performer_row(e: Mapping[str, Any], user_entry_id: int | None) -> dict[str, Any]:
|
|
157
|
+
"""A draft standings entry as a Best/Worst GW Performers row.
|
|
158
|
+
|
|
159
|
+
Draft has no hits, so `points` is the gameweek score as it stands and
|
|
160
|
+
`performer_score` states it without a gross or a hit beside it.
|
|
161
|
+
"""
|
|
162
|
+
return {
|
|
163
|
+
"name": e["manager_name"],
|
|
164
|
+
"points": e["event_total"],
|
|
165
|
+
"rank_str": e["rank_str"],
|
|
166
|
+
# An unset draft_entry_id must not match an entry the league details
|
|
167
|
+
# left without an entry id, which would print a rival as "You".
|
|
168
|
+
"is_user": user_entry_id is not None and e["entry_id"] == user_entry_id,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _print_performer(perf: Mapping[str, Any]) -> None:
|
|
173
|
+
"""Print one Best/Worst GW Performers row to the terminal.
|
|
174
|
+
|
|
175
|
+
The one renderer `fpl review` and `fpl league` share, for both formats:
|
|
176
|
+
`fpl league` kept its own copy, which numbered a tie as a placing and
|
|
177
|
+
worded a hit its own way (#381).
|
|
178
|
+
"""
|
|
179
|
+
name = "[bold cyan]You[/bold cyan]" if perf["is_user"] else rich_escape(perf["name"])
|
|
180
|
+
console.print(f" {perf['rank_str']}. {name} - {performer_score(perf)}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class GwPerformers:
|
|
185
|
+
"""A league's Best/Worst GW Performers, ranked for every command that shows them.
|
|
186
|
+
|
|
187
|
+
`best` and `worst` are performer rows. `user_gw_rank` is the user's
|
|
188
|
+
place on the gameweek ("3="), None when they are not in the standings
|
|
189
|
+
handed over. `user_context_row` is the user's own row when they sit
|
|
190
|
+
above the worst list: it is shown beside the list as a "Your GW rank"
|
|
191
|
+
line, never as a numbered row in it that reads as a bottom placing
|
|
192
|
+
(#360). `field_size` is None when the standings were one page of a
|
|
193
|
+
larger league.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
best: list[dict[str, Any]]
|
|
197
|
+
worst: list[dict[str, Any]]
|
|
198
|
+
user_gw_rank: str | None
|
|
199
|
+
user_context_row: dict[str, Any] | None
|
|
200
|
+
field_size: int | None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _gw_performers(
|
|
204
|
+
entries: list[dict[str, Any]],
|
|
205
|
+
score_key: str,
|
|
206
|
+
to_row: Callable[[Mapping[str, Any]], dict[str, Any]],
|
|
207
|
+
*,
|
|
208
|
+
worst_n: int,
|
|
209
|
+
complete: bool,
|
|
210
|
+
with_context_row: bool,
|
|
211
|
+
) -> GwPerformers:
|
|
212
|
+
"""Rank *entries* on *score_key* into the top three and the bottom *worst_n*.
|
|
213
|
+
|
|
214
|
+
Each list runs past its cut to keep a tie whole, numbered as a shared
|
|
215
|
+
place. Both passes rank the same dicts in place, so each pass's rows
|
|
216
|
+
are built before the next one re-ranks them.
|
|
217
|
+
"""
|
|
218
|
+
desc = sorted(entries, key=lambda e: e[score_key], reverse=True)
|
|
219
|
+
_assign_tie_ranks(desc, score_key)
|
|
220
|
+
best = [to_row(e) for e in _slice_with_ties(desc, 3)]
|
|
221
|
+
user_row = next((row for row in map(to_row, desc) if row["is_user"]), None)
|
|
222
|
+
|
|
223
|
+
asc = sorted(entries, key=lambda e: e[score_key])
|
|
224
|
+
_assign_tie_ranks(asc, score_key)
|
|
225
|
+
worst = [to_row(e) for e in _slice_with_ties(asc, worst_n)]
|
|
226
|
+
|
|
227
|
+
user_in_worst = any(row["is_user"] for row in worst)
|
|
228
|
+
return GwPerformers(
|
|
229
|
+
best=best,
|
|
230
|
+
worst=worst,
|
|
231
|
+
user_gw_rank=user_row["rank_str"] if user_row else None,
|
|
232
|
+
user_context_row=user_row if with_context_row and not user_in_worst else None,
|
|
233
|
+
field_size=len(entries) if complete else None,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def classic_gw_performers(standings_with_costs: list[dict[str, Any]], *, complete: bool) -> GwPerformers:
|
|
238
|
+
"""Classic Best/Worst GW Performers: top three and bottom five plus ties, on net points.
|
|
239
|
+
|
|
240
|
+
Takes `_fetch_standings_with_costs` output. `complete` is False when the
|
|
241
|
+
standings were one 50-entry page of a larger league: the GW ranks then
|
|
242
|
+
cover only that page, so no field size is named that would read as the
|
|
243
|
+
whole league. `fpl review` and `fpl league` both rank through here: they
|
|
244
|
+
each ran this sequence themselves and drifted apart on it (#381).
|
|
245
|
+
"""
|
|
246
|
+
return _gw_performers(
|
|
247
|
+
standings_with_costs, "net_points", _performer_row,
|
|
248
|
+
worst_n=5, complete=complete, with_context_row=True,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def draft_gw_performers(standings_with_names: list[dict[str, Any]], user_entry_id: int | None) -> GwPerformers:
|
|
253
|
+
"""Draft Best/Worst GW Performers: top and bottom three plus ties, on the GW score.
|
|
254
|
+
|
|
255
|
+
Takes standings rows carrying `manager_name`, `entry_id` and
|
|
256
|
+
`event_total`. Draft review has never printed a "Your GW rank" line, so
|
|
257
|
+
neither does this, and `user_context_row` is always None.
|
|
258
|
+
"""
|
|
259
|
+
return _gw_performers(
|
|
260
|
+
standings_with_names, "event_total", lambda e: _draft_performer_row(e, user_entry_id),
|
|
261
|
+
worst_n=3, complete=True, with_context_row=False,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _print_gw_performers(performers: GwPerformers, *, header_suffix: str = "") -> None:
|
|
266
|
+
"""Print both performer lists, and the user's GW rank line when they sit above the worst one."""
|
|
267
|
+
console.print(f"\n[bold]### Best GW Performers{header_suffix}[/bold]")
|
|
268
|
+
for perf in performers.best:
|
|
269
|
+
_print_performer(perf)
|
|
270
|
+
console.print(f"\n[bold]### Worst GW Performers{header_suffix}[/bold]")
|
|
271
|
+
for perf in performers.worst:
|
|
272
|
+
_print_performer(perf)
|
|
273
|
+
if performers.user_context_row:
|
|
274
|
+
line = your_gw_rank_line(performers.user_gw_rank, performers.field_size, performers.user_context_row)
|
|
275
|
+
console.print(f" {line}")
|
|
276
|
+
|
|
277
|
+
|
|
124
278
|
def _gw_position_with_half(position: int | str, total: int) -> str:
|
|
125
279
|
"""Return position string annotated with pre-computed half classification.
|
|
126
280
|
|
|
@@ -12,17 +12,16 @@ from rich.table import Table
|
|
|
12
12
|
|
|
13
13
|
from fpl_cli.cli._context import console, error_console
|
|
14
14
|
from fpl_cli.cli._helpers import (
|
|
15
|
-
_assign_tie_ranks,
|
|
16
15
|
_center_window_with_ties,
|
|
17
16
|
_fetch_standings_with_costs,
|
|
18
17
|
_format_pts_display,
|
|
19
18
|
_format_review_player,
|
|
20
19
|
_live_player_stats,
|
|
21
20
|
_net_transfer_ids,
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
_print_gw_performers,
|
|
22
|
+
classic_gw_performers,
|
|
23
|
+
print_historical_league_notice,
|
|
24
24
|
signed_net_markup,
|
|
25
|
-
your_gw_rank_line,
|
|
26
25
|
)
|
|
27
26
|
from fpl_cli.cli._league_recap_data import derive_point_in_time_positions
|
|
28
27
|
from fpl_cli.services.fixture_predictions import is_blank_gameweek, is_double_gameweek
|
|
@@ -74,7 +73,7 @@ async def _review_classic_team(
|
|
|
74
73
|
|
|
75
74
|
if entry_id:
|
|
76
75
|
try:
|
|
77
|
-
|
|
76
|
+
error_console.print("[dim]Fetching your team data...[/dim]")
|
|
78
77
|
picks_response = await client.get_manager_picks(entry_id, gw)
|
|
79
78
|
|
|
80
79
|
entry_history = picks_response.get("entry_history", {})
|
|
@@ -262,7 +261,7 @@ async def _review_classic_team(
|
|
|
262
261
|
elif entry_id:
|
|
263
262
|
error_console.print("[yellow]Could not fetch your team data[/yellow]")
|
|
264
263
|
else:
|
|
265
|
-
|
|
264
|
+
error_console.print("[dim]Set classic_entry_id in config/settings.yaml to see your squad[/dim]")
|
|
266
265
|
|
|
267
266
|
return {
|
|
268
267
|
"my_entry_summary": my_entry_summary,
|
|
@@ -391,27 +390,6 @@ async def _review_classic_transfers(
|
|
|
391
390
|
return classic_transfers_data
|
|
392
391
|
|
|
393
392
|
|
|
394
|
-
def _performer_row(e: dict[str, Any]) -> dict[str, Any]:
|
|
395
|
-
"""A standings entry as a Best/Worst GW Performers row.
|
|
396
|
-
|
|
397
|
-
`points` is the score the row was ranked on (net when the league plays
|
|
398
|
-
net), which is what `performer_score` and the last-place fine read.
|
|
399
|
-
"""
|
|
400
|
-
return {
|
|
401
|
-
"name": e["name"],
|
|
402
|
-
"points": e["net_points"],
|
|
403
|
-
"gross_points": e["gross_points"],
|
|
404
|
-
"transfer_cost": e["transfer_cost"],
|
|
405
|
-
"rank_str": e["rank_str"],
|
|
406
|
-
"is_user": e.get("is_user", False),
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
def _print_performer(perf: dict[str, Any]) -> None:
|
|
411
|
-
name = "[bold cyan]You[/bold cyan]" if perf["is_user"] else rich_escape(perf["name"])
|
|
412
|
-
console.print(f" {perf['rank_str']}. {name} - {performer_score(perf)}")
|
|
413
|
-
|
|
414
|
-
|
|
415
393
|
async def _review_classic_league(
|
|
416
394
|
client: FPLClient,
|
|
417
395
|
classic_league_id: int | None,
|
|
@@ -444,12 +422,12 @@ async def _review_classic_league(
|
|
|
444
422
|
league_name = standings_data.get("league", {}).get("name", "Classic League")
|
|
445
423
|
standings = standings_data.get("standings", {}).get("results", [])
|
|
446
424
|
|
|
447
|
-
# Check if we're reviewing a historical GW (league data would be stale)
|
|
425
|
+
# Check if we're reviewing a historical GW (league data would be stale).
|
|
426
|
+
# The heading is printed only on the path that has a table for it to
|
|
427
|
+
# introduce: the two below say why there is none, on stderr.
|
|
448
428
|
is_historical_review = api_current_gw_id is not None and gw != api_current_gw_id
|
|
449
429
|
if is_historical_review:
|
|
450
|
-
|
|
451
|
-
console.print(f"[dim]League standings not shown for historical GW{gw} review[/dim]")
|
|
452
|
-
console.print("[dim]Use 'fpl league' for current standings[/dim]")
|
|
430
|
+
print_historical_league_notice(gw)
|
|
453
431
|
return {"league_name": league_name}
|
|
454
432
|
|
|
455
433
|
# FPL builds a mini-league's table for the first time only once the
|
|
@@ -457,12 +435,11 @@ async def _review_classic_league(
|
|
|
457
435
|
# `new_entries` and `results` is empty. Rendering the sections anyway
|
|
458
436
|
# printed bare headings and handed the report a 0-point, 0-entry league.
|
|
459
437
|
if not standings:
|
|
460
|
-
|
|
461
|
-
console.print(
|
|
438
|
+
error_console.print(
|
|
462
439
|
f"[dim]{rich_escape(league_name)}: standings not published yet"
|
|
463
440
|
" -- FPL builds mini-league tables after the opening gameweek is finalised[/dim]"
|
|
464
441
|
)
|
|
465
|
-
|
|
442
|
+
error_console.print("[dim]Re-run once the table appears, or use 'fpl league'[/dim]")
|
|
466
443
|
return {"league_name": league_name, "standings_pending": True}
|
|
467
444
|
|
|
468
445
|
console.print("\n[bold]## League[/bold]")
|
|
@@ -540,54 +517,20 @@ async def _review_classic_league(
|
|
|
540
517
|
client, standings, entry_id, gw, fetch_costs=use_net_points,
|
|
541
518
|
)
|
|
542
519
|
|
|
543
|
-
#
|
|
544
|
-
|
|
545
|
-
sorted_by_net_desc = sorted(standings_with_costs, key=lambda x: x["net_points"], reverse=True)
|
|
546
|
-
_assign_tie_ranks(sorted_by_net_desc, "net_points")
|
|
547
|
-
|
|
548
|
-
# Snapshot best performer report data before ascending sort overwrites ranks
|
|
549
|
-
best_performers_for_report = [
|
|
550
|
-
_performer_row(e) for e in _slice_with_ties(sorted_by_net_desc, 3)
|
|
551
|
-
]
|
|
552
|
-
|
|
553
|
-
console.print(f"\n[bold]### Best GW Performers{header_suffix}[/bold]")
|
|
554
|
-
for perf in best_performers_for_report:
|
|
555
|
-
_print_performer(perf)
|
|
556
|
-
|
|
557
|
-
# Capture user's GW rank within the classic league (by net points)
|
|
558
|
-
user_gw_entry = next(
|
|
559
|
-
(e for e in sorted_by_net_desc if e["is_user"]), None
|
|
560
|
-
)
|
|
561
|
-
classic_user_gw_rank = user_gw_entry["rank_str"] if user_gw_entry else None
|
|
562
|
-
# One page of standings is 50 entries; past that the ranks above
|
|
563
|
-
# cover only the page fetched, so the report must not name a field
|
|
564
|
-
# size that reads as the whole league
|
|
520
|
+
# One page of standings is 50 entries; past that the GW ranks cover
|
|
521
|
+
# only the page fetched (see `classic_gw_performers`)
|
|
565
522
|
standings_complete = not standings_data.get("standings", {}).get("has_next", False)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
# Worst performers (bottom 5 + ties)
|
|
569
|
-
sorted_by_net_asc = sorted(standings_with_costs, key=lambda x: x["net_points"])
|
|
570
|
-
_assign_tie_ranks(sorted_by_net_asc, "net_points")
|
|
571
|
-
|
|
572
|
-
worst_performers_data = _slice_with_ties(sorted_by_net_asc, 5)
|
|
573
|
-
user_in_bottom = any(p["is_user"] for p in worst_performers_data)
|
|
523
|
+
performers = classic_gw_performers(standings_with_costs, complete=standings_complete)
|
|
574
524
|
user_entry_data = next((p for p in standings_with_costs if p["is_user"]), None)
|
|
575
|
-
# The user's own row when they sit above the bottom five, shown beside
|
|
576
|
-
# the table rather than in it: a numbered row there read as a bottom
|
|
577
|
-
# placing (#360). Its `rank_str` is the ascending one, so it is
|
|
578
|
-
# labelled with the GW rank instead.
|
|
579
|
-
user_context_row = (
|
|
580
|
-
_performer_row(user_entry_data) if user_entry_data and not user_in_bottom else None
|
|
581
|
-
)
|
|
582
525
|
|
|
583
526
|
# Calculate transfer impact narrative (only when net points are tracked)
|
|
584
527
|
transfer_impact = None
|
|
585
|
-
if use_net_points and len(
|
|
586
|
-
last_place =
|
|
528
|
+
if use_net_points and len(performers.worst) >= 2:
|
|
529
|
+
last_place = performers.worst[0]
|
|
587
530
|
|
|
588
531
|
if user_entry_data:
|
|
589
532
|
user_transfer_cost = user_entry_data["transfer_cost"]
|
|
590
|
-
user_is_last =
|
|
533
|
+
user_is_last = last_place["is_user"]
|
|
591
534
|
if user_is_last and user_transfer_cost > 0:
|
|
592
535
|
sorted_by_gross = sorted(standings_with_costs, key=lambda x: x["gross_points"])
|
|
593
536
|
user_gross_rank = sorted_by_gross.index(user_entry_data)
|
|
@@ -600,13 +543,7 @@ async def _review_classic_league(
|
|
|
600
543
|
f"{last_place['name']}'s -{last_place['transfer_cost']} hit saved you from last place"
|
|
601
544
|
)
|
|
602
545
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
console.print(f"\n[bold]### Worst GW Performers{header_suffix}[/bold]")
|
|
606
|
-
for perf in worst_performers_for_report:
|
|
607
|
-
_print_performer(perf)
|
|
608
|
-
if user_context_row:
|
|
609
|
-
console.print(f" {your_gw_rank_line(classic_user_gw_rank, gw_field_size, user_context_row)}")
|
|
546
|
+
_print_gw_performers(performers, header_suffix=" (Net Points)" if use_net_points else "")
|
|
610
547
|
|
|
611
548
|
if transfer_impact:
|
|
612
549
|
error_console.print(f"\n[yellow] ⚠ {rich_escape(transfer_impact)}[/yellow]")
|
|
@@ -615,8 +552,8 @@ async def _review_classic_league(
|
|
|
615
552
|
classic_league_data = {
|
|
616
553
|
"league_name": league_name,
|
|
617
554
|
"user_position": user_rank,
|
|
618
|
-
"user_gw_rank":
|
|
619
|
-
"gw_field_size":
|
|
555
|
+
"user_gw_rank": performers.user_gw_rank,
|
|
556
|
+
"gw_field_size": performers.field_size,
|
|
620
557
|
"total_entries": total_entries,
|
|
621
558
|
"user_gw_points": user_gw_pts,
|
|
622
559
|
"user_total": user_total,
|
|
@@ -635,9 +572,9 @@ async def _review_classic_league(
|
|
|
635
572
|
],
|
|
636
573
|
"nearby_rivals_omitted": nearby_omitted,
|
|
637
574
|
"use_net_points": use_net_points,
|
|
638
|
-
"best_performers":
|
|
639
|
-
"worst_performers":
|
|
640
|
-
"user_context_row": user_context_row,
|
|
575
|
+
"best_performers": performers.best,
|
|
576
|
+
"worst_performers": performers.worst,
|
|
577
|
+
"user_context_row": performers.user_context_row,
|
|
641
578
|
"transfer_impact": transfer_impact,
|
|
642
579
|
}
|
|
643
580
|
if use_net_points and user_entry_data:
|
|
@@ -11,12 +11,13 @@ from rich.table import Table
|
|
|
11
11
|
|
|
12
12
|
from fpl_cli.cli._context import console, error_console
|
|
13
13
|
from fpl_cli.cli._helpers import (
|
|
14
|
-
_assign_tie_ranks,
|
|
15
14
|
_format_pts_display,
|
|
16
15
|
_format_review_player,
|
|
17
16
|
_live_player_stats,
|
|
18
17
|
_net_transfer_ids,
|
|
19
|
-
|
|
18
|
+
_print_gw_performers,
|
|
19
|
+
draft_gw_performers,
|
|
20
|
+
print_historical_league_notice,
|
|
20
21
|
signed_net_markup,
|
|
21
22
|
)
|
|
22
23
|
from fpl_cli.models.player import POSITION_MAP
|
|
@@ -444,9 +445,7 @@ async def _review_draft(
|
|
|
444
445
|
# ## League section - only show for current GW (live data)
|
|
445
446
|
is_historical_review = api_current_gw_id is not None and gw != api_current_gw_id
|
|
446
447
|
if is_historical_review:
|
|
447
|
-
|
|
448
|
-
console.print(f"[dim]League standings not shown for historical GW{gw} review[/dim]")
|
|
449
|
-
console.print("[dim]Use 'fpl league' for current standings[/dim]")
|
|
448
|
+
print_historical_league_notice(gw)
|
|
450
449
|
else:
|
|
451
450
|
console.print("\n[bold]## League[/bold]")
|
|
452
451
|
console.print(f"- Position: {user_rank} of {total_entries}")
|
|
@@ -468,71 +467,31 @@ async def _review_draft(
|
|
|
468
467
|
"event_total": s.get("event_total", 0),
|
|
469
468
|
})
|
|
470
469
|
|
|
471
|
-
#
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
console.print("\n[bold]### Best GW Performers[/bold]")
|
|
477
|
-
for entry in best_gw_display:
|
|
478
|
-
name = entry["manager_name"]
|
|
479
|
-
gw_pts = entry["event_total"]
|
|
480
|
-
rank = entry["rank_str"]
|
|
481
|
-
is_user = entry["entry_id"] == draft_entry_id
|
|
482
|
-
if is_user:
|
|
483
|
-
console.print(f" {rank}. [bold cyan]You[/bold cyan] - {gw_pts} pts")
|
|
484
|
-
else:
|
|
485
|
-
console.print(f" {rank}. {rich_escape(name)} - {gw_pts} pts")
|
|
486
|
-
|
|
487
|
-
# Snapshot best data and user GW rank before worst sort overwrites ranks
|
|
488
|
-
best_for_report = [
|
|
489
|
-
{"name": e["manager_name"], "points": e["event_total"], "rank_str": e["rank_str"]}
|
|
490
|
-
for e in best_gw_display
|
|
491
|
-
]
|
|
492
|
-
user_entry = next(
|
|
493
|
-
(e for e in sorted_by_gw if e["entry_id"] == draft_entry_id),
|
|
494
|
-
None,
|
|
495
|
-
)
|
|
496
|
-
user_gw_rank = user_entry["rank_str"] if user_entry else None
|
|
497
|
-
|
|
498
|
-
# Worst performers: sorted ascending (bottom 3 + ties)
|
|
499
|
-
worst_sorted = sorted(standings_with_names, key=lambda x: x["event_total"])
|
|
500
|
-
_assign_tie_ranks(worst_sorted, "event_total")
|
|
501
|
-
worst_gw_display = _slice_with_ties(worst_sorted, 3)
|
|
502
|
-
|
|
503
|
-
console.print("\n[bold]### Worst GW Performers[/bold]")
|
|
504
|
-
for entry in worst_gw_display:
|
|
505
|
-
name = entry["manager_name"]
|
|
506
|
-
gw_pts = entry["event_total"]
|
|
507
|
-
rank = entry["rank_str"]
|
|
508
|
-
is_user = entry["entry_id"] == draft_entry_id
|
|
509
|
-
if is_user:
|
|
510
|
-
console.print(f" {rank}. [bold cyan]You[/bold cyan] - {gw_pts} pts")
|
|
511
|
-
else:
|
|
512
|
-
console.print(f" {rank}. {rich_escape(name)} - {gw_pts} pts")
|
|
470
|
+
# Top and bottom three plus ties, ranked and printed by the
|
|
471
|
+
# code `fpl league` uses (#381)
|
|
472
|
+
performers = draft_gw_performers(standings_with_names, draft_entry_id)
|
|
473
|
+
_print_gw_performers(performers)
|
|
513
474
|
|
|
514
475
|
# Store for report (worst_performers sorted ascending - lowest first)
|
|
515
476
|
draft_league_data = {
|
|
516
477
|
"league_name": draft_league_name,
|
|
517
478
|
"user_position": user_rank,
|
|
518
|
-
"user_gw_rank": user_gw_rank,
|
|
479
|
+
"user_gw_rank": performers.user_gw_rank,
|
|
519
480
|
"total_entries": total_entries,
|
|
520
481
|
"user_gw_points": user_gw_pts,
|
|
521
482
|
"user_total": user_total,
|
|
522
|
-
"best_performers":
|
|
523
|
-
"worst_performers":
|
|
524
|
-
{
|
|
525
|
-
"name": e["manager_name"],
|
|
526
|
-
"points": e["event_total"],
|
|
527
|
-
"rank_str": e["rank_str"],
|
|
528
|
-
"is_user": e["entry_id"] == draft_entry_id,
|
|
529
|
-
}
|
|
530
|
-
for e in worst_gw_display
|
|
531
|
-
],
|
|
483
|
+
"best_performers": performers.best,
|
|
484
|
+
"worst_performers": performers.worst,
|
|
532
485
|
}
|
|
533
486
|
|
|
534
487
|
elif not draft_entry_id:
|
|
535
|
-
|
|
488
|
+
error_console.print("[dim]Set draft_entry_id in config/settings.yaml to see your draft squad[/dim]")
|
|
489
|
+
else:
|
|
490
|
+
error_console.print(
|
|
491
|
+
f"[yellow]Could not find draft_entry_id {draft_entry_id} in the standings of"
|
|
492
|
+
f" {rich_escape(draft_league_name)} -- check draft_entry_id and draft_league_id"
|
|
493
|
+
" in config/settings.yaml[/yellow]"
|
|
494
|
+
)
|
|
536
495
|
|
|
537
496
|
except Exception as e: # noqa: BLE001 — display resilience
|
|
538
497
|
error_console.print(f"[yellow]Could not fetch draft league data: {rich_escape(str(e))}[/yellow]")
|