Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
410 lines
16 KiB
Python
410 lines
16 KiB
Python
"""
|
|
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
|