local-llm-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/__init__.py +3 -0
- examples/simple_chat.py +66 -0
- local_llm_kit/__init__.py +11 -0
- local_llm_kit/backends/__init__.py +9 -0
- local_llm_kit/backends/base.py +113 -0
- local_llm_kit/backends/llamacpp.py +293 -0
- local_llm_kit/backends/transformers.py +526 -0
- local_llm_kit/chat.py +114 -0
- local_llm_kit/cli.py +239 -0
- local_llm_kit/function_calling.py +245 -0
- local_llm_kit/llm.py +536 -0
- local_llm_kit/memory.py +178 -0
- local_llm_kit/prompt_formatting.py +478 -0
- local_llm_kit-0.1.0.dist-info/METADATA +249 -0
- local_llm_kit-0.1.0.dist-info/RECORD +22 -0
- local_llm_kit-0.1.0.dist-info/WHEEL +5 -0
- local_llm_kit-0.1.0.dist-info/entry_points.txt +2 -0
- local_llm_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- local_llm_kit-0.1.0.dist-info/top_level.txt +3 -0
- tests/__init__.py +3 -0
- tests/test_function_calling.py +137 -0
- tests/test_prompt_formatting.py +105 -0
examples/__init__.py
ADDED
examples/simple_chat.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple example script for local_llm_kit chat.
|
|
3
|
+
"""
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from local_llm_kit import chat
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
parser = argparse.ArgumentParser(description="Simple chat with a local LLM")
|
|
11
|
+
parser.add_argument("--model", "-m", required=True, help="Path to the model or model name")
|
|
12
|
+
parser.add_argument("--backend", "-b", choices=["transformers", "llamacpp"],
|
|
13
|
+
help="Backend to use (default: auto-detect)")
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
# Initial message
|
|
17
|
+
print(f"Simple chat with {args.model}")
|
|
18
|
+
print("Type 'exit' or 'quit' to end the conversation.")
|
|
19
|
+
print()
|
|
20
|
+
|
|
21
|
+
# Chat loop
|
|
22
|
+
messages = [
|
|
23
|
+
{"role": "system", "content": "You are a helpful assistant."}
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
while True:
|
|
28
|
+
# Get user input
|
|
29
|
+
user_input = input("You: ")
|
|
30
|
+
if user_input.lower() in ["exit", "quit"]:
|
|
31
|
+
print("Goodbye!")
|
|
32
|
+
break
|
|
33
|
+
|
|
34
|
+
# Add user message
|
|
35
|
+
messages.append({"role": "user", "content": user_input})
|
|
36
|
+
|
|
37
|
+
# Generate and display response
|
|
38
|
+
try:
|
|
39
|
+
print("Assistant: ", end="", flush=True)
|
|
40
|
+
response_text = ""
|
|
41
|
+
|
|
42
|
+
# Stream the response token by token
|
|
43
|
+
for chunk in chat(
|
|
44
|
+
messages=messages,
|
|
45
|
+
model_path=args.model,
|
|
46
|
+
backend=args.backend,
|
|
47
|
+
stream=True
|
|
48
|
+
):
|
|
49
|
+
# Extract and print chunk text
|
|
50
|
+
if "choices" in chunk and chunk["choices"] and "delta" in chunk["choices"][0]:
|
|
51
|
+
delta = chunk["choices"][0]["delta"]
|
|
52
|
+
if "content" in delta and delta["content"]:
|
|
53
|
+
print(delta["content"], end="", flush=True)
|
|
54
|
+
response_text += delta["content"]
|
|
55
|
+
|
|
56
|
+
print() # Add newline after response
|
|
57
|
+
|
|
58
|
+
# Add assistant response to history
|
|
59
|
+
messages.append({"role": "assistant", "content": response_text})
|
|
60
|
+
|
|
61
|
+
except Exception as e:
|
|
62
|
+
print(f"\nError: {e}")
|
|
63
|
+
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
print("\nGoodbye!")
|
|
66
|
+
sys.exit(0)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class for model backends.
|
|
3
|
+
"""
|
|
4
|
+
import time
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Dict, Any, Iterator, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseBackend(ABC):
|
|
10
|
+
"""
|
|
11
|
+
Abstract base class for model backends.
|
|
12
|
+
|
|
13
|
+
All backend implementations must inherit from this class and
|
|
14
|
+
implement the required methods.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def generate(
|
|
19
|
+
self,
|
|
20
|
+
prompt: str,
|
|
21
|
+
temperature: float = 0.7,
|
|
22
|
+
max_new_tokens: int = 512,
|
|
23
|
+
top_p: float = 0.95,
|
|
24
|
+
top_k: int = 40,
|
|
25
|
+
repetition_penalty: float = 1.1,
|
|
26
|
+
stream: bool = False,
|
|
27
|
+
logprobs: bool = False,
|
|
28
|
+
top_logprobs: Optional[int] = None,
|
|
29
|
+
**kwargs
|
|
30
|
+
) -> Dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Generate text completion for a prompt.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
prompt: Input text to complete
|
|
36
|
+
temperature: Sampling temperature (higher = more random)
|
|
37
|
+
max_new_tokens: Maximum number of tokens to generate
|
|
38
|
+
top_p: Top-p sampling parameter
|
|
39
|
+
top_k: Top-k sampling parameter
|
|
40
|
+
repetition_penalty: Penalty for repetition
|
|
41
|
+
stream: Whether to stream the response (should be False for this method)
|
|
42
|
+
logprobs: Whether to return log probabilities
|
|
43
|
+
top_logprobs: Number of top tokens to return logprobs for
|
|
44
|
+
**kwargs: Additional backend-specific parameters
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Dictionary with at least a "text" key containing the generated text
|
|
48
|
+
"""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def generate_stream(
|
|
53
|
+
self,
|
|
54
|
+
prompt: str,
|
|
55
|
+
temperature: float = 0.7,
|
|
56
|
+
max_new_tokens: int = 512,
|
|
57
|
+
top_p: float = 0.95,
|
|
58
|
+
top_k: int = 40,
|
|
59
|
+
repetition_penalty: float = 1.1,
|
|
60
|
+
logprobs: bool = False,
|
|
61
|
+
top_logprobs: Optional[int] = None,
|
|
62
|
+
**kwargs
|
|
63
|
+
) -> Iterator[Dict[str, Any]]:
|
|
64
|
+
"""
|
|
65
|
+
Stream text completion for a prompt.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
prompt: Input text to complete
|
|
69
|
+
temperature: Sampling temperature (higher = more random)
|
|
70
|
+
max_new_tokens: Maximum number of tokens to generate
|
|
71
|
+
top_p: Top-p sampling parameter
|
|
72
|
+
top_k: Top-k sampling parameter
|
|
73
|
+
repetition_penalty: Penalty for repetition
|
|
74
|
+
logprobs: Whether to return log probabilities
|
|
75
|
+
top_logprobs: Number of top tokens to return logprobs for
|
|
76
|
+
**kwargs: Additional backend-specific parameters
|
|
77
|
+
|
|
78
|
+
Yields:
|
|
79
|
+
Dictionaries with at least a "text" key containing a chunk of generated text
|
|
80
|
+
"""
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def get_context_window(self) -> int:
|
|
85
|
+
"""
|
|
86
|
+
Get the context window size of the model.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Maximum context window size in tokens
|
|
90
|
+
"""
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def count_tokens(self, text: str) -> int:
|
|
95
|
+
"""
|
|
96
|
+
Count the number of tokens in a text.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
text: Input text
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Token count
|
|
103
|
+
"""
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def get_timestamp(self) -> int:
|
|
107
|
+
"""
|
|
108
|
+
Get current timestamp.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Current timestamp in seconds
|
|
112
|
+
"""
|
|
113
|
+
return int(time.time())
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend for llama.cpp models via llama-cpp-python.
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
import json
|
|
7
|
+
from typing import Dict, Any, Iterator, Optional, List, Callable, Union, Tuple
|
|
8
|
+
|
|
9
|
+
from .base import BaseBackend
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from llama_cpp import Llama, LlamaGrammar
|
|
15
|
+
LLAMA_CPP_AVAILABLE = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
logger.warning("llama-cpp-python package not available. Install with 'pip install llama-cpp-python'")
|
|
18
|
+
LLAMA_CPP_AVAILABLE = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LlamaCppBackend(BaseBackend):
|
|
22
|
+
"""
|
|
23
|
+
Backend for llama.cpp models via llama-cpp-python binding.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
model_path: str,
|
|
29
|
+
n_gpu_layers: int = -1,
|
|
30
|
+
n_ctx: int = 2048,
|
|
31
|
+
n_batch: int = 512,
|
|
32
|
+
verbose: bool = False,
|
|
33
|
+
**kwargs
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Initialize the llama.cpp backend.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
model_path: Path to the model file (.gguf)
|
|
40
|
+
n_gpu_layers: Number of layers to offload to GPU (-1 = all)
|
|
41
|
+
n_ctx: Context window size
|
|
42
|
+
n_batch: Batch size for prompt evaluation
|
|
43
|
+
verbose: Whether to enable verbose output
|
|
44
|
+
**kwargs: Additional arguments to pass to Llama constructor
|
|
45
|
+
"""
|
|
46
|
+
if not LLAMA_CPP_AVAILABLE:
|
|
47
|
+
raise ImportError(
|
|
48
|
+
"llama-cpp-python package is not installed. "
|
|
49
|
+
"Install it with 'pip install llama-cpp-python'"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self.model_path = model_path
|
|
53
|
+
self.n_ctx = n_ctx
|
|
54
|
+
|
|
55
|
+
# Load model
|
|
56
|
+
logger.info(f"Loading model from {model_path}")
|
|
57
|
+
self.model = Llama(
|
|
58
|
+
model_path=model_path,
|
|
59
|
+
n_gpu_layers=n_gpu_layers,
|
|
60
|
+
n_ctx=n_ctx,
|
|
61
|
+
n_batch=n_batch,
|
|
62
|
+
verbose=verbose,
|
|
63
|
+
**kwargs
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def generate(
|
|
67
|
+
self,
|
|
68
|
+
prompt: str,
|
|
69
|
+
temperature: float = 0.7,
|
|
70
|
+
max_new_tokens: int = 512,
|
|
71
|
+
top_p: float = 0.95,
|
|
72
|
+
top_k: int = 40,
|
|
73
|
+
repetition_penalty: float = 1.1,
|
|
74
|
+
stream: bool = False,
|
|
75
|
+
logprobs: bool = False,
|
|
76
|
+
top_logprobs: Optional[int] = None,
|
|
77
|
+
**kwargs
|
|
78
|
+
) -> Dict[str, Any]:
|
|
79
|
+
"""Generate text completion for a prompt."""
|
|
80
|
+
if stream:
|
|
81
|
+
# If stream is requested, use generate_stream and collect all chunks
|
|
82
|
+
chunks = list(self.generate_stream(
|
|
83
|
+
prompt=prompt,
|
|
84
|
+
temperature=temperature,
|
|
85
|
+
max_new_tokens=max_new_tokens,
|
|
86
|
+
top_p=top_p,
|
|
87
|
+
top_k=top_k,
|
|
88
|
+
repetition_penalty=repetition_penalty,
|
|
89
|
+
logprobs=logprobs,
|
|
90
|
+
top_logprobs=top_logprobs,
|
|
91
|
+
**kwargs
|
|
92
|
+
))
|
|
93
|
+
|
|
94
|
+
# Combine chunks
|
|
95
|
+
text = "".join(chunk["text"] for chunk in chunks)
|
|
96
|
+
|
|
97
|
+
# Combine logprobs if requested
|
|
98
|
+
combined_logprobs = None
|
|
99
|
+
if logprobs and chunks and "logprobs" in chunks[0]:
|
|
100
|
+
combined_logprobs = {
|
|
101
|
+
"tokens": [],
|
|
102
|
+
"token_logprobs": [],
|
|
103
|
+
"top_logprobs": []
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for chunk in chunks:
|
|
107
|
+
if "logprobs" in chunk:
|
|
108
|
+
chunk_logprobs = chunk["logprobs"]
|
|
109
|
+
combined_logprobs["tokens"].extend(chunk_logprobs.get("tokens", []))
|
|
110
|
+
combined_logprobs["token_logprobs"].extend(chunk_logprobs.get("token_logprobs", []))
|
|
111
|
+
|
|
112
|
+
if "top_logprobs" in chunk_logprobs:
|
|
113
|
+
combined_logprobs["top_logprobs"].extend(chunk_logprobs.get("top_logprobs", []))
|
|
114
|
+
|
|
115
|
+
result = {"text": text}
|
|
116
|
+
if combined_logprobs:
|
|
117
|
+
result["logprobs"] = combined_logprobs
|
|
118
|
+
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
# Setup generation parameters
|
|
122
|
+
params = {
|
|
123
|
+
"temperature": temperature,
|
|
124
|
+
"max_tokens": max_new_tokens,
|
|
125
|
+
"top_p": top_p,
|
|
126
|
+
"top_k": top_k,
|
|
127
|
+
"repeat_penalty": repetition_penalty,
|
|
128
|
+
"logprobs": top_logprobs if logprobs else None,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Add grammar if provided
|
|
132
|
+
if "grammar" in kwargs:
|
|
133
|
+
grammar = kwargs.pop("grammar")
|
|
134
|
+
if isinstance(grammar, str):
|
|
135
|
+
params["grammar"] = LlamaGrammar.from_string(grammar)
|
|
136
|
+
elif isinstance(grammar, dict):
|
|
137
|
+
params["grammar"] = LlamaGrammar.from_string(json.dumps(grammar))
|
|
138
|
+
|
|
139
|
+
# Generate
|
|
140
|
+
output = self.model(
|
|
141
|
+
prompt=prompt,
|
|
142
|
+
**params,
|
|
143
|
+
**kwargs
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Extract text and tokens
|
|
147
|
+
if isinstance(output, dict):
|
|
148
|
+
text = output.get("choices", [{}])[0].get("text", "")
|
|
149
|
+
else:
|
|
150
|
+
# Handle list output format
|
|
151
|
+
text = output[0]["choices"][0]["text"]
|
|
152
|
+
|
|
153
|
+
result = {"text": text}
|
|
154
|
+
|
|
155
|
+
# Add logprobs if requested
|
|
156
|
+
if logprobs and top_logprobs:
|
|
157
|
+
try:
|
|
158
|
+
tokens_with_logprobs = []
|
|
159
|
+
|
|
160
|
+
if isinstance(output, dict) and "choices" in output:
|
|
161
|
+
choice = output["choices"][0]
|
|
162
|
+
if "logprobs" in choice:
|
|
163
|
+
tokens_with_logprobs = choice["logprobs"]["tokens"]
|
|
164
|
+
token_logprobs = choice["logprobs"]["token_logprobs"]
|
|
165
|
+
top_logprobs_data = choice["logprobs"]["top_logprobs"]
|
|
166
|
+
elif isinstance(output, list):
|
|
167
|
+
choice = output[0]["choices"][0]
|
|
168
|
+
if "logprobs" in choice:
|
|
169
|
+
tokens_with_logprobs = choice["logprobs"]["tokens"]
|
|
170
|
+
token_logprobs = choice["logprobs"]["token_logprobs"]
|
|
171
|
+
top_logprobs_data = choice["logprobs"]["top_logprobs"]
|
|
172
|
+
|
|
173
|
+
if tokens_with_logprobs:
|
|
174
|
+
result["logprobs"] = {
|
|
175
|
+
"tokens": tokens_with_logprobs,
|
|
176
|
+
"token_logprobs": token_logprobs,
|
|
177
|
+
"top_logprobs": top_logprobs_data
|
|
178
|
+
}
|
|
179
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
180
|
+
logger.warning(f"Failed to extract logprobs: {e}")
|
|
181
|
+
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
def generate_stream(
|
|
185
|
+
self,
|
|
186
|
+
prompt: str,
|
|
187
|
+
temperature: float = 0.7,
|
|
188
|
+
max_new_tokens: int = 512,
|
|
189
|
+
top_p: float = 0.95,
|
|
190
|
+
top_k: int = 40,
|
|
191
|
+
repetition_penalty: float = 1.1,
|
|
192
|
+
logprobs: bool = False,
|
|
193
|
+
top_logprobs: Optional[int] = None,
|
|
194
|
+
**kwargs
|
|
195
|
+
) -> Iterator[Dict[str, Any]]:
|
|
196
|
+
"""Stream text completion for a prompt."""
|
|
197
|
+
# Setup generation parameters
|
|
198
|
+
params = {
|
|
199
|
+
"temperature": temperature,
|
|
200
|
+
"max_tokens": max_new_tokens,
|
|
201
|
+
"top_p": top_p,
|
|
202
|
+
"top_k": top_k,
|
|
203
|
+
"repeat_penalty": repetition_penalty,
|
|
204
|
+
"logprobs": top_logprobs if logprobs else None,
|
|
205
|
+
"stream": True,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Add grammar if provided
|
|
209
|
+
if "grammar" in kwargs:
|
|
210
|
+
grammar = kwargs.pop("grammar")
|
|
211
|
+
if isinstance(grammar, str):
|
|
212
|
+
params["grammar"] = LlamaGrammar.from_string(grammar)
|
|
213
|
+
elif isinstance(grammar, dict):
|
|
214
|
+
params["grammar"] = LlamaGrammar.from_string(json.dumps(grammar))
|
|
215
|
+
|
|
216
|
+
# Generate with streaming
|
|
217
|
+
for chunk in self.model(
|
|
218
|
+
prompt=prompt,
|
|
219
|
+
**params,
|
|
220
|
+
**kwargs
|
|
221
|
+
):
|
|
222
|
+
# Extract delta text from chunk
|
|
223
|
+
if "choices" in chunk and chunk["choices"]:
|
|
224
|
+
delta_text = chunk["choices"][0].get("text", "")
|
|
225
|
+
|
|
226
|
+
# Build chunk result
|
|
227
|
+
result = {"text": delta_text}
|
|
228
|
+
|
|
229
|
+
# Add logprobs if available
|
|
230
|
+
if logprobs and top_logprobs:
|
|
231
|
+
try:
|
|
232
|
+
if "logprobs" in chunk["choices"][0]:
|
|
233
|
+
logprobs_data = chunk["choices"][0]["logprobs"]
|
|
234
|
+
result["logprobs"] = {
|
|
235
|
+
"tokens": logprobs_data.get("tokens", []),
|
|
236
|
+
"token_logprobs": logprobs_data.get("token_logprobs", []),
|
|
237
|
+
"top_logprobs": logprobs_data.get("top_logprobs", [])
|
|
238
|
+
}
|
|
239
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
yield result
|
|
243
|
+
|
|
244
|
+
def get_context_window(self) -> int:
|
|
245
|
+
"""Get the context window size of the model."""
|
|
246
|
+
return self.n_ctx
|
|
247
|
+
|
|
248
|
+
def count_tokens(self, text: str) -> int:
|
|
249
|
+
"""Count the number of tokens in a text."""
|
|
250
|
+
return len(self.model.tokenize(text.encode("utf-8")))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Function to create JSON grammar for structured outputs
|
|
254
|
+
def create_json_grammar() -> str:
|
|
255
|
+
"""
|
|
256
|
+
Create a grammar for JSON generation.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Grammar string in BNF format
|
|
260
|
+
"""
|
|
261
|
+
grammar = """
|
|
262
|
+
root ::= object
|
|
263
|
+
|
|
264
|
+
value ::= object | array | string | number | ("true" | "false" | "null")
|
|
265
|
+
|
|
266
|
+
object ::= "{" ws (string_colon_value (comma_string_colon_value)*)? ws "}"
|
|
267
|
+
|
|
268
|
+
string_colon_value ::= ws string ws ":" ws value
|
|
269
|
+
|
|
270
|
+
comma_string_colon_value ::= ws "," ws string ws ":" ws value
|
|
271
|
+
|
|
272
|
+
array ::= "[" ws (value (comma_value)*)? ws "]"
|
|
273
|
+
|
|
274
|
+
comma_value ::= ws "," ws value
|
|
275
|
+
|
|
276
|
+
string ::= "\"" (
|
|
277
|
+
[^"\\] |
|
|
278
|
+
"\\\\" |
|
|
279
|
+
"\\\"" |
|
|
280
|
+
"\\/" |
|
|
281
|
+
"\\b" |
|
|
282
|
+
"\\f" |
|
|
283
|
+
"\\n" |
|
|
284
|
+
"\\r" |
|
|
285
|
+
"\\t" |
|
|
286
|
+
"\\u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F]
|
|
287
|
+
)* "\""
|
|
288
|
+
|
|
289
|
+
number ::= "-"? ("0" | [1-9] [0-9]*) ("." [0-9]+)? ([eE] [+-]? [0-9]+)?
|
|
290
|
+
|
|
291
|
+
ws ::= [ \\t\\n\\r]*
|
|
292
|
+
"""
|
|
293
|
+
return grammar
|