Lucas commited on
Commit
95cf15d
·
1 Parent(s): a1e6f13

Add llama.cpp parser backend for Space

Browse files
.env.example CHANGED
@@ -3,7 +3,7 @@ HISTORY_STORE=local_json
3
  LOCAL_HISTORY_PATH=data/completed_sessions.json
4
 
5
  PARSER_BACKEND=ollama
6
- OLLAMA_MODEL=qwen3:8b
7
  # Private parser trace logs. These can include check-in text, so leave them off
8
  # unless debugging parser failures.
9
  PARSER_LOG_MESSAGES=0
@@ -11,10 +11,12 @@ PARSER_LOG_RESPONSES=0
11
  PARSER_LOG_PARSED_JSON=0
12
 
13
  # Hugging Face Space parser runtime.
14
- # PARSER_BACKEND=transformers
15
- # PARSER_MODEL_ID=Qwen/Qwen3-1.7B
16
- # PARSER_MAX_NEW_TOKENS=384
17
- # ZEROGPU_DURATION_SECONDS=120
 
 
18
 
19
  # Optional Hugging Face Dataset storage.
20
  # Keep real tokens in your local .env or Hugging Face Space secrets.
 
3
  LOCAL_HISTORY_PATH=data/completed_sessions.json
4
 
5
  PARSER_BACKEND=ollama
6
+ OLLAMA_MODEL=qwen3:1.7B
7
  # Private parser trace logs. These can include check-in text, so leave them off
8
  # unless debugging parser failures.
9
  PARSER_LOG_MESSAGES=0
 
11
  PARSER_LOG_PARSED_JSON=0
12
 
13
  # Hugging Face Space parser runtime.
14
+ # PARSER_BACKEND=llama_cpp
15
+ # LLAMA_CPP_MODEL_REPO=unsloth/Qwen3-1.7B-GGUF
16
+ # LLAMA_CPP_MODEL_FILE=Qwen3-1.7B-Q4_K_M.gguf
17
+ # LLAMA_CPP_MAX_TOKENS=384
18
+ # LLAMA_CPP_N_CTX=2048
19
+ # LLAMA_CPP_N_THREADS=2
20
 
21
  # Optional Hugging Face Dataset storage.
22
  # Keep real tokens in your local .env or Hugging Face Space secrets.
DECISIONS.md CHANGED
@@ -74,8 +74,9 @@ with a one-line note on why.
74
  the planned session and stores ordered `LoggedExercise` entries. Each `LoggedSet`
75
  stores set number, actual reps, actual load, optional RPE, optional rest seconds before
76
  the set, and notes.
77
- - **Parser model:** local MVP parser uses Ollama `qwen3:4b`, which passed the current
78
- parser acceptance fixtures after prompt optimization and deterministic parser cleanup.
 
79
  The parser may infer safe structured facts and possible context signals, but it must
80
  not invent quantities or training actions. Pain information must use per-issue
81
  `pain_issues`; exercise filtering is performed by the deterministic engine.
@@ -85,11 +86,14 @@ with a one-line note on why.
85
  are acceptable before the engine builds a session.
86
  - **Local parser runtime:** use Ollama/llama.cpp for local evaluation and development
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.
 
74
  the planned session and stores ordered `LoggedExercise` entries. Each `LoggedSet`
75
  stores set number, actual reps, actual load, optional RPE, optional rest seconds before
76
  the set, and notes.
77
+ - **Parser model:** local MVP parser uses Ollama `qwen3:1.7B` by default for speed.
78
+ Earlier `qwen3:4b` runs passed the current parser acceptance fixtures after prompt
79
+ optimization and deterministic parser cleanup.
80
  The parser may infer safe structured facts and possible context signals, but it must
81
  not invent quantities or training actions. Pain information must use per-issue
82
  `pain_issues`; exercise filtering is performed by the deterministic engine.
 
86
  are acceptable before the engine builds a session.
87
  - **Local parser runtime:** use Ollama/llama.cpp for local evaluation and development
88
  because it is much faster on the Mac than local Transformers CPU/disk-offload.
