File size: 7,642 Bytes
78fc1e6
 
d02b074
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78fc1e6
 
 
 
d02b074
78fc1e6
d02b074
 
 
 
78fc1e6
 
 
 
d02b074
 
 
 
 
 
 
 
 
 
 
 
 
 
78fc1e6
d02b074
c9853ac
5405e38
ce15608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9853ac
ce15608
5405e38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9853ac
5405e38
c9853ac
5405e38
 
 
c9853ac
 
d02b074
 
 
 
 
 
 
 
 
 
 
 
78fc1e6
 
 
d02b074
81deeef
 
 
 
 
78fc1e6
d02b074
 
78fc1e6
 
 
 
 
d02b074
 
 
 
 
 
 
 
 
78fc1e6
d02b074
 
78fc1e6
 
 
 
 
 
d02b074
 
 
 
 
 
 
 
78fc1e6
 
 
d02b074
 
 
 
 
 
 
 
 
78fc1e6
d02b074
 
78fc1e6
d02b074
 
 
 
 
 
78fc1e6
 
d02b074
 
 
 
 
 
 
 
 
 
 
78fc1e6
 
 
 
 
d02b074
78fc1e6
d02b074
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
"""
eval/run_ragas.py
DeepEval FaithfulnessMetric with Mistral 7B as local judge.

RAGAS 0.1.21 is incompatible with llama-cpp-python's synchronous Llama object
(requires agenerate_prompt async method). We use DeepEval's FaithfulnessMetric
instead, which supports any custom LLM via DeepEvalBaseLLM and runs synchronously
when async_mode=False.

DeepEval FaithfulnessMetric definition (identical to RAGAS):
  1. LLM extracts all factual claims from the generated response
  2. LLM checks each claim against retrieved context — truthful if not contradicted
  3. Score = number of truthful claims / total claims

Mistral 7B Instruct Q4_K_M was confirmed to produce valid JSON for claim extraction
(tested before implementation). No JSON confinement library required.

Citation: DeepEval FaithfulnessMetric — https://deepeval.com/docs/metrics-faithfulness
"""

import sys, json
sys.path.insert(0, "src")
sys.path.insert(0, ".")

from deepeval.models.base_model import DeepEvalBaseLLM
from deepeval.metrics import FaithfulnessMetric
from deepeval.test_case import LLMTestCase
from deepeval import evaluate as deepeval_evaluate
from pipeline.pipeline import EmpathRAGPipeline

PROMPTS_PATH = "eval/test_prompts.json"
RESULTS_PATH = "eval/ragas_results.json"
N_EVAL       = 40


class MistralJudge(DeepEvalBaseLLM):
    """
    Wraps llama-cpp-python's Llama instance as a DeepEvalBaseLLM judge.
    Mistral 7B Instruct Q4_K_M confirmed to produce valid JSON for
    claim extraction prompts (tested independently).
    """
    def __init__(self, llm):
        self._llm = llm

    def load_model(self):
        return self._llm

    def generate(self, prompt: str) -> str:
        from llama_cpp import LlamaGrammar
        import json as _json
        # DeepEval FaithfulnessMetric makes 3 types of calls:
        # 1. Extract truths from retrieval_context → {"truths": ["...", "..."]}
        # 2. Extract claims from actual_output → {"claims": ["...", "..."]}
        # 3. Verify claims against truths → {"verdicts": [{"verdict": "yes/no", "reason": "..."}]}
        # Detect which call type from prompt keywords and use appropriate schema.
        prompt_lower = prompt.lower()
        if '"truths"' in prompt_lower or 'truths key' in prompt_lower:
            # Call 1: extract truths from context
            schema = _json.dumps({
                "type": "object",
                "properties": {"truths": {"type": "array", "items": {"type": "string"}}}
            })
        elif '"claims"' in prompt_lower or 'claims' in prompt_lower:
            # Call 2: extract claims from output
            schema = _json.dumps({
                "type": "object",
                "properties": {"claims": {"type": "array", "items": {"type": "string"}}}
            })
        else:
            # Call 3: verify claims (verdicts)
            schema = _json.dumps({
                "type": "object",
                "properties": {
                    "verdicts": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "verdict": {"type": "string", "enum": ["yes", "no", "idk"]},
                                "reason": {"type": "string"}
                            }
                        }
                    }
                }
            })
        try:
            grammar = LlamaGrammar.from_json_schema(schema, verbose=False)
            out = self._llm(prompt, max_tokens=1024, temperature=0.0, grammar=grammar, stop=["[INST]"])
        except Exception as e:
            # Fallback: no grammar if schema compilation fails
            print(f"[MistralJudge] Grammar fallback: {e}")
            out = self._llm(prompt, max_tokens=1024, temperature=0.0, stop=["[INST]"])
        return out["choices"][0]["text"].strip()

    async def a_generate(self, prompt: str) -> str:
        # DeepEval calls a_generate when async_mode=True.
        # We set async_mode=False so this should never be called,
        # but implement it as a synchronous fallback for safety.
        return self.generate(prompt)

    def get_model_name(self) -> str:
        return "Mistral-7B-Instruct-v0.2-Q4_K_M (local)"


