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 +786 -0
- LLMlight/RAG.py +441 -0
- LLMlight/__init__.py +37 -0
- LLMlight/examples.py +175 -0
- LLMlight/utils.py +150 -0
- llmlight-0.1.0.dist-info/METADATA +177 -0
- llmlight-0.1.0.dist-info/RECORD +10 -0
- llmlight-0.1.0.dist-info/WHEEL +5 -0
- llmlight-0.1.0.dist-info/licenses/LICENSE +21 -0
- llmlight-0.1.0.dist-info/top_level.txt +1 -0
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')
|