89
+ `qwen3:1.7B` is the current local Ollama default for MVP speed.
90
+ - **Space parser runtime:** Hugging Face Spaces use a GGUF llama.cpp backend,
91
+ configured with `PARSER_BACKEND=llama_cpp`,
92
+ `LLAMA_CPP_MODEL_REPO=unsloth/Qwen3-1.7B-GGUF`,
93
+ `LLAMA_CPP_MODEL_FILE=Qwen3-1.7B-Q4_K_M.gguf`, `LLAMA_CPP_MAX_TOKENS=384`,
94
+ `LLAMA_CPP_N_CTX=2048`, and a Space-specific `LLAMA_CPP_N_THREADS`. This replaces the
95
+ slow CPU Transformers runtime. The Transformers backend remains only as an explicit
96
+ local experiment/fallback path and is not installed by the default requirements.
97
  - **MVP training template:** use the supplied fixed 4-day exercise template for the
98
  first engine slice. Preserve exercise grouping, execution order, set counts, rep
99
  targets, and notes. For MVP simplicity, all exercise rests are normalized to 1 minute.
MVP_COMPROMISES.md CHANGED
@@ -47,8 +47,9 @@ forgetting what is missing afterward.
47
  - **Multi-round clarification over one-shot perfection.** The small model proposes
48
  structured follow-up questions, and deterministic cleanup removes obvious duplicates,
49
  already-answered questions, and unsupported activity/body-area follow-ups.
50
- - **Local development uses Ollama.** This is fast and useful locally, but deployment may
51
- need a different runtime path on Hugging Face Spaces.
 
52
  - **Acceptance fixtures are test coverage, not product proof.** They help catch obvious
53
  regressions, but real value still comes from logged sessions.
54
 
 
47
  - **Multi-round clarification over one-shot perfection.** The small model proposes
48
  structured follow-up questions, and deterministic cleanup removes obvious duplicates,
49
  already-answered questions, and unsupported activity/body-area follow-ups.
50
+ - **Local development uses Ollama.** This is fast and useful locally. Hugging Face
51
+ Spaces use a GGUF llama.cpp backend so CPU deployment does not rely on slow PyTorch
52
+ Transformers generation.
53
  - **Acceptance fixtures are test coverage, not product proof.** They help catch obvious
54
  regressions, but real value still comes from logged sessions.
55
 
PLAN.md CHANGED
@@ -129,14 +129,14 @@ exists.
129
 
130
  **Decision status:**
131
 
132
- - [x] Choose local parser model: Ollama `qwen3:4b`.
133
- - [x] Keep Transformers path as the Hugging Face Spaces deployment fallback.
134
 
135
  **Acceptance rule:**
136
 
137
  - Local development/evaluation now prefers Ollama because quantized llama.cpp/Metal
138
  inference is much faster on the Mac than local Transformers CPU/disk-offload.
139
- - Current local parser model: `qwen3:4b`, which passed the current acceptance fixtures.
140
 
141
  **Tasks:**
142
 
@@ -147,8 +147,9 @@ exists.
147
  - [x] Fix parser keys and enum values through schema validation.
148
  - [x] Validate model JSON into `ParsedCheckIn`.
149
  - [x] Add optional local Transformers runtime wrapper.
150
- - [x] Add parser backend selector for local Ollama vs Space Transformers runtime.
151
  - [x] Add Ollama local runtime wrapper with JSON-schema structured outputs.
 
152
  - [x] Run the model on one fixture and parse raw text into `ParsedCheckIn`.
153
  - [x] Run the model against all acceptance fixtures.
154
  - [x] Remove overfit fixture-shaped prompt examples.
@@ -328,7 +329,7 @@ progression have real history.
328
  - [ ] Tiny Titan route.
329
  - [ ] Research-reader.
330
  - [ ] Fine-tuning.
331
- - [ ] llama.cpp route.
332
  - [ ] Custom UI polish.
333
 
334
  ---
 
129
 
130
  **Decision status:**
131
 
132
+ - [x] Choose local parser model: Ollama `qwen3:1.7B`.
133
+ - [x] Replace the Hugging Face Spaces Transformers parser with a GGUF llama.cpp path.
134
 
135
  **Acceptance rule:**
136
 
137
  - Local development/evaluation now prefers Ollama because quantized llama.cpp/Metal
138
  inference is much faster on the Mac than local Transformers CPU/disk-offload.
139
+ - Current local parser default: `qwen3:1.7B`, chosen for MVP parser speed.
140
 
141
  **Tasks:**
142
 
 
147
  - [x] Fix parser keys and enum values through schema validation.
