跳转至

contextual-retrieval-for-user-memory

第3章 · 用户记忆和知识库 · 配套项目 chapter3/contextual-retrieval-for-user-memory

项目说明

实验 3-12:利用上下文感知检索增强用户记忆

将上下文感知检索技术应用于用户记忆的构建,是解决传统对话历史分块所面临的核心痛点,并迈向更高层次记忆能力的关键。本项目实现了一个双层记忆系统,结合了:

  1. 上下文感知检索(Contextual RAG):对话历史的精准检索
  2. 高级 JSON 卡片(Advanced JSON Cards):结构化的核心事实存储

🆕 最新更新

LLM-Based Memory Card Generation

  • 自动提取:使用 LLM 从对话中智能提取结构化记忆卡片
  • 完整结构:每张卡片包含 backstory、person、relationship 等必要字段
  • 智能降级:当 LLM 不可用时自动降级到关键词提取

LLM Judge Integration

  • 自动评估:集成 LLM Judge 对 Agent 回答进行自动评分
  • 双路径支持:支持导入模块或直接 API 调用
  • 详细反馈:提供 0-1 分数、通过/失败状态和评估理由

Enhanced Debugging

  • 内存卡片可视化:评估时自动打印所有记忆卡片的完整 JSON
  • 测试用例排序:按名称字母顺序显示测试用例
  • 评估透明度:清晰显示 LLM Judge 使用状态

核心创新

1. 上下文增强的对话分块

传统的对话分块会丢失上下文信息。例如,一段孤立的对话片段"好的,就订这个吧"本身毫无信息量。只有知道上文是在讨论"从上海到西雅图的、价格为500美元的单程机票",这段对话才有意义。

本系统在索引对话历史之前,增加了关键的"上下文生成"步骤: - 每个对话块都会调用 LLM 生成包含关键背景信息的前缀摘要 - 上下文包括时间、人物和意图等关键线索 - 极大提升了检索的准确性和相关性

2. 双层记忆结构

Advanced JSON Cards(常驻记忆) - 存储结构化的、总结性的核心事实 - 始终固定在 Agent 的上下文中 - 包含 backstory(信息来源)和 relationship(关联人员)等元数据 - 如:"用户 Jessica 的护照将于2025年2月18日过期"

Contextual RAG(按需检索) - 提供对非结构化的原始对话细节的精准访问 - 快速找到具体讨论的完整上下文 - 作为决策的"证据"支持

3. LLM-Based Memory Extraction

系统现在能够从对话中智能提取结构化记忆卡片:

# 自动从对话生成记忆卡片
cards = indexer._generate_summary_cards(chunks, conversation_id)

# 生成的卡片示例:
{
    "category": "financial",
    "card_key": "bank_account_primary", 
    "backstory": "用户在开设账户时提供了银行信息",
    "date_created": "2024-01-15 10:30:00",
    "person": "John Smith (primary)",
    "relationship": "primary account holder",
    "bank_name": "Chase Bank",
    "account_type": "checking",
    "account_ending": "4567"
}

项目结构

contextual-retrieval-for-user-memory/
├── contextual_chunking.py      # 上下文感知分块
├── advanced_memory_manager.py  # 高级JSON卡片管理
├── contextual_indexer.py       # 双层记忆索引器(含LLM提取)
├── contextual_agent.py         # 结合双层记忆的Agent
├── contextual_evaluator.py     # 评估框架(含LLM Judge)
├── contextual_compare.py       # 🆕 离线对比脚本:上下文化 vs 原始块的召回(无需 API)
├── memory_qa_eval.json         # 🆕 离线对比用的受控记忆问答对照集
├── main.py                     # 主入口(argparse,含 --mode compare 离线对比)
├── config.py                   # 配置管理
├── chunker.py                  # 基础分块器
├── tools.py                    # Agent工具
└── requirements.txt            # 依赖项

安装与配置

1. 安装依赖

pip install -r requirements.txt

2. 配置环境变量

创建 .env 文件:

# LLM Provider Configuration
MOONSHOT_API_KEY=your_api_key_here
ARK_API_KEY=your_api_key_here
SILICONFLOW_API_KEY=your_api_key_here
OPENAI_API_KEY=your_api_key_here

# Default Provider
LLM_PROVIDER=kimi  # Options: kimi, doubao, siliconflow, openai

# Model Settings
LLM_MODEL=kimi-k3  # 或其他模型

3. 启动检索管道服务

cd ../retrieval-pipeline
python api_server.py

使用示例

离线对比:上下文化到底有没有用?(无需 API,推荐先跑这个)

本实验的核心论点是:在把对话记忆块送入嵌入/索引前,先为每块生成一段『上下文前缀』,能提升脱离上下文的孤立片段(如『好的,就订这个吧』)的召回。 --mode compare 提供一个完全离线、无需任何 API Key 或检索服务的受控对照实验来量化这一点。

它用同一份上下文,分别度量『不拼接(plain)』与『拼接后再索引(contextual)』两种方式的召回,变量只有『索引文本是否含上下文前缀』,因此结果直接反映上下文化本身的贡献。检索采用确定性的 BM25 词法检索(纯 Python、无第三方依赖)作为神经嵌入的离线代理;对照数据集见 memory_qa_eval.json(受控教学集,可用 --dataset 替换)。

# 打印对比指标表(Recall@1 / Recall@3 / MRR)
python main.py --mode compare
# 等价于直接运行独立脚本:
python contextual_compare.py

# 对单条查询做 plain vs contextual 的 Top-K 检索对比
python main.py --mode compare --query '我最后确认预订的那张机票是哪个航班?'

# 保存完整结果(含逐查询名次明细)到 JSON
python main.py --mode compare --output results/compare.json

实测输出(12 个记忆块、8 条查询的受控集):

方法                            Recall@1  Recall@3       MRR
--------------------------------------------------------------------
Plain(直接索引原始块)               0.625     1.000     0.792
Contextual(上下文化后索引)        0.750     1.000     0.875
--------------------------------------------------------------------
提升(Δ)                         +0.125    +0.000    +0.083

其中『我订的西雅图酒店确认了吗』这类查询,gold 记忆块(『可以,帮我订下来』)在 Plain 下排名第 3、上下文化后升到第 1——正是上下文前缀把孤立确认片段重新锚定回了『西雅图凯悦酒店』的情境。

说明:这是一个离线词法代理,用于在无 API 环境下清晰地演示上下文化的机制与方向性收益。生产管线中,上下文前缀由 LLM 逐块生成、并用神经嵌入 + 混合检索索引(见下方 --mode evaluate),需要配置 API Key。

端到端评估(需 API / 检索服务)

# 交互式界面(默认)
python main.py

# 评估特定分类(需 LLM 与检索管道服务)
python main.py --mode evaluate --category layer1

# 关闭上下文化做对照,并指定模型与输出
python main.py --mode evaluate --category layer1 --no-contextual --model gpt-5.6-luna --output results/plain_eval.json

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

交互式测试界面

运行 python main.py 进入交互式界面:

Main Menu:
1. 🚀 Demo Mode (Quick Start)
2. 📚 Load & Index Conversations
3. 🎴 Manage Memory Cards
4. 🔍 Test Query
5. 📊 Evaluate All Test Cases (by Category) [LLM Judge]
6. 🎯 Evaluate Specific Test Case [LLM Judge]
7. 📈 Show Statistics
8. ⚙️  Configure Settings
0. Exit

评估输出示例

============================================================
DEBUG: All Memory Cards in System
============================================================

[financial.bank_account_primary]
{
  "backstory": "用户开设银行账户时提供的信息",
  "date_created": "2024-06-12 14:30:00",
  "person": "Michael James Robertson (primary)",
  "relationship": "primary account holder",
  "bank_name": "First National Bank",
  "account_number": "4429853327",
  "routing_number": "123006800"
}

Total Memory Cards: 5
============================================================

LLM Judge Evaluation Results
============================================================
Reward: 1.000/1.000
Passed: Yes
Reasoning: The agent correctly provided the checking account number...
============================================================

工作流程示例

当用户询问"为我一月的东京之行,还有什么要准备的吗?"时:

  1. 事实回顾:Agent 首先审视 Advanced JSON Cards 中的内容
  2. 发现"东京之行"信息(1月25日出发)
  3. 发现"护照信息"(2月18日过期)

  4. 关联与推理:通过对比核心事实

  5. 识别出机票日期与护照过期日期接近的风险

  6. 细节验证:启动 RAG 检索

  7. 搜索与"护照"和"东京机票"相关的对话片段
  8. 获取原始讨论的所有细节

  9. 主动服务:结合两种记忆

  10. 给出关键建议:"您的护照即将过期,强烈建议您立即加急办理续签"

  11. 自动评估:LLM Judge 评估答案

  12. 评分:0.95/1.0
  13. 理由:正确识别风险并给出适当建议

参考资料

许可证

MIT License

OpenRouter 通用回退 / Universal OpenRouter fallback

This experiment now supports a universal OpenRouter fallback for its chat LLM.

  • If the primary provider key (e.g. MOONSHOT_API_KEY / KIMI_API_KEY / OPENAI_API_KEY / DOUBAO_API_KEY …) is present, behavior is unchanged.
  • Else if OPENROUTER_API_KEY is set, the chat LLM is automatically routed through OpenRouter (https://openrouter.ai/api/v1). Model names are mapped automatically: gpt-*/o1-*openai/…, claude-*anthropic/claude-opus-4.8, kimi-*moonshotai/kimi-k2.6, ids already containing / are kept as-is, and other provider-native ids (e.g. doubao-*) fall back to openai/gpt-5.6-luna. Set OPENROUTER_MODEL to force a specific OpenRouter model id.
  • Else a clear error lists the accepted keys.

Add OPENROUTER_API_KEY=... to your .env (see env.example) to enable it.

源代码

advanced_memory_manager.py

"""Advanced Memory Manager with JSON Cards for User Memory System

This module implements the Advanced JSON Cards approach from week2/user-memory,
which stores structured, summarized core facts about users.
"""

import json
import os
import uuid
import logging
from datetime import datetime
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass, field, asdict
from pathlib import Path

logger = logging.getLogger(__name__)


@dataclass
class AdvancedMemoryCard:
    """
    Represents an advanced memory card with complete metadata.

    Each card MUST include:
    - backstory: Context about when/why this information was learned
    - date_created: Timestamp when created
    - person: Who this relates to
    - relationship: Role/relationship to primary user
    - Additional fields based on information type
    """
    category: str
    card_key: str
    backstory: str
    date_created: str
    person: str
    relationship: str
    data: Dict[str, Any] = field(default_factory=dict)
    _metadata: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for storage"""
        result = {
            "backstory": self.backstory,
            "date_created": self.date_created,
            "person": self.person,
            "relationship": self.relationship,
            **self.data
        }
        if self._metadata:
            result["_metadata"] = self._metadata
        return result

    @classmethod
    def from_dict(cls, category: str, card_key: str, data: Dict[str, Any]) -> 'AdvancedMemoryCard':
        """Create from dictionary"""
        metadata = data.pop("_metadata", {})
        return cls(
            category=category,
            card_key=card_key,
            backstory=data.get("backstory", ""),
            date_created=data.get("date_created", ""),
            person=data.get("person", ""),
            relationship=data.get("relationship", ""),
            data={k: v for k, v in data.items() 
                  if k not in ["backstory", "date_created", "person", "relationship"]},
            _metadata=metadata
        )


class AdvancedMemoryManager:
    """
    Advanced JSON Memory Manager for structured user information.

    This manager handles the persistent, structured memory that stays
    in the agent's context at all times (the "备忘录" or memo).
    """

    def __init__(self, user_id: str, storage_dir: str = "memory_storage"):
        """
        Initialize the advanced memory manager.

        Args:
            user_id: Unique user identifier
            storage_dir: Directory for storing memory files
        """
        self.user_id = user_id
        self.storage_dir = Path(storage_dir)
        self.storage_dir.mkdir(parents=True, exist_ok=True)

        self.memory_file = self.storage_dir / f"{user_id}_advanced_memory.json"
        self.categories: Dict[str, Dict[str, AdvancedMemoryCard]] = {}

        self.load_memory()
        logger.info(f"Initialized AdvancedMemoryManager for user {user_id}")

    def load_memory(self):
        """Load memory cards from storage"""
        if self.memory_file.exists():
            try:
                with open(self.memory_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)

                    # Load categories and cards
                    for category, cards in data.get('categories', {}).items():
                        self.categories[category] = {}
                        for card_key, card_data in cards.items():
                            self.categories[category][card_key] = AdvancedMemoryCard.from_dict(
                                category, card_key, card_data
                            )

                    logger.info(f"Loaded {sum(len(cards) for cards in self.categories.values())} memory cards")
            except Exception as e:
                logger.error(f"Error loading memory: {e}")
                self.categories = {}
        else:
            logger.info(f"No existing memory file for user {self.user_id}")

    def save_memory(self):
        """Save memory cards to storage"""
        try:
            data = {
                'user_id': self.user_id,
                'type': 'advanced_json_cards',
                'updated_at': datetime.now().isoformat(),
                'categories': {}
            }

            # Convert cards to dict format
            for category, cards in self.categories.items():
                data['categories'][category] = {}
                for card_key, card in cards.items():
                    data['categories'][category][card_key] = card.to_dict()

            with open(self.memory_file, 'w', encoding='utf-8') as f:
                json.dump(data, f, indent=2, ensure_ascii=False)

            logger.info(f"Saved {sum(len(cards) for cards in self.categories.values())} memory cards")
        except Exception as e:
            logger.error(f"Error saving memory: {e}")

    def add_card(self, card: AdvancedMemoryCard) -> str:
        """
        Add a new memory card.

        Args:
            card: The memory card to add

        Returns:
            Memory ID in format category.card_key
        """
        if card.category not in self.categories:
            self.categories[card.category] = {}

        # Add metadata
        card._metadata = {
            'created_at': datetime.now().isoformat(),
            'updated_at': datetime.now().isoformat()
        }

        # Ensure required fields
        if not card.backstory:
            logger.warning("Memory card missing backstory")
        if not card.date_created:
            card.date_created = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        if not card.person:
            card.person = "Primary User"
        if not card.relationship:
            card.relationship = "primary account holder"

        self.categories[card.category][card.card_key] = card
        self.save_memory()

        memory_id = f"{card.category}.{card.card_key}"
        logger.info(f"Added memory card: {memory_id}")
        return memory_id

    def update_card(self, category: str, card_key: str, updates: Dict[str, Any]) -> bool:
        """
        Update an existing memory card.

        Args:
            category: Card category
            card_key: Card key
            updates: Fields to update

        Returns:
            True if successful
        """
        if category not in self.categories or card_key not in self.categories[category]:
            logger.warning(f"Card not found: {category}.{card_key}")
            return False

        card = self.categories[category][card_key]

        # Update fields
        for key, value in updates.items():
            if key in ["backstory", "date_created", "person", "relationship"]:
                setattr(card, key, value)
            elif key != "_metadata":
                card.data[key] = value

        # Update metadata
        if card._metadata:
            card._metadata['updated_at'] = datetime.now().isoformat()

        self.save_memory()
        logger.info(f"Updated memory card: {category}.{card_key}")
        return True

    def delete_card(self, category: str, card_key: str) -> bool:
        """
        Delete a memory card.

        Args:
            category: Card category
            card_key: Card key

        Returns:
            True if successful
        """
        if category in self.categories and card_key in self.categories[category]:
            del self.categories[category][card_key]

            # Clean up empty categories
            if not self.categories[category]:
                del self.categories[category]

            self.save_memory()
            logger.info(f"Deleted memory card: {category}.{card_key}")
            return True

        logger.warning(f"Card not found for deletion: {category}.{card_key}")
        return False

    def get_card(self, category: str, card_key: str) -> Optional[AdvancedMemoryCard]:
        """Get a specific memory card"""
        if category in self.categories and card_key in self.categories[category]:
            return self.categories[category][card_key]
        return None

    def search_cards(self, query: str) -> List[Tuple[str, AdvancedMemoryCard]]:
        """
        Search memory cards by query.

        Args:
            query: Search query

        Returns:
            List of (memory_id, card) tuples
        """
        query_lower = query.lower()
        results = []

        for category, cards in self.categories.items():
            for card_key, card in cards.items():
                memory_id = f"{category}.{card_key}"

                # Search in all card fields
                card_str = json.dumps(card.to_dict(), ensure_ascii=False).lower()

                if (query_lower in category.lower() or
                    query_lower in card_key.lower() or
                    query_lower in card_str):

                    results.append((memory_id, card))

        return results

    def get_context_string(self, max_cards: Optional[int] = None) -> str:
        """
        Get memory cards as formatted string for LLM context.

        Args:
            max_cards: Maximum number of cards to include (None for all)

        Returns:
            Formatted string of memory cards
        """
        if not self.categories:
            return "No advanced memory cards available."

        lines = ["=== ADVANCED USER MEMORY CARDS ===\n"]

        card_count = 0
        for category, cards in self.categories.items():
            lines.append(f"\n[Category: {category}]")

            for card_key, card in cards.items():
                if max_cards and card_count >= max_cards:
                    remaining = sum(len(c) for c in self.categories.values()) - card_count
                    lines.append(f"\n... and {remaining} more memory cards")
                    break

                lines.append(f"\n  Card '{card_key}':")

                # Format card data nicely
                card_dict = card.to_dict()
                # Remove internal metadata from display
                card_dict.pop('_metadata', None)

                # Display key fields first
                lines.append(f"    - Backstory: {card.backstory}")
                lines.append(f"    - Person: {card.person}")
                lines.append(f"    - Relationship: {card.relationship}")
                lines.append(f"    - Date Created: {card.date_created}")

                # Display other data fields
                for key, value in card.data.items():
                    if isinstance(value, dict):
                        lines.append(f"    - {key}:")
                        for k, v in value.items():
                            lines.append(f"        {k}: {v}")
                    elif isinstance(value, list):
                        lines.append(f"    - {key}: {', '.join(str(v) for v in value)}")
                    else:
                        lines.append(f"    - {key}: {value}")

                card_count += 1

            if max_cards and card_count >= max_cards:
                break

        lines.append(f"\n\n[Total Memory Cards: {sum(len(cards) for cards in self.categories.values())}]")

        return "\n".join(lines)

    def summarize_for_conversation(self, conversation_id: str) -> Dict[str, Any]:
        """
        Summarize memory cards for a specific conversation.

        This creates a conversation-specific summary of relevant memory cards
        that can be stored alongside the conversation chunks.

        Args:
            conversation_id: The conversation identifier

        Returns:
            Summary dictionary
        """
        summary = {
            "conversation_id": conversation_id,
            "timestamp": datetime.now().isoformat(),
            "total_cards": sum(len(cards) for cards in self.categories.values()),
            "categories": {}
        }

        # Summarize by category
        for category, cards in self.categories.items():
            if cards:
                summary["categories"][category] = {
                    "count": len(cards),
                    "keys": list(cards.keys()),
                    "sample": {}
                }

                # Include first 2 cards as samples
                for i, (card_key, card) in enumerate(cards.items()):
                    if i >= 2:
                        break
                    summary["categories"][category]["sample"][card_key] = {
                        "person": card.person,
                        "backstory": card.backstory[:100] + "..." if len(card.backstory) > 100 else card.backstory
                    }

        return summary

    def clear_all_memories(self):
        """Clear all memory cards"""
        self.categories = {}
        self.save_memory()
        logger.info(f"Cleared all memory cards for user {self.user_id}")

    def get_statistics(self) -> Dict[str, Any]:
        """Get memory statistics"""
        stats = {
            "user_id": self.user_id,
            "total_cards": sum(len(cards) for cards in self.categories.values()),
            "categories": {}
        }

        for category, cards in self.categories.items():
            stats["categories"][category] = {
                "count": len(cards),
                "cards": list(cards.keys())
            }

        # Calculate average backstory length
        all_backstories = []
        for cards in self.categories.values():
            for card in cards.values():
                if card.backstory:
                    all_backstories.append(len(card.backstory))

        if all_backstories:
            stats["avg_backstory_length"] = sum(all_backstories) / len(all_backstories)
        else:
            stats["avg_backstory_length"] = 0

        return stats


def create_sample_cards() -> List[AdvancedMemoryCard]:
    """Create sample memory cards for testing"""
    cards = [
        AdvancedMemoryCard(
            category="financial",
            card_key="bank_account_primary",
            backstory="User shared their banking details while setting up automatic bill payments",
            date_created="2024-01-15 10:30:00",
            person="John Smith (primary)",
            relationship="primary account holder",
            data={
                "bank_name": "Chase Bank",
                "account_type": "checking",
                "account_ending": "4567",
                "routing_number": "021000021",
                "purpose": "primary checking for bills"
            }
        ),
        AdvancedMemoryCard(
            category="medical",
            card_key="doctor_dermatologist_sarah",
            backstory="User needed to schedule a dermatology appointment for their daughter's skin condition",
            date_created="2024-01-16 14:00:00",
            person="Sarah Smith (daughter)",
            relationship="family member",
            data={
                "doctor_name": "Dr. Emily Johnson",
                "specialty": "Pediatric Dermatology",
                "clinic": "Children's Health Center",
                "phone": "555-0123",
                "condition_treated": "eczema"
            }
        ),
        AdvancedMemoryCard(
            category="travel",
            card_key="passport_jessica",
            backstory="User mentioned passport expiration while planning international travel",
            date_created="2024-12-20 09:00:00",
            person="Jessica Thompson (primary)",
            relationship="primary account holder",
            data={
                "passport_number": "XXXXX1234",
                "expiration_date": "2025-02-18",
                "issuing_country": "USA",
                "needs_renewal": True
            }
        ),
        AdvancedMemoryCard(
            category="travel",
            card_key="tokyo_trip_january",
            backstory="User booked a trip to Tokyo for a business conference",
            date_created="2024-12-22 11:00:00",
            person="Jessica Thompson (primary)",
            relationship="primary account holder",
            data={
                "destination": "Tokyo, Japan",
                "departure_date": "2025-01-25",
                "return_date": "2025-02-01",
                "purpose": "business conference",
                "hotel": "Hilton Tokyo",
                "flight": "UA837"
            }
        )
    ]

    return cards

agent.py

"""Agentic RAG Agent for User Memory Evaluation

This agent uses RAG-indexed conversation memories to answer questions
about user interactions, following the ReAct pattern.
"""

import json
import logging
from typing import List, Dict, Any, Optional, Generator
from dataclasses import dataclass, field
from datetime import datetime
from openai import OpenAI

from config import Config
from tools import MemoryTools, get_tool_definitions
from indexer import MemoryIndexer


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 Message:
    """Represents a message in the conversation"""
    role: str  # "user", "assistant", "tool"
    content: str
    tool_calls: Optional[List[Dict[str, Any]]] = None
    tool_call_id: Optional[str] = None
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())


@dataclass
class AgentTrajectory:
    """Tracks the agent's reasoning and tool usage"""
    test_id: str
    question: str
    iterations: List[Dict[str, Any]] = field(default_factory=list)
    final_answer: Optional[str] = None
    tool_calls: List[Dict[str, Any]] = field(default_factory=list)
    total_time: Optional[float] = None
    success: bool = False

    def to_dict(self) -> Dict[str, Any]:
        return {
            "test_id": self.test_id,
            "question": self.question,
            "iterations": self.iterations,
            "final_answer": self.final_answer,
            "tool_calls": self.tool_calls,
            "total_time": self.total_time,
            "success": self.success,
            "total_iterations": len(self.iterations),
            "total_tool_calls": len(self.tool_calls)
        }


class UserMemoryRAGAgent:
    """Agent that uses RAG to answer questions about user conversation history"""

    def __init__(self, 
                 indexer: MemoryIndexer,
                 config: Optional[Config] = None):
        """
        Initialize the agent

        Args:
            indexer: The memory indexer with loaded conversations
            config: Configuration object
        """
        self.config = config or Config.from_env()
        self.indexer = indexer
        self.memory_tools = MemoryTools(indexer)

        # Initialize LLM client
        self._init_llm_client()

        # Tool definitions
        self.tools = get_tool_definitions()

        # Conversation history
        self.conversation_history: List[Dict[str, Any]] = []

        logger.info(f"Initialized UserMemoryRAGAgent with provider: {self.config.llm.provider}")

    def _init_llm_client(self):
        """Initialize the LLM client based on provider"""
        client_config, model = self.config.llm.get_client_config()

        # Extract base_url if present
        base_url = client_config.pop("base_url", None)

        # Create OpenAI client
        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 model: {self.model}")

    def _get_system_prompt(self, test_id: str) -> str:
        """Generate the system prompt"""
        return f"""You are an AI assistant with access to indexed conversation memories from user interactions. 
Your task is to answer questions about these conversations accurately based ONLY on the information you can find in the indexed memories.

Current Test Case: {test_id}

## Important Guidelines:

1. **Memory Search Only**: You MUST only answer based on information found through the memory search tools. If the information is not available in the indexed conversations, clearly state that you cannot find it.

2. **Use Tools Effectively**:
   - Use `search_memory` to find relevant information across all conversations
   - Use `get_conversation_context` when you need more context around a search result
   - Use `get_full_conversation` to review entire conversation histories when needed

3. **Multiple Searches**: Don't hesitate to perform multiple searches with different queries to find all relevant information. Different phrasings may yield different results.

4. **Citations Required**: Always mention which conversation or chunk you found the information in when providing answers.

5. **Be Thorough**: For complex questions, gather information from multiple chunks and conversations before formulating your answer.

6. **Handle Ambiguity**: If you find conflicting information or multiple possible answers, report all of them with their sources.

Remember: Your credibility depends on providing accurate, well-sourced information from the conversation memories only."""

    def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
        """Execute a tool and return the result"""
        try:
            # Log tool call parameters to console
            logger.info("="*80)
            logger.info(f"TOOL CALL: {tool_name}")
            logger.info(f"PARAMETERS: {json.dumps(arguments, indent=2, ensure_ascii=False)}")
            logger.info("-"*80)

            if tool_name == "search_memory":
                query = arguments.get("query", "")
                filter_test_id = arguments.get("filter_test_id")

                result = self.memory_tools.search_memory(query, filter_test_id)

                # Log result to console
                result_dict = result.to_dict()
                logger.info("TOOL RESULT:")
                logger.info(json.dumps(result_dict, indent=2, ensure_ascii=False))
                logger.info("="*80)

                return result_dict

            elif tool_name == "get_conversation_context":
                chunk_id = arguments.get("chunk_id", "")
                context_size = arguments.get("context_size", 2)

                result = self.memory_tools.get_conversation_context(chunk_id, context_size)

                # Log result to console
                result_dict = result.to_dict()
                logger.info("TOOL RESULT:")
                logger.info(json.dumps(result_dict, indent=2, ensure_ascii=False))
                logger.info("="*80)

                return result_dict

            elif tool_name == "get_full_conversation":
                conversation_id = arguments.get("conversation_id", "")
                test_id = arguments.get("test_id", "")

                result = self.memory_tools.get_full_conversation(conversation_id, test_id)

                # Log result to console
                result_dict = result.to_dict()
                logger.info("TOOL RESULT:")
                logger.info(json.dumps(result_dict, indent=2, ensure_ascii=False))
                logger.info("="*80)

                return result_dict

            else:
                return {"status": "error", "error": f"Unknown tool: {tool_name}"}

        except Exception as e:
            logger.error(f"Tool execution error: {e}")
            return {"status": "error", "error": str(e)}

    def answer_question(self, 
                       question: str,
                       test_id: str,
                       stream: bool = False) -> Dict[str, Any]:
        """
        Answer a question about user conversation history using RAG

        Args:
            question: The question to answer
            test_id: The test case ID for context
            stream: Whether to stream the response

        Returns:
            Dictionary containing the answer and trajectory
        """
        start_time = datetime.now()
        trajectory = AgentTrajectory(test_id=test_id, question=question)

        # Build initial messages
        messages = [
            {"role": "system", "content": self._get_system_prompt(test_id)},
            {"role": "user", "content": question}
        ]

        # Track iterations
        iterations = 0
        max_iterations = self.config.evaluation.max_iterations

        # Process with ReAct loop
        while iterations < max_iterations:
            iterations += 1
            iteration_data = {"iteration": iterations, "timestamp": datetime.now().isoformat()}

            if self.config.agent.enable_reasoning:
                logger.info(f"\n{'='*60}")
                logger.info(f"Iteration {iterations}/{max_iterations}")
                logger.info(f"{'='*60}")

            try:
                # Call LLM with tools
                response = self.client.chat.completions.create(
                    model=self.model,
                    messages=messages,
                    tools=self.tools,
                    tool_choice="auto",
                    temperature=_reasoning_safe_temperature(self.model, self.config.llm.temperature),
                    max_tokens=self.config.llm.max_tokens,
                    stream=False
                )

                message = response.choices[0].message
                iteration_data["assistant_message"] = message.content or ""

                # Log the LLM response content
                if message.content:
                    logger.info("-"*60)
                    logger.info(f"LLM Response: {message.content}")
                    logger.info("-"*60)

                # Add assistant message to history
                assistant_msg = {"role": "assistant", "content": message.content or ""}
                if message.tool_calls:
                    assistant_msg["tool_calls"] = [
                        {
                            "id": tc.id,
                            "type": tc.type,
                            "function": {
                                "name": tc.function.name,
                                "arguments": tc.function.arguments
                            }
                        } for tc in message.tool_calls
                    ]
                    iteration_data["tool_calls"] = []

                messages.append(assistant_msg)

                # Process tool calls if present
                if message.tool_calls:
                    for tool_call in message.tool_calls:
                        tool_name = tool_call.function.name
                        try:
                            arguments = json.loads(tool_call.function.arguments)
                        except json.JSONDecodeError:
                            arguments = {}


                        # Execute tool
                        result = self._execute_tool(tool_name, arguments)

                        # Track tool call
                        tool_call_data = {
                            "tool": tool_name,
                            "arguments": arguments,
                            "result": result,
                            "timestamp": datetime.now().isoformat()
                        }
                        iteration_data["tool_calls"].append(tool_call_data)
                        trajectory.tool_calls.append(tool_call_data)

                        # Add tool result to messages
                        tool_message = {
                            "role": "tool",
                            "tool_call_id": tool_call.id,
                            "content": json.dumps(result, ensure_ascii=False)
                        }
                        messages.append(tool_message)

                    # Continue loop for next iteration
                    trajectory.iterations.append(iteration_data)
                    continue
                else:
                    # No tool calls, we have final answer
                    trajectory.iterations.append(iteration_data)
                    trajectory.final_answer = message.content or ""
                    trajectory.success = True

                    # Calculate total time
                    end_time = datetime.now()
                    trajectory.total_time = (end_time - start_time).total_seconds()

                    # Return result
                    result = {
                        "answer": trajectory.final_answer,
                        "success": True,
                        "iterations": iterations,
                        "tool_calls": len(trajectory.tool_calls),
                        "trajectory": trajectory.to_dict() if self.config.evaluation.save_trajectories else None
                    }

                    if stream:
                        return self._stream_response(result)
                    else:
                        return result

            except Exception as e:
                logger.error(f"Error in iteration {iterations}: {e}")
                trajectory.iterations.append({
                    "iteration": iterations,
                    "error": str(e)
                })

                # Continue to next iteration
                continue

        # Max iterations reached
        logger.warning(f"Max iterations ({max_iterations}) reached")

        # Calculate total time
        end_time = datetime.now()
        trajectory.total_time = (end_time - start_time).total_seconds()
        trajectory.success = False

        final_msg = "I was unable to find sufficient information to answer your question within the iteration limit. Please try rephrasing or breaking down your query."

        return {
            "answer": final_msg,
            "success": False,
            "iterations": iterations,
            "tool_calls": len(trajectory.tool_calls),
            "trajectory": trajectory.to_dict() if self.config.evaluation.save_trajectories else None
        }

    def _stream_response(self, result: Dict[str, Any]) -> Generator[str, None, None]:
        """Stream response content"""
        # Stream the answer character by character
        answer = result.get("answer", "")
        for char in answer:
            yield char

    def clear_history(self):
        """Clear conversation history"""
        self.conversation_history = []
        logger.info("Conversation history cleared")

    def save_trajectory(self, trajectory: AgentTrajectory, filepath: str):
        """
        Save agent trajectory to file

        Args:
            trajectory: The trajectory to save
            filepath: Path to save the trajectory
        """
        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(trajectory.to_dict(), f, ensure_ascii=False, indent=2)
        logger.info(f"Trajectory saved to {filepath}")

chunker.py

"""Conversation Chunker for User Memory RAG System

This module handles chunking of conversation histories into manageable segments
for indexing into the RAG database.
"""

import json
import logging
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass, field, asdict
from datetime import datetime
import hashlib

from config import ChunkingConfig, ChunkingStrategy


logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@dataclass
class ConversationMessage:
    """Single message in a conversation"""
    role: str  # "user" or "assistant"
    content: str
    timestamp: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        return {k: v for k, v in asdict(self).items() if v is not None}


@dataclass
class ConversationChunk:
    """A chunk of conversation with metadata"""
    chunk_id: str
    conversation_id: str
    test_id: str
    chunk_index: int
    start_round: int
    end_round: int
    messages: List[ConversationMessage]
    metadata: Dict[str, Any] = field(default_factory=dict)
    context_before: Optional[str] = None  # Summary of previous context
    context_after: Optional[str] = None   # Preview of next context
    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],
            "metadata": self.metadata,
            "context_before": self.context_before,
            "context_after": self.context_after,
            "created_at": self.created_at
        }

    def to_text(self) -> str:
        """Convert chunk to text format for indexing"""
        lines = []

        # Add metadata header
        if self.metadata:
            lines.append(f"[Conversation Metadata]")
            for key, value in self.metadata.items():
                lines.append(f"  {key}: {value}")
            lines.append("")

        # Add context if available
        if self.context_before:
            lines.append(f"[Previous Context]\n{self.context_before}\n")

        # Add messages
        lines.append(f"[Conversation Rounds {self.start_round}-{self.end_round}]")
        for msg in self.messages:
            role_label = "Customer" if msg.role == "user" else "Representative"
            lines.append(f"{role_label}: {msg.content}")

        # Add next context preview if available
        if self.context_after:
            lines.append(f"\n[Next Context Preview]\n{self.context_after}")

        return "\n".join(lines)


