File size: 12,546 Bytes
f07e656
 
 
 
 
 
c89cad9
f07e656
 
 
 
c89cad9
243d8e9
f07e656
 
 
c89cad9
f07e656
 
c89cad9
 
 
f07e656
243d8e9
 
f826db5
 
 
 
 
243d8e9
 
f07e656
 
243d8e9
f826db5
 
 
c89cad9
 
 
243d8e9
16d4be5
f826db5
16d4be5
56fe6c5
16d4be5
 
 
 
 
 
 
 
 
 
ef757cd
16d4be5
 
 
 
 
 
 
 
ef757cd
16d4be5
5a20ea0
16d4be5
 
 
ef757cd
16d4be5
 
 
 
 
 
 
ef757cd
16d4be5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdfcaa
16d4be5
 
 
 
 
 
 
 
 
243d8e9
 
f826db5
c89cad9
 
f826db5
243d8e9
c89cad9
243d8e9
 
f07e656
243d8e9
 
270fbd3
 
c89cad9
 
 
f826db5
ef757cd
f826db5
 
 
243d8e9
c89cad9
 
 
243d8e9
 
f07e656
243d8e9
 
 
c89cad9
243d8e9
 
c89cad9
243d8e9
 
9810128
243d8e9
c89cad9
270fbd3
243d8e9
c89cad9
 
f07e656
243d8e9
e12b808
 
 
 
c89cad9
e12b808
 
 
56fe6c5
f07e656
e12b808
c89cad9
e12b808
 
c89cad9
 
e12b808
c89cad9
e12b808
 
c89cad9
 
 
e12b808
 
f07e656
c89cad9
243d8e9
c89cad9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243d8e9
 
c89cad9
 
243d8e9
c89cad9
243d8e9
 
c89cad9
243d8e9
 
f07e656
 
243d8e9
 
f07e656
c89cad9
243d8e9
 
c89cad9
 
243d8e9
 
 
c89cad9
 
 
243d8e9
 
c89cad9
 
 
 
 
 
 
 
243d8e9
 
f07e656
c89cad9
f07e656
 
 
 
243d8e9
c89cad9
243d8e9
c89cad9
f07e656
 
c89cad9
 
 
 
 
 
 
 
 
 
243d8e9
c89cad9
f07e656
c89cad9
243d8e9
c89cad9
 
 
 
 
 
 
 
243d8e9
f07e656
c89cad9
243d8e9
f07e656
c89cad9
f07e656
 
c89cad9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
import re
import numpy as np
from typing import Dict, List, Optional, Tuple
import json
from collections import defaultdict
import spacy
from transformers import pipeline
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import hashlib
from datetime import datetime
import concurrent.futures
import random

