ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
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
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
This commit is contained in:
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user