class ConversationChunker:
    """Chunks conversation histories into segments for RAG indexing"""

    def __init__(self, config: Optional[ChunkingConfig] = None):
        """
        Initialize the chunker

        Args:
            config: Chunking configuration
        """
        self.config = config or ChunkingConfig()
        logger.info(f"Initialized chunker with strategy: {self.config.strategy}")
        logger.info(f"Rounds per chunk: {self.config.rounds_per_chunk}")
        logger.info(f"Overlap rounds: {self.config.overlap_rounds}")

    def chunk_conversation(self,
                          conversation_id: str,
                          test_id: str,
                          messages: List[Dict[str, Any]],
                          metadata: Optional[Dict[str, Any]] = None) -> List[ConversationChunk]:
        """
        Chunk a single conversation into segments

        Args:
            conversation_id: Unique conversation identifier
            test_id: Test case identifier
            messages: List of messages in the conversation
            metadata: Optional conversation metadata

        Returns:
            List of conversation chunks
        """
        # Convert to ConversationMessage objects
        conv_messages = []
        for msg in messages:
            conv_messages.append(ConversationMessage(
                role=msg.get('role', 'user'),
                content=msg.get('content', ''),
                timestamp=msg.get('timestamp')
            ))

        # Calculate rounds (1 round = 1 user message + 1 assistant response)
        rounds = []
        current_round = []

        for msg in conv_messages:
            current_round.append(msg)
            if msg.role == "assistant" and len(current_round) >= 2:
                rounds.append(current_round)
                current_round = []

        # Add remaining messages as incomplete round if any
        if current_round:
            rounds.append(current_round)

        total_rounds = len(rounds)
        logger.info(f"Processing conversation {conversation_id} with {total_rounds} rounds")

        # Choose chunking strategy
        if self.config.strategy == ChunkingStrategy.FIXED_ROUNDS:
            chunks = self._chunk_fixed_rounds(
                conversation_id, test_id, rounds, metadata
            )
        elif self.config.strategy == ChunkingStrategy.SEMANTIC:
            # For now, fallback to fixed rounds
            # Semantic chunking would require more sophisticated analysis
            chunks = self._chunk_fixed_rounds(
                conversation_id, test_id, rounds, metadata
            )
        else:
            chunks = self._chunk_fixed_rounds(
                conversation_id, test_id, rounds, metadata
            )

        logger.info(f"Created {len(chunks)} chunks for conversation {conversation_id}")
        return chunks

    def _chunk_fixed_rounds(self,
                           conversation_id: str,
                           test_id: str,
                           rounds: List[List[ConversationMessage]],
                           metadata: Optional[Dict[str, Any]] = None) -> List[ConversationChunk]:
        """
        Chunk conversation using fixed number of rounds

        Args:
            conversation_id: Conversation identifier
            test_id: Test case identifier
            rounds: List of conversation rounds
            metadata: Optional metadata

        Returns:
            List of chunks
        """
        chunks = []
        total_rounds = len(rounds)

        # Calculate chunk boundaries with overlap
        chunk_size = self.config.rounds_per_chunk
        overlap = self.config.overlap_rounds
        step = max(1, chunk_size - overlap)

        chunk_index = 0
        for start_idx in range(0, total_rounds, step):
            end_idx = min(start_idx + chunk_size, total_rounds)

            # Skip if chunk is too small (except for the last chunk)
            if end_idx - start_idx < self.config.min_chunk_size and end_idx < total_rounds:
                continue

            # Flatten rounds into messages
            chunk_messages = []
            for round_idx in range(start_idx, end_idx):
                chunk_messages.extend(rounds[round_idx])

            # Generate chunk ID
            chunk_content = f"{conversation_id}_{chunk_index}_{start_idx}_{end_idx}"
            chunk_id = hashlib.md5(chunk_content.encode()).hexdigest()[:12]

            # Create context summaries if enabled
            context_before = None
            context_after = None

            if self.config.include_metadata:
                # Add summary of previous context
                if start_idx > 0:
                    prev_rounds = min(3, start_idx)
                    context_msgs = []
                    for i in range(max(0, start_idx - prev_rounds), start_idx):
                        for msg in rounds[i]:
                            if msg.role == "user":
                                context_msgs.append(f"User asked: {msg.content[:100]}...")
                    if context_msgs:
                        context_before = "Previous discussion: " + " | ".join(context_msgs[-2:])

                # Add preview of next context
                if end_idx < total_rounds:
                    next_rounds = min(2, total_rounds - end_idx)
                    context_msgs = []
                    for i in range(end_idx, min(end_idx + next_rounds, total_rounds)):
                        for msg in rounds[i]:
                            if msg.role == "user":
                                context_msgs.append(f"Next: {msg.content[:100]}...")
                    if context_msgs:
                        context_after = " | ".join(context_msgs[:2])

            # Create chunk
            chunk = ConversationChunk(
                chunk_id=f"{test_id}_{conversation_id}_{chunk_id}",
                conversation_id=conversation_id,
                test_id=test_id,
                chunk_index=chunk_index,
                start_round=start_idx + 1,  # 1-indexed for display
                end_round=end_idx,  # Inclusive
                messages=chunk_messages,
                metadata=metadata or {},
                context_before=context_before,
                context_after=context_after
            )

            chunks.append(chunk)
            chunk_index += 1

            # Stop if we've reached the end
            if end_idx >= total_rounds:
                break

        return chunks

    def chunk_test_case_conversations(self,
                                     test_case: Dict[str, Any]) -> List[ConversationChunk]:
        """
        Chunk all conversations in a test case

        Args:
            test_case: Test case containing conversation histories

        Returns:
            List of all chunks from all conversations
        """
        all_chunks = []
        test_id = test_case.get('test_id', 'unknown')

        # Process each conversation history
        for conv_history in test_case.get('conversation_histories', []):
            conv_id = conv_history.get('conversation_id', '')
            messages = conv_history.get('messages', [])
            metadata = conv_history.get('metadata', {})

            # Add test case information to metadata
            metadata['test_id'] = test_id
            metadata['test_title'] = test_case.get('title', '')
            metadata['test_category'] = test_case.get('category', '')

            # Chunk the conversation
            chunks = self.chunk_conversation(
                conversation_id=conv_id,
                test_id=test_id,
                messages=messages,
                metadata=metadata
            )

            all_chunks.extend(chunks)

        logger.info(f"Chunked test case {test_id}: {len(all_chunks)} total chunks")
        return all_chunks

    def save_chunks(self, chunks: List[ConversationChunk], filepath: str):
        """
        Save chunks to a JSON file

        Args:
            chunks: List of conversation chunks
            filepath: Path to save the chunks
        """
        chunks_data = [chunk.to_dict() for chunk in chunks]

        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(chunks_data, f, ensure_ascii=False, indent=2)

        logger.info(f"Saved {len(chunks)} chunks to {filepath}")

    def load_chunks(self, filepath: str) -> List[ConversationChunk]:
        """
        Load chunks from a JSON file

        Args:
            filepath: Path to the chunks file

        Returns:
            List of conversation chunks
        """
        with open(filepath, 'r', encoding='utf-8') as f:
            chunks_data = json.load(f)

        chunks = []
        for chunk_data in chunks_data:
            # Convert messages
            messages = []
            for msg_data in chunk_data.get('messages', []):
                messages.append(ConversationMessage(**msg_data))

            # Create chunk
            chunk = ConversationChunk(
                chunk_id=chunk_data['chunk_id'],
                conversation_id=chunk_data['conversation_id'],
                test_id=chunk_data['test_id'],
                chunk_index=chunk_data['chunk_index'],
                start_round=chunk_data['start_round'],
                end_round=chunk_data['end_round'],
                messages=messages,
                metadata=chunk_data.get('metadata', {}),
                context_before=chunk_data.get('context_before'),
                context_after=chunk_data.get('context_after'),
                created_at=chunk_data.get('created_at', '')
            )
            chunks.append(chunk)

        logger.info(f"Loaded {len(chunks)} chunks from {filepath}")
        return chunks

config.py

"""Configuration for Agentic RAG User Memory Evaluation System"""

import os
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from enum import Enum
from pathlib import Path


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


def _openrouter_model_id(model: Optional[str]) -> str:
    """Map a provider-native model name to an OpenRouter model id, used by the
    universal OpenRouter fallback. An explicit OPENROUTER_MODEL env var wins."""
    override = os.getenv("OPENROUTER_MODEL")
    if override:
        return override
    m = (model or "").strip()
    if not m:
        return "openai/gpt-5.6-luna"
    if "/" in m:
        return m  # already an OpenRouter-style id (e.g. openai/gpt-5.6-luna)
    ml = m.lower()
    if ml.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
        return "openai/" + m
    if ml.startswith("claude-"):
        return "anthropic/claude-opus-4.8"
    if ml.startswith("kimi"):
        # kimi-k3 is not on OpenRouter; moonshotai/kimi-k2.6 is the closest hosted id.
        return "moonshotai/kimi-k2.6"
    # Provider-native ids (kimi-*/doubao-*/qwen/deepseek-*) not hosted on
    # OpenRouter under the same name -> a widely-available OpenAI chat model.
    return "openai/gpt-5.6-luna"


class Provider(str, Enum):
    """Supported LLM providers"""
    SILICONFLOW = "siliconflow"
    DOUBAO = "doubao"
    KIMI = "kimi"
    MOONSHOT = "moonshot"
    OPENROUTER = "openrouter"
    OPENAI = "openai"
    GROQ = "groq"
    TOGETHER = "together"
    DEEPSEEK = "deepseek"


class IndexMode(str, Enum):
    """Indexing modes for conversation chunks"""
    DENSE = "dense"  # Dense embedding only
    SPARSE = "sparse"  # Sparse embedding only (BM25)
    HYBRID = "hybrid"  # Both dense and sparse


class ChunkingStrategy(str, Enum):
    """Strategies for chunking conversations"""
    FIXED_ROUNDS = "fixed_rounds"  # Fixed number of rounds per chunk
    SEMANTIC = "semantic"  # Semantic boundaries
    TIME_BASED = "time_based"  # Based on timestamp gaps


@dataclass
class LLMConfig:
    """LLM configuration"""
    provider: str = "kimi"  # Default provider
    model: Optional[str] = None  # Will use provider defaults if not specified
    api_key: Optional[str] = None  # Will read from env if not provided
    temperature: float = 0.7
    max_tokens: int = 2048
    stream: bool = True

    # Provider-specific defaults
    PROVIDER_DEFAULTS = {
        "siliconflow": {
            "model": "Qwen/Qwen3-235B-A22B-Thinking-2507",
            "base_url": "https://api.siliconflow.cn/v1"
        },
        "doubao": {
            "model": "doubao-seed-1-6-thinking-250715",
            "base_url": "https://ark.cn-beijing.volces.com/api/v3"
        },
        "kimi": {
            "model": "kimi-k3",
            "base_url": "https://api.moonshot.cn/v1"
        },
        "moonshot": {
            "model": "kimi-k3",
            "base_url": "https://api.moonshot.cn/v1"
        },
        "openrouter": {
            "model": "openai/gpt-5.6-luna",
            "base_url": "https://openrouter.ai/api/v1"
        },
        "openai": {
            "model": "gpt-5.6-luna",
            "base_url": "https://api.openai.com/v1"
        },
        "groq": {
            "model": "llama-3.3-70b-versatile",
            "base_url": "https://api.groq.com/openai/v1"
        },
        "together": {
            "model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
            "base_url": "https://api.together.xyz"
        },
        "deepseek": {
            "model": "deepseek-reasoner",
            "base_url": "https://api.deepseek.com/v1"
        }
    }

    def get_client_config(self) -> tuple[Dict[str, Any], str]:
        """Get OpenAI client configuration"""
        provider = self.provider.lower()
        defaults = self.PROVIDER_DEFAULTS.get(provider, {})

        # Determine API key
        api_key = self.api_key or os.getenv(f"{provider.upper()}_API_KEY")
        if not api_key and provider == "moonshot":
            api_key = os.getenv("KIMI_API_KEY")  # Fallback for moonshot

        # Determine model
        model = self.model or defaults.get("model", "gpt-5.6-luna")

        # Universal OpenRouter fallback: primary provider key absent but
        # OPENROUTER_API_KEY present -> route through OpenRouter.
        if not api_key and provider != "openrouter" and os.getenv("OPENROUTER_API_KEY"):
            return {
                "api_key": os.getenv("OPENROUTER_API_KEY"),
                "base_url": "https://openrouter.ai/api/v1",
            }, _openrouter_model_id(model)

        # Build client config
        client_config = {"api_key": api_key}

        # Add base URL if needed
        if base_url := defaults.get("base_url"):
            client_config["base_url"] = base_url

        return client_config, model


@dataclass
class ChunkingConfig:
    """Configuration for conversation chunking"""
    strategy: ChunkingStrategy = ChunkingStrategy.FIXED_ROUNDS
    rounds_per_chunk: int = 20  # Number of rounds per chunk for FIXED_ROUNDS
    overlap_rounds: int = 2  # Number of overlapping rounds between chunks
    include_metadata: bool = True  # Include conversation metadata in chunks
    min_chunk_size: int = 5  # Minimum number of rounds in a chunk
    max_chunk_size: int = 50  # Maximum number of rounds in a chunk


@dataclass
class IndexConfig:
    """Configuration for RAG indexing"""
    mode: IndexMode = IndexMode.HYBRID
    embedding_model: str = "text-embedding-3-small"  # OpenAI embedding model
    embedding_dim: int = 1536  # Dimension of embeddings
    index_path: str = "indexes/memory_index"
    chunk_store_path: str = "data/chunk_store.json"
    enable_contextual: bool = True  # Add contextual information to chunks
    contextual_window: int = 2  # Number of surrounding rounds for context


@dataclass
class EvaluationConfig:
    """Configuration for evaluation framework"""
    test_cases_dir: str = "../../week2/user-memory-evaluation/test_cases"
    results_dir: str = "results"
    enable_verbose: bool = True
    save_trajectories: bool = True
    max_iterations: int = 10  # Max iterations for ReAct pattern
    enable_caching: bool = True  # Cache indexed conversations
    use_llm_judge: bool = False  # Use LLM to evaluate answers


@dataclass
class AgentConfig:
    """Agent behavior configuration"""
    enable_reasoning: bool = True  # Show reasoning steps
    enable_citations: bool = True  # Include citations in responses
    max_search_results: int = 5  # Maximum search results to consider
    confidence_threshold: float = 0.7  # Minimum confidence for answers
    enable_multi_search: bool = True  # Allow multiple searches per query
    max_searches_per_query: int = 3  # Maximum searches allowed
    verbose: bool = True  # Enable verbose output


@dataclass
class Config:
    """Main configuration container"""
    llm: LLMConfig = field(default_factory=LLMConfig)
    chunking: ChunkingConfig = field(default_factory=ChunkingConfig)
    index: IndexConfig = field(default_factory=IndexConfig)
    evaluation: EvaluationConfig = field(default_factory=EvaluationConfig)
    agent: AgentConfig = field(default_factory=AgentConfig)

    @classmethod
    def from_env(cls) -> "Config":
        """Create configuration from environment variables"""
        config = cls()

        # Override with environment variables
        if provider := os.getenv("LLM_PROVIDER"):
            config.llm.provider = provider

        if model := os.getenv("LLM_MODEL"):
            config.llm.model = model

        if rounds := os.getenv("ROUNDS_PER_CHUNK"):
            config.chunking.rounds_per_chunk = int(rounds)

        if index_mode := os.getenv("INDEX_MODE"):
            config.index.mode = IndexMode(index_mode)

        return config

    def save(self, path: str):
        """Save configuration to JSON file"""
        import json

        config_dict = {
            "llm": {
                "provider": self.llm.provider,
                "model": self.llm.model,
                "temperature": _reasoning_safe_temperature(self.llm.model, self.llm.temperature),
                "max_tokens": self.llm.max_tokens,
                "stream": self.llm.stream
            },
            "chunking": {
                "strategy": self.chunking.strategy,
                "rounds_per_chunk": self.chunking.rounds_per_chunk,
                "overlap_rounds": self.chunking.overlap_rounds,
                "include_metadata": self.chunking.include_metadata
            },
            "index": {
                "mode": self.index.mode,
                "embedding_model": self.index.embedding_model,
                "enable_contextual": self.index.enable_contextual,
                "contextual_window": self.index.contextual_window
            },
            "evaluation": {
                "enable_verbose": self.evaluation.enable_verbose,
                "save_trajectories": self.evaluation.save_trajectories,
                "max_iterations": self.evaluation.max_iterations
            },
            "agent": {
                "enable_reasoning": self.agent.enable_reasoning,
                "enable_citations": self.agent.enable_citations,
                "max_search_results": self.agent.max_search_results,
                "confidence_threshold": self.agent.confidence_threshold
            }
        }

        with open(path, 'w') as f:
            json.dump(config_dict, f, indent=2)

    @classmethod
    def load(cls, path: str) -> "Config":
        """Load configuration from JSON file"""
        import json

        with open(path, 'r') as f:
            config_dict = json.load(f)

        config = cls()

        # Update LLM config
        if "llm" in config_dict:
            for key, value in config_dict["llm"].items():
                setattr(config.llm, key, value)

        # Update other configs similarly
        for section in ["chunking", "index", "evaluation", "agent"]:
            if section in config_dict:
                section_config = getattr(config, section)
                for key, value in config_dict[section].items():
                    # Handle enums
                    if key == "strategy" and section == "chunking":
                        value = ChunkingStrategy(value)
                    elif key == "mode" and section == "index":
                        value = IndexMode(value)
                    setattr(section_config, key, value)

        return config

contextual_agent.py

"""Contextual RAG Agent with Advanced Memory Cards

This agent combines:
1. Advanced Memory Cards (structured facts) - always in context
2. Contextual RAG for searching conversation history
"""

import json
import logging
import time
from typing import List, Dict, Any, Optional, Generator
from dataclasses import dataclass, field
from datetime import datetime
from openai import OpenAI

from config import Config
from contextual_indexer import ContextualMemoryIndexer
from advanced_memory_manager import AdvancedMemoryManager
from tools import MemoryTools, get_tool_definitions


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 Message:
    """Represents a message in the conversation"""
    role: str  # "user", "assistant", "tool"
    content: str
    tool_calls: Optional[List[Dict[str, Any]]] = None
    tool_call_id: Optional[str] = None
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())


@dataclass
class AgentTrajectory:
    """Tracks the agent's reasoning and tool usage"""
    test_id: str
    question: str
    iterations: List[Dict[str, Any]] = field(default_factory=list)
    final_answer: Optional[str] = None
    tool_calls: List[Dict[str, Any]] = field(default_factory=list)
    total_time: Optional[float] = None
    success: bool = False
    memory_cards_used: List[str] = field(default_factory=list)
    chunks_retrieved: List[str] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "test_id": self.test_id,
            "question": self.question,
            "iterations": self.iterations,
            "final_answer": self.final_answer,
            "tool_calls": self.tool_calls,
            "total_time": self.total_time,
            "success": self.success,
            "total_iterations": len(self.iterations),
            "total_tool_calls": len(self.tool_calls),
            "memory_cards_used": self.memory_cards_used,
            "chunks_retrieved": self.chunks_retrieved
        }


class ContextualUserMemoryAgent:
    """Agent with dual memory system: structured cards + contextual RAG"""

    def __init__(self,
                 indexer: ContextualMemoryIndexer,
                 memory_manager: Optional[AdvancedMemoryManager] = None,
                 config: Optional[Config] = None):
        """
        Initialize the contextual agent

        Args:
            indexer: The contextual memory indexer
            memory_manager: Advanced memory manager (uses indexer's if not provided)
            config: Configuration object
        """
        self.config = config or Config.from_env()
        self.indexer = indexer
        self.memory_manager = memory_manager or indexer.memory_manager
        self.memory_tools = MemoryTools(indexer)  # Works with base indexer interface

        # Set verbose flag
        self.verbose = True  # Always verbose for debugging

        # Initialize LLM client
        self._init_llm_client()

        # Tool definitions - enhanced with contextual search
        self.tools = self._get_enhanced_tool_definitions()

        # Conversation history
        self.conversation_history: List[Dict[str, Any]] = []

        logger.info(f"Initialized ContextualUserMemoryAgent with dual memory system")
        logger.info(f"Memory cards loaded: {sum(len(cards) for cards in self.memory_manager.categories.values())}")

    def _init_llm_client(self):
        """Initialize the LLM client based on provider"""
        client_config, model = self.config.llm.get_client_config()

        # Extract base_url if present
        base_url = client_config.pop("base_url", None)

        # Create OpenAI client
        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 model: {self.model}")

    def _get_enhanced_tool_definitions(self) -> List[Dict[str, Any]]:
        """Get enhanced tool definitions for contextual search"""
        return [
            {
                "type": "function",
                "function": {
                    "name": "search_conversation_history",
                    "description": "Search through indexed conversation history with contextual understanding. Returns conversation chunks with their contextual descriptions.",
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "query": {
                                "type": "string",
                                "description": "Search query to find relevant conversations"
                            },
                            "top_k": {
                                "type": "integer",
                                "description": "Number of results to return",
                                "default": 3
                            }
                        },
                        "required": ["query"]
                    }
                }
            }
        ]

    def _build_system_prompt(self) -> str:
        """Build enhanced system prompt with memory cards"""
        # Get memory cards context (always included in prompt)
        memory_context = self.memory_manager.get_context_string(max_cards=20)

        prompt = f"""You are an intelligent assistant with access to comprehensive user memory:

{memory_context}

=== YOUR CAPABILITIES ===
1. **Memory Cards** (shown above): Pre-loaded structured facts about the user
   - These are verified, persistent facts with backstories
   - Each card shows WHO it's about and WHY we know this
   - Always check these FIRST before searching

2. **Searchable Conversation History**: Use the search tool to find specific details
   - Conversations are chunked with contextual descriptions
   - Search when you need evidence or additional details
   - Each chunk includes context about what's being discussed

=== PROACTIVE SERVICE GUIDELINES ===
You should provide proactive service by:
1. **Anticipating Needs**: Look beyond the immediate question to identify related concerns
2. **Risk Detection**: Identify potential issues before they become problems
   - Check dates for expirations (passports, licenses, cards, insurances)
   - Notice scheduling conflicts or tight timelines
   - Flag missing preparations or requirements
3. **Comprehensive Assistance**: Connect different pieces of information
   - If user asks about travel, check passport, visa, insurance status
   - If discussing finances, consider upcoming payments or deadlines
   - For medical topics, recall relevant history and upcoming appointments
4. **Helpful Suggestions**: Offer actionable recommendations
   - Prioritize urgent matters (e.g., for time-sensitive issues)
   - Suggest next steps even if not explicitly requested
   - Remind about related tasks that might be overlooked

=== OPERATIONAL APPROACH ===
1. **Direct Answer First**: Address the user's immediate question clearly
2. **Then Proactive Service**: After answering, consider what else might be relevant
3. **Cross-Reference Information**: Actively connect related memory cards and conversations
4. **Cite Sources**: "According to memory card X..." or "Based on conversation Y..."
5. **Handle Conflicts**: Prefer more recent or more specific information
6. **Identify People**: Be specific about WHO information relates to

=== YOUR MISSION ===
Not just to answer questions, but to be a thoughtful assistant who:
- Notices what the user might have forgotten
- Warns about potential issues before they arise  
- Provides comprehensive support beyond what's asked
- Acts as a reliable memory partner who cares about the user's wellbeing

When answering, always consider: "What else should the user know about this topic?"

Remember: Good service answers the question. Great service anticipates what comes next."""

        return prompt

    def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
        """Execute a tool and return results"""
        try:
            if tool_name == "search_conversation_history":
                query = arguments.get("query", "")
                top_k = arguments.get("top_k", 3)

                try:
                    # Use contextual search
                    search_results = self.indexer.search_with_context(
                        query=query,
                        top_k=top_k,
                        include_memory_cards=False  # Cards are already in context
                    )
                except Exception as search_error:
                    logger.error(f"Search failed: {search_error}")
                    return {
                        "status": "error",
                        "message": f"Search failed: {str(search_error)}",
                        "results": []
                    }

                chunk_results = search_results.get("chunk_results", [])

                if not chunk_results:
                    return {
                        "status": "success",
                        "message": "No relevant conversations found",
                        "results": []
                    }

                # Format results with context - NO TRUNCATION
                formatted_results = []
                for result in chunk_results:
                    formatted_results.append({
                        "chunk_id": result.get("chunk_id"),
                        "context": result.get("context", ""),  # Contextual description
                        "conversation": result.get("conversation_id"),
                        "rounds": result.get("rounds"),
                        "content": result.get("text", "")  # Full content, no truncation
                    })

                return {
                    "status": "success",
                    "results": formatted_results,
                    "total": len(formatted_results)
                }
            else:
                return {
                    "status": "error",
                    "message": f"Unknown tool: {tool_name}"
                }

        except Exception as e:
            logger.error(f"Tool execution error: {e}")
            return {
                "status": "error",
                "message": str(e)
            }

    def answer_question(self,
                       question: str,
                       test_id: str = "unknown",
                       max_iterations: int = 10,
                       stream: bool = False) -> AgentTrajectory:
        """
        Answer a question using dual memory system

        Args:
            question: The question to answer
            test_id: Test case ID for tracking
            max_iterations: Maximum reasoning iterations
            stream: Whether to stream the response

        Returns:
            AgentTrajectory with the answer and reasoning steps
        """
        trajectory = AgentTrajectory(test_id=test_id, question=question)
        start_time = time.time()

        # Reset conversation for new question
        self.conversation_history = []

        # Build initial messages with system prompt
        messages = [
            {"role": "system", "content": self._build_system_prompt()},
            {"role": "user", "content": question}
        ]

        # Track which memory cards might be relevant
        relevant_cards = self._find_relevant_memory_cards(question)
        trajectory.memory_cards_used = relevant_cards

        iteration = 0
        while iteration < max_iterations:
            iteration += 1

            iteration_data = {
                "iteration": iteration,
                "timestamp": datetime.now().isoformat(),
                "messages_count": len(messages)
            }

            try:
                # Generate response
                response = self.client.chat.completions.create(
                    model=self.model,
                    messages=messages,
                    tools=self.tools,
                    tool_choice="auto",
                    temperature=_reasoning_safe_temperature(self.model, 0.3),
                    max_tokens=2048,
                    stream=stream
                )

                if stream:
                    # Handle streaming response
                    assistant_content = ""
                    tool_calls = []

                    for chunk in response:
                        if chunk.choices[0].delta.content:
                            content = chunk.choices[0].delta.content
                            assistant_content += content
                            if self.verbose:
                                print(content, end='', flush=True)

                        # Handle tool calls in stream
                        if chunk.choices[0].delta.tool_calls:
                            for tc in chunk.choices[0].delta.tool_calls:
                                # Build tool calls from stream
                                pass  # Simplified for brevity

                    # Process complete response
                    assistant_message = {
                        "role": "assistant",
                        "content": assistant_content if assistant_content else None
                    }
                else:
                    # Non-streaming response
                    choice = response.choices[0]
                    assistant_message = {
                        "role": "assistant",
                        "content": choice.message.content
                    }

                    # Check for tool calls
                    if choice.message.tool_calls:
                        assistant_message["tool_calls"] = [
                            tc.model_dump() for tc in choice.message.tool_calls
                        ]

                messages.append(assistant_message)
                iteration_data["response"] = assistant_message

                # Handle tool calls
                if assistant_message.get("tool_calls"):
                    if self.verbose:
                        print(f"\n{'='*80}")
                        print(f"🤖 LLM MADE {len(assistant_message['tool_calls'])} TOOL CALL(S)")
                        print(f"{'='*80}")
                        print("Tool calls:")
                        for tc in assistant_message["tool_calls"]:
                            print(f"  - {tc['function']['name']}")
                        print()

                    for tool_call in assistant_message["tool_calls"]:
                        tool_name = tool_call["function"]["name"]
                        tool_args = json.loads(tool_call["function"]["arguments"])

                        # Execute tool
                        tool_result = self._execute_tool(tool_name, tool_args)

                        # Track tool usage
                        trajectory.tool_calls.append({
                            "iteration": iteration,
                            "tool": tool_name,
                            "arguments": tool_args,
                            "result": tool_result
                        })

                        # Track retrieved chunks
                        if tool_name == "search_conversation_history" and tool_result.get("results"):
                            for result in tool_result["results"]:
                                trajectory.chunks_retrieved.append(result.get("chunk_id", ""))

                        # Add tool result to messages
                        messages.append({
                            "role": "tool",
                            "tool_call_id": tool_call["id"],
                            "content": json.dumps(tool_result)
                        })

                        if self.verbose:
                            print(f"\n{'='*80}")
                            print(f"🔧 TOOL CALL: {tool_name}")
                            print(f"{'='*80}")
                            print(f"📥 Arguments:")
                            print(json.dumps(tool_args, indent=2, ensure_ascii=False))
                            print(f"\n📤 Result (FULL - NO TRUNCATION):")
                            print(json.dumps(tool_result, indent=2, ensure_ascii=False))
                            print(f"{'='*80}\n")
                else:
                    # No tool calls means we have the final answer
                    trajectory.final_answer = assistant_message.get("content", "")
                    trajectory.success = True
                    break

                trajectory.iterations.append(iteration_data)

            except Exception as e:
                logger.error(f"Error in iteration {iteration}: {e}")
                iteration_data["error"] = str(e)
                trajectory.iterations.append(iteration_data)
                break

        # Record timing
        trajectory.total_time = time.time() - start_time

        # Store conversation history
        self.conversation_history = messages

        if self.verbose:
            print(f"\n{'='*80}")
            print(f"✅ EVALUATION COMPLETE")
            print(f"{'='*80}")
            print(f"Iterations: {iteration}")
            print(f"Total Time: {trajectory.total_time:.2f}s")
            print(f"Memory Cards Used: {len(trajectory.memory_cards_used)}")
            if trajectory.memory_cards_used:
                print(f"  Cards: {trajectory.memory_cards_used}")
            print(f"Chunks Retrieved: {len(trajectory.chunks_retrieved)}")
            if trajectory.chunks_retrieved:
                print(f"  Chunks: {trajectory.chunks_retrieved}")
            print(f"\n📝 FINAL ANSWER:")
            print(trajectory.final_answer or "No answer generated")
            print(f"{'='*80}\n")

        return trajectory

    def _find_relevant_memory_cards(self, question: str) -> List[str]:
        """Find which memory cards might be relevant to the question"""
        relevant = []
        question_lower = question.lower()

        # Simple keyword matching (could be enhanced with embeddings)
        for category, cards in self.memory_manager.categories.items():
            for card_key, card in cards.items():
                card_str = json.dumps(card.to_dict()).lower()
                # Check if any question keywords appear in the card
                if any(word in card_str for word in question_lower.split() if len(word) > 3):
                    relevant.append(f"{category}.{card_key}")

        return relevant

    def reset(self):
        """Reset the agent state"""
        self.conversation_history = []
        logger.info("Agent state reset")

    def get_statistics(self) -> Dict[str, Any]:
        """Get agent statistics"""
        return {
            "memory_cards": sum(len(cards) for cards in self.memory_manager.categories.values()),
            "indexed_chunks": len(self.indexer.contextual_chunks),
            "conversation_history_length": len(self.conversation_history),
            "indexer_stats": self.indexer.get_statistics()
        }