class AetherScoreEvaluator:
    def __init__(self):
        # NLP models
        try:
            self.nlp = spacy.load("en_core_web_sm")
        except OSError:
            print("Downloading 'en_core_web_sm' spacy model...")
            spacy.cli.download("en_core_web_sm")
            self.nlp = spacy.load("en_core_web_sm")

        # LLM Judge Model
        self.judge_model = pipeline(
            "text2text-generation", 
            model="google/flan-t5-base", 
            device=-1  # 0 for GPU 
        )

        # Sentence Transformer for Sentence ----> Embedding
        self.sentence_model = SentenceTransformer('all-MiniLM-L6-v2')
        
        # Scoring weights # Domain Specific weights can be added for better results
        self.weights = {'instruction_following': 0.25, 'hallucination_score': 0.20,
                        'assumption_control': 0.20, 'coherence': 0.20, 'accuracy': 0.15}
        
        # In-memory cache
        self.cache = {}

    # LLM Judge
    # def _evaluate_with_llm_judge(self, prompt: str, response: str) -> Dict:
        
    #     print("Using HF model LLM Judge...")
    
    #     query = (
    #         f"Prompt: {prompt}\n"
    #         f"Response: {response}\n\n"
    #         "Return ONLY a valid JSON object in this exact format:\n"
    #         "{\n"
    #         "  \"hallucination_score\": <float between 0 and 1>,\n"
    #         "  \"assumption_control\": <float between 0 and 1>,\n"
    #         "  \"explanation\": \"<one or two sentences>\"\n"
    #         "}"
    #     )
    
    #     try:
    #         result = self.judge_model(query, max_new_tokens=128, truncation=True)
    #         output = result[0]['generated_text']
    #     except Exception as e:
    #         return {
    #             "hallucination_score": (0.1, f"HF model failed: {e}"),
    #             "assumption_control": (0.1, f"HF model failed: {e}")
    #         }
    
    #     # Default values
        
    #     halluc_score = random.uniform(0.3, 0.7)
    #     assumption_score = random.uniform(0.3, 0.7)
    #     explanation = output
    
    #     try:
    #         parsed = json.loads(output.strip())
    #         halluc_score = float(parsed.get("hallucination_score", halluc_score))
    #         assumption_score = float(parsed.get("assumption_control", assumption_score))
    #         explanation = parsed.get("explanation", explanation)
    #     except Exception:
    #         pass  # fallback to defaults
    
    #     return {
    #         "hallucination_score": (halluc_score, explanation),
    #         "assumption_control": (assumption_score, explanation)
    #     }

    import random
    import json
    
    def _evaluate_with_llm_judge(self, prompt: str, response: str) -> Dict:
        
        print("Using rule-based evaluation instead of HF LLM...")

        prompt_words = set(prompt.lower().split())
        response_words = response.lower().split()
        
        # Hallucination score: fraction of words in response that are not in prompt
        if response_words:
            halluc_score = len([w for w in response_words if w not in prompt_words]) / len(response_words)
        else:
            halluc_score = 0.1
        
        # Assumption control: fraction of sentences starting with uncertain words
        uncertain_starts = ("i assume", "maybe", "probably", "likely", "could be")
        sentences = response.lower().split(".")
        if sentences:
            assumption_score = sum(0.3 for s in sentences if s.strip().startswith(uncertain_starts)) / len(sentences)
        else:
            assumption_score = 0.1
        
        # Ensure scores are between 0 and 1
        # halluc_score = max(0, min(1, halluc_score))
        # assumption_score = max(0, min(1, assumption_score))
        
        explanation = "Rule-based evaluation applied."
        
        return {
            "hallucination_score": (halluc_score, explanation),
            "assumption_control": (assumption_score, explanation)
        }


    # Single Evaluation # Inputs-->> Prompt, Agent Response, Expected Answer(Optional), Agent Name and Task type( General, QA, Summarizaton)etc 
    def evaluate_single(self, prompt: str, response: str, expected_answer: Optional[str] = None, task_type: str = "general") -> Dict:
        
        # Generating Eval ID
        eval_id = self._generate_eval_id(prompt, response)
        
        # If already stored in cache direclty we can return from there.
        if eval_id in self.cache:
            return self.cache[eval_id]

        scores, reasons = {}, {}

        # Taking Back scores and reasons of hallucination and assumption control from LLM Judge
        llm_judge_results = self._evaluate_with_llm_judge(prompt, response)
        scores['hallucination_score'], reasons['hallucination_score'] = llm_judge_results['hallucination_score']
        scores['assumption_control'], reasons['assumption_control'] = llm_judge_results['assumption_control']

        # Evaluating Instruction Following, Coherence and Accuracy
        scores['instruction_following'], reasons['instruction_following'] = self._evaluate_instruction_following(prompt, response)
        scores['coherence'], reasons['coherence'] = self._evaluate_coherence(response)
        scores['accuracy'], reasons['accuracy'] = self._evaluate_accuracy(response, expected_answer, task_type) if expected_answer else (0.5, "No expected answer provided.")

        # Calculating Overall Score
        scores['overall_score'] = self._calculate_overall_score(scores)
        reasons['overall_score'] = f" Weighted Average Score based on component scores."

        # Updating Eval ID, Timestamp and Task Type in Scores
        scores.update({'eval_id': eval_id, 'timestamp': datetime.now().isoformat(), 'task_type': task_type})

        # Updating scores(Eval ID, timestamp and task_type) and reasons(all scores) in result
        result = {"scores": scores, "reasons": reasons}

        #Storing results with corresponding Eval ID in cache
        # self.cache[eval_id] = result
        
        return result
        
    # Batch Evaluation # Input of JSON/CSV file
    def evaluate_batch(self, data: List[Dict], mode: str = "comprehensive") -> List[Dict]:
        """Process a batch of evaluations in parallel."""
        
        results = []

        # Get Item function
        def process_item(item):
            # Calling our Evalution function for Single prompt response pair
            eval_result = self.evaluate_single(
                prompt=item.get('prompt', ''),
                response=item.get('response', ''),
                expected_answer=item.get('expected_answer',''),
                task_type=item.get('task_type', 'general')
            )
            # Combining with original metadata
            eval_result.update({
                'task_id': item.get('task_id', eval_result['scores']['eval_id']),
                'agent_name': item.get('agent_name', 'Unknown'),
            })
            return eval_result
        
        with concurrent.futures.ThreadPoolExecutor() as executor:
            future_to_item = {executor.submit(process_item, item): item for item in data}
            for future in concurrent.futures.as_completed(future_to_item):
                try:
                    results.append(future.result())
                except Exception as exc:
                    print(f'An item generated an exception: {exc}')

        return results

    # Instruction Following Evaluation (Prompt, Response)
    def _evaluate_instruction_following(self, prompt: str, response: str) -> Tuple[float, str]:
        score, checks, passed = 1.0, 0, 0
        
        # Check for negative constraints
        negations = re.findall(r"(don't|do not|avoid|without) ([\w\s,]+)", prompt.lower())
        for _, constraint_phrase in negations:
            checks += 1
            words_to_avoid = [w.strip() for w in constraint_phrase.split(',')]
            if not any(word in response.lower() for word in words_to_avoid if len(word) > 2):
                passed += 1
        
        # Fallback to semantic similarity if no specific instructions found
        if checks == 0:
            sim = self._semantic_similarity(prompt, response)
            return sim, f"No specific constraints found. Score based on semantic similarity ({sim:.2f}) to prompt."

        # Final Score calculation
        score = passed / checks if checks > 0 else 1.0
        reason = f"{passed}/{checks} specific constraints were followed."
        
        return score, reason

    # Evaluating Coherence (response)
    def _evaluate_coherence(self, response: str) -> Tuple[float, str]:

        # Extracting Sentences from Response
        doc = self.nlp(response)
        sentences = [sent.text for sent in doc.sents]
        
        # If only one Sentence then Coherence is Neutral
        if len(sentences) < 2:
            return 0.7, "Coherence is neutral for single-sentence responses."

        # Fetching Embeddings from our Sentence Model
        embeddings = self.sentence_model.encode(sentences)
        sims = [cosine_similarity([embeddings[i]], [embeddings[i+1]])[0][0] for i in range(len(sentences)-1)]
        
        score = np.mean(sims)
        
        reason = f"Average sentence-to-sentence similarity score is {score:.2f} across {len(sentences)} sentences."
        return score, reason

        
    # Evaluating Accuracy (Response, Expected, Task_type)
    def _evaluate_accuracy(self, response: str, expected: str, task_type: str) -> Tuple[float, str]:
        sim = self._semantic_similarity(response, expected)
        reason = f"Semantic similarity between response and expected answer is {sim:.2f}."
        if sim > 0.95:
            reason += " (High match)"
        elif sim < 0.5:
            reason += " (Low match)"
        return sim, reason
        
    # Overall Score
    def _calculate_overall_score(self, scores: Dict) -> float:
        total, weight_sum = 0.0, 0.0
        for metric, weight in self.weights.items():
            if metric in scores:
                total += scores[metric] * weight
                weight_sum += weight
        return total / weight_sum #if weight_sum > 0 else 0.5

    # Explanation Generator, work in progress
    def generate_explanation(self, scores: Dict) -> str:
        explanation = []
        overall = scores.get('overall_score', 0)
        explanation.append(f"Overall Score: {overall:.2f}/1.00 - Reflects a weighted average of all dimensions.")

        if scores.get('instruction_following', 0) < 0.6:
            explanation.append("⚠️ Low Instruction Following: The response may have ignored key constraints or parts of the prompt.")
        if scores.get('hallucination_score', 0) < 0.6:
            explanation.append("⚠️ Potential Hallucination: The response might contain unverified or fabricated information.")
        if scores.get('accuracy', 0) < 0.6 and scores.get('accuracy', 0.5) != 0.5:
            explanation.append("⚠️ Low Accuracy: The response significantly differs from the provided expected answer.")
        
        if not explanation[1:]:
             explanation.append("✅ Great Performance: The agent performed well across the primary evaluation dimensions.")

        return "\n".join(explanation)

    # Agent Scores 
    def get_agent_scores_from_results(self, results: List[Dict]) -> Dict[str, List[float]]:
        agent_scores = defaultdict(list)
        for result in results:
            agent_name = result.get('agent_name', 'Unknown')
            overall_score = result.get('scores', {}).get('overall_score', 0)
            agent_scores[agent_name].append(overall_score)
        return agent_scores

    # Helper Functions
    def _generate_eval_id(self, prompt: str, response: str) -> str:
        return hashlib.md5(f"{prompt}{response}".encode()).hexdigest()[:12]
    
    def _semantic_similarity(self, text1: str, text2: str) -> float:
        if not text1 or not text2: return 0.0
        emb1 = self.sentence_model.encode([text1])
        emb2 = self.sentence_model.encode([text2])
        return cosine_similarity(emb1, emb2)[0][0]