runner-ai-intelligence / src /application /positioning_service.py
avfranco's picture
HF Space deploy snapshot (minimal allow-list)
d64fd55
Raw History Blame Contribute Delete
7.46 kB
from typing import Optional, Dict, List, Literal
from pydantic import BaseModel, Field
from datetime import date
from domain.training.weekly_snapshot import WeeklySnapshot
from domain.training.weekly_trend import WeeklyTrend
from domain.runner_positioning import TrainingPhase
from _app.presentation.ui_text import get_text
class WeeklyPositioning(BaseModel):
"""
Application-layer model for Positioning Intelligence v1.
Narrative-focused assessment of the runner's current standing.
"""
status: Literal["CONSTRUCTIVE_ADAPTATION", "PRODUCTIVE_LOAD", "STRAIN", "PLATEAU", "BASELINE_BUILDING"]
signal_strength: float = 1.0 # 0.0 to 1.0
rationale: str
training_phase: TrainingPhase = TrainingPhase.BASE
@property
def status_value(self) -> str:
return self.status
class PositioningEngine:
"""
Engine to compute high-level positioning status from snapshots and trends.
"""
def detect_training_phase(self, snapshot: WeeklySnapshot, trend: WeeklyTrend) -> TrainingPhase:
"""
Infer training phase from workload and trend signals.
"""
distance_delta = trend.distance_delta_pct or 0
pace_delta = trend.pace_delta_s_per_km or 0
run_count = snapshot.run_count
# consistency = snapshot.consistency_score or 0
# Recovery phase (very low activity and sharp drop in load)
if run_count <= 1 and distance_delta < -80:
return TrainingPhase.RECOVERY
# Plateau phase (load dropped or adaptation stalled)
if distance_delta < -20:
return TrainingPhase.PLATEAU
# Build phase (clear volume increase)
if distance_delta > 5:
return TrainingPhase.BUILD
# Peak phase (performance improving without load reduction)
if pace_delta < -5 and distance_delta >= 0:
return TrainingPhase.PEAK
# Default: base training
return TrainingPhase.BASE
def compute(self, snapshot: WeeklySnapshot, trend: WeeklyTrend) -> WeeklyPositioning:
if not trend or not trend.comparison_available:
return WeeklyPositioning(
status="BASELINE_BUILDING",
rationale="positioning_rationale_building",
signal_strength=0.1,
training_phase=TrainingPhase.BASE
)
# 1. Logic to determine status
# This is a v1 heuristic mapping
status = "PLATEAU"
rationale_key = "positioning_rationale_plateau"
if trend.comparison_available:
# Constructive Adaptation: Increasing load with positive/stable consistency
if trend.distance_delta_pct > 5.0 and trend.consistency_delta >= -5.0:
status = "CONSTRUCTIVE_ADAPTATION"
rationale_key = "positioning_rationale_constructive_adaptation"
# Strain: High load increase or high HR increase
elif trend.distance_delta_pct > 20.0 or (trend.hr_delta and trend.hr_delta > 5.0):
status = "STRAIN"
rationale_key = "positioning_rationale_strain"
# Productive Load: Stable or slightly increasing load
elif trend.distance_delta_pct >= -5.0:
status = "PRODUCTIVE_LOAD"
rationale_key = "positioning_rationale_productive_load"
else:
status = "BASELINE_BUILDING"
rationale_key = "positioning_rationale_building"
training_phase = self.detect_training_phase(snapshot, trend)
return WeeklyPositioning(
status=status,
rationale=rationale_key, # Store key here
signal_strength=1.0 if trend.comparison_available else 0.5,
training_phase=training_phase
)
def build_positioning_view(
snapshot: WeeklySnapshot,
trend: WeeklyTrend,
positioning: WeeklyPositioning,
goal_progress: Optional[dict] = None,
language: str = "en"
) -> dict:
"""
Builds a UI-ready positioning view using BOTH absolute state (snapshot)
and relative changes (trend). This restores coaching-quality output.
"""
positioning = PositioningEngine().compute(snapshot, trend)
# --- Absolute context (snapshot) ---
total_distance = snapshot.total_distance_km or 0
run_count = snapshot.run_count or 0
avg_pace = snapshot.avg_pace_sec_per_km
avg_hr = snapshot.avg_hr
# --- Relative context (trend) ---
distance_delta = trend.distance_delta_pct or 0
pace_delta = trend.pace_delta_s_per_km or 0
hr_delta = trend.hr_delta or 0
freq_delta = int(trend.frequency_delta or 0)
consistency_delta = trend.consistency_delta or 0
# --- Headline (context-aware) ---
if run_count <= 1:
headline = get_text("positioning_headline_building", language)
elif total_distance < 5:
headline = f"Your training volume is currently low at {total_distance:.1f} km."
else:
status_lower = positioning.status.lower()
headline = get_text(f"positioning_headline_{status_lower}", language)
# --- State ---
status_icons = {
"CONSTRUCTIVE_ADAPTATION": "🟒",
"PRODUCTIVE_LOAD": "🟑",
"STRAIN": "πŸ”΄",
"PLATEAU": "βšͺ",
"BASELINE_BUILDING": "βšͺ",
}
status_icon = status_icons.get(positioning.status, "βšͺ")
status_name = get_text(f"positioning_status_{positioning.status.lower()}", language)
# --- Health ---
if hr_delta > 5:
health_signal = f"πŸ”΄ {get_text('health_strain', language)}"
else:
health_signal = f"🟒 {get_text('health_stable', language)}"
# --- Goal trajectory ---
if goal_progress:
if positioning.status == "BASELINE_BUILDING":
goal_trajectory = get_text("trajectory_establishing", language)
elif positioning.status == "CONSTRUCTIVE_ADAPTATION":
goal_trajectory = f"🎯 {get_text('trajectory_improving', language)}"
else:
goal_trajectory = f"🎯 {get_text('trajectory_maintaining', language)}"
else:
goal_trajectory = get_text("trajectory_no_goal", language)
# --- Forward focus ---
forward_focus = get_text(f"positioning_forward_focus_{positioning.status.lower()}", language)
# --- Narrative insight ---
insight = get_text(positioning.rationale, language)
# --- Evidence (enriched with absolute + relative) ---
evidence = {
"distance": f"{total_distance:.1f} km ({distance_delta:+.1f}%)",
"pace": f"{pace_delta:+.1f} sec/km" if pace_delta else None,
"hr": f"{hr_delta:+.1f} bpm" if hr_delta else None,
"frequency": f"{run_count} runs ({freq_delta:+d})",
"consistency": f"{int(consistency_delta):+d}",
}
# Remove empty evidence fields
evidence = {k: v for k, v in evidence.items() if v is not None}
return {
"headline": headline,
"state": f"{status_icon} {status_name}",
"health_signal": health_signal,
"goal_trajectory": goal_trajectory,
"training_phase": positioning.training_phase.value if hasattr(positioning.training_phase, "value") else positioning.training_phase,
"forward_focus": forward_focus,
"trajectory": get_text(f"positioning_trajectory_{positioning.status.lower()}", language),
"insight": insight,
"evidence": evidence if positioning.status != "BASELINE_BUILDING" else None,
}