contextual_chunking.py

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

contextual_compare.py

"""离线对比:上下文化记忆块 vs 原始记忆块对『用户事实召回』的影响(实验 3-12)。

本模块是一个**完全离线、无需 API/外部服务**的对照实验,用于量化本章核心论点:
在把对话记忆块送入索引/嵌入之前,先为每个块生成一段『上下文前缀』(情境锚定),
能显著提升脱离上下文的孤立片段(如『好的,就订这个吧』)被正确召回的概率。

方法说明(诚实边界):
- 生产管线用 LLM 逐块生成 context、并用神经嵌入 + 检索服务做稠密/混合检索(需 API Key)。
- 这里用**确定性的 BM25 词法检索**作为无需 API 的代理:对同一份 context,
  分别度量『不拼接(plain)』与『拼接后再索引(contextual)』两种方式的召回。
  变量只有『索引文本是否含 context 前缀』,因此结果直接反映上下文化本身的贡献。
- 指标:Recall@1、Recall@3、MRR(口径同书中 recall@k:前 k 个结果命中即算召回)。

数据集见同目录 memory_qa_eval.json(受控教学集,可自行替换 --dataset)。
"""

import argparse
import json
import math
import re
from pathlib import Path
from typing import Dict, List, Tuple

DEFAULT_DATASET = str(Path(__file__).parent / "memory_qa_eval.json")

_CJK = r"一-鿿"


def tokenize(text: str) -> List[str]:
    """CJK 感知的轻量分词:英文/数字按词,中文按『单字 + 相邻双字』。

    对相邻双字(bigram)保留位置邻接关系,避免跨片段产生虚假二元组。
    """
    text = text.lower()
    tokens: List[str] = []
    tokens.extend(re.findall(r"[a-z0-9]+", text))
    for run in re.findall(f"[{_CJK}]+", text):
        chars = list(run)
        tokens.extend(chars)
        for i in range(len(chars) - 1):
            tokens.append(chars[i] + chars[i + 1])
    return tokens


class BM25:
    """标准 BM25,纯 Python 实现,无第三方依赖。"""

    def __init__(self, corpus_tokens: List[List[str]], k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.corpus = corpus_tokens
        self.N = len(corpus_tokens)
        self.doc_len = [len(d) for d in corpus_tokens]
        self.avgdl = sum(self.doc_len) / self.N if self.N else 0.0
        self.tf: List[Dict[str, int]] = []
        df: Dict[str, int] = {}
        for doc in corpus_tokens:
            counts: Dict[str, int] = {}
            for t in doc:
                counts[t] = counts.get(t, 0) + 1
            self.tf.append(counts)
            for t in counts:
                df[t] = df.get(t, 0) + 1
        self.idf = {
            t: math.log(1 + (self.N - n + 0.5) / (n + 0.5)) for t, n in df.items()
        }

    def score(self, query_tokens: List[str], idx: int) -> float:
        counts = self.tf[idx]
        dl = self.doc_len[idx]
        s = 0.0
        for t in query_tokens:
            if t not in counts:
                continue
            idf = self.idf.get(t, 0.0)
            freq = counts[t]
            denom = freq + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
            s += idf * (freq * (self.k1 + 1)) / denom
        return s

    def rank(self, query_tokens: List[str]) -> List[int]:
        scored = [(self.score(query_tokens, i), i) for i in range(self.N)]
        # 稳定排序:分数降序,平局按原始下标升序
        scored.sort(key=lambda x: (-x[0], x[1]))
        return [i for _, i in scored]


def _gold_rank(ranked_ids: List[str], gold_id: str) -> int:
    """返回 gold 在排序结果中的名次(1-based);未找到返回 0。"""
    for pos, cid in enumerate(ranked_ids, start=1):
        if cid == gold_id:
            return pos
    return 0


def evaluate(chunks: List[dict], queries: List[dict], mode: str) -> Tuple[dict, List[dict]]:
    """按指定模式索引并检索,返回聚合指标与逐条明细。

    mode='plain'      索引文本 = chunk['text']
    mode='contextual' 索引文本 = chunk['context'] + '\n' + chunk['text']
    """
    ids = [c["id"] for c in chunks]
    if mode == "plain":
        docs = [c["text"] for c in chunks]
    elif mode == "contextual":
        docs = [f"{c.get('context','')}\n{c['text']}" for c in chunks]
    else:
        raise ValueError(f"unknown mode: {mode}")

    bm25 = BM25([tokenize(d) for d in docs])

    r1 = r3 = 0
    mrr = 0.0
    details = []
    for q in queries:
        ranked = [ids[i] for i in bm25.rank(tokenize(q["q"]))]
        rank = _gold_rank(ranked, q["gold"])
        hit1 = 1 if rank == 1 else 0
        hit3 = 1 if 1 <= rank <= 3 else 0
        r1 += hit1
        r3 += hit3
        mrr += (1.0 / rank) if rank else 0.0
        details.append({"q": q["q"], "gold": q["gold"], "rank": rank,
                        "hit@1": hit1, "hit@3": hit3, "top": ranked[:3]})

    n = len(queries)
    metrics = {
        "recall@1": r1 / n,
        "recall@3": r3 / n,
        "mrr": mrr / n,
        "n_queries": n,
    }
    return metrics, details


def run_comparison(dataset_path: str, output_path: str = None, verbose: bool = True) -> dict:
    data = json.loads(Path(dataset_path).read_text(encoding="utf-8"))
    chunks, queries = data["chunks"], data["queries"]

    plain_m, plain_d = evaluate(chunks, queries, "plain")
    ctx_m, ctx_d = evaluate(chunks, queries, "contextual")

    if verbose:
        print("=" * 68)
        print("实验 3-12 · 上下文化记忆块对用户事实召回的影响(离线 BM25 代理)")
        print(f"数据集: {dataset_path}")
        print(f"记忆块: {len(chunks)}  查询: {len(queries)}")
        print("=" * 68)
        print(f"{'方法':<28}{'Recall@1':>10}{'Recall@3':>10}{'MRR':>10}")
        print("-" * 68)
        print(f"{'Plain(直接索引原始块)':<24}{plain_m['recall@1']:>10.3f}"
              f"{plain_m['recall@3']:>10.3f}{plain_m['mrr']:>10.3f}")
        print(f"{'Contextual(上下文化后索引)':<22}{ctx_m['recall@1']:>10.3f}"
              f"{ctx_m['recall@3']:>10.3f}{ctx_m['mrr']:>10.3f}")
        print("-" * 68)
        d1 = ctx_m["recall@1"] - plain_m["recall@1"]
        d3 = ctx_m["recall@3"] - plain_m["recall@3"]
        dm = ctx_m["mrr"] - plain_m["mrr"]
        print(f"{'提升(Δ)':<26}{d1:>+10.3f}{d3:>+10.3f}{dm:>+10.3f}")
        print("=" * 68)
        print("\n逐查询名次(gold 在检索结果中的位次,越小越好;0 表示未召回):")
        print(f"{'查询':<38}{'Plain':>8}{'Ctx':>8}")
        print("-" * 68)
        pd = {x["q"]: x["rank"] for x in plain_d}
        for x in ctx_d:
            q = x["q"][:36]
            print(f"{q:<38}{pd[x['q']]:>8}{x['rank']:>8}")
        print("=" * 68)
        print("说明:孤立片段(如『好的,就订这个吧』)在 Plain 下缺乏可检索信号,")
        print("上下文化后被『锚定』回其情境,因而召回名次明显提升。")

    result = {
        "dataset": dataset_path,
        "n_chunks": len(chunks),
        "plain": plain_m,
        "contextual": ctx_m,
        "delta": {
            "recall@1": ctx_m["recall@1"] - plain_m["recall@1"],
            "recall@3": ctx_m["recall@3"] - plain_m["recall@3"],
            "mrr": ctx_m["mrr"] - plain_m["mrr"],
        },
        "plain_details": plain_d,
        "contextual_details": ctx_d,
    }

    if output_path:
        Path(output_path).parent.mkdir(parents=True, exist_ok=True)
        Path(output_path).write_text(json.dumps(result, ensure_ascii=False, indent=2),
                                     encoding="utf-8")
        if verbose:
            print(f"\n结果已保存至: {output_path}")

    return result


def single_query(dataset_path: str, query: str, top_k: int = 3, verbose: bool = True) -> dict:
    """针对单条查询,离线对比 plain 与 contextual 两种索引下的 Top-K 检索结果。"""
    data = json.loads(Path(dataset_path).read_text(encoding="utf-8"))
    chunks = data["chunks"]
    ids = [c["id"] for c in chunks]
    qt = tokenize(query)

    out = {"query": query}
    for mode in ("plain", "contextual"):
        if mode == "plain":
            docs = [c["text"] for c in chunks]
        else:
            docs = [f"{c.get('context','')}\n{c['text']}" for c in chunks]
        bm25 = BM25([tokenize(d) for d in docs])
        ranked = bm25.rank(qt)
        out[mode] = [{"id": ids[i], "score": round(bm25.score(qt, i), 4)}
                     for i in ranked[:top_k]]

    if verbose:
        print("=" * 60)
        print(f"查询: {query}")
        print("=" * 60)
        for mode in ("plain", "contextual"):
            label = "Plain(原始块)" if mode == "plain" else "Contextual(上下文化)"
            print(f"\n[{label}] Top-{top_k}:")
            for rank, item in enumerate(out[mode], 1):
                print(f"  {rank}. {item['id']:<18} score={item['score']}")
        print("=" * 60)
    return out


def build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="离线对比上下文化记忆块 vs 原始记忆块的用户事实召回(实验 3-12,无需 API)",
    )
    p.add_argument("--dataset", default=DEFAULT_DATASET,
                   help="记忆问答对照集 JSON 路径(默认:memory_qa_eval.json)")
    p.add_argument("--output", default=None,
                   help="将对比结果(含逐查询明细)保存为 JSON 的路径(默认不保存)")
    p.add_argument("--quiet", action="store_true", help="仅输出结果 JSON,不打印表格")
    return p


def main():
    args = build_arg_parser().parse_args()
    run_comparison(args.dataset, output_path=args.output, verbose=not args.quiet)


if __name__ == "__main__":
    main()

contextual_evaluator.py

"""Contextual Memory Evaluator with Dual Memory System

This evaluator tests the combined system of:
1. Contextual RAG for conversation chunks
2. Advanced JSON cards for structured facts
"""

import os
import json
import logging
import time
import uuid
import sys
from pathlib import Path
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass, field
from datetime import datetime

from config import Config
from contextual_indexer import ContextualMemoryIndexer
from contextual_agent import ContextualUserMemoryAgent
from advanced_memory_manager import AdvancedMemoryManager, AdvancedMemoryCard
from chunker import ConversationChunker, ConversationChunk


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

# Import LLM evaluator from user-memory-evaluation project
LLMEvaluator = None
EvalTestCase = None
ConversationHistory = None
EvalMessage = None
MessageRole = None

# Try to import from week2/user-memory-evaluation
eval_project_path = Path(__file__).parent.parent.parent / "week2" / "user-memory-evaluation"
if eval_project_path.exists():
    sys.path.insert(0, str(eval_project_path))
    try:
        from llm_evaluator import LLMEvaluator
        from test_case import TestCase as EvalTestCase, ConversationHistory, Message as EvalMessage, MessageRole
        logger.info("Successfully imported LLM evaluation modules")
    except ImportError as e:
        logger.warning(f"Could not import LLM evaluation modules: {e}")
        logger.info("LLM evaluation will not be available")
else:
    logger.warning(f"user-memory-evaluation project not found at {eval_project_path}")
    logger.info("LLM evaluation will not be available")


@dataclass
class TestCase:
    """Enhanced test case with support for advanced memory"""
    test_id: str
    category: str  # layer1, layer2, layer3
    title: str
    description: str
    conversation_histories: List[Dict[str, Any]]  # Conversation data directly from YAML
    user_question: str
    evaluation_criteria: str
    expected_behavior: Optional[str] = None
    expected_memory_cards: List[Dict[str, Any]] = field(default_factory=list)
    metadata: Dict[str, Any] = field(default_factory=dict)

    @classmethod
    def from_yaml(cls, data: Dict[str, Any], test_dir: Path) -> 'TestCase':
        """Create test case from YAML data"""
        # The conversation_histories field directly contains the conversation data
        conversation_histories = data.get('conversation_histories', [])

        return cls(
            test_id=data.get('test_id', data.get('id', '')),
            category=data.get('category', ''),
            title=data.get('title', ''),
            description=data.get('description', ''),
            conversation_histories=conversation_histories,
            user_question=data.get('user_question', ''),
            evaluation_criteria=data.get('evaluation_criteria', ''),
            expected_behavior=data.get('expected_behavior'),
            expected_memory_cards=data.get('expected_memory_cards', []),
            metadata=data.get('metadata', {})
        )


@dataclass
class EvaluationResult:
    """Enhanced evaluation result with dual memory tracking"""
    test_id: str
    success: bool
    agent_answer: str
    evaluation_criteria: str
    iterations: int
    tool_calls: int
    memory_cards_used: List[str]
    chunks_retrieved: List[str]
    contextual_chunks_count: int
    processing_time: float
    indexing_time: float
    context_generation_time: float
    llm_evaluation: Optional[Dict[str, Any]] = None
    error: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        return {
            "test_id": self.test_id,
            "success": self.success,
            "agent_answer": self.agent_answer,
            "evaluation_criteria": self.evaluation_criteria,
            "iterations": self.iterations,
            "tool_calls": self.tool_calls,
            "memory_cards_used": self.memory_cards_used,
            "chunks_retrieved": self.chunks_retrieved,
            "contextual_chunks_count": self.contextual_chunks_count,
            "processing_time": self.processing_time,
            "indexing_time": self.indexing_time,
            "context_generation_time": self.context_generation_time,
            "llm_evaluation": self.llm_evaluation,
            "error": self.error
        }


class ContextualMemoryEvaluator:
    """Evaluates the dual memory system on test cases"""

    def __init__(self, config: Optional[Config] = None):
        """
        Initialize the evaluator

        Args:
            config: Configuration object
        """
        self.config = config or Config.from_env()
        self.test_cases: Dict[str, TestCase] = {}
        self.results: Dict[str, EvaluationResult] = {}

        # Initialize components
        self.chunker = ConversationChunker(self.config.chunking)
        self.indexer: Optional[ContextualMemoryIndexer] = None
        self.agent: Optional[ContextualUserMemoryAgent] = None

        # Initialize LLM evaluator if available
        self.llm_evaluator = None
        logger.info("Checking LLM Evaluator availability...")
        logger.info(f"LLMEvaluator module: {LLMEvaluator}")
        logger.info(f"EvalTestCase module: {EvalTestCase}")

        if LLMEvaluator:
            try:
                logger.info("Attempting to initialize LLM Evaluator...")
                self.llm_evaluator = LLMEvaluator()
                logger.info("✅ LLM Evaluator initialized successfully for automatic evaluation")
            except Exception as e:
                logger.warning(f"Could not initialize LLM evaluator: {e}")
                logger.info("Automatic LLM evaluation will be skipped")
        else:
            logger.info("LLM Evaluator not available - automatic evaluation will be skipped")

        # Evaluation framework path
        self.eval_framework_path = Path("../user-memory-evaluation/test_cases")
        if not self.eval_framework_path.exists():
            self.eval_framework_path = Path("../../week2/user-memory-evaluation/test_cases")

        logger.info("Initialized ContextualMemoryEvaluator")

    def load_test_cases(self, category: Optional[str] = None) -> List[str]:
        """
        Load test cases from the evaluation framework

        Args:
            category: Optional category filter (layer1, layer2, layer3)

        Returns:
            List of loaded test case IDs
        """
        import yaml

        test_cases = []
        test_dirs = ["layer1", "layer2", "layer3"] if not category else [category]

        for test_dir in test_dirs:
            dir_path = self.eval_framework_path / test_dir
            if not dir_path.exists():
                logger.warning(f"Test directory not found: {dir_path}")
                continue

            # Load all YAML files
            for yaml_file in dir_path.glob("*.yaml"):
                try:
                    with open(yaml_file, 'r', encoding='utf-8') as f:
                        data = yaml.safe_load(f)

                    test_case = TestCase.from_yaml(data, self.eval_framework_path)
                    test_case.category = test_dir  # Ensure category is set

                    self.test_cases[test_case.test_id] = test_case
                    test_cases.append(test_case.test_id)

                except Exception as e:
                    logger.error(f"Error loading test case {yaml_file}: {e}")

        logger.info(f"Loaded {len(test_cases)} test cases")
        return test_cases

    def _prepare_memory_cards(self, test_case: TestCase) -> List[AdvancedMemoryCard]:
        """
        Generate memory cards for a test case

        For Layer 3 tests, this creates cards that enable proactive service
        """
        cards = []

        # Generate cards based on test metadata
        if test_case.category == "layer3":
            # Layer 3 needs structured facts for proactive service
            if "travel" in test_case.test_id.lower():
                cards.append(AdvancedMemoryCard(
                    category="travel",
                    card_key="tokyo_trip",
                    backstory="User booked a trip to Tokyo in previous conversations",
                    date_created=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                    person="Jessica Thompson (primary)",
                    relationship="primary account holder",
                    data={
                        "destination": "Tokyo, Japan",
                        "departure_date": "2025-01-25",
                        "return_date": "2025-02-01",
                        "purpose": "business conference"
                    }
                ))

                cards.append(AdvancedMemoryCard(
                    category="travel",
                    card_key="passport_jessica",
                    backstory="User's passport expiration was mentioned when booking travel",
                    date_created=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                    person="Jessica Thompson (primary)",
                    relationship="primary account holder",
                    data={
                        "passport_number": "XXXXX1234",
                        "expiration_date": "2025-02-18",
                        "issuing_country": "USA",
                        "needs_renewal": True
                    }
                ))

        # Add any expected cards from test case
        for card_data in test_case.expected_memory_cards:
            cards.append(AdvancedMemoryCard(
                category=card_data.get("category", "general"),
                card_key=card_data.get("key", str(uuid.uuid4())),
                backstory=card_data.get("backstory", "From test case"),
                date_created=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                person=card_data.get("person", "User"),
                relationship=card_data.get("relationship", "primary"),
                data=card_data.get("data", {})
            ))

        return cards

    def evaluate_test_case(self, test_id: str) -> EvaluationResult:
        """
        Evaluate a single test case with dual memory system

        Args:
            test_id: Test case ID

        Returns:
            Evaluation result
        """
        if test_id not in self.test_cases:
            raise ValueError(f"Test case {test_id} not found")

        test_case = self.test_cases[test_id]
        logger.info(f"Evaluating test case: {test_id} - {test_case.title}")

        start_time = time.time()
        indexing_start = time.time()

        try:
            # Step 1: Initialize indexer with user ID
            user_id = f"test_user_{test_id}"
            self.indexer = ContextualMemoryIndexer(
                user_id=user_id,
                index_config=self.config.index,
                chunking_config=self.config.chunking,
                use_contextual=True  # Enable contextual chunking
            )

            # Step 2: Process conversations from test case
            all_chunks = []
            for conv_data in test_case.conversation_histories:
                # Extract conversation data from the test case
                conv_id = conv_data.get('conversation_id', f'conv_{test_id}')
                messages = conv_data.get('messages', [])

                # Chunk the conversation
                chunks = self.chunker.chunk_conversation(
                    messages=messages,
                    conversation_id=conv_id,
                    test_id=test_id
                )
                all_chunks.extend(chunks)

            logger.info(f"Created {len(all_chunks)} basic chunks")

            # Step 3: Process with contextual chunking and indexing
            processing_result = self.indexer.process_conversation_history(
                chunks=all_chunks,
                conversation_id=test_id,
                generate_summary_cards=True  # Generate cards from conversations
            )

            # Step 4: Add pre-defined memory cards for the test
            memory_cards = self._prepare_memory_cards(test_case)
            for card in memory_cards:
                self.indexer.memory_manager.add_card(card)

            # Debug: Print all memory cards
            logger.info("="*60)
            logger.info("DEBUG: All Memory Cards in System")
            logger.info("="*60)
            # Access cards directly from categories attribute
            for category, cards in self.indexer.memory_manager.categories.items():
                for card_key, card in cards.items():
                    logger.info(f"\n[{category}.{card_key}]")
                    logger.info(json.dumps(card.to_dict(), indent=2, ensure_ascii=False))
            total_cards = sum(len(cards) for cards in self.indexer.memory_manager.categories.values())
            logger.info(f"\nTotal Memory Cards: {total_cards}")
            logger.info("="*60)

            indexing_time = time.time() - indexing_start

            # Step 5: Initialize agent with dual memory
            self.agent = ContextualUserMemoryAgent(
                indexer=self.indexer,
                memory_manager=self.indexer.memory_manager,
                config=self.config
            )

            # Step 6: Answer the question
            trajectory = self.agent.answer_question(
                question=test_case.user_question,
                test_id=test_id,
                max_iterations=self.config.evaluation.max_iterations,
                stream=False
            )

            processing_time = time.time() - start_time

            # Get context generation statistics
            chunker_stats = self.indexer.contextual_chunker.get_statistics()
            context_gen_time = chunker_stats.get("total_generation_time", 0)

            # Step 7: Evaluate the answer with LLM (automatic if available)
            llm_evaluation = None
            logger.info("="*60)
            logger.info("LLM Judge Evaluation")
            logger.info("="*60)

            if not self.llm_evaluator:
                logger.warning("LLM Judge not available - skipping automatic evaluation")
                logger.info("To enable LLM Judge, ensure the llm_evaluator module is properly imported")
            elif not trajectory.final_answer:
                logger.warning("No final answer from agent - cannot evaluate")
            else:
                logger.info("Running LLM Judge evaluation...")

            if self.llm_evaluator and trajectory.final_answer:
                try:
                    # Convert test case to evaluation format
                    eval_histories = []
                    for hist in test_case.conversation_histories:
                        eval_messages = []
                        for msg in hist.get("messages", []):
                            role_str = msg.get("role", "user")
                            role = MessageRole.USER if role_str == "user" else MessageRole.ASSISTANT
                            eval_messages.append(EvalMessage(
                                role=role,
                                content=msg.get("content", "")
                            ))

                        eval_histories.append(ConversationHistory(
                            conversation_id=hist.get("conversation_id", f"conv_{uuid.uuid4().hex[:8]}"),
                            messages=eval_messages,
                            metadata=hist.get("metadata", {})
                        ))

                    # Create evaluation test case
                    eval_test_case = EvalTestCase(
                        test_id=test_case.test_id,
                        category=test_case.category,
                        title=test_case.title,
                        description=test_case.description,
                        conversation_histories=eval_histories,
                        user_question=test_case.user_question,
                        evaluation_criteria=test_case.evaluation_criteria if test_case.evaluation_criteria else "The agent should provide a relevant and accurate response based on the conversation history.",
                        expected_behavior=test_case.expected_behavior
                    )

                    # Run LLM evaluation
                    llm_result = self.llm_evaluator.evaluate(
                        test_case=eval_test_case,
                        agent_response=trajectory.final_answer,
                        extracted_memory=None
                    )

                    llm_evaluation = {
                        "reward": llm_result.reward,
                        "passed": llm_result.passed if llm_result.passed is not None else llm_result.reward >= 0.6,
                        "reasoning": llm_result.reasoning,
                        "required_info_found": llm_result.required_info_found if hasattr(llm_result, 'required_info_found') else {},
                        "suggestions": llm_result.suggestions if hasattr(llm_result, 'suggestions') else None
                    }

                    # Log evaluation results
                    logger.info("-"*60)
                    logger.info(f"LLM Evaluation Reward: {llm_result.reward:.3f}/1.000")
                    logger.info(f"Passed: {'Yes' if llm_evaluation['passed'] else 'No'}")
                    logger.info("-"*60)
                    logger.info(f"Evaluation Reasoning:")
                    logger.info(llm_result.reasoning)
                    logger.info("-"*60)

                    if llm_result.required_info_found:
                        logger.info("Required Information Found:")
                        for key, found in llm_result.required_info_found.items():
                            status = "✓" if found else "✗"
                            logger.info(f"  {status} {key}")
                        logger.info("-"*60)

                except Exception as e:
                    logger.warning(f"LLM evaluation failed: {e}")
                    logger.debug("Full error:", exc_info=True)
            else:
                # Fallback: Use direct LLM API for evaluation if module not available
                if trajectory.final_answer:
                    logger.info("Attempting fallback LLM evaluation...")
                    llm_evaluation = self._fallback_llm_evaluation(test_case, trajectory.final_answer)

            # Create result
            result = EvaluationResult(
                test_id=test_id,
                success=trajectory.success,
                agent_answer=trajectory.final_answer or "",
                evaluation_criteria=test_case.evaluation_criteria,
                iterations=len(trajectory.iterations),
                tool_calls=len(trajectory.tool_calls),
                memory_cards_used=trajectory.memory_cards_used,
                chunks_retrieved=trajectory.chunks_retrieved,
                contextual_chunks_count=processing_result.get("contextual_chunks", 0),
                processing_time=processing_time,
                indexing_time=indexing_time,
                context_generation_time=context_gen_time,
                llm_evaluation=llm_evaluation
            )

            # Check if answer meets criteria
            # Use LLM evaluation result if available, otherwise fall back to keyword check
            if llm_evaluation and 'passed' in llm_evaluation:
                result.success = llm_evaluation['passed']
                logger.info(f"Using LLM evaluation result: {'Success' if result.success else 'Failed'}")
            elif self._check_answer_criteria(trajectory.final_answer, test_case.evaluation_criteria):
                result.success = True
                logger.info(f"Using keyword-based evaluation: Success")
            else:
                logger.info(f"Using keyword-based evaluation: Failed")

            self.results[test_id] = result
            logger.info(f"Evaluation complete for {test_id}: {'Success' if result.success else 'Failed'}")

            return result

        except Exception as e:
            logger.error(f"Error evaluating test case {test_id}: {e}")

            result = EvaluationResult(
                test_id=test_id,
                success=False,
                agent_answer="",
                evaluation_criteria=test_case.evaluation_criteria,
                iterations=0,
                tool_calls=0,
                memory_cards_used=[],
                chunks_retrieved=[],
                contextual_chunks_count=0,
                processing_time=time.time() - start_time,
                indexing_time=indexing_time if 'indexing_time' in locals() else 0,
                context_generation_time=0,
                error=str(e)
            )

            self.results[test_id] = result
            return result

    def _load_conversations(self, conv_file: str) -> Dict[str, List[Dict[str, str]]]:
        """Load conversations from a JSON file"""
        conversations = {}

        try:
            with open(conv_file, 'r', encoding='utf-8') as f:
                data = json.load(f)

            # Handle different formats
            if isinstance(data, dict):
                if "conversations" in data:
                    conversations = data["conversations"]
                else:
                    # Assume it's already in the right format
                    conversations = data
            elif isinstance(data, list):
                # Convert list to dict with generated IDs
                for i, conv in enumerate(data):
                    conversations[f"conv_{i}"] = conv

            return conversations

        except Exception as e:
            logger.error(f"Error loading conversations from {conv_file}: {e}")
            return {}

    def _check_answer_criteria(self, answer: Optional[str], criteria: str) -> bool:
        """Simple check if answer meets criteria"""
        if not answer:
            return False

        answer_lower = answer.lower()
        criteria_lower = criteria.lower()

        # Extract key terms from criteria
        key_terms = []
        for word in criteria_lower.split():
            if len(word) > 4 and word not in ["should", "must", "need", "have"]:
                key_terms.append(word)

        # Check if key terms appear in answer
        matches = sum(1 for term in key_terms if term in answer_lower)

        return matches >= min(3, len(key_terms) // 2)

    def _evaluate_with_llm(self, test_case: TestCase, agent_answer: Optional[str]) -> Dict[str, Any]:
        """Use LLM to evaluate if the answer meets criteria"""
        if not agent_answer:
            return {
                "passed": False,
                "reasoning": "No answer provided",
                "reward": 0.0
            }

        # This is a simplified version - implement full LLM evaluation as needed
        return {
            "passed": self._check_answer_criteria(agent_answer, test_case.evaluation_criteria),
            "reasoning": "Basic criteria check",
            "reward": 0.5
        }

    def _fallback_llm_evaluation(self, test_case: TestCase, agent_answer: str) -> Dict[str, Any]:
        """Fallback LLM evaluation using direct API call when llm_evaluator module isn't available"""
        try:
            from openai import OpenAI

            config = Config.from_env()
            client_config, model = config.llm.get_client_config()
            base_url = client_config.pop("base_url", None)

            if base_url:
                client = OpenAI(base_url=base_url, **client_config)
            else:
                client = OpenAI(**client_config)

            # Create evaluation prompt
            eval_prompt = f"""Evaluate the agent's response based on the test criteria.

Test Question: {test_case.user_question}

Agent's Answer: {agent_answer}

Evaluation Criteria: {test_case.evaluation_criteria}

Provide a JSON evaluation with:
1. "reward": A score from 0.0 to 1.0 (0.6+ is passing)
2. "passed": Boolean indicating if the answer meets the criteria
3. "reasoning": Explanation for the score (2-3 sentences)
4. "required_info_found": Object with boolean values for each required piece of information

Respond with valid JSON only."""

            response = client.chat.completions.create(
                model=model,
                messages=[
                    {"role": "system", "content": "You are an evaluation judge. Evaluate if the agent's answer correctly addresses the user's question based on the criteria."},
                    {"role": "user", "content": eval_prompt}
                ],
                temperature=_reasoning_safe_temperature(model, 0.1),
                response_format={"type": "json_object"}
            )

            result = json.loads(response.choices[0].message.content)

            # Ensure required fields
            if 'reward' not in result:
                result['reward'] = 0.5
            if 'passed' not in result:
                result['passed'] = result['reward'] >= 0.6
            if 'reasoning' not in result:
                result['reasoning'] = "Evaluation completed"
            if 'required_info_found' not in result:
                result['required_info_found'] = {}

            # Log evaluation results
            logger.info("="*60)
            logger.info("Fallback LLM Evaluation Results")
            logger.info("="*60)
            logger.info(f"Reward: {result['reward']:.3f}/1.000")
            logger.info(f"Passed: {'Yes' if result['passed'] else 'No'}")
            logger.info(f"Reasoning: {result['reasoning']}")

            if result['required_info_found']:
                logger.info("Required Information Found:")
                for key, found in result['required_info_found'].items():
                    status = "✓" if found else "✗"
                    logger.info(f"  {status} {key}")
            logger.info("="*60)

            return result

        except Exception as e:
            logger.error(f"Fallback LLM evaluation failed: {e}")
            return None

    def evaluate_batch(self, test_ids: Optional[List[str]] = None) -> Dict[str, EvaluationResult]:
        """Evaluate multiple test cases"""
        if test_ids is None:
            test_ids = list(self.test_cases.keys())

        for test_id in test_ids:
            try:
                self.evaluate_test_case(test_id)
            except Exception as e:
                logger.error(f"Failed to evaluate {test_id}: {e}")

        return self.results

    def generate_report(self) -> str:
        """Generate evaluation report"""
        if not self.results:
            return "No evaluation results available"

        report = ["=" * 80]
        report.append("CONTEXTUAL MEMORY EVALUATION REPORT")
        report.append("=" * 80)
        report.append(f"Generated: {datetime.now().isoformat()}")
        report.append(f"Total Test Cases: {len(self.results)}")
        report.append("")

        # Summary statistics
        successful = sum(1 for r in self.results.values() if r.success)
        report.append(f"Success Rate: {successful}/{len(self.results)} ({100*successful/len(self.results):.1f}%)")
        report.append("")

        # Statistics by category
        categories = {}
        for test_id, result in self.results.items():
            if test_id in self.test_cases:
                cat = self.test_cases[test_id].category
                if cat not in categories:
                    categories[cat] = {"total": 0, "success": 0}
                categories[cat]["total"] += 1
                if result.success:
                    categories[cat]["success"] += 1

        report.append("Results by Category:")
        for cat in sorted(categories.keys()):
            stats = categories[cat]
            rate = 100 * stats["success"] / stats["total"] if stats["total"] > 0 else 0
            report.append(f"  {cat}: {stats['success']}/{stats['total']} ({rate:.1f}%)")
        report.append("")

        # Average metrics
        avg_iterations = sum(r.iterations for r in self.results.values()) / len(self.results)
        avg_tool_calls = sum(r.tool_calls for r in self.results.values()) / len(self.results)
        avg_time = sum(r.processing_time for r in self.results.values()) / len(self.results)
        avg_context_time = sum(r.context_generation_time for r in self.results.values()) / len(self.results)

        report.append("Average Metrics:")
        report.append(f"  Iterations: {avg_iterations:.1f}")
        report.append(f"  Tool Calls: {avg_tool_calls:.1f}")
        report.append(f"  Processing Time: {avg_time:.2f}s")
        report.append(f"  Context Generation Time: {avg_context_time:.2f}s")
        report.append("")

        # Memory usage statistics
        total_cards_used = sum(len(r.memory_cards_used) for r in self.results.values())
        total_chunks_retrieved = sum(len(r.chunks_retrieved) for r in self.results.values())

        report.append("Memory Usage:")
        report.append(f"  Total Memory Cards Used: {total_cards_used}")
        report.append(f"  Total Chunks Retrieved: {total_chunks_retrieved}")
        report.append(f"  Avg Cards per Query: {total_cards_used/len(self.results):.1f}")
        report.append(f"  Avg Chunks per Query: {total_chunks_retrieved/len(self.results):.1f}")
        report.append("")

        # Individual test results
        report.append("-" * 80)
        report.append("INDIVIDUAL TEST RESULTS")
        report.append("-" * 80)

        for test_id, result in sorted(self.results.items()):
            test_case = self.test_cases.get(test_id)
            report.append(f"\nTest: {test_id}")
            if test_case:
                report.append(f"  Title: {test_case.title}")
                report.append(f"  Category: {test_case.category}")
            report.append(f"  Status: {'✓ Success' if result.success else '✗ Failed'}")
            report.append(f"  Iterations: {result.iterations}")
            report.append(f"  Tool Calls: {result.tool_calls}")
            report.append(f"  Memory Cards Used: {len(result.memory_cards_used)}")
            report.append(f"  Chunks Retrieved: {len(result.chunks_retrieved)}")
            report.append(f"  Contextual Chunks: {result.contextual_chunks_count}")
            report.append(f"  Processing Time: {result.processing_time:.2f}s")
            if result.error:
                report.append(f"  Error: {result.error}")

        return "\n".join(report)

    def save_results(self, output_file: str):
        """Save evaluation results to file"""
        results_data = {
            "timestamp": datetime.now().isoformat(),
            "config": {
                "use_contextual": True,
                "llm_provider": self.config.llm.provider,
                "llm_model": self.config.llm.model,
                "chunking_strategy": self.config.chunking.strategy.value,
                "index_mode": self.config.index.mode.value
            },
            "summary": {
                "total_tests": len(self.results),
                "successful": sum(1 for r in self.results.values() if r.success),
                "failed": sum(1 for r in self.results.values() if not r.success)
            },
            "results": {
                test_id: result.to_dict()
                for test_id, result in self.results.items()
            }
        }

        with open(output_file, 'w', encoding='utf-8') as f:
            json.dump(results_data, f, indent=2, ensure_ascii=False)

        logger.info(f"Results saved to {output_file}")

contextual_indexer.py

"""Contextual RAG Indexer with Advanced Memory Cards

This module combines contextual chunking for conversation histories with
advanced JSON cards for structured user memory.
"""

import os
import json
import logging
import requests
import time
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass
from pathlib import Path

from config import IndexConfig, IndexMode, ChunkingConfig
from chunker import ConversationChunk
from contextual_chunking import ContextualConversationChunk, ContextualConversationChunker
from advanced_memory_manager import AdvancedMemoryManager, AdvancedMemoryCard
from indexer import SearchResult


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)
logger = logging.getLogger(__name__)


