avfranco's picture
HF Space deploy snapshot (minimal allow-list)
557ee65
from typing import Dict, Any, Optional
import logging
import json
import time
from observability import logger as obs_logger
from observability import components as obs_components
from domain.training.agent_models import PlanOutput, WeeklySummary
from domain.runner.profile import RunnerProfile
from domain.runner.goal import Goal
from llm.base import LLMClient
from pathlib import Path
logger = logging.getLogger(__name__)
class PlanAgent:
"""
Generates a weekly plan using an LLM based on recent mileage and consistency via the provided LLMClient.
"""
def __init__(self, llm_client: LLMClient):
self.llm_client = llm_client
self.instruction = self._load_instruction("en")
def _load_instruction(self, language: str = "en") -> str:
try:
# Resolve path relative to this file: src/agents/plan/../../prompts/
base_path = Path(__file__).parent.parent.parent / "prompts"
filename = f"plan_{language}.txt"
file_path = base_path / filename
if not file_path.exists():
logger.warning(f"Prompt file not found: {file_path}. Falling back to English.")
file_path = base_path / "plan_en.txt"
if not file_path.exists():
logger.error("English prompt file missing!")
return "You are generating a weekly running plan. Output JSON."
return file_path.read_text(encoding="utf-8")
except Exception as e:
logger.error(f"Error loading prompt for language {language}: {e}")
return "You are generating a weekly running plan. Output JSON."
async def run(
self,
features: WeeklySummary,
language: str = "en",
profile: Optional[RunnerProfile] = None,
goal: Optional[Goal] = None,
) -> Dict[str, Any]:
with obs_logger.start_span("plan_agent.run", obs_components.AGENT):
start_time = time.time()
# Load language-specific instruction
self.instruction = self._load_instruction(language)
# Take most recent week from WeeklySummary
if features.weekly_km:
# take most recent week by sorting keys (YYYY-WW)
weeks = sorted(features.weekly_km.items(), reverse=True)
recent_km = weeks[0][1]
else:
recent_km = 0.0
cons = features.consistency_score
# Construct Prompt
prompt = self._construct_prompt(
recent_km, cons, language=language, profile=profile, goal=goal
)
try:
# Call LLM via Client
with obs_logger.start_span("plan_agent.llm", obs_components.AGENT):
plan_output = await self.llm_client.generate(
prompt, instruction=self.instruction, schema=None, name="plan_agent"
)
result = None
if isinstance(plan_output, PlanOutput):
result = {"plan": plan_output.plan}
elif isinstance(plan_output, dict):
if "plan" in plan_output:
if isinstance(plan_output["plan"], str):
result = {"plan": plan_output["plan"]}
else:
result = {"plan": json.dumps(plan_output)}
else:
result = {"plan": json.dumps({"plan": plan_output})}
elif isinstance(plan_output, str):
raw = plan_output.strip()
result = {"plan": raw}
else:
logger.error(f"Unexpected response type from LLM: {type(plan_output)}")
fail_msg = (
"Não foi possível gerar o plano neste momento."
if language == "pt-BR"
else "Could not generate plan at this time."
)
result = {"plan": fail_msg}
return result
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
obs_logger.log_event(
"error",
f"Failed to generate plan: {e}",
event="error",
component=obs_components.AGENT,
duration_ms=duration_ms,
)
logger.error(f"Failed to generate plan with LLM: {e}", exc_info=True)
fail_msg = (
"Não foi possível gerar o plano neste momento devido a um erro."
if language == "pt-BR"
else "Could not generate plan at this time due to an error."
)
return {"plan": fail_msg}
def _construct_prompt(
self,
recent_km: float,
consistency: int,
language: str = "en",
profile: Optional[RunnerProfile] = None,
goal: Optional[Goal] = None,
) -> str:
is_pt = language == "pt-BR"
profile_context = ""
if profile:
profile_context = "\n**Runner Profile Context:**\n"
if profile.runner_display_name:
profile_context += f"- Display Name: {profile.runner_display_name}\n"
if profile.age:
profile_context += f"- Age: {profile.age}\n"
if profile.experience_level:
profile_context += f"- Experience Level: {profile.experience_level}\n"
if profile.baseline_weekly_km:
profile_context += f"- Baseline Weekly KM: {profile.baseline_weekly_km}\n"
if profile.injury_history_notes:
profile_context += f"- Injury Notes: {profile.injury_history_notes}\n"
if goal:
goal_type_label = goal.type.replace("_", " ").title()
date_str = goal.target_date.strftime("%Y-%m-%d") if goal.target_date else "N/A"
profile_context += f"\n**Current Active Goal:**\n"
profile_context += f"- Type: {goal_type_label}\n"
profile_context += f"- Target: {goal.target_value} {goal.unit}\n"
profile_context += f"- Target Date: {date_str}\n"
if is_pt:
return f"""
**Perfil do Corredor:**
{profile_context}
- Quilometragem Semanal Recente: {recent_km:.1f} km
- Score de Consistência: {consistency}/100
"""
return f"""
**Runner Profile:**
{profile_context}
- Recent Weekly Mileage: {recent_km:.1f} km
- Consistency Score: {consistency}/100
"""