Lucas commited on
Commit ·
06feb89
1
Parent(s): 774ed92
Improve parser runtime config and check-in state
Browse files- .env.example +20 -0
- DECISIONS.md +3 -3
- README.md +7 -1
- app.py +97 -6
- tests/test_app.py +38 -0
- tests/test_env.py +34 -0
- tests/test_parser_runtime.py +55 -4
- training_coach/engine.py +73 -0
- training_coach/env.py +19 -0
- training_coach/parser_runtime.py +52 -15
- training_coach/parser_service.py +24 -2
- training_coach/storage.py +94 -3
.env.example
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local development defaults.
|
| 2 |
+
HISTORY_STORE=local_json
|
| 3 |
+
LOCAL_HISTORY_PATH=data/completed_sessions.json
|
| 4 |
+
|
| 5 |
+
PARSER_BACKEND=ollama
|
| 6 |
+
OLLAMA_MODEL=qwen3:8b
|
| 7 |
+
|
| 8 |
+
# Hugging Face Space parser runtime.
|
| 9 |
+
# PARSER_BACKEND=transformers
|
| 10 |
+
# PARSER_MODEL_ID=Qwen/Qwen3-1.7B
|
| 11 |
+
# PARSER_MAX_NEW_TOKENS=384
|
| 12 |
+
# ZEROGPU_DURATION_SECONDS=120
|
| 13 |
+
|
| 14 |
+
# Optional Hugging Face Dataset storage.
|
| 15 |
+
# Keep real tokens in your local .env or Hugging Face Space secrets.
|
| 16 |
+
# HISTORY_STORE=hf_dataset
|
| 17 |
+
# HF_HISTORY_DATASET_REPO=your-username/training-coach-history
|
| 18 |
+
# HF_HISTORY_FILE=completed_sessions.json
|
| 19 |
+
# HF_HISTORY_PRIVATE=1
|
| 20 |
+
# HF_TOKEN=hf_...
|
DECISIONS.md
CHANGED
|
@@ -87,9 +87,9 @@ with a one-line note on why.
|
|
| 87 |
because it is much faster on the Mac than local Transformers CPU/disk-offload.
|
| 88 |
`qwen3:4b` is the current local parser candidate for tiny/local badge fit.
|
| 89 |
- **Space parser runtime:** Hugging Face Spaces use the Transformers parser backend,
|
| 90 |
-
configured with `PARSER_BACKEND=transformers`
|
| 91 |
-
Local development remains Ollama by default so the
|
| 92 |
-
cloud AI API.
|
| 93 |
- **MVP training template:** use the supplied fixed 4-day exercise template for the
|
| 94 |
first engine slice. Preserve exercise grouping, execution order, set counts, rep
|
| 95 |
targets, and notes. For MVP simplicity, all exercise rests are normalized to 1 minute.
|
|
|
|
| 87 |
because it is much faster on the Mac than local Transformers CPU/disk-offload.
|
| 88 |
`qwen3:4b` is the current local parser candidate for tiny/local badge fit.
|
| 89 |
- **Space parser runtime:** Hugging Face Spaces use the Transformers parser backend,
|
| 90 |
+
configured with `PARSER_BACKEND=transformers`, `PARSER_MODEL_ID=Qwen/Qwen3-1.7B`,
|
| 91 |
+
and `PARSER_MAX_NEW_TOKENS=384`. Local development remains Ollama by default so the
|
| 92 |
+
app still runs locally without a cloud AI API.
|
| 93 |
- **MVP training template:** use the supplied fixed 4-day exercise template for the
|
| 94 |
first engine slice. Preserve exercise grouping, execution order, set counts, rep
|
| 95 |
targets, and notes. For MVP simplicity, all exercise rests are normalized to 1 minute.
|
README.md
CHANGED
|
@@ -10,6 +10,8 @@ app_file: app.py
|
|
| 10 |
pinned: false
|
| 11 |
license: apache-2.0
|
| 12 |
short_description: Small Models Big Adventures Hackaton - Weightlifting Trainer
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
# Strength Training Coach
|
|
@@ -107,6 +109,9 @@ cloud model inference or training logic.
|
|
| 107 |
|
| 108 |
## Parser Runtime Modes
|
| 109 |
|
|
|
|
|
|
|
|
|
|
| 110 |
Local development defaults to Ollama:
|
| 111 |
|
| 112 |
```txt
|
|
@@ -118,7 +123,8 @@ Hugging Face Spaces should use the Transformers runtime:
|
|
| 118 |
|
| 119 |
```txt
|
| 120 |
PARSER_BACKEND=transformers
|
| 121 |
-
PARSER_MODEL_ID=Qwen/Qwen3-
|
|
|
|
| 122 |
ZEROGPU_DURATION_SECONDS=120
|
| 123 |
```
|
| 124 |
|
|
|
|
| 10 |
pinned: false
|
| 11 |
license: apache-2.0
|
| 12 |
short_description: Small Models Big Adventures Hackaton - Weightlifting Trainer
|
| 13 |
+
preload_from_hub:
|
| 14 |
+
- Qwen/Qwen3-1.7B
|
| 15 |
---
|
| 16 |
|
| 17 |
# Strength Training Coach
|
|
|
|
| 109 |
|
| 110 |
## Parser Runtime Modes
|
| 111 |
|
| 112 |
+
Copy `.env.example` to `.env` for local settings. `.env` is ignored by git and is
|
| 113 |
+
loaded automatically by `app.py` before storage or parser backends are configured.
|
| 114 |
+
|
| 115 |
Local development defaults to Ollama:
|
| 116 |
|
| 117 |
```txt
|
|
|
|
| 123 |
|
| 124 |
```txt
|
| 125 |
PARSER_BACKEND=transformers
|
| 126 |
+
PARSER_MODEL_ID=Qwen/Qwen3-1.7B
|
| 127 |
+
PARSER_MAX_NEW_TOKENS=384
|
| 128 |
ZEROGPU_DURATION_SECONDS=120
|
| 129 |
```
|
| 130 |
|
app.py
CHANGED
|
@@ -1,13 +1,22 @@
|
|
| 1 |
from datetime import date
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
|
|
|
|
| 5 |
from training_coach.engine import build_session_for_day, suggest_next_training_day
|
| 6 |
from training_coach.models import CheckIn, CompletedSession, CompletedSet, PainIssue
|
| 7 |
from training_coach.parser_service import parse_check_in_with_configured_backend
|
| 8 |
from training_coach.storage import create_history_store
|
| 9 |
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
history_store = create_history_store()
|
| 12 |
LOG_HEADERS = [
|
| 13 |
"exercise_id",
|
|
@@ -76,18 +85,47 @@ def _format_parser_panel(parsed):
|
|
| 76 |
return "\n\n".join(sections) or "No extra parser notes."
|
| 77 |
|
| 78 |
|
| 79 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
if not check_in.strip():
|
|
|
|
| 81 |
default_result = _default_parse_result("Write a check-in first.")
|
| 82 |
return default_result, default_result[-1]
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
try:
|
| 85 |
parsed = parse_check_in_with_configured_backend(check_in)
|
| 86 |
except Exception as error:
|
|
|
|
| 87 |
default_result = _default_parse_result(f"Parser failed: {error}")
|
| 88 |
return default_result, default_result[-1]
|
| 89 |
|
| 90 |
parsed_check_in = parsed.check_in
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
parse_result = (
|
| 92 |
_first_or_default(parsed_check_in.time_available_minutes, 60),
|
| 93 |
_first_or_default(parsed_check_in.energy_level, "medium"),
|
|
@@ -102,6 +140,14 @@ def _parse_check_in_details(check_in):
|
|
| 102 |
],
|
| 103 |
_format_parser_panel(parsed),
|
| 104 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
return parse_result, _format_follow_up_reply(parsed)
|
| 106 |
|
| 107 |
|
|
@@ -123,9 +169,12 @@ def _assistant_reply_from_summary(parser_summary):
|
|
| 123 |
return parser_summary
|
| 124 |
|
| 125 |
|
| 126 |
-
def send_check_in_message(message, chat_history, transcript):
|
| 127 |
if not message.strip():
|
| 128 |
-
parsed_result, assistant_reply = _parse_check_in_details(
|
|
|
|
|
|
|
|
|
|
| 129 |
return (
|
| 130 |
"",
|
| 131 |
chat_history or [],
|
|
@@ -134,7 +183,10 @@ def send_check_in_message(message, chat_history, transcript):
|
|
| 134 |
)
|
| 135 |
|
| 136 |
updated_transcript = _append_user_message(transcript or "", message)
|
| 137 |
-
parsed_result, assistant_reply = _parse_check_in_details(
|
|
|
|
|
|
|
|
|
|
| 138 |
updated_chat_history = [
|
| 139 |
*(chat_history or []),
|
| 140 |
{"role": "user", "content": message.strip()},
|
|
@@ -153,6 +205,7 @@ def send_check_in_message(message, chat_history, transcript):
|
|
| 153 |
|
| 154 |
|
| 155 |
def reset_check_in_conversation():
|
|
|
|
| 156 |
return (
|
| 157 |
"",
|
| 158 |
[],
|
|
@@ -196,6 +249,17 @@ def build_preview(
|
|
| 196 |
mood,
|
| 197 |
pain_issues_state=None,
|
| 198 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
structured_check_in = CheckIn(
|
| 200 |
raw_text=check_in,
|
| 201 |
time_available_minutes=time_minutes,
|
|
@@ -215,6 +279,18 @@ def build_preview(
|
|
| 215 |
check_in=structured_check_in,
|
| 216 |
completed_sessions=completed_sessions,
|
| 217 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
check_in_text = structured_check_in.raw_text.strip() or "No check-in text yet."
|
| 220 |
soreness_text = structured_check_in.soreness.strip() or "None noted."
|
|
@@ -417,8 +493,15 @@ def _log_inputs_to_completed_sets(log_rows, reps_values, load_values, rpe_values
|
|
| 417 |
|
| 418 |
def save_completed_session(day_number, log_rows, *log_input_values):
|
| 419 |
if day_number is None:
|
|
|
|
| 420 |
return "Build today's session before saving."
|
| 421 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
try:
|
| 423 |
if log_input_values:
|
| 424 |
reps_values = log_input_values[:MAX_LOG_ROWS]
|
|
@@ -435,6 +518,7 @@ def save_completed_session(day_number, log_rows, *log_input_values):
|
|
| 435 |
else:
|
| 436 |
completed_sets = _log_rows_to_completed_sets(log_rows)
|
| 437 |
if not completed_sets:
|
|
|
|
| 438 |
return "Add at least one completed set with reps and load before saving."
|
| 439 |
|
| 440 |
completed_session = CompletedSession(
|
|
@@ -444,8 +528,14 @@ def save_completed_session(day_number, log_rows, *log_input_values):
|
|
| 444 |
)
|
| 445 |
history_store.append_completed_session(completed_session)
|
| 446 |
except Exception as error:
|
|
|
|
| 447 |
return f"Could not save completed session: {error}"
|
| 448 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
return (
|
| 450 |
f"Saved Day {day_number} with {len(completed_sets)} completed sets. "
|
| 451 |
"The next build will suggest the following training day."
|
|
@@ -539,7 +629,7 @@ with gr.Blocks(title="Strength Training Coach") as demo:
|
|
| 539 |
|
| 540 |
parse_button.click(
|
| 541 |
fn=send_check_in_message,
|
| 542 |
-
inputs=[check_in, check_in_chat, check_in_state],
|
| 543 |
outputs=[
|
| 544 |
check_in,
|
| 545 |
check_in_chat,
|
|
@@ -557,7 +647,7 @@ with gr.Blocks(title="Strength Training Coach") as demo:
|
|
| 557 |
)
|
| 558 |
check_in.submit(
|
| 559 |
fn=send_check_in_message,
|
| 560 |
-
inputs=[check_in, check_in_chat, check_in_state],
|
| 561 |
outputs=[
|
| 562 |
check_in,
|
| 563 |
check_in_chat,
|
|
@@ -632,4 +722,5 @@ with gr.Blocks(title="Strength Training Coach") as demo:
|
|
| 632 |
|
| 633 |
|
| 634 |
if __name__ == "__main__":
|
|
|
|
| 635 |
demo.launch()
|
|
|
|
| 1 |
from datetime import date
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
+
from training_coach.env import load_local_env
|
| 8 |
from training_coach.engine import build_session_for_day, suggest_next_training_day
|
| 9 |
from training_coach.models import CheckIn, CompletedSession, CompletedSet, PainIssue
|
| 10 |
from training_coach.parser_service import parse_check_in_with_configured_backend
|
| 11 |
from training_coach.storage import create_history_store
|
| 12 |
|
| 13 |
|
| 14 |
+
load_local_env()
|
| 15 |
+
logging.basicConfig(
|
| 16 |
+
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
| 17 |
+
format="%(asctime)s level=%(levelname)s logger=%(name)s %(message)s",
|
| 18 |
+
)
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
history_store = create_history_store()
|
| 21 |
LOG_HEADERS = [
|
| 22 |
"exercise_id",
|
|
|
|
| 85 |
return "\n\n".join(sections) or "No extra parser notes."
|
| 86 |
|
| 87 |
|
| 88 |
+
def _pain_issue_key(issue):
|
| 89 |
+
if issue.affected_muscle is not None:
|
| 90 |
+
return str(issue.affected_muscle)
|
| 91 |
+
return issue.notes.strip().lower()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _merge_pain_issues(previous_issues_state, parsed_issues):
|
| 95 |
+
merged = {}
|
| 96 |
+
for issue_data in previous_issues_state or []:
|
| 97 |
+
issue = PainIssue.model_validate(issue_data)
|
| 98 |
+
merged[_pain_issue_key(issue)] = issue
|
| 99 |
+
for issue in parsed_issues:
|
| 100 |
+
merged[_pain_issue_key(issue)] = issue
|
| 101 |
+
return list(merged.values())
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _parse_check_in_details(check_in, previous_pain_issues_state=None):
|
| 105 |
if not check_in.strip():
|
| 106 |
+
logger.info("event=parse_skipped reason=empty_check_in")
|
| 107 |
default_result = _default_parse_result("Write a check-in first.")
|
| 108 |
return default_result, default_result[-1]
|
| 109 |
|
| 110 |
+
logger.info(
|
| 111 |
+
"event=parse_request text_chars=%s previous_pain_issues=%s",
|
| 112 |
+
len(check_in),
|
| 113 |
+
len(previous_pain_issues_state or []),
|
| 114 |
+
)
|
| 115 |
try:
|
| 116 |
parsed = parse_check_in_with_configured_backend(check_in)
|
| 117 |
except Exception as error:
|
| 118 |
+
logger.exception("event=parse_failed error_type=%s", type(error).__name__)
|
| 119 |
default_result = _default_parse_result(f"Parser failed: {error}")
|
| 120 |
return default_result, default_result[-1]
|
| 121 |
|
| 122 |
parsed_check_in = parsed.check_in
|
| 123 |
+
parsed_check_in.pain_issues = _merge_pain_issues(
|
| 124 |
+
previous_pain_issues_state,
|
| 125 |
+
parsed_check_in.pain_issues,
|
| 126 |
+
)
|
| 127 |
+
if parsed_check_in.pain_issues:
|
| 128 |
+
parsed_check_in.pain_or_injury = "yes"
|
| 129 |
parse_result = (
|
| 130 |
_first_or_default(parsed_check_in.time_available_minutes, 60),
|
| 131 |
_first_or_default(parsed_check_in.energy_level, "medium"),
|
|
|
|
| 140 |
],
|
| 141 |
_format_parser_panel(parsed),
|
| 142 |
)
|
| 143 |
+
logger.info(
|
| 144 |
+
"event=parse_complete missing_fields=%s follow_up_questions=%s "
|
| 145 |
+
"pain_issues=%s context_signals=%s",
|
| 146 |
+
len(parsed.missing_fields),
|
| 147 |
+
len(parsed.follow_up_questions),
|
| 148 |
+
len(parsed_check_in.pain_issues),
|
| 149 |
+
len(parsed.context_signals),
|
| 150 |
+
)
|
| 151 |
return parse_result, _format_follow_up_reply(parsed)
|
| 152 |
|
| 153 |
|
|
|
|
| 169 |
return parser_summary
|
| 170 |
|
| 171 |
|
| 172 |
+
def send_check_in_message(message, chat_history, transcript, pain_issues_state=None):
|
| 173 |
if not message.strip():
|
| 174 |
+
parsed_result, assistant_reply = _parse_check_in_details(
|
| 175 |
+
transcript or "",
|
| 176 |
+
pain_issues_state,
|
| 177 |
+
)
|
| 178 |
return (
|
| 179 |
"",
|
| 180 |
chat_history or [],
|
|
|
|
| 183 |
)
|
| 184 |
|
| 185 |
updated_transcript = _append_user_message(transcript or "", message)
|
| 186 |
+
parsed_result, assistant_reply = _parse_check_in_details(
|
| 187 |
+
updated_transcript,
|
| 188 |
+
pain_issues_state,
|
| 189 |
+
)
|
| 190 |
updated_chat_history = [
|
| 191 |
*(chat_history or []),
|
| 192 |
{"role": "user", "content": message.strip()},
|
|
|
|
| 205 |
|
| 206 |
|
| 207 |
def reset_check_in_conversation():
|
| 208 |
+
logger.info("event=check_in_reset")
|
| 209 |
return (
|
| 210 |
"",
|
| 211 |
[],
|
|
|
|
| 249 |
mood,
|
| 250 |
pain_issues_state=None,
|
| 251 |
):
|
| 252 |
+
logger.info(
|
| 253 |
+
"event=session_build_start time_minutes=%s energy=%s sleep=%s "
|
| 254 |
+
"sleep_hours_present=%s pain_or_injury=%s mood=%s pain_issues=%s",
|
| 255 |
+
time_minutes,
|
| 256 |
+
energy,
|
| 257 |
+
sleep,
|
| 258 |
+
sleep_hours is not None,
|
| 259 |
+
pain_or_injury,
|
| 260 |
+
mood,
|
| 261 |
+
len(pain_issues_state or []),
|
| 262 |
+
)
|
| 263 |
structured_check_in = CheckIn(
|
| 264 |
raw_text=check_in,
|
| 265 |
time_available_minutes=time_minutes,
|
|
|
|
| 279 |
check_in=structured_check_in,
|
| 280 |
completed_sessions=completed_sessions,
|
| 281 |
)
|
| 282 |
+
exercise_count = len(session_plan.planned_exercises)
|
| 283 |
+
set_count = sum(
|
| 284 |
+
len(exercise.prescribed_sets)
|
| 285 |
+
for exercise in session_plan.planned_exercises
|
| 286 |
+
)
|
| 287 |
+
logger.info(
|
| 288 |
+
"event=session_build_complete day_number=%s history_sessions=%s exercises=%s sets=%s",
|
| 289 |
+
day_number,
|
| 290 |
+
len(completed_sessions),
|
| 291 |
+
exercise_count,
|
| 292 |
+
set_count,
|
| 293 |
+
)
|
| 294 |
|
| 295 |
check_in_text = structured_check_in.raw_text.strip() or "No check-in text yet."
|
| 296 |
soreness_text = structured_check_in.soreness.strip() or "None noted."
|
|
|
|
| 493 |
|
| 494 |
def save_completed_session(day_number, log_rows, *log_input_values):
|
| 495 |
if day_number is None:
|
| 496 |
+
logger.info("event=session_save_skipped reason=missing_day")
|
| 497 |
return "Build today's session before saving."
|
| 498 |
|
| 499 |
+
logger.info(
|
| 500 |
+
"event=session_save_start day_number=%s log_rows=%s ui_input_values=%s",
|
| 501 |
+
day_number,
|
| 502 |
+
len(log_rows or []),
|
| 503 |
+
len(log_input_values),
|
| 504 |
+
)
|
| 505 |
try:
|
| 506 |
if log_input_values:
|
| 507 |
reps_values = log_input_values[:MAX_LOG_ROWS]
|
|
|
|
| 518 |
else:
|
| 519 |
completed_sets = _log_rows_to_completed_sets(log_rows)
|
| 520 |
if not completed_sets:
|
| 521 |
+
logger.info("event=session_save_skipped reason=no_completed_sets")
|
| 522 |
return "Add at least one completed set with reps and load before saving."
|
| 523 |
|
| 524 |
completed_session = CompletedSession(
|
|
|
|
| 528 |
)
|
| 529 |
history_store.append_completed_session(completed_session)
|
| 530 |
except Exception as error:
|
| 531 |
+
logger.exception("event=session_save_failed error_type=%s", type(error).__name__)
|
| 532 |
return f"Could not save completed session: {error}"
|
| 533 |
|
| 534 |
+
logger.info(
|
| 535 |
+
"event=session_save_complete day_number=%s completed_sets=%s",
|
| 536 |
+
day_number,
|
| 537 |
+
len(completed_sets),
|
| 538 |
+
)
|
| 539 |
return (
|
| 540 |
f"Saved Day {day_number} with {len(completed_sets)} completed sets. "
|
| 541 |
"The next build will suggest the following training day."
|
|
|
|
| 629 |
|
| 630 |
parse_button.click(
|
| 631 |
fn=send_check_in_message,
|
| 632 |
+
inputs=[check_in, check_in_chat, check_in_state, pain_issues_state],
|
| 633 |
outputs=[
|
| 634 |
check_in,
|
| 635 |
check_in_chat,
|
|
|
|
| 647 |
)
|
| 648 |
check_in.submit(
|
| 649 |
fn=send_check_in_message,
|
| 650 |
+
inputs=[check_in, check_in_chat, check_in_state, pain_issues_state],
|
| 651 |
outputs=[
|
| 652 |
check_in,
|
| 653 |
check_in_chat,
|
|
|
|
| 722 |
|
| 723 |
|
| 724 |
if __name__ == "__main__":
|
| 725 |
+
logger.info("event=app_start")
|
| 726 |
demo.launch()
|
tests/test_app.py
CHANGED
|
@@ -116,6 +116,44 @@ class AppTest(unittest.TestCase):
|
|
| 116 |
self.assertNotIn("How many minutes", result[-1])
|
| 117 |
self.assertEqual(result[-1], "No extra parser notes.")
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
def test_parse_check_in_omits_unsure_pain_severity_from_panel(self):
|
| 120 |
parsed = ParsedCheckIn(
|
| 121 |
check_in=CheckIn(
|
|
|
|
| 116 |
self.assertNotIn("How many minutes", result[-1])
|
| 117 |
self.assertEqual(result[-1], "No extra parser notes.")
|
| 118 |
|
| 119 |
+
def test_send_check_in_message_preserves_previous_pain_issues(self):
|
| 120 |
+
previous_pain_issues = [
|
| 121 |
+
PainIssue(
|
| 122 |
+
affected_muscle="biceps_brachii",
|
| 123 |
+
severity="mild",
|
| 124 |
+
notes="Bicep is hurting.",
|
| 125 |
+
).model_dump(mode="json")
|
| 126 |
+
]
|
| 127 |
+
parsed = ParsedCheckIn(
|
| 128 |
+
check_in=CheckIn(
|
| 129 |
+
raw_text="bicep hurts\nand my left leg also\nhamstring, mild but sharp",
|
| 130 |
+
pain_or_injury="yes",
|
| 131 |
+
pain_issues=[
|
| 132 |
+
PainIssue(
|
| 133 |
+
affected_muscle="hamstrings",
|
| 134 |
+
severity="mild",
|
| 135 |
+
notes="Left hamstring pain, mild but sharp.",
|
| 136 |
+
)
|
| 137 |
+
],
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
with patch("app.parse_check_in_with_configured_backend", return_value=parsed):
|
| 142 |
+
result = send_check_in_message(
|
| 143 |
+
"hamstring, mild but sharp",
|
| 144 |
+
[],
|
| 145 |
+
"bicep hurts\nand my left leg also",
|
| 146 |
+
previous_pain_issues,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
pain_issues = result[10]
|
| 150 |
+
self.assertEqual(
|
| 151 |
+
[issue["affected_muscle"] for issue in pain_issues],
|
| 152 |
+
["biceps_brachii", "hamstrings"],
|
| 153 |
+
)
|
| 154 |
+
self.assertIn("biceps_brachii", result[-1])
|
| 155 |
+
self.assertIn("hamstrings", result[-1])
|
| 156 |
+
|
| 157 |
def test_parse_check_in_omits_unsure_pain_severity_from_panel(self):
|
| 158 |
parsed = ParsedCheckIn(
|
| 159 |
check_in=CheckIn(
|
tests/test_env.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tempfile
|
| 3 |
+
import unittest
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
from training_coach.env import load_local_env
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class EnvTests(unittest.TestCase):
|
| 11 |
+
def test_load_local_env_sets_missing_values_without_overriding_existing(self):
|
| 12 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 13 |
+
env_path = Path(temp_dir) / ".env"
|
| 14 |
+
env_path.write_text(
|
| 15 |
+
"\n".join(
|
| 16 |
+
[
|
| 17 |
+
"# comment",
|
| 18 |
+
"PARSER_BACKEND=transformers",
|
| 19 |
+
"OLLAMA_MODEL='qwen3:8b'",
|
| 20 |
+
"EXISTING=value-from-file",
|
| 21 |
+
]
|
| 22 |
+
)
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
with patch.dict(os.environ, {"EXISTING": "value-from-env"}, clear=True):
|
| 26 |
+
load_local_env(env_path)
|
| 27 |
+
|
| 28 |
+
self.assertEqual(os.environ["PARSER_BACKEND"], "transformers")
|
| 29 |
+
self.assertEqual(os.environ["OLLAMA_MODEL"], "qwen3:8b")
|
| 30 |
+
self.assertEqual(os.environ["EXISTING"], "value-from-env")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
if __name__ == "__main__":
|
| 34 |
+
unittest.main()
|
tests/test_parser_runtime.py
CHANGED
|
@@ -1,23 +1,74 @@
|
|
| 1 |
import unittest
|
|
|
|
| 2 |
|
| 3 |
from training_coach.parser_runtime import (
|
| 4 |
DEFAULT_TRANSFORMERS_MODEL,
|
| 5 |
PARSER_MODEL,
|
| 6 |
-
|
| 7 |
ParserRuntimeUnavailableError,
|
| 8 |
_load_transformers,
|
| 9 |
generate_parser_response,
|
|
|
|
| 10 |
parse_check_in_with_model,
|
| 11 |
)
|
| 12 |
|
| 13 |
|
| 14 |
class ParserRuntimeTest(unittest.TestCase):
|
|
|
|
|
|
|
|
|
|
| 15 |
def test_runtime_exports_model_name(self):
|
| 16 |
self.assertEqual(PARSER_MODEL, "Qwen/Qwen2.5-1.5B-Instruct")
|
| 17 |
-
self.assertEqual(DEFAULT_TRANSFORMERS_MODEL, "Qwen/Qwen3-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
self.assertEqual(
|
| 21 |
|
| 22 |
def test_runtime_can_import_transformers_or_reports_clear_error(self):
|
| 23 |
try:
|
|
|
|
| 1 |
import unittest
|
| 2 |
+
from unittest.mock import patch
|
| 3 |
|
| 4 |
from training_coach.parser_runtime import (
|
| 5 |
DEFAULT_TRANSFORMERS_MODEL,
|
| 6 |
PARSER_MODEL,
|
| 7 |
+
MODEL_CACHE_ENV_VAR,
|
| 8 |
ParserRuntimeUnavailableError,
|
| 9 |
_load_transformers,
|
| 10 |
generate_parser_response,
|
| 11 |
+
load_parser_model,
|
| 12 |
parse_check_in_with_model,
|
| 13 |
)
|
| 14 |
|
| 15 |
|
| 16 |
class ParserRuntimeTest(unittest.TestCase):
|
| 17 |
+
def tearDown(self):
|
| 18 |
+
load_parser_model.cache_clear()
|
| 19 |
+
|
| 20 |
def test_runtime_exports_model_name(self):
|
| 21 |
self.assertEqual(PARSER_MODEL, "Qwen/Qwen2.5-1.5B-Instruct")
|
| 22 |
+
self.assertEqual(DEFAULT_TRANSFORMERS_MODEL, "Qwen/Qwen3-1.7B")
|
| 23 |
+
|
| 24 |
+
def test_runtime_uses_default_huggingface_cache_by_default(self):
|
| 25 |
+
self.assertEqual(MODEL_CACHE_ENV_VAR, "PARSER_MODEL_CACHE_DIR")
|
| 26 |
+
|
| 27 |
+
def test_load_parser_model_uses_default_hf_cache_without_override(self):
|
| 28 |
+
class FakeTokenizer:
|
| 29 |
+
@classmethod
|
| 30 |
+
def from_pretrained(cls, model_name, cache_dir=None):
|
| 31 |
+
return {"model_name": model_name, "cache_dir": cache_dir}
|
| 32 |
+
|
| 33 |
+
class FakeModel:
|
| 34 |
+
@classmethod
|
| 35 |
+
def from_pretrained(cls, model_name, **kwargs):
|
| 36 |
+
return {"model_name": model_name, **kwargs}
|
| 37 |
+
|
| 38 |
+
load_parser_model.cache_clear()
|
| 39 |
+
with patch.dict("os.environ", {}, clear=True), patch(
|
| 40 |
+
"training_coach.parser_runtime._load_transformers",
|
| 41 |
+
return_value=(FakeModel, FakeTokenizer),
|
| 42 |
+
):
|
| 43 |
+
tokenizer, model = load_parser_model("test/model")
|
| 44 |
+
|
| 45 |
+
self.assertIsNone(tokenizer["cache_dir"])
|
| 46 |
+
self.assertIsNone(model["cache_dir"])
|
| 47 |
+
|
| 48 |
+
def test_load_parser_model_allows_explicit_cache_override(self):
|
| 49 |
+
class FakeTokenizer:
|
| 50 |
+
@classmethod
|
| 51 |
+
def from_pretrained(cls, model_name, cache_dir=None):
|
| 52 |
+
return {"model_name": model_name, "cache_dir": cache_dir}
|
| 53 |
+
|
| 54 |
+
class FakeModel:
|
| 55 |
+
@classmethod
|
| 56 |
+
def from_pretrained(cls, model_name, **kwargs):
|
| 57 |
+
return {"model_name": model_name, **kwargs}
|
| 58 |
+
|
| 59 |
+
load_parser_model.cache_clear()
|
| 60 |
+
with patch.dict(
|
| 61 |
+
"os.environ",
|
| 62 |
+
{MODEL_CACHE_ENV_VAR: "/tmp/parser-cache"},
|
| 63 |
+
clear=True,
|
| 64 |
+
), patch(
|
| 65 |
+
"training_coach.parser_runtime._load_transformers",
|
| 66 |
+
return_value=(FakeModel, FakeTokenizer),
|
| 67 |
+
):
|
| 68 |
+
tokenizer, model = load_parser_model("test/model")
|
| 69 |
|
| 70 |
+
self.assertEqual(tokenizer["cache_dir"], "/tmp/parser-cache")
|
| 71 |
+
self.assertEqual(model["cache_dir"], "/tmp/parser-cache")
|
| 72 |
|
| 73 |
def test_runtime_can_import_transformers_or_reports_clear_error(self):
|
| 74 |
try:
|
training_coach/engine.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
from datetime import date
|
|
|
|
| 2 |
from math import ceil
|
| 3 |
import re
|
| 4 |
from collections.abc import Sequence
|
|
@@ -17,6 +18,7 @@ from training_coach.models import (
|
|
| 17 |
MVP_LOAD_INCREMENT_KG = 1.0
|
| 18 |
MVP_BASE_TARGET_RIR = 2
|
| 19 |
MVP_REP_STEP = 2
|
|
|
|
| 20 |
|
| 21 |
INJURY_KEYWORDS = (
|
| 22 |
"ache",
|
|
@@ -309,6 +311,15 @@ def build_session_for_day(
|
|
| 309 |
if day_number not in MVP_4_DAY_TEMPLATE:
|
| 310 |
raise ValueError("day_number must be between 1 and 4")
|
| 311 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
planned_exercises = []
|
| 313 |
for order, item in enumerate(MVP_4_DAY_TEMPLATE[day_number], start=1):
|
| 314 |
reps_low, reps_high, target_reps = _template_reps(item)
|
|
@@ -322,16 +333,39 @@ def build_session_for_day(
|
|
| 322 |
)
|
| 323 |
)
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
if completed_sessions:
|
| 326 |
planned_exercises = [
|
| 327 |
apply_double_progression(exercise, completed_sessions)
|
| 328 |
for exercise in planned_exercises
|
| 329 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
|
|
|
|
| 331 |
planned_exercises, pain_filter_notes = apply_pain_filter(
|
| 332 |
planned_exercises,
|
| 333 |
check_in,
|
| 334 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
session_notes = [
|
| 337 |
f"MVP fixed day {day_number} template.",
|
|
@@ -340,19 +374,58 @@ def build_session_for_day(
|
|
| 340 |
if pain_filter_notes:
|
| 341 |
session_notes.extend(pain_filter_notes)
|
| 342 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
planned_exercises, time_compression_notes = apply_time_compression(
|
| 344 |
planned_exercises,
|
| 345 |
check_in,
|
| 346 |
)
|
| 347 |
if time_compression_notes:
|
| 348 |
session_notes.extend(time_compression_notes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
planned_exercises, readiness_notes = apply_readiness_modifier(
|
| 351 |
planned_exercises,
|
| 352 |
check_in,
|
| 353 |
)
|
| 354 |
if readiness_notes:
|
| 355 |
session_notes.extend(readiness_notes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
return SessionPlan(
|
| 358 |
date=session_date,
|
|
|
|
| 1 |
from datetime import date
|
| 2 |
+
import logging
|
| 3 |
from math import ceil
|
| 4 |
import re
|
| 5 |
from collections.abc import Sequence
|
|
|
|
| 18 |
MVP_LOAD_INCREMENT_KG = 1.0
|
| 19 |
MVP_BASE_TARGET_RIR = 2
|
| 20 |
MVP_REP_STEP = 2
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
INJURY_KEYWORDS = (
|
| 24 |
"ache",
|
|
|
|
| 311 |
if day_number not in MVP_4_DAY_TEMPLATE:
|
| 312 |
raise ValueError("day_number must be between 1 and 4")
|
| 313 |
|
| 314 |
+
logger.info(
|
| 315 |
+
"event=engine_build_start day_number=%s history_sessions=%s "
|
| 316 |
+
"time_minutes=%s pain_or_injury=%s pain_issues=%s",
|
| 317 |
+
day_number,
|
| 318 |
+
len(completed_sessions or []),
|
| 319 |
+
check_in.time_available_minutes,
|
| 320 |
+
check_in.pain_or_injury,
|
| 321 |
+
len(check_in.pain_issues),
|
| 322 |
+
)
|
| 323 |
planned_exercises = []
|
| 324 |
for order, item in enumerate(MVP_4_DAY_TEMPLATE[day_number], start=1):
|
| 325 |
reps_low, reps_high, target_reps = _template_reps(item)
|
|
|
|
| 333 |
)
|
| 334 |
)
|
| 335 |
|
| 336 |
+
template_set_count = sum(
|
| 337 |
+
len(exercise.prescribed_sets)
|
| 338 |
+
for exercise in planned_exercises
|
| 339 |
+
)
|
| 340 |
+
logger.info(
|
| 341 |
+
"event=engine_template_loaded day_number=%s exercises=%s sets=%s",
|
| 342 |
+
day_number,
|
| 343 |
+
len(planned_exercises),
|
| 344 |
+
template_set_count,
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
if completed_sessions:
|
| 348 |
planned_exercises = [
|
| 349 |
apply_double_progression(exercise, completed_sessions)
|
| 350 |
for exercise in planned_exercises
|
| 351 |
]
|
| 352 |
+
logger.info(
|
| 353 |
+
"event=engine_progression_applied day_number=%s exercises=%s",
|
| 354 |
+
day_number,
|
| 355 |
+
len(planned_exercises),
|
| 356 |
+
)
|
| 357 |
|
| 358 |
+
before_pain_count = len(planned_exercises)
|
| 359 |
planned_exercises, pain_filter_notes = apply_pain_filter(
|
| 360 |
planned_exercises,
|
| 361 |
check_in,
|
| 362 |
)
|
| 363 |
+
if pain_filter_notes:
|
| 364 |
+
logger.info(
|
| 365 |
+
"event=engine_pain_filter_applied before_exercises=%s after_exercises=%s",
|
| 366 |
+
before_pain_count,
|
| 367 |
+
len(planned_exercises),
|
| 368 |
+
)
|
| 369 |
|
| 370 |
session_notes = [
|
| 371 |
f"MVP fixed day {day_number} template.",
|
|
|
|
| 374 |
if pain_filter_notes:
|
| 375 |
session_notes.extend(pain_filter_notes)
|
| 376 |
|
| 377 |
+
before_time_sets = sum(
|
| 378 |
+
len(exercise.prescribed_sets)
|
| 379 |
+
for exercise in planned_exercises
|
| 380 |
+
)
|
| 381 |
planned_exercises, time_compression_notes = apply_time_compression(
|
| 382 |
planned_exercises,
|
| 383 |
check_in,
|
| 384 |
)
|
| 385 |
if time_compression_notes:
|
| 386 |
session_notes.extend(time_compression_notes)
|
| 387 |
+
after_time_sets = sum(
|
| 388 |
+
len(exercise.prescribed_sets)
|
| 389 |
+
for exercise in planned_exercises
|
| 390 |
+
)
|
| 391 |
+
logger.info(
|
| 392 |
+
"event=engine_time_compression_applied before_sets=%s after_sets=%s time_minutes=%s",
|
| 393 |
+
before_time_sets,
|
| 394 |
+
after_time_sets,
|
| 395 |
+
check_in.time_available_minutes,
|
| 396 |
+
)
|
| 397 |
|
| 398 |
+
before_readiness_sets = sum(
|
| 399 |
+
len(exercise.prescribed_sets)
|
| 400 |
+
for exercise in planned_exercises
|
| 401 |
+
)
|
| 402 |
planned_exercises, readiness_notes = apply_readiness_modifier(
|
| 403 |
planned_exercises,
|
| 404 |
check_in,
|
| 405 |
)
|
| 406 |
if readiness_notes:
|
| 407 |
session_notes.extend(readiness_notes)
|
| 408 |
+
after_readiness_sets = sum(
|
| 409 |
+
len(exercise.prescribed_sets)
|
| 410 |
+
for exercise in planned_exercises
|
| 411 |
+
)
|
| 412 |
+
logger.info(
|
| 413 |
+
"event=engine_readiness_applied before_sets=%s after_sets=%s",
|
| 414 |
+
before_readiness_sets,
|
| 415 |
+
after_readiness_sets,
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
final_set_count = sum(
|
| 419 |
+
len(exercise.prescribed_sets)
|
| 420 |
+
for exercise in planned_exercises
|
| 421 |
+
)
|
| 422 |
+
logger.info(
|
| 423 |
+
"event=engine_build_complete day_number=%s exercises=%s sets=%s notes=%s",
|
| 424 |
+
day_number,
|
| 425 |
+
len(planned_exercises),
|
| 426 |
+
final_set_count,
|
| 427 |
+
len(session_notes),
|
| 428 |
+
)
|
| 429 |
|
| 430 |
return SessionPlan(
|
| 431 |
date=session_date,
|
training_coach/env.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def load_local_env(path: str | Path = ".env") -> None:
|
| 6 |
+
env_path = Path(path)
|
| 7 |
+
if not env_path.exists():
|
| 8 |
+
return
|
| 9 |
+
|
| 10 |
+
for line in env_path.read_text().splitlines():
|
| 11 |
+
stripped = line.strip()
|
| 12 |
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
| 13 |
+
continue
|
| 14 |
+
|
| 15 |
+
key, value = stripped.split("=", 1)
|
| 16 |
+
key = key.strip()
|
| 17 |
+
value = value.strip().strip('"').strip("'")
|
| 18 |
+
if key and key not in os.environ:
|
| 19 |
+
os.environ[key] = value
|
training_coach/parser_runtime.py
CHANGED
|
@@ -1,14 +1,17 @@
|
|
| 1 |
from functools import lru_cache
|
|
|
|
| 2 |
import os
|
| 3 |
-
from
|
| 4 |
|
| 5 |
-
from training_coach.parser import
|
| 6 |
from training_coach.models import ParsedCheckIn
|
| 7 |
|
| 8 |
|
| 9 |
-
|
| 10 |
-
DEFAULT_TRANSFORMERS_MODEL =
|
|
|
|
| 11 |
DEFAULT_ZEROGPU_DURATION_SECONDS = 120
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class ParserRuntimeUnavailableError(RuntimeError):
|
|
@@ -39,15 +42,23 @@ def _load_transformers():
|
|
| 39 |
|
| 40 |
@lru_cache(maxsize=1)
|
| 41 |
def load_parser_model(model_name: str = DEFAULT_TRANSFORMERS_MODEL):
|
|
|
|
|
|
|
| 42 |
AutoModelForCausalLM, AutoTokenizer = _load_transformers()
|
| 43 |
-
|
| 44 |
-
tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir=
|
| 45 |
model = AutoModelForCausalLM.from_pretrained(
|
| 46 |
model_name,
|
| 47 |
-
cache_dir=
|
| 48 |
device_map="auto",
|
| 49 |
torch_dtype="auto",
|
| 50 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return tokenizer, model
|
| 52 |
|
| 53 |
|
|
@@ -56,22 +67,40 @@ def generate_parser_response(
|
|
| 56 |
raw_text: str,
|
| 57 |
model_name: str = DEFAULT_TRANSFORMERS_MODEL,
|
| 58 |
) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
tokenizer, model = load_parser_model(model_name)
|
| 60 |
messages = build_parser_messages(raw_text)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
inputs = tokenizer([prompt], return_tensors="pt").to(model.device)
|
| 67 |
output_ids = model.generate(
|
| 68 |
**inputs,
|
| 69 |
-
max_new_tokens=
|
| 70 |
do_sample=False,
|
| 71 |
pad_token_id=tokenizer.eos_token_id,
|
| 72 |
)
|
| 73 |
generated_ids = output_ids[:, inputs.input_ids.shape[-1] :]
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
def parse_check_in_with_model(
|
|
@@ -79,5 +108,13 @@ def parse_check_in_with_model(
|
|
| 79 |
model_name: str | None = None,
|
| 80 |
) -> ParsedCheckIn:
|
| 81 |
model_name = model_name or os.getenv("PARSER_MODEL_ID", DEFAULT_TRANSFORMERS_MODEL)
|
|
|
|
| 82 |
response_text = generate_parser_response(raw_text, model_name=model_name)
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from functools import lru_cache
|
| 2 |
+
import logging
|
| 3 |
import os
|
| 4 |
+
from time import perf_counter
|
| 5 |
|
| 6 |
+
from training_coach.parser import PARSER_MODEL, build_parser_messages, parse_model_response
|
| 7 |
from training_coach.models import ParsedCheckIn
|
| 8 |
|
| 9 |
|
| 10 |
+
MODEL_CACHE_ENV_VAR = "PARSER_MODEL_CACHE_DIR"
|
| 11 |
+
DEFAULT_TRANSFORMERS_MODEL = "Qwen/Qwen3-1.7B"
|
| 12 |
+
DEFAULT_MAX_NEW_TOKENS = 384
|
| 13 |
DEFAULT_ZEROGPU_DURATION_SECONDS = 120
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
|
| 17 |
class ParserRuntimeUnavailableError(RuntimeError):
|
|
|
|
| 42 |
|
| 43 |
@lru_cache(maxsize=1)
|
| 44 |
def load_parser_model(model_name: str = DEFAULT_TRANSFORMERS_MODEL):
|
| 45 |
+
start_time = perf_counter()
|
| 46 |
+
logger.info("event=parser_model_load_start model=%s", model_name)
|
| 47 |
AutoModelForCausalLM, AutoTokenizer = _load_transformers()
|
| 48 |
+
cache_dir = os.getenv(MODEL_CACHE_ENV_VAR) or None
|
| 49 |
+
tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir=cache_dir)
|
| 50 |
model = AutoModelForCausalLM.from_pretrained(
|
| 51 |
model_name,
|
| 52 |
+
cache_dir=cache_dir,
|
| 53 |
device_map="auto",
|
| 54 |
torch_dtype="auto",
|
| 55 |
)
|
| 56 |
+
logger.info(
|
| 57 |
+
"event=parser_model_load_complete model=%s cache_dir_configured=%s elapsed_ms=%s",
|
| 58 |
+
model_name,
|
| 59 |
+
cache_dir is not None,
|
| 60 |
+
round((perf_counter() - start_time) * 1000),
|
| 61 |
+
)
|
| 62 |
return tokenizer, model
|
| 63 |
|
| 64 |
|
|
|
|
| 67 |
raw_text: str,
|
| 68 |
model_name: str = DEFAULT_TRANSFORMERS_MODEL,
|
| 69 |
) -> str:
|
| 70 |
+
start_time = perf_counter()
|
| 71 |
+
logger.info(
|
| 72 |
+
"event=parser_generate_start model=%s text_chars=%s",
|
| 73 |
+
model_name,
|
| 74 |
+
len(raw_text),
|
| 75 |
+
)
|
| 76 |
tokenizer, model = load_parser_model(model_name)
|
| 77 |
messages = build_parser_messages(raw_text)
|
| 78 |
+
chat_template_kwargs = {
|
| 79 |
+
"tokenize": False,
|
| 80 |
+
"add_generation_prompt": True,
|
| 81 |
+
"enable_thinking": False,
|
| 82 |
+
}
|
| 83 |
+
try:
|
| 84 |
+
prompt = tokenizer.apply_chat_template(messages, **chat_template_kwargs)
|
| 85 |
+
except TypeError:
|
| 86 |
+
chat_template_kwargs.pop("enable_thinking")
|
| 87 |
+
prompt = tokenizer.apply_chat_template(messages, **chat_template_kwargs)
|
| 88 |
inputs = tokenizer([prompt], return_tensors="pt").to(model.device)
|
| 89 |
output_ids = model.generate(
|
| 90 |
**inputs,
|
| 91 |
+
max_new_tokens=int(os.getenv("PARSER_MAX_NEW_TOKENS", str(DEFAULT_MAX_NEW_TOKENS))),
|
| 92 |
do_sample=False,
|
| 93 |
pad_token_id=tokenizer.eos_token_id,
|
| 94 |
)
|
| 95 |
generated_ids = output_ids[:, inputs.input_ids.shape[-1] :]
|
| 96 |
+
response_text = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
|
| 97 |
+
logger.info(
|
| 98 |
+
"event=parser_generate_complete model=%s response_chars=%s elapsed_ms=%s",
|
| 99 |
+
model_name,
|
| 100 |
+
len(response_text),
|
| 101 |
+
round((perf_counter() - start_time) * 1000),
|
| 102 |
+
)
|
| 103 |
+
return response_text
|
| 104 |
|
| 105 |
|
| 106 |
def parse_check_in_with_model(
|
|
|
|
| 108 |
model_name: str | None = None,
|
| 109 |
) -> ParsedCheckIn:
|
| 110 |
model_name = model_name or os.getenv("PARSER_MODEL_ID", DEFAULT_TRANSFORMERS_MODEL)
|
| 111 |
+
logger.info("event=parser_model_parse_start model=%s", model_name)
|
| 112 |
response_text = generate_parser_response(raw_text, model_name=model_name)
|
| 113 |
+
parsed = parse_model_response(response_text)
|
| 114 |
+
logger.info(
|
| 115 |
+
"event=parser_model_parse_complete model=%s missing_fields=%s follow_up_questions=%s",
|
| 116 |
+
model_name,
|
| 117 |
+
len(parsed.missing_fields),
|
| 118 |
+
len(parsed.follow_up_questions),
|
| 119 |
+
)
|
| 120 |
+
return parsed
|
training_coach/parser_service.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import os
|
| 2 |
|
| 3 |
from training_coach.models import ParsedCheckIn
|
|
@@ -7,6 +8,7 @@ from training_coach.parser_runtime import parse_check_in_with_model
|
|
| 7 |
|
| 8 |
DEFAULT_LOCAL_BACKEND = "ollama"
|
| 9 |
DEFAULT_SPACE_BACKEND = "transformers"
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def parser_backend() -> str:
|
|
@@ -20,13 +22,33 @@ def parser_backend() -> str:
|
|
| 20 |
|
| 21 |
def parse_check_in_with_configured_backend(raw_text: str) -> ParsedCheckIn:
|
| 22 |
backend = parser_backend()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
if backend == "ollama":
|
| 24 |
-
|
| 25 |
raw_text,
|
| 26 |
model_name=os.getenv("OLLAMA_MODEL", "qwen3:8b"),
|
| 27 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
if backend == "transformers":
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
raise ValueError(
|
| 31 |
"Unsupported PARSER_BACKEND. Use 'ollama' for local Ollama or "
|
| 32 |
"'transformers' for Hugging Face Spaces."
|
|
|
|
| 1 |
+
import logging
|
| 2 |
import os
|
| 3 |
|
| 4 |
from training_coach.models import ParsedCheckIn
|
|
|
|
| 8 |
|
| 9 |
DEFAULT_LOCAL_BACKEND = "ollama"
|
| 10 |
DEFAULT_SPACE_BACKEND = "transformers"
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
|
| 14 |
def parser_backend() -> str:
|
|
|
|
| 22 |
|
| 23 |
def parse_check_in_with_configured_backend(raw_text: str) -> ParsedCheckIn:
|
| 24 |
backend = parser_backend()
|
| 25 |
+
logger.info(
|
| 26 |
+
"event=parser_start backend=%s text_chars=%s",
|
| 27 |
+
backend,
|
| 28 |
+
len(raw_text),
|
| 29 |
+
)
|
| 30 |
if backend == "ollama":
|
| 31 |
+
parsed = parse_check_in_with_ollama(
|
| 32 |
raw_text,
|
| 33 |
model_name=os.getenv("OLLAMA_MODEL", "qwen3:8b"),
|
| 34 |
)
|
| 35 |
+
logger.info(
|
| 36 |
+
"event=parser_complete backend=%s missing_fields=%s follow_up_questions=%s",
|
| 37 |
+
backend,
|
| 38 |
+
len(parsed.missing_fields),
|
| 39 |
+
len(parsed.follow_up_questions),
|
| 40 |
+
)
|
| 41 |
+
return parsed
|
| 42 |
if backend == "transformers":
|
| 43 |
+
parsed = parse_check_in_with_model(raw_text)
|
| 44 |
+
logger.info(
|
| 45 |
+
"event=parser_complete backend=%s missing_fields=%s follow_up_questions=%s",
|
| 46 |
+
backend,
|
| 47 |
+
len(parsed.missing_fields),
|
| 48 |
+
len(parsed.follow_up_questions),
|
| 49 |
+
)
|
| 50 |
+
return parsed
|
| 51 |
+
logger.error("event=parser_unsupported_backend backend=%s", backend)
|
| 52 |
raise ValueError(
|
| 53 |
"Unsupported PARSER_BACKEND. Use 'ollama' for local Ollama or "
|
| 54 |
"'transformers' for Hugging Face Spaces."
|
training_coach/storage.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import json
|
|
|
|
| 2 |
import os
|
| 3 |
from pathlib import Path
|
| 4 |
from tempfile import TemporaryDirectory
|
|
@@ -9,6 +10,7 @@ from training_coach.models import CompletedSession
|
|
| 9 |
|
| 10 |
DEFAULT_HISTORY_PATH = Path("data/completed_sessions.json")
|
| 11 |
DEFAULT_HF_HISTORY_FILE = "completed_sessions.json"
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class HistoryStore(Protocol):
|
|
@@ -50,23 +52,53 @@ class JsonHistoryStore:
|
|
| 50 |
|
| 51 |
def load_completed_sessions(self) -> list[CompletedSession]:
|
| 52 |
if not self.path.exists():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
return []
|
| 54 |
|
|
|
|
| 55 |
data = json.loads(self.path.read_text(encoding="utf-8"))
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
def save_completed_sessions(self, sessions: list[CompletedSession]) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 60 |
data = _history_payload(sessions)
|
| 61 |
self.path.write_text(
|
| 62 |
json.dumps(data, indent=2) + "\n",
|
| 63 |
encoding="utf-8",
|
| 64 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
def append_completed_session(self, session: CompletedSession) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
sessions = self.load_completed_sessions()
|
| 68 |
sessions.append(session)
|
| 69 |
self.save_completed_sessions(sessions)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
class HuggingFaceDatasetHistoryStore:
|
|
@@ -120,17 +152,47 @@ class HuggingFaceDatasetHistoryStore:
|
|
| 120 |
)
|
| 121 |
|
| 122 |
def load_completed_sessions(self) -> list[CompletedSession]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
try:
|
| 124 |
downloaded_path = self._download()
|
| 125 |
except Exception as error:
|
| 126 |
if _is_missing_hf_history_error(error):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
raise
|
| 129 |
|
| 130 |
data = json.loads(Path(downloaded_path).read_text(encoding="utf-8"))
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
def save_completed_sessions(self, sessions: list[CompletedSession]) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
api = self._api()
|
| 135 |
api.create_repo(
|
| 136 |
repo_id=self.repo_id,
|
|
@@ -153,11 +215,29 @@ class HuggingFaceDatasetHistoryStore:
|
|
| 153 |
token=self.token,
|
| 154 |
commit_message="Update completed training sessions",
|
| 155 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
def append_completed_session(self, session: CompletedSession) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
sessions = self.load_completed_sessions()
|
| 159 |
sessions.append(session)
|
| 160 |
self.save_completed_sessions(sessions)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
|
| 163 |
def create_history_store() -> HistoryStore:
|
|
@@ -165,13 +245,24 @@ def create_history_store() -> HistoryStore:
|
|
| 165 |
repo_id = os.getenv("HF_HISTORY_DATASET_REPO", "").strip()
|
| 166 |
if backend == "hf_dataset" or repo_id:
|
| 167 |
if not repo_id:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
raise RuntimeError(
|
| 169 |
"Set HF_HISTORY_DATASET_REPO when HISTORY_STORE=hf_dataset."
|
| 170 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
return HuggingFaceDatasetHistoryStore(
|
| 172 |
repo_id=repo_id,
|
| 173 |
token=os.getenv("HF_TOKEN"),
|
| 174 |
path_in_repo=os.getenv("HF_HISTORY_FILE", DEFAULT_HF_HISTORY_FILE),
|
| 175 |
private=os.getenv("HF_HISTORY_PRIVATE", "1") not in {"0", "false", "False"},
|
| 176 |
)
|
| 177 |
-
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
+
import logging
|
| 3 |
import os
|
| 4 |
from pathlib import Path
|
| 5 |
from tempfile import TemporaryDirectory
|
|
|
|
| 10 |
|
| 11 |
DEFAULT_HISTORY_PATH = Path("data/completed_sessions.json")
|
| 12 |
DEFAULT_HF_HISTORY_FILE = "completed_sessions.json"
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
|
| 16 |
class HistoryStore(Protocol):
|
|
|
|
| 52 |
|
| 53 |
def load_completed_sessions(self) -> list[CompletedSession]:
|
| 54 |
if not self.path.exists():
|
| 55 |
+
logger.info(
|
| 56 |
+
"event=history_load_complete backend=json path=%s sessions=0 missing_file=true",
|
| 57 |
+
self.path,
|
| 58 |
+
)
|
| 59 |
return []
|
| 60 |
|
| 61 |
+
logger.info("event=history_load_start backend=json path=%s", self.path)
|
| 62 |
data = json.loads(self.path.read_text(encoding="utf-8"))
|
| 63 |
+
sessions = _sessions_from_payload(data)
|
| 64 |
+
logger.info(
|
| 65 |
+
"event=history_load_complete backend=json path=%s sessions=%s",
|
| 66 |
+
self.path,
|
| 67 |
+
len(sessions),
|
| 68 |
+
)
|
| 69 |
+
return sessions
|
| 70 |
|
| 71 |
def save_completed_sessions(self, sessions: list[CompletedSession]) -> None:
|
| 72 |
+
logger.info(
|
| 73 |
+
"event=history_save_start backend=json path=%s sessions=%s",
|
| 74 |
+
self.path,
|
| 75 |
+
len(sessions),
|
| 76 |
+
)
|
| 77 |
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 78 |
data = _history_payload(sessions)
|
| 79 |
self.path.write_text(
|
| 80 |
json.dumps(data, indent=2) + "\n",
|
| 81 |
encoding="utf-8",
|
| 82 |
)
|
| 83 |
+
logger.info(
|
| 84 |
+
"event=history_save_complete backend=json path=%s sessions=%s",
|
| 85 |
+
self.path,
|
| 86 |
+
len(sessions),
|
| 87 |
+
)
|
| 88 |
|
| 89 |
def append_completed_session(self, session: CompletedSession) -> None:
|
| 90 |
+
logger.info(
|
| 91 |
+
"event=history_append_start backend=json day_number=%s sets=%s",
|
| 92 |
+
session.day_number,
|
| 93 |
+
len(session.completed_sets),
|
| 94 |
+
)
|
| 95 |
sessions = self.load_completed_sessions()
|
| 96 |
sessions.append(session)
|
| 97 |
self.save_completed_sessions(sessions)
|
| 98 |
+
logger.info(
|
| 99 |
+
"event=history_append_complete backend=json sessions=%s",
|
| 100 |
+
len(sessions),
|
| 101 |
+
)
|
| 102 |
|
| 103 |
|
| 104 |
class HuggingFaceDatasetHistoryStore:
|
|
|
|
| 152 |
)
|
| 153 |
|
| 154 |
def load_completed_sessions(self) -> list[CompletedSession]:
|
| 155 |
+
logger.info(
|
| 156 |
+
"event=history_load_start backend=hf_dataset repo_id=%s path_in_repo=%s",
|
| 157 |
+
self.repo_id,
|
| 158 |
+
self.path_in_repo,
|
| 159 |
+
)
|
| 160 |
try:
|
| 161 |
downloaded_path = self._download()
|
| 162 |
except Exception as error:
|
| 163 |
if _is_missing_hf_history_error(error):
|
| 164 |
+
logger.info(
|
| 165 |
+
"event=history_load_complete backend=hf_dataset repo_id=%s "
|
| 166 |
+
"path_in_repo=%s sessions=0 missing_file=true",
|
| 167 |
+
self.repo_id,
|
| 168 |
+
self.path_in_repo,
|
| 169 |
+
)
|
| 170 |
return []
|
| 171 |
+
logger.exception(
|
| 172 |
+
"event=history_load_failed backend=hf_dataset repo_id=%s error_type=%s",
|
| 173 |
+
self.repo_id,
|
| 174 |
+
type(error).__name__,
|
| 175 |
+
)
|
| 176 |
raise
|
| 177 |
|
| 178 |
data = json.loads(Path(downloaded_path).read_text(encoding="utf-8"))
|
| 179 |
+
sessions = _sessions_from_payload(data)
|
| 180 |
+
logger.info(
|
| 181 |
+
"event=history_load_complete backend=hf_dataset repo_id=%s "
|
| 182 |
+
"path_in_repo=%s sessions=%s",
|
| 183 |
+
self.repo_id,
|
| 184 |
+
self.path_in_repo,
|
| 185 |
+
len(sessions),
|
| 186 |
+
)
|
| 187 |
+
return sessions
|
| 188 |
|
| 189 |
def save_completed_sessions(self, sessions: list[CompletedSession]) -> None:
|
| 190 |
+
logger.info(
|
| 191 |
+
"event=history_save_start backend=hf_dataset repo_id=%s path_in_repo=%s sessions=%s",
|
| 192 |
+
self.repo_id,
|
| 193 |
+
self.path_in_repo,
|
| 194 |
+
len(sessions),
|
| 195 |
+
)
|
| 196 |
api = self._api()
|
| 197 |
api.create_repo(
|
| 198 |
repo_id=self.repo_id,
|
|
|
|
| 215 |
token=self.token,
|
| 216 |
commit_message="Update completed training sessions",
|
| 217 |
)
|
| 218 |
+
logger.info(
|
| 219 |
+
"event=history_save_complete backend=hf_dataset repo_id=%s "
|
| 220 |
+
"path_in_repo=%s sessions=%s",
|
| 221 |
+
self.repo_id,
|
| 222 |
+
self.path_in_repo,
|
| 223 |
+
len(sessions),
|
| 224 |
+
)
|
| 225 |
|
| 226 |
def append_completed_session(self, session: CompletedSession) -> None:
|
| 227 |
+
logger.info(
|
| 228 |
+
"event=history_append_start backend=hf_dataset repo_id=%s day_number=%s sets=%s",
|
| 229 |
+
self.repo_id,
|
| 230 |
+
session.day_number,
|
| 231 |
+
len(session.completed_sets),
|
| 232 |
+
)
|
| 233 |
sessions = self.load_completed_sessions()
|
| 234 |
sessions.append(session)
|
| 235 |
self.save_completed_sessions(sessions)
|
| 236 |
+
logger.info(
|
| 237 |
+
"event=history_append_complete backend=hf_dataset repo_id=%s sessions=%s",
|
| 238 |
+
self.repo_id,
|
| 239 |
+
len(sessions),
|
| 240 |
+
)
|
| 241 |
|
| 242 |
|
| 243 |
def create_history_store() -> HistoryStore:
|
|
|
|
| 245 |
repo_id = os.getenv("HF_HISTORY_DATASET_REPO", "").strip()
|
| 246 |
if backend == "hf_dataset" or repo_id:
|
| 247 |
if not repo_id:
|
| 248 |
+
logger.error(
|
| 249 |
+
"event=history_store_config_failed backend=hf_dataset "
|
| 250 |
+
"reason=missing_repo_id"
|
| 251 |
+
)
|
| 252 |
raise RuntimeError(
|
| 253 |
"Set HF_HISTORY_DATASET_REPO when HISTORY_STORE=hf_dataset."
|
| 254 |
)
|
| 255 |
+
logger.info(
|
| 256 |
+
"event=history_store_created backend=hf_dataset repo_id=%s path_in_repo=%s",
|
| 257 |
+
repo_id,
|
| 258 |
+
os.getenv("HF_HISTORY_FILE", DEFAULT_HF_HISTORY_FILE),
|
| 259 |
+
)
|
| 260 |
return HuggingFaceDatasetHistoryStore(
|
| 261 |
repo_id=repo_id,
|
| 262 |
token=os.getenv("HF_TOKEN"),
|
| 263 |
path_in_repo=os.getenv("HF_HISTORY_FILE", DEFAULT_HF_HISTORY_FILE),
|
| 264 |
private=os.getenv("HF_HISTORY_PRIVATE", "1") not in {"0", "false", "False"},
|
| 265 |
)
|
| 266 |
+
path = os.getenv("LOCAL_HISTORY_PATH", DEFAULT_HISTORY_PATH)
|
| 267 |
+
logger.info("event=history_store_created backend=json path=%s", path)
|
| 268 |
+
return JsonHistoryStore(path)
|