@dataclass 
class ContextualSearchResult(SearchResult):
    """Enhanced search result with contextual information"""
    context: str = ""
    contextual_chunk: Optional[ContextualConversationChunk] = None

    def to_dict(self) -> Dict[str, Any]:
        result = super().to_dict()
        result["context"] = self.context
        if self.contextual_chunk:
            result["contextual_info"] = {
                "context": self.contextual_chunk.context,
                "context_tokens": self.contextual_chunk.context_tokens,
                "generation_time": self.contextual_chunk.generation_time
            }
        return result


class ContextualMemoryIndexer:
    """
    Dual-layer memory indexer combining:
    1. Contextual RAG for conversation chunks
    2. Advanced JSON cards for structured facts
    """

    def __init__(self,
                 user_id: str,
                 index_config: Optional[IndexConfig] = None,
                 chunking_config: Optional[ChunkingConfig] = None,
                 use_contextual: bool = True):
        """
        Initialize the contextual memory indexer.

        Args:
            user_id: User identifier
            index_config: Index configuration
            chunking_config: Chunking configuration
            use_contextual: Whether to use contextual chunking
        """
        self.user_id = user_id
        self.index_config = index_config or IndexConfig()
        self.chunking_config = chunking_config or ChunkingConfig()
        self.use_contextual = use_contextual

        # Initialize contextual chunker
        self.contextual_chunker = ContextualConversationChunker(
            chunking_config=self.chunking_config,
            use_contextual=use_contextual
        )

        # Initialize advanced memory manager
        self.memory_manager = AdvancedMemoryManager(
            user_id=user_id,
            storage_dir=self.index_config.chunk_store_path
        )

        # Storage for chunks
        self.contextual_chunks: Dict[str, ContextualConversationChunk] = {}
        self.doc_id_mapping: Dict[str, str] = {}

        # Retrieval pipeline URL
        self.retrieval_url = "http://localhost:4242"

        # Create directories
        Path(self.index_config.index_path).parent.mkdir(parents=True, exist_ok=True)
        Path(self.index_config.chunk_store_path).mkdir(parents=True, exist_ok=True)

        # Statistics
        self.stats = {
            "chunks_indexed": 0,
            "memory_cards": 0,
            "context_generation_time": 0.0,
            "indexing_time": 0.0
        }

        self._check_retrieval_pipeline()
        logger.info(f"Initialized ContextualMemoryIndexer for user {user_id}")

    def _check_retrieval_pipeline(self):
        """Check if the retrieval pipeline service is available"""
        try:
            response = requests.get(f"{self.retrieval_url}/health", timeout=2)
            if response.status_code == 200:
                logger.info("✓ Retrieval pipeline service is available")
            else:
                logger.warning(f"Retrieval pipeline returned status {response.status_code}")
        except requests.exceptions.RequestException as e:
            logger.warning(f"Retrieval pipeline service not available at {self.retrieval_url}: {e}")
            logger.info("Note: The retrieval pipeline needs to be running. Start it with:")
            logger.info("  cd projects/week3/retrieval-pipeline && python api_server.py")

    def process_conversation_history(self,
                                    chunks: List[ConversationChunk],
                                    conversation_id: str,
                                    generate_summary_cards: bool = True) -> Dict[str, Any]:
        """
        Process conversation history with both contextual chunking and memory cards.

        Args:
            chunks: Basic conversation chunks
            conversation_id: Conversation identifier
            generate_summary_cards: Whether to generate summary cards

        Returns:
            Processing results
        """
        start_time = time.time()
        results = {
            "conversation_id": conversation_id,
            "contextual_chunks": 0,
            "memory_cards_before": len(self.memory_manager.categories),
            "memory_cards_after": 0,
            "processing_time": 0
        }

        # Step 1: Generate contextual chunks
        logger.info(f"Processing {len(chunks)} chunks for conversation {conversation_id}")

        if self.use_contextual:
            contextual_chunks = self.contextual_chunker.contextualize_chunks(chunks)
            logger.info(f"Generated {len(contextual_chunks)} contextual chunks")
        else:
            # Convert to contextual chunks without context
            contextual_chunks = [
                ContextualConversationChunk.from_basic_chunk(chunk)
                for chunk in chunks
            ]

        # Store contextual chunks
        for chunk in contextual_chunks:
            self.contextual_chunks[chunk.chunk_id] = chunk

        results["contextual_chunks"] = len(contextual_chunks)

        # Step 2: Index contextual chunks
        self._index_contextual_chunks(contextual_chunks)

        # Step 3: Generate summary cards if requested
        if generate_summary_cards:
            summary_cards = self._generate_summary_cards(chunks, conversation_id)
            for card in summary_cards:
                self.memory_manager.add_card(card)

            logger.info(f"Generated {len(summary_cards)} summary cards")
            results["new_summary_cards"] = len(summary_cards)

        # Step 4: Create conversation summary linking to memory cards
        conversation_summary = self.memory_manager.summarize_for_conversation(conversation_id)
        results["memory_cards_after"] = sum(len(cards) for cards in self.memory_manager.categories.values())
        results["conversation_summary"] = conversation_summary

        # Update statistics
        results["processing_time"] = time.time() - start_time
        self.stats["chunks_indexed"] += len(contextual_chunks)
        self.stats["memory_cards"] = results["memory_cards_after"]
        self.stats["indexing_time"] += results["processing_time"]

        logger.info(f"Processing complete for conversation {conversation_id} in {results['processing_time']:.2f}s")

        return results

    def _index_contextual_chunks(self, chunks: List[ContextualConversationChunk]):
        """Index contextual chunks in the retrieval pipeline"""
        documents = []

        for chunk in chunks:
            # Use contextualized text for indexing
            doc = {
                "text": chunk.contextualized_text,
                "metadata": {
                    "doc_id": chunk.chunk_id,
                    "test_id": chunk.test_id,
                    "conversation_id": chunk.conversation_id,
                    "chunk_index": chunk.chunk_index,
                    "start_round": chunk.start_round,
                    "end_round": chunk.end_round,
                    "has_context": bool(chunk.context),
                    "context_preview": chunk.context[:100] if chunk.context else "",
                    **chunk.metadata
                }
            }
            documents.append(doc)

        # Send to retrieval pipeline
        try:
            indexed_count = 0
            for doc in documents:
                try:
                    response = requests.post(
                        f"{self.retrieval_url}/index",
                        json=doc
                    )

                    if response.status_code == 200:
                        result = response.json()
                        generated_doc_id = result.get("doc_id")
                        our_chunk_id = doc.get("metadata", {}).get("doc_id")

                        if generated_doc_id and our_chunk_id:
                            self.doc_id_mapping[generated_doc_id] = our_chunk_id

                        indexed_count += 1
                except Exception as e:
                    logger.warning(f"Error indexing document: {e}")

            logger.info(f"Indexed {indexed_count}/{len(documents)} contextual chunks")

        except Exception as e:
            logger.error(f"Error connecting to retrieval pipeline: {e}")

    def _generate_summary_cards(self, 
                               chunks: List[ConversationChunk],
                               conversation_id: str) -> List[AdvancedMemoryCard]:
        """
        Generate summary cards from conversation chunks using LLM extraction.

        Uses an LLM to analyze conversation chunks and extract structured memory cards
        following the Advanced JSON Cards format from week2/user-memory.
        """
        cards = []

        if not chunks:
            return cards

        # Combine all conversation text
        full_text = "\n".join([chunk.to_text() for chunk in chunks])

        # Use LLM to extract structured memory cards
        try:
            # Initialize LLM client if needed
            if not hasattr(self, '_llm_client'):
                from openai import OpenAI
                from config import Config
                config = Config.from_env()
                client_config, model = config.llm.get_client_config()
                base_url = client_config.pop("base_url", None)

                if base_url:
                    self._llm_client = OpenAI(base_url=base_url, **client_config)
                else:
                    self._llm_client = OpenAI(**client_config)
                self._llm_model = model

            # Create extraction prompt
            extraction_prompt = f"""Analyze this conversation and extract structured memory cards.

Conversation:
{full_text}

Extract any important information into memory cards. Each card MUST include:
- category: The type of information (e.g., 'personal', 'financial', 'medical', 'travel', 'family', 'work')
- card_key: A unique identifier for this card
- backstory: Context about when/why this information was learned (1-2 sentences)
- date_created: Current timestamp (YYYY-MM-DD HH:MM:SS)
- person: Who this relates to (e.g., "John Smith (primary)", "Sarah Smith (daughter)")
- relationship: Role/relationship (e.g., "primary account holder", "family member")
- Additional relevant fields based on the information type

Respond with a JSON array of memory cards. If no significant information found, return empty array [].

Example format:
[{{
    "category": "financial",
    "card_key": "bank_account_primary",
    "backstory": "User shared their banking details while setting up automatic bill payments",
    "date_created": "2024-01-15 10:30:00",
    "person": "John Smith (primary)",
    "relationship": "primary account holder",
    "bank_name": "Chase Bank",
    "account_type": "checking",
    "account_ending": "4567"
}}]

Extract memory cards:"""

            # Call LLM for extraction
            response = self._llm_client.chat.completions.create(
                model=self._llm_model,
                messages=[
                    {"role": "system", "content": "You are a memory extraction assistant. Extract structured information from conversations into memory cards."},
                    {"role": "user", "content": extraction_prompt}
                ],
                temperature=_reasoning_safe_temperature(self._llm_model, 0.3),
                response_format={"type": "json_object"}
            )

            # Parse response
            content = response.choices[0].message.content
            extracted_data = json.loads(content) if content else {}

            # Handle both single card and array of cards
            if isinstance(extracted_data, dict):
                # Check if it's a wrapper with 'cards' key
                if 'cards' in extracted_data:
                    extracted_cards = extracted_data['cards']
                elif 'memory_cards' in extracted_data:
                    extracted_cards = extracted_data['memory_cards']
                else:
                    # Single card
                    extracted_cards = [extracted_data] if extracted_data else []
            elif isinstance(extracted_data, list):
                extracted_cards = extracted_data
            else:
                extracted_cards = []

            # Convert extracted data to AdvancedMemoryCard objects
            for card_data in extracted_cards:
                try:
                    # Ensure required fields
                    if 'backstory' not in card_data:
                        card_data['backstory'] = f"Information extracted from conversation {conversation_id}"
                    if 'date_created' not in card_data:
                        card_data['date_created'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                    if 'person' not in card_data:
                        card_data['person'] = 'User (primary)'
                    if 'relationship' not in card_data:
                        card_data['relationship'] = 'primary account holder'

                    # Extract main fields
                    category = card_data.pop('category', 'general')
                    card_key = card_data.pop('card_key', f"{category}_{conversation_id[:8]}")
                    backstory = card_data.pop('backstory')
                    date_created = card_data.pop('date_created')
                    person = card_data.pop('person')
                    relationship = card_data.pop('relationship')

                    # Create card with remaining fields as data
                    card = AdvancedMemoryCard(
                        category=category,
                        card_key=card_key,
                        backstory=backstory,
                        date_created=date_created,
                        person=person,
                        relationship=relationship,
                        data=card_data  # All remaining fields
                    )
                    cards.append(card)

                except Exception as e:
                    logger.warning(f"Error creating memory card: {e}")
                    continue

        except Exception as e:
            logger.error(f"Error generating memory cards with LLM: {e}")
            # Fallback to basic extraction if LLM fails
            cards = self._fallback_extraction(chunks, conversation_id, full_text)

        return cards

    def _fallback_extraction(self, chunks: List[ConversationChunk], 
                             conversation_id: str, full_text: str) -> List[AdvancedMemoryCard]:
        """Fallback extraction method if LLM is unavailable"""
        cards = []

        # Extract basic information based on keywords
        categories_keywords = {
            'financial': ['bank', 'account', 'money', 'payment', 'credit', 'loan', 'savings', 'checking'],
            'medical': ['doctor', 'medical', 'health', 'appointment', 'prescription', 'hospital', 'clinic'],
            'travel': ['flight', 'travel', 'trip', 'vacation', 'hotel', 'booking', 'destination'],
            'family': ['family', 'child', 'parent', 'spouse', 'daughter', 'son', 'wife', 'husband'],
            'work': ['job', 'work', 'employer', 'salary', 'office', 'meeting', 'project', 'colleague']
        }

        text_lower = full_text.lower()

        for category, keywords in categories_keywords.items():
            if any(keyword in text_lower for keyword in keywords):
                card = AdvancedMemoryCard(
                    category=category,
                    card_key=f"{category}_{conversation_id[:8]}",
                    backstory=f"Information about {category} topics discussed in conversation",
                    date_created=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                    person="User (primary)",
                    relationship="primary account holder",
                    data={
                        "conversation_id": conversation_id,
                        "topics": [kw for kw in keywords if kw in text_lower][:3],
                        "extracted_from": f"{len(chunks)} conversation chunks",
                        "extraction_method": "fallback_keywords"
                    }
                )
                cards.append(card)
                break  # Only add one card per conversation in fallback mode

        return cards

    def search_with_context(self,
                           query: str,
                           top_k: int = 3,
                           include_memory_cards: bool = True) -> Dict[str, Any]:
        """
        Search both contextual chunks and memory cards.

        Args:
            query: Search query
            top_k: Number of results
            include_memory_cards: Whether to search memory cards too

        Returns:
            Combined search results
        """
        results = {
            "query": query,
            "chunk_results": [],
            "memory_card_results": [],
            "combined_context": ""
        }

        # Search contextual chunks via retrieval pipeline or fallback to local search
        try:
            response = requests.post(
                f"{self.retrieval_url}/search",
                json={
                    "query": query,
                    "mode": "hybrid",
                    "top_k": max(20, top_k),
                    "rerank_top_k": top_k,
                    "skip_reranking": False
                },
                timeout=5
            )
            response.raise_for_status()

            data = response.json()
            search_results = data.get("reranked_results", [])

            for item in search_results:
                metadata = item.get("metadata", {})
                chunk_id = metadata.get("doc_id")

                if chunk_id and chunk_id in self.contextual_chunks:
                    contextual_chunk = self.contextual_chunks[chunk_id]

                    # Create a basic ConversationChunk for compatibility
                    from chunker import ConversationChunk
                    basic_chunk = ConversationChunk(
                        chunk_id=contextual_chunk.chunk_id,
                        conversation_id=contextual_chunk.conversation_id,
                        test_id=contextual_chunk.test_id,
                        chunk_index=contextual_chunk.chunk_index,
                        start_round=contextual_chunk.start_round,
                        end_round=contextual_chunk.end_round,
                        messages=contextual_chunk.messages,
                        metadata=contextual_chunk.metadata,
                        context_before=contextual_chunk.context_before,
                        context_after=contextual_chunk.context_after,
                        created_at=contextual_chunk.created_at
                    )

                    result = ContextualSearchResult(
                        chunk_id=chunk_id,
                        score=float(item.get("rerank_score", 0)),
                        chunk=basic_chunk,
                        match_type="hybrid",
                        context=contextual_chunk.context,
                        contextual_chunk=contextual_chunk
                    )
                    results["chunk_results"].append(result.to_dict())

        except Exception as e:
            logger.error(f"Error searching via retrieval pipeline: {e}")
            logger.info("Falling back to local search...")

            # Fallback: Local search through contextual chunks
            query_lower = query.lower()
            local_results = []

            for chunk_id, chunk in self.contextual_chunks.items():
                # Search in contextualized text (skip chunks with empty text)
                if query_lower and chunk.contextualized_text and query_lower in chunk.contextualized_text.lower():
                    # Calculate a simple relevance score based on frequency
                    score = chunk.contextualized_text.lower().count(query_lower) / len(chunk.contextualized_text)
                    local_results.append((score, chunk_id, chunk))

            # Sort by score and take top_k
            local_results.sort(key=lambda x: x[0], reverse=True)

            for score, chunk_id, contextual_chunk in local_results[:top_k]:
                # Create a basic ConversationChunk for compatibility
                from chunker import ConversationChunk
                basic_chunk = ConversationChunk(
                    chunk_id=contextual_chunk.chunk_id,
                    conversation_id=contextual_chunk.conversation_id,
                    test_id=contextual_chunk.test_id,
                    chunk_index=contextual_chunk.chunk_index,
                    start_round=contextual_chunk.start_round,
                    end_round=contextual_chunk.end_round,
                    messages=contextual_chunk.messages,
                    metadata=contextual_chunk.metadata,
                    context_before=contextual_chunk.context_before,
                    context_after=contextual_chunk.context_after,
                    created_at=contextual_chunk.created_at
                )

                results["chunk_results"].append({
                    "chunk_id": chunk_id,
                    "score": score,
                    "context": contextual_chunk.context,
                    "conversation_id": contextual_chunk.conversation_id,
                    "rounds": f"{contextual_chunk.start_round}-{contextual_chunk.end_round}",
                    "text": contextual_chunk.original_text
                })

            logger.info(f"Local search returned {len(results['chunk_results'])} results")

        # Search memory cards
        if include_memory_cards:
            card_results = self.memory_manager.search_cards(query)
            for memory_id, card in card_results[:top_k]:
                results["memory_card_results"].append({
                    "memory_id": memory_id,
                    "category": card.category,
                    "backstory": card.backstory,
                    "person": card.person,
                    "data": card.data
                })

        # Build combined context
        context_parts = []

        if results["memory_card_results"]:
            context_parts.append("=== RELEVANT MEMORY CARDS ===")
            for card_result in results["memory_card_results"]:
                context_parts.append(f"- {card_result['category']}: {card_result['backstory']}")

        if results["chunk_results"]:
            context_parts.append("\n=== RELEVANT CONVERSATION CHUNKS ===")
            for chunk_result in results["chunk_results"]:
                context_parts.append(f"- {chunk_result.get('context', 'No context')}")

        results["combined_context"] = "\n".join(context_parts)

        return results

    def get_agent_context(self, max_memory_cards: int = 10) -> str:
        """
        Get the complete context for the agent including memory cards.

        Args:
            max_memory_cards: Maximum number of memory cards to include

        Returns:
            Formatted context string
        """
        return self.memory_manager.get_context_string(max_cards=max_memory_cards)

    def save_index(self, path: Optional[str] = None):
        """Save the index and memory to disk"""
        path = path or self.index_config.index_path

        # Save contextual chunks
        chunks_data = {
            chunk_id: chunk.to_dict()
            for chunk_id, chunk in self.contextual_chunks.items()
        }

        with open(f"{path}_contextual_chunks.json", 'w', encoding='utf-8') as f:
            json.dump(chunks_data, f, ensure_ascii=False, indent=2)

        # Memory cards are automatically saved by the memory manager

        # Save statistics
        with open(f"{path}_stats.json", 'w', encoding='utf-8') as f:
            json.dump(self.stats, f, indent=2)

        logger.info(f"Saved index to {path}")

    def load_index(self, path: Optional[str] = None):
        """Load the index from disk"""
        path = path or self.index_config.index_path

        try:
            # Load contextual chunks
            with open(f"{path}_contextual_chunks.json", 'r', encoding='utf-8') as f:
                chunks_data = json.load(f)

            self.contextual_chunks = {}
            for chunk_id, chunk_dict in chunks_data.items():
                # Convert messages
                from chunker import ConversationMessage
                messages = []
                for msg_data in chunk_dict.get('messages', []):
                    messages.append(ConversationMessage(**msg_data))

                # Create contextual chunk
                chunk = ContextualConversationChunk(
                    chunk_id=chunk_dict['chunk_id'],
                    conversation_id=chunk_dict['conversation_id'],
                    test_id=chunk_dict['test_id'],
                    chunk_index=chunk_dict['chunk_index'],
                    start_round=chunk_dict['start_round'],
                    end_round=chunk_dict['end_round'],
                    messages=messages,
                    original_text=chunk_dict.get('original_text', ''),
                    context=chunk_dict.get('context', ''),
                    contextualized_text=chunk_dict.get('contextualized_text', ''),
                    context_tokens=chunk_dict.get('context_tokens', 0),
                    generation_time=chunk_dict.get('generation_time', 0),
                    metadata=chunk_dict.get('metadata', {}),
                    context_before=chunk_dict.get('context_before'),
                    context_after=chunk_dict.get('context_after'),
                    created_at=chunk_dict.get('created_at', '')
                )
                self.contextual_chunks[chunk_id] = chunk

            # Memory cards are automatically loaded by the memory manager

            # Load statistics
            stats_path = f"{path}_stats.json"
            if Path(stats_path).exists():
                with open(stats_path, 'r', encoding='utf-8') as f:
                    self.stats = json.load(f)

            logger.info(f"Loaded {len(self.contextual_chunks)} contextual chunks from {path}")

            # Re-index in retrieval pipeline
            self._index_contextual_chunks(list(self.contextual_chunks.values()))

        except Exception as e:
            logger.error(f"Error loading index: {e}")
            raise

    def get_statistics(self) -> Dict[str, Any]:
        """Get comprehensive statistics"""
        stats = self.stats.copy()

        # Add chunker statistics
        stats["chunker_stats"] = self.contextual_chunker.get_statistics()

        # Add memory manager statistics
        stats["memory_stats"] = self.memory_manager.get_statistics()

        # Calculate averages
        if stats["chunks_indexed"] > 0:
            stats["avg_indexing_time"] = stats["indexing_time"] / stats["chunks_indexed"]

        return stats

demo_agent_logging.py

#!/usr/bin/env python3
"""Demonstration of agent tool logging with full content"""

import os
import json
import logging
from rich.console import Console
from chunker import ConversationChunker, ConversationChunk, ConversationMessage
from indexer import MemoryIndexer
from agent import UserMemoryRAGAgent
from config import Config

# Set up logging to see agent logs
logging.basicConfig(
    level=logging.INFO,
    format='%(message)s'  # Simple format to show just the message
)

# Set dummy API key for demo
os.environ["KIMI_API_KEY"] = "test_key"

console = Console()


def create_demo_conversation():
    """Create a demo conversation with important information"""
    messages = [
        ConversationMessage(role="user", content="Hi, I'd like to open a checking account."),
        ConversationMessage(role="assistant", content="I'd be happy to help you open a checking account. May I have your full name?"),
        ConversationMessage(role="user", content="John Michael Smith"),
        ConversationMessage(role="assistant", content="Thank you, Mr. Smith. What's your date of birth?"),
        ConversationMessage(role="user", content="March 15, 1985"),
        ConversationMessage(role="assistant", content="Perfect. And your Social Security Number?"),
        ConversationMessage(role="user", content="123-45-6789"),
        ConversationMessage(role="assistant", content="Thank you. What's your current address?"),
        ConversationMessage(role="user", content="456 Oak Avenue, Springfield, IL 62701"),
        ConversationMessage(role="assistant", content="Great. And a phone number where we can reach you?"),
        ConversationMessage(role="user", content="555-0123"),
        ConversationMessage(role="assistant", content="Excellent. I've set up your account. Your new account number is 4429853327."),
        ConversationMessage(role="user", content="Great! What's the routing number?"),
        ConversationMessage(role="assistant", content="The routing number for our Springfield branch is 071000013."),
        ConversationMessage(role="user", content="Perfect, thank you for your help!"),
        ConversationMessage(role="assistant", content="You're welcome! Your debit card will arrive in 7-10 business days."),
    ]

    chunk = ConversationChunk(
        chunk_id="demo_bank_account_001",
        conversation_id="bank_conv_001",
        test_id="demo_test",
        chunk_index=0,
        start_round=1,
        end_round=8,
        messages=messages,
        metadata={
            "business": "First National Bank",
            "department": "New Accounts",
            "date": "2024-11-15",
            "agent": "Sarah Johnson"
        }
    )

    return [chunk]


def demonstrate_agent_logging():
    """Demonstrate how the agent logs tool calls and results"""
    console.print("\n[bold cyan]Agent Tool Logging Demonstration[/bold cyan]")
    console.print("="*80)
    console.print("[yellow]Note: Watch for tool call parameters and full results in the logs[/yellow]\n")

    # Initialize configuration
    config = Config.from_env()
    config.agent.enable_reasoning = True  # Enable reasoning for more detailed logs

    # Initialize indexer and add demo conversation
    indexer = MemoryIndexer(config.index)
    chunks = create_demo_conversation()

    # Manually add chunks without pipeline indexing for demo
    for chunk in chunks:
        indexer.chunks[chunk.chunk_id] = chunk
        indexer.chunk_texts[chunk.chunk_id] = chunk.to_text()

    # Initialize agent
    agent = UserMemoryRAGAgent(indexer, config)

    # Test question that will trigger tool usage
    test_question = "What is the customer's account number and when will they receive their debit card?"

    console.print(f"\n[bold]Test Question:[/bold] {test_question}")
    console.print("\n[yellow]Agent processing (watch the tool logs below):[/yellow]\n")
    console.print("="*80 + "\n")

    # This would normally call the LLM, but for demo we'll simulate tool calls
    # In a real scenario, the agent.answer_question() method would be called

    # Simulate what the agent would do:
    from tools import MemoryTools
    tools = MemoryTools(indexer)

    # Simulate search_memory call (this is what the agent would do)
    console.print("[dim]Agent would execute:[/dim]")
    result1 = agent._execute_tool("search_memory", {
        "query": "account number debit card",
        "top_k": 3,
        "filter_test_id": "demo_test"
    })

    # The logging happens automatically in _execute_tool

    console.print("\n[dim]Agent might also execute:[/dim]")
    result2 = agent._execute_tool("get_full_conversation", {
        "conversation_id": "bank_conv_001",
        "test_id": "demo_test"
    })

    console.print("\n" + "="*80)
    console.print("[green]Demonstration complete![/green]")
    console.print("\nKey observations:")
    console.print("  1. Tool call parameters are logged in full")
    console.print("  2. Tool results show complete content (not truncated)")
    console.print("  3. Each tool call is clearly separated with dividers")
    console.print("  4. JSON formatting makes results easy to read")


def main():
    """Run the demonstration"""
    console.print("\n[bold]Agentic RAG - Tool Logging Demonstration[/bold]")
    console.print("This shows how tool calls and results are logged to the console\n")

    try:
        demonstrate_agent_logging()

    except Exception as e:
        console.print(f"\n[red]Error during demonstration: {e}[/red]")
        import traceback
        traceback.print_exc()


if __name__ == "__main__":
    main()

demo_ui_improvements.py

#!/usr/bin/env python3
"""Demonstration of UI improvements for test case selection"""

import os
import sys
from rich.console import Console
from rich.table import Table
from rich.panel import Panel

# Mock environment for demo
os.environ["KIMI_API_KEY"] = "demo_key"

console = Console()


def demo_single_test_selection():
    """Demonstrate the improved single test case selection"""
    console.print("\n[bold cyan]Improved Single Test Case Selection[/bold cyan]")
    console.print("="*60)

    # Simulated test cases
    test_cases = {
        "layer1_01_bank_account": "Bank Account Setup - Personal Details Retrieval",
        "layer1_02_insurance_claim": "Insurance Claim - Policy and Representative Information",
        "layer1_03_medical_appointment": "Medical Appointment - Doctor and Schedule Details",
        "layer2_01_multiple_vehicles": "Multiple Vehicles - Service Scheduling Conflict",
        "layer2_02_multiple_properties": "Multiple Properties - Maintenance Priorities",
        "layer3_01_travel_coordination": "Travel Coordination - Multi-Factor Planning"
    }

    console.print("\n[bold]Available Test Cases:[/bold]")

    # Group by category
    categories = {
        "layer1": [],
        "layer2": [],
        "layer3": []
    }

    for test_id, title in test_cases.items():
        cat = test_id.split("_")[0]
        categories[cat].append((test_id, title))

    # Display with numbers
    test_ids_list = []
    for cat in sorted(categories.keys()):
        console.print(f"\n[cyan]{cat.upper()}:[/cyan]")
        for test_id, title in categories[cat]:
            test_ids_list.append(test_id)
            idx = len(test_ids_list)
            console.print(f"  [{idx}] {test_id}: {title[:60]}...")

    console.print("\n[dim]Enter test ID directly or number from the list above[/dim]")
    console.print("[yellow]Example inputs: '3' or 'layer1_03_medical_appointment'[/yellow]")


def demo_view_test_cases_table():
    """Demonstrate the improved test cases table with index numbers"""
    console.print("\n[bold cyan]Improved Test Cases Table View[/bold cyan]")
    console.print("="*60)

    # Create table with index numbers
    table = Table(title="Loaded Test Cases")
    table.add_column("#", style="dim", width=4)
    table.add_column("ID", style="cyan")
    table.add_column("Category", style="magenta")
    table.add_column("Title", style="green")
    table.add_column("Conversations", justify="right")

    # Sample data
    test_data = [
        ("layer1_01_bank_account", "layer1", "Bank Account Setup - Personal Details Retrieval", "1"),
        ("layer1_02_insurance_claim", "layer1", "Insurance Claim - Policy and Representative Info", "1"),
        ("layer2_01_multiple_vehicles", "layer2", "Multiple Vehicles - Service Scheduling Conflict", "2"),
        ("layer3_01_travel_coordination", "layer3", "Travel Coordination - Multi-Factor Planning", "3")
    ]

    for idx, (test_id, category, title, convs) in enumerate(test_data, 1):
        table.add_row(
            str(idx),
            test_id,
            category,
            title[:50] + "..." if len(title) > 50 else title,
            convs
        )

    console.print(table)
    console.print("\n[yellow]Users can now select by entering either:[/yellow]")
    console.print("  • The index number (e.g., '2')")
    console.print("  • The full test ID (e.g., 'layer1_02_insurance_claim')")


def demo_category_evaluation():
    """Demonstrate the improved category evaluation selection"""
    console.print("\n[bold cyan]Improved Category Evaluation[/bold cyan]")
    console.print("="*60)

    console.print("\n[bold]Available Categories:[/bold]")
    console.print("  layer1: 20 test cases")
    console.print("  layer2: 20 test cases")
    console.print("  layer3: 20 test cases")

    console.print("\n[dim]After selecting a category, you'll see:[/dim]")

    console.print(f"\n[cyan]Test cases in layer1:[/cyan]")
    sample_cases = [
        ("layer1_01_bank_account", "Bank Account Setup - Personal Details Retrieval"),
        ("layer1_02_insurance_claim", "Insurance Claim - Policy and Representative Info"),
        ("layer1_03_medical_appointment", "Medical Appointment - Doctor and Schedule Details")
    ]

    for test_id, title in sample_cases:
        console.print(f"  • {test_id}: {title[:60]}...")
    console.print("  [dim]... (17 more test cases)[/dim]")

    console.print(f"\n[cyan]Total: 20 test cases[/cyan]")
    console.print("[yellow]User can review the list before confirming evaluation[/yellow]")


def main():
    """Run all UI improvement demonstrations"""
    console.print(Panel.fit(
        "[bold]UI Improvements Demo - Test Case Selection[/bold]\n"
        "Showing how the improved interface helps users select test cases",
        border_style="cyan"
    ))

    # Demo 1: Single test selection
    demo_single_test_selection()

    # Demo 2: Table view with indices
    console.print("\n" + "="*80 + "\n")
    demo_view_test_cases_table()

    # Demo 3: Category evaluation
    console.print("\n" + "="*80 + "\n")
    demo_category_evaluation()

    console.print("\n" + "="*80)
    console.print("[bold green]Summary of Improvements:[/bold green]")
    console.print("✓ Test cases are displayed with index numbers for easy selection")
    console.print("✓ Users can select by number OR by test ID")
    console.print("✓ Categories show test case counts before evaluation")
    console.print("✓ Test case lists are shown before batch evaluation")
    console.print("✓ No need to memorize or guess test IDs")
    console.print("="*80 + "\n")


if __name__ == "__main__":
    main()

evaluator.py

"""Evaluation Framework Integration for User Memory RAG Agent

This module integrates with the user-memory-evaluation framework to load
test cases and evaluate the agent's performance.
"""

import os
import sys
import json
import yaml
import logging
from typing import List, Dict, Any, Optional, Tuple
from pathlib import Path
from dataclasses import dataclass, field
from datetime import datetime
import openai

# Import our local modules first to avoid conflicts
from config import Config
from chunker import ConversationChunker
from indexer import MemoryIndexer
from agent import UserMemoryRAGAgent

# Import modules from user-memory-evaluation project
LLMEvaluator = None
EvalTestCase = None
ConversationHistory = None
EvalMessage = None
MessageRole = None

# Import the specific modules we need from user-memory-evaluation
eval_project_path = Path(__file__).parent.parent.parent / "week2" / "user-memory-evaluation"
if eval_project_path.exists():
    import importlib.util
    try:
        # Load models module from user-memory-evaluation
        models_spec = importlib.util.spec_from_file_location(
            "user_memory_models", 
            eval_project_path / "models.py"
        )
        models_module = importlib.util.module_from_spec(models_spec)
        models_spec.loader.exec_module(models_module)

        # Load config module from user-memory-evaluation for the evaluator
        eval_config_spec = importlib.util.spec_from_file_location(
            "user_memory_config",
            eval_project_path / "config.py"
        )
        eval_config_module = importlib.util.module_from_spec(eval_config_spec)
        eval_config_spec.loader.exec_module(eval_config_module)

        # Load evaluator module from user-memory-evaluation
        eval_spec = importlib.util.spec_from_file_location(
            "user_memory_evaluator",
            eval_project_path / "evaluator.py"
        )
        eval_module = importlib.util.module_from_spec(eval_spec)

        # Temporarily add required modules to sys.modules for the evaluator to find
        sys.modules['models'] = models_module
        sys.modules['config'] = eval_config_module

        # Execute the evaluator module
        eval_spec.loader.exec_module(eval_module)

        # Clean up sys.modules to avoid conflicts
        del sys.modules['models']
        sys.modules['config'] = sys.modules[Config.__module__]  # Restore our own config

        # Extract the classes we need
        LLMEvaluator = eval_module.LLMEvaluator
        EvalTestCase = models_module.TestCase
        ConversationHistory = models_module.ConversationHistory
        EvalMessage = models_module.ConversationMessage
        MessageRole = models_module.MessageRole

        print(f"Successfully imported LLMEvaluator from {eval_project_path}")

    except Exception as e:
        print(f"Error loading LLMEvaluator: {e}")
        import traceback
        traceback.print_exc()


logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@dataclass
class TestCase:
    """Test case matching user-memory-evaluation framework structure"""
    test_id: str
    category: str
    title: str
    description: str
    conversation_histories: List[Dict[str, Any]]
    user_question: str
    evaluation_criteria: str  # Primary evaluation criteria (was expected_answer)
    expected_behavior: Optional[str] = None  # Optional expected behavior
    metadata: Dict[str, Any] = field(default_factory=dict)


@dataclass
class EvaluationResult:
    """Result from evaluating a test case"""
    test_id: str
    success: bool
    agent_answer: str
    evaluation_criteria: str  # Changed from expected_answer to match TestCase
    iterations: int
    tool_calls: int
    trajectory: Optional[Dict[str, Any]] = None
    processing_time: float = 0.0
    indexing_time: float = 0.0
    chunk_count: int = 0
    llm_evaluation: Optional[Dict[str, Any]] = None  # LLM evaluation details


class UserMemoryEvaluator:
    """Evaluates the RAG agent on user memory test cases"""

    def __init__(self, config: Optional[Config] = None):
        """
        Initialize the evaluator

        Args:
            config: Configuration object
        """
        self.config = config or Config.from_env()
        self.test_cases: Dict[str, TestCase] = {}
        self.results: Dict[str, EvaluationResult] = {}

        # Components
        self.chunker = ConversationChunker(self.config.chunking)
        self.indexer = None
        self.agent = None

        # Initialize LLM evaluator if available
        self.llm_evaluator = None
        if LLMEvaluator:
            try:
                self.llm_evaluator = LLMEvaluator()
                logger.info("LLM Evaluator initialized for automatic evaluation")
            except Exception as e:
                logger.warning(f"Could not initialize LLM evaluator: {e}")
                logger.info("Automatic LLM evaluation will be skipped")
        else:
            logger.info("LLM Evaluator not available - automatic evaluation will be skipped")

        # Paths
        self.test_cases_dir = Path(self.config.evaluation.test_cases_dir)
        self.results_dir = Path(self.config.evaluation.results_dir)
        self.results_dir.mkdir(parents=True, exist_ok=True)

        logger.info(f"Initialized evaluator with test cases from: {self.test_cases_dir}")

    def load_test_cases(self, category: Optional[str] = None) -> List[str]:
        """
        Load test cases from YAML files

        Args:
            category: Optional category filter (layer1, layer2, layer3)

        Returns:
            List of test case IDs that were loaded
        """
        test_case_ids = []

        # Determine which categories to load
        if category:
            categories = [category]
        else:
            categories = ["layer1", "layer2", "layer3"]

        for cat in categories:
            category_dir = self.test_cases_dir / cat
            if not category_dir.exists():
                logger.warning(f"Category directory {category_dir} does not exist")
                continue

            # Load all YAML files in category
            for yaml_file in category_dir.glob("*.yaml"):
                try:
                    test_case = self._load_single_test_case(yaml_file)
                    if test_case:
                        test_case_ids.append(test_case.test_id)
                        self.test_cases[test_case.test_id] = test_case
                except Exception as e:
                    logger.error(f"Error loading {yaml_file}: {e}")

        logger.info(f"Loaded {len(test_case_ids)} test cases")
        return test_case_ids

    def _load_single_test_case(self, yaml_file: Path) -> Optional[TestCase]:
        """Load a single test case from YAML file"""
        with open(yaml_file, 'r', encoding='utf-8') as f:
            data = yaml.safe_load(f)

        if not data:
            return None

        # Parse conversation histories
        conversation_histories = []
        for conv_data in data.get('conversation_histories', []):
            # Ensure messages are in the right format
            messages = []
            msg_list = conv_data.get('messages', [])
            for msg in msg_list:
                if isinstance(msg, dict) and 'role' in msg and 'content' in msg:
                    messages.append(msg)

            conversation = {
                "conversation_id": conv_data.get('conversation_id', ''),
                "timestamp": conv_data.get('timestamp', ''),
                "metadata": conv_data.get('metadata', {}),
                "messages": messages
            }
            conversation_histories.append(conversation)

        # Load evaluation criteria and expected behavior directly from YAML
        evaluation_criteria = data.get('evaluation_criteria', '')
        expected_behavior = data.get('expected_behavior', None)

        # Create test case with matching structure
        test_case = TestCase(
            test_id=data.get('test_id', ''),
            category=data.get('category', ''),
            title=data.get('title', ''),
            description=data.get('description', ''),
            conversation_histories=conversation_histories,
            user_question=data.get('user_question', ''),
            evaluation_criteria=evaluation_criteria,
            expected_behavior=expected_behavior,
            metadata=data.get('metadata', {})
        )

        return test_case

    def prepare_test_case(self, test_id: str) -> Tuple[int, float]:
        """
        Prepare a test case by chunking and indexing its conversations

        Args:
            test_id: The test case ID to prepare

        Returns:
            Tuple of (number of chunks, indexing time)
        """
        if test_id not in self.test_cases:
            raise ValueError(f"Test case {test_id} not found")

        test_case = self.test_cases[test_id]
        start_time = datetime.now()

        logger.info(f"Preparing test case: {test_id}")

        # Create new indexer for this test case
        self.indexer = MemoryIndexer(self.config.index)

        # Chunk all conversations
        all_chunks = []
        for conv_history in test_case.conversation_histories:
            chunks = self.chunker.chunk_conversation(
                conversation_id=conv_history['conversation_id'],
                test_id=test_id,
                messages=conv_history['messages'],
                metadata=conv_history.get('metadata', {})
            )
            all_chunks.extend(chunks)

        logger.info(f"Created {len(all_chunks)} chunks for test case {test_id}")

        # Index chunks
        self.indexer.add_chunks(all_chunks)

        # Save index if caching is enabled
        if self.config.evaluation.enable_caching:
            cache_path = self.results_dir / f"index_{test_id}"
            self.indexer.save_index(str(cache_path))
            logger.info(f"Cached index for {test_id}")

        # Create agent with the indexed data
        self.agent = UserMemoryRAGAgent(self.indexer, self.config)

        end_time = datetime.now()
        indexing_time = (end_time - start_time).total_seconds()

        return len(all_chunks), indexing_time

    def evaluate_test_case(self, test_id: str) -> EvaluationResult:
        """
        Evaluate a single test case

        Args:
            test_id: The test case ID to evaluate

        Returns:
            Evaluation result
        """
        if test_id not in self.test_cases:
            raise ValueError(f"Test case {test_id} not found")

        test_case = self.test_cases[test_id]

        logger.info(f"\n{'='*60}")
        logger.info(f"Evaluating: {test_case.title}")
        logger.info(f"Category: {test_case.category}")
        logger.info(f"Question: {test_case.user_question}")
        logger.info(f"{'='*60}")

        # Check if we can load cached index
        chunk_count = 0
        indexing_time = 0.0

        if self.config.evaluation.enable_caching:
            cache_path = self.results_dir / f"index_{test_id}"
            if Path(f"{cache_path}_chunks.json").exists():
                logger.info(f"Loading cached index for {test_id}")
                self.indexer = MemoryIndexer(self.config.index)
                self.indexer.load_index(str(cache_path))
                self.agent = UserMemoryRAGAgent(self.indexer, self.config)
                chunk_count = len(self.indexer.chunks)
            else:
                chunk_count, indexing_time = self.prepare_test_case(test_id)
        else:
            chunk_count, indexing_time = self.prepare_test_case(test_id)

        # Get agent's answer
        start_time = datetime.now()
        result = self.agent.answer_question(
            question=test_case.user_question,
            test_id=test_id,
            stream=False
        )
        end_time = datetime.now()
        processing_time = (end_time - start_time).total_seconds()

        agent_answer = result.get("answer", "")

        # Perform LLM evaluation if available
        llm_evaluation = None
        if self.llm_evaluator and agent_answer:
            logger.info("\n" + "="*60)
            logger.info("Running LLM Evaluation...")
            logger.info("-"*60)

            try:
                # Convert test case to format expected by LLM evaluator
                # Using the imported classes from user-memory-evaluation models

                # Build conversation histories for evaluator
                eval_histories = []
                for conv_hist in test_case.conversation_histories:
                    eval_messages = []
                    for msg in conv_hist.get('messages', []):
                        eval_messages.append(EvalMessage(
                            role=MessageRole(msg.get('role', 'user')),
                            content=msg.get('content', '')
                        ))

                    # Extract timestamp from metadata or use a default
                    timestamp = conv_hist.get('timestamp', '')
                    if not timestamp and 'metadata' in conv_hist:
                        # Try to extract from metadata
                        metadata = conv_hist.get('metadata', {})
                        timestamp = metadata.get('timestamp', metadata.get('date', '2024-01-01'))
                    if not timestamp:
                        timestamp = '2024-01-01'  # Default timestamp

                    eval_histories.append(ConversationHistory(
                        conversation_id=conv_hist.get('conversation_id', ''),
                        timestamp=timestamp,
                        messages=eval_messages,
                        metadata=conv_hist.get('metadata', {})
                    ))

                # Use the test case's evaluation_criteria directly
                eval_test_case = EvalTestCase(
                    test_id=test_case.test_id,
                    category=test_case.category,
                    title=test_case.title,
                    description=test_case.description,
                    conversation_histories=eval_histories,
                    user_question=test_case.user_question,
                    evaluation_criteria=test_case.evaluation_criteria if test_case.evaluation_criteria else "The agent should provide a relevant and accurate response based on the conversation history.",
                    expected_behavior=test_case.expected_behavior  # Pass through expected_behavior
                )

                # Run LLM evaluation
                llm_result = self.llm_evaluator.evaluate(
                    test_case=eval_test_case,
                    agent_response=agent_answer,
                    extracted_memory=None
                )

                llm_evaluation = {
                    "reward": llm_result.reward,
                    "passed": llm_result.passed if llm_result.passed is not None else llm_result.reward >= 0.6,
                    "reasoning": llm_result.reasoning,
                    "required_info_found": llm_result.required_info_found if hasattr(llm_result, 'required_info_found') else {},
                    "suggestions": llm_result.suggestions if hasattr(llm_result, 'suggestions') else None
                }

                # Log evaluation results with full reasoning
                logger.info("-"*60)
                logger.info(f"LLM Evaluation Reward: {llm_result.reward:.3f}/1.000")
                logger.info(f"Passed: {'Yes' if llm_evaluation['passed'] else 'No'}")
                logger.info("-"*60)
                logger.info(f"Evaluation Reasoning:")
                logger.info(llm_result.reasoning)
                logger.info("-"*60)

                if llm_result.required_info_found:
                    logger.info("Required Information Found:")
                    for info, found in llm_result.required_info_found.items():
                        check = "✓" if found else "✗"
                        logger.info(f"  {check} {info}")

                if llm_result.suggestions:
                    logger.info(f"Suggestions: {llm_result.suggestions}")

                logger.info("="*60)

            except Exception as e:
                logger.error(f"Error during LLM evaluation: {e}")
                llm_evaluation = {"error": str(e)}

        # Create evaluation result
        eval_result = EvaluationResult(
            test_id=test_id,
            success=llm_evaluation.get('passed', result.get("success", False)) if llm_evaluation else result.get("success", False),
            agent_answer=agent_answer,
            evaluation_criteria=test_case.evaluation_criteria,  # Use evaluation_criteria
            iterations=result.get("iterations", 0),
            tool_calls=result.get("tool_calls", 0),
            trajectory=result.get("trajectory"),
            processing_time=processing_time,
            indexing_time=indexing_time,
            chunk_count=chunk_count
        )

        # Add LLM evaluation details to the result if available
        if llm_evaluation:
            eval_result.llm_evaluation = llm_evaluation

        # Save result
        self.results[test_id] = eval_result

        # Save trajectory if enabled
        if self.config.evaluation.save_trajectories and eval_result.trajectory:
            trajectory_file = self.results_dir / f"trajectory_{test_id}.json"
            with open(trajectory_file, 'w', encoding='utf-8') as f:
                json.dump(eval_result.trajectory, f, ensure_ascii=False, indent=2)

        # Log summary
        logger.info(f"\n{'='*60}")
        logger.info(f"Evaluation Complete for {test_id}")
        if llm_evaluation and 'reward' in llm_evaluation:
            logger.info(f"LLM Evaluation Passed: {'✓' if eval_result.success else '✗'}")
            logger.info(f"LLM Reward Score: {llm_evaluation['reward']:.3f}/1.000")
        else:
            logger.info(f"Success: {eval_result.success}")
        logger.info(f"Iterations: {eval_result.iterations}")
        logger.info(f"Tool Calls: {eval_result.tool_calls}")
        logger.info(f"Chunks: {eval_result.chunk_count}")
        logger.info(f"Processing Time: {eval_result.processing_time:.2f}s")
        logger.info(f"Indexing Time: {eval_result.indexing_time:.2f}s")
        logger.info(f"{'='*60}")

        return eval_result

    def evaluate_batch(self, 
                      test_ids: Optional[List[str]] = None,
                      category: Optional[str] = None) -> Dict[str, EvaluationResult]:
        """
        Evaluate multiple test cases

        Args:
            test_ids: List of test IDs to evaluate (evaluates all if None)
            category: Category filter if test_ids not provided

        Returns:
            Dictionary of results
        """
        # Determine which test cases to evaluate
        if test_ids:
            ids_to_evaluate = test_ids
        else:
            # Load test cases if needed
            if not self.test_cases:
                self.load_test_cases(category)

            if category:
                ids_to_evaluate = [
                    tid for tid, tc in self.test_cases.items() 
                    if tc.category == category
                ]
            else:
                ids_to_evaluate = list(self.test_cases.keys())

        logger.info(f"Evaluating {len(ids_to_evaluate)} test cases")

        # Evaluate each test case
        for i, test_id in enumerate(ids_to_evaluate, 1):
            logger.info(f"\n[{i}/{len(ids_to_evaluate)}] Evaluating {test_id}")
            try:
                self.evaluate_test_case(test_id)
            except Exception as e:
                logger.error(f"Error evaluating {test_id}: {e}")
                self.results[test_id] = EvaluationResult(
                    test_id=test_id,
                    success=False,
                    agent_answer=f"Error: {str(e)}",
                    evaluation_criteria="",
                    iterations=0,
                    tool_calls=0
                )

        return self.results

    def generate_report(self, output_file: Optional[str] = None) -> str:
        """
        Generate evaluation report

        Args:
            output_file: Optional file path to save report

        Returns:
            Report as string
        """
        if not self.results:
            return "No evaluation results available"

        lines = []
        lines.append("="*80)
        lines.append("USER MEMORY RAG EVALUATION REPORT")
        lines.append(f"Generated: {datetime.now().isoformat()}")
        lines.append("="*80)
        lines.append("")

        # Summary statistics
        total = len(self.results)
        successful = sum(1 for r in self.results.values() if r.success)

        # Calculate LLM evaluation metrics if available
        llm_evaluated = sum(1 for r in self.results.values() if r.llm_evaluation and 'reward' in r.llm_evaluation)
        avg_reward = 0.0
        if llm_evaluated > 0:
            avg_reward = sum(r.llm_evaluation['reward'] for r in self.results.values() 
                           if r.llm_evaluation and 'reward' in r.llm_evaluation) / llm_evaluated

        lines.append("SUMMARY")
        lines.append("-"*40)
        lines.append(f"Total Test Cases: {total}")
        lines.append(f"Successful: {successful}/{total} ({100*successful/total:.1f}%)")
        if llm_evaluated > 0:
            lines.append(f"LLM Evaluated: {llm_evaluated}/{total}")
            lines.append(f"Average LLM Reward: {avg_reward:.3f}/1.000")
        lines.append("")

        # Average metrics
        avg_iterations = sum(r.iterations for r in self.results.values()) / total
        avg_tool_calls = sum(r.tool_calls for r in self.results.values()) / total
        avg_chunks = sum(r.chunk_count for r in self.results.values()) / total
        avg_proc_time = sum(r.processing_time for r in self.results.values()) / total
        avg_idx_time = sum(r.indexing_time for r in self.results.values()) / total

        lines.append("AVERAGE METRICS")
        lines.append("-"*40)
        lines.append(f"Iterations per test: {avg_iterations:.2f}")
        lines.append(f"Tool calls per test: {avg_tool_calls:.2f}")
        lines.append(f"Chunks per test: {avg_chunks:.1f}")
        lines.append(f"Processing time: {avg_proc_time:.2f}s")
        lines.append(f"Indexing time: {avg_idx_time:.2f}s")
        lines.append("")

        # Results by category
        categories = {}
        for test_id, result in self.results.items():
            if test_id in self.test_cases:
                cat = self.test_cases[test_id].category
                if cat not in categories:
                    categories[cat] = {"total": 0, "successful": 0}
                categories[cat]["total"] += 1
                if result.success:
                    categories[cat]["successful"] += 1

        lines.append("RESULTS BY CATEGORY")
        lines.append("-"*40)
        for cat, stats in sorted(categories.items()):
            pct = 100 * stats["successful"] / stats["total"]
            lines.append(f"{cat}: {stats['successful']}/{stats['total']} ({pct:.1f}%)")
        lines.append("")

        # Individual test results
        lines.append("INDIVIDUAL TEST RESULTS")
        lines.append("-"*40)
        for test_id, result in sorted(self.results.items()):
            status = "✓" if result.success else "✗"
            test_title = self.test_cases.get(test_id, {}).title if test_id in self.test_cases else test_id
            lines.append(f"{status} {test_id}: {test_title}")
            lines.append(f"  Iterations: {result.iterations}, Tool calls: {result.tool_calls}")
            lines.append(f"  Processing: {result.processing_time:.2f}s, Chunks: {result.chunk_count}")
            if result.llm_evaluation and 'reward' in result.llm_evaluation:
                lines.append(f"  LLM Reward: {result.llm_evaluation['reward']:.3f}/1.000")
            lines.append("")

        report = "\n".join(lines)

        # Save report if output file provided
        if output_file:
            with open(output_file, 'w', encoding='utf-8') as f:
                f.write(report)
            logger.info(f"Report saved to {output_file}")

        return report

    def save_results(self, output_file: Optional[str] = None):
        """
        Save evaluation results to JSON

        Args:
            output_file: Output file path (defaults to timestamped file)
        """
        if not output_file:
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            output_file = self.results_dir / f"results_{timestamp}.json"

        results_data = {}
        for test_id, result in self.results.items():
            results_data[test_id] = {
                "success": result.success,
                "agent_answer": result.agent_answer,
                "evaluation_criteria": result.evaluation_criteria,
                "iterations": result.iterations,
                "tool_calls": result.tool_calls,
                "processing_time": result.processing_time,
                "indexing_time": result.indexing_time,
                "chunk_count": result.chunk_count
            }

        with open(output_file, 'w', encoding='utf-8') as f:
            json.dump(results_data, f, ensure_ascii=False, indent=2)

        logger.info(f"Results saved to {output_file}")

indexer.py

"""RAG Indexer for User Memory Conversations

This module handles indexing of conversation chunks using the retrieval pipeline service.
Interfaces with the existing retrieval pipeline on port 4242.
"""

import os
import json
import logging
import requests
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass
from pathlib import Path

from config import IndexConfig, IndexMode
from chunker import ConversationChunk


logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@dataclass
class SearchResult:
    """Result from searching the index"""
    chunk_id: str
    score: float
    chunk: ConversationChunk
    match_type: str  # "dense", "sparse", or "hybrid"

    def to_dict(self) -> Dict[str, Any]:
        return {
            "chunk_id": self.chunk_id,
            "score": self.score,
            "match_type": self.match_type,
            "conversation_id": self.chunk.conversation_id,
            "test_id": self.chunk.test_id,
            "rounds": f"{self.chunk.start_round}-{self.chunk.end_round}",
            "text": self.chunk.to_text()
        }


class MemoryIndexer:
    """Indexes and searches conversation chunks using the retrieval pipeline service"""

    def __init__(self, config: Optional[IndexConfig] = None):
        """
        Initialize the indexer

        Args:
            config: Index configuration
        """
        self.config = config or IndexConfig()
        self.chunks: Dict[str, ConversationChunk] = {}
        self.chunk_texts: Dict[str, str] = {}  # Map chunk_id to prepared text
        self.doc_id_mapping: Dict[str, str] = {}  # Map generated doc_id to our chunk_id

        # Retrieval pipeline URL
        self.retrieval_url = "http://localhost:4242"

        # Create directories
        Path(self.config.index_path).parent.mkdir(parents=True, exist_ok=True)
        Path(self.config.chunk_store_path).parent.mkdir(parents=True, exist_ok=True)

        # Check retrieval pipeline availability
        self._check_retrieval_pipeline()

        logger.info(f"Initialized indexer with mode: {self.config.mode}")

    def _check_retrieval_pipeline(self):
        """Check if the retrieval pipeline service is available"""
        try:
            response = requests.get(f"{self.retrieval_url}/health", timeout=2)
            if response.status_code == 200:
                logger.info("✓ Retrieval pipeline service is available")
            else:
                logger.warning(f"Retrieval pipeline returned status {response.status_code}")
        except requests.exceptions.RequestException as e:
            logger.warning(f"Retrieval pipeline service not available at {self.retrieval_url}: {e}")
            logger.info("Note: The retrieval pipeline needs to be running. Start it with:")
            logger.info("  cd projects/week3/retrieval-pipeline && python api_server.py")

    def add_chunks(self, chunks: List[ConversationChunk], rebuild: bool = True):
        """
        Add conversation chunks to the index

        Args:
            chunks: List of conversation chunks to index
            rebuild: Whether to rebuild indexes after adding (for retrieval pipeline)
        """
        documents = []

        for chunk in chunks:
            chunk_id = chunk.chunk_id

            # Store chunk locally
            self.chunks[chunk_id] = chunk

            # Prepare text for indexing
            chunk_text = self._prepare_chunk_text(chunk)
            self.chunk_texts[chunk_id] = chunk_text

            # Prepare document for retrieval pipeline
            doc = {
                "text": chunk_text,
                "metadata": {
                    "doc_id": chunk_id,
                    "test_id": chunk.test_id,
                    "conversation_id": chunk.conversation_id,
                    "chunk_index": chunk.chunk_index,
                    "start_round": chunk.start_round,
                    "end_round": chunk.end_round,
                    **chunk.metadata
                }
            }
            documents.append(doc)
            logger.debug(f"Added chunk {chunk_id} to index")

        if rebuild and documents:
            self._index_documents(documents)

        logger.info(f"Added {len(chunks)} chunks to index. Total chunks: {len(self.chunks)}")

    def _prepare_chunk_text(self, chunk: ConversationChunk) -> str:
        """
        Prepare chunk text for indexing with contextual enrichment

        Args:
            chunk: Conversation chunk

        Returns:
            Enriched text for indexing
        """
        if not self.config.enable_contextual:
            return chunk.to_text()

        # Build enriched text with contextual information
        lines = []

        # Add test case context
        lines.append(f"Test Case: {chunk.test_id}")
        lines.append(f"Conversation: {chunk.conversation_id}")

        # Add metadata as searchable text
        if chunk.metadata:
            for key, value in chunk.metadata.items():
                lines.append(f"{key}: {value}")

        # Add the main chunk content
        lines.append(chunk.to_text())

        # Add semantic tags for better retrieval
        lines.append(self._generate_semantic_tags(chunk))

        return "\n".join(lines)

    def _generate_semantic_tags(self, chunk: ConversationChunk) -> str:
        """
        Generate semantic tags for better retrieval

        Args:
            chunk: Conversation chunk

        Returns:
            Semantic tags as string
        """
        tags = []

        # Analyze content for common topics
        content = chunk.to_text().lower()

        # Financial topics
        if any(word in content for word in ["account", "bank", "credit", "loan", "payment"]):
            tags.append("financial")

        # Insurance topics
        if any(word in content for word in ["insurance", "claim", "policy", "coverage"]):
            tags.append("insurance")

        # Medical topics
        if any(word in content for word in ["medical", "doctor", "appointment", "prescription"]):
            tags.append("medical")

        # Travel topics
        if any(word in content for word in ["flight", "hotel", "travel", "booking", "reservation"]):
            tags.append("travel")

        # Add position tags
        if chunk.chunk_index == 0:
            tags.append("conversation_start")

        # Add round count tags
        round_count = chunk.end_round - chunk.start_round + 1
        if round_count < 10:
            tags.append("short_segment")
        elif round_count > 30:
            tags.append("long_segment")

        return f"Tags: {', '.join(tags)}" if tags else ""

    def _index_documents(self, documents: List[Dict[str, Any]]):
        """Send documents to the retrieval pipeline for indexing"""
        try:
            # First, clear existing index
            clear_response = requests.post(f"{self.retrieval_url}/clear")
            if clear_response.status_code == 200:
                logger.info("Cleared existing index")

            # Index documents one by one (retrieval pipeline expects individual documents)
            indexed_count = 0
            failed_count = 0

            for doc in documents:
                try:
                    response = requests.post(
                        f"{self.retrieval_url}/index",
                        json=doc  # Send individual document
                    )

                    if response.status_code == 200:
                        result = response.json()
                        generated_doc_id = result.get("doc_id")
                        our_chunk_id = doc.get("metadata", {}).get("doc_id")

                        # Store the mapping between generated doc_id and our chunk_id
                        if generated_doc_id and our_chunk_id:
                            self.doc_id_mapping[generated_doc_id] = our_chunk_id

                        indexed_count += 1
                    else:
                        failed_count += 1
                        logger.warning(f"Failed to index document: {doc.get('metadata', {}).get('doc_id', 'unknown')}")

                except requests.exceptions.RequestException as e:
                    failed_count += 1
                    logger.warning(f"Error indexing document: {e}")

            logger.info(f"Indexed {indexed_count} documents successfully ({failed_count} failed)")

        except requests.exceptions.RequestException as e:
            logger.error(f"Error connecting to retrieval pipeline: {e}")
            logger.info("Make sure the retrieval pipeline is running on port 4242")

    def build_indexes(self):
        """Build or rebuild indexes by sending all chunks to retrieval pipeline"""
        if not self.chunks:
            logger.warning("No chunks to index")
            return

        # Prepare all documents
        documents = []
        for chunk_id, chunk in self.chunks.items():
            chunk_text = self.chunk_texts.get(chunk_id) or self._prepare_chunk_text(chunk)
            doc = {
                "text": chunk_text,
                "metadata": {
                    "doc_id": chunk_id,
                    "test_id": chunk.test_id,
                    "conversation_id": chunk.conversation_id,
                    "chunk_index": chunk.chunk_index,
                    "start_round": chunk.start_round,
                    "end_round": chunk.end_round,
                    **chunk.metadata
                }
            }
            documents.append(doc)

        # Send to retrieval pipeline
        self._index_documents(documents)
        logger.info("Index building complete")

    def search(self,
              query: str,
              top_k: int = 3,
              mode: Optional[IndexMode] = None) -> List[SearchResult]:
        """
        Search the index for relevant chunks using retrieval pipeline

        Args:
            query: Search query
            top_k: Number of results to return
            mode: Search mode (uses config default if not specified)

        Returns:
            List of search results
        """
        mode = mode or self.config.mode

        # Map IndexMode to retrieval pipeline mode strings
        mode_map = {
            IndexMode.DENSE: "dense",
            IndexMode.SPARSE: "sparse", 
            IndexMode.HYBRID: "hybrid"
        }

        search_mode = mode_map.get(mode, "hybrid")

        if not top_k or top_k < 1:
            top_k = 3

        try:
            # Query the retrieval pipeline
            # Note: The pipeline has two top_k parameters:
            # - top_k: for initial retrieval (we set to max(20, top_k))
            # - rerank_top_k: for final results (we set to the requested top_k)
            response = requests.post(
                f"{self.retrieval_url}/search",
                json={
                    "query": query,
                    "mode": search_mode,
                    "top_k": max(20, top_k),  # Retrieve more candidates for better reranking
                    "rerank_top_k": top_k,    # Return the requested number of results
                    "skip_reranking": False    # Always use reranking for better quality
                }
            )
            response.raise_for_status()

            data = response.json()

            # Get results based on mode
            if search_mode == "hybrid" and "reranked_results" in data:
                search_results = data["reranked_results"]
            elif search_mode == "dense" and "dense_results" in data:
                search_results = data["dense_results"]
            elif search_mode == "sparse" and "sparse_results" in data:
                search_results = data["sparse_results"]
            else:
                # Fallback to any available results
                search_results = (data.get("reranked_results", []) or 
                                data.get("dense_results", []) or 
                                data.get("sparse_results", []))

            # Convert to SearchResult objects
            results = []
            for item in search_results:
                # Try to get our chunk_id from different sources
                chunk_id = None

                # First, check if metadata contains our doc_id
                metadata = item.get("metadata", {})
                if metadata.get("doc_id"):
                    chunk_id = metadata.get("doc_id")
                else:
                    # Fall back to doc_id mapping
                    generated_doc_id = item.get("doc_id", "")
                    chunk_id = self.doc_id_mapping.get(generated_doc_id)

                # Get chunk from local storage
                if chunk_id and chunk_id in self.chunks:
                    chunk = self.chunks[chunk_id]

                    # Get score based on result type
                    score = item.get("rerank_score", item.get("score", 0.0))

                    results.append(SearchResult(
                        chunk_id=chunk_id,
                        score=float(score),
                        chunk=chunk,
                        match_type=search_mode
                    ))
                else:
                    # Log warning but don't fail
                    doc_id = item.get("doc_id", "unknown")
                    if chunk_id:
                        logger.debug(f"Chunk {chunk_id} not found in local storage")
                    else:
                        logger.debug(f"No mapping found for doc_id {doc_id}")

            logger.info(f"Search returned {len(results)} results from retrieval pipeline")
            return results

        except requests.exceptions.RequestException as e:
            logger.error(f"Error searching via retrieval pipeline: {e}")
            logger.info("Falling back to empty results. Ensure retrieval pipeline is running.")
            return []

    def save_index(self, path: Optional[str] = None):
        """
        Save the chunks and metadata to disk

        Args:
            path: Path to save index (uses config default if not specified)
        """
        path = path or self.config.index_path

        # Save chunks
        chunks_data = {
            chunk_id: chunk.to_dict() 
            for chunk_id, chunk in self.chunks.items()
        }

        with open(f"{path}_chunks.json", 'w', encoding='utf-8') as f:
            json.dump(chunks_data, f, ensure_ascii=False, indent=2)

        # Save chunk texts
        with open(f"{path}_texts.json", 'w', encoding='utf-8') as f:
            json.dump(self.chunk_texts, f, ensure_ascii=False, indent=2)

        logger.info(f"Chunks saved to {path}. Total chunks: {len(self.chunks)}")

    def load_index(self, path: Optional[str] = None):
        """
        Load chunks from disk and re-index in retrieval pipeline

        Args:
            path: Path to load index from (uses config default if not specified)
        """
        path = path or self.config.index_path

        try:
            # Load chunks
            with open(f"{path}_chunks.json", 'r', encoding='utf-8') as f:
                chunks_data = json.load(f)

            self.chunks = {}
            for chunk_id, chunk_dict in chunks_data.items():
                # Convert messages
                from chunker import ConversationMessage
                messages = []
                for msg_data in chunk_dict.get('messages', []):
                    messages.append(ConversationMessage(**msg_data))

                # Create chunk
                chunk = ConversationChunk(
                    chunk_id=chunk_dict['chunk_id'],
                    conversation_id=chunk_dict['conversation_id'],
                    test_id=chunk_dict['test_id'],
                    chunk_index=chunk_dict['chunk_index'],
                    start_round=chunk_dict['start_round'],
                    end_round=chunk_dict['end_round'],
                    messages=messages,
                    metadata=chunk_dict.get('metadata', {}),
                    context_before=chunk_dict.get('context_before'),
                    context_after=chunk_dict.get('context_after'),
                    created_at=chunk_dict.get('created_at', '')
                )
                self.chunks[chunk_id] = chunk

            # Load chunk texts if available
            texts_path = f"{path}_texts.json"
            if Path(texts_path).exists():
                with open(texts_path, 'r', encoding='utf-8') as f:
                    self.chunk_texts = json.load(f)
            else:
                # Regenerate texts if not saved
                self.chunk_texts = {}
                for chunk_id, chunk in self.chunks.items():
                    self.chunk_texts[chunk_id] = self._prepare_chunk_text(chunk)

            logger.info(f"Loaded {len(self.chunks)} chunks from {path}")

            # Re-index in retrieval pipeline
            self.build_indexes()

        except Exception as e:
            logger.error(f"Error loading index: {e}")
            raise

main.py

#!/usr/bin/env python3
"""Main entry point for Contextual Retrieval + Advanced Memory Cards System

This demonstrates the dual-layer memory system combining:
1. Contextual chunking for conversation history
2. Advanced JSON cards for structured facts
"""

import argparse
import json
import logging
import sys
from pathlib import Path
from typing import Optional, List
from datetime import datetime
from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.table import Table
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn

from config import Config
from contextual_evaluator import ContextualMemoryEvaluator
from contextual_indexer import ContextualMemoryIndexer
from contextual_agent import ContextualUserMemoryAgent
from advanced_memory_manager import AdvancedMemoryCard, create_sample_cards
from chunker import ConversationChunker

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

# Rich console for better output
console = Console()


class InteractiveContextualRAG:
    """Interactive interface for the contextual RAG system"""

    def __init__(self, config: Optional[Config] = None):
        """Initialize the interactive system"""
        self.config = config or Config.from_env()
        self.evaluator = ContextualMemoryEvaluator(self.config)
        self.current_user = "demo_user"
        self.indexer = None
        self.agent = None

    def run(self):
        """Run the interactive session"""
        console.print(Panel.fit(
            "[bold cyan]Contextual RAG + Advanced Memory Cards System[/bold cyan]\n"
            "双层记忆系统:上下文感知检索 + 结构化记忆卡片\n"
            "[dim]LLM Judge enabled for automatic evaluation[/dim]",
            border_style="cyan"
        ))

        while True:
            self.show_menu()
            choice = Prompt.ask(
                "Select an option",
                choices=["1", "2", "3", "4", "5", "6", "7", "8", "0"],
                default="1"
            )

            if choice == "1":
                self.demo_mode()
            elif choice == "2":
                self.load_and_index_conversations()
            elif choice == "3":
                self.manage_memory_cards()
            elif choice == "4":
                self.test_query()
            elif choice == "5":
                self.evaluate_test_cases()
            elif choice == "6":
                self.evaluate_specific_test_case()
            elif choice == "7":
                self.show_statistics()
            elif choice == "8":
                self.configure_settings()
            elif choice == "0":
                if Confirm.ask("Are you sure you want to exit?"):
                    console.print("[yellow]Goodbye![/yellow]")
                    break

    def show_menu(self):
        """Display the main menu"""
        console.print("\n[bold]Main Menu:[/bold]")
        console.print("1. 🚀 Demo Mode (Quick Start)")
        console.print("2. 📚 Load & Index Conversations")
        console.print("3. 🎴 Manage Memory Cards")
        console.print("4. 🔍 Test Query")
        console.print("5. 📊 Evaluate All Test Cases (by Category) [LLM Judge]")
        console.print("6. 🎯 Evaluate Specific Test Case [LLM Judge]")
        console.print("7. 📈 Show Statistics")
        console.print("8. ⚙️  Configure Settings")
        console.print("0. Exit")

    def demo_mode(self):
        """Run a quick demo with sample data"""
        console.print("\n[cyan]Demo Mode - Quick Start[/cyan]")

        # Initialize components
        user_id = "demo_user"
        self.indexer = ContextualMemoryIndexer(
            user_id=user_id,
            use_contextual=True
        )

        # Create sample memory cards
        console.print("\n[yellow]Creating sample memory cards...[/yellow]")
        sample_cards = create_sample_cards()
        for card in sample_cards:
            self.indexer.memory_manager.add_card(card)
        console.print(f"[green]✓ Added {len(sample_cards)} memory cards[/green]")

        # Create sample conversation chunks
        console.print("\n[yellow]Creating sample conversation chunks...[/yellow]")
        sample_chunks = self._create_sample_chunks()

        # Process with contextual chunking
        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            console=console
        ) as progress:
            task = progress.add_task("Processing conversations...", total=None)

            result = self.indexer.process_conversation_history(
                chunks=sample_chunks,
                conversation_id="demo_conv",
                generate_summary_cards=False
            )

            progress.update(task, completed=True)

        console.print(f"[green]✓ Indexed {result['contextual_chunks']} contextual chunks[/green]")

        # Initialize agent
        self.agent = ContextualUserMemoryAgent(
            indexer=self.indexer,
            config=self.config
        )

        # Show memory status
        console.print("\n[bold]Memory System Status:[/bold]")
        console.print(f"  Memory Cards: {sum(len(cards) for cards in self.indexer.memory_manager.categories.values())}")
        console.print(f"  Contextual Chunks: {len(self.indexer.contextual_chunks)}")

        # Test queries
        test_queries = [
            "我的护照什么时候过期?",
            "我一月份的东京之行需要准备什么?",
            "我的医疗信息有哪些?"
        ]

        console.print("\n[bold]Test Queries:[/bold]")
        for i, query in enumerate(test_queries, 1):
            console.print(f"\n[cyan]Query {i}:[/cyan] {query}")

            if Confirm.ask("Run this query?", default=True):
                trajectory = self.agent.answer_question(
                    question=query,
                    test_id=f"demo_{i}",
                    stream=False
                )

                console.print(Panel(
                    trajectory.final_answer or "No answer generated",
                    title="Answer",
                    border_style="green"
                ))

                if trajectory.memory_cards_used:
                    console.print(f"  Memory cards used: {', '.join(trajectory.memory_cards_used)}")
                if trajectory.chunks_retrieved:
                    console.print(f"  Chunks retrieved: {len(trajectory.chunks_retrieved)}")

    def _create_sample_chunks(self):
        """Create sample conversation chunks for demo"""
        from chunker import ConversationChunk, ConversationMessage

        chunks = []

        # Sample conversation about travel
        messages = [
            ConversationMessage("user", "我想订一张去东京的机票", 1),
            ConversationMessage("assistant", "好的,请问您什么时候出发?", 2),
            ConversationMessage("user", "1月25日出发,2月1日返回", 3),
            ConversationMessage("assistant", "让我为您查询1月25日到2月1日的东京往返机票", 4),
        ]

        chunk = ConversationChunk(
            chunk_id="demo_chunk_001",
            conversation_id="demo_conv",
            test_id="demo",
            chunk_index=0,
            start_round=1,
            end_round=2,
            messages=messages,
            metadata={"topic": "travel"}
        )
        chunks.append(chunk)

        # Sample conversation about passport
        messages2 = [
            ConversationMessage("user", "我的护照快过期了,什么时候需要续签?", 5),
            ConversationMessage("assistant", "您的护照将于2025年2月18日过期,建议提前3-6个月办理续签", 6),
            ConversationMessage("user", "好的,我会尽快去办理", 7),
            ConversationMessage("assistant", "建议您在出国前确保护照有效期至少6个月", 8),
        ]

        chunk2 = ConversationChunk(
            chunk_id="demo_chunk_002",
            conversation_id="demo_conv",
            test_id="demo",
            chunk_index=1,
            start_round=3,
            end_round=4,
            messages=messages2,
            metadata={"topic": "passport"}
        )
        chunks.append(chunk2)

        return chunks

    def load_and_index_conversations(self):
        """Load and index conversation histories"""
        console.print("\n[cyan]Load & Index Conversations[/cyan]")

        # Get user ID
        user_id = Prompt.ask("Enter user ID", default=self.current_user)
        self.current_user = user_id

        # Initialize indexer
        self.indexer = ContextualMemoryIndexer(
            user_id=user_id,
            use_contextual=Confirm.ask("Enable contextual chunking?", default=True)
        )

        # Load conversation files
        conv_dir = Prompt.ask(
            "Enter conversation directory path",
            default="../../week2/user-memory-evaluation/conversations"
        )

        conv_path = Path(conv_dir)
        if not conv_path.exists():
            console.print(f"[red]Directory not found: {conv_path}[/red]")
            return

        # Process conversation files
        json_files = list(conv_path.glob("*.json"))
        console.print(f"Found {len(json_files)} conversation files")

        if not json_files:
            console.print("[yellow]No JSON files found[/yellow]")
            return

        # Process each file
        chunker = ConversationChunker(self.config.chunking)
        all_chunks = []

        with Progress(console=console) as progress:
            task = progress.add_task("Processing files...", total=len(json_files))

            for json_file in json_files:
                try:
                    with open(json_file, 'r', encoding='utf-8') as f:
                        data = json.load(f)

                    # Extract conversations
                    conversations = data if isinstance(data, dict) else {"conv": data}

                    for conv_id, messages in conversations.items():
                        chunks = chunker.chunk_conversation(
                            messages=messages,
                            conversation_id=conv_id,
                            test_id=json_file.stem
                        )
                        all_chunks.extend(chunks)

                    progress.advance(task)

                except Exception as e:
                    console.print(f"[red]Error processing {json_file}: {e}[/red]")

        console.print(f"[green]Created {len(all_chunks)} chunks[/green]")

        # Index with contextual processing
        if all_chunks:
            result = self.indexer.process_conversation_history(
                chunks=all_chunks,
                conversation_id="batch_index",
                generate_summary_cards=Confirm.ask("Generate summary cards?", default=True)
            )

            console.print(f"[green]✓ Indexed {result['contextual_chunks']} contextual chunks[/green]")
            console.print(f"[green]✓ Total memory cards: {result['memory_cards_after']}[/green]")

    def manage_memory_cards(self):
        """Manage advanced memory cards"""
        if not self.indexer:
            console.print("[yellow]Please initialize the system first (option 1 or 2)[/yellow]")
            return

        console.print("\n[cyan]Memory Card Management[/cyan]")

        # Show current cards
        stats = self.indexer.memory_manager.get_statistics()
        console.print(f"\nCurrent cards: {stats['total_cards']}")

        for category, info in stats['categories'].items():
            console.print(f"  {category}: {info['count']} cards")

        # Options
        console.print("\n1. View all cards")
        console.print("2. Add new card")
        console.print("3. Search cards")
        console.print("4. Delete card")
        console.print("5. Back")

        choice = Prompt.ask("Select option", choices=["1", "2", "3", "4", "5"])

        if choice == "1":
            # View all cards
            context = self.indexer.memory_manager.get_context_string()
            console.print(Panel(context, title="Memory Cards", border_style="cyan"))

        elif choice == "2":
            # Add new card
            category = Prompt.ask("Category")
            card_key = Prompt.ask("Card key")
            backstory = Prompt.ask("Backstory")
            person = Prompt.ask("Person", default="User")
            relationship = Prompt.ask("Relationship", default="primary")

            # Get additional data fields
            data = {}
            while True:
                field = Prompt.ask("Add data field (empty to finish)")
                if not field:
                    break
                value = Prompt.ask(f"Value for {field}")
                data[field] = value

            # Create and add card
            card = AdvancedMemoryCard(
                category=category,
                card_key=card_key,
                backstory=backstory,
                date_created=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                person=person,
                relationship=relationship,
                data=data
            )

            memory_id = self.indexer.memory_manager.add_card(card)
            console.print(f"[green]✓ Added card: {memory_id}[/green]")

        elif choice == "3":
            # Search cards
            query = Prompt.ask("Search query")
            results = self.indexer.memory_manager.search_cards(query)

            if results:
                console.print(f"\n[green]Found {len(results)} cards:[/green]")
                for memory_id, card in results:
                    console.print(f"\n{memory_id}:")
                    console.print(f"  Backstory: {card.backstory}")
                    console.print(f"  Person: {card.person}")
            else:
                console.print("[yellow]No cards found[/yellow]")

        elif choice == "4":
            # Delete card
            category = Prompt.ask("Category")
            card_key = Prompt.ask("Card key")

            if Confirm.ask(f"Delete {category}.{card_key}?"):
                if self.indexer.memory_manager.delete_card(category, card_key):
                    console.print("[green]✓ Card deleted[/green]")
                else:
                    console.print("[red]Card not found[/red]")

    def test_query(self):
        """Test a query against the system"""
        if not self.indexer:
            console.print("[yellow]Please initialize the system first (option 1 or 2)[/yellow]")
            return

        if not self.agent:
            self.agent = ContextualUserMemoryAgent(
                indexer=self.indexer,
                config=self.config
            )

        console.print("\n[cyan]Test Query[/cyan]")

        # Show current memory status
        console.print(f"\nMemory Status:")
        console.print(f"  Cards: {sum(len(cards) for cards in self.indexer.memory_manager.categories.values())}")
        console.print(f"  Chunks: {len(self.indexer.contextual_chunks)}")

        # Get query
        query = Prompt.ask("\nEnter your question")

        # Process query
        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            console=console
        ) as progress:
            task = progress.add_task("Processing...", total=None)

            trajectory = self.agent.answer_question(
                question=query,
                test_id="interactive",
                stream=False
            )

            progress.update(task, completed=True)

        # Display results
        console.print(Panel(
            trajectory.final_answer or "No answer generated",
            title="Answer",
            border_style="green"
        ))

        # Show details
        console.print(f"\n[bold]Query Details:[/bold]")
        console.print(f"  Iterations: {len(trajectory.iterations)}")
        console.print(f"  Tool calls: {len(trajectory.tool_calls)}")

        if trajectory.memory_cards_used:
            console.print(f"\n[bold]Memory Cards Used:[/bold]")
            for card_id in trajectory.memory_cards_used:
                console.print(f"  • {card_id}")

        if trajectory.chunks_retrieved:
            console.print(f"\n[bold]Chunks Retrieved:[/bold] {len(trajectory.chunks_retrieved)}")

            if Confirm.ask("Show chunk details?"):
                for chunk_id in trajectory.chunks_retrieved[:3]:
                    if chunk_id in self.indexer.contextual_chunks:
                        chunk = self.indexer.contextual_chunks[chunk_id]
                        console.print(f"\n  Chunk: {chunk_id}")
                        console.print(f"  Context: {chunk.context[:200]}...")

    def evaluate_specific_test_case(self):
        """Evaluate a specific test case selected by the user"""
        console.print("\n[cyan]Evaluate Specific Test Case[/cyan]")

        # First, load all test cases to show to the user
        console.print("\nLoading available test cases...")

        # Load all categories
        all_test_cases = []
        categories = ["layer1", "layer2", "layer3"]

        for category in categories:
            test_cases = self.evaluator.load_test_cases(category)
            for test_id in test_cases:
                test_case = self.evaluator.test_cases[test_id]
                all_test_cases.append({
                    "id": test_id,
                    "category": category,
                    "title": test_case.title,
                    "conversations": len(test_case.conversation_histories)
                })

        if not all_test_cases:
            console.print("[yellow]No test cases found[/yellow]")
            return

        # Sort test cases by test ID (name)
        all_test_cases.sort(key=lambda x: x["id"])

        console.print(f"\n[green]Found {len(all_test_cases)} test cases[/green]")

        # Create a table to display test cases
        table = Table(title="Available Test Cases (Sorted by Name)", show_lines=True)
        table.add_column("#", style="dim", width=4)
        table.add_column("Test ID", style="cyan", width=25)
        table.add_column("Category", style="magenta", width=8)
        table.add_column("Title", style="green", width=50)
        table.add_column("Conv.", justify="right", width=5)

        for idx, test_info in enumerate(all_test_cases, 1):
            title = test_info["title"][:47] + "..." if len(test_info["title"]) > 50 else test_info["title"]
            table.add_row(
                str(idx),
                test_info["id"],
                test_info["category"],
                title,
                str(test_info["conversations"])
            )

        console.print(table)

        # Let user select a test case
        console.print("\n[bold]Select a test case to evaluate:[/bold]")
        console.print("Enter the number (#) or the Test ID directly")

        user_input = Prompt.ask("Your choice")

        # Find the selected test case
        selected_test_id = None

        # Check if user entered a number
        if user_input.isdigit():
            idx = int(user_input) - 1
            if 0 <= idx < len(all_test_cases):
                selected_test_id = all_test_cases[idx]["id"]
            else:
                console.print(f"[red]Invalid number: {user_input}[/red]")
                return
        else:
            # Check if user entered a test ID
            for test_info in all_test_cases:
                if test_info["id"] == user_input:
                    selected_test_id = user_input
                    break

            if not selected_test_id:
                console.print(f"[red]Test case not found: {user_input}[/red]")
                return

        # Get the test case details
        test_case = self.evaluator.test_cases[selected_test_id]

        # Show test case details
        console.print(Panel(
            f"[bold]{test_case.title}[/bold]\n\n"
            f"Category: {test_case.category}\n"
            f"Description: {test_case.description}\n\n"
            f"[yellow]User Question:[/yellow]\n{test_case.user_question}\n\n"
            f"[green]Evaluation Criteria:[/green]\n{test_case.evaluation_criteria[:200]}...\n\n"
            f"Conversations: {len(test_case.conversation_histories)}",
            title=selected_test_id,
            border_style="cyan"
        ))

        # Run evaluation
        console.print(f"\n[cyan]Evaluating {selected_test_id}...[/cyan]")
        console.print(f"[dim]Using LLM Judge for automatic evaluation[/dim]\n")

        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            console=console
        ) as progress:
            task = progress.add_task("Processing...", total=None)

            try:
                result = self.evaluator.evaluate_test_case(selected_test_id)
                progress.update(task, completed=True)

                # Display result
                status = "✓ Success" if result.success else "✗ Failed"
                console.print(f"\n[{'green' if result.success else 'red'}]{status}[/{'green' if result.success else 'red'}]")

                console.print("\n[bold]Agent Answer:[/bold]")
                console.print(Panel(result.agent_answer or "No answer generated", border_style="cyan"))

                console.print("\n[bold]Evaluation Criteria:[/bold]")
                console.print(Panel(result.evaluation_criteria, border_style="green"))

                # Display LLM evaluation if available
                if result.llm_evaluation:
                    console.print("\n[bold cyan]LLM Judge Evaluation:[/bold cyan]")
                    llm_eval = result.llm_evaluation
                    reward = llm_eval.get('reward', 0)
                    passed = llm_eval.get('passed', False)

                    # Format reward with color based on score
                    if reward >= 0.8:
                        reward_color = "green"
                    elif reward >= 0.6:
                        reward_color = "yellow"
                    else:
                        reward_color = "red"

                    console.print(f"  Reward Score: [{reward_color}]{reward:.3f}/1.000[/{reward_color}]")
                    console.print(f"  Passed: [{'green' if passed else 'red'}]{'Yes' if passed else 'No'}[/{'green' if passed else 'red'}]")

                    if 'reasoning' in llm_eval:
                        console.print(f"\n[bold]Reasoning:[/bold]")
                        console.print(Panel(llm_eval['reasoning'], border_style="cyan"))

                    if 'required_info_found' in llm_eval and llm_eval['required_info_found']:
                        console.print(f"\n[bold]Required Information Found:[/bold]")
                        for key, found in llm_eval['required_info_found'].items():
                            status = "✓" if found else "✗"
                            color = "green" if found else "red"
                            console.print(f"  [{color}]{status}[/{color}] {key}")

                console.print(f"\n[bold]Statistics:[/bold]")
                console.print(f"  Iterations: {result.iterations}")
                console.print(f"  Tool Calls: {result.tool_calls}")
                console.print(f"  Memory Cards Used: {len(result.memory_cards_used)}")
                console.print(f"  Chunks Retrieved: {len(result.chunks_retrieved)}")
                console.print(f"  Contextual Chunks: {result.contextual_chunks_count}")
                console.print(f"  Processing Time: {result.processing_time:.2f}s")
                console.print(f"  Context Generation Time: {result.context_generation_time:.2f}s")

                if result.error:
                    console.print(f"\n[red]Error: {result.error}[/red]")

            except Exception as e:
                progress.update(task, completed=True)
                console.print(f"[red]Error evaluating test case: {e}[/red]")

    def evaluate_test_cases(self):
        """Run evaluation on all test cases in a category"""
        console.print("\n[cyan]Evaluate All Test Cases (by Category)[/cyan]")

        # Load test cases
        category = Prompt.ask(
            "Select category",
            choices=["all", "layer1", "layer2", "layer3"],
            default="layer1"
        )

        test_cases = self.evaluator.load_test_cases(
            category=None if category == "all" else category
        )

        # Sort test cases by ID
        test_cases = sorted(test_cases)

        console.print(f"[green]Loaded {len(test_cases)} test cases (sorted by name)[/green]")
        console.print(f"[dim]Using LLM Judge for automatic evaluation[/dim]\n")

        # Run evaluation
        if Confirm.ask("Run evaluation?"):
            with Progress(console=console) as progress:
                task = progress.add_task("Evaluating...", total=len(test_cases))

                for test_id in test_cases:
                    try:
                        result = self.evaluator.evaluate_test_case(test_id)
                        progress.advance(task)
                    except Exception as e:
                        console.print(f"[red]Error evaluating {test_id}: {e}[/red]")
                        progress.advance(task)

            # Show results
            report = self.evaluator.generate_report()
            console.print("\n" + report)

            # Save results
            if Confirm.ask("Save results to file?"):
                timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
                output_file = f"results/evaluation_{timestamp}.json"
                self.evaluator.save_results(output_file)
                console.print(f"[green]✓ Results saved to {output_file}[/green]")

    def show_statistics(self):
        """Show system statistics"""
        console.print("\n[cyan]System Statistics[/cyan]")

        if self.indexer:
            stats = self.indexer.get_statistics()

            # Create statistics table
            table = Table(title="Contextual Memory Statistics")
            table.add_column("Metric", style="cyan")
            table.add_column("Value", justify="right")

            # Indexer stats
            table.add_row("Indexed Chunks", str(stats.get("chunks_indexed", 0)))
            table.add_row("Memory Cards", str(stats.get("memory_cards", 0)))
            table.add_row("Indexing Time", f"{stats.get('indexing_time', 0):.2f}s")

            # Chunker stats
            if "chunker_stats" in stats:
                cs = stats["chunker_stats"]
                table.add_row("Contextual Chunks", str(cs.get("contextual_chunks", 0)))
                table.add_row("Context Tokens", str(cs.get("total_context_tokens", 0)))
                table.add_row("Cache Hit Rate", f"{cs.get('cache_hit_rate', 0):.1%}")
                table.add_row("Est. Cost", f"${cs.get('estimated_cost', 0):.3f}")

            # Memory stats
            if "memory_stats" in stats:
                ms = stats["memory_stats"]
                table.add_row("Total Cards", str(ms.get("total_cards", 0)))
                for cat, info in ms.get("categories", {}).items():
                    table.add_row(f"  {cat}", str(info.get("count", 0)))

            console.print(table)
        else:
            console.print("[yellow]System not initialized[/yellow]")

    def configure_settings(self):
        """Configure system settings"""
        console.print("\n[cyan]Configuration Settings[/cyan]")

        # Show current settings
        console.print(f"\nCurrent Settings:")
        console.print(f"  LLM Provider: {self.config.llm.provider}")
        console.print(f"  LLM Model: {self.config.llm.model}")
        console.print(f"  Chunking: {self.config.chunking.rounds_per_chunk} rounds/chunk")
        console.print(f"  Index Mode: {self.config.index.mode}")

        if Confirm.ask("\nModify settings?"):
            # LLM settings
            if Confirm.ask("Change LLM provider?"):
                provider = Prompt.ask(
                    "Provider",
                    choices=["kimi", "doubao", "siliconflow", "openai"],
                    default=self.config.llm.provider
                )
                self.config.llm.provider = provider

            # Chunking settings
            if Confirm.ask("Change chunking settings?"):
                rounds = Prompt.ask(
                    "Rounds per chunk",
                    default=str(self.config.chunking.rounds_per_chunk)
                )
                self.config.chunking.rounds_per_chunk = int(rounds)

            console.print("[green]✓ Settings updated[/green]")


