runner-ai-intelligence / src /services /structure_service.py
avfranco's picture
HF Space deploy snapshot (minimal allow-list)
557ee65
from datetime import date, timedelta
from typing import List, Dict, Any, Optional, Union
import uuid
import config
from domain.training.planned_session import PlannedSession
from domain.training.run import Run
from persistence.repositories.planned_session_repository import PlannedSessionRepository
from persistence.repositories.goal_repo import SqlGoalRepository
from persistence.repositories.runner_repo import RunnerRepository
from engines import structure_engine
from observability import logger as obs_logger
from observability import components as obs_components
class StructureService:
"""
Orchestrates weekly structure integrity tracking.
Gated by storage capability.
"""
def __init__(
self,
planned_repo: PlannedSessionRepository,
goal_repo: SqlGoalRepository,
runner_repo: RunnerRepository,
):
self.planned_repo = planned_repo
self.goal_repo = goal_repo
self.runner_repo = runner_repo
def ensure_week_initialized(
self, runner_id: uuid.UUID, week_start: date
) -> List[PlannedSession]:
"""
Generates and persists a template if no sessions exist for the week.
"""
with obs_logger.start_span("structure_service.ensure_week_initialized", component=obs_components.SERVICE):
sessions = self.planned_repo.get_sessions_for_week(runner_id, week_start)
if not sessions:
obs_logger.log_event(
"info",
"No sessions found for week, generating template",
event="week_template_needed",
)
# Fetch context for template generation
goal = self.goal_repo.get_active_goal(runner_id)
profile = self.runner_repo.get_runner_profile(runner_id)
sessions = structure_engine.generate_week_template(
runner_id=runner_id, week_start=week_start, goal=goal, profile=profile
)
self.planned_repo.create_sessions_bulk(sessions)
obs_logger.log_event(
"info",
"Week template generated and persisted",
event="week_template_generated",
fields={"runner_id": str(runner_id), "week_start": week_start.isoformat()},
)
return sessions
def process_run_for_structure(self, run: Run, runner_id: uuid.UUID) -> None:
"""
Matches a new run to planned sessions and updates status.
"""
with obs_logger.start_span("structure_service.process_run", component=obs_components.SERVICE):
if not run.start_time:
obs_logger.log_event(
"warning",
"Skipping structure processing: run missing start_time",
fields={"run_id": str(run.id)},
)
return
# Determine week_start (Monday)
run_date = run.start_time.date()
week_start = run_date - timedelta(days=run_date.weekday())
sessions = self.ensure_week_initialized(runner_id, week_start)
match_id = structure_engine.match_run_to_session(run, sessions)
if match_id:
self.planned_repo.mark_completed(match_id, run.id)
obs_logger.log_event(
"info",
"Run matched to planned session",
event="session_matched",
fields={"run_id": str(run.id), "session_id": str(match_id)},
)
def compute_structure_status(
self, runner_id: uuid.UUID, week_start: date, weekly_volume: float, goal_volume: float
) -> Dict[str, Any]:
"""
Computes structure DTO for UI/Snapshot.
Does not persist.
"""
# Always return a DTO, even if storage is disabled (will be empty)
sessions = self.planned_repo.get_sessions_for_week(runner_id, week_start)
if not sessions:
return {
"weekday_completed": 0,
"weekday_total": 0,
"long_run_completed": False,
"classification": "reset_week",
"km_remaining": max(0.0, goal_volume - weekly_volume),
}
weekday_sessions = [s for s in sessions if s.session_type == "weekday"]
long_run_session = next((s for s in sessions if s.session_type == "long_run"), None)
weekday_completed = sum(1 for s in weekday_sessions if s.completed_run_id)
long_run_completed = (
long_run_session.completed_run_id is not None if long_run_session else False
)
with obs_logger.start_span("structure_engine.classify_week", component=obs_components.DOMAIN):
classification = structure_engine.classify_week(sessions, weekly_volume, goal_volume)
obs_logger.log_event(
"info",
"Week structure classified",
event="week_classified",
fields={
"runner_id": str(runner_id),
"week_start": week_start.isoformat(),
"classification": classification,
},
)
return {
"weekday_completed": weekday_completed,
"weekday_total": len(weekday_sessions),
"long_run_completed": long_run_completed,
"classification": classification,
"km_remaining": max(0.0, goal_volume - weekly_volume),
}