import json import logging import os import re from typing import get_args from pydantic import ValidationError from training_coach.models import ( CheckIn, ContextSignal, ContextSignalLabel, FollowUpField, FollowUpQuestion, Muscle, PainIssue, PainSeverity, ParsedCheckIn, ) PARSER_MODEL = "Qwen/Qwen2.5-1.5B-Instruct" PARSER_FALLBACK_MODEL = "Qwen/Qwen3-4B" PARSER_LOG_MESSAGES_ENV_VAR = "PARSER_LOG_MESSAGES" PARSER_LOG_RESPONSES_ENV_VAR = "PARSER_LOG_RESPONSES" PARSER_LOG_PARSED_JSON_ENV_VAR = "PARSER_LOG_PARSED_JSON" logger = logging.getLogger(__name__) MUSCLE_TEXT_MAP = { "tricep": Muscle.TRICEPS_BRACHII, "triceps": Muscle.TRICEPS_BRACHII, "hamstring": Muscle.HAMSTRINGS, "hamstrings": Muscle.HAMSTRINGS, "calf": Muscle.GASTROCNEMIUS, "calves": Muscle.GASTROCNEMIUS, "bicep": Muscle.BICEPS_BRACHII, "biceps": Muscle.BICEPS_BRACHII, "glute": Muscle.GLUTEUS_MAXIMUS, "glutes": Muscle.GLUTEUS_MAXIMUS, "front delt": Muscle.FRONT_DELTOID, "front shoulder": Muscle.FRONT_DELTOID, } SLEEP_TRIGGER_KEYWORDS = ("sleep", "slept", "rest", "night", "nap") BODY_AREA_TRIGGER_KEYWORDS = ( "arm", "elbow", "shoulder", "back", "knee", "leg", "hip", "ankle", "wrist", "neck", "tricep", "triceps", "hamstring", "hamstrings", "calf", "calves", "bicep", "biceps", "glute", "glutes", ) ACTIVITY_TRIGGER_KEYWORDS = ( "run", "ran", "10k", "5k", "cardio", "bike", "cycling", "swim", "hike", "soccer", "football", "basketball", "tennis", "sport", ) INJURY_KEYWORDS = ( "ache", "aches", "hurt", "hurts", "injured", "injury", "pain", "painful", "pulled", "strain", "strained", "tear", "tearing", "tore", "torn", ) PARSER_SYSTEM_PROMPT = """You extract strength-training check-ins into strict JSON. Return JSON only. Do not include markdown. Return exactly one object matching the expected response JSON schema. Use the exact keys and enum values from the schema. Do not rename fields or use synonyms. Prefer null over guessing. Prefer a follow-up question over inventing a value. Rules: - Extract only what the user said or what is a safe language inference. - Do not invent numbers. If sleep was "terrible" but hours are missing, set sleep_quality to "poor" and sleep_hours to null. - Never infer sleep_hours from vague sleep quality. "Slept well" means sleep_quality "good" and sleep_hours null unless the user states hours. - Use sleep_quality, not sleep or rest. Allowed values: "poor", "okay", "good", or null. - Use energy_level, not energy. Allowed values: "low", "medium", "high", or null. - Use mood_stress, not mood. Allowed values: "stressed", "neutral", "ready", or null. - Use pain_or_injury. Allowed values: "yes", "no", "unsure". - If the user says "no pain", set pain_or_injury to "no". - Use pain_issues for specific painful/problem areas. Each issue has one affected_muscle, severity, and notes. - Pain severity values are "mild", "moderate", "severe", or "unsure". - Use one exact Muscle enum value for affected_muscle. If the area is unclear, set affected_muscle to null and ask a follow-up. - Do not put side or location words in affected_muscle. Put side/location words in notes. - Map tricep/triceps to "triceps_brachii"; hamstring/hamstrings to "hamstrings"; calf/calves to "gastrocnemius"; front delt/front shoulder to "front_deltoid". - Map bicep/biceps to "biceps_brachii"; back tightness to "spinal_erectors" only if low/lower back is stated; shoulder without front/side/rear is unclear and should use null. - Every pain_issues item must have non-empty notes. - Use missing_fields and follow_up_items when useful details are absent. - follow_up_items is the conversational follow-up plan. Each item has field, question, and reason. - follow_up_items field values are fixed. Use only: "time_available_minutes", "energy_level", "sleep_quality", "sleep_hours", "soreness", "pain_or_injury", "pain_issues", "mood_stress", "context_signals", "readiness", "other". - Questions must be specific, short, and grounded in the user's exact check-in. - Ask only for missing or unclear details needed before planning today's session. Do not ask for details the user already provided. - Do not ask post-workout questions. - Do not mark soreness as missing unless the user mentions pain, injury, tightness, soreness, or an unclear body-area issue. - If sleep_quality is "poor" and sleep_hours is null, include "sleep_hours" in missing_fields and add one follow_up_items entry asking how many hours the user slept. - Do not include sleep_hours in missing_fields when sleep_quality is "good" or "okay" unless the user asks to track exact hours. - Use context_signals for adjacent factors that may affect training, such as a recent 10k run, travel, illness, alcohol, unusually hard work, or notable stress. - Context signal label values are fixed. Use only: "recent_endurance_run", "travel", "illness", "alcohol", "unusual_stress", "unusually_hard_work", "other". - Use "recent_endurance_run" for running/cardio/endurance activity yesterday or recently, such as "ran a 10k". - Every context signal should include a non-empty follow_up_question unless the check-in already answers the concern. - Do not create a context signal or activity/readiness follow-up unless the user explicitly mentions an activity, sport, illness, travel, alcohol, hard work, or notable stress. - Context signals are not training decisions. Do not tell the engine to skip, cut, add, or change exercises. - If unsure, use null, "unsure", notes, missing_fields, or follow_up_items. """ def expected_response_format() -> dict: return ParsedCheckIn.model_json_schema() def compact_response_shape() -> str: muscle_values = ", ".join(f'"{muscle.value}"' for muscle in Muscle) return ( "{\n" ' "check_in": {\n' ' "raw_text": "",\n' ' "time_available_minutes": ,\n' ' "energy_level": "low" | "medium" | "high" | null,\n' ' "sleep_quality": "poor" | "okay" | "good" | null,\n' ' "sleep_hours": ,\n' ' "soreness": "",\n' ' "pain_or_injury": "yes" | "no" | "unsure",\n' ' "pain_issues": [{"affected_muscle": , ' '"severity": "mild" | "moderate" | "severe" | "unsure", ' '"notes": ""}],\n' ' "mood_stress": "stressed" | "neutral" | "ready" | null,\n' ' "notes": ""\n' " },\n" ' "missing_fields": [],\n" ' "follow_up_items": [{"field": "", ' '"question": "", "reason": ""}],\n' ' "context_signals": [{"label": "", ' '"evidence": "", "follow_up_question": ""}],\n' ' "notes": ""\n' "}\n\n" f"Allowed affected_muscle values: {muscle_values}.\n" "Use [] for lists with no entries. Do not add extra keys.\n" "missing_fields, follow_up_items, context_signals, and the final notes " "are top-level keys, never keys inside check_in.\n" "Include at most 3 follow_up_items, only for details that block " "planning today's session.\n" "Return compact single-line JSON without indentation." ) def parser_trace_enabled(env_var: str) -> bool: return os.getenv(env_var, "").strip().lower() in {"1", "true", "yes", "on"} def log_parser_messages( *, backend: str, model_name: str, messages: list[dict[str, str]], ) -> None: if not parser_trace_enabled(PARSER_LOG_MESSAGES_ENV_VAR): return for index, message in enumerate(messages): logger.info( "event=parser_message backend=%s model=%s index=%s role=%s content=%r", backend, model_name, index, message.get("role"), message.get("content", ""), ) def log_parser_response_text( *, backend: str, model_name: str, response_text: str, ) -> None: if not parser_trace_enabled(PARSER_LOG_RESPONSES_ENV_VAR): return logger.info( "event=parser_response_text backend=%s model=%s response_text=%r", backend, model_name, response_text, ) def _infer_muscle_from_text(text: str) -> Muscle | None: normalized = text.lower() for phrase, muscle in MUSCLE_TEXT_MAP.items(): if phrase in normalized: return muscle return None def _contains_keyword(text: str, keywords: tuple[str, ...]) -> bool: normalized = text.lower() for keyword in keywords: if re.search(rf"\b{re.escape(keyword)}\b", normalized): return True return False def _append_unique(values: list[str], value: str) -> list[str]: if value in values: return values return [*values, value] def _is_sleep_follow_up(question: str) -> bool: normalized = question.lower() return "sleep" in normalized or "slept" in normalized def _without_sleep_follow_ups(questions: list[str]) -> list[str]: return [question for question in questions if not _is_sleep_follow_up(question)] def _is_activity_follow_up(question: str) -> bool: normalized = question.lower() return ( "run" in normalized or "activity" in normalized or "legs and energy" in normalized or "readiness" in normalized ) def _without_activity_follow_ups(questions: list[str]) -> list[str]: return [ question for question in questions if not _is_activity_follow_up(question) ] def _is_body_area_follow_up(question: str) -> bool: normalized = question.lower() return "body area" in normalized or "normal soreness" in normalized def _without_body_area_follow_ups(questions: list[str]) -> list[str]: return [ question for question in questions if not _is_body_area_follow_up(question) ] def _item_is_sleep_follow_up(item: FollowUpQuestion) -> bool: return item.field in ("sleep_hours", "sleep_quality") or _is_sleep_follow_up( item.question ) def _item_is_activity_follow_up(item: FollowUpQuestion) -> bool: return item.field in ("context_signals", "readiness") or _is_activity_follow_up( item.question ) def _item_is_body_area_follow_up(item: FollowUpQuestion) -> bool: return item.field in ("pain_or_injury", "pain_issues", "soreness") and ( _is_body_area_follow_up(item.question) or "pain" in item.question.lower() or "injury" in item.question.lower() or "soreness" in item.question.lower() ) def _dedupe_follow_up_items(items: list[FollowUpQuestion]) -> list[FollowUpQuestion]: seen: set[tuple[str, str]] = set() deduped = [] for item in items: key = (item.field, item.question.strip().lower()) if key in seen: continue seen.add(key) deduped.append(item) return deduped def _questions_from_items(items: list[FollowUpQuestion]) -> list[str]: questions = [] for item in items: questions = _append_unique(questions, item.question) return questions def _infer_sleep_quality_from_text(text: str): normalized = text.lower() if re.search(r"\b(terrible|bad|badly|awful|rough|poor)\b", normalized): return "poor" if re.search(r"\b(good|great|well|excellent)\b", normalized): return "good" if re.search(r"\b(ok|okay|fine|meh|decent)\b", normalized): return "okay" return None # Negation word followed within a few words by an injury keyword, e.g. # "no pain", "nothing hurts", "doesn't hurt", "never injured". _NEGATED_INJURY_PATTERN = re.compile( r"\b(?:no|not|nothing|never|without|zero|don'?t|doesn'?t|isn'?t|aren'?t)\b" r"(?:\W+\w+){0,3}?\W*" r"\b(?:ache|aches|aching|hurt|hurts|hurting|injur\w*|pain\w*|" r"sore\w*|strain\w*|pull\w*|tear\w*|tore|torn)\b", re.IGNORECASE, ) _PAIN_FREE_PATTERN = re.compile(r"\bpain[- ]?free\b", re.IGNORECASE) def _strip_negated_injury_mentions(text: str) -> str: text = _NEGATED_INJURY_PATTERN.sub(" ", text) return _PAIN_FREE_PATTERN.sub(" ", text) def _mentions_injury(text: str) -> bool: return _contains_keyword(_strip_negated_injury_mentions(text), INJURY_KEYWORDS) def _known_check_in_fields(check_in: CheckIn) -> set[str]: known = set() if check_in.time_available_minutes is not None: known.add("time_available_minutes") if check_in.energy_level is not None: known.add("energy_level") if check_in.sleep_quality is not None: known.add("sleep_quality") if check_in.sleep_hours is not None: known.add("sleep_hours") if check_in.mood_stress is not None: known.add("mood_stress") if check_in.soreness.strip(): known.add("soreness") if check_in.pain_or_injury != "unsure": known.add("pain_or_injury") return known # Scalar fields whose follow-ups are redundant once the check-in answers them. # Pain and soreness follow-ups are handled by the keyword rules below instead. _SCALAR_FOLLOW_UP_FIELDS = ( "time_available_minutes", "energy_level", "sleep_quality", "sleep_hours", "mood_stress", ) def apply_follow_up_triggers(parsed: ParsedCheckIn) -> ParsedCheckIn: raw_text = parsed.check_in.raw_text known_fields = _known_check_in_fields(parsed.check_in) missing_fields = [ field for field in parsed.missing_fields if field not in known_fields ] follow_up_items = _dedupe_follow_up_items(list(parsed.follow_up_items)) # Small models sometimes echo bare field names into follow_up_questions. field_names = set(get_args(FollowUpField)) legacy_questions = [ question for question in parsed.follow_up_questions if question.strip() and question.strip() not in field_names ] follow_up_items = [ item for item in follow_up_items if not (item.field in _SCALAR_FOLLOW_UP_FIELDS and item.field in known_fields) ] sleep_hours_known = parsed.check_in.sleep_hours is not None sleep_quality_known = parsed.check_in.sleep_quality is not None if sleep_hours_known and sleep_quality_known: legacy_questions = _without_sleep_follow_ups(legacy_questions) if not _contains_keyword(raw_text, ACTIVITY_TRIGGER_KEYWORDS): follow_up_items = [ item for item in follow_up_items if not _item_is_activity_follow_up(item) ] legacy_questions = _without_activity_follow_ups(legacy_questions) already_has_pain_issue = ( parsed.check_in.pain_or_injury == "yes" and bool(parsed.check_in.pain_issues) ) if already_has_pain_issue or _mentions_injury(raw_text): follow_up_items = [ item for item in follow_up_items if not _item_is_body_area_follow_up(item) ] legacy_questions = _without_body_area_follow_ups(legacy_questions) follow_up_questions = _questions_from_items(follow_up_items) for question in legacy_questions: follow_up_questions = _append_unique(follow_up_questions, question) return parsed.model_copy( update={ "missing_fields": missing_fields, "follow_up_items": follow_up_items, "follow_up_questions": follow_up_questions, } ) def normalize_parsed_check_in(parsed: ParsedCheckIn) -> ParsedCheckIn: normalized_issues = [] raw_text = parsed.check_in.raw_text inferred_raw_muscle = _infer_muscle_from_text(raw_text) for issue in parsed.check_in.pain_issues: if issue.affected_muscle is not None: normalized_issues.append(issue) continue inferred_muscle = _infer_muscle_from_text(issue.notes) if inferred_muscle is None: normalized_issues.append(issue) continue normalized_issues.append(issue.model_copy(update={"affected_muscle": inferred_muscle})) pain_or_injury = parsed.check_in.pain_or_injury if _mentions_injury(raw_text): pain_or_injury = "yes" if pain_or_injury == "yes" and not normalized_issues and inferred_raw_muscle is not None: normalized_issues.append( PainIssue( affected_muscle=inferred_raw_muscle, severity="unsure", notes=raw_text, ) ) sleep_quality = parsed.check_in.sleep_quality if sleep_quality is None: sleep_quality = _infer_sleep_quality_from_text(parsed.check_in.raw_text) context_signals = list(parsed.context_signals) if not _contains_keyword(raw_text, ACTIVITY_TRIGGER_KEYWORDS): context_signals = [ signal for signal in context_signals if signal.label != "recent_endurance_run" ] check_in = parsed.check_in.model_copy( update={ "pain_issues": normalized_issues, "pain_or_injury": pain_or_injury, "sleep_quality": sleep_quality, } ) normalized = parsed.model_copy( update={"check_in": check_in, "context_signals": context_signals} ) return apply_follow_up_triggers(normalized) def build_parser_messages(raw_text: str) -> list[dict[str, str]]: return [ {"role": "system", "content": PARSER_SYSTEM_PROMPT}, { "role": "user", "content": ( "Parse this check-in into one JSON object with exactly this " "shape:\n" f"{compact_response_shape()}\n\n" f"Check-in:\n{raw_text}\n" "/no_think" ), }, ] # Top-level list keys that small models sometimes misplace inside check_in. # check_in's own "notes" is legitimate, so notes is never lifted. _LIFTABLE_TOP_LEVEL_KEYS = ( "missing_fields", "follow_up_items", "follow_up_questions", "context_signals", ) def _lift_misplaced_top_level_keys(data): if not isinstance(data, dict) or not isinstance(data.get("check_in"), dict): return data check_in = data["check_in"] for key in _LIFTABLE_TOP_LEVEL_KEYS: if key not in check_in: continue value = check_in.pop(key) if key not in data: data[key] = value logger.info("event=parser_lifted_misplaced_key key=%s", key) return data _CHECK_IN_ENUM_FIELDS = { "energy_level": ({"low", "medium", "high"}, None), "sleep_quality": ({"poor", "okay", "good"}, None), "mood_stress": ({"stressed", "neutral", "ready"}, None), "pain_or_injury": ({"yes", "no", "unsure"}, "unsure"), } _FOLLOW_UP_FIELD_VALUES = set(get_args(FollowUpField)) _CONTEXT_LABEL_VALUES = set(get_args(ContextSignalLabel)) _PAIN_SEVERITY_VALUES = set(get_args(PainSeverity)) _MUSCLE_VALUES = {muscle.value for muscle in Muscle} def _prune_unknown_keys(obj: dict, model) -> None: allowed = set(model.model_fields) dropped = [key for key in obj if key not in allowed] for key in dropped: obj.pop(key) if dropped: logger.info( "event=parser_dropped_unknown_keys model=%s keys=%s", model.__name__, dropped, ) def _coerce_string(obj: dict, key: str, fallback: str = "") -> None: if key in obj and not isinstance(obj[key], str): obj[key] = fallback def _sanitize_parsed_payload(data): """Deterministically repair small-model output before strict validation. ParsedCheckIn forbids extra keys and constrains enums/ranges; the model intermittently invents keys or values, so anything unknown is dropped and anything out of range degrades to its unknown-equivalent instead of failing the whole parse. """ if not isinstance(data, dict): return data _prune_unknown_keys(data, ParsedCheckIn) _coerce_string(data, "notes") check_in = data.get("check_in") if isinstance(check_in, dict): _prune_unknown_keys(check_in, CheckIn) for key in ("raw_text", "soreness", "notes"): _coerce_string(check_in, key) minutes = check_in.get("time_available_minutes") if isinstance(minutes, (int, float)) and minutes <= 0: check_in["time_available_minutes"] = None hours = check_in.get("sleep_hours") if isinstance(hours, (int, float)) and not 0 <= hours <= 24: check_in["sleep_hours"] = None issues = check_in.get("pain_issues") if isinstance(issues, list): sanitized_issues = [] for issue in issues: if not isinstance(issue, dict): continue _prune_unknown_keys(issue, PainIssue) if issue.get("severity") not in _PAIN_SEVERITY_VALUES: issue["severity"] = "unsure" muscle = issue.get("affected_muscle") if muscle is not None and muscle not in _MUSCLE_VALUES: issue["affected_muscle"] = None notes = issue.get("notes") if not isinstance(notes, str) or not notes.strip(): issue["notes"] = "mentioned in check-in" sanitized_issues.append(issue) check_in["pain_issues"] = sanitized_issues elif issues is not None: check_in["pain_issues"] = [] items = data.get("follow_up_items") if isinstance(items, list): sanitized_items = [] for item in items: if not isinstance(item, dict): continue _prune_unknown_keys(item, FollowUpQuestion) question = item.get("question") if not isinstance(question, str) or not question.strip(): continue if item.get("field") not in _FOLLOW_UP_FIELD_VALUES: item["field"] = "other" reason = item.get("reason") if not isinstance(reason, str) or not reason.strip(): item["reason"] = "missing from check-in" sanitized_items.append(item) data["follow_up_items"] = sanitized_items elif items is not None: data["follow_up_items"] = [] signals = data.get("context_signals") if isinstance(signals, list): sanitized_signals = [] for signal in signals: if not isinstance(signal, dict): continue _prune_unknown_keys(signal, ContextSignal) evidence = signal.get("evidence") if not isinstance(evidence, str) or not evidence.strip(): continue if signal.get("label") not in _CONTEXT_LABEL_VALUES: signal["label"] = "other" if not isinstance(signal.get("follow_up_question"), str): signal["follow_up_question"] = "" sanitized_signals.append(signal) data["context_signals"] = sanitized_signals elif signals is not None: data["context_signals"] = [] for key in ("missing_fields", "follow_up_questions"): values = data.get(key) if isinstance(values, list): data[key] = [value for value in values if isinstance(value, str)] elif values is not None: data[key] = [] return data def _null_invalid_check_in_enums(data): if not isinstance(data, dict) or not isinstance(data.get("check_in"), dict): return data check_in = data["check_in"] for key, (allowed, fallback) in _CHECK_IN_ENUM_FIELDS.items(): value = check_in.get(key) if value is None or value in allowed: continue logger.info( "event=parser_invalid_enum_coerced field=%s value=%r fallback=%r", key, value, fallback, ) check_in[key] = fallback return data def parse_model_response(response_text: str) -> ParsedCheckIn: try: data = json.loads(response_text) except json.JSONDecodeError as error: logger.error( "event=parser_json_decode_failed response_chars=%s error_pos=%s " "error_line=%s error_column=%s", len(response_text), error.pos, error.lineno, error.colno, ) if parser_trace_enabled(PARSER_LOG_RESPONSES_ENV_VAR): logger.error( "event=parser_invalid_json_response response_text=%r", response_text, ) raise ValueError("Parser model returned invalid JSON") from error if parser_trace_enabled(PARSER_LOG_PARSED_JSON_ENV_VAR): logger.info("event=parser_json_loaded data=%r", data) data = _lift_misplaced_top_level_keys(data) data = _sanitize_parsed_payload(data) data = _null_invalid_check_in_enums(data) try: parsed = ParsedCheckIn.model_validate(data) except ValidationError as error: logger.error( "event=parser_validation_failed response_chars=%s error_count=%s", len(response_text), error.error_count(), ) if parser_trace_enabled(PARSER_LOG_PARSED_JSON_ENV_VAR): logger.error("event=parser_validation_errors errors=%r", error.errors()) raise ValueError("Parser model returned JSON that does not match ParsedCheckIn") from error if parser_trace_enabled(PARSER_LOG_PARSED_JSON_ENV_VAR): logger.info( "event=parser_validated_json parsed=%s", parsed.model_dump_json(), ) normalized = normalize_parsed_check_in(parsed) if parser_trace_enabled(PARSER_LOG_PARSED_JSON_ENV_VAR): logger.info( "event=parser_normalized_json parsed=%s", normalized.model_dump_json(), ) return normalized