FITPLAN_AI_DUP / model_api.py
Sreehitha-V's picture
Create model_api.py
7a410c3 verified
"""
model_api.py β€” Fast AI plan generation for FitPlan Pro.
Uses llama-3.1-8b-instant (131,072 TPM free tier) for speed.
Generates full plan in ONE API call β€” no chunking needed.
Typical time: 10-20 seconds for a 20-day plan.
"""
import os, time, json, re
# ══════════════════════════════════════════════════════════════════════════════
# Groq API call
# ══════════════════════════════════════════════════════════════════════════════
def query_model(prompt, max_tokens=8000, model="llama-3.1-8b-instant"):
"""Single Groq call. Uses 8b-instant by default (131k TPM β€” very fast)."""
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
if not GROQ_API_KEY:
raise ValueError(
"GROQ_API_KEY not set.\n"
"1. Go to https://console.groq.com\n"
"2. Create API Key (gsk_...)\n"
"3. HuggingFace > Settings > Secrets > GROQ_API_KEY"
)
try:
from groq import Groq
client = Groq(api_key=GROQ_API_KEY)
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": (
"You are a certified personal trainer and nutritionist. "
"Output ONLY valid JSON arrays. No text before or after. "
"Be specific with exercise names, quantities, and food items."
)},
{"role": "user", "content": prompt}
],
max_tokens=max_tokens,
temperature=0.7,
)
return resp.choices[0].message.content
except Exception as e:
err = str(e)
if "401" in err or "invalid_api_key" in err.lower():
raise ValueError("Invalid Groq API key. Check https://console.groq.com/keys") from None
if "429" in err or "rate_limit" in err.lower():
raise ValueError("Rate limit hit. Wait 30s and retry.") from None
if "model_not_found" in err.lower() or "model" in err.lower():
raise ValueError(f"Model error: {err}") from None
raise
# ══════════════════════════════════════════════════════════════════════════════
# JSON repair
# ══════════════════════════════════════════════════════════════════════════════
def _repair_json(text):
if not text:
return None
text = re.sub(r"```(?:json)?\s*", "", text)
text = re.sub(r"```", "", text).strip()
try:
return json.loads(text)
except Exception:
pass
# Try to extract array
m = re.search(r"(\[.*\])", text, re.DOTALL)
if m:
try:
return json.loads(m.group(1))
except Exception:
text = m.group(1)
# Repair truncated JSON
stack, in_str, escape, result = [], False, False, []
for ch in text:
if escape:
escape = False; result.append(ch); continue
if ch == "\\" and in_str:
escape = True; result.append(ch); continue
if ch == '"' and not escape:
in_str = not in_str; result.append(ch); continue
if in_str:
result.append(ch); continue
if ch in "{[":
stack.append(ch); result.append(ch)
elif ch in "}]":
if stack: stack.pop()
result.append(ch)
else:
result.append(ch)
if in_str: result.append('"')
joined = "".join(result).rstrip().rstrip(",")
for opener in reversed(stack):
joined += "}" if opener == "{" else "]"
try:
return json.loads(joined)
except Exception:
return None
# ══════════════════════════════════════════════════════════════════════════════
# Defaults
# ══════════════════════════════════════════════════════════════════════════════
_PRE_VIDEOS = [
"https://www.youtube.com/embed/R0mMyV5OtcM",
"https://www.youtube.com/embed/sTxC3J3gQEU",
"https://www.youtube.com/embed/CBzmVKDKOko",
]
_POST_VIDEOS = [
"https://www.youtube.com/embed/Qyd_guFDMh4",
"https://www.youtube.com/embed/L_xrDAtykMI",
"https://www.youtube.com/embed/v7AYKMP6rOE",
]
def _default_dietary(dtype):
if dtype == "veg":
return {"breakfast":"Oats with banana, chia seeds and honey (1 cup oats)",
"lunch": "Brown rice (1 cup) with mixed dal and seasonal vegetables",
"dinner": "Paneer curry (100g) with 2 multigrain rotis and salad",
"snacks": "Handful mixed nuts, 1 fruit and green tea"}
return {"breakfast":"3 boiled eggs with 2 whole wheat toast and black coffee",
"lunch": "Grilled chicken breast (150g) with brown rice and salad",
"dinner": "Baked fish (150g) with steamed broccoli and sweet potato",
"snacks": "Boiled eggs (2) and 20 almonds"}
def _default_pre(day_num):
v = _PRE_VIDEOS[day_num % len(_PRE_VIDEOS)]
return [
{"name":"Arm Circles", "duration":"30s","video_url":v},
{"name":"Leg Swings", "duration":"30s","video_url":v},
{"name":"Hip Circles", "duration":"30s","video_url":v},
{"name":"Jumping Jacks", "duration":"30s","video_url":v},
]
def _default_post(day_num):
v = _POST_VIDEOS[day_num % len(_POST_VIDEOS)]
return [
{"name":"Quad Stretch", "duration":"40s","video_url":v},
{"name":"Hamstring Stretch", "duration":"40s","video_url":v},
{"name":"Child's Pose", "duration":"45s","video_url":v},
{"name":"Chest Stretch", "duration":"30s","video_url":v},
]
_FALLBACK_WORKOUTS = [
[{"name":"Push-ups","sets":3,"reps":"12","rest":"60s","timer":60,"notes":"Elbows at 45 deg"},
{"name":"Tricep Dips","sets":3,"reps":"10","rest":"60s","timer":60,"notes":"Elbows close"},
{"name":"Shoulder Taps","sets":3,"reps":"20","rest":"45s","timer":45,"notes":"Hips stable"},
{"name":"Pike Push-ups","sets":3,"reps":"8","rest":"60s","timer":60,"notes":"Hips high"},
{"name":"Plank","sets":3,"reps":"30s","rest":"45s","timer":30,"notes":"Straight line"}],
[{"name":"Squats","sets":4,"reps":"15","rest":"60s","timer":60,"notes":"Knees over toes"},
{"name":"Reverse Lunges","sets":3,"reps":"12","rest":"60s","timer":60,"notes":"90 degree knee"},
{"name":"Glute Bridges","sets":3,"reps":"20","rest":"45s","timer":45,"notes":"Squeeze at top"},
{"name":"Jump Squats","sets":3,"reps":"12","rest":"75s","timer":60,"notes":"Soft landing"},
{"name":"Calf Raises","sets":3,"reps":"25","rest":"30s","timer":30,"notes":"Full ROM"}],
[{"name":"Burpees","sets":3,"reps":"10","rest":"75s","timer":75,"notes":"Controlled drop"},
{"name":"High Knees","sets":3,"reps":"30s","rest":"45s","timer":30,"notes":"Drive knees high"},
{"name":"Mountain Climbers","sets":3,"reps":"30","rest":"45s","timer":45,"notes":"Fast alt"},
{"name":"Box Jumps","sets":3,"reps":"8","rest":"75s","timer":60,"notes":"Soft landing"},
{"name":"Jumping Jacks","sets":3,"reps":"40","rest":"30s","timer":30,"notes":"Full ext"}],
[{"name":"Bicycle Crunches","sets":3,"reps":"20","rest":"45s","timer":45,"notes":"Exhale twist"},
{"name":"Leg Raises","sets":3,"reps":"15","rest":"45s","timer":45,"notes":"Lower back flat"},
{"name":"Russian Twists","sets":3,"reps":"20","rest":"45s","timer":45,"notes":"Feet off floor"},
{"name":"Plank","sets":3,"reps":"45s","rest":"45s","timer":45,"notes":"Hollow body"},
{"name":"Dead Bug","sets":3,"reps":"12","rest":"30s","timer":30,"notes":"Slow extend"}],
]
_MUSCLE_GROUPS = [
"Upper Body Push","Lower Body","Upper Body Pull",
"Core & Cardio","Full Body Compound","Shoulders & Arms",
"Lower Body Posterior",
]
def _fallback_day(dn, dtype):
is_rest = (dn % 7 == 0)
return {
"day": dn,
"is_rest_day": is_rest,
"muscle_group": "Rest & Recovery" if is_rest else _MUSCLE_GROUPS[(dn-1) % len(_MUSCLE_GROUPS)],
"workout": [] if is_rest else _FALLBACK_WORKOUTS[(dn-1) % len(_FALLBACK_WORKOUTS)],
"dietary": _default_dietary(dtype),
"pre_stretch": _default_pre(dn),
"post_stretch": _default_post(dn),
}
# ══════════════════════════════════════════════════════════════════════════════
# Validation
# ══════════════════════════════════════════════════════════════════════════════
def _validate_day(day, dn, dtype):
day["day"] = dn
day.setdefault("is_rest_day", False)
day.setdefault("muscle_group", _MUSCLE_GROUPS[(dn-1) % len(_MUSCLE_GROUPS)])
if not isinstance(day.get("workout"), list):
day["workout"] = []
for ex in day["workout"]:
if not isinstance(ex, dict): continue
ex.setdefault("name", "Exercise")
ex.setdefault("sets", 3)
ex.setdefault("reps", "12")
ex.setdefault("rest", "60s")
ex.setdefault("notes", "Maintain form")
try:
ex["timer"] = int(str(ex.get("timer", ex.get("rest","60").replace("s",""))).replace("s",""))
except Exception:
ex["timer"] = 60
if not isinstance(day.get("dietary"), dict) or not any(day.get("dietary",{}).values()):
day["dietary"] = _default_dietary(dtype)
for m in ["breakfast","lunch","dinner","snacks"]:
day["dietary"].setdefault(m, "Balanced nutritious meal")
if not isinstance(day.get("pre_stretch"), list) or not day.get("pre_stretch"):
day["pre_stretch"] = _default_pre(dn)
if not isinstance(day.get("post_stretch"), list) or not day.get("post_stretch"):
day["post_stretch"] = _default_post(dn)
# Add video URLs if missing
for s in day.get("pre_stretch", []):
s.setdefault("video_url", _PRE_VIDEOS[dn % len(_PRE_VIDEOS)])
for s in day.get("post_stretch", []):
s.setdefault("video_url", _POST_VIDEOS[dn % len(_POST_VIDEOS)])
return day
def _to_text(days):
lines = []
for d in days:
dn = d.get("day", 1)
mg = d.get("muscle_group", "Full Body")
if d.get("is_rest_day"):
lines.append(f"## Day {dn} - Rest Day\n\nRest and recover.\n")
continue
lines.append(f"## Day {dn} - {mg}")
for ex in d.get("workout", []):
lines.append(f"- {ex.get('name')} - {ex.get('sets')}x{ex.get('reps')} (rest {ex.get('rest')})")
diet = d.get("dietary", {})
for meal in ["breakfast","lunch","dinner","snacks"]:
if diet.get(meal):
lines.append(f" {meal.title()}: {diet[meal]}")
lines.append("")
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════════════════════
# MAIN β€” Single fast API call
# ══════════════════════════════════════════════════════════════════════════════
def query_model_chunked(name, gender, height, weight, goal, fitness_level,
equipment, days_per_week=5, months=1,
dietary_type="veg", progress_callback=None):
"""
Generate complete workout + diet plan in ONE fast API call.
Uses llama-3.1-8b-instant (131,072 TPM) β€” typically 10-20 seconds.
Falls back to llama-3.3-70b-versatile if 8b fails.
Returns: (full_plan_text, structured_days_list, bmi, bmi_cat)
"""
from prompt_builder import calculate_bmi, bmi_category, bmi_advice
bmi = calculate_bmi(weight, height)
bmi_cat = bmi_category(bmi)
total_days = days_per_week * 4 * months # no cap β€” max 84 (7d Γ— 4w Γ— 3mo)
eq_str = ", ".join(equipment) if equipment else "Bodyweight only"
diet_label = "Vegetarian" if dietary_type == "veg" else "Non-Vegetarian"
diet_rule = "NO meat/fish/eggs" if dietary_type == "veg" else "Include meat/fish/eggs every meal"
# Which days are rest days
rest_days = set()
for week in range(months * 4 + 1):
for offset in range(days_per_week + 1, 8):
d = week * 7 + offset
if 1 <= d <= total_days:
rest_days.add(d)
# Muscle group per day
muscles = [
"Upper Body Push","Lower Body","Upper Body Pull",
"Core & Cardio","Full Body Compound","Shoulders & Arms",
"Lower Body Posterior",
]
# Build compact day specs
day_specs = []
for d in range(1, total_days + 1):
if d in rest_days:
day_specs.append(f"{d}:REST")
else:
day_specs.append(f"{d}:{muscles[(d-1) % len(muscles)]}")
intensity = {
"Beginner": "2-3 sets, 90s rest, basic moves only",
"Intermediate": "3-4 sets, 60s rest, progressive overload",
"Advanced": "4-5 sets, 45s rest, supersets allowed",
}.get(fitness_level, "3 sets, 60s rest")
goal_tip = {
"Weight Loss": "high reps 15-20, short rest, cardio circuits",
"Build Muscle": "moderate reps 8-12, compound lifts, protein surplus",
"General Fitness": "balanced 10-15 reps, mix strength and cardio",
}.get(goal, "balanced training")
if progress_callback:
progress_callback(1, 1, 0, total_days, status=f"Generating your {total_days}-day plan...")
# ── COMPACT PROMPT ────────────────────────────────────────────────────────
prompt = f"""Generate a {total_days}-day personalised fitness plan as a JSON array.
User: {name},{gender},{height}cm,{weight}kg,BMI:{bmi:.1f}({bmi_cat}),Goal:{goal}({goal_tip}),Level:{fitness_level}({intensity}),Equipment:{eq_str},Diet:{diet_label}({diet_rule})
Days to generate: {", ".join(day_specs)}
REST days have is_rest_day=true and empty workout array but still have dietary plan.
Return a JSON array of {total_days} objects. Each object:
{{"day":N,"is_rest_day":false,"muscle_group":"...","workout":[{{"name":"ExerciseName","sets":3,"reps":"12","rest":"60s","timer":60,"notes":"form tip"}},...5 exercises],"dietary":{{"breakfast":"food+qty","lunch":"food+qty","dinner":"food+qty","snacks":"food"}},"pre_stretch":[{{"name":"stretch","duration":"30s","video_url":"https://www.youtube.com/embed/R0mMyV5OtcM"}}],"post_stretch":[{{"name":"stretch","duration":"40s","video_url":"https://www.youtube.com/embed/Qyd_guFDMh4"}}]}}
Rules:
- Use ONLY equipment: {eq_str}
- {diet_rule} β€” be specific with food names and quantities
- Vary exercises each day β€” no repeats across days
- Adjust sets/reps/rest for {fitness_level} level
- Output ONLY the JSON array, nothing else"""
# ── ATTEMPT with fast model first, fallback to powerful model ─────────────
# ── Chunk strategy: 20 days per call for reliability ─────────────────────
CHUNK_SIZE = 20
all_days = []
last_err = None
n_chunks = max(1, -(-total_days // CHUNK_SIZE)) # ceiling division
for chunk_idx in range(n_chunks):
chunk_start = chunk_idx * CHUNK_SIZE + 1
chunk_end = min(chunk_start + CHUNK_SIZE - 1, total_days)
chunk_days_needed = chunk_end - chunk_start + 1
# Update progress
if progress_callback:
pct = int((chunk_start - 1) / total_days * 90)
progress_callback(chunk_idx+1, n_chunks, chunk_start-1, total_days,
status=f"Generating days {chunk_start}–{chunk_end} of {total_days}...")
# Which days in this chunk are rest days
chunk_rest = {d for d in rest_days if chunk_start <= d <= chunk_end}
# Muscle group per day for this chunk
chunk_muscles = {
d: muscles[(d-1) % len(muscles)]
for d in range(chunk_start, chunk_end+1)
if d not in chunk_rest
}
# Build day specs for prompt
day_specs = []
for d in range(chunk_start, chunk_end+1):
if d in rest_days:
day_specs.append(f"{d}:REST")
else:
day_specs.append(f"{d}:{chunk_muscles.get(d,'Full Body')}")
# Build chunk prompt
avoid = ""
if all_days:
used = [ex.get("name","") for day in all_days[-5:] for ex in day.get("workout",[])]
used = [n for n in used if n][:15]
if used:
avoid = f"\nAvoid repeating these exercises from previous days: {', '.join(used)}"
chunk_prompt = f"""Generate days {chunk_start} to {chunk_end} of a {total_days}-day fitness plan. Output ONLY a JSON array of {chunk_days_needed} objects.
User: {name},{gender},{height}cm,{weight}kg,BMI:{bmi:.1f}({bmi_cat}),Goal:{goal}({goal_tip}),Level:{fitness_level}({intensity}),Equipment:{eq_str},Diet:{diet_label}({diet_rule})
Days: {", ".join(day_specs)}
REST days: is_rest_day=true, workout=[], include dietary plan.{avoid}
Each object: {{"day":N,"is_rest_day":false,"muscle_group":"...","workout":[{{"name":"Exercise","sets":3,"reps":"12","rest":"60s","timer":60,"notes":"form tip"}},...5 exercises],"dietary":{{"breakfast":"food+qty","lunch":"food+qty","dinner":"food+qty","snacks":"food"}},"pre_stretch":[{{"name":"stretch","duration":"30s","video_url":"https://www.youtube.com/embed/R0mMyV5OtcM"}}],"post_stretch":[{{"name":"stretch","duration":"40s","video_url":"https://www.youtube.com/embed/Qyd_guFDMh4"}}]}}
Rules: Only use equipment: {eq_str}. {diet_rule}. Vary exercises from previous days. {fitness_level} intensity. Output ONLY the JSON array."""
# Try models
chunk_parsed = None
models_to_try = [
("llama-3.1-8b-instant", 8000),
("llama-3.3-70b-versatile", 6000),
]
for model_name, max_tok in models_to_try:
for attempt in range(2):
try:
raw = query_model(chunk_prompt, max_tokens=max_tok, model=model_name)
chunk_parsed = _repair_json(raw)
if chunk_parsed and isinstance(chunk_parsed, list) and len(chunk_parsed) > 0:
break
if attempt == 0:
time.sleep(2)
except ValueError as e:
last_err = e
if "rate_limit" in str(e).lower():
if progress_callback:
progress_callback(chunk_idx+1, n_chunks, chunk_start-1, total_days,
status="Rate limit β€” waiting 30s...")
time.sleep(30)
if attempt == 0:
time.sleep(3); continue
break
except Exception as e:
last_err = e
if attempt == 0:
time.sleep(3); continue
break
if chunk_parsed and isinstance(chunk_parsed, list) and len(chunk_parsed) > 0:
break
# Build lookup by day number
parsed_map = {}
if chunk_parsed:
for p in chunk_parsed:
if isinstance(p, dict) and p.get("day"):
parsed_map[int(p["day"])] = p
# Build structured days for this chunk
for dn in range(chunk_start, chunk_end + 1):
raw_day = (parsed_map.get(dn) or
(chunk_parsed[dn - chunk_start] if chunk_parsed and (dn - chunk_start) < len(chunk_parsed) else None) or
_fallback_day(dn, dietary_type))
if not isinstance(raw_day, dict):
raw_day = _fallback_day(dn, dietary_type)
if dn in rest_days:
raw_day["is_rest_day"] = True
raw_day["workout"] = []
raw_day["muscle_group"] = "Rest & Recovery"
all_days.append(_validate_day(raw_day, dn, dietary_type))
# Small pause between chunks to avoid rate limits
if chunk_idx < n_chunks - 1:
time.sleep(1)
if progress_callback:
progress_callback(n_chunks, n_chunks, total_days, total_days, status="βœ… Plan Ready!")
return _to_text(all_days), all_days, bmi, bmi_cat