FitCoach / utils /pushback.py
coffeine16's picture
Upload folder using huggingface_hub
5dadca5 verified
"""
Actor Pushback Engine β€” makes actors ACTIVE, not passive.
After the orchestrator submits a plan, each actor reviews it
against their own constraints and either ACCEPTS or REJECTS
with specific, actionable suggestions.
This transforms the environment from a "grader" into a
"negotiation arena" β€” the orchestrator must iterate with
actors until all accept.
Example episode flow:
Step 1: consult fitness_advisor β†’ "use 10-16 sets, no barbell"
Step 2: consult nutrition_advisor β†’ "target 2650 kcal"
Step 3: submit_plan β†’ REJECTED by fitness_advisor:
"You prescribed 24 sets β€” max is 16 for a beginner.
Reduce to 14-16 sets. Also, 'Barbell Squat' needs
barbell which is not available. Use Dumbbell Squat."
Step 4: submit revised plan β†’ ACCEPTED by all β†’ reward 0.95
"""
from __future__ import annotations
import json
from typing import Optional
# Exercise alternatives for injury-safe substitutions
SAFE_ALTERNATIVES = {
"deadlift": ["hip thrust", "glute bridge", "cable pull-through", "goblet squat"],
"bent-over row": ["chest-supported row", "cable row", "dumbbell row (neutral spine)"],
"good morning": ["hip thrust", "glute bridge", "Romanian deadlift (light, neutral)"],
"overhead press": ["landmine press", "high incline press", "front raise"],
"upright row": ["lateral raise", "face pull", "rear delt fly"],
"lunge": ["leg press (shallow)", "step-up (low box)", "wall sit"],
"deep squat": ["goblet squat (parallel)", "leg press", "wall sit"],
"box jump": ["step-up", "sled push", "seated box jump"],
"leg extension": ["terminal knee extension (band)", "wall sit", "step-up"],
"barbell curl": ["dumbbell curl (neutral grip)", "hammer curl", "cable curl"],
}
# Equipment-safe alternatives
EQUIPMENT_SWAPS = {
"barbell squat": "dumbbell squat",
"barbell bench press": "dumbbell bench press",
"barbell row": "dumbbell row",
"barbell deadlift": "dumbbell Romanian deadlift",
"barbell curl": "dumbbell curl",
"lat pulldown": "pull-up (or resistance band pull-down)",
"cable row": "dumbbell row",
"leg press": "dumbbell squat",
"chest press machine":"dumbbell bench press",
}
def _get_exercises(workout: dict) -> list[dict]:
exercises = []
for day in workout.get("days", []):
exercises.extend(day.get("exercises", []))
return exercises
def fitness_pushback(
plan_workout: dict,
client: dict,
actor_response: dict,
) -> Optional[dict]:
"""
FitnessAdvisor reviews the submitted plan.
Returns None if ACCEPTED, or a rejection dict with suggestions.
"""
issues = []
suggestions = []
constraints = actor_response.get("constraints", {})
vol_min = constraints.get("weekly_sets_min", 0)
vol_max = constraints.get("weekly_sets_max", 999)
banned = constraints.get("must_avoid_exercises", [])
equipment = set(constraints.get("must_use_only_equipment", []))
exercises = _get_exercises(plan_workout)
agent_sets = int(plan_workout.get("weekly_volume_sets", 0))
if agent_sets == 0:
agent_sets = sum(int(ex.get("sets", 0) or 0) for ex in exercises)
# Check volume
if agent_sets < vol_min:
issues.append(f"Volume too low: {agent_sets} sets/week (minimum {vol_min})")
suggestions.append(f"Increase to {vol_min}-{vol_max} sets/week. Add 1-2 more exercises per day.")
elif agent_sets > vol_max:
issues.append(f"Volume too high: {agent_sets} sets/week (maximum {vol_max})")
suggestions.append(f"Reduce to {vol_min}-{vol_max} sets/week. Remove 1-2 exercises or reduce sets per exercise to 2.")
# Check banned exercises
for ex in exercises:
name_l = ex.get("name", "").lower()
for banned_move in banned:
if banned_move in name_l:
alts = SAFE_ALTERNATIVES.get(banned_move, ["a safe alternative"])
issues.append(f"'{ex['name']}' is BANNED due to injury")
suggestions.append(
f"Replace '{ex['name']}' with: {', '.join(alts[:3])}"
)
# Check equipment
EQUIPMENT_KEYWORDS = {
"barbell": {"barbell"},
"cables": {"cable", "lat pulldown"},
"machines": {"machine", "leg press", "leg curl", "leg extension"},
"kettlebell": {"kettlebell"},
}
for ex in exercises:
name_l = ex.get("name", "").lower()
for eq, keywords in EQUIPMENT_KEYWORDS.items():
if eq not in equipment and any(kw in name_l for kw in keywords):
swap = EQUIPMENT_SWAPS.get(name_l.strip(), f"a {list(equipment)[0] if equipment else 'bodyweight'} alternative")
issues.append(f"'{ex['name']}' requires {eq} (not available)")
suggestions.append(f"Replace '{ex['name']}' with {swap}")
break
if not issues:
return None # ACCEPTED
return {
"actor": "fitness_advisor",
"verdict": "REJECTED",
"issues": issues,
"suggestions": suggestions,
"message": (
f"πŸ‹οΈ FITNESS ADVISOR REJECTS this plan ({len(issues)} issue(s)):\n"
+ "\n".join(f" βœ— {iss}" for iss in issues)
+ "\n\nSuggested fixes:\n"
+ "\n".join(f" β†’ {sug}" for sug in suggestions)
),
}
def nutrition_pushback(
plan_nutrition: dict,
client: dict,
actor_response: dict,
) -> Optional[dict]:
"""
NutritionAdvisor reviews the submitted plan.
Returns None if ACCEPTED, or a rejection dict with suggestions.
"""
issues = []
suggestions = []
constraints = actor_response.get("constraints", {})
target_cal = constraints.get("calories_target", 0)
target_pro = constraints.get("protein_minimum_g", 0)
banned_foods = constraints.get("banned_foods", [])
tolerance = constraints.get("tolerance_pct", 15) / 100
daily = plan_nutrition.get("daily_targets", {})
agent_cal = float(daily.get("calories", 0))
agent_pro = float(daily.get("protein_g", 0))
# Check macros
if target_cal > 0 and agent_cal > 0:
cal_err = abs(agent_cal - target_cal) / target_cal
if cal_err > tolerance:
direction = "increase" if agent_cal < target_cal else "decrease"
issues.append(
f"Calories off by {cal_err*100:.0f}%: {agent_cal:.0f} kcal "
f"(target {target_cal:.0f} Β± {tolerance*100:.0f}%)"
)
suggestions.append(
f"{direction.capitalize()} calories to {target_cal:.0f} kcal. "
f"Acceptable range: {target_cal*(1-tolerance):.0f}–{target_cal*(1+tolerance):.0f} kcal."
)
elif target_cal > 0 and agent_cal == 0:
issues.append("No calorie target specified in daily_targets")
suggestions.append(f"Set daily_targets.calories to {target_cal:.0f} kcal")
if target_pro > 0 and agent_pro > 0:
pro_err = abs(agent_pro - target_pro) / target_pro
if pro_err > tolerance:
issues.append(
f"Protein off by {pro_err*100:.0f}%: {agent_pro:.0f}g "
f"(target {target_pro:.0f}g)"
)
suggestions.append(
f"Adjust protein to {target_pro:.0f}g/day. "
f"Add high-protein foods: paneer (18g/100g), rajma (8.7g/100g), soya chunks (52g/100g)."
)
# Check banned foods
plan_text = json.dumps(plan_nutrition).lower()
for food in banned_foods:
if food.lower() in plan_text:
issues.append(f"'{food}' is banned for this client's dietary restrictions")
suggestions.append(f"Remove '{food}'. Use plant-based alternatives from IFCT 2017 database.")
break # one violation is enough
if not issues:
return None # ACCEPTED
return {
"actor": "nutrition_advisor",
"verdict": "REJECTED",
"issues": issues,
"suggestions": suggestions,
"message": (
f"πŸ₯— NUTRITION ADVISOR REJECTS this plan ({len(issues)} issue(s)):\n"
+ "\n".join(f" βœ— {iss}" for iss in issues)
+ "\n\nSuggested fixes:\n"
+ "\n".join(f" β†’ {sug}" for sug in suggestions)
),
}
def progress_pushback(
plan_workout: dict,
plan_nutrition: dict,
client: dict,
progress: dict,
complications: list,
actor_response: dict,
) -> Optional[dict]:
"""
ProgressAnalyst reviews the submitted plan.
Returns None if ACCEPTED, or a rejection dict.
"""
issues = []
suggestions = []
constraints = actor_response.get("constraints", {})
must_adapt = constraints.get("must_adapt_if_plateau", False)
if not must_adapt:
return None # No plateau, auto-accept
# Check if the plan actually adapted
exercises = _get_exercises(plan_workout)
agent_sets = int(plan_workout.get("weekly_volume_sets", 0))
if agent_sets == 0:
agent_sets = sum(int(ex.get("sets", 0) or 0) for ex in exercises)
daily = plan_nutrition.get("daily_targets", {})
agent_cal = float(daily.get("calories", 0))
# Get baseline volume/calories from actor recommendations
recs = actor_response.get("recommendations", {})
plateau_status = recs.get("plateau_status", "")
if plateau_status not in ("plateau", "reversing"):
return None
signals = recs.get("signals", [])
plateau_signal = next((s for s in signals if s["type"] == "plateau"), None)
if not plateau_signal:
return None
# The agent should have done something β€” increased volume or adjusted calories
# We check loosely here since the grader does the precise check
goal = client.get("goal", "maintenance")
if goal == "weight_loss" and agent_cal > 1800:
# Probably didn't reduce calories enough β€” but let grader decide
pass
# Check if required actions were taken
required = plateau_signal.get("required_actions", [])
if required and agent_sets == 0 and agent_cal == 0:
issues.append("Plateau detected but plan has no volume or calorie data")
suggestions.append("You must either increase volume β‰₯10% or adjust calories β‰₯150 kcal")
if not issues:
return None
return {
"actor": "progress_analyst",
"verdict": "REJECTED",
"issues": issues,
"suggestions": suggestions,
"message": (
f"πŸ“Š PROGRESS ANALYST REJECTS this plan ({len(issues)} issue(s)):\n"
+ "\n".join(f" βœ— {iss}" for iss in issues)
+ "\n\nSuggested fixes:\n"
+ "\n".join(f" β†’ {sug}" for sug in suggestions)
),
}
def collect_actor_pushback(
workout: dict,
nutrition: dict,
client: dict,
progress: dict,
complications: list,
actor_responses: dict,
) -> list[dict]:
"""
Run all actor pushbacks and collect rejections.
Returns list of rejection dicts (empty if all accepted).
"""
rejections = []
if "fitness_advisor" in actor_responses:
fb = fitness_pushback(workout, client, actor_responses["fitness_advisor"])
if fb:
rejections.append(fb)
if "nutrition_advisor" in actor_responses:
nb = nutrition_pushback(nutrition, client, actor_responses["nutrition_advisor"])
if nb:
rejections.append(nb)
if "progress_analyst" in actor_responses:
pb = progress_pushback(
workout, nutrition, client, progress, complications,
actor_responses["progress_analyst"]
)
if pb:
rejections.append(pb)
return rejections