chrisjcc commited on
Commit
154a24e
Β·
1 Parent(s): b87b596

Introduce Evaluation Pipeline & Langfuse Evaluation Monitoring

Browse files
Files changed (3) hide show
  1. app.py +12 -1
  2. evaluate.py +258 -0
  3. telemetry.py +62 -0
app.py CHANGED
@@ -57,8 +57,13 @@ from pydantic import BaseModel
57
  from strands import Agent
58
  from strands.agent.conversation_manager import SlidingWindowConversationManager
59
  from strands.models.openai import OpenAIModel
 
60
  from strands.handlers.callback_handler import PrintingCallbackHandler
61
 
 
 
 
 
62
  # Import confluence-ingestor
63
  from confluence_ingestor import ConfluenceRAG
64
  from confluence_ingestor.adapters.strands import (
@@ -428,7 +433,8 @@ def create_enhanced_agent():
428
  return _cached_agent
429
 
430
 
431
- def query_agent(question: str, files: Optional[List[FilePayload]] = None) -> str:
 
432
  """Process question with the enhanced agent, optionally including files."""
433
  try:
434
  logger.info(f"Processing query: {question}")
@@ -566,6 +572,10 @@ def query_agent(question: str, files: Optional[List[FilePayload]] = None) -> str
566
  result = agent(message_content)
567
 
568
  logger.info("Query completed successfully")
 
 
 
 
569
  return str(result)
570
  except Exception as e:
571
  logger.error(f"Query failed: {e}")
@@ -573,6 +583,7 @@ def query_agent(question: str, files: Optional[List[FilePayload]] = None) -> str
573
  return f"Error: {str(e)}"
574
 
575
 
 
576
  # =============================================================================
577
  # FASTAPI APPLICATION
578
  # =============================================================================
 
57
  from strands import Agent
58
  from strands.agent.conversation_manager import SlidingWindowConversationManager
59
  from strands.models.openai import OpenAIModel
60
+ from strands.models.openai import OpenAIModel
61
  from strands.handlers.callback_handler import PrintingCallbackHandler
62
 
63
+ # Telemetry
64
+ from telemetry import setup_telemetry
65
+ setup_telemetry()
66
+
67
  # Import confluence-ingestor
68
  from confluence_ingestor import ConfluenceRAG
69
  from confluence_ingestor.adapters.strands import (
 
433
  return _cached_agent
434
 
435
 
436
+
437
+ def query_agent(question: str, files: Optional[List[FilePayload]] = None, return_full_result: bool = False):
438
  """Process question with the enhanced agent, optionally including files."""
439
  try:
440
  logger.info(f"Processing query: {question}")
 
572
  result = agent(message_content)
573
 
574
  logger.info("Query completed successfully")
575
+
576
+ if return_full_result:
577
+ return result
578
+
579
  return str(result)
580
  except Exception as e:
581
  logger.error(f"Query failed: {e}")
 
583
  return f"Error: {str(e)}"
584
 
585
 
586
+
587
  # =============================================================================
588
  # FASTAPI APPLICATION
589
  # =============================================================================
evaluate.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import json
4
+ import asyncio
5
+ import base64
6
+ import httpx
7
+ from typing import List, Dict, Any
8
+ from openai import AsyncOpenAI
9
+
10
+ # OpenTelemetry
11
+ from opentelemetry import trace
12
+ from opentelemetry.sdk.trace import TracerProvider
13
+
14
+ # App imports (triggers setup_telemetry)
15
+ from app import query_agent
16
+
17
+ tracer = trace.get_tracer("evaluation_script")
18
+
19
+ # Initialize OpenAI Client for Evaluation
20
+ aclient = AsyncOpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
21
+
22
+ class LangfuseClient:
23
+ def __init__(self):
24
+ self.secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
25
+ self.public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
26
+ self.base_url = os.environ.get("LANGFUSE_BASE_URL", "https://cloud.langfuse.com").rstrip("/")
27
+
28
+ if not (self.secret_key and self.public_key):
29
+ print("⚠ Langfuse credentials missing. Scoring disabled.")
30
+ self.enabled = False
31
+ return
32
+
33
+ self.enabled = True
34
+ auth_str = f"{self.public_key}:{self.secret_key}"
35
+ auth_bytes = auth_str.encode("ascii")
36
+ base64_auth = base64.b64encode(auth_bytes).decode("ascii")
37
+ self.headers = {
38
+ "Authorization": f"Basic {base64_auth}",
39
+ "Content-Type": "application/json"
40
+ }
41
+
42
+ def score_trace(self, trace_id: str, name: str, value: float, comment: str = None):
43
+ if not self.enabled:
44
+ return
45
+
46
+ url = f"{self.base_url}/api/public/scores"
47
+ payload = {
48
+ "traceId": trace_id,
49
+ "name": name,
50
+ "value": value,
51
+ "comment": comment
52
+ }
53
+
54
+ try:
55
+ # Synchronous call for simplicity in this script
56
+ resp = httpx.post(url, json=payload, headers=self.headers, timeout=10.0)
57
+ if resp.status_code not in (200, 201):
58
+ print(f" ⚠ Failed to log score {name}: {resp.status_code} - {resp.text}")
59
+ except Exception as e:
60
+ print(f" ⚠ Error logging score: {e}")
61
+
62
+ async def evaluate_helpfulness(question: str, answer: str) -> dict:
63
+ """Evaluates if the answer is helpful using LLM."""
64
+ prompt = f"""
65
+ You are an expert evaluator. Rate the helpfulness of the AI response to the user question.
66
+
67
+ Question: {question}
68
+ Response: {answer}
69
+
70
+ Score 0.0 to 1.0 (1.0 is most helpful).
71
+ Provide reasoning.
72
+
73
+ Output JSON: {{"score": float, "reason": "string"}}
74
+ """
75
+ try:
76
+ response = await aclient.chat.completions.create(
77
+ model="gpt-4o",
78
+ messages=[{"role": "user", "content": prompt}],
79
+ response_format={"type": "json_object"},
80
+ temperature=0
81
+ )
82
+ return json.loads(response.choices[0].message.content)
83
+ except Exception as e:
84
+ print(f" ⚠ Eval Error: {e}")
85
+ return {"score": 0.0, "reason": "Evaluation failed"}
86
+
87
+ async def evaluate_faithfulness(question: str, answer: str, context: str) -> dict:
88
+ """Evaluates if the answer is faithful to the context."""
89
+ if not context:
90
+ return {"score": 0.5, "reason": "No context available for faithfulness check."}
91
+
92
+ prompt = f"""
93
+ You are an expert evaluator. Rate the faithfulness of the AI response to the retrieved context.
94
+
95
+ Context:
96
+ {context[:10000]}... (truncated)
97
+
98
+ Question: {question}
99
+ Response: {answer}
100
+
101
+ Score 0.0 to 1.0 (1.0 is fully supported by context).
102
+ If the response contains information NOT in the context (hallucination), penalize heavily.
103
+
104
+ Output JSON: {{"score": float, "reason": "string"}}
105
+ """
106
+ try:
107
+ response = await aclient.chat.completions.create(
108
+ model="gpt-4o",
109
+ messages=[{"role": "user", "content": prompt}],
110
+ response_format={"type": "json_object"},
111
+ temperature=0
112
+ )
113
+ return json.loads(response.choices[0].message.content)
114
+ except Exception as e:
115
+ print(f" ⚠ Faithfulness Eval Error: {e}")
116
+ return {"score": 0.0, "reason": "Evaluation failed"}
117
+
118
+ async def evaluate_trajectory(question: str, tool_calls: List[str], rubric: str) -> dict:
119
+ """Evaluates if the tool usage followed the rubric."""
120
+ prompt = f"""
121
+ You are an expert evaluator. Rate the agent's execution trajectory against the rubric.
122
+
123
+ Rubric: {rubric}
124
+
125
+ Question: {question}
126
+ Tool Sequence: {json.dumps(tool_calls)}
127
+
128
+ Score 0.0 to 1.0 (1.0 is perfect adherence).
129
+ Did it skip required steps? Did it use irrelevant tools?
130
+
131
+ Output JSON: {{"score": float, "reason": "string"}}
132
+ """
133
+ try:
134
+ response = await aclient.chat.completions.create(
135
+ model="gpt-4o",
136
+ messages=[{"role": "user", "content": prompt}],
137
+ response_format={"type": "json_object"},
138
+ temperature=0
139
+ )
140
+ return json.loads(response.choices[0].message.content)
141
+ except Exception as e:
142
+ print(f" ⚠ Trajectory Eval Error: {e}")
143
+ return {"score": 0.0, "reason": "Evaluation failed"}
144
+
145
+ def extract_context_and_tools(agent_result) -> tuple[str, List[str]]:
146
+ """Extracts retrieved text and tool names from AgentResult."""
147
+ context = []
148
+ tool_calls = []
149
+
150
+ if not hasattr(agent_result, 'trace') or not agent_result.trace:
151
+ return "", []
152
+
153
+ for span in agent_result.trace.spans:
154
+ # Check for tool execution spans (simplified check)
155
+ if hasattr(span, 'span_type') and str(span.span_type) == 'tool_execution':
156
+ # Tool Name
157
+ tool_name = span.tool_call.name
158
+ tool_calls.append(tool_name)
159
+
160
+ # Context from Search/Load Tools
161
+ if 'confluence' in tool_name or 'get_application_summary' in tool_name or 'compare' in tool_name:
162
+ context.append(f"Source ({tool_name}): {span.tool_result.content}")
163
+
164
+ return "\n\n".join(context), tool_calls
165
+
166
+ async def run_evaluation():
167
+ print("πŸš€ Starting Evaluation Pipeline (Custom LLM Evals: Helpfulness, Faithfulness, Trajectory)...")
168
+
169
+ # Initialize Client
170
+ lf_client = LangfuseClient()
171
+
172
+ # Load dataset
173
+ try:
174
+ with open("evaluation/dataset.json", "r") as f:
175
+ dataset = json.load(f)
176
+ except FileNotFoundError:
177
+ print("❌ evaluation/dataset.json not found.")
178
+ return
179
+
180
+ print(f"πŸ“‹ Loaded {len(dataset)} test cases.")
181
+
182
+ results = []
183
+
184
+ for case in dataset:
185
+ case_id = case["id"]
186
+ question = case["question"]
187
+ expected_key_points = case["expected_answer_key_points"]
188
+
189
+ print(f"\nπŸ§ͺ Running Case: {case_id}")
190
+
191
+ # Start a span for this evaluation case
192
+ with tracer.start_as_current_span(f"Eval: {case_id}") as span:
193
+ # Get Trace ID (OTel stores it as int, needs hex conversion)
194
+ trace_id_int = span.get_span_context().trace_id
195
+ trace_id_hex = "{:032x}".format(trace_id_int)
196
+
197
+ # Add metadata
198
+ span.set_attribute("evaluation.case_id", case_id)
199
+ span.set_attribute("evaluation.question", question)
200
+
201
+ # 1. Run Agent (Request Full Result)
202
+ # The agent's internal spans will be nested under this span automatically
203
+ result_obj = query_agent(question, return_full_result=True)
204
+ answer = str(result_obj)
205
+
206
+ # Extract Internals
207
+ context_text, tool_sequence = extract_context_and_tools(result_obj)
208
+ # Log extracted data for debug
209
+ span.set_attribute("evaluation.tool_sequence", json.dumps(tool_sequence))
210
+
211
+ # 2. Run Evaluators & Log Scores
212
+
213
+ # A. Helpfulness
214
+ help_res = await evaluate_helpfulness(question, answer)
215
+ lf_client.score_trace(trace_id=trace_id_hex, name="Helpfulness", value=help_res["score"], comment=help_res["reason"])
216
+ print(f" βœ… Helpfulness: {help_res['score']:.2f}")
217
+
218
+ # B. Faithfulness
219
+ faith_res = await evaluate_faithfulness(question, answer, context_text)
220
+ lf_client.score_trace(trace_id=trace_id_hex, name="Faithfulness", value=faith_res["score"], comment=faith_res["reason"])
221
+ print(f" πŸ“– Faithfulness: {faith_res['score']:.2f}")
222
+
223
+ # C. Trajectory
224
+ rubric = "1. Retrieve data (summary). 2. Analyze specifics/compare. 3. Check compliance if relevant. 4. Explain."
225
+ traj_res = await evaluate_trajectory(question, tool_sequence, rubric)
226
+ lf_client.score_trace(trace_id=trace_id_hex, name="Trajectory", value=traj_res["score"], comment=traj_res["reason"])
227
+ print(f" πŸ‘£ Trajectory: {traj_res['score']:.2f} ({len(tool_sequence)} tools)")
228
+
229
+ # D. Goal Success
230
+ hits = 0
231
+ if expected_key_points:
232
+ for point in expected_key_points:
233
+ if point.lower() in answer.lower():
234
+ hits += 1
235
+ elif any(word in answer.lower() for word in point.split() if len(word) > 4):
236
+ hits += 0.5
237
+ success_rate = min(1.0, hits / len(expected_key_points))
238
+ else:
239
+ success_rate = 1.0
240
+
241
+ lf_client.score_trace(trace_id=trace_id_hex, name="Goal Success", value=success_rate, comment=f"Matched {hits}/{len(expected_key_points)} key points")
242
+ print(f" 🎯 Goal Success: {success_rate:.2f}")
243
+
244
+ results.append({
245
+ "case_id": case_id,
246
+ "trace_id": trace_id_hex,
247
+ "helpfulness": help_res["score"],
248
+ "faithfulness": faith_res["score"],
249
+ "trajectory": traj_res["score"],
250
+ "goal_success": success_rate
251
+ })
252
+
253
+ # Summary
254
+ print("\nπŸ“Š Evaluation Summary")
255
+ print(json.dumps(results, indent=2))
256
+
257
+ if __name__ == "__main__":
258
+ asyncio.run(run_evaluation())
telemetry.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import base64
4
+ from opentelemetry import trace
5
+ from opentelemetry.sdk.trace import TracerProvider
6
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
7
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
8
+ from opentelemetry.sdk.resources import Resource
9
+
10
+ def setup_telemetry():
11
+ """
12
+ Configures OpenTelemetry to export traces to Langfuse.
13
+ Dependencies: langfuse, opentelemetry-sdk, opentelemetry-exporter-otlp
14
+ """
15
+
16
+ # helper to check if vars exist
17
+ secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
18
+ public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
19
+ base_url = os.environ.get("LANGFUSE_BASE_URL", "https://cloud.langfuse.com")
20
+
21
+ if not (secret_key and public_key):
22
+ print("⚠ Langfuse credentials not found. Telemetry disabled.")
23
+ return
24
+
25
+ print(f"Initializing Langfuse Telemetry at {base_url}...")
26
+
27
+ # Auth Header for Langfuse (Basic Auth)
28
+ auth_str = f"{public_key}:{secret_key}"
29
+ auth_bytes = auth_str.encode("ascii")
30
+ base64_auth = base64.b64encode(auth_bytes).decode("ascii")
31
+
32
+ # Configure OTLP HTTP Exporter
33
+ # Langfuse OTLP HTTP endpoint: /api/public/otel/v1/traces
34
+
35
+ # Construct endpoint
36
+ # Remove trailing slash from base
37
+ clean_base_url = base_url.rstrip("/")
38
+ # Note: OTLPSpanExporter (HTTP) does NOT automatically append /v1/traces in all versions
39
+ # or if we provide a full URL. Best to be explicit for Langfuse.
40
+ endpoint = f"{clean_base_url}/api/public/otel/v1/traces"
41
+
42
+ exporter = OTLPSpanExporter(
43
+ endpoint=endpoint,
44
+ headers={"Authorization": f"Basic {base64_auth}"}
45
+ )
46
+
47
+ # Resource
48
+ resource = Resource.create(attributes={
49
+ "service.name": "fraud_model_explainability_assistant",
50
+ "service.version": "1.0.0"
51
+ })
52
+
53
+ # Provider
54
+ provider = TracerProvider(resource=resource)
55
+ processor = BatchSpanProcessor(exporter)
56
+ provider.add_span_processor(processor)
57
+
58
+ # Set global provider
59
+ trace.set_tracer_provider(provider)
60
+
61
+ print("βœ… Langfuse Telemetry configured.")
62
+