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.
- {fplkit-2.6.0 → fplkit-2.6.1}/CHANGELOG.md +13 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/PKG-INFO +1 -1
- {fplkit-2.6.0 → fplkit-2.6.1}/docs/architecture.md +1 -1
- {fplkit-2.6.0 → fplkit-2.6.1}/docs/command-reference.md +8 -3
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/_version.py +2 -2
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_review_summarisation.py +52 -12
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/doctor.py +7 -3
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/config/defaults.yaml +1 -1
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/team_ratings.py +61 -6
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/templates/gw_review.md.j2 +2 -2
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_doctor.py +95 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_review.py +84 -7
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_providers.py +16 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_report_agent.py +6 -6
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_team_ratings.py +124 -1
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/README.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/TOOLS.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/build-test-plan/SKILL.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/build-test-plan/references/plan-template.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/build-test-plan/references/workspace-env.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/create-pr/SKILL.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/SKILL.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/references/entity-normalisation.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/references/output-template.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/references/rules.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/_bootstrap.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/normalise_entities.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/preview-ingest/SKILL.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/release/SKILL.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/release-notes/SKILL.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/squad-builder/SKILL.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/squad-builder/references/output-template.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/squad-builder/references/rules.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.agents/skills/update-gw-prep/SKILL.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.claude/output-styles/fpl-mate.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.claude/settings.json +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.env.example +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.github/workflows/ci.yml +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.github/workflows/pr-title.yml +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.github/workflows/release.yml +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/.gitignore +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/AGENTS.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/CLAUDE.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/CONTRIBUTING.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/LICENSE +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/README.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/cliff.toml +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/config/team_ratings_overrides.yaml +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/docs/custom-analysis.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/docs/fpl-rules.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/docs/images/fpl-player-demo-v1-0.png +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/action/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/action/waiver.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/bench_order.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/captain.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/starting_xi.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/stats.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/base.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/common.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/data/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/data/fixture.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/data/price.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/data/scout.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/orchestration/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/agents/orchestration/report.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/contract.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/core_insights.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/dataset_fetcher.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/football_data.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/fpl.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/fpl_draft.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/historical.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/historical_types.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/_http.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/_models.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/anthropic.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/openai_compat.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/providers/perplexity.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/understat.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/api/vaastav.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_banner.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_context.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_fines.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_fines_config.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_helpers.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_json.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_league_recap_data.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_league_recap_history.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_league_recap_types.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_plan_grid.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_review_analysis.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_review_classic.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/_review_draft.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/allocate.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/captain.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/chips.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/credentials.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/differentials.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/doctor_providers.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/fdr.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/fixtures.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/history.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/init.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/intel.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/league.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/league_fines.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/league_recap.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/player.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/preview.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/price_changes.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/price_history.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/ratings.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/returnees.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/review.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/sell_prices.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/squad.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/stats.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/status.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/targets.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/transfer_eval.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/waivers.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/cli/xg.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/config/fixture_predictions.yaml +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/config/previews/EXAMPLE.yaml +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/config/team_managers.yaml +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/constants.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/chip_plan.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/fixture.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/league_history.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/player.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/team.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/models/types.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/parsers/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/parsers/recommendations.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/paths.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/league_recap.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/returnees.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/review.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/prompts/scout.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/scraper/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/scraper/fpl_prices.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/season.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/fixture_predictions.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/league_history.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/league_history_counters.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/league_history_fines.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/league_history_notes.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/matchup.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/player_clubs.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/player_prior.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/returnee_radar.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/constants.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/data_prep.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/display.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/evaluation.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/ownership.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/shrinkage.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/signals.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/single_gw.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/scoring/value_quality.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/season_previews.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/squad_allocator.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/team_form.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/services/team_ratings_prior.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/templates/gw_preview.md.j2 +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/files.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/gameweek.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/markdown.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/teams.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/text.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/fpl_cli/utils/time.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/pyproject.toml +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/pyrightconfig.json +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/scripts/backtest_early_season_prior_blend.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/scripts/calibrate_promoted_prior.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/scripts/calibrate_quality_ceilings.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/__init__.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/conftest.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/classic_squad_fixture.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/recommendations_fixture.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/fixtures/waiver_ranked_golden.json +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_action.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_analysis.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_analysis_squad.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_base.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_data.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_data_price.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_agents_orchestration.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_allocate.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_client.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_core_insights.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_dataset_fetcher.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_draft.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_football_data.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_historical.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_understat.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_api_vaastav.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_bench_order_script.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_calibrate_quality_ceilings.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_classic_squad_extraction.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_allocate.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_banner.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_captain.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_chips.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_context_settings.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_credentials.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_custom_analysis.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_differentials.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_doctor_providers.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_failure_parity.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_failure_streams.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_fdr.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_fdr_blanks.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_fixtures.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_format.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_format_suppression.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_fpl_block.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_history.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_init.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_intel.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_json.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_json_contract.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_league.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_league_fines.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_league_recap.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_plan_grid.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_player.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_preview.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_price_changes.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_price_history.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_returnees.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_sell_prices.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_settings_warning.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_squad.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_stats.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_status.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_targets.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_transfer_eval.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_waivers.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_warnings_stderr.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_cli_xg.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_common.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_common_scoring.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_data_prep_harmonisation.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_dependencies.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_doc_consistency.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_fines.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_fines_config.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_fixture_predictions.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_fpl_prices.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_gw_prep_script_bootstrap.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_init.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_init_fines.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_history_counters.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_history_fines.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_history_notes.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_history_store.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_league_recap.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_matchup_service.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_models.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_normalise_entities.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_output_style_sync.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_paths.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_player_clubs.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_player_prior.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_player_resolution.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_preseason.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_prompt_tables.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_recommendations_parser.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_report_paths.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_returnee_radar.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_review.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_rolling_pts_per_m.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_scoring.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_scraper.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_season.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_season_awareness.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_season_previews.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_sell_prices.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_skill_fdr_quality_signals.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_skill_stats_thresholds.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_squad_allocator.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_starting_xi_agent.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_starting_xi_script.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_stats.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_team_ratings_prior.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_transfer_eval_agent.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_transfer_eval_script.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_utils_gameweek.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_utils_markdown.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_utils_text.py +0 -0
- {fplkit-2.6.0 → fplkit-2.6.1}/tests/test_utils_time.py +0 -0
- {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.
|
|
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:
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 6,
|
|
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
|
-
|
|
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__(
|
|
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 !=
|
|
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
|
-
|
|
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":
|
|
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=
|
|
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 {
|
|
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
|
-
> **
|
|
17
|
-
>
|
|
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
|
|
2231
|
-
|
|
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
|
|
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
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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("
|
|
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 "
|
|
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
|
|