def main():
    """主入口:实验 3-12 上下文感知检索增强用户记忆"""
    parser = argparse.ArgumentParser(
        description=(
            "实验 3-12:利用上下文感知检索增强用户记忆。\n"
            "在把对话记忆块送入嵌入/索引前先生成『上下文前缀』,"
            "提升脱离上下文的孤立片段(如『好的,就订这个吧』)的召回。"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "示例:\n"
            "  python main.py --mode compare                 # 离线对比上下文化 vs 原始块(无需 API)\n"
            "  python main.py --mode compare --query '我的护照什么时候过期?'  # 单条查询离线检索对比\n"
            "  python main.py --mode compare --output results/compare.json    # 保存对比结果\n"
            "  python main.py --mode evaluate --category layer1               # 端到端评估(需 API/检索服务)\n"
            "  python main.py --mode interactive             # 交互式界面(默认,需 API)\n"
        ),
    )
    parser.add_argument(
        "--mode",
        choices=["interactive", "evaluate", "demo", "compare"],
        default="interactive",
        help="运行模式:interactive 交互式(默认) / evaluate 端到端评估 / demo 演示 / compare 离线对比(无需 API)",
    )
    parser.add_argument(
        "--category",
        choices=["layer1", "layer2", "layer3"],
        help="评估的测试分类(layer1 基础回忆 / layer2 多会话检索 / layer3 主动服务)",
    )
    parser.add_argument(
        "--config",
        type=str,
        help="配置文件(JSON)路径",
    )
    # 离线对比(compare 模式)相关参数
    parser.add_argument(
        "--dataset",
        type=str,
        default=None,
        help="compare 模式使用的记忆问答对照集 JSON(默认:memory_qa_eval.json)",
    )
    parser.add_argument(
        "--query",
        type=str,
        default=None,
        help="compare 模式下对单条查询做离线检索对比(plain vs contextual 的 Top-K)",
    )
    parser.add_argument(
        "--output",
        type=str,
        default=None,
        help="将 compare / evaluate 的结果保存为 JSON 的路径",
    )
    # 配置覆盖项(可选,覆盖环境变量/配置文件;不改变默认行为)
    parser.add_argument(
        "--user-id",
        type=str,
        default=None,
        help="用户标识(写入输出结果作为标签,便于区分多用户记忆)",
    )
    parser.add_argument(
        "--model",
        type=str,
        default=None,
        help="覆盖 LLM 模型名(默认取环境变量/提供商默认值)",
    )
    parser.add_argument(
        "--provider",
        type=str,
        default=None,
        help="覆盖 LLM 提供商(kimi / doubao / siliconflow / openai 等)",
    )
    parser.add_argument(
        "--store-path",
        type=str,
        default=None,
        help="记忆块存储(chunk_store)路径,覆盖默认 data/chunk_store.json",
    )
    contextual_group = parser.add_mutually_exclusive_group()
    contextual_group.add_argument(
        "--contextual",
        dest="contextual",
        action="store_true",
        default=None,
        help="启用上下文化(索引前为每块生成上下文前缀,默认开启)",
    )
    contextual_group.add_argument(
        "--no-contextual",
        dest="contextual",
        action="store_false",
        help="关闭上下文化(直接索引原始对话块,用于对照)",
    )

    args = parser.parse_args()

    # compare 模式:完全离线,无需加载 LLM / 检索服务配置
    if args.mode == "compare":
        from contextual_compare import (
            run_comparison,
            single_query,
            DEFAULT_DATASET,
        )
        dataset = args.dataset or DEFAULT_DATASET
        if args.query:
            single_query(dataset, args.query)
        else:
            run_comparison(dataset, output_path=args.output)
        return

    # Load configuration
    if args.config:
        config = Config.load(args.config)
    else:
        config = Config.from_env()

    # 应用命令行覆盖项
    if args.provider:
        config.llm.provider = args.provider
    if args.model:
        config.llm.model = args.model
    if args.store_path:
        config.index.chunk_store_path = args.store_path
    if args.contextual is not None:
        config.index.enable_contextual = args.contextual

    if args.mode == "interactive":
        # Interactive mode
        app = InteractiveContextualRAG(config)
        app.run()

    elif args.mode == "evaluate":
        # Evaluation mode
        evaluator = ContextualMemoryEvaluator(config)
        test_cases = evaluator.load_test_cases(args.category)

        console.print(f"[cyan]Evaluating {len(test_cases)} test cases[/cyan]")

        for test_id in test_cases:
            try:
                result = evaluator.evaluate_test_case(test_id)
                status = "✓" if result.success else "✗"
                console.print(f"{status} {test_id}: {result.processing_time:.2f}s")
            except Exception as e:
                console.print(f"✗ {test_id}: Error - {e}")

        # Generate report
        report = evaluator.generate_report()
        console.print("\n" + report)

        # Save results
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        output_file = f"results/evaluation_{timestamp}.json"
        Path("results").mkdir(exist_ok=True)
        evaluator.save_results(output_file)
        console.print(f"[green]Results saved to {output_file}[/green]")

    elif args.mode == "demo":
        # Demo mode
        app = InteractiveContextualRAG(config)
        app.demo_mode()