def run_faithfulness_eval():
    with open(PROMPTS_PATH) as f:
        prompts = json.load(f)

    print("Initialising pipeline (use_real_guardrail=False for speed)...")
    pipeline = EmpathRAGPipeline(
        use_real_guardrail=False,
        allow_stub_guardrail=True,
        guardrail_threshold=0.5,
    )

    # Monkey-patch guardrail to skip IG (no-op since stub is active, but kept for
    # consistency with other eval scripts in case real guardrail is swapped in)
    original_check = pipeline.guardrail.check
    def fast_check(text, threshold=0.5, skip_ig=False):
        return original_check(text, threshold=threshold, skip_ig=True)
    pipeline.guardrail.check = fast_check

    print("Wrapping Mistral as DeepEval judge (async_mode=False)...")
    judge = MistralJudge(pipeline.llm)
    metric = FaithfulnessMetric(
        model=judge,
        threshold=0.5,
        async_mode=False,       # synchronous — avoids agenerate_prompt error
        include_reason=False,   # speeds up evaluation — we only need the score
        verbose_mode=False,
    )

    test_cases  = []
    count       = 0

    print(f"Collecting pipeline outputs (target: {N_EVAL} non-crisis prompts)...")
    for prompt in prompts:
        if count >= N_EVAL:
            break
        result = pipeline.run(prompt["text"])
        if result["crisis"] or not result["retrieved_chunks"]:
            continue

        test_cases.append(LLMTestCase(
            input=prompt["text"],
            actual_output=result["response"],
            retrieval_context=result["retrieved_chunks"],
        ))
        count += 1
        print(f"  [{count:02d}/{N_EVAL}] {prompt['emotion']:<12} | {prompt['text'][:50]}...")

    print(f"\nRunning DeepEval FaithfulnessMetric on {len(test_cases)} test cases...")
    print("(Each case: Mistral extracts claims, then verifies each against context)")

    scores = []
    for i, tc in enumerate(test_cases):
        metric.measure(tc)
        score = metric.score
        scores.append(score)
        print(f"  [{i+1:02d}/{len(test_cases)}] faithfulness={score:.3f}")

    mean_score = sum(scores) / len(scores) if scores else 0.0
    passed     = mean_score >= 0.5   # DeepEval default threshold is 0.5

    print(f"\nFaithfulness Results (DeepEval FaithfulnessMetric):")
    print(f"  Mean faithfulness: {mean_score:.4f}")
    print(f"  Threshold:         0.5 (DeepEval default)")
    print(f"  Target for paper:  > 0.65")
    print(f"  {'PASS' if mean_score >= 0.65 else 'BELOW 0.65 TARGET' if mean_score >= 0.5 else 'BELOW THRESHOLD'}")
    print(f"  n_evaluated:       {len(scores)}")

    output = {
        "method":        "DeepEval FaithfulnessMetric (Mistral-7B judge, async_mode=False)",
        "faithfulness":  round(mean_score, 4),
        "target":        0.65,
        "pass":          mean_score >= 0.65,
        "n_evaluated":   len(scores),
        "per_sample":    [round(s, 4) for s in scores],
        "score_distribution": {
            "min":    round(min(scores), 4) if scores else None,
            "max":    round(max(scores), 4) if scores else None,
            "median": round(sorted(scores)[len(scores)//2], 4) if scores else None,
        }
    }
    with open(RESULTS_PATH, "w") as f:
        json.dump(output, f, indent=2)
    print(f"Results saved to {RESULTS_PATH}")


if __name__ == "__main__":
    run_faithfulness_eval()