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