Lucas
Handle negated pain mentions in injury keyword detection
781cd97
Raw
History Blame Contribute Delete
26.1 kB
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": "<the user\'s check-in text>",\n'
' "time_available_minutes": <positive integer or null>,\n'
' "energy_level": "low" | "medium" | "high" | null,\n'
' "sleep_quality": "poor" | "okay" | "good" | null,\n'
' "sleep_hours": <number or null>,\n'
' "soreness": "<text, empty string if none>",\n'
' "pain_or_injury": "yes" | "no" | "unsure",\n'
' "pain_issues": [{"affected_muscle": <muscle value or null>, '
'"severity": "mild" | "moderate" | "severe" | "unsure", '
'"notes": "<non-empty text>"}],\n'
' "mood_stress": "stressed" | "neutral" | "ready" | null,\n'
' "notes": "<text, empty string if none>"\n'
" },\n"
' "missing_fields": [<names of useful fields the check-in did not '
"provide, [] when nothing useful is missing>],\n"
' "follow_up_items": [{"field": "<follow-up field value>", '
'"question": "<non-empty text>", "reason": "<non-empty text>"}],\n'
' "context_signals": [{"label": "<context signal label>", '
'"evidence": "<non-empty text>", "follow_up_question": "<text>"}],\n'
' "notes": "<text, empty string if none>"\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