Lucas commited on
Commit
06feb89
·
1 Parent(s): 774ed92

Improve parser runtime config and check-in state

Browse files
.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` and `PARSER_MODEL_ID=Qwen/Qwen3-4B`.
91
- Local development remains Ollama by default so the app still runs locally without a
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-4B
 
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 _parse_check_in_details(check_in):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(transcript or "")
 
 
 
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(updated_transcript)
 
 
 
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
- MODEL_CACHE_DIR,
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-4B")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- def test_runtime_uses_project_local_model_cache(self):
20
- self.assertEqual(str(MODEL_CACHE_DIR), ".cache/huggingface")
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 pathlib import Path
4
 
5
- from training_coach.parser import PARSER_FALLBACK_MODEL, PARSER_MODEL, build_parser_messages, parse_model_response
6
  from training_coach.models import ParsedCheckIn
7
 
8
 
9
- MODEL_CACHE_DIR = Path(".cache/huggingface")
10
- DEFAULT_TRANSFORMERS_MODEL = PARSER_FALLBACK_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
- MODEL_CACHE_DIR.mkdir(parents=True, exist_ok=True)
44
- tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir=MODEL_CACHE_DIR)
45
  model = AutoModelForCausalLM.from_pretrained(
46
  model_name,
47
- cache_dir=MODEL_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
- prompt = tokenizer.apply_chat_template(
62
- messages,
63
- tokenize=False,
64
- add_generation_prompt=True,
65
- )
 
 
 
 
 
66
  inputs = tokenizer([prompt], return_tensors="pt").to(model.device)
67
  output_ids = model.generate(
68
  **inputs,
69
- max_new_tokens=800,
70
  do_sample=False,
71
  pad_token_id=tokenizer.eos_token_id,
72
  )
73
  generated_ids = output_ids[:, inputs.input_ids.shape[-1] :]
74
- return tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
 
 
 
 
 
 
 
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
- return parse_model_response(response_text)
 
 
 
 
 
 
 
 
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
- return parse_check_in_with_ollama(
25
  raw_text,
26
  model_name=os.getenv("OLLAMA_MODEL", "qwen3:8b"),
27
  )
 
 
 
 
 
 
 
28
  if backend == "transformers":
29
- return parse_check_in_with_model(raw_text)
 
 
 
 
 
 
 
 
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
- return _sessions_from_payload(data)
 
 
 
 
 
 
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
- return _sessions_from_payload(data)
 
 
 
 
 
 
 
 
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
- return JsonHistoryStore(os.getenv("LOCAL_HISTORY_PATH", DEFAULT_HISTORY_PATH))
 
 
 
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)