forked from bojieli/ai-agent-book
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagent.py
More file actions
409 lines (345 loc) · 15.8 KB
/
Copy pathagent.py
File metadata and controls
409 lines (345 loc) · 15.8 KB
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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
"""
Log Sanitization Agent using Local Ollama LLM
"""
import os
import time
import re
import json
from typing import List, Tuple, Dict, Optional
from pathlib import Path
import ollama
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
from config import (
OLLAMA_MODEL,
OLLAMA_TEMPERATURE,
SYSTEM_PROMPT,
USER_PROMPT_TEMPLATE,
PII_DETECTION_SCHEMA,
OUTPUT_DIR
)
from metrics import PerformanceMetrics, MetricsCollector
def _value_appears_in_text(value: str, text: str) -> bool:
"""Return True if *value* appears as a substring of *text* (case-insensitive)."""
return value.lower() in text.lower()
class LogSanitizationAgent:
"""Agent for sanitizing logs using local Qwen3 0.6B model via Ollama"""
def __init__(self, model: str = OLLAMA_MODEL):
"""Initialize the sanitization agent.
Primary backend is the local Ollama model. If Ollama is unavailable
(not running / not reachable) and OPENROUTER_API_KEY is set, the agent
falls back to OpenRouter (default hosted model: openai/gpt-5.6-luna),
so the experiment still runs without a local model.
"""
self.model = model
self.backend = "ollama"
self.metrics_collector = MetricsCollector(OUTPUT_DIR)
# Try the local Ollama backend first.
try:
self.client = ollama.Client()
models = self.client.list()
# models is a dict with 'models' key containing a list
if isinstance(models, dict) and 'models' in models:
available_models = [m.get('name', '') for m in models['models']]
else:
# If it's a direct list (older API versions)
available_models = [m.get('name', '') for m in models] if isinstance(models, list) else []
if not any(self.model in m for m in available_models):
print(f"⚠️ Model {self.model} not found. Pulling it now...")
self.client.pull(self.model)
print(f"✅ Model {self.model} pulled successfully")
else:
print(f"✅ Using model: {self.model}")
except Exception as e:
# Universal fallback: route through OpenRouter when Ollama is down.
openrouter_key = os.getenv("OPENROUTER_API_KEY")
if openrouter_key:
from openai import OpenAI
from agentbook.providers import resolve_backend
# 这里的回退条件是“本地 Ollama 连不上”,而非缺少凭证,
# 因此由本实验判定后再向注册表要一个 OpenRouter backend。
# 本地小模型(qwen3:0.6b 等)在 OpenRouter 上未必可用,
# substitute_unknown 让注册表替换成可用的默认模型。
backend = resolve_backend(
"openrouter", model=self.model, api_key=openrouter_key
)
self.backend = "openrouter"
self.client = OpenAI(api_key=backend.api_key,
base_url=backend.base_url)
self.model = backend.model
print(f"⚠️ Ollama unavailable ({e}); "
f"falling back to OpenRouter model: {self.model}")
else:
print(f"❌ Failed to connect to Ollama: {e}")
print("Please ensure Ollama is running: ollama serve, "
"or set OPENROUTER_API_KEY as a fallback")
raise
def _chat_stream(self, messages):
"""Yield content chunks from the active backend (Ollama or OpenRouter)."""
if self.backend == "ollama":
stream = self.client.chat(
model=self.model,
messages=messages,
stream=True,
format=PII_DETECTION_SCHEMA, # Use structured output format
options={
"temperature": OLLAMA_TEMPERATURE,
"num_predict": 1000,
}
)
for chunk in stream:
yield chunk.get('message', {}).get('content', '')
else:
# 用与 Ollama 相同的 JSON Schema 强约束输出结构 (pii_items 数组),
# 避免模型自行发明字段名. strict 模式要求 additionalProperties=false.
strict_schema = dict(PII_DETECTION_SCHEMA)
strict_schema["additionalProperties"] = False
stream = self.client.chat.completions.create(
model=self.model,
messages=messages,
stream=True,
temperature=OLLAMA_TEMPERATURE,
response_format={
"type": "json_schema",
"json_schema": {
"name": "pii_detection",
"strict": True,
"schema": strict_schema,
},
},
max_tokens=1000,
)
for chunk in stream:
if not chunk.choices:
continue
yield chunk.choices[0].delta.content or ""
def count_tokens(self, text: str) -> int:
"""Estimate token count (rough approximation)"""
# Rough estimate: 1 token ≈ 4 characters for English text
# For more accurate counting, we'd need the actual tokenizer
return len(text) // 4
def detect_pii(self, conversation_text: str) -> Tuple[List[str], Dict]:
"""
Detect Level 3 PII in conversation text using local LLM
Args:
conversation_text: Text to analyze
Returns:
- List of detected PII values
- Performance metrics dictionary
"""
# Prepare the prompt
user_prompt = USER_PROMPT_TEMPLATE.format(conversation_text=conversation_text)
# Count input tokens
input_tokens = self.count_tokens(SYSTEM_PROMPT + user_prompt)
# Measure prefill time (time to first token)
start_time = time.perf_counter()
# Create messages for Ollama
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
]
# Track first token time
first_token_time = None
output_tokens_count = 0
full_response = ""
try:
# Use structured output with JSON schema (backend-agnostic stream)
print("\n 🧠 Analyzing (JSON): \033[90m", end="", flush=True) # Gray color for JSON
for content in self._chat_stream(messages):
if first_token_time is None and content:
first_token_time = time.perf_counter()
full_response += content
output_tokens_count += len(content) // 4 # Rough token estimate
# Stream the actual content
if content:
print(content, end="", flush=True)
print("\033[0m") # Reset color and new line
end_time = time.perf_counter()
except Exception as e:
print(f"\n❌ Error during PII detection: {e}")
return [], {}
# Calculate performance metrics
prefill_time_ms = (first_token_time - start_time) * 1000 if first_token_time else 0
total_time_ms = (end_time - start_time) * 1000
output_time_ms = total_time_ms - prefill_time_ms
prefill_speed = input_tokens / (prefill_time_ms / 1000) if prefill_time_ms > 0 else 0
output_speed = output_tokens_count / (output_time_ms / 1000) if output_time_ms > 0 else 0
# Parse JSON response
pii_values = []
accepted_items = []
try:
response_json = json.loads(full_response)
if not isinstance(response_json, dict):
return [], {}
raw_items = response_json.get('pii_items')
if isinstance(raw_items, list):
for item in raw_items:
if isinstance(item, dict):
value = item.get('value')
if value and isinstance(value, str) and _value_appears_in_text(value, conversation_text):
pii_values.append(value)
accepted_items.append(item)
elif isinstance(item, str) and item and _value_appears_in_text(item, conversation_text):
pii_values.append(item)
accepted_items.append({"value": item})
else:
legacy_values = response_json.get('pii_values')
if isinstance(legacy_values, list):
for pii in legacy_values:
if pii and isinstance(pii, str):
cleaned = pii.strip().strip('-').strip()
if cleaned:
pii_values.append(cleaned)
except json.JSONDecodeError as e:
print(f"\n ⚠️ Failed to parse JSON response: {e}")
# Fallback to simple line splitting if JSON parsing fails
pii_values = [line.strip() for line in full_response.split('\n') if line.strip()]
accepted_items = []
metrics = {
'input_tokens': input_tokens,
'output_tokens': output_tokens_count,
'prefill_time_ms': prefill_time_ms,
'output_time_ms': output_time_ms,
'total_time_ms': total_time_ms,
'prefill_speed_tps': prefill_speed,
'output_speed_tps': output_speed,
'pii_items_found': len(pii_values),
'pii_items': accepted_items
}
return pii_values, metrics
def sanitize_text(self, text: str, pii_values: List[str]) -> Tuple[str, int]:
"""
Replace PII values with [REDACTED] in the text
Returns:
- Sanitized text
- Number of replacements made
"""
sanitized = text
replacements = 0
for pii_value in pii_values:
# Escape special regex characters in PII value
escaped_value = re.escape(pii_value)
# Count occurrences before replacement
occurrences = len(re.findall(escaped_value, sanitized, re.IGNORECASE))
# Replace all occurrences
sanitized = re.sub(escaped_value, '[REDACTED]', sanitized, flags=re.IGNORECASE)
replacements += occurrences
return sanitized, replacements
def sanitize_conversation(
self,
conversation: Dict,
test_id: str = "unknown"
) -> Dict:
"""
Sanitize a single conversation and collect metrics
Returns:
Dictionary with sanitized conversation and metrics
"""
# Format conversation text
conv_text = self.format_conversation(conversation)
conv_id = conversation.get('conversation_id', 'unknown')
print(f"🔍 Processing conversation: {conv_id}")
# Detect PII
pii_values, perf_metrics = self.detect_pii(conv_text)
accepted_items = perf_metrics.get('pii_items', [])
if pii_values:
print(f" ✅ Found {len(pii_values)} PII items:")
for pii in pii_values:
print(f" - {pii}")
else:
print(" ⚠️ No PII items detected")
# Sanitize the text
sanitized_text, replacements = self.sanitize_text(conv_text, pii_values)
# Create performance metric. detect_pii() returns an empty metrics dict
# when the LLM backend fails (e.g. Ollama not running) — fall back to
# zeros so one failed conversation doesn't crash the whole batch.
metric = PerformanceMetrics(
test_id=test_id,
conversation_id=conv_id,
input_text_length=len(conv_text),
input_tokens=perf_metrics.get('input_tokens', 0),
prefill_time_ms=perf_metrics.get('prefill_time_ms', 0),
output_time_ms=perf_metrics.get('output_time_ms', 0),
total_time_ms=perf_metrics.get('total_time_ms', 0),
output_tokens=perf_metrics.get('output_tokens', 0),
prefill_speed_tps=perf_metrics.get('prefill_speed_tps', 0),
output_speed_tps=perf_metrics.get('output_speed_tps', 0),
pii_items_found=perf_metrics.get('pii_items_found', 0),
replacements_made=replacements,
sanitized_text_length=len(sanitized_text)
)
self.metrics_collector.add_metric(metric)
return {
'conversation_id': conv_id,
'original_length': len(conv_text),
'sanitized_length': len(sanitized_text),
'pii_found': pii_values,
'replacements_made': replacements,
'sanitized_text': sanitized_text,
'pii_items': accepted_items,
'metrics': metric.to_dict()
}
def format_conversation(self, conversation: Dict) -> str:
"""Format conversation dictionary into text"""
lines = []
lines.append(f"Conversation ID: {conversation.get('conversation_id', 'unknown')}")
lines.append(f"Timestamp: {conversation.get('timestamp', 'unknown')}")
lines.append("-" * 50)
messages = conversation.get('messages', [])
for msg in messages:
role = msg.get('role', 'unknown').upper()
content = msg.get('content', '')
lines.append(f"{role}: {content}")
lines.append("") # Empty line between messages
return "\n".join(lines)
def save_sanitized_log(self, test_id: str, results: List[Dict]):
"""Save sanitized logs to output directory"""
output_file = OUTPUT_DIR / f"{test_id}_sanitized.txt"
summary_file = OUTPUT_DIR / f"{test_id}_summary.json"
# Save sanitized text
with open(output_file, 'w') as f:
for result in results:
f.write(f"\n{'='*60}\n")
f.write(f"Conversation: {result['conversation_id']}\n")
f.write(f"{'='*60}\n")
f.write(result['sanitized_text'])
f.write("\n")
# Save summary
summary = {
'test_id': test_id,
'total_conversations': len(results),
'total_pii_found': sum(len(r['pii_found']) for r in results),
'total_replacements': sum(r['replacements_made'] for r in results),
'conversations': [
{
'conversation_id': r['conversation_id'],
'pii_count': len(r['pii_found']),
'replacements': r['replacements_made']
}
for r in results
]
}
with open(summary_file, 'w') as f:
json.dump(summary, f, indent=2)
print(f"✅ Sanitized log saved to: {output_file}")
print(f"✅ Summary saved to: {summary_file}")
def process_test_case(self, test_id: str, conversations: List[Dict]) -> List[Dict]:
"""Process all conversations in a test case"""
results = []
print(f"\n{'='*60}")
print(f"Processing Test Case: {test_id}")
print(f"Total Conversations: {len(conversations)}")
print(f"{'='*60}")
for i, conv in enumerate(conversations, 1):
print(f"\n[{i}/{len(conversations)}] ", end="")
result = self.sanitize_conversation(conv, test_id)
results.append(result)
# Save results
self.save_sanitized_log(test_id, results)
# Save metrics
self.metrics_collector.save_metrics()
self.metrics_collector.print_summary()
return results