跳转至

log-sanitization

第2章 · 上下文工程 · 配套项目 chapter2/log-sanitization

项目说明

Log Sanitization / 日志脱敏

本实验演示如何从 Agent 的日志与工具输出中检测并脱敏敏感信息。它提供两种互补的脱敏引擎

  1. 离线规则引擎(regex,默认) —— 纯正则表达式 + 校验算法(Luhn、身份证校验码), 无需 Ollama、无需网络、无需外部框架,结果确定、速度快,适合作为日志落盘前的第一道防线。 它同时覆盖 Agent 场景中最常泄露的密钥类敏感信息(API Key、云厂商令牌、私钥、连接串口令) 与传统 PII(身份证、手机号、信用卡、邮箱等)。
  2. 本地 LLM 引擎(llm) —— 通过 Ollama 调用一个本地小模型(默认 qwen3:0.6b)语义识别 Level 3 PII。呼应本章“小模型也能胜任结构化任务”的论点,同时也暴露小模型的局限 (例如可能返回带描述前缀的值而非原始字符串,导致回填失败)。

想快速看效果,直接运行 python main.py --demo(离线,无需任何依赖)即可看到多个代表性样本的 before/after 对比与脱敏类别汇总。

离线规则引擎覆盖的敏感信息类别

regex_sanitizer.py 按优先级处理以下类别(重叠时高优先级规则胜出),每类替换为带标签的占位符:

类别 占位符 说明
私钥 / 证书 [REDACTED_PRIVATE_KEY] PEM 私钥块
JWT [REDACTED_JWT] eyJ... 三段式令牌
连接串凭据 [REDACTED_URL_CRED] scheme://user:PASSWORD@host
AWS 访问密钥 [REDACTED_AWS_KEY] AKIA...
GitHub / Slack / Google / OpenAI 密钥 [REDACTED_*_TOKEN] / [REDACTED_API_KEY] ghp_xoxb-AIzask-
Bearer 令牌 [REDACTED_BEARER_TOKEN] Authorization: Bearer ...
口令 / 密钥赋值 [REDACTED_SECRET] password=...token: ...
邮箱 [REDACTED_EMAIL]
信用卡号 [REDACTED_CREDIT_CARD] 通过 Luhn 校验,降低误报
IBAN [REDACTED_IBAN] 国际银行账号
美国社保号 [REDACTED_SSN]
身份证号 [REDACTED_ID_CARD] 中国大陆 18 位,含校验码验证
手机号 [REDACTED_PHONE] 中国大陆
IP 地址 [REDACTED_IP] IPv4

Level 3 PII Categories(LLM 引擎)

Based on the privacy protection architecture, Level 3 PII includes highly sensitive information: - Social Security Numbers (SSN) - Credit Card Numbers - Bank Account Numbers - Medical Record Numbers - Medical Diagnoses and Treatment Information - Prescription Information - Driver's License Numbers - Passport Numbers - Financial PINs - Tax ID Numbers - Health Insurance IDs - Biometric Data

Features

  • Offline Rule Engine: Regex + Luhn/ID-checksum based sanitizer that needs no model, no network, and covers API keys/secrets in addition to PII
  • Local LLM Processing: Uses Ollama with a local small model (default qwen3:0.6b) for privacy-preserving PII detection
  • Internal Reasoning: Shows the model's thinking process using <think> tags for transparency
  • Streaming Output: Real-time display of thinking and PII detection progress
  • Performance Metrics: Measures TTFT (Time to First Token), token counts, and processing speeds
  • Batch Processing: Can process multiple test cases from user-memory-evaluation framework
  • Detailed Metrics: Tracks prefill time, output time, tokens per second for both phases

Installation

1. Install Ollama

通用回退(OpenRouter):本实验默认用本地 Ollama 小模型。若 Ollama 不可用 (未运行 / 不可达)且设置了 OPENROUTER_API_KEY,Agent 会自动改走 OpenRouter (默认托管模型 openai/gpt-5.6-luna)。想强制走回退做验证,可把 Ollama 指到一个 不可达端口:export OLLAMA_HOST=http://127.0.0.1:1

macOS:

brew install ollama
ollama serve  # Run in separate terminal

Linux:

curl -fsSL https://ollama.com/install.sh | sh
systemctl start ollama

Windows:

Download from ollama.com

说明:以下 Ollama 相关步骤仅在使用 --mode llm(本地 LLM 引擎)或运行 LLM 批量评测路径时才需要。 离线规则引擎(--demo--input)只依赖 Python 标准库,无需安装 Ollama。

2. Pull the Qwen3 Model

ollama pull qwen3:0.6b

Note: The 0.6B model requires approximately 500MB of disk space(可按需换用 qwen3:1.7bqwen3:4b 提升准确率)。

3. Install Python Dependencies

pip install -r requirements.txt

Usage

完整参数说明见 python main.py --help(中文)。

离线规则演示(推荐,无需 Ollama)

对多个内置代表性样本展示 before/after 与脱敏类别汇总:

python main.py --demo

脱敏任意日志文件(离线)

python main.py --input app.log                 # 结果写到 app.log.sanitized
python main.py --input app.log -o cleaned.log  # 指定输出文件

也可以直接运行规则引擎模块,仅对内置样本做演示:

python regex_sanitizer.py

使用本地 LLM 引擎

上述演示 / 文件脱敏加 --mode llm 即改用本地 Ollama 模型:

python main.py --demo --mode llm
python main.py --input app.log --mode llm --model qwen3:1.7b

Process All Layer 3 Test Cases(LLM 批量评测路径)

Process all complex test cases from user-memory-evaluation(该路径固定使用 LLM,需要 Ollama 与 chapter3 评测框架):

python main.py

Process Specific Test Case

python main.py --test-id layer3_13_emergency_medical_cascade

Limit Number of Test Cases

Process only the first N test cases:

python main.py --limit 3

选择模型

python main.py --demo --mode llm --model qwen3:4b   # 默认 qwen3:0.6b

Output Structure

The sanitized logs and metrics are saved in the output/ directory:

output/
├── <test_id>_sanitized.txt    # Sanitized conversation text
├── <test_id>_summary.json     # Summary of PII found and replaced
├── performance_metrics.json   # Detailed performance metrics
└── performance_summary.json   # Aggregated performance statistics

Performance Metrics

The system tracks the following metrics for each conversation:

Timing Metrics

  • Prefill Time (TTFT): Time to first token in milliseconds
  • Output Time: Time to generate all output tokens
  • Total Time: End-to-end processing time