148
  - [x] Validate model JSON into `ParsedCheckIn`.
149
  - [x] Add optional local Transformers runtime wrapper.
150
+ - [x] Add parser backend selector for local Ollama vs Space llama.cpp runtime.
151
  - [x] Add Ollama local runtime wrapper with JSON-schema structured outputs.
152
+ - [x] Add GGUF llama.cpp runtime wrapper for Hugging Face Spaces CPU deployment.
153
  - [x] Run the model on one fixture and parse raw text into `ParsedCheckIn`.
154
  - [x] Run the model against all acceptance fixtures.
155
  - [x] Remove overfit fixture-shaped prompt examples.
 
329
  - [ ] Tiny Titan route.
330
  - [ ] Research-reader.
331
  - [ ] Fine-tuning.
332
+ - [x] llama.cpp route.
333
  - [ ] Custom UI polish.
334
 
335
  ---
README.md CHANGED
@@ -11,7 +11,7 @@ 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
@@ -116,25 +116,31 @@ Local development defaults to Ollama:
116
 
117
  ```txt
118
  PARSER_BACKEND=ollama
119
- OLLAMA_MODEL=qwen3:8b
120
  ```
121
 
122
- Hugging Face Spaces should use the Transformers runtime:
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
 
131
- For GPU parser inference on Hugging Face, set the Space hardware to ZeroGPU. The
132
- Transformers generation path is decorated with `spaces.GPU`; outside ZeroGPU the
133
- decorator is a no-op.
134
 
135
- If `PARSER_BACKEND` is not set, the app chooses `transformers` when the Hugging Face
136
  `SPACE_ID` environment variable exists, otherwise `ollama`.
137
 
 
 
 
 
138
  ## Rule Source
139
 
140
  The canonical training-rule source is:
 
11
  license: apache-2.0
12
  short_description: Small Models Big Adventures Hackaton - Weightlifting Trainer
13
  preload_from_hub:
14
+ - unsloth/Qwen3-1.7B-GGUF Qwen3-1.7B-Q4_K_M.gguf
15
  ---
16
 
17
  # Strength Training Coach
 
116
 
117
  ```txt
118
  PARSER_BACKEND=ollama
119
+ OLLAMA_MODEL=qwen3:1.7B
120
  ```
121
 
122
+ Hugging Face Spaces should use the GGUF llama.cpp runtime:
123
 
124
  ```txt
125
+ PARSER_BACKEND=llama_cpp
126
+ LLAMA_CPP_MODEL_REPO=unsloth/Qwen3-1.7B-GGUF
127
+ LLAMA_CPP_MODEL_FILE=Qwen3-1.7B-Q4_K_M.gguf
128
+ LLAMA_CPP_MAX_TOKENS=384
129
+ LLAMA_CPP_N_CTX=2048
130
+ LLAMA_CPP_N_THREADS=2
131
  ```
132
 
133
+ The Space README frontmatter preloads only the selected GGUF file at build time so the
134
+ app does not need to download model weights during the first parser request. Set
135
+ `LLAMA_CPP_N_THREADS` to the Space CPU thread count.
136
 
137
+ If `PARSER_BACKEND` is not set, the app chooses `llama_cpp` when the Hugging Face
138
  `SPACE_ID` environment variable exists, otherwise `ollama`.
139
 
140
+ The old Transformers parser backend still exists for explicit local experiments, but it
141
+ is not installed by the default deployment requirements and is no longer the Space
142
+ runtime path.
143
+
144
  ## Rule Source
145
 
146
  The canonical training-rule source is:
requirements.txt CHANGED
@@ -1,7 +1,4 @@
1
  gradio==6.17.3
2
  huggingface_hub>=0.36.0
3
  pydantic>=2.11.10,<2.13
4
- accelerate>=1.0.0
5
- spaces>=0.35.0
6
- torch>=2.8.0
7
- transformers>=4.48.0
 
1
  gradio==6.17.3
2
  huggingface_hub>=0.36.0
3
  pydantic>=2.11.10,<2.13
4
+ llama-cpp-python>=0.3.16
 
 
 
tests/test_env.py CHANGED
@@ -15,8 +15,8 @@ class EnvTests(unittest.TestCase):
15
  "\n".join(
16
  [
17
  "# comment",
18
- "PARSER_BACKEND=transformers",
19
- "OLLAMA_MODEL='qwen3:8b'",
20
  "EXISTING=value-from-file",
21
  ]
22
  )
