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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,364 @@
"""Contextual Chunking for User Memory Conversations
This module implements Anthropic's Contextual Retrieval approach specifically
for conversation histories. Each conversation chunk gets contextualized with
information about its position and meaning within the full conversation.
"""
import json
import hashlib
import logging
import time
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass, field
from datetime import datetime
from openai import OpenAI
from config import ChunkingConfig, LLMConfig
from chunker import ConversationChunk, ConversationMessage
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@dataclass
class ContextualConversationChunk:
"""Enhanced conversation chunk with contextual information"""
chunk_id: str
conversation_id: str
test_id: str
chunk_index: int
start_round: int
end_round: int
messages: List[ConversationMessage]
original_text: str # Original conversation text
context: str # Generated contextual description
contextualized_text: str # Context + original text
context_tokens: int = 0 # Track token usage
generation_time: float = 0.0 # Track generation time
metadata: Dict[str, Any] = field(default_factory=dict)
context_before: Optional[str] = None
context_after: Optional[str] = None
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
return {
"chunk_id": self.chunk_id,
"conversation_id": self.conversation_id,
"test_id": self.test_id,
"chunk_index": self.chunk_index,
"start_round": self.start_round,
"end_round": self.end_round,
"messages": [msg.to_dict() for msg in self.messages],
"original_text": self.original_text,
"context": self.context,
"contextualized_text": self.contextualized_text,
"context_tokens": self.context_tokens,
"generation_time": self.generation_time,
"metadata": self.metadata,
"context_before": self.context_before,
"context_after": self.context_after,
"created_at": self.created_at
}
@classmethod
def from_basic_chunk(cls, chunk: ConversationChunk, context: str = "",
contextualized_text: str = "", context_tokens: int = 0,
generation_time: float = 0.0) -> 'ContextualConversationChunk':
"""Create from a basic ConversationChunk"""
original_text = chunk.to_text()
if not contextualized_text:
contextualized_text = f"{context}\n\n{original_text}" if context else original_text
return cls(
chunk_id=chunk.chunk_id,
conversation_id=chunk.conversation_id,
test_id=chunk.test_id,
chunk_index=chunk.chunk_index,
start_round=chunk.start_round,
end_round=chunk.end_round,
messages=chunk.messages,
original_text=original_text,
context=context,
contextualized_text=contextualized_text,
context_tokens=context_tokens,
generation_time=generation_time,
metadata=chunk.metadata,
context_before=chunk.context_before,
context_after=chunk.context_after,
created_at=chunk.created_at
)
class ContextualConversationChunker:
"""
Implements contextual chunking specifically for conversation histories.
Key Educational Points:
1. Conversation Context: Each chunk gets context about what was discussed before and after
2. Topic Continuity: Context helps maintain topic understanding across chunk boundaries
3. Temporal Information: Context includes temporal relationships between conversations
4. Semantic Enrichment: Context adds semantic meaning to isolated conversation fragments
"""
def __init__(self,
chunking_config: Optional[ChunkingConfig] = None,
llm_config: Optional[LLMConfig] = None,
use_contextual: bool = True):
"""
Initialize the contextual conversation chunker.
Args:
chunking_config: Configuration for chunking parameters
llm_config: LLM configuration for context generation
use_contextual: Whether to generate contextual chunks (for comparison)
"""
self.chunking_config = chunking_config or ChunkingConfig()
self.llm_config = llm_config or LLMConfig()
self.use_contextual = use_contextual
# Initialize LLM client for context generation
if self.use_contextual:
self._init_llm_client()
# Statistics tracking
self.stats = {
"total_chunks": 0,
"contextual_chunks": 0,
"total_context_tokens": 0,
"total_generation_time": 0.0,
"cache_hits": 0,
"cache_misses": 0
}
# Context cache to avoid regenerating for similar chunks
self.context_cache = {}
logger.info(f"Initialized ContextualConversationChunker (contextual={use_contextual})")
def _init_llm_client(self):
"""Initialize LLM client for context generation"""
client_config, model = self.llm_config.get_client_config()
base_url = client_config.pop("base_url", None)
if base_url:
self.client = OpenAI(base_url=base_url, **client_config)
else:
self.client = OpenAI(**client_config)
self.model = model
logger.info(f"Using {self.llm_config.provider} ({self.model}) for context generation")
def contextualize_chunks(self,
chunks: List[ConversationChunk],
full_conversation_history: Optional[str] = None,
on_chunk_ready: Optional[callable] = None) -> List[ContextualConversationChunk]:
"""
Add contextual information to conversation chunks.
Args:
chunks: List of basic conversation chunks
full_conversation_history: Optional full conversation text for better context
on_chunk_ready: Callback for when each chunk is ready
Returns:
List of contextualized conversation chunks
"""
if not self.use_contextual:
# Return non-contextual chunks for comparison
return [
ContextualConversationChunk.from_basic_chunk(chunk)
for chunk in chunks
]
contextual_chunks = []
logger.info(f"Contextualizing {len(chunks)} conversation chunks")
# Build full conversation if not provided
if full_conversation_history is None:
full_conversation_history = self._build_full_conversation(chunks)
for i, chunk in enumerate(chunks):
logger.info(f"Generating context for chunk {i+1}/{len(chunks)}")
# Generate context for this chunk
chunk_hash = hashlib.md5(chunk.to_text().encode()).hexdigest()
if chunk_hash in self.context_cache:
context = self.context_cache[chunk_hash]
generation_time = 0.0
context_tokens = 0
self.stats["cache_hits"] += 1
logger.debug(f"Cache hit for chunk {chunk.chunk_id}")
else:
# Generate new context using Anthropic's approach
context, context_tokens, generation_time = self._generate_chunk_context(
chunk,
full_conversation_history,
)
# Cache the context
self.context_cache[chunk_hash] = context
self.stats["cache_misses"] += 1
self.stats["total_context_tokens"] += context_tokens
self.stats["total_generation_time"] += generation_time
# Create contextual chunk
contextual_chunk = ContextualConversationChunk.from_basic_chunk(
chunk,
context=context,
context_tokens=context_tokens,
generation_time=generation_time
)
contextual_chunks.append(contextual_chunk)
# Log the full contextualized chunk
logger.info(f"\n{'='*80}")
logger.info(f"📝 CONTEXTUAL CHUNK {i}/{len(chunks)} CREATED")
logger.info(f"{'='*80}")
logger.info(f"Chunk ID: {contextual_chunk.chunk_id}")
logger.info(f"Rounds: {contextual_chunk.start_round}-{contextual_chunk.end_round}")
logger.info(f"Context Generated:\n{context}")
logger.info(f"Original Text Preview (first 500 chars):\n{contextual_chunk.original_text[:500]}...")
logger.info(f"Context Tokens: {context_tokens}")
logger.info(f"Generation Time: {generation_time:.2f}s")
logger.info(f"{'='*80}\n")
# Call callback if provided
if on_chunk_ready:
try:
on_chunk_ready(contextual_chunk)
except Exception as e:
logger.error(f"Error in on_chunk_ready callback: {e}")
# Log progress
if (i + 1) % 5 == 0:
avg_time = self.stats["total_generation_time"] / (i + 1) if i > 0 else 0
logger.info(f"Progress: {i+1}/{len(chunks)} chunks, avg time: {avg_time:.2f}s")
# Update statistics
self.stats["total_chunks"] += len(contextual_chunks)
self.stats["contextual_chunks"] += len(contextual_chunks)
logger.info(f"Contextualization complete. Statistics: {json.dumps(self.stats, indent=2)}")
return contextual_chunks
def _build_full_conversation(self, chunks: List[ConversationChunk]) -> str:
"""Build full conversation text from chunks"""
all_messages = []
for chunk in chunks:
all_messages.extend([chunk.to_text()])
return "\n\n---\n\n".join(all_messages)
def _generate_chunk_context(self,
chunk: ConversationChunk,
full_conversation: str,
) -> Tuple[str, int, float]:
"""
Generate contextual description for a conversation chunk.
This follows Anthropic's Contextual Retrieval approach adapted for conversations:
1. Provide the full conversation history
2. Show the specific chunk
3. Ask for concise context about what's being discussed
Returns:
Tuple of (context, token_count, generation_time)
"""
start_time = time.time()
try:
# Build context about surrounding conversation
chunk_text = chunk.to_text()
# Create prompt following Anthropic's template with conversation-specific adaptations
prompt = f"""<full_conversation>
{full_conversation}
</full_conversation>
Here is a specific conversation chunk we want to situate within the whole conversation history:
<chunk>
Conversation rounds {chunk.start_round} to {chunk.end_round}:
{chunk_text}
</chunk>
Additional context:
- This is chunk {chunk.chunk_index + 1} from conversation {chunk.conversation_id}
Please provide a brief context (2-3 sentences) that describes:
1. What main topics or issues are being discussed in this chunk
2. Any key decisions, facts, or preferences mentioned
3. How this relates to the overall conversation flow
Write the context in a way that would help someone quickly understand what this conversation segment is about when searching through conversation history. Use the same language as the conversation."""
# Generate context
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "user", "content": prompt}
],
temperature=_reasoning_safe_temperature(self.model, 0.3), # Low temperature for consistency
max_tokens=150 # Slightly more tokens for conversation context
)
context = response.choices[0].message.content.strip()
# Estimate token count (rough approximation)
token_count = len(prompt.split()) + len(context.split())
generation_time = time.time() - start_time
# Add structured prefix to context
context_prefix = f"[Conversation {chunk.conversation_id}, Rounds {chunk.start_round}-{chunk.end_round}] "
context = context_prefix + context
logger.debug(f"Generated context in {generation_time:.2f}s: {context}")
return context, token_count, generation_time
except Exception as e:
logger.error(f"Error generating chunk context: {e}")
# Return a basic context on error
basic_context = f"[Conversation chunk {chunk.chunk_index + 1}, Rounds {chunk.start_round}-{chunk.end_round}]"
return basic_context, 0, time.time() - start_time
def get_statistics(self) -> Dict[str, Any]:
"""Get chunking statistics"""
stats = self.stats.copy()
# Calculate averages
if stats["contextual_chunks"] > 0:
stats["avg_context_tokens"] = stats["total_context_tokens"] / stats["contextual_chunks"]
stats["avg_generation_time"] = stats["total_generation_time"] / stats["contextual_chunks"]
else:
stats["avg_context_tokens"] = 0
stats["avg_generation_time"] = 0
# Cache efficiency
total_cache_ops = stats["cache_hits"] + stats["cache_misses"]
if total_cache_ops > 0:
stats["cache_hit_rate"] = stats["cache_hits"] / total_cache_ops
else:
stats["cache_hit_rate"] = 0
# Cost estimation (rough)
if self.llm_config.provider == "openai":
cost_per_1k = 0.03
else:
cost_per_1k = 0.01
stats["estimated_cost"] = (stats["total_context_tokens"] / 1000) * cost_per_1k
return stats