Token Metrics

  • Input Tokens: Number of tokens in the prompt
  • Output Tokens: Number of tokens generated
  • Prefill Speed: Tokens per second during prefill phase
  • Output Speed: Tokens per second during generation

Sanitization Metrics

  • PII Items Found: Number of Level 3 PII values detected
  • Replacements Made: Number of replacements with [REDACTED]

Example Output

🚀 Starting Log Sanitization with Local LLM
============================================================
📦 Loading test cases from user-memory-evaluation...
🤖 Initializing Ollama agent...
✅ Using model: qwen3:0.6b

[1/1] Test Case: layer3_13_emergency_medical_cascade
   Title: Emergency Medical Crisis - Multi-System Coordination Response
   Conversations: 8

🔍 Processing conversation: emergency_room_001
   Found 3 PII items
   - 123-45-6789
   - 4532 1234 5678 9012
   - MRN-789456

============================================================
PERFORMANCE SUMMARY
============================================================

📊 Total Conversations Processed: 8

⏱️  Timing Metrics (milliseconds):
   Prefill (TTFT): 125.34 ms (median: 118.50)
   Output Time:    234.67 ms (median: 220.00)
   Total Time:     360.01 ms (median: 338.50)

📝 Token Metrics:
   Average Input Tokens:  450.5
   Average Output Tokens: 25.3
   Total Tokens Processed: 4206

⚡ Speed Metrics (tokens/second):
   Prefill Speed: 3592.8 tok/s
   Output Speed:  107.8 tok/s

🔒 Sanitization Results:
   Total PII Items Found:     24
   Total Replacements Made:   48
   Average PII per Conversation: 3.0

Architecture

The project consists of several modules:

  1. regex_sanitizer.py: Offline rule-based sanitizer (regex + Luhn/ID checksums), covers keys/secrets and PII
  2. samples.py: Representative agent-log samples used by the offline demo
  3. config.py: Configuration for Ollama model and PII categories
  4. test_loader.py: Loads test cases from user-memory-evaluation framework
  5. agent.py: Core LLM sanitization logic using Ollama
  6. metrics.py: Performance metrics collection and reporting
  7. main.py: Main entry point and orchestration

How It Works

  1. Test Case Loading: The system loads conversation histories from the user-memory-evaluation framework
  2. PII Detection: Each conversation is sent to the local Qwen3 0.6B model with a specialized prompt to detect Level 3 PII
  3. Sanitization: Detected PII values are replaced with [REDACTED] in the original text
  4. Metrics Collection: Performance metrics are collected for each operation
  5. Output Generation: Sanitized logs and performance summaries are saved to the output directory

Privacy Considerations

  • All processing happens locally using Ollama - no data is sent to external APIs
  • The Qwen3 0.6B model runs entirely on your local machine
  • Sanitized logs replace sensitive information with [REDACTED] placeholders
  • Original PII values are logged for verification but should be handled securely

Troubleshooting

"Ollama not found"

Make sure Ollama is installed and running:

ollama serve

"Model qwen3:0.6b not found"

Pull the model:

ollama pull qwen3:0.6b

"Evaluation framework not found"

Ensure the user-memory-evaluation project exists at:

../user-memory-evaluation/

源代码

agent.py

"""
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

from config import (
    OLLAMA_MODEL, 
    OLLAMA_TEMPERATURE,
    SYSTEM_PROMPT, 
    USER_PROMPT_TEMPLATE,
    PII_DETECTION_SCHEMA,
    OUTPUT_DIR
)
from metrics import PerformanceMetrics, MetricsCollector


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 openrouter_fallback import (
                    OPENROUTER_BASE_URL,
                    map_model_to_openrouter,
                )
                self.backend = "openrouter"
                self.client = OpenAI(api_key=openrouter_key,
                                     base_url=OPENROUTER_BASE_URL)
                # 本地小模型(qwen3:0.6b 等)在 OpenRouter 上未必可用,
                # 默认改用 openai/gpt-5.6-luna;带 "/" 的 id 原样透传。
                self.model = (map_model_to_openrouter(self.model)
                              if "/" in self.model else "openai/gpt-5.6-luna")
                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_values 数组),
            # 避免模型自行发明字段名。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 = []

        try:
            response_json = json.loads(full_response)

            # Extract PII values
            pii_values = response_json.get('pii_values', [])
            # Strip leading/trailing whitespace and special characters like '-' or empty
            cleaned_pii_values = []
            for pii in pii_values:
                if pii and isinstance(pii, str):
                    cleaned = pii.strip().strip('-').strip()
                    if cleaned:
                        cleaned_pii_values.append(cleaned)
            pii_values = cleaned_pii_values

        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()]

        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)
        }

        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)

        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,
            '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

config.py

"""
Configuration for Log Sanitization with Local LLM
"""

import os
from pathlib import Path

# Ollama Configuration
# 默认使用 0.6B 超小模型,呼应本章“小模型也能胜任结构化任务”的论点,
# 且可在 CPU / 消费级设备上运行;可用 --model 覆盖为 qwen3:1.7b、qwen3:4b 等。
OLLAMA_MODEL = "qwen3:0.6b"
OLLAMA_TEMPERATURE = 0.1  # Low temperature for consistent detection

# Paths
PROJECT_ROOT = Path(__file__).parent
OUTPUT_DIR = PROJECT_ROOT / "output"
OUTPUT_DIR.mkdir(exist_ok=True)

# Performance Metrics Configuration
METRICS_FILE = OUTPUT_DIR / "performance_metrics.json"

# Evaluation Framework Path
# The user-memory-evaluation framework lives in chapter3, not chapter2.
# PROJECT_ROOT = chapter2/log-sanitization, so go up two levels to the repo root.
EVAL_FRAMEWORK_PATH = PROJECT_ROOT.parent.parent / "chapter3" / "user-memory-evaluation"

# System Prompt for PII Detection
SYSTEM_PROMPT = """You are a privacy protection agent that detects Level 3 PII.

Level 3 PII includes:
- Social Security Numbers (SSN) - format: XXX-XX-XXXX or XXXXXXXXX
- Credit Card Numbers - format: XXXX XXXX XXXX XXXX or 16 digits
- Credit Card Expiry Date and CVV
- Bank Account Numbers
- Full Residential Addresses
- Medical Record Numbers
- Medical Diagnoses and Treatment Details
- Prescription Information
- Driver's License Numbers
- Passport Numbers
- Financial PINs
- Tax ID Numbers
- Health Insurance IDs
- Biometric Data
- Usernames for Financial Accounts
- Passwords

