File size: 8,394 Bytes
95cf15d
 
 
689f982
95cf15d
 
 
 
 
 
 
 
 
 
 
 
 
56f7955
675478d
95cf15d
 
689f982
 
 
95cf15d
 
 
 
 
56f7955
 
 
 
 
 
 
 
 
 
 
 
 
95cf15d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689f982
95cf15d
 
689f982
 
 
95cf15d
689f982
 
95cf15d
 
 
 
689f982
 
95cf15d
 
 
 
 
 
 
 
 
 
689f982
 
95cf15d
 
 
 
 
 
 
 
 
 
 
 
56f7955
95cf15d
56f7955
 
 
 
 
 
 
 
 
 
 
95cf15d
56f7955
95cf15d
 
 
 
 
 
 
 
 
 
689f982
 
95cf15d
 
 
 
675478d
95cf15d
 
 
 
 
 
 
 
 
 
689f982
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56f7955
 
 
95cf15d
 
56f7955
 
95cf15d
 
 
56f7955
 
 
95cf15d
 
689f982
56f7955
 
 
 
 
 
95cf15d
 
 
 
 
 
 
 
689f982
95cf15d
689f982
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95cf15d
 
689f982
95cf15d
 
689f982
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
from functools import lru_cache
import logging
import os
import threading
from time import perf_counter

from training_coach.models import ParsedCheckIn
from training_coach.parser import (
    build_parser_messages,
    log_parser_messages,
    log_parser_response_text,
    parse_model_response,
)


DEFAULT_LLAMA_CPP_MODEL_REPO = "unsloth/Qwen3-1.7B-GGUF"
DEFAULT_LLAMA_CPP_MODEL_FILE = "Qwen3-1.7B-Q4_K_M.gguf"
DEFAULT_LLAMA_CPP_MAX_TOKENS = 512
DEFAULT_LLAMA_CPP_N_CTX = 2048
logger = logging.getLogger(__name__)

# llama-cpp-python is not thread-safe; serializes warmup vs. user requests.
_generate_lock = threading.Lock()


class LlamaCppRuntimeUnavailableError(RuntimeError):
    pass


# Generic JSON with no whitespace between tokens. Minified output saves roughly
# a quarter of the completion tokens versus the pretty-printed JSON the model
# prefers, and the model ignores prompt instructions to minify.
MINIFIED_JSON_GBNF = r"""
root   ::= object
value  ::= object | array | string | number | "true" | "false" | "null"
object ::= "{" ( string ":" value ("," string ":" value)* )? "}"
array  ::= "[" ( value ("," value)* )? "]"
string ::= "\"" ( [^"\\\x7F\x00-\x1F] | "\\" (["\\bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F]) )* "\""
number ::= ("-"? ([0-9] | [1-9] [0-9]*)) ("." [0-9]+)? ([eE] [-+]? [0-9]+)?
"""


def _load_llama_cpp():
    try:
        from llama_cpp import Llama, LlamaGrammar
    except ImportError as error:
        raise LlamaCppRuntimeUnavailableError(
            "Install llama-cpp-python to run the GGUF parser backend."
        ) from error

    return Llama, LlamaGrammar


def _optional_int_env(name: str) -> int | None:
    raw_value = os.getenv(name, "").strip()
    if not raw_value:
        return None
    return int(raw_value)


@lru_cache(maxsize=1)
def load_llama_cpp_model(
    repo_id: str = DEFAULT_LLAMA_CPP_MODEL_REPO,
    filename: str = DEFAULT_LLAMA_CPP_MODEL_FILE,
    n_ctx: int = DEFAULT_LLAMA_CPP_N_CTX,
    n_threads: int | None = None,
    n_threads_batch: int | None = None,
):
    start_time = perf_counter()
    # os_cpu_count is logged because containers report the host core count,
    # not the cgroup quota; llama.cpp defaults n_threads_batch to that host
    # count and prefill collapses under CFS throttling when oversubscribed.
    logger.info(
        "event=parser_llama_cpp_model_load_start repo=%s file=%s n_ctx=%s "
        "n_threads=%s n_threads_batch=%s os_cpu_count=%s",
        repo_id,
        filename,
        n_ctx,
        n_threads,
        n_threads_batch,
        os.cpu_count(),
    )
    Llama, _LlamaGrammar = _load_llama_cpp()
    kwargs = {
        "repo_id": repo_id,
        "filename": filename,
        "n_ctx": n_ctx,
        "verbose": False,
    }
    if n_threads is not None:
        kwargs["n_threads"] = n_threads
    if n_threads_batch is not None:
        kwargs["n_threads_batch"] = n_threads_batch

    model = Llama.from_pretrained(**kwargs)
    logger.info(
        "event=parser_llama_cpp_model_load_complete repo=%s file=%s elapsed_ms=%s",
        repo_id,
        filename,
        round((perf_counter() - start_time) * 1000),
    )
    return model


@lru_cache(maxsize=1)
def llama_cpp_json_grammar():
    _Llama, LlamaGrammar = _load_llama_cpp()
    return LlamaGrammar.from_string(MINIFIED_JSON_GBNF, verbose=False)


