LLMlight 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.
LLMlight/LLMlight.py ADDED
@@ -0,0 +1,786 @@
1
+ """LLMlight.
2
+
3
+ Name : LLMlight.py
4
+ Author : E.Taskesen
5
+ Contact : erdogant@gmail.com
6
+ github : https://github.com/erdogant/LLMlight
7
+ Licence : See licences
8
+
9
+ """
10
+
11
+ import requests
12
+ import logging
13
+ import os
14
+ import numpy as np
15
+ from llama_cpp import Llama
16
+ from transformers import AutoTokenizer
17
+ import copy
18
+
19
+ from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
20
+ from sklearn.metrics.pairwise import cosine_similarity
21
+ from sentence_transformers import SentenceTransformer
22
+
23
+ from .RAG import RAG_with_RSE
24
+ from . import utils
25
+ # DEBUG
26
+ # import utils
27
+ # from RAG import RAG_with_RSE
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # %%
32
+ class LLMlight:
33
+ """Large Language Model Light.
34
+
35
+ Run your LLM models local and with minimum dependencies.
36
+ 1. Go to LM-studio.
37
+ 2. Go to left panel and select developers mode.
38
+ 3. On top select your model of interest.
39
+ 4. Then go to settings in the top bar.
40
+ 5. Enable "server on local network" if you need.
41
+ 6. Enable Running.
42
+
43
+ Parameters
44
+ ----------
45
+ modelname : str
46
+ 'hermes-3-llama-3.2-3b'
47
+ 'mistral-7b-grok'
48
+ 'openhermes-2.5-mistral-7b'
49
+ 'gemma-2-9b-it'
50
+ system : str
51
+ String of the system message.
52
+ "I am a helpfull assistant"
53
+ temperature : float, optional
54
+ Sampling temperature (default is 0.7).
55
+ top_p : float, optional
56
+ Top-p (nucleus) sampling parameter (default is 1.0, no filtering).
57
+ embedding_method : str
58
+ None
59
+ 'tfidf': Best use when it is a structured documents and the words in the queries are matching.
60
+ 'bow': Bag of words approach. Best use when you expect words in the document and queries to be matching.
61
+ 'bert': Best use when document is more free text and the queries may not match exactly the words or sentences in the document.
62
+ 'bge-small':
63
+ retrieval_method : str
64
+ 'naive_RAG': Simple RAG. Chunk text in fixed parts. Use cosine similarity to for ranking. The top scoring chunks will be combined (n chunks) and used as input with the prompt.
65
+ 'RSE': Identify and extract entire segments of relevant text.
66
+ chunks: dict : {'type': 'words', 'size': 250, 'n': 5}
67
+ type : str
68
+ 'words': Chunks are created using words.
69
+ 'chars': Chunks are created using chars.
70
+ size : str
71
+ The chunk size is measured in words. When lower chunk sizes are taken, the cosine similarity will increase in accuracy but the smaller chunk size reduces the input context for the LLM.
72
+ Note that 1000 words (~10.000 chars) costs ~3000 tokens. Thus with a context window (n_ctx) of 4096 your can set chunk size=100 words with n chunks=10.
73
+ 256: Create chunks every 256 words.
74
+ 512: Create chunks every 512 words.
75
+ n : int, optional
76
+ Top scoring chunks to be used in the context of the prompt (all with length chunk size).
77
+ endpoint : str
78
+ Endpoint of the LLM API
79
+ "http://localhost:1234/v1/chat/completions"
80
+ './models/Hermes-3-Llama-3.2-3B.Q4_K_M.gguf'
81
+ r'C:/Users/username/.lmstudio/models/lmstudio-community/gemma-2-9b-it-GGUF/gemma-2-9b-it-Q4_K_M.gguf'
82
+ n_ctx : int, default: 4096
83
+ The context window length is determined by the max tokens. A larger number of tokens will ask more cpu/gpu resources.
84
+ Note that 1000 words (~10.000 chars) costs ~3000 tokens. Thus with a context window (n_ctx) of 4096 your can set chunk size=100 words with n=10.
85
+
86
+ Examples
87
+ --------
88
+ >>> model = LLMlight()
89
+ >>> model.run('hello, who are you?')
90
+ >>> system_message = "You are a helpful assistant."
91
+ >>> response = model.run('What is the capital of France?', system=system_message, top_p=0.9)
92
+
93
+ """
94
+ def __init__(self,
95
+ modelname="hermes-3-llama-3.2-3b",
96
+ temperature=0.7,
97
+ top_p=1.0,
98
+ embedding_method='bert',
99
+ retrieval_method='naive_RAG',
100
+ chunks={'type': 'words', 'size': 250, 'n': 5},
101
+ endpoint="http://localhost:1234/v1/chat/completions",
102
+ n_ctx=4096,
103
+ verbose='info',
104
+ ):
105
+
106
+ # Set the logger
107
+ set_logger(verbose)
108
+ # Store data in self
109
+ self.modelname = modelname
110
+ self.endpoint = endpoint
111
+ self.temperature = temperature
112
+ self.top_p = top_p
113
+ self.retrieval_method = retrieval_method
114
+ self.embedding_method = embedding_method
115
+ if chunks is None: chunks = {'type': 'words', 'size': None, 'n': None}
116
+ self.chunks = {**{'type': 'words', 'size': 250, 'n': 5}, **chunks}
117
+ self.n_ctx = n_ctx
118
+ self.context = None
119
+
120
+ # Set the correct name for the model.
121
+ if embedding_method == 'bert':
122
+ self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
123
+ elif embedding_method == 'bge-small':
124
+ self.embedding_model = SentenceTransformer('BAAI/bge-small-en')
125
+ else:
126
+ self.embedding_model = None
127
+ # Load local model
128
+ if os.path.isfile(self.endpoint):
129
+ self.llm = load_local_gguf_model(self.endpoint, n_ctx=self.n_ctx)
130
+
131
+ def check_logger(self):
132
+ """Check the verbosity."""
133
+ logger.debug('DEBUG')
134
+ logger.info('INFO')
135
+ logger.warning('WARNING')
136
+ logger.critical('CRITICAL')
137
+
138
+ def get_available_models(self, validate=False):
139
+ # Set your local API base URL
140
+ # base_url = "http://localhost:1234/v1"
141
+ base_url = self.endpoint.split("/chat")[0]
142
+ logger.info('Collecting models in the API endpoint..')
143
+
144
+ # Query available models
145
+ response = requests.get(f"{base_url}/models")
146
+ model_dict = {}
147
+
148
+ try:
149
+ response = requests.get("http://localhost:1234/v1/models", timeout=10)
150
+ if response.status_code == 200:
151
+ try:
152
+ models = response.json()["data"]
153
+ model_dict = {model["id"]: model for model in models}
154
+ except (KeyError, ValueError) as e:
155
+ logger.error("Error parsing model data:", e)
156
+ else:
157
+ logger.warning("Request failed with status code:", response.status_code)
158
+ logger.warning("Response:", response.text)
159
+
160
+ except requests.exceptions.RequestException as e:
161
+ logger.error("Request error:", e)
162
+
163
+ # Check each model whether it returns a response
164
+ if validate:
165
+ logger.info("Validating models:")
166
+ keys = copy.deepcopy(list(model_dict.keys()))
167
+
168
+ for key in keys:
169
+ from LLMlight import LLMlight
170
+ llm = LLMlight(modelname=key)
171
+ response = llm.run('What is the capital of France?', system="You are a helpful assistant.", return_type='string')
172
+ response = response[0:30].replace('\n', ' ').replace('\r', ' ').lower()
173
+ if 'error: 404' in response:
174
+ logger.error(f"{llm.modelname}: {response}")
175
+ model_dict.pop(key)
176
+ else:
177
+ logger.info(f"{llm.modelname}: {response}")
178
+
179
+ return list(model_dict.keys())
180
+
181
+ def run(self,
182
+ query,
183
+ instructions=None,
184
+ system=None,
185
+ tasktype='User question',
186
+ response_format=None,
187
+ context=None,
188
+ global_reasoning = False,
189
+ temperature=None,
190
+ top_p=None,
191
+ embedding_method=None,
192
+ return_type='string',
193
+ stream=False):
194
+ """
195
+ Run the model with the provided parameters.
196
+ The final prompt is created based on the query, instructions, and the context
197
+
198
+ Parameters
199
+ ----------
200
+ query : str
201
+ The question or query or entire prompt to send to the model.
202
+ context : str
203
+ Large text string that will be chunked, and embedded. Chunks will be feeded to the model.
204
+ instructions : str
205
+ Set your instructions.
206
+ "Answer the question strictly based on the provided context."
207
+ system : str, optional
208
+ Optional system message to set context for the AI (default is None).
209
+ "You are helpfull assistant."
210
+ tasktype : str
211
+ Specifify the task type. This will be used in front of the query that is provided.
212
+ * 'User question(s)'
213
+ * 'Aim'
214
+ * 'Task'
215
+ global_reasoning: bool
216
+ True: Apply a a two steps proces, first the user question is re-formulated into a more generic question so that text chunks can be summarized more accurately. The total combined summarized context is then used as the new context for the user question following the rest of the pipeline.
217
+ False: Do not apply global reasoning
218
+ temperature : float, optional
219
+ Sampling temperature (default is 0.7).
220
+ top_p : float, optional
221
+ Top-p (nucleus) sampling parameter (default is 1.0, no filtering).
222
+ return_type: bool, optional
223
+ Return dictionary in case the output is a json
224
+ 'full': Output the full json
225
+ 'dict': Convert json into dictionary.
226
+ 'string': Return only the string output
227
+ stream : bool, optional
228
+ Whether to enable streaming (default is False).
229
+
230
+ Returns
231
+ -------
232
+ str
233
+ The model's response or an error message if the request fails.
234
+ """
235
+ logger.debug(f'{self.modelname} is loaded..')
236
+
237
+ if temperature is None: temperature = self.temperature
238
+ if top_p is None: top_p = self.top_p
239
+ if context is None: context = self.context
240
+ if embedding_method is not None: self.embedding_method = embedding_method
241
+ if isinstance(context, dict): context = '\n\n'.join(context.values())
242
+ headers = {"Content-Type": "application/json"}
243
+
244
+ # Set system message
245
+ system = set_system_message(system)
246
+ # Global Reasoning
247
+ if global_reasoning: context = self.global_reasoning(query, context)
248
+ # Extract relevant text using retrieval_method method
249
+ relevant_text = self.relevant_text_retrieval(query, context)
250
+ # Set the prompt
251
+ prompt = self.set_prompt(query, instructions, response_format, relevant_text, tasktype)
252
+ # Prepare messages
253
+ messages = [{"role": "system", "content": system}, {"role": "user", "content": prompt}]
254
+
255
+ # Run model
256
+ if os.path.isfile(self.endpoint):
257
+ # Run LLM from gguf model
258
+ response = self.requests_post_gguf(messages, temperature, top_p, headers, stream, return_type)
259
+ else:
260
+ # Run LLM with http model
261
+ response = self.requests_post_http(messages, temperature, top_p, headers, stream, return_type)
262
+
263
+ # Return
264
+ return response
265
+
266
+ def requests_post_gguf(self, messages, temperature, top_p, headers, stream=False, return_type='string'):
267
+ # Note that it is better to use messages_prompt instead of a dict (messages_dict) because most GGUF-based models don't have a tokenizer/parser that can interpret the JSON-style message structure.
268
+ # Convert messages to string prompt
269
+ prompt = convert_prompt(messages, modelname=self.modelname)
270
+ # Prepare data for request.
271
+ max_tokens = compute_max_tokens(prompt, n_ctx=self.n_ctx)
272
+ # Send post request to local GGUF model
273
+ response = self.llm(
274
+ prompt=prompt,
275
+ temperature=temperature,
276
+ top_p=top_p,
277
+ stream=stream,
278
+ max_tokens=self.n_ctx - max_tokens,
279
+ stop=["<end_of_turn>", "<|im_end|>"] # common stop tokens for chat formats
280
+ )
281
+
282
+ # Take only the output
283
+ if return_type == 'string':
284
+ response = response.get('choices', [{}])[0].get('text', "No response")
285
+
286
+ # Return
287
+ return response
288
+
289
+ def requests_post_http(self, messages, temperature, top_p, headers, stream=False, return_type='string'):
290
+ # Prepare data for request.
291
+ max_tokens = compute_max_tokens(messages[0]['content'] + messages[1]['content'], n_ctx=self.n_ctx)
292
+ data = {
293
+ "model": self.modelname,
294
+ "messages": messages,
295
+ "temperature": temperature,
296
+ "top_p": top_p,
297
+ "stream": stream,
298
+ "max_tokens": self.n_ctx - max_tokens,
299
+ }
300
+
301
+ # Send POST request
302
+ response = self.requests_post(headers, data, stream=stream, return_type=return_type)
303
+
304
+ # Return
305
+ return response
306
+
307
+ def requests_post(self, headers, data, stream=False, return_type='string'):
308
+ """Create the request to the LLM."""
309
+ # Get response
310
+ response = requests.post(self.endpoint, headers=headers, json=data, stream=stream)
311
+
312
+ # Handle the response
313
+ if response.status_code == 200:
314
+ try:
315
+ # Create dictionary in case json
316
+ response_text = response.json().get('choices', [{}])[0].get('message', {}).get('content', "No response")
317
+
318
+ if return_type == 'dict':
319
+ response_text = utils.is_valid_json(response_text)
320
+ return response_text
321
+ elif return_type == 'string':
322
+ return response_text
323
+ else:
324
+ return response.json()
325
+ except:
326
+ return response_text
327
+ else:
328
+ logger.error(f"Error: {response.status_code} - {response}")
329
+ return f"Error: {response.status_code} - {response}"
330
+
331
+ def task(self,
332
+ query="Extract key insights while maintaining coherence of the previous summaries.",
333
+ instructions="",
334
+ system="You are a helpfull assistant.",
335
+ response_format="**comprehensive, structured document covering all key insights**",
336
+ tasktype='User question',
337
+ context=None,
338
+ chunks={'type': 'words', 'size': None, 'n': None},
339
+ return_type='string',
340
+ ):
341
+ """
342
+ Analyze the large text in an iterative, coherent manner.
343
+ - Each chunk is processed while keeping track of previous summaries.
344
+ - After all chunks are processed, a final coherent text is made.
345
+ - The query can for example be to summarize the text or to extract key insights.
346
+
347
+ """
348
+ chunks = {**{'type': 'words', 'size': None, 'n': None}, **chunks}
349
+ if chunks['size'] is None: chunks['size'] = self.chunks['size']
350
+ if chunks['type'] is None: chunks['type'] = self.chunks['type']
351
+ if system is None:
352
+ logger.error('system can not be None. <return>')
353
+ return
354
+ if (context is None) and (not hasattr(self, 'text') or self.context is None):
355
+ logger.error('No input text found. Use context or <model.read_pdf("here comes your file path to the pdf")> first. <return>')
356
+ return
357
+
358
+ if context is None:
359
+ if isinstance(self.context, dict):
360
+ context = self.context['body'] + '\n---\n' + self.context['references']
361
+ else:
362
+ context = self.context
363
+
364
+ logger.info(f'Processing the document for the given task..')
365
+
366
+ # Create chunks based on words
367
+ chunks = utils.chunk_text(context, chunks['size'], method=chunks['type'])
368
+
369
+ # Build a structured prompt that includes all previous summaries
370
+ results_list = []
371
+ for i, chunk in enumerate(chunks):
372
+ logger.info(f'Working on text chunk {i}/{len(chunks)}')
373
+
374
+ # Keep last N summaries for context (this needs to be within the context-window otherwise it will return an error.)
375
+ previous_results = "\n---\n".join(results_list[-self.chunks['n']:])
376
+
377
+ prompt = (
378
+ "### Context:\n"
379
+ + (f"Previous results:\n{previous_results}\n" if len(results_list) > 0 else "")
380
+
381
+ + "\n---\nNew text chunk (Part of a larger document, maintain context):\n"
382
+ + f"{chunk}\n\n"
383
+
384
+ "### Instructions:\n"
385
+ + "- Extract key insights from the **new text chunk** while maintaining coherence with **Previous summaries**.\n"
386
+ + f"{instructions}\n\n"
387
+
388
+ f"### {tasktype}:\n"
389
+ f"{query}\n\n"
390
+
391
+ "### Improved Results:\n"
392
+ )
393
+
394
+ # Get the summary for the current chunk
395
+ chunk_result = self.query_llm(prompt, system=system)
396
+ results_list.append(f"Results {i+1}:\n" + chunk_result)
397
+
398
+ # Final summarization pass over all collected summaries
399
+ results_total = "\n---\n".join(results_list[-self.chunks['n']:])
400
+ final_prompt = f"""
401
+ ### Context:
402
+ {results_total}
403
+
404
+ ### Task:
405
+ Connect the result parts in context into a **coherent, well-structured document**.
406
+
407
+ ### Instructions:
408
+ - Maintain as much as possible the key insights but ensure logical flow.
409
+ - Connect insights smoothly while keeping essential details intact.
410
+ - Do **not** use any external knowledge or assumptions.
411
+
412
+ {response_format}
413
+
414
+ f"### {tasktype}:\n"
415
+ {query}\n\n
416
+
417
+ Begin your response below:
418
+ """
419
+ logger.info('Combining all information to create a single coherent output..')
420
+ # Create the final summary.
421
+ final_result = self.query_llm(final_prompt, system=system, return_type=return_type)
422
+ # Return
423
+ return final_result
424
+ # return {'summary': final_result, 'summary_per_chunk': results_total}
425
+
426
+
427
+ def query_llm(self, prompt, system=None, return_type='string'):
428
+ """Calls the LLM and returns the response."""
429
+ headers = {"Content-Type": "application/json"}
430
+ if system is None: system = "You are a helpful assistant."
431
+ messages = [{"role": "system", "content": system}, {"role": "user", "content": prompt}]
432
+ data = {"model": self.modelname, "messages": messages, "temperature": self.temperature, "top_p": self.top_p}
433
+
434
+ # Send POST request
435
+ response = self.requests_post(headers, data, stream=False, return_type=return_type)
436
+
437
+ # Return
438
+ return response
439
+
440
+ def global_reasoning(self, query, context):
441
+ """Global Reasoning.
442
+ 1. Rewrite the input user question into something like: "Based on the extracted summaries, does the document explain the societal relevance of the research? Justify your answer."
443
+ 2. Break the document into manageable chunks with overlapping parts to make sure we do not miss out.
444
+ 3. Create a global reasoning question based on the input user question.
445
+ 4. Take the summarized outputs and aggregate them.
446
+
447
+ prompt = "Is the proposal well thought out?"
448
+ instructions = "Your task is to rewrite questions for global reasoning. As an example, if there is a question like: 'Does this document section explain the societal relevance of the research?', the desired output would be: 'Does this document section explain the societal relevance of the research? If so, summarize it. If not, return 'No societal relevance found.''"
449
+ response = model.llm.run(query=prompt, instructions=instructions, tasktype='Task')
450
+ """
451
+ # Initialize model for question refinement and summarization
452
+ qmodel = LLMlight(modelname=self.modelname, temperature=0.7, endpoint=self.endpoint)
453
+
454
+ # 1. Rewrite user question in global reasoning question.
455
+ logger.info('Rewriting user question for deep reasoning..')
456
+ instructions = (f"In the context are chunks of text from a document. "
457
+ + " Rewrite the user question in such a way that relevant information can be captured by a Large language model for summarization for the chunks of text in the context."
458
+ + " Only return the new question with no other information."
459
+ )
460
+ # Create new query
461
+ new_query = qmodel.run(query=query, instructions=instructions, tasktype='Task')
462
+
463
+ # Create chunks with overlapping parts to make sure we do not miss out
464
+ chunks = utils.chunk_text(context, chunk_size=6000, method='chars', overlap=1000)
465
+
466
+ # Now summaries for the chunks
467
+ instructions = ("- Base your summary **strictly** on the provided text.\n"
468
+ +"- Do **not** use any external knowledge or assumptions.\n"
469
+ +"- If the answer is **explicitly available**, extract it exactly.\n"
470
+ +"- If the answer is not available, Return only: 'N/A'"
471
+ )
472
+
473
+ summaries = []
474
+ for chunk in chunks:
475
+ prompt = f"""Context:
476
+ {chunk}
477
+
478
+ Question:
479
+ {new_query}
480
+ """
481
+
482
+ response = qmodel.run(query=prompt, instructions=instructions, tasktype='Question')
483
+ summaries.append(response)
484
+
485
+ # Final summarization pass over all collected summaries
486
+ # Filter out "N/A" summaries
487
+ summaries = [s for s in summaries if s.strip() != "N/A"]
488
+ summaries_final = "\n\n---\n\n".join(summaries)
489
+ return summaries_final
490
+
491
+
492
+ def parse_large_document(self, query, context, top_chunks=3, chunk_size=512, return_type='string'):
493
+ """Splits large text into chunks and finds the most relevant ones."""
494
+ # Create chunks
495
+ chunks = utils.chunk_text(context, chunk_size=chunk_size, method='words')
496
+ # Embedding
497
+ query_vector, chunk_vectors = self.fit_transform(query, chunks)
498
+ # Compute similarity
499
+ similarities = cosine_similarity(query_vector, chunk_vectors)[0]
500
+ # Get top scoring chunks
501
+ if top_chunks is None: top_chunks = len(similarities)
502
+ top_indices = np.argsort(similarities)[-top_chunks:][::-1]
503
+
504
+ # Join relevant chunks and send as prompt
505
+ relevant_chunks = [chunks[i] for i in top_indices]
506
+ relevant_scores = [similarities[i] for i in top_indices]
507
+
508
+ # Set the return type
509
+ if return_type == 'score':
510
+ return list(zip(relevant_scores, relevant_chunks))
511
+ elif return_type == 'list':
512
+ return relevant_chunks
513
+ elif return_type == 'string_flat':
514
+ return " ".join(relevant_chunks)
515
+ else:
516
+ return "\n---------\n".join(relevant_chunks)
517
+
518
+ def fit_transform(self, query, chunks):
519
+ """Converts context chunks and query into vector space representations based on the selected embedding method."""
520
+ if self.embedding_method == 'tfidf':
521
+ vectorizer = TfidfVectorizer()
522
+ chunk_vectors = vectorizer.fit_transform(chunks)
523
+ # dense_matrix = chunk_vectors.toarray() # Converts to a NumPy array
524
+ query_vector = vectorizer.transform([query])
525
+ elif self.embedding_method == 'bow':
526
+ vectorizer = CountVectorizer()
527
+ chunk_vectors = vectorizer.fit_transform(chunks)
528
+ query_vector = vectorizer.transform([query])
529
+ # elif self.embedding_model is not None:
530
+ elif self.embedding_method == 'bert' or self.embedding_method == 'bge-small':
531
+ chunk_vectors = np.vstack([self.embedding_model.encode(chunk) for chunk in chunks])
532
+ query_vector = self.embedding_model.encode([query])
533
+ query_vector = query_vector.reshape(1, -1)
534
+ else:
535
+ raise ValueError("Unsupported embedding method. Choose a supported embedding method.")
536
+ return query_vector, chunk_vectors
537
+
538
+
539
+ def relevant_text_retrieval(self, query, context):
540
+ # Default
541
+ relevant_chunks = context
542
+
543
+ # Create advanced prompt using relevant chunks of text, the input query and instructions
544
+ if context is not None:
545
+ if self.retrieval_method == 'naive_RAG' and np.isin(self.embedding_method, ['tfidf', 'bow', 'bert', 'bge-small']):
546
+ # Find the best matching parts using simple retrieval_method approach.
547
+ logger.info(f'retrieval_method approach [{self.retrieval_method}] is applied with [{self.embedding_method}] embedding.')
548
+ relevant_chunks = self.parse_large_document(query, context, chunk_size=self.chunks['size'], top_chunks=self.chunks['n'], return_type='string')
549
+ elif self.retrieval_method == 'RSE' and np.isin(self.embedding_method, ['bert', 'bge-small']):
550
+ logger.info(f'RAG approach [{self.retrieval_method}] is applied.')
551
+ relevant_chunks = RAG_with_RSE(context, query, label=None, chunk_size=self.chunks['size'], irrelevant_chunk_penalty=0, embedding_method=self.embedding_method, device='cpu', batch_size=32)
552
+ else:
553
+ logger.info(f'The entire text will be used as context.')
554
+ # Return
555
+ return relevant_chunks
556
+
557
+ def set_prompt(self, query, instructions, response_format, context, tasktype):
558
+ # Default and update when context and instructions are available.
559
+ prompt = (
560
+ ("Context:\n" + context + "\n\n" if context else "")
561
+ + ("Instructions:\n" + instructions + "\n\n" if instructions else "")
562
+ + ("Response format:\n" + response_format + "\n\n" if response_format else "")
563
+ + f"{tasktype}:\n"
564
+ + query
565
+ )
566
+
567
+ # Return
568
+ return prompt
569
+
570
+ def read_pdf(self, filepath, title_pages=[1, 2], body_pages=[], reference_pages=[-1], return_type='dict'):
571
+ """
572
+ Reads a PDF file and extracts its text content as a string.
573
+
574
+ Args:
575
+ pdf_path (str): Path to the PDF file.
576
+
577
+ Returns:
578
+ str: Extracted text from the PDF.
579
+
580
+ """
581
+ if os.path.isfile(filepath):
582
+ self.context = utils.read_pdf(filepath, title_pages=title_pages, body_pages=body_pages, reference_pages=reference_pages, return_type=return_type)
583
+ if self.context is None:
584
+ logger.error('No input text gathered. <return>')
585
+ return
586
+ if return_type=='dict':
587
+ counts = utils.count_words(self.context['body'])
588
+ self.context['body'] = self.context['body'] + f"\n---\nThe exact word count in this document is: {counts}"
589
+ else:
590
+ logger.warning(f'{filepath} does not exist.')
591
+ self.context = None
592
+
593
+
594
+ def convert_prompt(messages, modelname='llama', add_assistant_start=True):
595
+ """
596
+ Builds a prompt in the appropriate format for different models (LLaMA, Grok, Mistral).
597
+
598
+ Args:
599
+ messages (list of dict): Each dict must have 'role' ('system', 'user', 'assistant') and 'content'.
600
+ modelname (str): The type of model to generate the prompt for ('llama', 'grok', or 'mistral').
601
+ add_assistant_start (bool): Whether to add the assistant start (default True).
602
+ add_bos_token (bool): Helps models know it's a fresh conversation. Useful for llama/mistral/hermes-style models
603
+
604
+ Returns:
605
+ str: The final prompt string in the correct format for the given model.
606
+
607
+ Example:
608
+ >>> messages = [
609
+ ... {"role": "system", "content": "You are a helpful assistant."},
610
+ ... {"role": "user", "content": "What is the capital of France?"}
611
+ ... ]
612
+ >>> prompt = convert_prompt(messages, modelname='llama')
613
+ >>> print(prompt)
614
+
615
+ """
616
+ prompt = ""
617
+
618
+ # if add_bos_token and ('llama' in modelname or 'mistral' in modelname):
619
+ # prompt += "<|begin_of_text|>\n"
620
+
621
+ for msg in messages:
622
+ role = msg["role"]
623
+ content = msg["content"].strip()
624
+
625
+ if 'llama' in modelname or 'mistral' in modelname:
626
+ prompt += f"<|im_start|>{role}\n{content}\n<|im_end|>\n"
627
+ elif 'grok' in modelname:
628
+ prompt += f"<start_of_turn>{role}\n{content}<end_of_turn>\n"
629
+ else:
630
+ # Default to ChatML format if model not recognized
631
+ prompt += f"<|im_start|>{role}\n{content}\n<|im_end|>\n"
632
+
633
+ if add_assistant_start:
634
+ if 'llama' in modelname or 'mistral' in modelname:
635
+ prompt += "<|im_start|>assistant\n"
636
+ elif 'grok' in modelname:
637
+ prompt += "<start_of_turn>assistant\n"
638
+
639
+ return prompt
640
+
641
+
642
+
643
+ def load_local_gguf_model(model_path: str, n_ctx: int=4096, n_threads: int=8, n_gpu_layers: int=0, verbose: bool=True) -> Llama:
644
+ """
645
+ Loads a local GGUF model using llama-cpp-python.
646
+
647
+ Args:
648
+ model_path (str): Path to the .gguf model file.
649
+ n_ctx (int): Maximum context length. Default is 4096.
650
+ n_threads (int): Number of CPU threads to use. Default is 8.
651
+ n_gpu_layers (int): Number of layers to offload to GPU (if available). Default is 20.
652
+ verbose (bool): Whether to print status info.
653
+
654
+ Returns:
655
+ Llama: The loaded Llama model object.
656
+
657
+ Example:
658
+ >>> model_path = r'C://Users//beeld//.lmstudio//models//NousResearch//Hermes-3-Llama-3.2-3B-GGUF//Hermes-3-Llama-3.2-3B.Q4_K_M.gguf'
659
+ >>> llm = load_local_gguf_model(model_path, verbose=True)
660
+ >>> prompt = "<start_of_turn>user\\nWhat is 2 + 2?\\n<end_of_turn>\\n<start_of_turn>model\\n"
661
+ >>> response = llm(prompt=prompt, max_tokens=20, stop=["<end_of_turn>"])
662
+ >>> print(response["choices"][0]["text"].strip())
663
+ '4'
664
+
665
+ """
666
+ if not os.path.exists(model_path):
667
+ raise FileNotFoundError(f"Model file not found at: {model_path}")
668
+
669
+ logger.info(f"Loading model from {model_path}")
670
+ logger.info(f"Context length: {n_ctx}, Threads: {n_threads}, GPU layers: {n_gpu_layers}")
671
+
672
+ llm = Llama(
673
+ model_path=model_path,
674
+ n_ctx=n_ctx,
675
+ n_threads=n_threads,
676
+ n_gpu_layers=n_gpu_layers,
677
+ verbose=verbose
678
+ )
679
+
680
+ logger.info("Model loaded successfully!")
681
+ # Return
682
+ return llm
683
+
684
+ def compute_max_tokens(string, n_ctx=4096):
685
+ tokenizer = AutoTokenizer.from_pretrained("gpt2")
686
+ # Tokenize the input string
687
+ tokens = tokenizer.encode(string, truncation=True, max_length=n_ctx)
688
+ # Get the number of tokens
689
+ return len(tokens)
690
+
691
+ def set_system_message(system):
692
+ return "You are a helpful assistant." if system is None else system
693
+
694
+
695
+
696
+ # %%
697
+ def convert_verbose_to_new(verbose):
698
+ """Convert old verbosity to the new."""
699
+ # In case the new verbosity is used, convert to the old one.
700
+ if verbose is None: verbose=0
701
+ if not isinstance(verbose, str) and verbose<10:
702
+ status_map = {
703
+ 'None': 'silent',
704
+ 0: 'silent',
705
+ 6: 'silent',
706
+ 1: 'critical',
707
+ 2: 'warning',
708
+ 3: 'info',
709
+ 4: 'debug',
710
+ 5: 'debug'}
711
+ if verbose>=2: print('[LLMlight] WARNING use the standardized verbose status. The status [1-6] will be deprecated in future versions.')
712
+ return status_map.get(verbose, 0)
713
+ else:
714
+ return verbose
715
+
716
+ def get_logger():
717
+ return logger.getEffectiveLevel()
718
+
719
+
720
+ def set_logger(verbose: [str, int] = 'info'):
721
+ """Set the logger for verbosity messages.
722
+
723
+ Parameters
724
+ ----------
725
+ verbose : [str, int], default is 'info' or 20
726
+ Set the verbose messages using string or integer values.
727
+ * [0, 60, None, 'silent', 'off', 'no']: No message.
728
+ * [10, 'debug']: Messages from debug level and higher.
729
+ * [20, 'info']: Messages from info level and higher.
730
+ * [30, 'warning']: Messages from warning level and higher.
731
+ * [50, 'critical', 'error']: Messages from critical level and higher.
732
+
733
+ Returns
734
+ -------
735
+ None.
736
+
737
+ > # Set the logger to warning
738
+ > set_logger(verbose='warning')
739
+ > # Test with different messages
740
+ > logger.debug("Hello debug")
741
+ > logger.info("Hello info")
742
+ > logger.warning("Hello warning")
743
+ > logger.critical("Hello critical")
744
+
745
+ """
746
+ # Convert verbose to new
747
+ verbose = convert_verbose_to_new(verbose)
748
+ # Set 0 and None as no messages.
749
+ if (verbose==0) or (verbose is None):
750
+ verbose=60
751
+ # Convert str to levels
752
+ if isinstance(verbose, str):
753
+ levels = {'silent': 60,
754
+ 'off': 60,
755
+ 'no': 60,
756
+ 'debug': 10,
757
+ 'info': 20,
758
+ 'warning': 30,
759
+ 'error': 50,
760
+ 'critical': 50}
761
+ verbose = levels[verbose]
762
+
763
+ # Configure root logger if no handlers exist
764
+ # if not logger.handlers:
765
+ # handler = logging.StreamHandler()
766
+ # fmt = '[{asctime}] [{name}] [{levelname}] {msg}'
767
+ # formatter = logging.Formatter(fmt=fmt, style='{', datefmt='%d-%m-%Y %H:%M:%S')
768
+ # handler.setFormatter(formatter)
769
+ # logger.addHandler(handler)
770
+
771
+ # Set the level
772
+ logger.setLevel(verbose)
773
+
774
+
775
+ def disable_tqdm():
776
+ """Set the logger for verbosity messages."""
777
+ return (True if (logger.getEffectiveLevel()>=30) else False)
778
+
779
+
780
+ def check_logger(verbose: [str, int] = 'info'):
781
+ """Check the logger."""
782
+ set_logger(verbose)
783
+ logger.debug('DEBUG')
784
+ logger.info('INFO')
785
+ logger.warning('WARNING')
786
+ logger.critical('CRITICAL')