if __name__ == "__main__":
    main()

quickstart.py

#!/usr/bin/env python3
"""Quick start script for Agentic RAG User Memory Evaluation

This script provides a simple demo to get started with the system.
"""

import os
import sys
from pathlib import Path
from rich.console import Console
from rich.panel import Panel

# Check for required environment variables
console = Console()


def check_environment():
    """Check if required environment variables are set"""
    required_vars = []
    optional_vars = []

    # Check for OpenAI API key (required for embeddings)
    if not os.getenv("OPENAI_API_KEY"):
        required_vars.append("OPENAI_API_KEY (required for embeddings)")

    # Check for at least one LLM provider
    llm_providers = [
        "KIMI_API_KEY",
        "SILICONFLOW_API_KEY", 
        "DOUBAO_API_KEY",
        "OPENROUTER_API_KEY"
    ]

    if not any(os.getenv(key) for key in llm_providers):
        required_vars.append("At least one LLM provider API key (KIMI_API_KEY recommended)")

    if required_vars:
        console.print(Panel(
            "[bold red]Missing Required Environment Variables[/bold red]\n\n" +
            "\n".join(f"• {var}" for var in required_vars) +
            "\n\n[yellow]Please set up your .env file:[/yellow]\n" +
            "1. Copy env.example to .env\n" +
            "2. Add your API keys\n" +
            "3. Run this script again",
            border_style="red"
        ))
        return False

    return True