def build_completion_prompt(messages: list[dict[str, str]]) -> str:
    # Qwen/ChatML prompt built by hand instead of create_chat_completion: Qwen3
    # always wants to open with a <think> block, which a JSON grammar forbids,
    # pushing the model off-distribution (it returned bare "{}"). Prefilling an
    # empty think block keeps generation on-distribution and JSON-only.
    rendered = "".join(
        f"<|im_start|>{message['role']}\n{message['content']}<|im_end|>\n"
        for message in messages
    )
    return rendered + "<|im_start|>assistant\n<think>\n\n</think>\n\n"


def generate_parser_response_llama_cpp(
    raw_text: str,
    *,
    repo_id: str = DEFAULT_LLAMA_CPP_MODEL_REPO,
    filename: str = DEFAULT_LLAMA_CPP_MODEL_FILE,
    max_tokens: int = DEFAULT_LLAMA_CPP_MAX_TOKENS,
    n_ctx: int = DEFAULT_LLAMA_CPP_N_CTX,
    n_threads: int | None = None,
    n_threads_batch: int | None = None,
    warn_on_truncation: bool = True,
) -> str:
    start_time = perf_counter()
    messages = build_parser_messages(raw_text)
    logger.info(
        "event=parser_llama_cpp_generate_start repo=%s file=%s text_chars=%s max_tokens=%s",
        repo_id,
        filename,
        len(raw_text),
        max_tokens,
    )
    log_parser_messages(
        backend="llama_cpp",
        model_name=f"{repo_id}/{filename}",
        messages=messages,
    )
    with _generate_lock:
        model = load_llama_cpp_model(
            repo_id=repo_id,
            filename=filename,
            n_ctx=n_ctx,
            n_threads=n_threads,
            n_threads_batch=n_threads_batch,
        )
        # The small built-in generic JSON grammar replaces the full Pydantic
        # schema grammar, which made per-token sampling unusably slow on Space
        # CPUs. Schema conformance is enforced by parse_model_response.
        response = model.create_completion(
            prompt=build_completion_prompt(messages),
            max_tokens=max_tokens,
            temperature=0,
            grammar=llama_cpp_json_grammar(),
            stop=["<|im_end|>"],
        )
    choice = response["choices"][0]
    response_text = choice["text"].strip()
    usage = response.get("usage", {})
    logger.info(
        "event=parser_llama_cpp_generate_complete repo=%s file=%s "
        "response_chars=%s finish_reason=%s prompt_tokens=%s "
        "completion_tokens=%s elapsed_ms=%s",
        repo_id,
        filename,
        len(response_text),
        choice.get("finish_reason"),
        usage.get("prompt_tokens"),
        usage.get("completion_tokens"),
        round((perf_counter() - start_time) * 1000),
    )
    if warn_on_truncation and choice.get("finish_reason") == "length":
        logger.warning(
            "event=parser_llama_cpp_truncated max_tokens=%s "
            "completion_tokens=%s",
            max_tokens,
            usage.get("completion_tokens"),
        )
    log_parser_response_text(
        backend="llama_cpp",
        model_name=f"{repo_id}/{filename}",
        response_text=response_text,
    )
    return response_text


def llama_cpp_runtime_config() -> dict:
    n_threads = _optional_int_env("LLAMA_CPP_N_THREADS")
    n_threads_batch = _optional_int_env("LLAMA_CPP_N_THREADS_BATCH")
    if n_threads_batch is None:
        # llama.cpp defaults prefill threads to the host core count, which
        # oversubscribes container cgroup quotas; match decode threads instead.
        n_threads_batch = n_threads
    return {
        "repo_id": os.getenv("LLAMA_CPP_MODEL_REPO", DEFAULT_LLAMA_CPP_MODEL_REPO),
        "filename": os.getenv("LLAMA_CPP_MODEL_FILE", DEFAULT_LLAMA_CPP_MODEL_FILE),
        "max_tokens": int(
            os.getenv("LLAMA_CPP_MAX_TOKENS", str(DEFAULT_LLAMA_CPP_MAX_TOKENS))
        ),
        "n_ctx": int(os.getenv("LLAMA_CPP_N_CTX", str(DEFAULT_LLAMA_CPP_N_CTX))),
        "n_threads": n_threads,
        "n_threads_batch": n_threads_batch,
    }


def parse_check_in_with_llama_cpp(raw_text: str) -> ParsedCheckIn:
    response_text = generate_parser_response_llama_cpp(
        raw_text,
        **llama_cpp_runtime_config(),
    )
    return parse_model_response(response_text)


def warm_up_llama_cpp_parser() -> None:
    """Load the model and prefill the constant prompt prefix at startup.

    llama.cpp reuses the KV cache for the longest common prefix between calls,
    and the parser prompt is identical up to the trailing check-in text, so a
    warmup generation makes the first real request only pay for its suffix.
    """
    start_time = perf_counter()
    logger.info("event=parser_llama_cpp_warmup_start")
    config = llama_cpp_runtime_config()
    config["max_tokens"] = 1
    try:
        generate_parser_response_llama_cpp(
            "warmup", warn_on_truncation=False, **config
        )
    except Exception:
        logger.exception("event=parser_llama_cpp_warmup_failed")
        return
    logger.info(
        "event=parser_llama_cpp_warmup_complete elapsed_ms=%s",
        round((perf_counter() - start_time) * 1000),
    )