Analyze the conversation and return a JSON with the exact PII values found. NEVER use placeholders. Simply return the PII values found."""

USER_PROMPT_TEMPLATE = """Analyze the following conversation for Level 3 PII:

{conversation_text}"""

# JSON Schema for structured output
PII_DETECTION_SCHEMA = {
    "type": "object",
    "properties": {
        "pii_values": {
            "type": "array",
            "items": {
                "type": "string"
            },
            "description": "Array of exact PII values found in the conversation. NEVER use placeholders."
        }
    },
    "required": ["pii_values"]
}

main.py

#!/usr/bin/env python3
"""
Main script for Log Sanitization using Local LLM
"""

import argparse
import sys
from collections import Counter
from pathlib import Path
from typing import Optional

from config import OUTPUT_DIR, OLLAMA_MODEL
import regex_sanitizer
from samples import SAMPLES


def main(test_id: Optional[str] = None, limit: Optional[int] = None,
         model: str = OLLAMA_MODEL):
    """
    Main function to run log sanitization

    Args:
        test_id: Specific test case ID to process (optional)
        limit: Maximum number of test cases to process (optional)
    """
    print("🚀 Starting Log Sanitization with Local LLM")
    print("=" * 60)

    # Initialize components
    try:
        from agent import LogSanitizationAgent
        from test_loader import TestCaseLoader

        print("📦 Loading test cases from user-memory-evaluation...")
        loader = TestCaseLoader()

        print(f"🤖 Initializing Ollama agent (model: {model})...")
        agent = LogSanitizationAgent(model=model)

    except Exception as e:
        print(f"❌ Initialization failed: {e}")
        return 1

    # Get test cases to process
    if test_id:
        # Process specific test case
        print(f"\n📋 Processing specific test case: {test_id}")
        conversations = loader.get_test_case_conversations(test_id)

        if not conversations:
            print(f"❌ Test case {test_id} not found or has no conversations")
            return 1

        agent.process_test_case(test_id, conversations)

    else:
        # Process Layer 3 test cases (most complex, likely to have PII)
        print("\n📋 Getting Layer 3 test cases...")
        test_cases = loader.get_layer3_test_cases()

        if not test_cases:
            print("❌ No Layer 3 test cases found")
            return 1

        print(f"Found {len(test_cases)} Layer 3 test cases")

        # Apply limit if specified
        if limit:
            test_cases = test_cases[:limit]
            print(f"Processing first {limit} test cases")

        # Process each test case
        for i, tc in enumerate(test_cases, 1):
            print(f"\n[{i}/{len(test_cases)}] Test Case: {tc['test_id']}")
            print(f"   Title: {tc['title']}")
            print(f"   Conversations: {tc['num_conversations']}")

            # Get conversation histories
            conversations = loader.get_test_case_conversations(tc['test_id'])

            if conversations:
                agent.process_test_case(tc['test_id'], conversations)
            else:
                print(f"   ⚠️  No conversations found for {tc['test_id']}")

    print("\n" + "=" * 60)
    print("✅ Log Sanitization Complete!")
    print(f"📁 Results saved to: {OUTPUT_DIR}")

    return 0


def demo_regex_mode():
    """离线规则脱敏演示:对多个代表性样本展示 before/after 与类别汇总"""
    print("🎯 离线规则脱敏演示 (regex 模式,无需 Ollama)")
    print("=" * 60)
    print(f"共 {len(SAMPLES)} 个代表性样本,覆盖密钥 / 令牌 / 私钥 / PII 等类别\n")

    total = Counter()
    total_hits = 0
    for name, text in SAMPLES:
        redacted, findings = regex_sanitizer.sanitize(text)
        regex_sanitizer.print_report(name, text, redacted, findings)
        total.update(regex_sanitizer.summarize(findings))
        total_hits += len(findings)

    print(f"\n{'=' * 64}")
    print("脱敏类别汇总 (across all samples)")
    print("=" * 64)
    for category, count in total.most_common():
        label = regex_sanitizer.CATEGORY_LABELS.get(category, category)
        print(f"   {label:<16} {count} 处")
    print(f"\n   合计脱敏 {total_hits} 处敏感信息,覆盖 {len(total)} 个类别")
    return 0


def sanitize_file(input_path: str, output_path: Optional[str] = None,
                  mode: str = "regex", model: str = OLLAMA_MODEL):
    """对任意日志文件执行脱敏,结果写入输出文件"""
    in_file = Path(input_path)
    if not in_file.exists():
        print(f"❌ 输入文件不存在: {input_path}")
        return 1

    text = in_file.read_text(encoding="utf-8", errors="replace")
    out_file = Path(output_path) if output_path else in_file.with_suffix(in_file.suffix + ".sanitized")

    if mode == "regex":
        print(f"🔍 使用离线规则引擎脱敏: {input_path}")
        redacted, findings = regex_sanitizer.sanitize(text)
        counts = regex_sanitizer.summarize(findings)
    else:
        print(f"🔍 使用本地 LLM ({model}) 脱敏: {input_path}")
        try:
            from agent import LogSanitizationAgent
        except Exception as e:
            print(f"❌ 加载 LLM 引擎失败: {e}")
            return 1
        agent = LogSanitizationAgent(model=model)
        pii_values, _ = agent.detect_pii(text)
        redacted, _ = agent.sanitize_text(text, pii_values)
        counts = Counter({"pii": len(pii_values)})
        findings = pii_values

    out_file.write_text(redacted, encoding="utf-8")

    print(f"\n✅ 已写入脱敏结果: {out_file}")
    print(f"   共脱敏 {sum(counts.values())} 处敏感信息")
    for category, count in counts.most_common():
        label = regex_sanitizer.CATEGORY_LABELS.get(category, category)
        print(f"   - {label}: {count} 处")
    return 0


def demo_mode(model: str = OLLAMA_MODEL):
    """Run a quick demo with sample PII-containing text (本地 LLM 模式)"""
    print("🎯 Running Demo Mode (LLM)")
    print("=" * 60)

    # Create a sample conversation with Level 3 PII
    sample_conversation = {
        'conversation_id': 'demo_001',
        'timestamp': '2024-01-01 10:00:00',
        'messages': [
            {
                'role': 'user',
                'content': 'I need to update my information. My SSN is 123-45-6789.'
            },
            {
                'role': 'assistant',
                'content': 'I can help you update your information. Can you confirm your credit card?'
            },
            {
                'role': 'user',
                'content': 'Yes, it\'s 4532 1234 5678 9012. Also, my medical record number is MRN-789456.'
            },
            {
                'role': 'assistant',
                'content': 'Thank you. I\'ve noted your SSN ending in 6789 and card ending in 9012.'
            },
            {
                'role': 'user',
                'content': 'Great. My driver\'s license is DL-123456789 and passport is P987654321.'
            }
        ]
    }

    try:
        from agent import LogSanitizationAgent
        agent = LogSanitizationAgent(model=model)
        print("\n📝 Sample conversation created with Level 3 PII")
        print("🔍 Detecting and sanitizing PII...\n")

        result = agent.sanitize_conversation(sample_conversation, 'demo')

        print("\n" + "=" * 60)
        print("DEMO RESULTS")
        print("=" * 60)
        print(f"PII Items Found: {len(result['pii_found'])}")
        for pii in result['pii_found']:
            print(f"  - {pii}")

        print(f"\nReplacements Made: {result['replacements_made']}")
        print("\n--- SANITIZED TEXT ---")
        print(result['sanitized_text'])

        # Save demo results
        agent.save_sanitized_log('demo', [result])
        agent.metrics_collector.save_metrics()
        agent.metrics_collector.print_summary()

    except Exception as e:
        print(f"❌ Demo failed: {e}")
        return 1

    return 0


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="日志脱敏实验:从 Agent 日志 / 工具输出中检测并脱敏敏感信息"
                    "(API 密钥、令牌、私钥、信用卡、身份证、手机号、邮箱等)。",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""\
两种脱敏引擎:
  regex  离线规则引擎(默认),基于正则 + 校验算法,无需 Ollama,结果确定、速度快
  llm    本地 LLM 引擎,通过 Ollama 调用小模型(默认 qwen3:0.6b)语义识别 Level 3 PII

常用示例:
  python main.py --demo                     # 离线跑内置样本,展示 before/after 与脱敏汇总
  python main.py --demo --mode llm          # 用本地 LLM 跑演示样本
  python main.py --input app.log            # 离线脱敏一个日志文件
  python main.py --input app.log -o out.log # 指定输出文件
  python main.py --input app.log --mode llm # 用本地 LLM 脱敏文件
  python main.py                            # (LLM) 批量处理 chapter3 评测框架中的 Layer 3 用例
  python main.py --test-id layer3_01_travel_coordination
  python main.py --limit 3 --model qwen3:1.7b
""",
    )

    parser.add_argument(
        '--mode',
        choices=['regex', 'llm'],
        default='regex',
        help='脱敏引擎:regex=离线规则(默认),llm=本地 Ollama 模型。'
             '(注意:不带 --demo/--input 的批量评测路径始终使用 LLM)'
    )

    parser.add_argument(
        '-i', '--input',
        type=str,
        metavar='FILE',
        help='待脱敏的日志文件路径(配合 --mode 选择引擎)'
    )

    parser.add_argument(
        '-o', '--output',
        type=str,
        metavar='FILE',
        help='脱敏结果输出文件路径(仅 --input 模式生效,默认写到 <输入>.sanitized)'
    )

    parser.add_argument(
        '--model',
        type=str,
        default=OLLAMA_MODEL,
        help=f'Ollama 模型名(默认 {OLLAMA_MODEL}),仅 llm 模式生效'
    )

    parser.add_argument(
        '--test-id',
        type=str,
        help='仅处理指定 ID 的评测用例(LLM 批量路径)'
    )

    parser.add_argument(
        '--limit',
        type=int,
        help='最多处理多少个评测用例(LLM 批量路径)'
    )

    parser.add_argument(
        '--demo',
        action='store_true',
        help='运行演示:默认离线规则引擎跑内置代表性样本;加 --mode llm 则用本地 LLM'
    )

    args = parser.parse_args()

    if args.input:
        exit_code = sanitize_file(args.input, args.output, mode=args.mode, model=args.model)
    elif args.demo:
        if args.mode == 'llm':
            exit_code = demo_mode(model=args.model)
        else:
            exit_code = demo_regex_mode()
    else:
        exit_code = main(test_id=args.test_id, limit=args.limit, model=args.model)

    sys.exit(exit_code)

