| 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 |
|
|
|
|
| |
| |
| _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_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)) |
| |
| 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" |
| ), |
| }, |
| ] |
|
|
|
|
| |
| |
| _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 |
|
|