import math from typing import Dict, Optional, Any from .runner_intelligence_snapshot import RunnerIntelligenceSnapshot from _app.presentation.ui_text import UI_TEXT from tools.helpers import decode_chart def _format_pace(seconds_per_km: float) -> str: if not seconds_per_km or seconds_per_km <= 0: return "N/A" minutes = int(seconds_per_km // 60) seconds = int(seconds_per_km % 60) return f"{minutes}:{seconds:02d} /km" def _build_performance_story(snapshot: RunnerIntelligenceSnapshot, trend_dict: Dict, language: str) -> str: t = UI_TEXT.get(language, UI_TEXT["en"]) runs_word = t.get("unit_runs") dist_word = t.get("lbl_total_of") story_tpl = t.get("home_story_template", "") story = story_tpl.format(count=snapshot.run_count, unit=runs_word, of=dist_word, dist=snapshot.weekly_distance_km) comparison = trend_dict.get("comparison_available", False) if trend_dict else False if comparison: dist_delta = trend_dict.get("distance_delta_pct", 0) pace_delta = trend_dict.get("pace_delta_s_per_km", 0) dist_trend = t.get("lbl_more" if dist_delta > 0 else "lbl_less", "more" if dist_delta > 0 else "less") pace_trend = t.get("lbl_faster" if pace_delta < 0 else "lbl_slower", "faster" if pace_delta < 0 else "slower") that_is = t.get("lbl_that_is") than_avg = t.get("lbl_than_avg") pace_was = t.get("lbl_pace_was") if abs(dist_delta) > 5: story += f" {that_is} **{abs(dist_delta):.1f}% {dist_trend}** {than_avg}" if abs(pace_delta) > 2: formatted_delta = _format_pace(abs(pace_delta)).replace(" /km", "") story += f" {pace_was} **{formatted_delta} {pace_trend}**." return story def _build_delta_summary(trend_dict: Dict, language: str) -> Dict[str, str]: if not trend_dict or not trend_dict.get("comparison_available"): return {} t = UI_TEXT.get(language, UI_TEXT["en"]) def format_val(metric_name, delta_val): if delta_val is None: return t.get("na", "N/A") is_positive_good = metric_name in ["distance", "frequency", "consistency"] icon = "⚪" if delta_val > 0: icon = "🟢" if is_positive_good else "🔴" elif delta_val < 0: icon = "🔴" if is_positive_good else "🟢" formatted = f"{delta_val:+.1f}" if metric_name == "distance": formatted = f"{delta_val:+.1f}%" elif metric_name == "pace": unit = t.get("unit_spkm", "s/km") formatted = f"{delta_val:+.1f} {unit}" elif metric_name == "frequency": unit = t.get("unit_runs", "runs") formatted = f"{int(delta_val):+d} {unit}" elif metric_name == "hr": unit = t.get("unit_bpm", "bpm") formatted = f"{delta_val:+.1f} {unit}" elif metric_name == "consistency": unit = t.get("unit_pts", "pts") formatted = f"{int(delta_val):+d} {unit}" return f"{icon} {formatted}" return { "distance": format_val("distance", trend_dict.get("distance_delta_pct")), "pace": format_val("pace", trend_dict.get("pace_delta_s_per_km")), "frequency": format_val("frequency", trend_dict.get("frequency_delta")), "hr": format_val("hr", trend_dict.get("hr_delta")), "consistency": format_val("consistency", trend_dict.get("consistency_delta")) } def _build_evidence_view(positioning_view: Dict, trend: Dict, language: str) -> str: t = UI_TEXT.get(language, UI_TEXT["en"]) evidence = positioning_view.get("evidence", "") if evidence and isinstance(evidence, dict): pace_delta = trend.get('pace_trend_s_per_km') if trend else 0 if pace_delta is None: pace_delta = 0 pace_word = t.get('positioning_evidence_pace_improved', 'Improved') if pace_delta <= 0 else t.get('positioning_evidence_pace_worsened', 'Worsened') return f""" 📈 {t.get('positioning_evidence_distance', 'Dist')}: {evidence.get('distance', 0)}
⚡ {pace_word}: {evidence.get("pace")}
🫀 {t.get('positioning_evidence_hr', 'HR')}: {evidence.get('hr', 0)}
🏃 {t.get('lbl_runs_count', 'Runs')}: {evidence.get("frequency")}
🎯 {t.get('positioning_evidence_consistency', 'Consistency')}: {evidence.get('consistency', 0)} """.strip() else: return f"{positioning_view.get('trajectory', '')}\n".strip() def _build_structure_view(structure_status: Dict, recommendation: Dict, language: str) -> str: if not structure_status: return "" t = UI_TEXT.get(language, UI_TEXT["en"]) wd_comp = structure_status.get("weekday_completed", 0) wd_total = structure_status.get("weekday_total", 0) lr_comp = "✅" if structure_status.get("long_run_completed") else "⏳" classif = structure_status.get("classification", "reset_week") classif_lbl = t.get(classif, classif) km_rem = structure_status.get("km_remaining", 0.0) km_rem_subtext = "" if km_rem > 0: subtext_tpl = t.get("lbl_km_remaining_subtext", "{val} km") km_rem_subtext = f'{subtext_tpl.format(val=f"{km_rem:.1f}")}' advice = recommendation.get('description', "") if recommendation else t.get("coaching_advice", "") advice_html = f'
{advice}
' if advice else "" return f"""
{t.get('lbl_weekday_runs', 'Weekday')}: {wd_comp} / {wd_total}
{t.get('lbl_long_run', 'Long Run')}: {lr_comp}
{t.get('lbl_structure_status', 'Status')}: {classif_lbl}
{t.get('lbl_km_remaining', 'Remaining') + ': ' if km_rem > 0 else ''} {km_rem_subtext}
{advice_html} """.strip() def _build_goal_status_text(active_goal: Dict, language: str) -> str: if not active_goal: return "" t = UI_TEXT.get(language, UI_TEXT["en"]) status_key = active_goal.get("status", "unknown") status_lbl = t.get(f"goal_status_{status_key}", status_key) tpl = t.get("goal_status_template", "Goal status: {val}") return tpl.format(val=status_lbl) def build_intelligence_snapshot(context) -> RunnerIntelligenceSnapshot: """ Builds a RunnerIntelligenceSnapshot from a PipelineContext. This is an aggregation layer only. It uses safe accessors (`getattr`) to extract already-computed values without introducing new business logic. """ summary = context.summary # Helper to safely extract depending on whether summary is a dict, WeeklySnapshot, or WeeklySummary def _extract_summary_val(dict_key, attr_names, default, transform=None): if not summary: return default val = default if isinstance(summary, dict): val = summary.get(dict_key, default) else: for attr in attr_names: if hasattr(summary, attr): val = getattr(summary, attr, default) break return transform(val) if transform and val != default else val # --- Extract signals from domain objects (projection layer) --- recommendation_obj = context.recommendation insights_obj = context.insights or {} training_state = None health_signal = None positioning_status = None positioning_change = None next_run = None training_focus = None training_type = None training_why = None performance_brief = None performance_focus = None if recommendation_obj: training_focus = getattr(recommendation_obj, "focus", None) training_type = getattr(recommendation_obj, "session_type", None) training_why = getattr(recommendation_obj, "description", None) if not next_run: next_run = getattr(context, "next_run", None) if not training_focus: training_focus = getattr(context, "training_focus", None) key_insight = None forward_focus = None goal_trajectory = None goal_progress_pct = None positioning_view = None active_goal = None goal_view = None if getattr(context, "positioning_view", None): positioning_view = context.positioning_view training_state = positioning_view.get("training_phase", None) health_signal = positioning_view.get("health_signal", None) positioning_status = positioning_view.get("state", None) positioning_change = positioning_view.get("change", None) forward_focus = positioning_view.get("forward_focus", None) key_insight = positioning_view.get("insight", None) goal_trajectory = positioning_view.get("goal_trajectory", None) goal_progress_pct = positioning_view.get("goal_progress_pct", None) if hasattr(context, "weekly_snapshot"): weekly_snapshot = context.weekly_snapshot performance_brief = getattr(weekly_snapshot, "performance_brief", None) performance_focus = getattr(weekly_snapshot, "performance_focus", None) else: performance_brief = getattr(context, "weekly_brief", None) performance_focus = getattr(context, "weekly_focus", None) # Fallbacks for scalar signals directly on context (useful for tests/minimal contexts) # This aligns with the "aggregation layer" philosophy of the builder. if not key_insight: key_insight = getattr(context, "key_insight", None) if not forward_focus: forward_focus = getattr(context, "forward_focus", None) if not training_state: training_state = getattr(context, "training_state", None) if not health_signal: health_signal = getattr(context, "health_signal", None) if not positioning_status: positioning_status = getattr(context, "positioning_status", None) if not positioning_change: positioning_change = getattr(context, "positioning_change", None) pos_view_safe = getattr(context, "positioning_view", None) or {} if not goal_trajectory: goal_trajectory = pos_view_safe.get("goal_trajectory") if isinstance(pos_view_safe, dict) else getattr(pos_view_safe, "goal_trajectory", None) if not goal_trajectory: goal_trajectory = getattr(context, "goal_trajectory", "NO_GOAL") goal_prog_safe = getattr(context, "goal_progress", None) or {} if goal_progress_pct is None: goal_progress_pct = getattr(context, "goal_progress_pct", None) if goal_progress_pct is None: goal_progress_pct = goal_prog_safe.get("progress_percentage", 0) if isinstance(goal_prog_safe, dict) else getattr(goal_prog_safe, "progress_percentage", 0) week_charts = getattr(context, "charts", {}) or getattr(context.weekly_snapshot, "charts", {}) snapshot = RunnerIntelligenceSnapshot( id=getattr(context.weekly_snapshot, "id", None), week_start=_extract_summary_val("week_start", ["week_start_date", "week_start"], None), training_state=training_state, health_signal=health_signal, positioning_status=positioning_status, positioning_change=positioning_change, goal_trajectory=goal_trajectory, goal_progress_pct=goal_progress_pct, next_run=next_run, training_focus=training_focus, training_type=training_type, training_why=training_why, key_insight=key_insight, forward_focus=forward_focus, performance_brief=performance_brief, performance_focus=performance_focus, weekly_distance_km=_extract_summary_val( "total_distance_m", ["total_distance_km", "total_distance_m"], 0.0, transform=lambda x: x / 1000.0 if not hasattr(summary, "total_distance_km") else x ), num_runs=_extract_summary_val("num_runs", ["run_count", "num_runs"], 0), run_count=_extract_summary_val("num_runs", ["run_count", "num_runs"], 0), consistency_score=_extract_summary_val("consistency_score", ["consistency_score"], 0), avg_pace=_extract_summary_val("avg_pace_s_per_km", ["avg_pace_sec_per_km", "avg_pace_s_per_km"], 0.0), avg_hr=_extract_summary_val("avg_hr_bpm", ["avg_hr", "avg_hr_bpm"], 0.0), structure_status=getattr(context.weekly_snapshot, "structure_status", {}) if context.weekly_snapshot else {}, # Detailed DTO components for UI transparency # Week specific trend trend=context.trends.model_dump() if getattr(context, "trends", None) and hasattr(context.trends, "model_dump") else (context.trends if getattr(context, "trends", None) else {}), # Week over Weeks trend weekly_trend=context.weekly_trend.model_dump() if getattr(context, "weekly_trend", None) and hasattr(context.weekly_trend, "model_dump") else (context.weekly_trend if getattr(context, "weekly_trend", None) else {}), positioning=context.positioning.model_dump() if getattr(context, "positioning", None) and hasattr(context.positioning, "model_dump") else (context.positioning if getattr(context, "positioning", None) else {}), positioning_view=context.positioning_view.model_dump() if getattr(context, "positioning_view", None) and hasattr(context.positioning_view, "model_dump") else (context.positioning_view if getattr(context, "positioning_view", None) else {}), goal_trajectory_data=context.goal_trajectory.model_dump() if getattr(context, "goal_trajectory", None) and hasattr(context.goal_trajectory, "model_dump") else (context.goal_trajectory if getattr(context, "goal_trajectory", None) else {}), insights=context.insights.model_dump() if getattr(context, "insights", None) and hasattr(context.insights, "model_dump") else (context.insights if getattr(context, "insights", None) else {}), plan=getattr(context, "plan", None), recommendation=context.recommendation.model_dump() if getattr(context, "recommendation", None) and hasattr(context.recommendation, "model_dump") else (context.recommendation if getattr(context, "recommendation", None) else {}), charts=decode_chart(week_charts), weekly_brief=performance_brief, weekly_focus=performance_focus, weekly_snapshot = context.weekly_snapshot if getattr(context, "weekly_snapshot", None) and hasattr(context.weekly_snapshot, "model_dump") else (context.weekly_snapshot if getattr(context, "weekly_snapshot", None) else {}), active_goal = context.active_goal.model_dump() if getattr(context, "active_goal", None) and hasattr(context.active_goal, "model_dump") else (context.active_goal if getattr(context, "active_goal", None) else {}), goal_view=context.goal_progress.model_dump() if getattr(context, "goal_progress", None) and hasattr(context.goal_progress, "model_dump") else (context.goal_progress if getattr(context, "goal_progress", None) else {}), ) language = getattr(context, "language", "en") snapshot.performance_story = _build_performance_story(snapshot, snapshot.weekly_trend, language) snapshot.delta_summary = _build_delta_summary(snapshot.weekly_trend, language) snapshot.evidence_view = _build_evidence_view(snapshot.positioning_view, snapshot.trend, language) snapshot.structure_view = _build_structure_view(snapshot.structure_status, snapshot.recommendation, language) snapshot.goal_status_text = _build_goal_status_text(snapshot.active_goal, language) return snapshot