@@ -25,8 +25,8 @@ class EnvTests(unittest.TestCase):
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
 
 
15
  "\n".join(
16
  [
17
  "# comment",
18
+ "PARSER_BACKEND=llama_cpp",
19
+ "OLLAMA_MODEL='qwen3:1.7B'",
20
  "EXISTING=value-from-file",
21
  ]
22
  )
 
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"], "llama_cpp")
29
+ self.assertEqual(os.environ["OLLAMA_MODEL"], "qwen3:1.7B")
30
  self.assertEqual(os.environ["EXISTING"], "value-from-env")
31
 
32
 
tests/test_parser_llama_cpp.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import unittest
3
+ from unittest.mock import patch
4
+
5
+ from training_coach.parser_llama_cpp import (
6
+ DEFAULT_LLAMA_CPP_MAX_TOKENS,
7
+ DEFAULT_LLAMA_CPP_MODEL_FILE,
8
+ DEFAULT_LLAMA_CPP_MODEL_REPO,
9
+ DEFAULT_LLAMA_CPP_N_CTX,
10
+ LlamaCppRuntimeUnavailableError,
11
+ _load_llama_cpp,
12
+ generate_parser_response_llama_cpp,
13
+ llama_cpp_response_grammar,
14
+ load_llama_cpp_model,
15
+ parse_check_in_with_llama_cpp,
16
+ )
17
+
18
+
19
+ class FakeLlama:
20
+ from_pretrained_calls = []
21
+ completion_calls = []
22
+
23
+ @classmethod
24
+ def from_pretrained(cls, **kwargs):
25
+ cls.from_pretrained_calls.append(kwargs)
26
+ return cls()
27
+
28
+ def create_chat_completion(self, **kwargs):
29
+ self.completion_calls.append(kwargs)
30
+ return {
31
+ "choices": [
32
+ {
33
+ "message": {
34
+ "content": json.dumps(
35
+ {
36
+ "check_in": {
37
+ "raw_text": "60 min",
38
+ "time_available_minutes": 60,
39
+ }
40
+ }
41
+ )
42
+ }
43
+ }
44
+ ]
45
+ }
46
+
47
+
48
+ class FakeGrammar:
49
+ schemas = []
50
+
51
+ @classmethod
52
+ def from_json_schema(cls, schema, verbose=True):
53
+ cls.schemas.append((schema, verbose))
54
+ return {"schema": schema, "verbose": verbose}
55
+
56
+
57
+ class LlamaCppParserTest(unittest.TestCase):
58
+ def setUp(self):
59
+ load_llama_cpp_model.cache_clear()
60
+ llama_cpp_response_grammar.cache_clear()
61
+ FakeLlama.from_pretrained_calls = []
62
+ FakeLlama.completion_calls = []
63
+ FakeGrammar.schemas = []
64
+
65
+ def tearDown(self):
66
+ load_llama_cpp_model.cache_clear()
67
+ llama_cpp_response_grammar.cache_clear()
68
+
69
+ def test_llama_cpp_defaults_are_locked(self):
70
+ self.assertEqual(DEFAULT_LLAMA_CPP_MODEL_REPO, "unsloth/Qwen3-1.7B-GGUF")
71
+ self.assertEqual(DEFAULT_LLAMA_CPP_MODEL_FILE, "Qwen3-1.7B-Q4_K_M.gguf")
72
+ self.assertEqual(DEFAULT_LLAMA_CPP_MAX_TOKENS, 384)
73
+ self.assertEqual(DEFAULT_LLAMA_CPP_N_CTX, 2048)
74
+
75
+ def test_runtime_can_import_llama_cpp_or_reports_clear_error(self):
76
+ try:
77
+ loaded = _load_llama_cpp()
78
+ except LlamaCppRuntimeUnavailableError as error:
79
+ self.assertIn("Install llama-cpp-python", str(error))
80
+ else:
81
+ self.assertEqual(len(loaded), 2)
82
+
83
+ def test_load_model_uses_repo_file_and_runtime_settings(self):
84
+ with patch(
85
+ "training_coach.parser_llama_cpp._load_llama_cpp",
86
+ return_value=(FakeLlama, FakeGrammar),
87
+ ):
88
+ load_llama_cpp_model(
89
+ repo_id="repo/model",
90
+ filename="model.gguf",
91
+ n_ctx=4096,
92
+ n_threads=2,
93
+ )
94
+
95
+ self.assertEqual(
96
+ FakeLlama.from_pretrained_calls,
97
+ [
98
+ {
99
+ "repo_id": "repo/model",
100
+ "filename": "model.gguf",
101
+ "n_ctx": 4096,
102
+ "verbose": False,
103
+ "n_threads": 2,
104
+ }
105
+ ],
106
+ )
107
+
108
+ def test_generate_parser_response_uses_schema_grammar(self):
109
+ with patch(
110
+ "training_coach.parser_llama_cpp._load_llama_cpp",
111
+ return_value=(FakeLlama, FakeGrammar),
112
+ ):
113
+ response = generate_parser_response_llama_cpp(
114
+ "60 min",
115
+ repo_id="repo/model",
116
+ filename="model.gguf",
117
+ max_tokens=128,
118
+ n_ctx=2048,
119
+ n_threads=None,
120
+ )
121
+
122
+ completion_call = FakeLlama.completion_calls[0]
123
+ self.assertEqual(completion_call["max_tokens"], 128)
124
+ self.assertEqual(completion_call["temperature"], 0)
125
+ self.assertEqual(completion_call["grammar"]["verbose"], False)
126
+ self.assertEqual(FakeGrammar.schemas[0][1], False)
127
+ self.assertIn("ParsedCheckIn", FakeGrammar.schemas[0][0])
128
+ self.assertIn("60 min", completion_call["messages"][1]["content"])
129
+ self.assertIn("check_in", response)
130
+
131
+ def test_parse_check_in_with_llama_cpp_reads_env_and_validates_response(self):
132
+ with patch.dict(
133
+ "os.environ",
134
+ {
135
+ "LLAMA_CPP_MODEL_REPO": "repo/model",
136
+ "LLAMA_CPP_MODEL_FILE": "model.gguf",
137
+ "LLAMA_CPP_MAX_TOKENS": "128",
138
+ "LLAMA_CPP_N_CTX": "4096",
139
+ "LLAMA_CPP_N_THREADS": "2",
140
+ },
141
+ clear=True,
142
+ ), patch(
143
+ "training_coach.parser_llama_cpp._load_llama_cpp",
144
+ return_value=(FakeLlama, FakeGrammar),
145
+ ):
146
+ parsed = parse_check_in_with_llama_cpp("60 min")
147
+
148
+ self.assertEqual(parsed.check_in.time_available_minutes, 60)
149
+ self.assertEqual(FakeLlama.from_pretrained_calls[0]["repo_id"], "repo/model")
150
+ self.assertEqual(FakeLlama.from_pretrained_calls[0]["filename"], "model.gguf")
151
+ self.assertEqual(FakeLlama.from_pretrained_calls[0]["n_ctx"], 4096)
152
+ self.assertEqual(FakeLlama.from_pretrained_calls[0]["n_threads"], 2)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ unittest.main()
tests/test_parser_ollama.py CHANGED
@@ -27,7 +27,7 @@ class _FakeResponse:
27
 