metrics.py

"""
Performance Metrics Module for Log Sanitization
"""

import time
import json
from typing import Dict, List, Optional
from pathlib import Path
from dataclasses import dataclass, asdict
from datetime import datetime

@dataclass
class PerformanceMetrics:
    """Store performance metrics for a single sanitization operation"""
    test_id: str
    conversation_id: str
    input_text_length: int
    input_tokens: int

    # Timing metrics
    prefill_time_ms: float  # Time to First Token (TTFT)
    output_time_ms: float
    total_time_ms: float

    # Token metrics
    output_tokens: int
    prefill_speed_tps: float  # tokens per second
    output_speed_tps: float

    # Sanitization results
    pii_items_found: int
    replacements_made: int
    sanitized_text_length: int

    # Timestamps
    timestamp: str = ""

    def __post_init__(self):
        if not self.timestamp:
            self.timestamp = datetime.now().isoformat()

    def to_dict(self) -> Dict:
        """Convert to dictionary for JSON serialization"""
        return asdict(self)


class MetricsCollector:
    """Collect and aggregate performance metrics"""

    def __init__(self, output_dir: Path):
        self.output_dir = output_dir
        self.metrics_file = output_dir / "performance_metrics.json"
        self.summary_file = output_dir / "performance_summary.json"
        self.metrics: List[PerformanceMetrics] = []

    def add_metric(self, metric: PerformanceMetrics):
        """Add a new metric to the collection"""
        self.metrics.append(metric)

    def calculate_summary(self) -> Dict:
        """Calculate summary statistics across all metrics"""
        if not self.metrics:
            return {"error": "No metrics collected"}

        # Collect all values for each metric
        prefill_times = [m.prefill_time_ms for m in self.metrics]
        output_times = [m.output_time_ms for m in self.metrics]
        total_times = [m.total_time_ms for m in self.metrics]

        input_tokens = [m.input_tokens for m in self.metrics]
        output_tokens = [m.output_tokens for m in self.metrics]

        prefill_speeds = [m.prefill_speed_tps for m in self.metrics]
        output_speeds = [m.output_speed_tps for m in self.metrics]

        pii_counts = [m.pii_items_found for m in self.metrics]
        replacements = [m.replacements_made for m in self.metrics]

        def calculate_stats(values: List[float]) -> Dict:
            """Calculate min, max, mean, median for a list of values"""
            if not values:
                return {"min": 0, "max": 0, "mean": 0, "median": 0}

            sorted_values = sorted(values)
            n = len(sorted_values)

            return {
                "min": min(values),
                "max": max(values),
                "mean": sum(values) / n,
                "median": sorted_values[n // 2] if n % 2 == 1 else 
                         (sorted_values[n // 2 - 1] + sorted_values[n // 2]) / 2
            }

        summary = {
            "total_conversations": len(self.metrics),
            "timestamp": datetime.now().isoformat(),

            "timing_metrics": {
                "prefill_time_ms": calculate_stats(prefill_times),
                "output_time_ms": calculate_stats(output_times),
                "total_time_ms": calculate_stats(total_times)
            },

            "token_metrics": {
                "input_tokens": calculate_stats(input_tokens),
                "output_tokens": calculate_stats(output_tokens),
                "total_input_tokens": sum(input_tokens),
                "total_output_tokens": sum(output_tokens)
            },

            "speed_metrics": {
                "prefill_speed_tps": calculate_stats(prefill_speeds),
                "output_speed_tps": calculate_stats(output_speeds)
            },

            "sanitization_metrics": {
                "pii_items_found": calculate_stats(pii_counts),
                "replacements_made": calculate_stats(replacements),
                "total_pii_found": sum(pii_counts),
                "total_replacements": sum(replacements)
            }
        }

        return summary

    def save_metrics(self):
        """Save all metrics and summary to files"""
        # Save detailed metrics
        metrics_data = [m.to_dict() for m in self.metrics]
        with open(self.metrics_file, 'w') as f:
            json.dump(metrics_data, f, indent=2)

        # Save summary
        summary = self.calculate_summary()
        with open(self.summary_file, 'w') as f:
            json.dump(summary, f, indent=2)

        print(f"✅ Metrics saved to {self.metrics_file}")
        print(f"✅ Summary saved to {self.summary_file}")

    def print_summary(self):
        """Print a human-readable summary of metrics"""
        summary = self.calculate_summary()

        print("\n" + "=" * 60)
        print("PERFORMANCE SUMMARY")
        print("=" * 60)

        print(f"\n📊 Total Conversations Processed: {summary['total_conversations']}")

        print("\n⏱️  Timing Metrics (milliseconds):")
        timing = summary['timing_metrics']
        print(f"   Prefill (TTFT): {timing['prefill_time_ms']['mean']:.2f} ms (median: {timing['prefill_time_ms']['median']:.2f})")
        print(f"   Output Time:    {timing['output_time_ms']['mean']:.2f} ms (median: {timing['output_time_ms']['median']:.2f})")
        print(f"   Total Time:     {timing['total_time_ms']['mean']:.2f} ms (median: {timing['total_time_ms']['median']:.2f})")

        print("\n📝 Token Metrics:")
        tokens = summary['token_metrics']
        print(f"   Average Input Tokens:  {tokens['input_tokens']['mean']:.1f}")
        print(f"   Average Output Tokens: {tokens['output_tokens']['mean']:.1f}")
        print(f"   Total Tokens Processed: {tokens['total_input_tokens'] + tokens['total_output_tokens']}")

        print("\n⚡ Speed Metrics (tokens/second):")
        speed = summary['speed_metrics']
        print(f"   Prefill Speed: {speed['prefill_speed_tps']['mean']:.1f} tok/s")
        print(f"   Output Speed:  {speed['output_speed_tps']['mean']:.1f} tok/s")

        print("\n🔒 Sanitization Results:")
        sanitization = summary['sanitization_metrics']
        print(f"   Total PII Items Found:     {sanitization['total_pii_found']}")
        print(f"   Total Replacements Made:   {sanitization['total_replacements']}")
        print(f"   Average PII per Conversation: {sanitization['pii_items_found']['mean']:.1f}")

        print("\n" + "=" * 60)

openrouter_fallback.py

"""Universal OpenRouter fallback helper (Chapter 2 experiments).

Goal: every experiment keeps working when the direct provider key is missing
but ``OPENROUTER_API_KEY`` is present. Default behavior is fully preserved:

  * If a primary provider key is present -> use the primary provider unchanged.
  * Else if ``OPENROUTER_API_KEY`` is present -> route through OpenRouter
    (base_url=https://openrouter.ai/api/v1) and translate the model id.
  * Else raise a clear error listing every accepted key.

Model translation (only applied when the OpenRouter fallback is active):
  * ids already containing "/" are passed through unchanged;
  * gpt-* / o1* / o3* / o4* / chatgpt* -> "openai/<id>";
  * claude-*                            -> "anthropic/claude-opus-4.8";
  * kimi-*                              -> "moonshotai/kimi-k2.6";
  * anything else                       -> passed through unchanged.
"""

import os

OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"


def is_openrouter_key(api_key):
    """OpenRouter keys reliably start with ``sk-or-``."""
    return bool(api_key) and api_key.startswith("sk-or-")


def map_model_to_openrouter(model):
    """Translate a bare provider model id into an OpenRouter-qualified id."""
    if not model or "/" in model:
        return model
    low = model.lower()
    if low.startswith(("gpt-", "gpt5", "o1", "o3", "o4", "chatgpt")):
        return "openai/" + model
    if low.startswith("claude"):
        return "anthropic/claude-opus-4.8"
    if low.startswith("kimi"):
        return "moonshotai/kimi-k2.6"
    return model


def resolve_llm(model=None, primary_keys=("OPENAI_API_KEY",), primary_base_url=None):
    """Resolve ``(api_key, base_url, model)`` honoring the primary->OpenRouter fallback.

    Args:
        model: requested model id (may be remapped when the fallback activates).
        primary_keys: env var names checked in order for the primary provider.
        primary_base_url: base_url used when a primary key is found
            (``None`` means the OpenAI SDK default / official endpoint).
    """
    or_key = os.getenv("OPENROUTER_API_KEY")
    # gpt-5.x (incl. gpt-5.6*) needs OpenAI org-verification on the direct API;
    # when an OpenRouter key is present, prefer routing these ids through it.
    if or_key and model and model.lower().startswith("gpt-5"):
        return or_key, OPENROUTER_BASE_URL, map_model_to_openrouter(model)
    for env in primary_keys:
        key = os.getenv(env)
        if key:
            return key, primary_base_url, model
    if or_key:
        return or_key, OPENROUTER_BASE_URL, map_model_to_openrouter(model)
    accepted = ", ".join(list(primary_keys) + ["OPENROUTER_API_KEY"])
    raise RuntimeError(
        "No LLM API key found. Set one of the primary keys or the universal "
        "fallback. Accepted keys: " + accepted + ". See env.example."
    )

regex_sanitizer.py

"""
基于规则(正则)的离线日志脱敏引擎

与 agent.py 中依赖本地 LLM 的方案互补:本模块不需要任何模型或网络,
纯靠正则表达式 + 校验算法(Luhn、身份证校验码)识别日志 / 工具输出中的
敏感信息,速度快、结果确定,适合作为 Agent 日志落盘前的第一道防线。

覆盖的敏感信息类别(按匹配优先级从高到低):
  - 私钥 / 证书(PEM 块)
  - JWT
  - 云厂商与第三方密钥(AWS AKIA、GitHub、Slack、Google、OpenAI 风格 sk-)
  - HTTP Authorization: Bearer 令牌
  - 配置中的口令 / 密钥赋值(password=..., token: ... 等)
  - 邮箱地址
  - 信用卡号(Luhn 校验)
  - IBAN 国际银行账号
  - 美国社会安全号(SSN)
  - 中国大陆身份证号(校验码验证)
  - 中国大陆手机号
  - IPv4 地址

每一类都会被替换为带类别标签的占位符(如 [REDACTED_API_KEY]),
既隐去了原值,又保留了“这里原本是什么”的可读性,方便排障。
"""

import re
from collections import Counter
from typing import Dict, List, Tuple


def _luhn_ok(number: str) -> bool:
    """Luhn 校验,用于降低信用卡号的误报率"""
    digits = [int(c) for c in number if c.isdigit()]
    if not 13 <= len(digits) <= 19:
        return False
    checksum = 0
    parity = len(digits) % 2
    for i, d in enumerate(digits):
        if i % 2 == parity:
            d *= 2
            if d > 9:
                d -= 9
        checksum += d
    return checksum % 10 == 0


def _cn_id_ok(value: str) -> bool:
    """中国大陆二代身份证号(18 位)校验码验证"""
    s = value.upper()
    if len(s) != 18 or not s[:17].isdigit():
        return False
    weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
    check_codes = "10X98765432"
    total = sum(int(s[i]) * weights[i] for i in range(17))
    return check_codes[total % 11] == s[17]


# 每条规则:(类别, 占位符, 编译后的正则, 用于取值的分组号, 可选校验函数)
# 分组号为 0 表示整段命中都要脱敏;为 N 表示只脱敏第 N 个捕获组(保留键名等上下文)。
_RULES = [
    (
        "private_key", "[REDACTED_PRIVATE_KEY]",
        re.compile(
            r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"
            r"[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"
        ),
        0, None,
    ),
    (
        "jwt", "[REDACTED_JWT]",
        re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
        0, None,
    ),
    (
        # 连接串中的口令,如 postgres://user:PASSWORD@host:5432/db
        "url_credential", "[REDACTED_URL_CRED]",
        re.compile(r"://[^\s:/@]+:([^\s:/@]+)@"),
        1, None,
    ),
    (
        "aws_access_key", "[REDACTED_AWS_KEY]",
        re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
        0, None,
    ),
    (
        "github_token", "[REDACTED_GITHUB_TOKEN]",
        re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"),
        0, None,
    ),
    (
        "slack_token", "[REDACTED_SLACK_TOKEN]",
        re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
        0, None,
    ),
    (
        "google_api_key", "[REDACTED_GOOGLE_API_KEY]",
        re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
        0, None,
    ),
    (
        "api_key", "[REDACTED_API_KEY]",
        re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
        0, None,
    ),
    (
        "bearer_token", "[REDACTED_BEARER_TOKEN]",
        re.compile(r"(?i)\bBearer\s+([A-Za-z0-9._~+/=-]{10,})"),
        1, None,
    ),
    (
        "secret_assignment", "[REDACTED_SECRET]",
        re.compile(
            r"(?i)(?:password|passwd|pwd|secret|token|api[_-]?key|"
            r"access[_-]?key|auth|credential)[\"']?\s*[=:]\s*[\"']?([^\s\"',}]{4,})"
        ),
        1, None,
    ),
    (
        "email", "[REDACTED_EMAIL]",
        re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
        0, None,
    ),
    (
        "credit_card", "[REDACTED_CREDIT_CARD]",
        re.compile(r"\b(?:\d[ -]?){13,19}\b"),
        0, _luhn_ok,
    ),
    (
        "iban", "[REDACTED_IBAN]",
        re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b"),
        0, None,
    ),
    (
        "us_ssn", "[REDACTED_SSN]",
        re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
        0, None,
    ),
    (
        "cn_id_card", "[REDACTED_ID_CARD]",
        re.compile(r"\b\d{17}[\dXx]\b"),
        0, _cn_id_ok,
    ),
    (
        "cn_phone", "[REDACTED_PHONE]",
        re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)"),
        0, None,
    ),
    (
        "ip_address", "[REDACTED_IP]",
        re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b"),
        0, None,
    ),
]

# 人类可读的类别中文名,用于打印汇总
CATEGORY_LABELS = {
    "private_key": "私钥 / 证书",
    "jwt": "JWT 令牌",
    "url_credential": "连接串凭据",
    "aws_access_key": "AWS 访问密钥",
    "github_token": "GitHub 令牌",
    "slack_token": "Slack 令牌",
    "google_api_key": "Google API Key",
    "api_key": "API Key (sk-)",
    "bearer_token": "Bearer 令牌",
    "secret_assignment": "口令 / 密钥赋值",
    "email": "邮箱地址",
    "credit_card": "信用卡号",
    "iban": "IBAN 银行账号",
    "us_ssn": "美国社保号(SSN)",
    "cn_id_card": "身份证号",
    "cn_phone": "手机号",
    "ip_address": "IP 地址",
}


def sanitize(text: str) -> Tuple[str, List[Dict]]:
    """
    对文本执行离线规则脱敏。

    Returns:
        - 脱敏后的文本
        - 命中列表,每项为 {category, value, placeholder, start, end}
    """
    candidates: List[Dict] = []
    for priority, (category, placeholder, pattern, group, validator) in enumerate(_RULES):
        for m in pattern.finditer(text):
            start, end = m.span(group)
            if start < 0:  # 该捕获组未参与本次匹配
                continue
            value = text[start:end]
            if validator and not validator(value):
                continue
            candidates.append({
                "category": category,
                "placeholder": placeholder,
                "value": value,
                "start": start,
                "end": end,
                "priority": priority,
            })

    # 处理重叠:优先级高(数字小)的规则胜出,避免同一段被重复/错误脱敏
    candidates.sort(key=lambda c: (c["priority"], c["start"]))
    accepted: List[Dict] = []
    for c in candidates:
        if any(not (c["end"] <= a["start"] or c["start"] >= a["end"]) for a in accepted):
            continue
        accepted.append(c)

    # 按位置顺序重建脱敏文本
    accepted.sort(key=lambda c: c["start"])
    parts: List[str] = []
    last = 0
    for c in accepted:
        parts.append(text[last:c["start"]])
        parts.append(c["placeholder"])
        last = c["end"]
    parts.append(text[last:])

    findings = [
        {k: c[k] for k in ("category", "value", "placeholder", "start", "end")}
        for c in accepted
    ]
    return "".join(parts), findings


def summarize(findings: List[Dict]) -> Counter:
    """统计各类别命中次数"""
    return Counter(f["category"] for f in findings)


def print_report(name: str, original: str, redacted: str, findings: List[Dict]) -> None:
    """打印单条样本的 before/after 与命中明细"""
    print(f"\n{'=' * 64}")
    print(f"样本: {name}  (命中 {len(findings)} 处敏感信息)")
    print("=" * 64)
    print("--- 脱敏前 (BEFORE) ---")
    print(original.rstrip())
    print("\n--- 脱敏后 (AFTER) ---")
    print(redacted.rstrip())
    if findings:
        print("\n--- 命中明细 ---")
        for f in findings:
            label = CATEGORY_LABELS.get(f["category"], f["category"])
            print(f"   [{label}] {f['value']}  ->  {f['placeholder']}")


if __name__ == "__main__":
    # 直接运行本模块时,对内置样本做一次快速演示
    from samples import SAMPLES

    total = Counter()
    for name, text in SAMPLES:
        redacted, findings = sanitize(text)
        print_report(name, text, redacted, findings)
        total.update(summarize(findings))

    print(f"\n{'=' * 64}")
    print("脱敏类别汇总")
    print("=" * 64)
    for category, count in total.most_common():
        label = CATEGORY_LABELS.get(category, category)
        print(f"   {label:<16} {count} 处")
    print(f"\n   合计脱敏 {sum(total.values())} 处敏感信息")

samples.py

"""
用于日志脱敏演示的代表性样本

这些样本模拟真实 Agent 运行时最容易泄露敏感信息的几种场景:
工具调用的 HTTP 请求/响应、客服对话、数据库连接报错、CI/Git 日志、
以及配置转储。它们混合了密钥类(API Key、令牌、私钥)与 PII 类
(身份证、手机号、信用卡、邮箱)敏感信息,便于展示脱敏的覆盖面。

注意:以下所有密钥、卡号、证件号均为虚构,仅用于演示,不对应任何真实账户。
"""

SAMPLES = [
    (
        "工具调用日志 (HTTP 请求/响应)",
        """[2024-05-12 09:14:22] TOOL_CALL http_request
  url: https://api.example.com/v1/users/8842
  headers: {"Authorization": "Bearer sk-proj-ABCD1234efgh5678IJKL9012mnop3456qrst", "X-Api-Key": "AIzaSyD-EXAMPLEfakeKEY1234567890abcdef12"}
  response: {"user_id": 8842, "email": "alice.wang@example.com", "phone": "13912345678"}""",
    ),
    (
        "客服对话 (PII 泄露)",
        """USER: 你好,我要办理报销,我的身份证号是 11010119900307721X,手机号 13800138000。
ASSISTANT: 好的,请再提供一下银行卡号以便核对。
USER: 卡号是 4111 1111 1111 1111,另外我的美国社保号是 123-45-6789。
ASSISTANT: 收到,我这就为您登记。""",
    ),
    (
        "数据库连接报错 (凭据泄露)",
        """[ERROR] db.connect failed after 3 retries
  dsn: postgres://admin:S3cr3t_P4ssw0rd@db.internal:5432/prod
  fallback_config: {"db_password": "hunter2xyz", "aws_access_key_id": "AKIAIOSFODNN7EXAMPLE"}
  host_ip: 192.168.10.24""",
    ),
    (
        "CI / Git 日志 (令牌泄露)",
        """Cloning into 'service-repo'...
  remote: using deploy token ghp_16C7e42F292c6912E7710c838347Ae178B4a99
  Slack notify webhook token: xoxb-PLACEHOLDERfaketoken000000notarealslacktoken
  session jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c""",
    ),
    (
        "配置转储 (私钥泄露)",
        """[DEBUG] dumping runtime config
  service_account_key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA7QwZbq3vX9kLmN0pQrStUvWxYz1234567890abcdefghijkl
mnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0987654321zyxwvutsrqponm
QIDAQAB
-----END RSA PRIVATE KEY-----
  admin_contact: ops-team@example.com""",
    ),
]

test_loader.py

"""
Test Case Loader for User Memory Evaluation Framework
"""

import sys
import json
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Any
from config import EVAL_FRAMEWORK_PATH

class TestCaseLoader:
    """Load test cases from user-memory-evaluation framework"""

    def __init__(self):
        self.eval_framework_path = EVAL_FRAMEWORK_PATH
        if not self.eval_framework_path.exists():
            raise ValueError(f"Evaluation framework not found at {self.eval_framework_path}")

    def get_all_test_cases(self) -> List[Dict[str, Any]]:
        """Get all available test cases"""
        script = """
import sys
import json
from pathlib import Path
import io

# Suppress rich console output
import rich.console
rich.console.Console = lambda *args, **kwargs: type('FakeConsole', (), {
    'print': lambda self, *a, **k: None,
    '__getattr__': lambda self, name: lambda *a, **k: None
})()

# Redirect output
old_stdout = sys.stdout
sys.stdout = io.StringIO()

try:
    from framework import UserMemoryEvaluationFramework

    framework = UserMemoryEvaluationFramework()
    test_cases = []

    for tc in framework.list_test_cases():
        test_cases.append({
            'test_id': tc.test_id,
            'category': tc.category,
            'title': tc.title,
            'description': tc.description,
            'num_conversations': len(tc.conversation_histories),
            'user_question': tc.user_question
        })

    # Restore stdout for JSON output
    sys.stdout = old_stdout
    print(json.dumps(test_cases))

except Exception as e:
    sys.stdout = old_stdout
    print(json.dumps([]))
"""

        result = subprocess.run(
            [sys.executable, "-c", script],
            cwd=self.eval_framework_path,
            capture_output=True,
            text=True
        )

        if result.returncode != 0:
            print(f"Error getting test cases: {result.stderr}")
            return []

        try:
            return json.loads(result.stdout)
        except json.JSONDecodeError:
            print(f"Error parsing test cases JSON")
            return []

    def get_layer3_test_cases(self) -> List[Dict[str, Any]]:
        """Get only Layer 3 test cases (most complex)"""
        all_cases = self.get_all_test_cases()
        return [tc for tc in all_cases if tc['category'] == 'layer3']

    def get_test_case_conversations(self, test_id: str) -> List[Dict[str, Any]]:
        """Get detailed conversation histories for a specific test case"""
        script = f"""
import sys
import json
from pathlib import Path
import io

# Redirect stdout to suppress any print statements from framework
old_stdout = sys.stdout
sys.stdout = io.StringIO()

try:
    from framework import UserMemoryEvaluationFramework

    framework = UserMemoryEvaluationFramework()
    tc = framework.get_test_case("{test_id}")

    # Restore stdout for our JSON output
    sys.stdout = old_stdout

    if not tc:
        print(json.dumps([]))
    else:
        conversations = []
        for conv in tc.conversation_histories:
            conv_data = {{
                'conversation_id': conv.conversation_id,
                'timestamp': conv.timestamp,
                'messages': []
            }}

            for msg in conv.messages:
                msg_data = {{
                    'role': msg.role.value,
                    'content': msg.content
                }}
                # Add metadata if it exists
                if hasattr(msg, 'metadata'):
                    msg_data['metadata'] = msg.metadata
                conv_data['messages'].append(msg_data)

            conversations.append(conv_data)

        print(json.dumps(conversations))

except Exception as e:
    import traceback
    sys.stdout = old_stdout
    sys.stderr.write(traceback.format_exc())
    print(json.dumps([]))
"""

        result = subprocess.run(
            [sys.executable, "-c", script],
            cwd=self.eval_framework_path,
            capture_output=True,
            text=True
        )

        if result.returncode != 0:
            print(f"Error getting conversation histories: {result.stderr}")
            return []

        try:
            return json.loads(result.stdout)
        except json.JSONDecodeError as e:
            print(f"Error parsing conversation histories JSON: {e}")
            if result.stdout:
                print(f"stdout (first 500 chars): {result.stdout[:500]}")
            if result.stderr:
                print(f"stderr (first 500 chars): {result.stderr[:500]}")
            return []

    def format_conversation_text(self, conversation: Dict[str, Any]) -> str:
        """Format a conversation into readable text"""
        lines = []
        lines.append(f"Conversation ID: {conversation['conversation_id']}")
        lines.append(f"Timestamp: {conversation['timestamp']}")
        lines.append("-" * 50)

        for msg in conversation['messages']:
            role = msg['role'].upper()
            content = msg['content']
            lines.append(f"{role}: {content}")
            lines.append("")  # Empty line between messages

        return "\n".join(lines)

test_loader_debug.py

#!/usr/bin/env python3
"""Debug script to test loading conversations"""

from test_loader import TestCaseLoader

def main():
    loader = TestCaseLoader()

    # Get all test cases
    print("Getting all test cases...")
    all_cases = loader.get_all_test_cases()
    print(f"Found {len(all_cases)} test cases")

    # Get Layer 3 test cases
    layer3_cases = loader.get_layer3_test_cases()
    print(f"Found {len(layer3_cases)} Layer 3 test cases")

    if layer3_cases:
        # Try to load the first one
        first_case = layer3_cases[0]
        print(f"\nTrying to load: {first_case['test_id']}")

        conversations = loader.get_test_case_conversations(first_case['test_id'])

        if conversations:
            print(f"Successfully loaded {len(conversations)} conversations")
            # Print first conversation snippet
            if conversations[0]['messages']:
                print(f"First message: {conversations[0]['messages'][0]['content'][:100]}...")
        else:
            print("Failed to load conversations")

if __name__ == "__main__":
    main()

test_sanitize_conversation.py

#!/usr/bin/env python3
"""Regression tests for sanitize_conversation() in agent.py.

Bug: detect_pii() catches any backend exception and returns ([], {}), but
sanitize_conversation then subscripted the empty metrics dict
(perf_metrics['input_tokens']) -> KeyError that killed the whole batch.
Fixed with .get(..., 0) defaults so a dead backend degrades gracefully.
"""

import pytest

from agent import LogSanitizationAgent
from metrics import MetricsCollector


def _make_agent(client, tmp_path):
    """Build an agent without __init__ (which requires a live Ollama)."""
    ag = LogSanitizationAgent.__new__(LogSanitizationAgent)
    ag.model = "qwen3:0.6b"
    ag.backend = "ollama"
    ag.metrics_collector = MetricsCollector(tmp_path)
    ag.client = client
    return ag


CONV = {
    "conversation_id": "demo_001",
    "messages": [{"role": "user", "content": "My SSN is 123-45-6789."}],
}


class _DeadClient:
    def chat(self, **kwargs):
        raise ConnectionError("[test] Ollama server is not running")


class _FakeClient:
    """Mimics ollama.Client.chat(stream=True) chunk shape."""

    def __init__(self, payload: str):
        self.payload = payload

    def chat(self, **kwargs):
        return [{"message": {"content": self.payload}}]


def test_dead_backend_returns_result_with_zero_metrics(tmp_path):
    ag = _make_agent(_DeadClient(), tmp_path)
    result = ag.sanitize_conversation(CONV, "t1")  # must not raise
    assert result["pii_found"] == []
    assert result["replacements_made"] == 0
    assert result["metrics"]["input_tokens"] == 0
    assert result["metrics"]["pii_items_found"] == 0


def test_working_backend_still_detects_pii(tmp_path):
    ag = _make_agent(_FakeClient('{"pii_values": ["123-45-6789"]}'), tmp_path)
    result = ag.sanitize_conversation(CONV, "t1")
    assert result["pii_found"] == ["123-45-6789"]
    assert result["replacements_made"] == 1
    assert "[REDACTED]" in result["sanitized_text"]
    assert result["metrics"]["pii_items_found"] == 1