LLMlight 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -0,0 +1,1059 @@
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
+ import re
19
+ from tqdm import tqdm
20
+
21
+ from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
22
+ from sklearn.metrics.pairwise import cosine_similarity
23
+ from sentence_transformers import SentenceTransformer
24
+
25
+ from .RAG import RAG_with_RSE
26
+ from . import utils
27
+ # DEBUG
28
+ # import utils
29
+ # from RAG import RAG_with_RSE
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # %%
34
+ class LLMlight:
35
+ """Large Language Model Light.
36
+
37
+ Run your LLM models local and with minimum dependencies.
38
+ 1. Go to LM-studio.
39
+ 2. Go to left panel and select developers mode.
40
+ 3. On top select your model of interest.
41
+ 4. Then go to settings in the top bar.
42
+ 5. Enable "server on local network" if you need.
43
+ 6. Enable Running.
44
+
45
+ Parameters
46
+ ----------
47
+ modelname : str
48
+ 'hermes-3-llama-3.2-3b'
49
+ 'mistral-7b-grok'
50
+ 'openhermes-2.5-mistral-7b'
51
+ 'gemma-2-9b-it'
52
+ system : str
53
+ String of the system message.
54
+ "I am a helpfull assistant"
55
+ preprocessing : str
56
+ None: No pre-processing is performed. The original context is used in the pipeline of method, embedding and the response.
57
+ 'chunk-wise': In case you have a very large document. The text will be analyze chunkwise based on the query, instructions and system. The total set of answered-chunks is then returned. The normal pipeline proceeds for the query, instructions, system etc.
58
+ 'global-reasoning': In case you have a very large document. The text will be summarized per chunk globally. The total set of summarized context is then returned. The normal pipeline proceeds for the query, instructions, system etc.
59
+ method : str
60
+ None: No processing is performed. The entire context is used for the query.
61
+ 'naive_RAG': Ideal for chats and when you need to answer specfic questions: Chunk of text are created. Use cosine similarity to for ranking. The top scoring chunks will be combined (n chunks) and used as input with the prompt.
62
+ 'RSE': Identify and extract entire segments of relevant text.
63
+ embedding : str
64
+ None
65
+ 'tfidf': Best use when it is a structured documents and the words in the queries are matching.
66
+ 'bow': Bag of words approach. Best use when you expect words in the document and queries to be matching.
67
+ 'bert': Best use when document is more free text and the queries may not match exactly the words or sentences in the document.
68
+ 'bge-small':
69
+ temperature : float, optional
70
+ Sampling temperature (default is 0.7).
71
+ top_p : float, optional
72
+ Top-p (nucleus) sampling parameter (default is 1.0, no filtering).
73
+ chunks: dict : {'method': 'chars', 'size': 1000, 'overlap': 250, 'top_chunks': 5}
74
+ type : str
75
+ 'chars' or 'words': Chunks are created using chars or words.
76
+ 'size': Chunk length in chars or words.
77
+ The accuracy increases with smaller chunk sizes. But it also reduces the input context for the LLM.
78
+ Estimates: 1000 words or ~10.000 chars costs ~3000 tokens.
79
+ With a context window (n_ctx) of 4096 your can set size=1000 words with n chunks=5 and leave some space for instructions, system and the query.
80
+ 'overlap': overlap between chunks
81
+ 'top_chunks': Retrieval of the top N chunks when performing RAG analysis.
82
+ endpoint : str
83
+ Endpoint of the LLM API
84
+ "http://localhost:1234/v1/chat/completions"
85
+ './models/Hermes-3-Llama-3.2-3B.Q4_K_M.gguf'
86
+ r'C:/Users/username/.lmstudio/models/lmstudio-community/gemma-2-9b-it-GGUF/gemma-2-9b-it-Q4_K_M.gguf'
87
+ n_ctx : int, default: 4096
88
+ The context window length is determined by the max tokens. A larger number of tokens will ask more cpu/gpu resources. Estimates: 1000 words or ~10.000 chars costs ~3000 tokens.
89
+
90
+ Examples
91
+ --------
92
+ >>> model = LLMlight()
93
+ >>> model.prompt('hello, who are you?')
94
+ >>> system_message = "You are a helpful assistant."
95
+ >>> response = model.prompt('What is the capital of France?', system=system_message, top_p=0.9)
96
+
97
+ """
98
+ def __init__(self,
99
+ modelname="hermes-3-llama-3.2-3b",
100
+ preprocessing=None,
101
+ method='naive_RAG',
102
+ embedding='bert',
103
+ temperature=0.7,
104
+ top_p=1.0,
105
+ chunks={'method': 'chars', 'size': 1000, 'overlap': 250, 'top_chunks': 5},
106
+ endpoint="http://localhost:1234/v1/chat/completions",
107
+ n_ctx=4096,
108
+ verbose='info',
109
+ ):
110
+
111
+ # Set the logger
112
+ set_logger(verbose)
113
+ # Store data in self
114
+ self.modelname = modelname
115
+ self.preprocessing = preprocessing
116
+ self.method = method
117
+ self.embedding = embedding
118
+ self.temperature = temperature
119
+ self.top_p = top_p
120
+ self.endpoint = endpoint
121
+ if chunks is None: chunks = {}
122
+ self.chunks = {**{'method': 'chars', 'size': 1000, 'overlap': 250, 'top_chunks': 5}, **chunks}
123
+ self.n_ctx = n_ctx
124
+ self.context = None
125
+
126
+ # Set the correct name for the model.
127
+ if embedding == 'bert':
128
+ self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
129
+ elif embedding == 'bge-small':
130
+ self.embedding_model = SentenceTransformer('BAAI/bge-small-en')
131
+ else:
132
+ self.embedding_model = None
133
+ # Load local model
134
+ if os.path.isfile(self.endpoint):
135
+ self.llm = load_local_gguf_model(self.endpoint, n_ctx=self.n_ctx)
136
+
137
+ def check_logger(self):
138
+ """Check the verbosity."""
139
+ logger.debug('DEBUG')
140
+ logger.info('INFO')
141
+ logger.warning('WARNING')
142
+ logger.critical('CRITICAL')
143
+
144
+ def get_available_models(self, validate=False):
145
+ # Set your local API base URL
146
+ # base_url = "http://localhost:1234/v1"
147
+ base_url = self.endpoint.split("/chat")[0]
148
+ logger.info('Collecting models in the API endpoint..')
149
+
150
+ # Query available models
151
+ response = requests.get(f"{base_url}/models")
152
+ model_dict = {}
153
+
154
+ try:
155
+ response = requests.get("http://localhost:1234/v1/models", timeout=10)
156
+ if response.status_code == 200:
157
+ try:
158
+ models = response.json()["data"]
159
+ model_dict = {model["id"]: model for model in models}
160
+ except (KeyError, ValueError) as e:
161
+ logger.error("Error parsing model data:", e)
162
+ else:
163
+ logger.warning("Request failed with status code:", response.status_code)
164
+ logger.warning("Response:", response.text)
165
+
166
+ except requests.exceptions.RequestException as e:
167
+ logger.error("Request error:", e)
168
+
169
+ # Check each model whether it returns a response
170
+ if validate:
171
+ logger.info("Validating models:")
172
+ keys = copy.deepcopy(list(model_dict.keys()))
173
+
174
+ for key in keys:
175
+ from LLMlight import LLMlight
176
+ llm = LLMlight(modelname=key)
177
+ response = llm.prompt('What is the capital of France?', system="You are a helpful assistant.", return_type='string')
178
+ response = response[0:30].replace('\n', ' ').replace('\r', ' ').lower()
179
+ if 'error: 404' in response:
180
+ logger.error(f"{llm.modelname}: {response}")
181
+ model_dict.pop(key)
182
+ else:
183
+ logger.info(f"{llm.modelname}: {response}")
184
+
185
+ return list(model_dict.keys())
186
+
187
+ def prompt(self,
188
+ query,
189
+ instructions=None,
190
+ system=None,
191
+ context=None,
192
+ response_format=None,
193
+ temperature=None,
194
+ top_p=None,
195
+ stream=False,
196
+ return_type='string',
197
+ ):
198
+ """
199
+ Run the model with the provided parameters.
200
+ The final prompt is created based on the query, instructions, and the context
201
+
202
+ Parameters
203
+ ----------
204
+ query : str
205
+ The question or query.
206
+ "What is the capital for France?"
207
+ context : str
208
+ Large text string that will be chunked, and embedded. The answer for the query is based on the chunks.
209
+ instructions : str
210
+ Set your instructions.
211
+ "Answer the question strictly based on the provided context."
212
+ system : str, optional
213
+ Optional system message to set context for the AI (default is None).
214
+ "You are helpfull assistant."
215
+ temperature : float, optional
216
+ Sampling temperature (default is 0.7).
217
+ top_p : float, optional
218
+ Top-p (nucleus) sampling parameter (default is 1.0, no filtering).
219
+ stream : bool, optional
220
+ Whether to enable streaming (default is False).
221
+ return_type: bool, optional
222
+ Return dictionary in case the output is a json
223
+ 'full': Output the full json
224
+ 'dict': Convert json into dictionary.
225
+ 'string': Return only the string answer (remove thinking strings using tags: <think> </think>).
226
+ 'string_with_thinking' Return the full response which includes the thinking proces (if available).
227
+
228
+ Returns
229
+ -------
230
+ str
231
+ The model's response or an error message if the request fails.
232
+ """
233
+ logger.info(f'{self.modelname} is loaded..')
234
+ headers = {"Content-Type": "application/json"}
235
+
236
+ if temperature is None: temperature = self.temperature
237
+ if top_p is None: top_p = self.top_p
238
+ if context is None: context = self.context
239
+ # if embedding is not None: self.embedding = embedding
240
+ if isinstance(context, dict): context = '\n\n'.join(context.values())
241
+
242
+ # task (str): The use case for generation. (default: 'full')
243
+ # 'summarization'
244
+ # 'chat'
245
+ # 'code'
246
+ # 'longform'
247
+ # 'full'
248
+ # task = set_task(self.preprocessing, self.method)
249
+ self.task = 'full'
250
+
251
+ # Set system message
252
+ system = set_system_message(system)
253
+ # Preprocessing on the context
254
+ processed_context = self.compute_preprocessing(query, context, instructions, system)
255
+ # Extract relevant text using retrieval method
256
+ relevant_context = self.relevant_text_retrieval(query, processed_context, instructions, system)
257
+ # Set the prompt
258
+ prompt = self.set_prompt(query, instructions, relevant_context, response_format=response_format)
259
+
260
+ # Run model
261
+ if os.path.isfile(self.endpoint):
262
+ # Run LLM from gguf model
263
+ response = self.requests_post_gguf(prompt, system, temperature=temperature, top_p=top_p, headers=headers, task=self.task, stream=stream, return_type=return_type)
264
+ else:
265
+ # Run LLM with http model
266
+ response = self.requests_post_http(prompt, system, temperature=temperature, top_p=top_p, headers=headers, task=self.task, stream=stream, return_type=return_type)
267
+ # Return
268
+ return response
269
+
270
+ def requests_post_gguf(self, prompt, system, temperature=0.8, top_p=1, headers=None, task='full', stream=False, return_type='string'):
271
+ # 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.
272
+ # Prepare data for request.
273
+ if headers is None: headers = {"Content-Type": "application/json"}
274
+ # Prepare messages
275
+ messages = [{"role": "system", "content": system}, {"role": "user", "content": prompt}]
276
+ # Convert messages to string prompt
277
+ prompt = convert_prompt(messages, modelname=self.modelname)
278
+ # Compute tokens
279
+ used_tokens, max_tokens = compute_tokens(prompt, n_ctx=self.n_ctx, task=task)
280
+
281
+ # Send post request to local GGUF model
282
+ response = self.llm(
283
+ prompt=prompt,
284
+ temperature=temperature,
285
+ top_p=top_p,
286
+ stream=stream,
287
+ max_tokens=max_tokens,
288
+ stop=["<end_of_turn>", "<|im_end|>"] # common stop tokens for chat formats
289
+ )
290
+
291
+ # Take only the output
292
+ if 'string' in return_type:
293
+ response = response.get('choices', [{}])[0].get('text', "No response")
294
+ if return_type == 'string':
295
+ # Remove thinking
296
+ response = re.sub(r'<think>.*?</think>', '', response, flags=re.DOTALL).strip()
297
+ # Return
298
+ return response
299
+
300
+ def requests_post_http(self, prompt, system, temperature=0.8, top_p=1, headers=None, task='full', stream=False, return_type='string'):
301
+ # Prepare data for request.
302
+ if headers is None: headers = {"Content-Type": "application/json"}
303
+ # Prepare messages
304
+ messages = [{"role": "system", "content": system}, {"role": "user", "content": prompt}]
305
+ # Create full prompt
306
+ prompt = messages[0]['content'] + messages[1]['content']
307
+ # Compute tokens
308
+ used_tokens, max_tokens = compute_tokens(prompt, n_ctx=self.n_ctx, task=task)
309
+ logger.info(f'Running {self.modelname} with max tokens: {max_tokens}')
310
+
311
+ data = {
312
+ "model": self.modelname,
313
+ "messages": messages,
314
+ "temperature": temperature,
315
+ "top_p": top_p,
316
+ "stream": stream,
317
+ "max_tokens": max_tokens,
318
+ }
319
+
320
+ # Send POST request
321
+ response = self.requests_post(headers, data, stream=stream, return_type=return_type)
322
+
323
+ # Return
324
+ return response
325
+
326
+ def requests_post(self, headers, data, stream=False, return_type='string'):
327
+ """Create the request to the LLM."""
328
+ # Get response
329
+ response = requests.post(self.endpoint, headers=headers, json=data, stream=stream)
330
+
331
+ # Handle the response
332
+ if response.status_code == 200:
333
+ try:
334
+ # Create dictionary in case json
335
+ response_text = response.json().get('choices', [{}])[0].get('message', {}).get('content', "No response")
336
+
337
+ if return_type == 'dict':
338
+ response_text = utils.is_valid_json(response_text)
339
+ return response_text
340
+ elif return_type == 'string_with_thinking':
341
+ return response_text
342
+ elif return_type == 'string':
343
+ response_text = re.sub(r'<think>.*?</think>', '', response_text, flags=re.DOTALL).strip()
344
+ return response_text
345
+ else:
346
+ return response.json()
347
+ except:
348
+ return response
349
+ else:
350
+ logger.error(f"{response.status_code} - {response}")
351
+ return f"Error: {response.status_code} - {response}"
352
+
353
+ def task(self,
354
+ query="Extract key insights while maintaining coherence of the previous summaries.",
355
+ instructions="",
356
+ system="You are a helpfull assistant.",
357
+ response_format="**comprehensive, structured document covering all key insights**",
358
+ task='question',
359
+ context=None,
360
+ return_type='string',
361
+ ):
362
+ """
363
+ Analyze the large text in an iterative, coherent manner.
364
+ - Each chunk is processed while keeping track of previous summaries.
365
+ - After all chunks are processed, a final coherent text is made.
366
+ - The query can for example be to summarize the text or to extract key insights.
367
+
368
+ """
369
+ if system is None:
370
+ logger.error('system can not be None. <return>')
371
+ return
372
+ if (context is None) and (not hasattr(self, 'text') or self.context is None):
373
+ logger.error('No input text found. Use context or <model.read_pdf("here comes your file path to the pdf")> first. <return>')
374
+ return
375
+
376
+ if context is None:
377
+ if isinstance(self.context, dict):
378
+ context = self.context['body'] + '\n---\n' + self.context['references']
379
+ else:
380
+ context = self.context
381
+
382
+ logger.info(f'Processing the document for the given task..')
383
+
384
+ # Create chunks based on words
385
+ chunks = utils.chunk_text(context, method=self.chunks['method'], chunk_size=self.chunks['size'], overlap=self.chunks['overlap'])
386
+
387
+ # Build a structured prompt that includes all previous summaries
388
+ response_list = []
389
+ for i, chunk in enumerate(chunks):
390
+ logger.info(f'Working on text chunk {i}/{len(chunks)}')
391
+
392
+ # Keep last N summaries for context (this needs to be within the context-window otherwise it will return an error.)
393
+ previous_results = "\n---\n".join(response_list[-self.chunks['top_chunks']:])
394
+
395
+ prompt = (
396
+ "### Context:\n"
397
+ + (f"Previous results:\n{previous_results}\n" if len(response_list) > 0 else "")
398
+
399
+ + "\n---\nNew text chunk (Part of a larger document, maintain context):\n"
400
+ + f"{chunk}\n\n"
401
+
402
+ "### Instructions:\n"
403
+ + "- Extract key insights from the **new text chunk** while maintaining coherence with **Previous summaries**.\n"
404
+ + f"{instructions}\n\n"
405
+
406
+ f"### {task}:\n"
407
+ f"{query}\n\n"
408
+
409
+ "### Improved Results:\n"
410
+ )
411
+
412
+ # Get the summary for the current chunk
413
+ # chunk_result = self.query_llm(prompt, system=system)
414
+ chunk_result= self.requests_post_http(prompt, system, temperature=self.temperature, top_p=self.top_p, task='full', stream=False, return_type='string')
415
+
416
+ response_list.append(f"Results {i+1}:\n" + chunk_result)
417
+
418
+ # Final summarization pass over all collected summaries
419
+ results_total = "\n---\n".join(response_list[-self.chunks['top_chunks']:])
420
+ final_prompt = f"""
421
+ ### Context:
422
+ {results_total}
423
+
424
+ ### Task:
425
+ Connect the result parts in context into a **coherent, well-structured document**.
426
+
427
+ ### Instructions:
428
+ - Maintain as much as possible the key insights but ensure logical flow.
429
+ - Connect insights smoothly while keeping essential details intact.
430
+
431
+ {response_format}
432
+
433
+ f"### {task}:\n"
434
+ {query}\n\n
435
+
436
+ Begin your response below:
437
+ """
438
+ logger.info('Combining all information to create a single coherent output..')
439
+ # Create the final summary.
440
+ # final_result = self.query_llm(final_prompt, system=system, return_type=return_type)
441
+ final_result = self.requests_post_http(final_prompt, system, temperature=self.temperature, top_p=self.top_p, task='full', stream=False, return_type=return_type)
442
+ # Return
443
+ return final_result
444
+ # return {'summary': final_result, 'summary_per_chunk': results_total}
445
+
446
+ # def query_llm(self, prompt, system=None, task='full', stream=False, return_type='string'):
447
+ # """Calls the LLM and returns the response."""
448
+ # # Set defaults
449
+ # headers = {"Content-Type": "application/json"}
450
+ # # System
451
+ # if system is None: system = "You are a helpful assistant."
452
+ # # Messages
453
+ # messages = [{"role": "system", "content": system}, {"role": "user", "content": prompt}]
454
+ # # Compute tokens
455
+ # used_tokens, max_tokens = compute_tokens(prompt, n_ctx=self.n_ctx, task=task)
456
+
457
+ # data = {
458
+ # "model": self.modelname,
459
+ # "messages": messages,
460
+ # "temperature": self.temperature,
461
+ # "top_p": self.top_p,
462
+ # "stream": stream,
463
+ # "max_tokens": max_tokens,
464
+ # }
465
+
466
+ # # Send POST request
467
+ # response = self.requests_post(headers, data, return_type=return_type)
468
+
469
+ # # Return
470
+ # return response
471
+
472
+ def global_reasoning(self, query, context, instructions, system, return_per_chunk=False, rewrite_query=False, stream=False):
473
+ """Global Reasoning.
474
+ 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."
475
+ 2. Break the document into manageable chunks with overlapping parts to make sure we do not miss out.
476
+ 3. Create a global reasoning question based on the input user question.
477
+ 4. Take the summarized outputs and aggregate them.
478
+
479
+ prompt = "Is the proposal well thought out?"
480
+ 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.''"
481
+ response = model.llm.prompt(query=prompt, instructions=instructions, task='Task')
482
+
483
+ """
484
+
485
+ if rewrite_query:
486
+ # 1. Rewrite user question in global reasoning question.
487
+ logger.info('Rewriting user question for global reasoning..')
488
+ instructions = """In the context are chunks of text from a document.
489
+ 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.
490
+ Only return the new question with no other information.
491
+ """
492
+ # Initialize model for question refinement and summarization
493
+ qmodel = LLMlight(modelname=self.modelname, temperature=0.7, endpoint=self.endpoint)
494
+ # Create new query
495
+ new_query = qmodel.prompt(query=query, instructions=instructions)
496
+ else:
497
+ new_query = query
498
+
499
+ # Create chunks with overlapping parts to make sure we do not miss out
500
+ chunks = utils.chunk_text(context, method=self.chunks['method'], chunk_size=self.chunks['size'], overlap=self.chunks['overlap'])
501
+
502
+ # Now summaries for the chunks
503
+ summaries = []
504
+ for i, chunk in enumerate(tqdm(chunks, desc="Processing chunks", unit="chunk")):
505
+ logger.info(f'Working on text chunk {i+1}/{len(chunks)}')
506
+
507
+ prompt = f"""
508
+ ### Context (Chunk {i+1} of {len(chunks)} from a larger document):
509
+ {chunk}
510
+
511
+ ### Instructions:
512
+ You are an expert summarizer. For the given chunk of text:
513
+ - Extract all **key points, decisions, facts, and actions**.
514
+ - Ensure your analysis captures important ideas, implications, or patterns.
515
+ - Preserve the **logical flow** and **chronological order**.
516
+ - **Avoid repetition** or superficial statements.
517
+ - Focus on **explicit and implicit information** that could be relevant in the full document.
518
+ - Keep the summary **clear, precise**, and suitable for combining with other chunk summaries later.
519
+
520
+ ### User Task:
521
+ Summarize this chunk comprehensively and professionally.
522
+ {query}
523
+
524
+ """
525
+
526
+ # Summarize
527
+ response = self.requests_post_http(prompt, system, temperature=self.temperature, top_p=self.top_p, task='summarization', stream=stream, return_type='string')
528
+ # Append
529
+ summaries.append(response)
530
+ # Show
531
+ logger.debug(response)
532
+
533
+ # Filter out "N/A" summaries
534
+ summaries = [s for s in summaries if s.strip() != "N/A" and not any(err in s.strip()[:30] for err in ("400", "404"))]
535
+ # Final summarization pass over all collected summaries
536
+ summaries_final = "\n\n---\n\n".join([f"### Summary {i+1}:\n{s}" for i, s in enumerate(summaries)])
537
+ # Return
538
+ if return_per_chunk:
539
+ return summaries_final
540
+
541
+ # Create final prompt
542
+ prompt_final = f"""### Context:
543
+ Below are the individual summaries generated from multiple sequential chunks of a larger document. They are presented in order:
544
+ {summaries_final}
545
+
546
+ ---
547
+
548
+ ### Instructions:
549
+ {instructions}
550
+
551
+ ### User Task:
552
+ You are an expert editor. Your goal is to synthesize the above summaries into **one complete, well-structured, and logically coherent document**. Ensure:
553
+ - Smooth transitions between sections.
554
+ - Elimination of redundancies and overlaps.
555
+ - Consistent tone, clarity, and structure.
556
+ - That all essential information from the summaries is preserved.
557
+ - The final result aligns with the given instructions.
558
+
559
+ Produce the final, polished document below:
560
+ """
561
+
562
+ system_summaries = (
563
+ "You are a helpful and detail-oriented assistant. "
564
+ "Your task is to compile and structure summaries into a single coherent and well-formatted document. "
565
+ "Follow all instructions precisely."
566
+ "Preserve important details, maintain logical flow, and respect any formatting requirements, such as using headings or bullet points when relevant.",
567
+ "Output the final results in the same language as the instructions.",
568
+ )
569
+
570
+ final_response = self.requests_post_http(prompt_final, system_summaries, temperature=self.temperature, top_p=self.top_p, task='summarization', stream=False, return_type='string')
571
+
572
+ # Return
573
+ return final_response
574
+
575
+ def chunk_wise(self, query, context, instructions, system, top_chunks=0, return_per_chunk=False, stream=False):
576
+ """Chunk-wise.
577
+ 1. Break the document into chunks with overlapping parts to make sure we do not miss out.
578
+ 2. Include the last two results in the prompt as context.
579
+ 3. Analyze each chunk seperately following the instructions and system messages and jointly with the last 2 results.
580
+
581
+ """
582
+ # Create chunks with overlapping parts to make sure we do not miss out
583
+ chunks = utils.chunk_text(context, method=self.chunks['method'], chunk_size=self.chunks['size'], overlap=self.chunks['overlap'])
584
+
585
+ # Build a structured prompt that includes all previous summaries
586
+ response_list = []
587
+ for i, chunk in enumerate(tqdm(chunks, desc="Processing chunks", unit="chunk")):
588
+ logger.info(f'Working on text chunk {i+1}/{len(chunks)}')
589
+
590
+ if top_chunks > 0:
591
+ previous_results = '\n\n---\n\n'.join(response_list[-top_chunks:])
592
+ prompt = f"""### Context:
593
+ Previous Results:\n{previous_results}" if response_list else "Previous Results: No results because this is the initial chunk."
594
+
595
+ ---
596
+ New Text Chunk (Part of a larger document, maintain continuity and coherence):
597
+ {chunk}
598
+
599
+ ### Instructions:
600
+ - Apply the instructions to the new chunk **in the context** of the previous results.
601
+ - Preserve logical structure and clarity.
602
+ - Maintain coherence and avoid repetition with prior content.
603
+ - Focus on extracting structured and relevant information.
604
+
605
+ {instructions}
606
+
607
+ ### User Question:
608
+ {query}
609
+
610
+ ### Final Improved Results:
611
+ """
612
+ else:
613
+ prompt = f"""
614
+ ### Context (Chunk {i+1} of {len(chunks)} — part of a larger document):
615
+ {chunk}
616
+
617
+ ---
618
+
619
+ ### Instructions:
620
+ Carefully analyze the above chunk in isolation while considering that it is part of a broader document. Apply the following instructions to this specific chunk:
621
+ {instructions}
622
+ - Avoid repetition and irrelevant details.
623
+ - Be clear and concise so this output can later be integrated with others.
624
+
625
+ ---
626
+
627
+ ### User Question:
628
+ {query}
629
+
630
+ ---
631
+
632
+ ### Output:
633
+ Provide your detailed, coherent analysis of this chunk below.
634
+ """
635
+ # Get the summary for the current chunk
636
+ chunk_result = self.requests_post_http(prompt, system, temperature=self.temperature, top_p=self.top_p, task='summarization', stream=stream, return_type='string')
637
+ response_list.append(chunk_result)
638
+
639
+ # Filter out "N/A" summaries
640
+ response_list = [s for s in response_list if s.strip() != "N/A" and not any(err in s.strip()[:30] for err in ("400", "404"))]
641
+ # Combine all results
642
+ response_total = "\n\n---\n\n".join([f"### Chunk {i+1}:\n{s}" for i, s in enumerate(response_list)])
643
+ # Return all chunk information
644
+ if return_per_chunk:
645
+ return response_total
646
+
647
+ if top_chunks > 0:
648
+ prompt_final = f"""### Context (results based on {len(chunks)} chunk of text):
649
+ {response_total}
650
+
651
+ ---
652
+
653
+ ### Task:
654
+ The context that is given to you contains the output of {len(chunks)} seperate text chunks.
655
+ Your task is to connect all the parts and make one output that is **coherent** and well-structured.
656
+
657
+ ### Instructions:
658
+ - Maintain as much as possible the key insights but ensure logical flow.
659
+ - Connect insights smoothly while keeping essential details intact.
660
+ - If repetitions are detected across the parts, combine it.
661
+ {instructions}
662
+
663
+ Begin your response below:
664
+ """
665
+
666
+ system_chunk_analysis = """
667
+ You are a meticulous and structured AI assistant that performs detailed analyses of long documents, broken into smaller chunks.
668
+ Your task is to analyze each chunk individually and extract relevant insights, observations, or structured responses based on specific user instructions.
669
+
670
+ - Always follow the given instructions precisely.
671
+ - If formatting is implied (e.g., headers, lists, bullet points), apply it clearly.
672
+ - Do not add summaries or conclusions beyond the current chunk.
673
+ - Avoid introducing outside knowledge or assumptions beyond what is present in the text.
674
+ - Your analysis should be standalone, yet written clearly enough to be compiled later with other parts.
675
+ """
676
+
677
+ logger.info('Combining all information to create a single coherent output.')
678
+ # Create the final summary.
679
+ final_response = self.requests_post_http(prompt_final, system_chunk_analysis, temperature=self.temperature, top_p=self.top_p, task='summarization', stream=False, return_type='string')
680
+ else:
681
+ prompt_final = f"""### Context:
682
+ {response_total}
683
+
684
+ ### Task:
685
+ Given to you is a text that is compiled after analyzing multiple seperate chunks of text.
686
+ Your task is to restructure the text so that it complies with the instructions.
687
+
688
+ ### Instructions:
689
+ - Maintain as much as possible the key insights but ensure logical flow.
690
+ - Connect insights smoothly while keeping essential details intact.
691
+ - If repetitions are detected across the parts, combine it.
692
+ - If there are vagues expressions, rewrite it to improve the quality.
693
+ {instructions}
694
+
695
+ Begin your response below:
696
+ """
697
+
698
+ system = "You are a helpfull assistant specialized in combining multiple results that belong together. You are permitted to make assumptions if it improves the results."
699
+ # Create the final summary.
700
+ final_response = self.requests_post_http(prompt_final, system, temperature=self.temperature, top_p=self.top_p, task='summarization', stream=False, return_type='string')
701
+
702
+ # Return
703
+ return final_response
704
+ # return {'response': final_response, 'response_per_chunk': response_total}
705
+
706
+ def parse_large_document(self, query, context, return_type='string'):
707
+ """Splits large text into chunks and finds the most relevant ones."""
708
+ # Create chunks
709
+ chunks = utils.chunk_text(context, method=self.chunks['method'], chunk_size=self.chunks['size'], overlap=self.chunks['overlap'])
710
+ # Embedding
711
+ query_vector, chunk_vectors = self.fit_transform(query, chunks)
712
+ # Compute similarity
713
+ similarities = cosine_similarity(query_vector, chunk_vectors)[0]
714
+ # Get top scoring chunks
715
+ if self.chunks['top_chunks'] is None: top_chunks = len(similarities)
716
+ top_indices = np.argsort(similarities)[-top_chunks:][::-1]
717
+
718
+ # Join relevant chunks and send as prompt
719
+ relevant_chunks = [chunks[i] for i in top_indices]
720
+ relevant_scores = [similarities[i] for i in top_indices]
721
+
722
+ # Set the return type
723
+ if return_type == 'score':
724
+ return list(zip(relevant_scores, relevant_chunks))
725
+ elif return_type == 'list':
726
+ return relevant_chunks
727
+ elif return_type == 'string_flat':
728
+ return " ".join(relevant_chunks)
729
+ else:
730
+ return "\n---------\n".join(relevant_chunks)
731
+
732
+ def fit_transform(self, query, chunks):
733
+ """Converts context chunks and query into vector space representations based on the selected embedding method."""
734
+ if self.embedding == 'tfidf':
735
+ vectorizer = TfidfVectorizer()
736
+ chunk_vectors = vectorizer.fit_transform(chunks)
737
+ # dense_matrix = chunk_vectors.toarray() # Converts to a NumPy array
738
+ query_vector = vectorizer.transform([query])
739
+ elif self.embedding == 'bow':
740
+ vectorizer = CountVectorizer()
741
+ chunk_vectors = vectorizer.fit_transform(chunks)
742
+ query_vector = vectorizer.transform([query])
743
+ # elif self.embedding_model is not None:
744
+ elif self.embedding == 'bert' or self.embedding == 'bge-small':
745
+ chunk_vectors = np.vstack([self.embedding_model.encode(chunk) for chunk in chunks])
746
+ query_vector = self.embedding_model.encode([query])
747
+ query_vector = query_vector.reshape(1, -1)
748
+ else:
749
+ raise ValueError("Unsupported embedding method. Choose a supported embedding method.")
750
+ return query_vector, chunk_vectors
751
+
752
+ def compute_preprocessing(self, query, context, instructions, system):
753
+ # Create advanced prompt using relevant chunks of text, the input query and instructions
754
+ if context is not None:
755
+ if self.preprocessing=='global-reasoning':
756
+ # Global Reasoning
757
+ relevant_context = self.global_reasoning(query, context, instructions, system, rewrite_query=False, return_per_chunk=True)
758
+ elif self.preprocessing=='chunk-wise':
759
+ # Analyze per chunk
760
+ relevant_context = self.chunk_wise(query, context, instructions, system, top_chunks=0, return_per_chunk=True)
761
+ else:
762
+ logger.info(f'No method is applied: The entire context is used.')
763
+ relevant_context = context
764
+ else:
765
+ # Default
766
+ relevant_context = context
767
+
768
+ # Return
769
+ return relevant_context
770
+
771
+ def relevant_text_retrieval(self, query, context, instructions, system):
772
+ # Create advanced prompt using relevant chunks of text, the input query and instructions
773
+ if context is not None:
774
+ if self.method == 'naive_RAG' and np.isin(self.embedding, ['tfidf', 'bow', 'bert', 'bge-small']):
775
+ # Find the best matching parts using simple retrieval method approach.
776
+ logger.info(f'[{self.method}] approach is applied with [{self.embedding}] embedding.')
777
+ relevant_context = self.parse_large_document(query, context, return_type='string')
778
+ elif self.method == 'RSE' and np.isin(self.embedding, ['bert', 'bge-small']):
779
+ logger.info(f'RAG approach [{self.method}] is applied.')
780
+ relevant_context = RAG_with_RSE(context, query, label=None, chunk_size=self.chunks['size'], irrelevant_chunk_penalty=0, embedding=self.embedding, device='cpu', batch_size=32)
781
+ else:
782
+ logger.info(f'No method is applied: The entire context is used.')
783
+ relevant_context = context
784
+ else:
785
+ # Default
786
+ relevant_context = context
787
+
788
+ # Return
789
+ return relevant_context
790
+
791
+ def set_prompt(self, query, instructions, context, response_format=''):
792
+ # Default and update when context and instructions are available.
793
+ prompt = (
794
+ ("Context:\n" + context + "\n\n" if context else "")
795
+ + (f"Instructions:\n{instructions}\n\n" if instructions != '' else "")
796
+ + (f"Response format:\n{response_format}\n\n" if (response_format != '') and (response_format is not None) else "")
797
+ + f"User question:\n"
798
+ + query
799
+ )
800
+
801
+ # Return
802
+ return prompt
803
+
804
+ def read_pdf(self, filepath, title_pages=[1, 2], body_pages=[], reference_pages=[-1], return_type='dict'):
805
+ """
806
+ Reads a PDF file and extracts its text content as a string.
807
+
808
+ Args:
809
+ pdf_path (str): Path to the PDF file.
810
+
811
+ Returns:
812
+ str: Extracted text from the PDF.
813
+
814
+ """
815
+ if os.path.isfile(filepath):
816
+ self.context = utils.read_pdf(filepath, title_pages=title_pages, body_pages=body_pages, reference_pages=reference_pages, return_type=return_type)
817
+ if self.context is None:
818
+ logger.error('No input text gathered. <return>')
819
+ return
820
+ if return_type=='dict':
821
+ counts = utils.count_words(self.context['body'])
822
+ self.context['body'] = self.context['body'] + f"\n---\nThe exact word count in this document is: {counts}"
823
+ else:
824
+ logger.warning(f'{filepath} does not exist.')
825
+ self.context = None
826
+
827
+
828
+ def convert_prompt(messages, modelname='llama', add_assistant_start=True):
829
+ """
830
+ Builds a prompt in the appropriate format for different models (LLaMA, Grok, Mistral).
831
+
832
+ Args:
833
+ messages (list of dict): Each dict must have 'role' ('system', 'user', 'assistant') and 'content'.
834
+ modelname (str): The type of model to generate the prompt for ('llama', 'grok', or 'mistral').
835
+ add_assistant_start (bool): Whether to add the assistant start (default True).
836
+ add_bos_token (bool): Helps models know it's a fresh conversation. Useful for llama/mistral/hermes-style models
837
+
838
+ Returns:
839
+ str: The final prompt string in the correct format for the given model.
840
+
841
+ Example:
842
+ >>> messages = [
843
+ ... {"role": "system", "content": "You are a helpful assistant."},
844
+ ... {"role": "user", "content": "What is the capital of France?"}
845
+ ... ]
846
+ >>> prompt = convert_prompt(messages, modelname='llama')
847
+ >>> print(prompt)
848
+
849
+ """
850
+ prompt = ""
851
+
852
+ # if add_bos_token and ('llama' in modelname or 'mistral' in modelname):
853
+ # prompt += "<|begin_of_text|>\n"
854
+
855
+ for msg in messages:
856
+ role = msg["role"]
857
+ content = msg["content"].strip()
858
+
859
+ if 'llama' in modelname or 'mistral' in modelname:
860
+ prompt += f"<|im_start|>{role}\n{content}\n<|im_end|>\n"
861
+ elif 'grok' in modelname:
862
+ prompt += f"<start_of_turn>{role}\n{content}<end_of_turn>\n"
863
+ else:
864
+ # Default to ChatML format if model not recognized
865
+ prompt += f"<|im_start|>{role}\n{content}\n<|im_end|>\n"
866
+
867
+ if add_assistant_start:
868
+ if 'llama' in modelname or 'mistral' in modelname:
869
+ prompt += "<|im_start|>assistant\n"
870
+ elif 'grok' in modelname:
871
+ prompt += "<start_of_turn>assistant\n"
872
+
873
+ return prompt
874
+
875
+
876
+
877
+ 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:
878
+ """
879
+ Loads a local GGUF model using llama-cpp-python.
880
+
881
+ Args:
882
+ model_path (str): Path to the .gguf model file.
883
+ n_ctx (int): Maximum context length. Default is 4096.
884
+ n_threads (int): Number of CPU threads to use. Default is 8.
885
+ n_gpu_layers (int): Number of layers to offload to GPU (if available). Default is 20.
886
+ verbose (bool): Whether to print status info.
887
+
888
+ Returns:
889
+ Llama: The loaded Llama model object.
890
+
891
+ Example:
892
+ >>> 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'
893
+ >>> llm = load_local_gguf_model(model_path, verbose=True)
894
+ >>> prompt = "<start_of_turn>user\\nWhat is 2 + 2?\\n<end_of_turn>\\n<start_of_turn>model\\n"
895
+ >>> response = llm(prompt=prompt, max_tokens=20, stop=["<end_of_turn>"])
896
+ >>> print(response["choices"][0]["text"].strip())
897
+ '4'
898
+
899
+ """
900
+ if not os.path.exists(model_path):
901
+ raise FileNotFoundError(f"Model file not found at: {model_path}")
902
+
903
+ logger.info(f"Loading model from {model_path}")
904
+ logger.info(f"Context length: {n_ctx}, Threads: {n_threads}, GPU layers: {n_gpu_layers}")
905
+
906
+ llm = Llama(
907
+ model_path=model_path,
908
+ n_ctx=n_ctx,
909
+ n_threads=n_threads,
910
+ n_gpu_layers=n_gpu_layers,
911
+ verbose=verbose
912
+ )
913
+
914
+ logger.info("Model loaded successfully!")
915
+ # Return
916
+ return llm
917
+
918
+ def compute_tokens(string, n_ctx=4096, task='full'):
919
+ tokenizer = AutoTokenizer.from_pretrained("gpt2")
920
+ # Tokenize the input string
921
+ tokens = tokenizer.encode(string, truncation=True, max_length=n_ctx)
922
+ # Get the number of tokens
923
+ used_tokens = len(tokens)
924
+ # Determine how many tokens are available for the model to generate
925
+ max_tokens = compute_max_tokens(used_tokens, n_ctx=n_ctx, task=task)
926
+ logger.info(f"Used_tokens={used_tokens}, max_tokens={max_tokens}, context_limit={n_ctx}")
927
+ return used_tokens, max_tokens
928
+
929
+
930
+ def compute_max_tokens(used_tokens, n_ctx=4096, task="full"):
931
+ """
932
+ Compute the number of tokens that can be generated based on the task type.
933
+
934
+ Parameters:
935
+ - used_tokens (int): Tokens already used in the input prompt.
936
+ - n_ctx (int): Total context window size of the model.
937
+ - task (str): The use case for generation. Options:
938
+ 'summarization', 'chat', 'code', 'longform', 'full'
939
+
940
+ Returns:
941
+ - max_tokens (int): Tokens allowed for generation.
942
+ """
943
+
944
+ available_tokens = max(n_ctx - used_tokens, 1) # Ensure at least 1
945
+
946
+ task = task.lower()
947
+ if task == "summarization":
948
+ max_tokens = max(min(available_tokens, int(n_ctx * 0.5)), 128)
949
+ elif task == "chat":
950
+ max_tokens = max(min(available_tokens, int(n_ctx * 0.6)), 128)
951
+ elif task == "code":
952
+ max_tokens = max(min(available_tokens, int(n_ctx * 0.75)), 128)
953
+ elif task == "longform":
954
+ max_tokens = max(min(available_tokens, int(n_ctx * 0.9)), 256)
955
+ elif task == "full":
956
+ max_tokens = available_tokens
957
+ else:
958
+ # Default to safe fallback
959
+ max_tokens = max(min(available_tokens, int(n_ctx * 0.5)), 128)
960
+
961
+ return max_tokens
962
+
963
+
964
+ def set_system_message(system):
965
+ return "You are a helpful assistant." if system is None else system
966
+
967
+
968
+
969
+ # %%
970
+ def convert_verbose_to_new(verbose):
971
+ """Convert old verbosity to the new."""
972
+ # In case the new verbosity is used, convert to the old one.
973
+ if verbose is None: verbose=0
974
+ if not isinstance(verbose, str) and verbose<10:
975
+ status_map = {
976
+ 'None': 'silent',
977
+ 0: 'silent',
978
+ 6: 'silent',
979
+ 1: 'critical',
980
+ 2: 'warning',
981
+ 3: 'info',
982
+ 4: 'debug',
983
+ 5: 'debug'}
984
+ if verbose>=2: print('[LLMlight] WARNING use the standardized verbose status. The status [1-6] will be deprecated in future versions.')
985
+ return status_map.get(verbose, 0)
986
+ else:
987
+ return verbose
988
+
989
+ def get_logger():
990
+ return logger.getEffectiveLevel()
991
+
992
+
993
+ def set_logger(verbose: [str, int] = 'info'):
994
+ """Set the logger for verbosity messages.
995
+
996
+ Parameters
997
+ ----------
998
+ verbose : [str, int], default is 'info' or 20
999
+ Set the verbose messages using string or integer values.
1000
+ * [0, 60, None, 'silent', 'off', 'no']: No message.
1001
+ * [10, 'debug']: Messages from debug level and higher.
1002
+ * [20, 'info']: Messages from info level and higher.
1003
+ * [30, 'warning']: Messages from warning level and higher.
1004
+ * [50, 'critical', 'error']: Messages from critical level and higher.
1005
+
1006
+ Returns
1007
+ -------
1008
+ None.
1009
+
1010
+ > # Set the logger to warning
1011
+ > set_logger(verbose='warning')
1012
+ > # Test with different messages
1013
+ > logger.debug("Hello debug")
1014
+ > logger.info("Hello info")
1015
+ > logger.warning("Hello warning")
1016
+ > logger.critical("Hello critical")
1017
+
1018
+ """
1019
+ # Convert verbose to new
1020
+ verbose = convert_verbose_to_new(verbose)
1021
+ # Set 0 and None as no messages.
1022
+ if (verbose==0) or (verbose is None):
1023
+ verbose=60
1024
+ # Convert str to levels
1025
+ if isinstance(verbose, str):
1026
+ levels = {'silent': 60,
1027
+ 'off': 60,
1028
+ 'no': 60,
1029
+ 'debug': 10,
1030
+ 'info': 20,
1031
+ 'warning': 30,
1032
+ 'error': 50,
1033
+ 'critical': 50}
1034
+ verbose = levels[verbose]
1035
+
1036
+ # Configure root logger if no handlers exist
1037
+ # if not logger.handlers:
1038
+ # handler = logging.StreamHandler()
1039
+ # fmt = '[{asctime}] [{name}] [{levelname}] {msg}'
1040
+ # formatter = logging.Formatter(fmt=fmt, style='{', datefmt='%d-%m-%Y %H:%M:%S')
1041
+ # handler.setFormatter(formatter)
1042
+ # logger.addHandler(handler)
1043
+
1044
+ # Set the level
1045
+ logger.setLevel(verbose)
1046
+
1047
+
1048
+ def disable_tqdm():
1049
+ """Set the logger for verbosity messages."""
1050
+ return (True if (logger.getEffectiveLevel()>=30) else False)
1051
+
1052
+
1053
+ def check_logger(verbose: [str, int] = 'info'):
1054
+ """Check the logger."""
1055
+ set_logger(verbose)
1056
+ logger.debug('DEBUG')
1057
+ logger.info('INFO')
1058
+ logger.warning('WARNING')
1059
+ logger.critical('CRITICAL')