From e11953736dce863c0b3cfa8fe46ed66cbe2a722a Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 23 Mar 2026 10:53:13 +0100 Subject: [PATCH] feat: Training Type Profiles Phase 1.2 - Auto-evaluation (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatic evaluation on activity INSERT/UPDATE: - create_activity(): Evaluate after manual creation - update_activity(): Re-evaluate after manual update - import_activity_csv(): Evaluate after CSV import (INSERT + UPDATE) - bulk_categorize_activities(): Evaluate after bulk training type assignment All evaluation calls wrapped in try/except to prevent activity operations from failing if evaluation encounters an error. Only activities with training_type_id assigned are evaluated. Phase 1.2 complete ✅ ## Next Steps (Phase 2): Admin-UI for training type profile configuration Co-Authored-By: Claude Opus 4.6 --- backend/routers/activity.py | 122 +++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index a37b0bb..b718134 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -16,6 +16,7 @@ from auth import require_auth, check_feature_access, increment_feature_usage from models import ActivityEntry from routers.profiles import get_pid from feature_logger import log_feature_usage +from evaluation_helper import evaluate_and_save_activity router = APIRouter(prefix="/api/activity", tags=["activity"]) logger = logging.getLogger(__name__) @@ -64,6 +65,26 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'], d['rpe'],d['source'],d['notes'])) + # Phase 1.2: Auto-evaluation after INSERT + # Load the activity data to evaluate + cur.execute(""" + SELECT id, profile_id, date, training_type_id, duration_min, + hr_avg, hr_max, distance_km, kcal_active, kcal_resting, + rpe, pace_min_per_km, cadence, elevation_gain + FROM activity_log + WHERE id = %s + """, (eid,)) + activity_row = cur.fetchone() + if activity_row: + activity_dict = dict(activity_row) + training_type_id = activity_dict.get("training_type_id") + if training_type_id: + try: + evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid) + logger.info(f"[AUTO-EVAL] Evaluated activity {eid} on INSERT") + except Exception as eval_error: + logger.error(f"[AUTO-EVAL] Failed to evaluate activity {eid}: {eval_error}") + # Phase 2: Increment usage counter (always for new entries) increment_feature_usage(pid, 'activity_entries') @@ -79,6 +100,27 @@ def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Head cur = get_cursor(conn) cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", list(d.values())+[eid,pid]) + + # Phase 1.2: Auto-evaluation after UPDATE + # Load the updated activity data to evaluate + cur.execute(""" + SELECT id, profile_id, date, training_type_id, duration_min, + hr_avg, hr_max, distance_km, kcal_active, kcal_resting, + rpe, pace_min_per_km, cadence, elevation_gain + FROM activity_log + WHERE id = %s + """, (eid,)) + activity_row = cur.fetchone() + if activity_row: + activity_dict = dict(activity_row) + training_type_id = activity_dict.get("training_type_id") + if training_type_id: + try: + evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid) + logger.info(f"[AUTO-EVAL] Re-evaluated activity {eid} on UPDATE") + except Exception as eval_error: + logger.error(f"[AUTO-EVAL] Failed to re-evaluate activity {eid}: {eval_error}") + return {"id":eid} @@ -214,6 +256,30 @@ def bulk_categorize_activities( """, (training_type_id, training_category, training_subcategory, pid, activity_type)) updated_count = cur.rowcount + # Phase 1.2: Auto-evaluation after bulk categorization + # Load all activities that were just updated and evaluate them + cur.execute(""" + SELECT id, profile_id, date, training_type_id, duration_min, + hr_avg, hr_max, distance_km, kcal_active, kcal_resting, + rpe, pace_min_per_km, cadence, elevation_gain + FROM activity_log + WHERE profile_id = %s + AND activity_type = %s + AND training_type_id = %s + """, (pid, activity_type, training_type_id)) + + activities_to_evaluate = cur.fetchall() + evaluated_count = 0 + for activity_row in activities_to_evaluate: + activity_dict = dict(activity_row) + try: + evaluate_and_save_activity(cur, activity_dict["id"], activity_dict, training_type_id, pid) + evaluated_count += 1 + except Exception as eval_error: + logger.warning(f"[AUTO-EVAL] Failed to evaluate bulk-categorized activity {activity_dict['id']}: {eval_error}") + + logger.info(f"[AUTO-EVAL] Evaluated {evaluated_count}/{updated_count} bulk-categorized activities") + # Save mapping for future imports (upsert) cur.execute(""" INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source, updated_at) @@ -275,6 +341,7 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional if existing: # Update existing entry (e.g., to add training type mapping) + existing_id = existing['id'] cur.execute(""" UPDATE activity_log SET end_time = %s, @@ -297,22 +364,73 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional tf(row.get('Max. Herzfrequenz (count/min)','')), tf(row.get('Distanz (km)','')), training_type_id, training_category, training_subcategory, - existing['id'] + existing_id )) skipped += 1 # Count as skipped (not newly inserted) + + # Phase 1.2: Auto-evaluation after CSV import UPDATE + if training_type_id: + try: + # Build activity dict for evaluation + activity_dict = { + "id": existing_id, + "profile_id": pid, + "date": date, + "training_type_id": training_type_id, + "duration_min": duration_min, + "hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')), + "hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')), + "distance_km": tf(row.get('Distanz (km)','')), + "kcal_active": kj(row.get('Aktive Energie (kJ)','')), + "kcal_resting": kj(row.get('Ruheeinträge (kJ)','')), + "rpe": None, + "pace_min_per_km": None, + "cadence": None, + "elevation_gain": None + } + evaluate_and_save_activity(cur, existing_id, activity_dict, training_type_id, pid) + logger.debug(f"[AUTO-EVAL] Re-evaluated updated activity {existing_id}") + except Exception as eval_error: + logger.warning(f"[AUTO-EVAL] Failed to re-evaluate updated activity {existing_id}: {eval_error}") else: # Insert new entry + new_id = str(uuid.uuid4()) cur.execute("""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""", - (str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min, + (new_id,pid,date,start,row.get('End',''),wtype,duration_min, kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')), tf(row.get('Durchschn. Herzfrequenz (count/min)','')), tf(row.get('Max. Herzfrequenz (count/min)','')), tf(row.get('Distanz (km)','')), training_type_id,training_category,training_subcategory)) inserted+=1 + + # Phase 1.2: Auto-evaluation after CSV import INSERT + if training_type_id: + try: + # Build activity dict for evaluation + activity_dict = { + "id": new_id, + "profile_id": pid, + "date": date, + "training_type_id": training_type_id, + "duration_min": duration_min, + "hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')), + "hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')), + "distance_km": tf(row.get('Distanz (km)','')), + "kcal_active": kj(row.get('Aktive Energie (kJ)','')), + "kcal_resting": kj(row.get('Ruheeinträge (kJ)','')), + "rpe": None, + "pace_min_per_km": None, + "cadence": None, + "elevation_gain": None + } + evaluate_and_save_activity(cur, new_id, activity_dict, training_type_id, pid) + logger.debug(f"[AUTO-EVAL] Evaluated imported activity {new_id}") + except Exception as eval_error: + logger.warning(f"[AUTO-EVAL] Failed to evaluate imported activity {new_id}: {eval_error}") except Exception as e: logger.warning(f"Import row failed: {e}") skipped+=1