Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,8 @@ sprint-4-tasks.md
sprint-5-tasks.md
sprint-6-tasks.md
sprint-7-tasks.md
fpl-agent-plan.md
fpl-agent-plan.md

# Scratch
scratch_manual_test.py
scratch_manual_test_explain.py
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ An AI-assisted Fantasy Premier League manager. It ingests FPL and news data, pre

- Backend: Python, FastAPI, SQLAlchemy, Alembic, Celery, Redis (broker + cache), Postgres
- Frontend: Next.js, React
- ILP: PuLP or OR-Tools (pick one early and stay consistent)
- LLM: Anthropic API via LangChain or direct SDK calls, tool-calling agent pattern
- ILP: **PuLP** (chosen in Sprint 2 — CBC solver via the `pulp[cbc]` extra, pure-Python-friendly install, no separate binary to manage manually; stay consistent, don't introduce OR-Tools later without a real reason)
- LLM: Google Gemini API, **direct SDK calls** (google-genai) — no LangChain for now; a single grounded explanation-generation call doesn't need chain/agent abstractions. Revisit for Sprint 4's tool-calling chat agent, where LangChain's agent loop may or may not earn its place — decide then, don't assume it now.
- Infra: Docker Compose locally, GitHub Actions CI/CD
- Testing: pytest (backend), fixture-based tests using saved sample FPL API payloads (don't hit the live API in tests)

Expand Down Expand Up @@ -76,4 +76,4 @@ No chip logic (wildcard/bench boost/free hit/triple captain), no multi-gameweek

## Current phase

Sprint 2: baseline predictions and optimizer — player feature transform, baseline xP model (rolling average x fixture difficulty), first ILP optimizer (short-term only, no transfer-cost logic yet — that's Sprint 3), recommendations API + dashboard page. See `docs/sprint-2-tasks.md` for the broken-down task list. Sprint 1 (repo scaffolding, DB schema, read-only ingestion, basic squad dashboard, CI) is complete.
Sprint 3: explanations and transfer economics — transfer-cost-aware ILP objective (short-term GW+1..+3 and long-term GW+4..+8 horizons, free-transfer state derived from public FPL entry-history data, confidence decay over horizon), grounded LLM explanation generation persisted per recommendation. See `docs/sprint-3-tasks.md` for the broken-down task list. Sprints 1 (foundations) and 2 (baseline xP model + short-term-only ILP optimizer) are complete.
278 changes: 278 additions & 0 deletions backend/agent/explain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import logging
import os
import re
from typing import Any

from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session

from db.models import Recommendation, RecommendationExplanation

logger = logging.getLogger(__name__)


# Pydantic schemas for structured Gemini response
class Citation(BaseModel):
claim: str = Field(
description=(
"A sentence or claim from the prose explanation "
"containing specific numbers."
)
)
supporting_data_ref: str = Field(
description=(
"The exact field and value used from the recommendation payload, "
"e.g., 'short_term_gain: -9.0' or 'predictions_in: 9.5 for gameweek 12'."
)
)


class ExplanationOutput(BaseModel):
prose: str = Field(
description=(
"A short prose explanation (2-4 sentences) covering what's "
"recommended, the short-term vs long-term rationale, and "
"any hit cost paid."
)
)
citations: list[Citation] = Field(
description="A list of claim and supporting_data_ref citation pairs."
)


def _collect_floats(val: Any) -> set[float]:
"""Recursively extract all numeric values from a nested dictionary/list

as floats.
"""
floats = set()
if isinstance(val, (int, float)):
floats.add(float(val))
elif isinstance(val, str):
# Find all numbers in the string and parse them as floats
for m in re.findall(r"-?\d+(?:\.\d+)?", val):
try:
floats.add(float(m))
except ValueError:
pass
elif isinstance(val, dict):
for k, v in val.items():
if isinstance(k, (int, float)):
floats.add(float(k))
elif isinstance(k, str):
try:
floats.add(float(k))
except ValueError:
pass
floats.update(_collect_floats(v))
elif isinstance(val, list):
for v in val:
floats.update(_collect_floats(v))
return floats


def validate_explanation(explanation_text: str, payload_json: dict | list) -> bool:
"""
Validation step checking that all numbers in the explanation prose
appear in the recommendation payload (either exactly, as absolute values,
or as substrings in string fields).
"""
# Extract all numbers from the explanation text
prose_numbers = re.findall(r"-?\d+(?:\.\d+)?", explanation_text)
if not prose_numbers:
return True

payload_floats = _collect_floats(payload_json)
payload_abs_floats = {abs(f) for f in payload_floats}

def _has_substring(obj: Any, target: str) -> bool:
if isinstance(obj, str) and target in obj:
return True
elif isinstance(obj, dict):
return any(
_has_substring(k, target) or _has_substring(v, target)
for k, v in obj.items()
)
elif isinstance(obj, list):
return any(_has_substring(v, target) for v in obj)
return False

for num_str in prose_numbers:
try:
val = float(num_str)
# 1. Exact or absolute float value match
if val in payload_floats or abs(val) in payload_abs_floats:
continue

# 2. Substring match in string representation (e.g. "FWD_15" matches "15")
if _has_substring(payload_json, num_str):
continue

# Number is present in prose but not found in source payload
logger.warning(
"Groundedness check failed: number '%s' in prose "
"does not appear in payload.",
num_str,
)
return False
except ValueError:
continue

return True


def explain_recommendation(
recommendation: Recommendation, session: Session
) -> RecommendationExplanation | None:
"""
Generate an explanation for a transfer recommendation using the Gemini SDK.
Persists the explanation to the recommendation_explanations table.
"""
if recommendation.type != "transfer":
logger.info(
"Skipping explanation generation: recommendation type is '%s' "
"(only 'transfer' is supported).",
recommendation.type,
)
return None

payload = recommendation.payload_json
if not payload:
logger.warning(
"Skipping explanation generation: recommendation payload is empty."
)
return None

# Inject price_pounds into the payload if not already present
# so that the groundedness check has access to the exact pounds
# representation (e.g., 8.5)
modified_payload = dict(payload)
for p_key in ["transfer_in", "transfer_out"]:
if p_key in modified_payload and isinstance(modified_payload[p_key], dict):
p_data = modified_payload[p_key]
if "price" in p_data and "price_pounds" not in p_data:
p_data["price_pounds"] = p_data["price"] / 10.0

# Save the modified payload back to the recommendation
recommendation.payload_json = modified_payload

# Extract details for the prompt
transfer_in_name = modified_payload.get("transfer_in", {}).get(
"name", "Unknown Player In"
)
transfer_in_price = modified_payload.get("transfer_in", {}).get("price_pounds")
transfer_in_pos = modified_payload.get("transfer_in", {}).get("position")

transfer_out_name = modified_payload.get("transfer_out", {}).get(
"name", "Unknown Player Out"
)
transfer_out_price = modified_payload.get("transfer_out", {}).get("price_pounds")
transfer_out_pos = modified_payload.get("transfer_out", {}).get("position")

short_term_gain = modified_payload.get("short_term_gain")
long_term_gain = modified_payload.get("long_term_gain")
hit_cost = modified_payload.get("hit_cost")
xp_gain = modified_payload.get("xp_gain")

predictions_in = modified_payload.get("predictions_in", {})
predictions_out = modified_payload.get("predictions_out", {})

data_summary = (
"Transfer Recommendation:\n"
f"- Player In: {transfer_in_name} (Price: {transfer_in_price}m, "
f"Position: {transfer_in_pos})\n"
f"- Player Out: {transfer_out_name} (Price: {transfer_out_price}m, "
f"Position: {transfer_out_pos})\n"
f"- Short-term Gain (GW+1 to GW+3): {short_term_gain}\n"
f"- Long-term Gain (GW+4 to GW+8): {long_term_gain}\n"
f"- Transfer Hit Cost: {hit_cost}\n"
f"- Net xP Gain (from optimizer): {xp_gain}\n\n"
"Player predicted xP values per gameweek:\n"
f"- {transfer_in_name} Predictions: {predictions_in}\n"
f"- {transfer_out_name} Predictions: {predictions_out}\n"
)

prompt = (
"You are the explanation generator for FergieTime, "
"an AI-assisted Fantasy Premier League manager.\n"
"Your task is to explain a recommended player transfer "
"to the manager.\n\n"
"Here is the transfer recommendation data:\n"
f"{data_summary}\n"
"Please generate an explanation containing:\n"
"1. A short prose explanation (2 to 4 sentences) covering:\n"
" - What transfer is recommended (e.g. transfer in player X, "
"transfer out player Y).\n"
" - The short-term vs long-term rationale (call out explicitly "
'if they diverge, e.g. "this costs points early but pays off '
'by gameweek X").\n'
" - Any hit cost paid.\n"
"2. A list of citations linking claims in the prose explanation "
"to fields/values in the provided data.\n"
" Each citation must have:\n"
" - 'claim': The sentence or phrase containing a specific "
"number.\n"
" - 'supporting_data_ref': The actual field and value used "
"from the recommendation data (e.g., 'short_term_gain: -9.0', "
"'hit_cost: 4', or 'Salah predictions: 9.5 for gameweek 12').\n\n"
"CRITICAL RULES:\n"
"1. State ONLY numbers that are present in the provided data.\n"
"2. Never estimate, round loosely in a way that changes meaning, "
"or invent a stat not passed in.\n"
"3. Every single number in the prose explanation MUST be present "
"in the provided data and cited in the citations list.\n"
)

# Call Gemini API
# Fallback to empty key if GEMINI_API_KEY is not in env (e.g. during mock tests)
api_key = os.environ.get("GEMINI_API_KEY", "MOCK_KEY")
client = genai.Client(api_key=api_key)

try:
response = client.models.generate_content(
model="gemini-3.5-flash",
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=ExplanationOutput,
temperature=0.0,
),
)

# Parse structured output
result = ExplanationOutput.model_validate_json(response.text)

# Run validation / groundedness check
is_valid = validate_explanation(result.prose, modified_payload)

# Determine explanation text to save
explanation_text = result.prose
if not is_valid:
logger.warning("Generated explanation failed groundedness check.")

citations_list = [c.model_dump() for c in result.citations]

# Create or update explanation
db_explanation = (
session.query(RecommendationExplanation)
.filter_by(recommendation_id=recommendation.id)
.first()
)
if db_explanation:
db_explanation.explanation_text = explanation_text
db_explanation.citations_json = citations_list
else:
db_explanation = RecommendationExplanation(
recommendation=recommendation,
explanation_text=explanation_text,
citations_json=citations_list,
)
session.add(db_explanation)

return db_explanation

except Exception as exc:
logger.error("Gemini API explanation generation failed: %s", exc)
raise exc
Loading
Loading