28
  class OllamaParserTest(unittest.TestCase):
29
  def test_ollama_model_choices_are_locked(self):
30
- self.assertEqual(OLLAMA_MODEL, "qwen3:8b")
31
  self.assertEqual(OLLAMA_TINY_CANDIDATE, "qwen3:4b")
32
 
33
  def test_build_ollama_prompt_includes_schema_and_check_in(self):
@@ -66,7 +66,7 @@ class OllamaParserTest(unittest.TestCase):
66
 
67
  sent_request = urlopen.call_args.args[0]
68
  body = json.loads(sent_request.data.decode("utf-8"))
69
- self.assertEqual(body["model"], "qwen3:8b")
70
  self.assertEqual(body["stream"], False)
71
  self.assertEqual(body["think"], False)
72
  self.assertEqual(body["format"]["title"], "ParsedCheckIn")
 
27
 
28
  class OllamaParserTest(unittest.TestCase):
29
  def test_ollama_model_choices_are_locked(self):
30
+ self.assertEqual(OLLAMA_MODEL, "qwen3:1.7B")
31
  self.assertEqual(OLLAMA_TINY_CANDIDATE, "qwen3:4b")
32
 
33
  def test_build_ollama_prompt_includes_schema_and_check_in(self):
 
66
 
67
  sent_request = urlopen.call_args.args[0]
