Repository navigation
Step 16: Jinja2 chat template engine and dotllm chat REPL - #47
Conversation
Three-stage Jinja2-subset interpreter (lexer → parser → evaluator) that parses model chat templates from GGUF metadata and formats multi-turn conversations into prompt strings. Jinja2 subset: for/if/set/elif/else, filters (trim, tojson, length, default, join, replace, map, selectattr), namespace(), raise_exception(), strftime_now(), is defined/none/mapping/string/sequence tests, dict/list literals, slicing, string methods, whitespace control, comments. Special token pre-splitting in BpeTokenizer: control tokens (type 3) and user-defined tokens (type 6) are matched and emitted as single token IDs, with EncodeSegment for continuation segments without BOS space prepend. New `dotllm chat` REPL command with streaming output, system prompt, /clear and /exit commands, stop sequence handling, and per-turn timing. Integration tests verify end-to-end chat with SmolLM2-135M-Instruct (ChatML template) and Llama-3.2-1B-Instruct (complex Llama template with dict literals, slicing, strftime_now). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@codex please review this PR thoroughly - performance issues and possible improvements, potential bugs, code quality and architecture |
|
@gemini please review this PR thoroughly - performance issues and possible improvements, potential bugs, code quality and architecture |
|
Gemini ✦ Overall, this is an excellent PR. The architecture for the Jinja2 subset (Lexer → Parser → Evaluator) is clean, standard, and easy to maintain. The approach to pre-splitting special tokens before BPE encoding is robust and correctly handles the longest-match-first requirement. While the chat template evaluation and REPL are not on the strict token-generation hot path, there are a few areas where we can reduce GC pressure and fix potential edge cases. Here is my review broken down by category: 🚀 Performance Improvements1. Tokenizer Segment Allocations ( string segment = text[pos..nextSpecialPos];Since 2. Jinja Evaluator Scope Allocations ( private void PushScope() => _scopes.Add(new Dictionary<string, object?>());Because 3. Fast-path in 🐛 Potential Bugs & Edge Cases1. 2 => Enumerable.Range(ToInt(args[0]), ToInt(args[1]) - ToInt(args[0])).Cast<object?>().ToList(),If the template calls int count = Math.Max(0, ToInt(args[1]) - ToInt(args[0]));
return Enumerable.Range(ToInt(args[0]), count).Cast<object?>().ToList();2. Stop Sequence Stripping ( foreach (var seq in options.StopSequences) {
if (assistantText.EndsWith(seq, StringComparison.Ordinal))
assistantText = assistantText[..^seq.Length];
}If a model outputs multiple overlapping stop sequences (rare, but happens with misconfigured models), stripping one might expose another, but the 🧹 Code Quality & Architecture
Overall, the architecture is solid and perfectly aligns with the project's goals. I recommend applying the |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4f188fd239
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ((tokenTypes[i] == 3 || tokenTypes[i] == 6) && | ||
| tokens[i].Length > 1 && |
There was a problem hiding this comment.
Use correct token-type IDs for special token matching
This condition hard-codes tokenTypes[i] == 3 || tokenTypes[i] == 6, which does not match the tokenizer type mapping documented in this file (CreateSentencePiece docs list user-defined as a different value), so user-defined chat markers can be skipped while unrelated token classes are scanned as “special.” In chat prompts that rely on added control markers (for stop strings like <|im_end|>/<|eot_id|>), that prevents pre-splitting into single IDs and can leave partial stop fragments in generated text while also adding unnecessary per-character scan overhead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in d78d7f2. Changed tokenTypes[i] == 6 → 4 to match the GGUF llama_token_type enum (CONTROL=3, USER_DEFINED=4, BYTE=6). Also updated all comments and docstrings to reflect the correct spec values.
| model.Dispose(); | ||
| gguf.Dispose(); |
There was a problem hiding this comment.
Guard dispose calls against partial initialization
The finally block always disposes model and gguf, but both are initialized inside the status callback; if GgufFile.Open, tokenizer loading, or LlamaModel.LoadFromGguf throws before assignment completes, this finally path raises NullReferenceException and masks the real load failure. Wrapping these disposals with null checks avoids turning recoverable initialization errors into secondary crashes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in d78d7f2. Changed declarations to nullable (GgufFile? gguf = null) and the finally block now uses model?.Dispose() / gguf?.Dispose(). Added ! null-forgiving operators after the Status block where initialization is guaranteed complete.
| private static readonly Dictionary<string, Func<object?, IReadOnlyList<object?>, object?>> Filters = new() | ||
| { | ||
| ["trim"] = (input, _) => input?.ToString()?.Trim() ?? "", | ||
| ["tojson"] = (input, _) => ToJson(input), |
There was a problem hiding this comment.
Honor
tojson filter arguments during evaluation
The tojson filter ignores all passed arguments, so templates that use tojson(indent=4) (common in tool-calling chat templates) are rendered in a different format than expected. That changes prompt shape from the trained template layout and can degrade tool-call prompting quality even though parsing succeeds.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in d78d7f2. The tojson filter now accepts an optional indent argument. tojson(indent=4) produces properly indented JSON with newlines, matching Python/Jinja2 behavior. The serialization methods (SerializeDictToJson, SerializeListToJson) gained indent and depth parameters for recursive indentation. Compact output (no args) is unchanged.
…e crash (#30) - Fix GGUF token type enum: type 4 = user-defined, not 6 (which is byte) - Guard Dispose against partial initialization (nullable + null-conditional) - Support tojson(indent=N) for indented JSON output (Llama 3.2 templates) - Prevent range(5,2) crash with Math.Max(0, ...) for negative counts - Remove redundant IList branch in IsTruthy (ICollection covers it) - Add first-char check in FindNextSpecialToken before SequenceEqual - Enhance REPL timing: prompt tokens, TTFT, split prefill/decode tok/s Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Thanks for the thorough review! Here's what was addressed in d78d7f2: Fixed:
Deferred (out of scope for review fix):
|
Closes #30
Summary
tokenizer.chat_templatemetadata and formats multi-turn conversations into prompt stringsBpeTokenizer— control tokens (type 3) and user-defined tokens (type 6) are matched and emitted as single token IDs before BPE encoding, withEncodeSegmentfor continuation segments without SentencePiece BOS space prependdotllm chatREPL — interactive multi-turn chat command with streaming output, system prompt, stop sequences, and per-turn timingJinja2 Subset
Covers the constructs used by real HuggingFace model templates (Llama 3.1/3.2, Qwen2/ChatML, Mistral, SmolLM):
{% for %},{% if/elif/else %},{% set %},{% set ns.attr %}not/and/or, ternaryx if cond else y,in/not inobj.attr,obj['key'],list[idx],list[start:stop]slicing[a, b], dicts{'key': value}trim,tojson,length,default,join,replace,upper,lower,first,last,string,list,items,selectattr,mapis defined,is not defined,is none,is not none,is mapping,is string,is sequenceraise_exception(),namespace(),range(),dict(),strftime_now(){%-/-%}), comments{# #},loopvariable (index,index0,first,last,length), string methods (.strip(),.replace(),.split(),.startswith()), keyword filter args (tojson(indent=4))Architecture
JinjaLexer— two-mode lexer (text vs tag), brace depth tracking for dict literals inside{{ }}JinjaParser— recursive-descent with precedence climbing, normalizesmsg['role']to member accessJinjaEvaluator— tree-walking evaluator with scope chain, Python-style truthiness,UndefinedsentinelJinjaChatTemplate— publicIChatTemplateimpl, parses once in constructor, evaluates perApply()callGgufChatTemplateFactory— bridge from GGUF metadata + tokenizer →JinjaChatTemplateNew Files
src/DotLLM.Tokenizers/ChatTemplates/JinjaException.cssrc/DotLLM.Tokenizers/ChatTemplates/JinjaToken.cssrc/DotLLM.Tokenizers/ChatTemplates/JinjaLexer.cssrc/DotLLM.Tokenizers/ChatTemplates/JinjaAst.cssrc/DotLLM.Tokenizers/ChatTemplates/JinjaParser.cssrc/DotLLM.Tokenizers/ChatTemplates/JinjaEvaluator.cssrc/DotLLM.Tokenizers/ChatTemplates/JinjaChatTemplate.csIChatTemplateimplementationsrc/DotLLM.Models/Gguf/GgufChatTemplateFactory.cssrc/DotLLM.Cli/Commands/ChatCommand.csModified Files
src/DotLLM.Tokenizers/Bpe/BpeTokenizer.cssrc/DotLLM.Tokenizers/Bpe/BpeCore.csEncodeSegmentdefault interface method onIBpeEncodingsrc/DotLLM.Tokenizers/Bpe/SentencePieceEncoding.csEncodeSegmentimpl (no BOS space prepend)src/DotLLM.Cli/Program.cschatcommandTest Plan
dotllm chat bartowski/SmolLM2-135M-Instruct-GGUFanddotllm chat bartowski/Llama-3.2-1B-Instruct-GGUF🤖 Generated with Claude Code