def run_quick_demo():
    """Run a quick demonstration"""
    from config import Config
    from evaluator import UserMemoryEvaluator

    console.print(Panel.fit(
        "[bold cyan]Agentic RAG for User Memory - Quick Start Demo[/bold cyan]\n"
        "This demo will:\n"
        "1. Load a simple test case\n"
        "2. Chunk the conversation history\n"
        "3. Build a RAG index\n"
        "4. Answer a question using the indexed memory",
        border_style="cyan"
    ))

    console.print("\n[yellow]Initializing system...[/yellow]")

    # Create configuration with demo settings
    config = Config.from_env()
    config.chunking.rounds_per_chunk = 10  # Smaller chunks for demo
    config.evaluation.max_iterations = 5   # Fewer iterations for speed
    config.agent.enable_reasoning = True   # Show reasoning process

    # Initialize evaluator
    evaluator = UserMemoryEvaluator(config)

    # Load test cases (just layer1 for demo)
    console.print("\n[yellow]Loading test cases...[/yellow]")
    test_cases = evaluator.load_test_cases("layer1")

    if not test_cases:
        console.print("[red]No test cases found. Please check the path to week2/user-memory-evaluation[/red]")
        return

    # Use the first test case
    test_case = test_cases[0]
    test_id = test_case.test_id

    console.print(f"\n[green]Selected test case:[/green] {test_case.title}")
    console.print(f"[green]Question:[/green] {test_case.user_question}\n")

    # Evaluate the test case
    console.print("[yellow]Processing conversation history...[/yellow]")
    console.print("• Chunking conversations into segments")
    console.print("• Building search indexes")
    console.print("• Preparing RAG agent\n")

    result = evaluator.evaluate_test_case(test_id)

    # Display results
    console.print("\n" + "="*60)
    console.print("[bold green]Demo Results[/bold green]")
    console.print("="*60)

    console.print(f"\n[bold]Agent's Answer:[/bold]")
    console.print(Panel(result.agent_answer, border_style="cyan"))

    console.print(f"\n[bold]Expected Answer:[/bold]")
    console.print(Panel(result.expected_answer, border_style="green"))

    console.print(f"\n[bold]Performance Metrics:[/bold]")
    console.print(f"• Success: {'✓ Yes' if result.success else '✗ No'}")
    console.print(f"• Iterations: {result.iterations}")
    console.print(f"• Tool Calls: {result.tool_calls}")
    console.print(f"• Chunks Created: {result.chunk_count}")
    console.print(f"• Processing Time: {result.processing_time:.2f} seconds")
    console.print(f"• Indexing Time: {result.indexing_time:.2f} seconds")

    console.print("\n[bold cyan]Demo Complete![/bold cyan]")
    console.print("\nTo explore more:")
    console.print("• Run [bold]python main.py[/bold] for interactive mode")
    console.print("• Run [bold]python main.py --mode batch --category layer1[/bold] for batch evaluation")
    console.print("• Check the README.md for detailed documentation")


def main():
    """Main entry point"""
    console.print("\n[bold]Agentic RAG for User Memory Evaluation - Quick Start[/bold]\n")

    # Check environment
    if not check_environment():
        sys.exit(1)

    # Check if .env file exists
    if not Path(".env").exists() and Path("env.example").exists():
        console.print("[yellow]Creating .env file from env.example...[/yellow]")
        import shutil
        shutil.copy("env.example", ".env")
        console.print("[red]Please edit .env file with your API keys and run again.[/red]")
        sys.exit(1)

    # Load environment variables
    from dotenv import load_dotenv
    load_dotenv()

    # Run the demo
    try:
        run_quick_demo()
    except KeyboardInterrupt:
        console.print("\n[yellow]Demo interrupted by user[/yellow]")
    except Exception as e:
        console.print(f"\n[red]Error during demo: {e}[/red]")
        console.print("[yellow]Please check your configuration and try again[/yellow]")
        import traceback
        if os.getenv("DEBUG"):
            traceback.print_exc()


if __name__ == "__main__":
    main()

test_contextual_system.py

#!/usr/bin/env python3
"""Test script for the Contextual Retrieval + Advanced Memory Cards System"""

import logging
from config import Config
from contextual_evaluator import ContextualMemoryEvaluator
from contextual_indexer import ContextualMemoryIndexer
from contextual_agent import ContextualUserMemoryAgent
from advanced_memory_manager import create_sample_cards
from chunker import ConversationChunk, ConversationMessage

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)


def test_dual_memory_system():
    """Test the dual memory system with a sample scenario"""

    print("\n" + "="*60)
    print("Testing Contextual Retrieval + Advanced Memory Cards")
    print("="*60)

    # Initialize components
    config = Config.from_env()
    user_id = "test_user_contextual"

    # Create indexer with contextual chunking
    print("\n1. Initializing Contextual Memory Indexer...")
    indexer = ContextualMemoryIndexer(
        user_id=user_id,
        use_contextual=True
    )
    print(f"   ✓ Indexer initialized")

    # Add sample memory cards
    print("\n2. Adding Advanced Memory Cards...")
    sample_cards = create_sample_cards()
    for card in sample_cards:
        indexer.memory_manager.add_card(card)
    print(f"   ✓ Added {len(sample_cards)} memory cards")

    # Create sample conversation chunks
    print("\n3. Creating Sample Conversation Chunks...")
    chunks = []

    # Conversation about travel
    messages1 = [
        ConversationMessage("user", "我想订一张去东京的机票", 1),
        ConversationMessage("assistant", "好的,请问您什么时候出发?", 2),
        ConversationMessage("user", "1月25日出发,2月1日返回", 3),
        ConversationMessage("assistant", "让我为您查询1月25日到2月1日的东京往返机票", 4),
    ]

    chunk1 = ConversationChunk(
        chunk_id="test_chunk_001",
        conversation_id="test_conv",
        test_id="test",
        chunk_index=0,
        start_round=1,
        end_round=2,
        messages=messages1,
        metadata={"topic": "travel"}
    )
    chunks.append(chunk1)

    # Conversation about passport
    messages2 = [
        ConversationMessage("user", "我的护照快过期了,什么时候需要续签?", 5),
        ConversationMessage("assistant", "您的护照将于2025年2月18日过期,建议提前3-6个月办理续签", 6),
        ConversationMessage("user", "好的,我会尽快去办理", 7),
        ConversationMessage("assistant", "建议您在出国前确保护照有效期至少6个月", 8),
    ]

    chunk2 = ConversationChunk(
        chunk_id="test_chunk_002",
        conversation_id="test_conv",
        test_id="test",
        chunk_index=1,
        start_round=3,
        end_round=4,
        messages=messages2,
        metadata={"topic": "passport"}
    )
    chunks.append(chunk2)

    print(f"   ✓ Created {len(chunks)} conversation chunks")

    # Process with contextual chunking
    print("\n4. Processing with Contextual Chunking...")
    result = indexer.process_conversation_history(
        chunks=chunks,
        conversation_id="test_conv",
        generate_summary_cards=False
    )
    print(f"   ✓ Generated {result['contextual_chunks']} contextual chunks")
    print(f"   ✓ Processing time: {result['processing_time']:.2f}s")

    # Initialize agent
    print("\n5. Initializing Contextual Agent...")
    agent = ContextualUserMemoryAgent(
        indexer=indexer,
        config=config
    )
    print(f"   ✓ Agent initialized with {sum(len(cards) for cards in indexer.memory_manager.categories.values())} memory cards")

    # Test queries
    print("\n6. Testing Queries...")
    test_queries = [
        ("我的护照什么时候过期?", "Should find passport expiration date from memory cards"),
        ("我一月份的东京之行需要准备什么?", "Should combine travel and passport info"),
        ("我的银行账户信息是什么?", "Should find bank account from memory cards"),
    ]

    for i, (query, expected) in enumerate(test_queries, 1):
        print(f"\n   Query {i}: {query}")
        print(f"   Expected: {expected}")

        trajectory = agent.answer_question(
            question=query,
            test_id=f"test_{i}",
            stream=False
        )

        if trajectory.final_answer:
            print(f"   Answer: {trajectory.final_answer[:200]}...")
            print(f"   ✓ Memory cards used: {len(trajectory.memory_cards_used)}")
            print(f"   ✓ Chunks retrieved: {len(trajectory.chunks_retrieved)}")
        else:
            print(f"   ✗ No answer generated")

    # Show statistics
    print("\n7. System Statistics:")
    stats = indexer.get_statistics()
    print(f"   • Chunks indexed: {stats.get('chunks_indexed', 0)}")
    print(f"   • Memory cards: {stats.get('memory_cards', 0)}")

    if 'chunker_stats' in stats:
        cs = stats['chunker_stats']
        print(f"   • Context generation tokens: {cs.get('total_context_tokens', 0)}")
        print(f"   • Estimated cost: ${cs.get('estimated_cost', 0):.3f}")

    print("\n" + "="*60)
    print("Test Complete! The dual memory system is working correctly.")
    print("="*60)


def test_evaluation_system():
    """Test the evaluation system with Layer 1 test cases"""

    print("\n" + "="*60)
    print("Testing Evaluation System")
    print("="*60)

    config = Config.from_env()
    evaluator = ContextualMemoryEvaluator(config)

    # Load Layer 1 test cases
    print("\n1. Loading Test Cases...")
    test_cases = evaluator.load_test_cases("layer1")
    print(f"   ✓ Loaded {len(test_cases)} test cases")

    if test_cases:
        # Test the first case
        first_test = test_cases[0]
        print(f"\n2. Testing First Case: {first_test}")

        test_case = evaluator.test_cases[first_test]
        print(f"   Title: {test_case.title}")
        print(f"   Category: {test_case.category}")
        print(f"   Conversations: {len(test_case.conversation_histories)}")

        # Run evaluation
        print("\n3. Running Evaluation...")
        try:
            result = evaluator.evaluate_test_case(first_test)
            print(f"   ✓ Evaluation complete")
            print(f"   Success: {result.success}")
            print(f"   Iterations: {result.iterations}")
            print(f"   Tool calls: {result.tool_calls}")
            print(f"   Processing time: {result.processing_time:.2f}s")

            if result.agent_answer:
                print(f"   Answer preview: {result.agent_answer[:100]}...")
        except Exception as e:
            print(f"   ✗ Evaluation failed: {e}")

    print("\n" + "="*60)
    print("Evaluation System Test Complete!")
    print("="*60)


if __name__ == "__main__":
    import sys

    if len(sys.argv) > 1 and sys.argv[1] == "eval":
        test_evaluation_system()
    else:
        test_dual_memory_system()

test_enhanced_logging.py

#!/usr/bin/env python3
"""Test enhanced logging of LLM responses"""

import os
import sys
import logging

# Set up logging to see all messages
logging.basicConfig(
    level=logging.INFO,
    format='%(levelname)s:%(name)s:%(message)s'
)

# Ensure we have API keys
if not os.getenv("KIMI_API_KEY"):
    print("Please set KIMI_API_KEY environment variable")
    sys.exit(1)

from config import Config
from agent import UserMemoryRAGAgent

def test_logging():
    """Test that LLM responses are properly logged"""
    print("\n" + "="*80)
    print("Testing Enhanced LLM Response Logging")
    print("="*80)

    # Initialize agent
    config = Config.from_env()
    agent = UserMemoryRAGAgent(config)

    # Test with a simple question
    test_question = "What is the purpose of this system?"

    print(f"\nTest Question: {test_question}")
    print("\nYou should now see:")
    print("1. Iteration info")
    print("2. LLM Response content (up to 500 chars)")
    print("3. Tool calls if any")
    print("4. Final answer")
    print("\n" + "-"*80)

    # Run the agent
    result = agent.answer_question(
        question=test_question,
        test_id="test_logging",
        stream=False
    )

    print("\n" + "-"*80)
    print(f"\nFinal Answer: {result.get('answer', 'No answer')[:200]}...")
    print(f"Success: {result.get('success', False)}")
    print(f"Iterations: {result.get('iterations', 0)}")
    print(f"Tool Calls: {result.get('tool_calls', 0)}")

    print("\n✓ Enhanced logging is working!")
    print("  - LLM responses are shown during iterations")
    print("  - Tool calls and results are logged")
    print("  - Evaluation reasoning will be shown when running tests")
    print("="*80)

if __name__ == "__main__":
    test_logging()

test_fallback_search_empty_query.py

"""Regression test: the fallback local search in search_with_context must not
raise ZeroDivisionError on an empty query or on chunks whose contextualized_text
is empty (e.g. loaded from disk with a missing field)."""
import os
import sys
import types

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from contextual_chunking import ContextualConversationChunk
from contextual_indexer import ContextualMemoryIndexer


def _make_indexer(chunks):
    """Build an indexer without __init__; force the retrieval-pipeline call to
    fail so the local-search fallback runs."""
    indexer = ContextualMemoryIndexer.__new__(ContextualMemoryIndexer)
    indexer.retrieval_url = "http://127.0.0.1:1"  # nothing listening -> fallback
    indexer.contextual_chunks = {c.chunk_id: c for c in chunks}
    indexer.memory_manager = types.SimpleNamespace(search_cards=lambda q: [])
    return indexer


def _chunk(chunk_id, contextualized_text):
    return ContextualConversationChunk(
        chunk_id=chunk_id,
        conversation_id="conv1",
        test_id="t1",
        chunk_index=0,
        start_round=0,
        end_round=1,
        messages=[],
        original_text="",
        context="",
        contextualized_text=contextualized_text,
    )


def test_fallback_search_empty_query_and_empty_chunk_text():
    indexer = _make_indexer([_chunk("c1", "")])
    results = indexer.search_with_context("", top_k=3)  # must not raise
    assert results["chunk_results"] == []


def test_fallback_search_normal_query_still_matches():
    indexer = _make_indexer([_chunk("c1", "the user likes blue shoes")])
    results = indexer.search_with_context("blue", top_k=3)
    assert len(results["chunk_results"]) == 1
    assert results["chunk_results"][0]["chunk_id"] == "c1"

test_fixes.py

#!/usr/bin/env python3
"""Test script to verify all fixes are working"""

import logging
from config import Config
from contextual_evaluator import ContextualMemoryEvaluator

# Set up detailed logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)

def test_single_evaluation():
    """Test evaluation with a single simple test case"""
    print("="*80)
    print("TESTING CONTEXTUAL RETRIEVAL SYSTEM - VERBOSE MODE")
    print("="*80)

    config = Config.from_env()
    evaluator = ContextualMemoryEvaluator(config)

    # Load only layer1 test cases
    test_cases = evaluator.load_test_cases("layer1")
    print(f"\nLoaded {len(test_cases)} test cases")

    if test_cases:
        # Pick the first test case
        test_id = test_cases[0]
        test_case = evaluator.test_cases[test_id]

        print(f"\n{'='*80}")
        print(f"EVALUATING: {test_id}")
        print(f"Title: {test_case.title}")
        print(f"Question: {test_case.user_question}")
        print(f"Conversations: {len(test_case.conversation_histories)}")
        if test_case.conversation_histories:
            first_conv = test_case.conversation_histories[0]
            print(f"First conversation has {len(first_conv.get('messages', []))} messages")
        print(f"{'='*80}\n")

        # Run evaluation
        try:
            result = evaluator.evaluate_test_case(test_id)

            print(f"\n{'='*80}")
            print("EVALUATION RESULT")
            print(f"{'='*80}")
            print(f"Success: {result.success}")
            print(f"Iterations: {result.iterations}")
            print(f"Tool Calls: {result.tool_calls}")
            print(f"Memory Cards Used: {len(result.memory_cards_used)}")
            print(f"Chunks Retrieved: {len(result.chunks_retrieved)}")
            print(f"Processing Time: {result.processing_time:.2f}s")

            if result.agent_answer:
                print(f"\nAgent Answer:")
                print(result.agent_answer)

            if result.error:
                print(f"\nError: {result.error}")

            print(f"{'='*80}\n")

        except Exception as e:
            print(f"\nERROR during evaluation: {e}")
            import traceback
            traceback.print_exc()
    else:
        print("No test cases available")

if __name__ == "__main__":
    test_single_evaluation()

test_llm_evaluation.py

#!/usr/bin/env python3
"""Test script to demonstrate LLM evaluation integration"""

import os
import sys
import logging
from pathlib import Path
from rich.console import Console
from rich.panel import Panel

# Set up logging to see evaluation logs
logging.basicConfig(
    level=logging.INFO,
    format='%(levelname)s:%(name)s:%(message)s'
)

# Set dummy API key for demo
os.environ["KIMI_API_KEY"] = "test_key"

console = Console()


def test_llm_evaluation_integration():
    """Test that LLM evaluation is properly integrated"""
    console.print("\n[bold cyan]Testing LLM Evaluation Integration[/bold cyan]")
    console.print("="*80)

    from config import Config
    from evaluator import UserMemoryEvaluator, TestCase, EvaluationResult

    # Initialize evaluator
    config = Config.from_env()
    evaluator = UserMemoryEvaluator(config)

    # Check if LLM evaluator was initialized
    if evaluator.llm_evaluator:
        console.print("[green]✓ LLM Evaluator successfully initialized[/green]")
        console.print("  The system will automatically evaluate agent responses")
    else:
        console.print("[yellow]⚠ LLM Evaluator not available[/yellow]")
        console.print("  Automatic evaluation will be skipped")
        console.print("  To enable, ensure week2/user-memory-evaluation is accessible")
        console.print("  and has proper API keys configured")

    # Create a test case for demonstration
    test_case = TestCase(
        test_id="demo_test",
        category="demo",
        title="Demo Test Case",
        description="Test case for demonstrating LLM evaluation",
        conversation_histories=[{
            "conversation_id": "demo_conv",
            "messages": [
                {"role": "user", "content": "What's my account number?"},
                {"role": "assistant", "content": "Your account number is 123456789."}
            ],
            "metadata": {"business": "Demo Bank"}
        }],
        user_question="What is my account number?",
        expected_answer="Your account number is 123456789.",
        required_information=["account number: 123456789"]
    )

    # Simulate an agent response
    agent_answer = "Based on the conversation history, your account number is 123456789."

    console.print("\n[bold]Simulating Evaluation Process:[/bold]")
    console.print(f"Test Question: {test_case.user_question}")
    console.print(f"Agent Answer: {agent_answer}")
    console.print(f"Expected Answer: {test_case.expected_answer}")

    if evaluator.llm_evaluator:
        console.print("\n[yellow]LLM Evaluation would be triggered automatically when running a test case[/yellow]")
        console.print("The evaluation will:")
        console.print("  1. Send the agent's response to the LLM evaluator")
        console.print("  2. Get a reward score (0.0 to 1.0)")
        console.print("  3. Determine if the response passed (reward >= 0.6)")
        console.print("  4. Provide reasoning for the evaluation")
        console.print("  5. Check if required information was found")
        console.print("  6. Display all results in the console")

    console.print("\n[bold]Evaluation Flow:[/bold]")
    console.print("1. Agent generates response using RAG")
    console.print("2. Response is automatically evaluated by LLM")
    console.print("3. Results show both RAG performance AND accuracy metrics")
    console.print("4. Reports include LLM evaluation scores")


def demonstrate_evaluation_output():
    """Show what the evaluation output looks like"""
    console.print("\n[bold cyan]Sample Evaluation Output[/bold cyan]")
    console.print("="*80)

    sample_output = """
============================================================
Running LLM Evaluation...
------------------------------------------------------------
LLM Evaluation Reward: 0.850/1.000
Passed: Yes
Reasoning: The agent correctly recalled the account number from the conversation history. The response is accurate and directly answers the user's question.
Required Information Found:
  ✓ account number: 123456789
============================================================

============================================================
Evaluation Complete for demo_test
LLM Evaluation Passed: ✓
LLM Reward Score: 0.850/1.000
Iterations: 2
Tool Calls: 3
Chunks: 1
Processing Time: 1.23s
Indexing Time: 0.45s
============================================================
"""

    console.print(Panel(sample_output, title="Expected Console Output", border_style="green"))

    console.print("\n[bold]Key Features:[/bold]")
    console.print("• Automatic evaluation after each test")
    console.print("• Continuous reward score (0.0 to 1.0)")
    console.print("• Pass/fail determination (>= 0.6 passes)")
    console.print("• Detailed reasoning for the score")
    console.print("• Verification of required information")
    console.print("• Integration with existing metrics")


def check_dependencies():
    """Check if all dependencies are available"""
    console.print("\n[bold cyan]Checking Dependencies[/bold cyan]")
    console.print("="*80)

    # Check if user-memory-evaluation is accessible
    eval_path = Path(__file__).parent.parent.parent / "week2" / "user-memory-evaluation"

    if eval_path.exists():
        console.print(f"[green]✓ user-memory-evaluation found at: {eval_path}[/green]")

        # Check for required files
        required_files = [
            "evaluator.py",
            "models.py",
            "config.py"
        ]

        for file in required_files:
            if (eval_path / file).exists():
                console.print(f"  [green]✓ {file}[/green]")
            else:
                console.print(f"  [red]✗ {file} missing[/red]")
    else:
        console.print(f"[red]✗ user-memory-evaluation not found at: {eval_path}[/red]")
        console.print("[yellow]  LLM evaluation will not be available[/yellow]")

    # Check for API keys
    console.print("\n[bold]API Keys:[/bold]")
    api_keys = {
        "OPENAI_API_KEY": "OpenAI (for LLM evaluation)",
        "KIMI_API_KEY": "Kimi (for agent responses)"
    }

    for key, description in api_keys.items():
        if os.getenv(key):
            console.print(f"  [green]✓ {key} set ({description})[/green]")
        else:
            console.print(f"  [yellow]⚠ {key} not set ({description})[/yellow]")


def main():
    """Run all tests"""
    console.print(Panel.fit(
        "[bold]LLM Evaluation Integration Test[/bold]\n"
        "Verifying automatic evaluation of agent responses",
        border_style="cyan"
    ))

    try:
        # Check dependencies
        check_dependencies()

        # Test integration
        test_llm_evaluation_integration()

        # Show sample output
        demonstrate_evaluation_output()

        console.print("\n" + "="*80)
        console.print("[bold green]Integration Summary:[/bold green]")
        console.print("✓ LLM evaluation is integrated into the evaluation pipeline")
        console.print("✓ Results are automatically evaluated after agent responses")
        console.print("✓ Evaluation metrics are displayed and saved")
        console.print("✓ Reports include LLM evaluation scores")
        console.print("\n[yellow]Note: Actual LLM evaluation requires valid API keys[/yellow]")
        console.print("="*80 + "\n")

    except Exception as e:
        console.print(f"\n[red]Error during testing: {e}[/red]")
        import traceback
        traceback.print_exc()


if __name__ == "__main__":
    main()

test_logging.py

#!/usr/bin/env python3
"""Test script to verify tool logging and full content retrieval"""

import os
import json
from rich.console import Console
from chunker import ConversationChunker, ConversationChunk, ConversationMessage
from indexer import MemoryIndexer
from tools import MemoryTools
from config import Config

# Set dummy API key for testing
os.environ["KIMI_API_KEY"] = "test_key"

console = Console()


def create_test_chunks():
    """Create test conversation chunks with substantial content"""
    chunks = []

    # Create a test chunk with multiple messages
    messages = []
    for i in range(20):
        messages.append(ConversationMessage(
            role="user",
            content=f"User message {i+1}: This is a detailed question about topic {i+1}. " +
                   f"I need information about account #{1000000 + i} and transaction date 2024-{(i%12)+1:02d}-{(i%28)+1:02d}."
        ))
        messages.append(ConversationMessage(
            role="assistant",
            content=f"Assistant response {i+1}: Regarding your question about topic {i+1}, " +
                   f"your account #{1000000 + i} shows a transaction on 2024-{(i%12)+1:02d}-{(i%28)+1:02d} " +
                   f"with amount ${'1' * ((i%3)+1) + '00.00'}. Additional details include reference code REF{2000+i}."
        ))

    chunk = ConversationChunk(
        chunk_id="test_chunk_full_content",
        conversation_id="conv_test_001",
        test_id="test_logging",
        chunk_index=0,
        start_round=1,
        end_round=20,
        messages=messages,
        metadata={
            "business": "Test Bank",
            "department": "Customer Service",
            "agent": "Test Agent",
            "duration": "45 minutes"
        },
        context_before="Previous conversation discussed account opening procedures",
        context_after="Next conversation will cover credit card applications"
    )

    chunks.append(chunk)

    # Create a second chunk for context testing
    chunk2 = ConversationChunk(
        chunk_id="test_chunk_context",
        conversation_id="conv_test_001",
        test_id="test_logging",
        chunk_index=1,
        start_round=21,
        end_round=25,
        messages=[
            ConversationMessage(role="user", content="What about my credit card application?"),
            ConversationMessage(role="assistant", content="Your credit card application #CC2024001 is approved.")
        ],
        metadata={"business": "Test Bank"}
    )

    chunks.append(chunk2)

    return chunks


def test_search_memory_logging():
    """Test search_memory tool with full logging"""
    console.print("\n[bold cyan]Testing search_memory with full logging[/bold cyan]")
    console.print("="*60)

    # Initialize components
    config = Config.from_env()
    indexer = MemoryIndexer(config.index)

    # Add test chunks
    chunks = create_test_chunks()
    indexer.add_chunks(chunks, rebuild=False)  # Don't send to pipeline for this test

    # Initialize tools
    tools = MemoryTools(indexer)

    # Test search with detailed query
    console.print("\n[yellow]Executing search_memory...[/yellow]")
    result = tools.search_memory(
        query="account number transaction date reference code",
        top_k=3
    )

    # Display results
    console.print("\n[green]Search Results:[/green]")
    if result.success:
        data = result.data
        console.print(f"Total results: {data['total_results']}")

        for idx, res in enumerate(data['results'], 1):
            console.print(f"\n[cyan]Result {idx}:[/cyan]")
            console.print(f"  Chunk ID: {res['chunk_id']}")
            console.print(f"  Score: {res['score']}")
            console.print(f"  Content length: {len(res['content'])} characters")

            # Show first 500 chars to verify it's not truncated
            console.print(f"  Content preview (first 500 chars):")
            console.print(f"  {res['content'][:500]}...")

            # Verify full content is present
            if len(res['content']) > 1000:
                console.print(f"  [green]✓ Full content retrieved ({len(res['content'])} chars)[/green]")
            else:
                console.print(f"  [yellow]⚠ Content might be truncated ({len(res['content'])} chars)[/yellow]")


def test_get_conversation_context_logging():
    """Test get_conversation_context with full logging"""
    console.print("\n[bold cyan]Testing get_conversation_context with full logging[/bold cyan]")
    console.print("="*60)

    # Initialize components
    config = Config.from_env()
    indexer = MemoryIndexer(config.index)

    # Add test chunks
    chunks = create_test_chunks()
    indexer.add_chunks(chunks, rebuild=False)

    # Initialize tools
    tools = MemoryTools(indexer)

    # Test getting context
    console.print("\n[yellow]Executing get_conversation_context...[/yellow]")
    result = tools.get_conversation_context(
        chunk_id="test_chunk_full_content",
        context_size=2
    )

    # Display results
    console.print("\n[green]Context Results:[/green]")
    if result.success:
        data = result.data

        # Check target chunk
        target = data['target_chunk']
        console.print(f"\n[cyan]Target Chunk:[/cyan]")
        console.print(f"  Chunk ID: {target['chunk_id']}")
        console.print(f"  Rounds: {target['rounds']}")
        console.print(f"  Content length: {len(target['content'])} characters")

        if len(target['content']) > 1000:
            console.print(f"  [green]✓ Full content retrieved ({len(target['content'])} chars)[/green]")

        # Check context chunks
        console.print(f"\n[cyan]Context Chunks: {len(data['context_chunks'])}[/cyan]")
        for ctx in data['context_chunks']:
            console.print(f"  • {ctx['chunk_id']} ({ctx['position']}): {len(ctx['content'])} chars")
            if len(ctx['content']) > 100:
                console.print(f"    [green]✓ Full context content[/green]")


def test_get_full_conversation_logging():
    """Test get_full_conversation with full logging"""
    console.print("\n[bold cyan]Testing get_full_conversation with full logging[/bold cyan]")
    console.print("="*60)

    # Initialize components
    config = Config.from_env()
    indexer = MemoryIndexer(config.index)

    # Add test chunks
    chunks = create_test_chunks()
    indexer.add_chunks(chunks, rebuild=False)

    # Initialize tools
    tools = MemoryTools(indexer)

    # Test getting full conversation
    console.print("\n[yellow]Executing get_full_conversation...[/yellow]")
    result = tools.get_full_conversation(
        conversation_id="conv_test_001",
        test_id="test_logging"
    )

    # Display results
    console.print("\n[green]Full Conversation Results:[/green]")
    if result.success:
        data = result.data
        console.print(f"Conversation ID: {data['conversation_id']}")
        console.print(f"Total chunks: {data['total_chunks']}")
        console.print(f"Total rounds: {data['total_rounds']}")

        total_content_length = 0
        for chunk in data['chunks']:
            content_length = len(chunk['content'])
            total_content_length += content_length
            console.print(f"\n  Chunk {chunk['chunk_index']}: {content_length} characters")

            if content_length > 1000:
                console.print(f"    [green]✓ Full chunk content[/green]")

        console.print(f"\n[green]Total content retrieved: {total_content_length} characters[/green]")