68
  body = json.loads(sent_request.data.decode("utf-8"))
69
+ self.assertEqual(body["model"], "qwen3:1.7B")
70
  self.assertEqual(body["stream"], False)
71
  self.assertEqual(body["think"], False)
72
  self.assertEqual(body["format"]["title"], "ParsedCheckIn")
tests/test_parser_service.py CHANGED
@@ -12,7 +12,7 @@ class ParserServiceTest(unittest.TestCase):
12
 
13
  def test_parser_backend_defaults_to_transformers_on_space(self):
14
  with patch.dict("os.environ", {"SPACE_ID": "LucasMah/app"}, clear=True):
15
- self.assertEqual(parser_backend(), "transformers")
16
 
17
  def test_parser_backend_env_override_wins(self):
18
  with patch.dict(
@@ -33,7 +33,20 @@ class ParserServiceTest(unittest.TestCase):
33
  result = parse_check_in_with_configured_backend("60 min")
34
 
35
  self.assertEqual(result, parsed)
36
- parse_ollama.assert_called_once_with("60 min", model_name="qwen3:8b")
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  def test_configured_parser_uses_transformers_backend(self):
39
  parsed = ParsedCheckIn(check_in=CheckIn(raw_text="60 min"))
 
12
 
13
  def test_parser_backend_defaults_to_transformers_on_space(self):
14
  with patch.dict("os.environ", {"SPACE_ID": "LucasMah/app"}, clear=True):
15
+ self.assertEqual(parser_backend(), "llama_cpp")
16
 
17
  def test_parser_backend_env_override_wins(self):
18
  with patch.dict(
 
33
  result = parse_check_in_with_configured_backend("60 min")
34
 
35
  self.assertEqual(result, parsed)
36
+ parse_ollama.assert_called_once_with("60 min", model_name="qwen3:1.7B")
37
+
38
+ def test_configured_parser_uses_llama_cpp_backend(self):
39
+ parsed = ParsedCheckIn(check_in=CheckIn(raw_text="60 min"))
40
+
41
+ with patch.dict("os.environ", {"PARSER_BACKEND": "llama_cpp"}, clear=True):
42
+ with patch(
43
+ "training_coach.parser_service.parse_check_in_with_llama_cpp",
44
+ return_value=parsed,
45
+ ) as parse_llama_cpp:
46
+ result = parse_check_in_with_configured_backend("60 min")
47
+
48
+ self.assertEqual(result, parsed)
49
+ parse_llama_cpp.assert_called_once_with("60 min")
50
 
51
  def test_configured_parser_uses_transformers_backend(self):
52
  parsed = ParsedCheckIn(check_in=CheckIn(raw_text="60 min"))
training_coach/parser_llama_cpp.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ import json
3
+ import logging
4
+ import os
5
+ from time import perf_counter
6
+
7
+ from training_coach.models import ParsedCheckIn
8
+ from training_coach.parser import (
9
+ build_parser_messages,
10
+ expected_response_format,
11
+ log_parser_messages,
12
+ log_parser_response_text,
13
+ parse_model_response,
14
+ )
15
+
16
+
17
+ DEFAULT_LLAMA_CPP_MODEL_REPO = "unsloth/Qwen3-1.7B-GGUF"
18
+ DEFAULT_LLAMA_CPP_MODEL_FILE = "Qwen3-1.7B-Q4_K_M.gguf"
19
+ DEFAULT_LLAMA_CPP_MAX_TOKENS = 384
20
+ DEFAULT_LLAMA_CPP_N_CTX = 2048
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class LlamaCppRuntimeUnavailableError(RuntimeError):
25
+ pass
26
+
27
+
28
+ def _load_llama_cpp():
29
+ try:
30
+ from llama_cpp import Llama, LlamaGrammar
31
+ except ImportError as error:
32
+ raise LlamaCppRuntimeUnavailableError(
33
+ "Install llama-cpp-python to run the GGUF parser backend."
34
+ ) from error
35
+
36
+ return Llama, LlamaGrammar
37
+
38
+
39
+ def _optional_int_env(name: str) -> int | None:
40
+ raw_value = os.getenv(name, "").strip()
41
+ if not raw_value:
42
+ return None
43
+ return int(raw_value)
44
+
45
+
46
+ @lru_cache(maxsize=1)
47
+ def load_llama_cpp_model(
48
+ repo_id: str = DEFAULT_LLAMA_CPP_MODEL_REPO,
49
+ filename: str = DEFAULT_LLAMA_CPP_MODEL_FILE,
50
+ n_ctx: int = DEFAULT_LLAMA_CPP_N_CTX,
51
+ n_threads: int | None = None,
52
+ ):
53
+ start_time = perf_counter()
54
+ logger.info(
55
+ "event=parser_llama_cpp_model_load_start repo=%s file=%s n_ctx=%s n_threads=%s",
56
+ repo_id,
57
+ filename,
58
+ n_ctx,
59
+ n_threads,
60
+ )
61
+ Llama, _LlamaGrammar = _load_llama_cpp()
62
+ kwargs = {
63
+ "repo_id": repo_id,
64
+ "filename": filename,
65
+ "n_ctx": n_ctx,
66
+ "verbose": False,
67
+ }
68
+ if n_threads is not None:
69
+ kwargs["n_threads"] = n_threads
70
+
71
+ model = Llama.from_pretrained(**kwargs)
72
+ logger.info(
73
+ "event=parser_llama_cpp_model_load_complete repo=%s file=%s elapsed_ms=%s",
74
+ repo_id,
75
+ filename,
76
+ round((perf_counter() - start_time) * 1000),
77
+ )
78
+ return model
79
+
80
+
81
+ @lru_cache(maxsize=1)
82
+ def llama_cpp_response_grammar():
83
+ _Llama, LlamaGrammar = _load_llama_cpp()
84
+ return LlamaGrammar.from_json_schema(
85
+ json.dumps(expected_response_format()),
86
+ verbose=False,
87
+ )
88
+
89
+
90
+ def generate_parser_response_llama_cpp(
91
+ raw_text: str,
92
+ *,
93
+ repo_id: str = DEFAULT_LLAMA_CPP_MODEL_REPO,
94
+ filename: str = DEFAULT_LLAMA_CPP_MODEL_FILE,
95
+ max_tokens: int = DEFAULT_LLAMA_CPP_MAX_TOKENS,
96
+ n_ctx: int = DEFAULT_LLAMA_CPP_N_CTX,
97
+ n_threads: int | None = None,
98
+ ) -> str:
99
+ start_time = perf_counter()
100
+ messages = build_parser_messages(raw_text)
101
+ logger.info(
102
+ "event=parser_llama_cpp_generate_start repo=%s file=%s text_chars=%s max_tokens=%s",
103
+ repo_id,
104
+ filename,
105
+ len(raw_text),
106
+ max_tokens,
107
+ )
108
+ log_parser_messages(
109
+ backend="llama_cpp",
110
+ model_name=f"{repo_id}/{filename}",
111
+ messages=messages,
112
+ )
113
+ model = load_llama_cpp_model(
114
+ repo_id=repo_id,
115
+ filename=filename,
116
+ n_ctx=n_ctx,
117
+ n_threads=n_threads,
118
+ )
119
+ response = model.create_chat_completion(
120
+ messages=messages,
121
+ max_tokens=max_tokens,
122
+ temperature=0,
123
+ grammar=llama_cpp_response_grammar(),
124
+ )
125
+ response_text = response["choices"][0]["message"]["content"].strip()
126
+ logger.info(
127
+ "event=parser_llama_cpp_generate_complete repo=%s file=%s "
128
+ "response_chars=%s elapsed_ms=%s",
129
+ repo_id,
130
+ filename,
131
+ len(response_text),
132
+ round((perf_counter() - start_time) * 1000),
133
+ )
134
+ log_parser_response_text(
135
+ backend="llama_cpp",
136
+ model_name=f"{repo_id}/{filename}",
137
+ response_text=response_text,
138
+ )
139
+ return response_text
140
+
141
+
142
+ def parse_check_in_with_llama_cpp(raw_text: str) -> ParsedCheckIn:
143
+ repo_id = os.getenv("LLAMA_CPP_MODEL_REPO", DEFAULT_LLAMA_CPP_MODEL_REPO)
144
+ filename = os.getenv("LLAMA_CPP_MODEL_FILE", DEFAULT_LLAMA_CPP_MODEL_FILE)
145
+ max_tokens = int(os.getenv("LLAMA_CPP_MAX_TOKENS", str(DEFAULT_LLAMA_CPP_MAX_TOKENS)))
146
+ n_ctx = int(os.getenv("LLAMA_CPP_N_CTX", str(DEFAULT_LLAMA_CPP_N_CTX)))
147
+ n_threads = _optional_int_env("LLAMA_CPP_N_THREADS")
148
+ response_text = generate_parser_response_llama_cpp(
149
+ raw_text,
150
+ repo_id=repo_id,
151
+ filename=filename,
152
+ max_tokens=max_tokens,
153
+ n_ctx=n_ctx,
154
+ n_threads=n_threads,
155
+ )
156
+ return parse_model_response(response_text)
training_coach/parser_ollama.py CHANGED
@@ -14,7 +14,7 @@ from training_coach.models import ParsedCheckIn
14
 
15
 
16
  OLLAMA_URL = "http://127.0.0.1:11434/api/generate"
17
- OLLAMA_MODEL = "qwen3:8b"
18
  OLLAMA_TINY_CANDIDATE = "qwen3:4b"
19
  logger = logging.getLogger(__name__)
20
 
 
14
 
15
 
16
  OLLAMA_URL = "http://127.0.0.1:11434/api/generate"
17
+ OLLAMA_MODEL = "qwen3:1.7B"
18
  OLLAMA_TINY_CANDIDATE = "qwen3:4b"
19
  logger = logging.getLogger(__name__)
20
 
training_coach/parser_service.py CHANGED
@@ -2,12 +2,14 @@ import logging
2
  import os
3
 
4
  from training_coach.models import ParsedCheckIn
 
5
  from training_coach.parser_ollama import parse_check_in_with_ollama
6
  from training_coach.parser_runtime import parse_check_in_with_model
7
 
8
 
9
  DEFAULT_LOCAL_BACKEND = "ollama"
10
- DEFAULT_SPACE_BACKEND = "transformers"
 
11
  logger = logging.getLogger(__name__)
12
 
13
 
@@ -30,7 +32,7 @@ def parse_check_in_with_configured_backend(raw_text: str) -> ParsedCheckIn:
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",
@@ -39,6 +41,15 @@ def parse_check_in_with_configured_backend(raw_text: str) -> ParsedCheckIn:
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(
@@ -50,6 +61,5 @@ def parse_check_in_with_configured_backend(raw_text: str) -> ParsedCheckIn:
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."
55
  )
 
2
  import os
3
 
4
  from training_coach.models import ParsedCheckIn
5
+ from training_coach.parser_llama_cpp import parse_check_in_with_llama_cpp
6
  from training_coach.parser_ollama import parse_check_in_with_ollama
7
  from training_coach.parser_runtime import parse_check_in_with_model
8
 
9
 
10
  DEFAULT_LOCAL_BACKEND = "ollama"
11
+ DEFAULT_SPACE_BACKEND = "llama_cpp"
12
+ DEFAULT_OLLAMA_MODEL = "qwen3:1.7B"
13
  logger = logging.getLogger(__name__)
14
 
15
 
 
32
  if backend == "ollama":
33
  parsed = parse_check_in_with_ollama(
34
  raw_text,
35
+ model_name=os.getenv("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL),
36
  )
37
  logger.info(
38
  "event=parser_complete backend=%s missing_fields=%s follow_up_questions=%s",
 
41
  len(parsed.follow_up_questions),
42
  )
43
  return parsed
44
+ if backend == "llama_cpp":
45
+ parsed = parse_check_in_with_llama_cpp(raw_text)
46
+ logger.info(
47
+ "event=parser_complete backend=%s missing_fields=%s follow_up_questions=%s",
48
+ backend,
49
+ len(parsed.missing_fields),
50
+ len(parsed.follow_up_questions),
51
+ )
52
+ return parsed
53
  if backend == "transformers":
54
  parsed = parse_check_in_with_model(raw_text)
55
  logger.info(
 
61
  return parsed
62
  logger.error("event=parser_unsupported_backend backend=%s", backend)
63
  raise ValueError(
64
+ "Unsupported PARSER_BACKEND. Use 'ollama', 'llama_cpp', or 'transformers'."
 
65
  )