File size: 12,066 Bytes
5dadca5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
"""

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