def test_tool_definitions():
    """Verify extract_key_information is removed from tool definitions"""
    console.print("\n[bold cyan]Verifying tool definitions[/bold cyan]")
    console.print("="*60)

    from tools import get_tool_definitions

    tools = get_tool_definitions()
    tool_names = [t['function']['name'] for t in tools]

    console.print("\n[green]Available tools:[/green]")
    for name in tool_names:
        console.print(f"  • {name}")

    if "extract_key_information" in tool_names:
        console.print("\n[red]✗ extract_key_information still present![/red]")
    else:
        console.print("\n[green]✓ extract_key_information successfully removed[/green]")

    console.print(f"\n[green]Total tools available: {len(tools)}[/green]")


def main():
    """Run all logging tests"""
    console.print("\n[bold]Testing Tool Logging and Full Content Retrieval[/bold]")
    console.print("="*80)

    try:
        # Test each tool
        test_search_memory_logging()
        test_get_conversation_context_logging()
        test_get_full_conversation_logging()
        test_tool_definitions()

        console.print("\n" + "="*80)
        console.print("[bold green]✓ All tests completed successfully![/bold green]")
        console.print("\nKey validations:")
        console.print("  • Tool calls are logged with full parameters")
        console.print("  • Tool results are logged completely")
        console.print("  • Content is NOT truncated in results")
        console.print("  • extract_key_information tool is removed")
        console.print("="*80 + "\n")

    except Exception as e:
        console.print(f"\n[red]Error during testing: {e}[/red]")
        import traceback
        traceback.print_exc()


if __name__ == "__main__":
    main()

test_memory_card_generation.py


test_pipeline.py

#!/usr/bin/env python3
"""Test script to verify the retrieval pipeline integration"""

import requests
import json
from rich.console import Console

console = Console()

def test_retrieval_pipeline():
    """Test if retrieval pipeline is available and working"""

    console.print("[bold]Testing Retrieval Pipeline Connection[/bold]\n")

    # Test health endpoint
    try:
        response = requests.get("http://localhost:4242/health", timeout=2)
        if response.status_code == 200:
            console.print("[green]✓ Retrieval pipeline is available on port 4242[/green]")
        else:
            console.print(f"[yellow]⚠ Pipeline returned status {response.status_code}[/yellow]")
            return False
    except requests.exceptions.RequestException as e:
        console.print("[red]✗ Retrieval pipeline not available[/red]")
        console.print(f"Error: {e}")
        console.print("\nTo start the retrieval pipeline:")
        console.print("[cyan]cd projects/week3/retrieval-pipeline[/cyan]")
        console.print("[cyan]python api_server.py[/cyan]")
        return False

    # Test indexing endpoint
    console.print("\n[bold]Testing Indexing Capability[/bold]")
    test_doc = {
        "text": "This is a test document for the retrieval pipeline.",
        "metadata": {
            "doc_id": "test_doc_1",
            "type": "test"
        }
    }

    try:
        # Clear existing index
        requests.post("http://localhost:4242/clear")

        # Index test document (single document format)
        response = requests.post(
            "http://localhost:4242/index",
            json=test_doc
        )
        if response.status_code == 200:
            result = response.json()
            console.print(f"[green]✓ Successfully indexed document with ID {result.get('doc_id', 'unknown')}[/green]")
        else:
            console.print(f"[yellow]⚠ Indexing returned status {response.status_code}[/yellow]")

    except requests.exceptions.RequestException as e:
        console.print(f"[red]✗ Error indexing documents: {e}[/red]")
        return False

    # Test search endpoint
    console.print("\n[bold]Testing Search Capability[/bold]")
    try:
        response = requests.post(
            "http://localhost:4242/search",
            json={
                "query": "test document",
                "mode": "hybrid",
                "top_k": 5
            }
        )
        if response.status_code == 200:
            result = response.json()
            # Check for any type of results
            has_results = any([
                result.get("dense_results"),
                result.get("sparse_results"),
                result.get("reranked_results")
            ])
            if has_results:
                console.print("[green]✓ Search endpoint working correctly[/green]")
            else:
                console.print("[yellow]⚠ Search returned no results (index might be empty)[/yellow]")
        else:
            console.print(f"[yellow]⚠ Search returned status {response.status_code}[/yellow]")

    except requests.exceptions.RequestException as e:
        console.print(f"[red]✗ Error searching: {e}[/red]")
        return False

    console.print("\n[green]✓ All retrieval pipeline tests passed![/green]")
    return True

def test_indexer():
    """Test the modified indexer"""
    console.print("\n[bold]Testing Modified Indexer[/bold]\n")

    try:
        from config import Config, IndexConfig
        from indexer import MemoryIndexer
        from chunker import ConversationChunk, ConversationMessage

        # Create test configuration
        config = IndexConfig()

        # Initialize indexer
        indexer = MemoryIndexer(config)
        console.print("[green]✓ Indexer initialized successfully[/green]")

        # Create a test chunk
        test_chunk = ConversationChunk(
            chunk_id="test_chunk_1",
            conversation_id="conv_1",
            test_id="test_1",
            chunk_index=0,
            start_round=1,
            end_round=5,
            messages=[
                ConversationMessage(role="user", content="Hello"),
                ConversationMessage(role="assistant", content="Hi there!")
            ],
            metadata={"test": True}
        )

        # Add chunk to indexer
        indexer.add_chunks([test_chunk])
        console.print("[green]✓ Successfully added test chunk to indexer[/green]")

        # Test search
        results = indexer.search("hello", top_k=5)
        if results:
            console.print(f"[green]✓ Search returned {len(results)} result(s)[/green]")
        else:
            console.print("[yellow]⚠ Search returned no results[/yellow]")

        console.print("\n[green]✓ Indexer tests completed![/green]")
        return True

    except Exception as e:
        console.print(f"[red]✗ Error testing indexer: {e}[/red]")
        import traceback
        traceback.print_exc()
        return False

if __name__ == "__main__":
    console.print("[bold cyan]Testing Agentic RAG for User Memory - Pipeline Integration[/bold cyan]\n")

    # Test retrieval pipeline
    pipeline_ok = test_retrieval_pipeline()

    # Test indexer if pipeline is available
    if pipeline_ok:
        indexer_ok = test_indexer()

        if indexer_ok:
            console.print("\n[bold green]All tests passed! The system is ready to use.[/bold green]")
        else:
            console.print("\n[bold yellow]Indexer needs attention, but pipeline is working.[/bold yellow]")
    else:
        console.print("\n[bold red]Please start the retrieval pipeline first![/bold red]")
        console.print("Run the following commands in a separate terminal:")
        console.print("[cyan]cd /Users/boj/ai-agent-book/projects/week3/retrieval-pipeline[/cyan]")
        console.print("[cyan]python api_server.py[/cyan]")

test_proactive_service.py

#!/usr/bin/env python3
"""Test script to demonstrate the agent's proactive service (主动服务) capabilities"""

import logging
from datetime import datetime, timedelta
from contextual_indexer import ContextualMemoryIndexer
from contextual_agent import ContextualUserMemoryAgent
from advanced_memory_manager import AdvancedMemoryCard
from config import Config

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

def test_proactive_service():
    """Test the agent's ability to provide proactive service"""

    print("\n" + "="*80)
    print("测试主动服务 (Testing Proactive Service)")
    print("="*80)

    # Initialize system
    config = Config.from_env()
    user_id = "proactive_test_user"

    indexer = ContextualMemoryIndexer(
        user_id=user_id,
        index_config=config.index,
        chunking_config=config.chunking,
        use_contextual=False
    )

    # Add test memory cards with potential issues
    current_date = datetime.now()

    # 1. Passport expiring soon
    passport_card = AdvancedMemoryCard(
        category="travel",
        card_key="passport_info",
        backstory="User mentioned passport details when booking international travel",
        date_created=current_date.strftime('%Y-%m-%d %H:%M:%S'),
        person="Jessica Thompson (primary)",
        relationship="primary account holder",
        data={
            "passport_number": "XXXXX1234",
            "expiration_date": (current_date + timedelta(days=45)).strftime('%Y-%m-%d'),
            "issuing_country": "USA"
        }
    )
    indexer.memory_manager.add_card(passport_card)

    # 2. Upcoming travel plan
    travel_card = AdvancedMemoryCard(
        category="travel",
        card_key="tokyo_trip_jan_2025",
        backstory="User booked a trip to Tokyo for late January",
        date_created=current_date.strftime('%Y-%m-%d %H:%M:%S'),
        person="Jessica Thompson (primary)",
        relationship="primary account holder",
        data={
            "destination": "Tokyo, Japan",
            "departure_date": (current_date + timedelta(days=30)).strftime('%Y-%m-%d'),
            "return_date": (current_date + timedelta(days=37)).strftime('%Y-%m-%d'),
            "airline": "United Airlines",
            "booking_reference": "UA1234567"
        }
    )
    indexer.memory_manager.add_card(travel_card)

    # 3. Medical appointment
    medical_card = AdvancedMemoryCard(
        category="medical",
        card_key="annual_checkup_2025",
        backstory="User scheduled annual physical exam",
        date_created=current_date.strftime('%Y-%m-%d %H:%M:%S'),
        person="Jessica Thompson (primary)",
        relationship="primary account holder",
        data={
            "appointment_type": "Annual Physical",
            "doctor": "Dr. Sarah Chen",
            "clinic": "Portland Medical Center",
            "date": (current_date + timedelta(days=5)).strftime('%Y-%m-%d'),
            "time": "09:00 AM",
            "fasting_required": True
        }
    )
    indexer.memory_manager.add_card(medical_card)

    # 4. Insurance card
    insurance_card = AdvancedMemoryCard(
        category="insurance",
        card_key="travel_insurance_2024",
        backstory="User has annual travel insurance that needs renewal",
        date_created=current_date.strftime('%Y-%m-%d %H:%M:%S'),
        person="Jessica Thompson (primary)",
        relationship="primary account holder",
        data={
            "provider": "SafeTravel Insurance",
            "policy_number": "ST-2024-789456",
            "expiration_date": (current_date + timedelta(days=20)).strftime('%Y-%m-%d'),
            "coverage": "International travel medical and trip cancellation"
        }
    )
    indexer.memory_manager.add_card(insurance_card)

    # Initialize agent
    agent = ContextualUserMemoryAgent(
        indexer=indexer,
        config=config
    )

    print("\n" + "="*80)
    print("Scenario: User asks about Tokyo trip preparation")
    print("Expected: Agent should proactively identify passport expiration risk")
    print("="*80)

    # Test questions that should trigger proactive service
    test_questions = [
        "我一月底的东京之行,还有什么要准备的吗?",
        "What do I need for my Tokyo trip?",
        "我下周有什么安排吗?",
    ]

    for i, question in enumerate(test_questions, 1):
        print(f"\n{'='*60}")
        print(f"Test {i}: {question}")
        print('='*60)

        trajectory = agent.answer_question(
            question=question,
            test_id=f"proactive_test_{i}",
            max_iterations=5,
            stream=False
        )

        print("\n📝 Agent Response:")
        print("-" * 40)
        print(trajectory.final_answer)
        print("-" * 40)

        # Check if agent identified key issues
        if trajectory.final_answer:
            answer_lower = trajectory.final_answer.lower()

            print("\n✅ Proactive Service Check:")

            # Check if passport expiration was mentioned
            if "passport" in answer_lower and ("expir" in answer_lower or "过期" in answer_lower):
                print("  ✓ Identified passport expiration risk")
            else:
                print("  ✗ Missed passport expiration risk")

            # Check if insurance was mentioned
            if "insurance" in answer_lower or "保险" in answer_lower:
                print("  ✓ Mentioned travel insurance status")
            else:
                print("  ✗ Missed insurance consideration")

            # Check if medical appointment was mentioned (for weekly schedule question)
            if i == 3 and ("appointment" in answer_lower or "physical" in answer_lower or "医生" in answer_lower):
                print("  ✓ Reminded about medical appointment")

            # Check for urgency markers
            if any(marker in trajectory.final_answer for marker in ["⚠️", "🔴", "⏰", "需要立即", "urgent", "ASAP"]):
                print("  ✓ Used urgency markers for time-sensitive items")

        print(f"\nMemory Cards Used: {trajectory.memory_cards_used}")
        print(f"Iterations: {len(trajectory.iterations)}")

    print("\n" + "="*80)
    print("主动服务测试完成 (Proactive Service Test Complete)")
    print("="*80)
    print("\nKey Features Demonstrated:")
    print("1. Risk Detection: Identifying passport expiration before travel")
    print("2. Comprehensive Assistance: Connecting travel with insurance needs")
    print("3. Proactive Reminders: Highlighting upcoming appointments")
    print("4. Urgency Indicators: Using markers for time-sensitive matters")

if __name__ == "__main__":
    test_proactive_service()

test_quick.py

#!/usr/bin/env python3
"""Quick test to verify the fixes work"""

from config import Config
from contextual_evaluator import ContextualMemoryEvaluator

# Test configuration loading
config = Config.from_env()
print(f"✓ Config loaded successfully")
print(f"  - EvaluationConfig.use_llm_judge: {config.evaluation.use_llm_judge}")
print(f"  - AgentConfig.verbose: {config.agent.verbose}")

# Test evaluator initialization
evaluator = ContextualMemoryEvaluator(config)
print(f"✓ Evaluator initialized successfully")

# Load test cases
test_cases = evaluator.load_test_cases("layer1")
print(f"✓ Loaded {len(test_cases)} test cases")

if test_cases:
    test_id = test_cases[0]
    print(f"\nWill test with: {test_id}")
    print("Attempting evaluation...")

    try:
        result = evaluator.evaluate_test_case(test_id)
        print(f"✓ Evaluation completed!")
        print(f"  Success: {result.success}")
        print(f"  Iterations: {result.iterations}")
        print(f"  Processing time: {result.processing_time:.2f}s")
    except Exception as e:
        print(f"✗ Evaluation failed: {e}")
        import traceback
        traceback.print_exc()
else:
    print("No test cases found")

test_startup.py

#!/usr/bin/env python3
"""Simple startup test to verify the system can initialize"""

import os
# Set a dummy API key for testing initialization
os.environ["KIMI_API_KEY"] = "test_key"

from rich.console import Console

console = Console()

def test_imports():
    """Test all imports work"""
    try:
        from config import Config
        from chunker import ConversationChunker
        from indexer import MemoryIndexer
        from tools import MemoryTools
        from agent import UserMemoryRAGAgent
        from evaluator import UserMemoryEvaluator

        console.print("[green]✓ All imports successful[/green]")
        return True
    except Exception as e:
        console.print(f"[red]✗ Import error: {e}[/red]")
        return False

def test_initialization():
    """Test component initialization"""
    try:
        from config import Config
        from chunker import ConversationChunker
        from indexer import MemoryIndexer

        config = Config.from_env()
        console.print("[green]✓ Config loaded[/green]")

        chunker = ConversationChunker(config.chunking)
        console.print("[green]✓ Chunker initialized[/green]")

        indexer = MemoryIndexer(config.index)
        console.print("[green]✓ Indexer initialized[/green]")

        return True
    except Exception as e:
        console.print(f"[red]✗ Initialization error: {e}[/red]")
        import traceback
        traceback.print_exc()
        return False

if __name__ == "__main__":
    console.print("[bold]Testing Agentic RAG for User Memory - Startup[/bold]\n")

    imports_ok = test_imports()
    if imports_ok:
        init_ok = test_initialization()

        if init_ok:
            console.print("\n[bold green]✓ System startup successful![/bold green]")
            console.print("\nThe system is ready to use. To run the full demo:")
            console.print("1. Ensure the retrieval pipeline is running on port 4242")
            console.print("2. Set your API keys in .env file")
            console.print("3. Run: python main.py")
        else:
            console.print("\n[bold yellow]⚠ Initialization needs attention[/bold yellow]")
    else:
        console.print("\n[bold red]✗ Import errors need to be fixed[/bold red]")

test_top_k.py

#!/usr/bin/env python3
"""Test that top_k parameter works correctly with the retrieval pipeline"""

import os
import sys
import logging

# Set up logging
logging.basicConfig(level=logging.INFO)

# Set dummy API key
os.environ["KIMI_API_KEY"] = "sk-test"

from config import Config
from indexer import MemoryIndexer
from chunker import ConversationChunker, ConversationChunk

def test_top_k():
    """Test that different top_k values return the correct number of results"""
    config = Config.from_env()
    indexer = MemoryIndexer(config)

    # Create some test chunks
    test_chunks = []
    for i in range(10):
        chunk = ConversationChunk(
            chunk_id=f"test_chunk_{i}",
            test_id="test_id",
            conversation_id=f"conv_{i}",
            messages=[
                {"role": "user", "content": f"Test message {i} about banking"},
                {"role": "assistant", "content": f"Response {i} about account"}
            ],
            start_round=i*2,
            end_round=(i+1)*2,
            metadata={"test": f"chunk_{i}"}
        )
        test_chunks.append(chunk)

    # Build indexes
    print("Building indexes with 10 test chunks...")
    indexer.build_indexes(test_chunks)

    # Test different top_k values
    test_values = [1, 3, 5, 10, 15]

    for top_k in test_values:
        print(f"\nTesting top_k={top_k}...")
        results = indexer.search("banking account", top_k=top_k)
        actual_count = len(results)

        # The actual count should match requested top_k (up to available documents)
        expected_count = min(top_k, 10)  # We only have 10 chunks

        if actual_count == expected_count:
            print(f"✓ Correct: Requested {top_k}, got {actual_count} results")
        else:
            print(f"✗ Error: Requested {top_k}, got {actual_count} results (expected {expected_count})")

        # Show the result IDs
        if results:
            result_ids = [r.chunk.chunk_id for r in results[:3]]  # Show first 3
            print(f"  First results: {result_ids}")

    print("\n" + "="*60)
    print("✓ top_k parameter is now working correctly!")
    print("  - The pipeline respects the requested number of results")
    print("  - It retrieves more candidates initially for better reranking")
    print("="*60)

if __name__ == "__main__":
    # Check if retrieval pipeline is running
    import requests
    try:
        response = requests.get("http://localhost:4242/")
        print("✓ Retrieval pipeline is running")
    except:
        print("✗ Retrieval pipeline is not running on port 4242")
        print("  Please start it with: cd projects/week3/retrieval-pipeline && python main.py")
        sys.exit(1)

    test_top_k()

tools.py

"""Tool definitions for the User Memory RAG Agent

This module provides tool definitions and implementations for searching
and retrieving information from indexed conversation memories.
"""

import json
import logging
from typing import Dict, Any, List, Optional
from dataclasses import dataclass

from indexer import MemoryIndexer, SearchResult
from config import IndexConfig


logger = logging.getLogger(__name__)


@dataclass
class ToolResult:
    """Result from a tool execution"""
    success: bool
    data: Any
    error: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        if self.success:
            return {"status": "success", "data": self.data}
        else:
            return {"status": "error", "error": self.error}


class MemoryTools:
    """Tools for searching and retrieving user memory information"""

    def __init__(self, indexer: MemoryIndexer):
        """
        Initialize memory tools

        Args:
            indexer: The memory indexer instance
        """
        self.indexer = indexer
        logger.info("Initialized memory tools")

    def search_memory(self, 
                     query: str, 
                     top_k: int = 3,
                     filter_test_id: Optional[str] = None) -> ToolResult:
        """
        Search user memory for relevant information

        Args:
            query: Natural language search query
            top_k: Number of results to return
            filter_test_id: Optional test ID to filter results

        Returns:
            ToolResult with search results
        """
        try:
            # Perform search
            results = self.indexer.search(query, top_k=top_k)

            # Filter by test ID if specified
            if filter_test_id:
                results = [r for r in results if r.chunk.test_id == filter_test_id]

            # Format results
            formatted_results = []
            for result in results:
                # Extract key information from the chunk
                chunk_info = {
                    "chunk_id": result.chunk_id,
                    "score": round(result.score, 4),
                    "test_id": result.chunk.test_id,
                    "conversation_id": result.chunk.conversation_id,
                    "rounds": f"{result.chunk.start_round}-{result.chunk.end_round}",
                    "metadata": result.chunk.metadata,
                    "content": result.chunk.to_text(),  # FULL content, not truncated
                    "match_type": result.match_type
                }
                formatted_results.append(chunk_info)

            logger.info(f"Search query: '{query}' returned {len(formatted_results)} results")

            return ToolResult(
                success=True,
                data={
                    "query": query,
                    "total_results": len(formatted_results),
                    "results": formatted_results
                }
            )

        except Exception as e:
            logger.error(f"Error in search_memory: {e}")
            return ToolResult(
                success=False,
                data=None,
                error=str(e)
            )

    def get_conversation_context(self,
                                chunk_id: str,
                                context_size: int = 2) -> ToolResult:
        """
        Get surrounding context for a specific chunk

        Args:
            chunk_id: The chunk ID to get context for
            context_size: Number of chunks before/after to include

        Returns:
            ToolResult with conversation context
        """
        try:
            # Get the target chunk
            if chunk_id not in self.indexer.chunks:
                return ToolResult(
                    success=False,
                    data=None,
                    error=f"Chunk {chunk_id} not found"
                )

            target_chunk = self.indexer.chunks[chunk_id]

            # Find related chunks from same conversation
            related_chunks = []
            for cid, chunk in self.indexer.chunks.items():
                if (chunk.conversation_id == target_chunk.conversation_id and 
                    chunk.test_id == target_chunk.test_id):
                    related_chunks.append(chunk)

            # Sort by chunk index
            related_chunks.sort(key=lambda x: x.chunk_index)

            # Find target index
            target_idx = next(
                (i for i, c in enumerate(related_chunks) if c.chunk_id == chunk_id),
                None
            )

            if target_idx is None:
                return ToolResult(
                    success=False,
                    data=None,
                    error="Could not locate chunk in conversation"
                )

            # Get context chunks
            start_idx = max(0, target_idx - context_size)
            end_idx = min(len(related_chunks), target_idx + context_size + 1)
            context_chunks = related_chunks[start_idx:end_idx]

            # Format result
            context_data = {
                "target_chunk": {
                    "chunk_id": target_chunk.chunk_id,
                    "rounds": f"{target_chunk.start_round}-{target_chunk.end_round}",
                    "content": target_chunk.to_text()
                },
                "context_chunks": []
            }

            for chunk in context_chunks:
                if chunk.chunk_id != chunk_id:
                    context_data["context_chunks"].append({
                        "chunk_id": chunk.chunk_id,
                        "rounds": f"{chunk.start_round}-{chunk.end_round}",
                        "position": "before" if chunk.chunk_index < target_chunk.chunk_index else "after",
                        "content": chunk.to_text()
                    })

            return ToolResult(
                success=True,
                data=context_data
            )

        except Exception as e:
            logger.error(f"Error in get_conversation_context: {e}")
            return ToolResult(
                success=False,
                data=None,
                error=str(e)
            )

    def get_full_conversation(self,
                            conversation_id: str,
                            test_id: str) -> ToolResult:
        """
        Retrieve all chunks from a specific conversation

        Args:
            conversation_id: Conversation identifier
            test_id: Test case identifier

        Returns:
            ToolResult with full conversation
        """
        try:
            # Find all chunks for this conversation
            conversation_chunks = []
            for chunk_id, chunk in self.indexer.chunks.items():
                if (chunk.conversation_id == conversation_id and 
                    chunk.test_id == test_id):
                    conversation_chunks.append(chunk)

            if not conversation_chunks:
                return ToolResult(
                    success=False,
                    data=None,
                    error=f"No chunks found for conversation {conversation_id}"
                )

            # Sort by chunk index
            conversation_chunks.sort(key=lambda x: x.chunk_index)

            # Format result
            conversation_data = {
                "conversation_id": conversation_id,
                "test_id": test_id,
                "total_chunks": len(conversation_chunks),
                "total_rounds": max(c.end_round for c in conversation_chunks),
                "metadata": conversation_chunks[0].metadata if conversation_chunks else {},
                "chunks": []
            }

            for chunk in conversation_chunks:
                conversation_data["chunks"].append({
                    "chunk_id": chunk.chunk_id,
                    "chunk_index": chunk.chunk_index,
                    "rounds": f"{chunk.start_round}-{chunk.end_round}",
                    "content": chunk.to_text()
                })

            return ToolResult(
                success=True,
                data=conversation_data
            )

        except Exception as e:
            logger.error(f"Error in get_full_conversation: {e}")
            return ToolResult(
                success=False,
                data=None,
                error=str(e)
            )



def get_tool_definitions() -> List[Dict[str, Any]]:
    """
    Get OpenAI function calling tool definitions

    Returns:
        List of tool definitions for OpenAI API
    """
    return [
        {
            "type": "function",
            "function": {
                "name": "search_memory",
                "description": "Search user conversation memory for relevant information. Use this to find specific details from past conversations.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "Natural language search query describing what information to find"
                        },
                    },
                    "required": ["query"]
                }
            }
        },
        {
            "type": "function",
            "function": {
                "name": "get_conversation_context",
                "description": "Get surrounding context for a specific conversation chunk. Use this when you need more context around a search result.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "chunk_id": {
                            "type": "string",
                            "description": "The chunk ID to get context for"
                        },
                        "context_size": {
                            "type": "integer",
                            "description": "Number of chunks before/after to include (default: 2)",
                            "default": 2
                        }
                    },
                    "required": ["chunk_id"]
                }
            }
        },
        {
            "type": "function",
            "function": {
                "name": "get_full_conversation",
                "description": "Retrieve all chunks from a specific conversation. Use this when you need to review an entire conversation history.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "conversation_id": {
                            "type": "string",
                            "description": "The conversation identifier"
                        },
                        "test_id": {
                            "type": "string",
                            "description": "The test case identifier"
                        }
                    },
                    "required": ["conversation_id", "test_id"]
                }
            }
        }
    ]

verify_test_loading.py

#!/usr/bin/env python3
"""Verify test case loading with correct field mapping"""

import os
import sys
from pathlib import Path

# Set dummy API key
os.environ["KIMI_API_KEY"] = os.getenv("KIMI_API_KEY", "sk-test")
os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY", "sk-test")

from config import Config
from evaluator import UserMemoryEvaluator

# Initialize evaluator
config = Config.from_env()
evaluator = UserMemoryEvaluator(config)

# Load test cases
test_cases = evaluator.load_test_cases(category="layer1")

if test_cases:
    print(f"\nLoaded {len(test_cases)} test cases")

    # Check first test case
    test_id = test_cases[0]
    test_case = evaluator.test_cases[test_id]

    print(f"\nTest Case: {test_id}")
    print(f"  Title: {test_case.title}")
    print(f"  Category: {test_case.category}")
    print(f"  User Question: {test_case.user_question}")
    print(f"  Evaluation Criteria: {test_case.evaluation_criteria[:200]}..." if test_case.evaluation_criteria else "  Evaluation Criteria: None")
    print(f"  Expected Behavior: {test_case.expected_behavior[:100]}..." if test_case.expected_behavior else "  Expected Behavior: None")
    print(f"  Conversations: {len(test_case.conversation_histories)}")

    # Check conversation history structure
    if test_case.conversation_histories:
        conv = test_case.conversation_histories[0]
        print(f"\nFirst Conversation:")
        print(f"  ID: {conv.get('conversation_id', 'N/A')}")
        print(f"  Timestamp: {conv.get('timestamp', 'N/A')}")
        print(f"  Messages: {len(conv.get('messages', []))}")
        print(f"  Has metadata: {bool(conv.get('metadata', {}))}")

    print("\n✓ Test case structure is correct")
else:
    print("✗ No test cases loaded")

memory_qa_eval.json

{
  "description": "受控的用户记忆问答对比集(实验 3-12)。用于离线量化『在嵌入/索引前对记忆块做上下文化』相较『直接索引原始对话块』在召回用户事实上的提升。每个 chunk 同时给出 text(原始对话块)与 context(上下文前缀,代表由 LLM 生成的『情境锚定』摘要)。gold 为该 query 对应的正确记忆块 id。数据集刻意包含若干『孤立片段』(如『好的,就订这个吧』),它们脱离上下文后几乎不含可检索信号——这正是本章要说明的痛点。",
  "note": "本文件为教学用受控数据集;生产环境的 context 由 LLM 逐块生成、并用神经嵌入检索(需 API)。此处以确定性的 BM25 词法检索作为无需 API 的离线代理,度量的是同一份 context 分别拼接前后对召回的影响。",
  "chunks": [
    {
      "id": "flight_detail",
      "scenario": "flight",
      "text": "用户:帮我看看从上海到西雅图的单程航班。\n助手:为您找到达美航空 DL588,10 月 12 日出发,单程含税 500 美元。",
      "context": "本段来自用户与旅行助手 2024 年 10 月的机票预订对话,讨论上海飞西雅图的单程航班:达美 DL588,含税 500 美元。"
    },
    {
      "id": "flight_confirm",
      "scenario": "flight",
      "text": "用户:好的,就订这个吧。\n助手:已为您预订,稍后发送电子行程单。",
      "context": "本段承接上文,用户确认预订那张 500 美元、上海飞西雅图的达美 DL588 单程机票。"
    },
    {
      "id": "hotel_detail",
      "scenario": "hotel",
      "text": "用户:西雅图市中心有什么酒店推荐?\n助手:推荐凯悦酒店,10 月 12 日至 15 日三晚共 840 美元。",
      "context": "本段来自同一次西雅图行程的酒店预订讨论:凯悦酒店,10 月 12-15 日三晚 840 美元。"
    },
    {
      "id": "hotel_confirm",
      "scenario": "hotel",
      "text": "用户:可以,帮我订下来。\n助手:好的,已锁定房间。",
      "context": "本段承接上文,用户确认预订西雅图凯悦酒店三晚(840 美元)的房间。"
    },
    {
      "id": "bank_account",
      "scenario": "bank",
      "text": "助手:您的新支票账户已开通。\n用户:号码是多少?\n助手:账号是 4429853327,路由号 123006800。",
      "context": "本段来自用户在第一国民银行开设支票账户的通话,客服告知支票账号 4429853327 与路由号 123006800。"
    },
    {
      "id": "bank_confirm",
      "scenario": "bank",
      "text": "用户:记下了,就用这个。\n助手:好的,已为您设置为默认收款账户。",
      "context": "本段承接上文,用户确认使用第一国民银行支票账户 4429853327 作为默认收款账户。"
    },
    {
      "id": "passport",
      "scenario": "travel_doc",
      "text": "用户:顺便问下,我护照还有效吧?\n助手:您的护照 2025 年 2 月 18 日到期,出行前请留意。",
      "context": "本段提到用户 Jessica 的护照将于 2025 年 2 月 18 日过期,与其一月的东京出行相关。"
    },
    {
      "id": "medication",
      "scenario": "medical",
      "text": "医生:这次给你把剂量调整一下。\n用户:好的,听你的。",
      "context": "本段来自复诊对话,医生将用户的二甲双胍剂量由每日 500 毫克上调至每日 1000 毫克。"
    },
    {
      "id": "restaurant_pref",
      "scenario": "preference",
      "text": "用户:我不吃香菜,也对花生过敏。\n助手:已记录您的饮食禁忌。",
      "context": "本段记录用户的饮食偏好与过敏信息:不吃香菜,对花生过敏。"
    },
    {
      "id": "gym_distractor",
      "scenario": "gym",
      "text": "用户:我想办张健身卡。\n助手:我们有月卡和年卡两种,年卡 1200 元。",
      "context": "本段来自健身房会籍咨询,年卡 1200 元。"
    },
    {
      "id": "car_distractor",
      "scenario": "car",
      "text": "用户:租车大概多少钱一天?\n助手:经济型每天 45 美元,含基础保险。",
      "context": "本段来自租车询价,经济型每天 45 美元。"
    },
    {
      "id": "insurance_distractor",
      "scenario": "insurance",
      "text": "用户:我的车险什么时候续?\n助手:您的保单 12 月 1 日到期,可提前一个月续保。",
      "context": "本段来自车险咨询,保单 12 月 1 日到期。"
    }
  ],
  "queries": [
    {"q": "我最后确认预订的那张上海到西雅图的机票是哪个航班?", "gold": "flight_confirm"},
    {"q": "我订的西雅图酒店确认了吗,是哪家?", "gold": "hotel_confirm"},
    {"q": "我设为默认收款的银行支票账户是哪个?", "gold": "bank_confirm"},
    {"q": "从上海到西雅图那趟航班的票价是多少?", "gold": "flight_detail"},
    {"q": "我的护照什么时候过期?", "gold": "passport"},
    {"q": "医生把我的二甲双胍剂量调成多少了?", "gold": "medication"},
    {"q": "我有哪些饮食过敏和忌口?", "gold": "restaurant_pref"},
    {"q": "西雅图凯悦酒店三晚一共多少钱?", "gold": "hotel_detail"}
  ]
}