跳转至

agentic-rag

第3章 · 用户记忆和知识库 · 配套项目 chapter3/agentic-rag

项目说明

Agentic RAG System

An educational implementation of an Agentic Retrieval-Augmented Generation (RAG) system with ReAct pattern, supporting multiple LLM providers and knowledge base backends.

🌟 Features

  • Agentic RAG with ReAct Pattern: Uses reasoning and tool-calling to iteratively search and retrieve information
  • Non-Agentic RAG Mode: Simple retrieval + LLM response for comparison
  • Multiple LLM Provider Support:
  • Kimi/Moonshot
  • Doubao
  • SiliconFlow
  • OpenAI
  • OpenRouter
  • Groq
  • Together AI
  • DeepSeek
  • Flexible Knowledge Base:
  • Offline BM25 (内置,零依赖离线运行): in-process BM25 over the bundled laws/ corpus — no server, no API key required
  • Local retrieval pipeline (requires ../retrieval-pipeline)
  • Dify knowledge base API
  • Document Chunking: Configurable chunking with paragraph boundary respect
  • Evaluation Framework: Comprehensive evaluation with Chinese legal dataset
  • Conversation History: Support for follow-up questions
  • Verbose Logging: Detailed logging to understand agent reasoning

📦 Installation

pip install -r requirements.txt

⚙️ Configuration

Set environment variables in .env file:

# LLM API Keys (set the one you're using)
MOONSHOT_API_KEY=your_kimi_api_key
ARK_API_KEY=your_doubao_api_key
SILICONFLOW_API_KEY=your_siliconflow_api_key
OPENAI_API_KEY=your_openai_api_key
OPENROUTER_API_KEY=your_openrouter_api_key
GROQ_API_KEY=your_groq_api_key
TOGETHER_API_KEY=your_together_api_key
DEEPSEEK_API_KEY=your_deepseek_api_key

# Knowledge Base Configuration (optional, defaults to local)
KB_TYPE=local  # Options: "offline" (内置离线 BM25,无需服务/API), "local", "dify"
DIFY_API_KEY=your_dify_api_key  # if using Dify
DIFY_DATASET_ID=your_dataset_id  # optional

# LLM Configuration (optional)
LLM_PROVIDER=kimi  # default provider
LLM_MODEL=kimi-k3  # optional, uses provider defaults

🚀 Usage

0. 零依赖离线对比实验(推荐先跑,无需 API / 无需外部服务)

本实验的核心论点是:面对复杂问题,让 Agent 自主分解、多轮迭代检索,其证据召回显著优于单次检索compare_offline.py 用内置的离线 BM25 检索器(offline_retriever.py,直接读取 laws/ 语料) 在小型中文司法问答集上量化这一差距,完全离线、无需任何 API Key

python compare_offline.py
# 可选参数:--corpus laws  --top-k 5  --dataset evaluation/offline_qa.json  --output result.json

真实输出(本机实测,21372 个法条分块 / 288 篇文档):

问题                          难度    单次检索    分解检索    检索次数
------------------------------------------------------------------------------
故意伤害致人重伤的,如何处…  easy    100%        100%        1 → 1
正当防卫是怎么规定的?        easy    100%        100%        1 → 1
醉酒驾驶机动车如何处罚?      easy    100%        100%        1 → 1
故意杀人罪判几年?            hard    0%          100%        1 → 1
盗窃罪的立案标准是什么?      hard    0%          100%        1 → 1
诈骗罪的量刑标准是什么?      hard    0%          100%        1 → 1
醉酒过失致人重伤且有盗窃前…  hard    33%         100%        1 → 3
------------------------------------------------------------------------------
聚合指标(平均证据召回率):
  全部                                48%         100%        1.0 → 1.3
  简单题                              100%        100%        1.0 → 1.0
  复杂题                              8%          100%        1.0 → 1.5

解读(与书中实验 3-9 一致):简单问题两种范式相差无几(均 100%),一次直接检索就够; 复杂/措辞欠佳的问题上差距显著(8% → 100%),单次检索因关键词不精确而漏检关键法条, 分解式多轮检索则能逐一补齐证据。该指标为纯检索层的『证据召回率』,是回答质量的上界 ——检索不到证据,生成阶段无从谈起。金标准法条均已确认存在于 laws/ 语料中。

说明:离线模式用数据集中预先标注的 subqueries 表示『Agent 分解后发起的检索』, 以隔离出检索策略本身的贡献;在真实系统中,这些子查询由 LLM 在 ReAct 循环中动态生成。 需要 LLM 生成、端到端评测答案质量时,请使用 evaluation/evaluate.py(需配置 API Key)。

也可以让完整 Agent 直接跑在离线知识库上(检索离线,仅答案生成需要 API Key):

python main.py --kb-type offline --query "醉酒过失致人重伤且有盗窃前科如何量刑"
python main.py --kb-type offline --query "故意杀人罪判几年" --mode compare

1. Start the Retrieval Pipeline

First, start the retrieval pipeline server (required for local knowledge base):

# In a separate terminal
cd ../retrieval-pipeline
python main.py
# Server will run on http://localhost:4242

2. Index Documents

Option A: Index Chinese Law Documents (Pre-included)

# Index the included Chinese law documents
python index_local_laws.py

# With specific categories
python index_local_laws.py --categories 宪法 民法典

# With document limit
python index_local_laws.py --max-docs 10

Option B: Index Custom Documents

# Index a single file
python main.py --index path/to/document.txt

# Index a directory
python main.py --index path/to/documents/

# Custom chunk size
python main.py --index documents/ --chunk-size 2048

3. Run the Agentic RAG System

Interactive Mode (Default)

# Start in agentic mode (default)
python main.py

# Start in non-agentic mode  
python main.py --mode non-agentic

# Enable verbose logging (default is enabled)
python main.py --verbose

# Disable verbose logging
python main.py --no-verbose

In interactive mode: - Type your questions and press Enter - Type 'quit' or 'exit' to stop - Type 'clear' to clear conversation history - Type 'mode' to switch between agentic/non-agentic modes

Single Query

# Agentic mode
python main.py --query "宪法第一条是什么?" --mode agentic

# Non-agentic mode
python main.py --query "盗窃罪的立案标准是什么?" --mode non-agentic

# Compare both modes
python main.py --query "故意杀人罪判几年?" --mode compare

Batch Processing

# Create a file with queries (one per line)
echo "故意杀人罪判几年?
盗窃罪的立案标准是什么?
醉酒驾驶如何处罚?" > queries.txt

# Run batch
python main.py --batch queries.txt --output results.json

# Batch with specific mode
python main.py --batch queries.txt --mode non-agentic

With Different Providers

python main.py --provider openai --model gpt-5.6-luna
python main.py --provider doubao --model doubao-seed-1-6-thinking-250715
python main.py --provider siliconflow --query "你好"

4. Run Evaluation

# Build evaluation dataset
cd evaluation
python dataset_builder.py

# Run evaluation
python evaluate.py

# With specific configuration
python evaluate.py --provider kimi --kb-type local --output custom_results

📁 Project Structure

agentic-rag/
├── config.py              # Configuration classes
├── agent.py               # Main AgenticRAG implementation
├── tools.py               # Knowledge base tools (含 offline BM25 后端)
├── offline_retriever.py   # 内置离线 BM25 检索器(读取 laws/,无需服务/API)
├── compare_offline.py     # 离线对比实验:分解检索 vs 单次检索(证据召回率表)
├── chunking.py            # Document chunking and indexing
├── main.py                # Main entry point
├── index_local_laws.py    # Index Chinese law documents
├── quickstart.py          # Quick demo script
├── test_simple.py         # Simple test script
├── requirements.txt       # Dependencies
├── README.md              # This file
├── document_store.json    # Local document storage
├── laws/                  # Chinese law documents
│   ├── 1-宪法/
│   ├── 2-宪法相关法/
│   ├── 3-民法典/
│   ├── 3-民法商法/
│   ├── 4-行政法/
│   ├── 5-经济法/
│   ├── 6-社会法/
│   ├── 7-刑法/
│   └── 8-诉讼与非诉讼程序法/
└── evaluation/
    ├── dataset_builder.py # Build evaluation dataset
    ├── offline_qa.json    # 离线对比数据集(问题 + 金标准法条 + Agent 分解子查询)
    └── evaluate.py        # Evaluation framework (端到端答案质量,需 API)

🧠 How It Works

Agentic RAG Mode

The agent uses the ReAct (Reasoning + Acting) pattern:

  1. Reasoning: Analyzes what information is needed to answer the question
  2. Tool Calling: Uses knowledge_base_search tool to find relevant chunks
  3. Iterative Search: May perform multiple searches with refined queries
  4. Document Retrieval: Can fetch complete documents with get_document for context
  5. Answer Synthesis: Combines retrieved information with citations
  6. Conversation Memory: Maintains context for follow-up questions

Example flow:

User: 宪法第一条是什么?
Agent: [Thinks] Need to find information about Article 1 of the Constitution
       [Tool] knowledge_base_search("宪法第一条")
       [Result] Found relevant chunks about constitutional articles
       [Answer] Based on the retrieved information, Article 1 states...

Non-Agentic RAG Mode

Simple retrieval-augmented generation:

  1. Direct Search: Searches once with the user's query as-is
  2. Context Injection: Puts top-K results in the prompt
  3. Single Response: LLM answers based on provided context
  4. No Iteration: Single-shot approach without refinement

📊 Configuration Options

Top-K Results

Control how many search results to retrieve:

# In config.py or via environment
local_top_k = 3  # Number of results to retrieve

Verbose Mode

See detailed agent reasoning:

# Enable verbose (default)
python main.py --verbose

# Disable for cleaner output
python main.py --no-verbose

LLM Temperature

Control response randomness:

# In config.py
temperature = 0.7  # 0.0 = deterministic, 1.0 = more creative

🎯 Evaluation Results

检索层(离线、可复现、真实实测):见上文 第 0 节 的证据召回率表——分解式多轮检索把复杂题的召回率从 8% 提升到 100%,而简单题两种范式打平(均 100%)。

生成层(端到端答案质量,需 LLM API)evaluation/evaluate.py 在此基础上真正调用 LLM 生成答案, 统计关键词/分析点召回、引用覆盖率、响应时间等指标。以下为该框架产出的指标与典型模式:

Metrics

  • Success Rate: Whether the answer contains key legal concepts
  • Response Time: Time to generate response
  • Retrieval Quality: Relevance of retrieved chunks
  • Citation Coverage: Proper source attribution

Expected Patterns

Agentic RAG typically shows: - ✅ Better coverage of complex multi-faceted questions - ✅ More accurate citations through explicit tool use
- ✅ Ability to refine searches based on initial results - ⚠️ Slower response time (multiple LLM calls)

Non-Agentic RAG typically shows: - ✅ Faster responses (single retrieval step) - ✅ Good performance on simple, direct questions - ⚠️ May miss relevant information with poor query formulation - ⚠️ Limited ability to handle ambiguous queries

🔧 Troubleshooting

Retrieval Pipeline Not Responding

# Check if the service is running
curl http://localhost:4242/health

# If not, start it:
cd ../retrieval-pipeline
python main.py

No Search Results

  1. Ensure documents are indexed:

    python index_local_laws.py
    

  2. Check document store:

    ls -la document_store.json
    

  3. Verify retrieval pipeline has documents:

    curl http://localhost:4242/stats
    

API Key Issues

# Check if environment variable is set
echo $MOONSHOT_API_KEY

# Or use .env file
cat .env | grep API_KEY

Indexing Errors

  • Ensure retrieval pipeline is running before indexing
  • Check file encodings (UTF-8 expected)
  • Verify network connectivity to localhost:4242

🤝 Contributing

Areas for potential enhancement:

  • Additional evaluation metrics
  • More sophisticated chunking strategies
  • Better reranking algorithms
  • Additional knowledge base backends (RAPTOR, GraphRAG)
  • Multi-language support
  • Query expansion techniques
  • Hybrid retrieval strategies

📄 License

This is an educational project for learning purposes.

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.

源代码

agent.py

"""Agentic RAG System with 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, LLMConfig, AgentConfig
from tools import KnowledgeBaseTools, get_tool_definitions


def _is_reasoning_model(model) -> bool:
    """Whether the model is a reasoning model (Kimi K3, GPT-5, ...)."""
    m = str(model or "").lower().replace("/", "-")
    return "kimi-k3" in m or "gpt-5" in m


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."""
    return 1 if _is_reasoning_model(model) else requested


def _reasoning_safe_max_tokens(model, requested=1024, floor=4096):
    """Reasoning models spend part of their budget on hidden reasoning tokens,
    so a small ``max_tokens`` (e.g. 1024) silently truncates the visible answer.
    Ensure reasoning models get at least ``floor`` tokens; leave other providers
    at the requested value."""
    if _is_reasoning_model(model):
        return max(requested, floor)
    return 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())


class AgenticRAG:
    """Agentic RAG system with ReAct pattern and multiple LLM provider support"""

    def __init__(self, config: Optional[Config] = None):
        """Initialize the agent"""
        self.config = config or Config.from_env()

        # Initialize LLM client
        self._init_llm_client()

        # Initialize knowledge base tools
        self.kb_tools = KnowledgeBaseTools(self.config.knowledge_base)

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

        # Tool definitions
        self.tools = get_tool_definitions()

        logger.info(f"Initialized AgenticRAG 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) -> str:
        """Generate the system prompt"""
        return """You are an intelligent assistant with access to a knowledge base. Your primary role is to answer questions accurately based on the information available in the knowledge base.

## Important Guidelines:

1. **Knowledge Base Only**: You MUST only answer questions based on information found in the knowledge base. If the information is not available, clearly state that you cannot answer based on the available knowledge.

2. **Use Tools Effectively**:
   - Use `knowledge_base_search` to search for relevant information
   - Use `get_document` to retrieve complete documents when you need more context
   - You may need multiple searches with different queries to fully answer a question

3. **Citations Required**: Always include citations in your answers. Format citations as [Doc: document_id] or [Chunk: chunk_id] inline with your response.

4. **Reasoning Process**: Think step-by-step:
   - First, understand what information is needed
   - Search for relevant information
   - If needed, retrieve full documents for context
   - Synthesize the information to answer the question
   - Include proper citations

5. **Handle Follow-ups**: For follow-up questions, consider the conversation context but always verify information from the knowledge base.

6. **Be Accurate**: Never make up information. If something is unclear or not found, say so explicitly.

Remember: Your credibility depends on providing accurate, well-cited information from the knowledge base only."""

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

                # Log full trajectory when verbose
                if self.config.agent.verbose:
                    logger.info("=" * 80)
                    logger.info(f"TOOL EXECUTION: {tool_name}")
                    logger.info("-" * 80)
                    logger.info(f"Query: {query}")
                    logger.info("-" * 80)

                if not results:
                    if self.config.agent.verbose:
                        logger.info("Results: No relevant documents found")
                        logger.info("=" * 80)
                    return {"status": "no_results", "message": "No relevant documents found"}

                # Format results for agent - KEEP ALL RESULTS
                formatted_results = []
                for i, r in enumerate(results, 1):
                    formatted_results.append({
                        "doc_id": r["doc_id"],
                        "chunk_id": r["chunk_id"],
                        "text": r["text"],
                        "score": r["score"]
                    })

                    # Log each result in full detail
                    if self.config.agent.verbose:
                        logger.info(f"Result {i}/{len(results)}:")
                        logger.info(f"  Document ID: {r['doc_id']}")
                        logger.info(f"  Chunk ID: {r['chunk_id']}")
                        logger.info(f"  Score: {r['score']:.4f}")
                        logger.info(f"  Text (full):\n{'-' * 40}")
                        logger.info(r['text'])
                        logger.info("-" * 40)

                if self.config.agent.verbose:
                    logger.info(f"Total results found: {len(results)}")
                    logger.info("=" * 80)

                return {
                    "status": "success",
                    "results": formatted_results[:3],  # Limit to top 3 for LLM context
                    "total_found": len(results),
                    "all_results": formatted_results  # Keep all for logging
                }

            elif tool_name == "get_document":
                doc_id = arguments.get("doc_id", "")

                # Log full trajectory when verbose
                if self.config.agent.verbose:
                    logger.info("=" * 80)
                    logger.info(f"TOOL EXECUTION: {tool_name}")
                    logger.info("-" * 80)
                    logger.info(f"Document ID: {doc_id}")
                    logger.info("-" * 80)

                document = self.kb_tools.get_document(doc_id)

                if "error" in document:
                    if self.config.agent.verbose:
                        logger.info(f"Error: {document['error']}")
                        logger.info("=" * 80)
                    return {"status": "error", "message": document["error"]}

                # Log full document content
                if self.config.agent.verbose:
                    logger.info("Document Retrieved:")
                    logger.info(f"  Doc ID: {document.get('doc_id', doc_id)}")
                    if document.get('metadata'):
                        logger.info(f"  Metadata: {json.dumps(document['metadata'], indent=2, ensure_ascii=False)}")
                    logger.info("  Content (full):\n" + "=" * 40)
                    logger.info(document.get('content', ''))
                    logger.info("=" * 80)

                return {
                    "status": "success",
                    "document": {
                        "doc_id": document.get("doc_id", doc_id),
                        "content": document.get("content", ""),
                        "metadata": document.get("metadata", {})
                    }
                }

            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 _build_messages(self, user_query: str) -> List[Dict[str, Any]]:
        """Build messages for the LLM including conversation history"""
        messages = [{"role": "system", "content": self._get_system_prompt()}]

        # Add conversation history (limited)
        history_limit = self.config.agent.conversation_history_limit
        if len(self.conversation_history) > history_limit:
            messages.extend(self.conversation_history[-history_limit:])
        else:
            messages.extend(self.conversation_history)

        # Add current user query
        messages.append({"role": "user", "content": user_query})

        return messages

    def query(self, user_query: str, stream: bool = None) -> Any:
        """
        Process a user query using the ReAct pattern.

        Args:
            user_query: The user's question
            stream: Whether to stream the response

        Returns:
            The agent's response (string or generator for streaming)
        """
        if stream is None:
            stream = self.config.llm.stream

        # Build messages
        messages = self._build_messages(user_query)

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

        # Process with ReAct loop
        while iterations < max_iterations:
            iterations += 1

            if self.config.agent.verbose:
                logger.info("\n" + "=" * 100)
                logger.info(f"ITERATION {iterations}/{max_iterations}")
                logger.info("=" * 100)

            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=_reasoning_safe_max_tokens(self.model, self.config.llm.max_tokens),
                    stream=False  # We handle streaming separately
                )

                message = response.choices[0].message

                # 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
                    ]
                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 = {}

                        if self.config.agent.verbose:
                            logger.info("\n" + "#" * 80)
                            logger.info(f"TOOL CALL: {tool_name}")
                            logger.info(f"Arguments: {json.dumps(arguments, indent=2, ensure_ascii=False)}")
                            logger.info("#" * 80)

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

                        # Log full tool result when verbose
                        if self.config.agent.verbose:
                            logger.info("\n" + "*" * 80)
                            logger.info("TOOL RESULT:")
                            logger.info("*" * 80)
                            # Show full result including all_results if present
                            if 'all_results' in result:
                                logger.info("All Search Results (Complete):")
                                for idx, res in enumerate(result['all_results'], 1):
                                    logger.info(f"\nResult {idx}:")
                                    logger.info(json.dumps(res, indent=2, ensure_ascii=False))
                            else:
                                logger.info(json.dumps(result, indent=2, ensure_ascii=False))
                            logger.info("*" * 80 + "\n")

                        # For messages, don't include all_results to avoid overloading LLM
                        result_for_llm = {k: v for k, v in result.items() if k != 'all_results'}

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

                    # Continue loop for next iteration
                    continue
                else:
                    # No tool calls, we have final answer
                    # Update conversation history
                    self.conversation_history.append({"role": "user", "content": user_query})
                    self.conversation_history.append(assistant_msg)

                    # Return response
                    if stream:
                        return self._stream_response(message.content or "")
                    else:
                        return message.content or ""

            except Exception as e:
                logger.error(f"Error in query processing: {e}")
                error_msg = f"Error processing query: {str(e)}"
                if stream:
                    return self._stream_response(error_msg)
                else:
                    return error_msg

        # Max iterations reached
        logger.warning(f"Max iterations ({max_iterations}) reached")
        final_msg = "I need more iterations to fully answer your question. Please try rephrasing or breaking down your query."

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

    def _stream_response(self, content: str) -> Generator[str, None, None]:
        """Stream response content"""
        # Simple character streaming for demonstration
        for char in content:
            yield char

    def query_non_agentic(self, user_query: str, stream: bool = None) -> Any:
        """
        Non-agentic RAG mode: Simple retrieval + LLM response.

        Args:
            user_query: The user's question
            stream: Whether to stream the response

        Returns:
            The response (string or generator for streaming)
        """
        if stream is None:
            stream = self.config.llm.stream

        try:
            # Simple retrieval
            search_results = self.kb_tools.knowledge_base_search(user_query)

            # Build context from search results
            context_parts = []
            for i, result in enumerate(search_results[:3], 1):  # Top 3 results
                context_parts.append(
                    f"[Document {i}] (ID: {result['doc_id']}, Chunk: {result['chunk_id']})\n{result['text']}\n"
                )

            if not context_parts:
                context = "No relevant information found in the knowledge base."
            else:
                context = "\n".join(context_parts)

            # Build prompt
            system_prompt = """You are an assistant that answers questions based on provided context from a knowledge base.

IMPORTANT RULES:
1. Only answer based on the provided context
2. Include citations in format [Doc: document_id] 
3. If the context doesn't contain the answer, say so clearly
4. Be accurate and don't make up information"""

            user_prompt = f"""Context from knowledge base:
{context}

User Question: {user_query}

Please answer the question based only on the provided context. Include citations."""

            # Call LLM
            messages = [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ]

            if stream:
                response_stream = self.client.chat.completions.create(
                    model=self.model,
                    messages=messages,
                    temperature=_reasoning_safe_temperature(self.model, self.config.llm.temperature),
                    max_tokens=_reasoning_safe_max_tokens(self.model, self.config.llm.max_tokens),
                    stream=True
                )

                def response_generator():
                    for chunk in response_stream:
                        if chunk.choices[0].delta.content:
                            yield chunk.choices[0].delta.content

                return response_generator()
            else:
                response = self.client.chat.completions.create(
                    model=self.model,
                    messages=messages,
                    temperature=_reasoning_safe_temperature(self.model, self.config.llm.temperature),
                    max_tokens=_reasoning_safe_max_tokens(self.model, self.config.llm.max_tokens),
                    stream=False
                )
                return response.choices[0].message.content

        except Exception as e:
            logger.error(f"Error in non-agentic query: {e}")
            error_msg = f"Error processing query: {str(e)}"
            if stream:
                return self._stream_response(error_msg)
            else:
                return error_msg

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

chunking.py

"""Document chunking and indexing script"""

import os
import json
import hashlib
import logging
import requests
from typing import List, Dict, Any, Optional, Tuple
from pathlib import Path
from datetime import datetime
from config import ChunkingConfig, KnowledgeBaseConfig, KnowledgeBaseType


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


class DocumentChunker:
    """Document chunking with configurable strategies"""

    def __init__(self, config: Optional[ChunkingConfig] = None):
        self.config = config or ChunkingConfig()

    def chunk_text(self, text: str, doc_id: str) -> List[Dict[str, Any]]:
        """
        Chunk text into smaller segments.

        Args:
            text: Document text to chunk
            doc_id: Document identifier

        Returns:
            List of chunks with metadata
        """
        chunks = []

        if self.config.respect_paragraph_boundary:
            chunks = self._chunk_by_paragraphs(text, doc_id)
        else:
            chunks = self._chunk_by_size(text, doc_id)

        logger.info(f"Created {len(chunks)} chunks for document {doc_id}")
        return chunks

    def _chunk_by_paragraphs(self, text: str, doc_id: str) -> List[Dict[str, Any]]:
        """Chunk text respecting paragraph boundaries"""
        paragraphs = text.split('\n\n')
        chunks = []
        current_chunk = []
        current_size = 0

        for para in paragraphs:
            para = para.strip()
            if not para:
                continue

            para_size = len(para)

            # If single paragraph exceeds max size, split it
            if para_size > self.config.max_chunk_size:
                # Save current chunk if exists
                if current_chunk:
                    chunk_text = '\n\n'.join(current_chunk)
                    chunks.append(self._create_chunk(chunk_text, doc_id, len(chunks)))
                    current_chunk = []
                    current_size = 0

                # Split large paragraph
                sentences = self._split_into_sentences(para)
                for sent in sentences:
                    if len(sent) > self.config.max_chunk_size:
                        # Force split very long sentences
                        for i in range(0, len(sent), self.config.chunk_size):
                            sub_chunk = sent[i:i + self.config.chunk_size]
                            chunks.append(self._create_chunk(sub_chunk, doc_id, len(chunks)))
                    else:
                        chunks.append(self._create_chunk(sent, doc_id, len(chunks)))
                continue

            # Check if adding this paragraph exceeds chunk size
            if current_size + para_size > self.config.chunk_size and current_chunk:
                # Save current chunk
                chunk_text = '\n\n'.join(current_chunk)
                chunks.append(self._create_chunk(chunk_text, doc_id, len(chunks)))

                # Start new chunk with overlap
                if self.config.chunk_overlap > 0 and current_chunk:
                    # Keep last paragraph for overlap
                    current_chunk = [current_chunk[-1], para]
                    current_size = len(current_chunk[0]) + para_size
                else:
                    current_chunk = [para]
                    current_size = para_size
            else:
                current_chunk.append(para)
                current_size += para_size

        # Save final chunk
        if current_chunk:
            chunk_text = '\n\n'.join(current_chunk)
            if len(chunk_text) >= self.config.min_chunk_size:
                chunks.append(self._create_chunk(chunk_text, doc_id, len(chunks)))

        return chunks

    def _chunk_by_size(self, text: str, doc_id: str) -> List[Dict[str, Any]]:
        """Simple size-based chunking"""
        chunks = []

        for i in range(0, len(text), self.config.chunk_size - self.config.chunk_overlap):
            chunk_text = text[i:i + self.config.chunk_size]

            if len(chunk_text) >= self.config.min_chunk_size:
                chunks.append(self._create_chunk(chunk_text, doc_id, len(chunks)))

        return chunks

    def _split_into_sentences(self, text: str) -> List[str]:
        """Split text into sentences (simple implementation)"""
        # Simple sentence splitting for Chinese and English
        import re

        # Split on common sentence endings
        sentences = re.split(r'([。!?\.!?]+)', text)

        # Reconstruct sentences with their endings
        result = []
        for i in range(0, len(sentences) - 1, 2):
            if i + 1 < len(sentences):
                result.append(sentences[i] + sentences[i + 1])
            else:
                result.append(sentences[i])

        return [s.strip() for s in result if s.strip()]

    def _create_chunk(self, text: str, doc_id: str, chunk_index: int) -> Dict[str, Any]:
        """Create a chunk with metadata"""
        chunk_id = f"{doc_id}_chunk_{chunk_index}"

        return {
            "chunk_id": chunk_id,
            "doc_id": doc_id,
            "text": text,
            "chunk_index": chunk_index,
            "char_count": len(text),
            "hash": hashlib.md5(text.encode()).hexdigest()
        }


class DocumentIndexer:
    """Index documents to knowledge base"""

    def __init__(self, 
                 kb_config: Optional[KnowledgeBaseConfig] = None,
                 chunking_config: Optional[ChunkingConfig] = None):
        self.kb_config = kb_config or KnowledgeBaseConfig()
        self.chunker = DocumentChunker(chunking_config)
        self.indexed_docs = {}

    def index_file(self, file_path: str, doc_id: Optional[str] = None) -> Dict[str, Any]:
        """
        Index a single file.

        Args:
            file_path: Path to the file
            doc_id: Optional document ID

        Returns:
            Indexing result
        """
        file_path = Path(file_path)
        if not file_path.exists():
            return {"error": f"File not found: {file_path}"}

        # Generate doc_id if not provided
        if not doc_id:
            doc_id = file_path.stem

        # Read file content
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                content = f.read()
        except Exception as e:
            return {"error": f"Error reading file: {e}"}

        # Chunk the document
        chunks = self.chunker.chunk_text(content, doc_id)

        # Index chunks
        result = self._index_chunks(chunks, doc_id, content)

        # Store full document
        self._store_document(doc_id, content, {"source_file": str(file_path)})

        return result

    def index_directory(self, dir_path: str, extensions: List[str] = None) -> Dict[str, Any]:
        """
        Index all files in a directory.

        Args:
            dir_path: Directory path
            extensions: File extensions to include (e.g., ['.txt', '.md'])

        Returns:
            Indexing results
        """
        dir_path = Path(dir_path)
        if not dir_path.exists():
            return {"error": f"Directory not found: {dir_path}"}

        extensions = extensions or ['.txt', '.md', '.json']
        results = {"indexed": [], "errors": []}

        for file_path in dir_path.rglob('*'):
            if file_path.is_file() and file_path.suffix in extensions:
                doc_id = f"{file_path.parent.name}/{file_path.stem}"
                result = self.index_file(str(file_path), doc_id)

                if "error" in result:
                    results["errors"].append({
                        "file": str(file_path),
                        "error": result["error"]
                    })
                else:
                    results["indexed"].append({
                        "file": str(file_path),
                        "doc_id": doc_id,
                        "chunks": result.get("chunks_indexed", 0)
                    })

        logger.info(f"Indexed {len(results['indexed'])} files, {len(results['errors'])} errors")
        return results

    def _index_chunks(self, chunks: List[Dict[str, Any]], doc_id: str, full_content: str) -> Dict[str, Any]:
        """Index chunks to the knowledge base"""
        if self.kb_config.type == KnowledgeBaseType.LOCAL:
            return self._index_to_local(chunks, doc_id)
        elif self.kb_config.type == KnowledgeBaseType.DIFY:
            return self._index_to_dify(chunks, doc_id, full_content)
        else:
            return {"error": f"Unsupported KB type: {self.kb_config.type}"}

    def _index_to_local(self, chunks: List[Dict[str, Any]], doc_id: str) -> Dict[str, Any]:
        """Index to local retrieval pipeline"""
        indexed_count = 0
        errors = []

        for chunk in chunks:
            try:
                # Index each chunk
                response = requests.post(
                    f"{self.kb_config.local_base_url}/index",
                    json={
                        "text": chunk["text"],
                        "doc_id": chunk["doc_id"],
                        "metadata": {
                            "chunk_id": chunk["chunk_id"],
                            "chunk_index": chunk["chunk_index"],
                            "char_count": chunk["char_count"]
                        }
                    }
                )
                response.raise_for_status()
                indexed_count += 1

            except Exception as e:
                errors.append(f"Error indexing chunk {chunk['chunk_id']}: {e}")

        result = {
            "doc_id": doc_id,
            "chunks_indexed": indexed_count,
            "total_chunks": len(chunks)
        }

        if errors:
            result["errors"] = errors

        return result

    def _index_to_dify(self, chunks: List[Dict[str, Any]], doc_id: str, full_content: str) -> Dict[str, Any]:
        """Index to Dify knowledge base"""
        if not self.kb_config.dify_api_key:
            return {"error": "Dify API key not configured"}

        try:
            headers = {
                "Authorization": f"Bearer {self.kb_config.dify_api_key}",
                "Content-Type": "application/json"
            }

            # Dify expects documents, not individual chunks
            # So we'll create segments from our chunks
            segments = []
            for chunk in chunks:
                segments.append({
                    "content": chunk["text"],
                    "keywords": [],  # Can add keywords if needed
                    "enabled": True
                })

            payload = {
                "name": doc_id,
                "text": full_content,
                "indexing_technique": "high_quality",  # or "economy"
                "process_rule": {
                    "mode": "custom",
                    "rules": {
                        "pre_processing_rules": [],
                        "segmentation": {
                            "separator": "\n\n",
                            "max_tokens": self.chunker.config.chunk_size // 4  # Rough token estimate
                        }
                    }
                }
            }

            if self.kb_config.dify_dataset_id:
                # Add to existing dataset
                response = requests.post(
                    f"{self.kb_config.dify_base_url}/datasets/{self.kb_config.dify_dataset_id}/documents",
                    headers=headers,
                    json=payload
                )
            else:
                # Create new document
                response = requests.post(
                    f"{self.kb_config.dify_base_url}/documents",
                    headers=headers,
                    json=payload
                )

            response.raise_for_status()

            return {
                "doc_id": doc_id,
                "chunks_indexed": len(chunks),
                "total_chunks": len(chunks),
                "dify_response": response.json()
            }

        except Exception as e:
            return {"error": f"Error indexing to Dify: {e}"}

    def _store_document(self, doc_id: str, content: str, metadata: Dict[str, Any]):
        """Store full document locally"""
        # Store in local file for retrieval
        store_path = self.kb_config.document_store_path

        try:
            # Load existing store
            if os.path.exists(store_path):
                with open(store_path, 'r', encoding='utf-8') as f:
                    store = json.load(f)
            else:
                store = {}

            # Add document
            store[doc_id] = {
                "doc_id": doc_id,
                "content": content,
                "metadata": metadata,
                "indexed_at": datetime.now().isoformat()
            }

            # Save store
            with open(store_path, 'w', encoding='utf-8') as f:
                json.dump(store, f, ensure_ascii=False, indent=2)

            self.indexed_docs[doc_id] = True
            logger.info(f"Stored document {doc_id}")

        except Exception as e:
            logger.error(f"Error storing document: {e}")


def main():
    """Main function for standalone chunking and indexing"""
    import argparse
    from config import Config

    parser = argparse.ArgumentParser(description="Chunk and index documents")
    parser.add_argument("path", help="File or directory path to index")
    parser.add_argument("--chunk-size", type=int, default=2048, help="Chunk size in characters")
    parser.add_argument("--max-chunk-size", type=int, default=1024, help="Max chunk size")
    parser.add_argument("--overlap", type=int, default=200, help="Chunk overlap")
    parser.add_argument("--kb-type", choices=["local", "dify"], default="local", help="Knowledge base type")
    parser.add_argument("--extensions", nargs="+", default=[".txt", ".md"], help="File extensions to index")

    args = parser.parse_args()

    # Create config
    config = Config.from_env()
    config.chunking.chunk_size = args.chunk_size
    config.chunking.max_chunk_size = args.max_chunk_size
    config.chunking.chunk_overlap = args.overlap
    config.knowledge_base.type = KnowledgeBaseType(args.kb_type)

    # Create indexer
    indexer = DocumentIndexer(config.knowledge_base, config.chunking)

    # Index path
    path = Path(args.path)
    if path.is_file():
        result = indexer.index_file(str(path))
    elif path.is_dir():
        result = indexer.index_directory(str(path), args.extensions)
    else:
        print(f"Path not found: {path}")
        return

    # Print results
    print(json.dumps(result, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()

compare_offline.py

"""离线对比实验:智能体化 RAG(多轮/分解检索)vs 非智能体化 RAG(单次检索)。

本脚本**完全离线运行**——只做检索、不调用任何 LLM、不依赖外部检索服务,
因此无需 API Key 即可复现。它在一个小型中文司法问答集(evaluation/offline_qa.json)
上,量化对比两种检索范式的『证据召回率』:

  - 非智能体化:把用户原始问题作为唯一查询做一次检索(single-shot);
  - 智能体化:模拟 Agent 分解/改写问题后发起多次检索,再对结果取并集。

金标准(gold_articles)为回答每个问题所必需的法条编号;某法条被判定为『命中』
当且仅当检索结果中存在一个以该法条编号开头的分块。证据召回率 = 命中金标准法条数
/ 金标准法条总数。这一检索层指标是回答质量的上界:检索不到证据,生成阶段就无从
谈起。生成阶段的端到端评测(需要 LLM API)见 evaluation/evaluate.py。
"""

import os
import re
import sys
import json
import time
import argparse
from typing import List, Dict, Any

sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from offline_retriever import OfflineRetriever, _ARTICLE_RE


def _leading_article(text: str) -> str:
    """抽取分块开头的法条编号(如『第二百三十五条』),无则返回空串。"""
    m = _ARTICLE_RE.match(text.strip())
    return m.group(0) if m else ""


def _covered(retrieved: List[Dict[str, Any]], gold_articles: List[str]) -> List[str]:
    """返回被检索结果命中的金标准法条列表。"""
    hit_markers = {_leading_article(r["text"]) for r in retrieved}
    hit_markers.discard("")
    return [g for g in gold_articles if g in hit_markers]


def run_case(retriever: OfflineRetriever, case: Dict[str, Any], top_k: int) -> Dict[str, Any]:
    gold = case["gold_articles"]

    # 非智能体化:单次检索,查询即用户原始问题。
    naive_query = case.get("naive_query", case["question"])
    naive_hits = retriever.search(naive_query, top_k)
    naive_covered = _covered(naive_hits, gold)

    # 智能体化:分解为多个子查询,逐一检索后取并集。
    subqueries = case.get("subqueries") or [case["question"]]
    agentic_hits: List[Dict[str, Any]] = []
    seen = set()
    for sq in subqueries:
        for r in retriever.search(sq, top_k):
            if r["chunk_id"] not in seen:
                seen.add(r["chunk_id"])
                agentic_hits.append(r)
    agentic_covered = _covered(agentic_hits, gold)

    return {
        "id": case["id"],
        "question": case["question"],
        "difficulty": case.get("difficulty", "unknown"),
        "gold_articles": gold,
        "naive": {
            "num_searches": 1,
            "covered": naive_covered,
            "recall": len(naive_covered) / len(gold) if gold else 0.0,
        },
        "agentic": {
            "num_searches": len(subqueries),
            "covered": agentic_covered,
            "recall": len(agentic_covered) / len(gold) if gold else 0.0,
        },
    }


def _mean(xs: List[float]) -> float:
    return sum(xs) / len(xs) if xs else 0.0


def _pad(text: str, width: int) -> str:
    """按显示宽度左对齐(一个中文字符按两个宽度计)。"""
    display = sum(2 if ord(c) > 127 else 1 for c in text)
    return text + " " * max(0, width - display)


def summarize(results: List[Dict[str, Any]]) -> Dict[str, Any]:
    def agg(subset):
        return {
            "count": len(subset),
            "naive_recall": _mean([r["naive"]["recall"] for r in subset]),
            "agentic_recall": _mean([r["agentic"]["recall"] for r in subset]),
            "naive_searches": _mean([r["naive"]["num_searches"] for r in subset]),
            "agentic_searches": _mean([r["agentic"]["num_searches"] for r in subset]),
        }

    summary = {"overall": agg(results)}
    for diff in ("easy", "hard"):
        subset = [r for r in results if r["difficulty"] == diff]
        if subset:
            summary[diff] = agg(subset)
    return summary


def print_table(results: List[Dict[str, Any]], summary: Dict[str, Any]):
    print("\n" + "=" * 78)
    print("离线检索对比:证据召回率(Evidence Recall)")
    print("=" * 78)
    print(_pad("问题", 30) + _pad("难度", 8) + _pad("单次检索", 12)
          + _pad("分解检索", 12) + "检索次数")
    print("-" * 78)
    for r in results:
        q = (r["question"][:13] + "…") if len(r["question"]) > 13 else r["question"]
        naive = f"{r['naive']['recall']:.0%}"
        agentic = f"{r['agentic']['recall']:.0%}"
        searches = f"1 → {r['agentic']['num_searches']}"
        print(_pad(q, 30) + _pad(r["difficulty"], 8) + _pad(naive, 12)
              + _pad(agentic, 12) + searches)
    print("-" * 78)

    def row(name, s):
        print(_pad(name, 30) + _pad("", 8) + _pad(f"{s['naive_recall']:.0%}", 12)
              + _pad(f"{s['agentic_recall']:.0%}", 12)
              + f"{s['naive_searches']:.1f}{s['agentic_searches']:.1f}")

    print("聚合指标(平均证据召回率):")
    row("  全部", summary["overall"])
    if "easy" in summary:
        row("  简单题", summary["easy"])
    if "hard" in summary:
        row("  复杂题", summary["hard"])
    print("=" * 78)
    ov = summary["overall"]
    lift = ov["agentic_recall"] - ov["naive_recall"]
    print(f"结论:分解式多轮检索将整体证据召回率从 {ov['naive_recall']:.0%} "
          f"提升到 {ov['agentic_recall']:.0%}(+{lift:.0%}),"
          f"代价是平均检索次数由 {ov['naive_searches']:.1f} 增至 {ov['agentic_searches']:.1f}。")
    if "hard" in summary:
        hv = summary["hard"]
        print(f"      复杂题上的差距最为显著:{hv['naive_recall']:.0%}{hv['agentic_recall']:.0%}。")
    print("=" * 78)


def main():
    parser = argparse.ArgumentParser(
        description="离线对比智能体化 RAG(多轮分解检索)与非智能体化 RAG(单次检索)的证据召回率;纯检索、无需 LLM 与外部服务。",
        formatter_class=argparse.RawTextHelpFormatter,
    )
    parser.add_argument("--dataset", type=str, default="evaluation/offline_qa.json",
                        help="离线问答数据集路径(默认:evaluation/offline_qa.json)")
    parser.add_argument("--corpus", type=str, default="laws",
                        help="法律语料目录,用于构建离线 BM25 索引(默认:laws)")
    parser.add_argument("--top-k", type=int, default=5,
                        help="每次检索返回的分块数量,即检索深度(默认:5)")
    parser.add_argument("--output", type=str, default=None,
                        help="将详细结果写入的 JSON 文件路径(默认:不落盘,仅打印)")
    args = parser.parse_args()

    print(f"[离线对比] 构建 BM25 索引,语料目录:{args.corpus} …")
    t0 = time.time()
    retriever = OfflineRetriever(args.corpus)
    print(f"[离线对比] 索引完成:{len(retriever.chunks)} 个法条分块 / "
          f"{len(retriever.documents)} 篇文档,用时 {time.time() - t0:.1f}s")

    with open(args.dataset, "r", encoding="utf-8") as f:
        dataset = json.load(f)
    cases = dataset["cases"]

    results = [run_case(retriever, c, args.top_k) for c in cases]
    summary = summarize(results)
    print_table(results, summary)

    if args.output:
        payload = {
            "dataset": args.dataset,
            "corpus": args.corpus,
            "top_k": args.top_k,
            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
            "results": results,
            "summary": summary,
        }
        with open(args.output, "w", encoding="utf-8") as f:
            json.dump(payload, f, ensure_ascii=False, indent=2)
        print(f"\n详细结果已保存至:{args.output}")


if __name__ == "__main__":
    main()

config.py

"""Configuration for Agentic RAG System"""

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


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 KnowledgeBaseType(str, Enum):
    """Knowledge base backend types"""
    OFFLINE = "offline"  # In-process BM25 over local law corpus (no server, no API)
    LOCAL = "local"  # Local retrieval pipeline
    DIFY = "dify"    # Dify knowledge base API
    RAPTOR = "raptor"  # RAPTOR tree-based index
    GRAPHRAG = "graphrag"  # GraphRAG graph-based index


@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 = 1024
    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"
        }
    }

    @classmethod
    def get_api_key(cls, provider: str) -> Optional[str]:
        """Get API key from environment"""
        env_mappings = {
            "siliconflow": "SILICONFLOW_API_KEY",
            "doubao": "ARK_API_KEY",
            "kimi": "MOONSHOT_API_KEY",
            "moonshot": "MOONSHOT_API_KEY",
            "openrouter": "OPENROUTER_API_KEY",
            "openai": "OPENAI_API_KEY",
            "groq": "GROQ_API_KEY",
            "together": "TOGETHER_API_KEY",
            "deepseek": "DEEPSEEK_API_KEY"
        }
        return os.getenv(env_mappings.get(provider.lower(), ""))

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

        # Get API key
        api_key = self.api_key or self.get_api_key(provider_lower)

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

        if not api_key:
            raise ValueError(
                f"API key required for provider '{provider_lower}'. Set the "
                f"provider's key (e.g. MOONSHOT_API_KEY / OPENAI_API_KEY) or "
                f"OPENROUTER_API_KEY to use the OpenRouter fallback."
            )

        # Build config
        config = {
            "api_key": api_key,
            "model": self.model or defaults.get("model")
        }

        # Add base_url if not OpenAI
        if "base_url" in defaults:
            config["base_url"] = defaults["base_url"]

        return config, config.pop("model")


@dataclass
class KnowledgeBaseConfig:
    """Knowledge base configuration"""
    type: KnowledgeBaseType = KnowledgeBaseType.LOCAL

    # Offline in-process BM25 backend config (no external server / no API key)
    offline_corpus_path: str = "laws"
    offline_top_k: int = 5

    # Local retrieval pipeline config
    local_base_url: str = "http://localhost:4242"
    local_top_k: int = 3

    # Dify config
    dify_api_key: Optional[str] = field(default_factory=lambda: os.getenv("DIFY_API_KEY"))
    dify_base_url: str = "https://api.dify.ai/v1"
    dify_dataset_id: Optional[str] = None
    dify_top_k: int = 3

    # RAPTOR tree-based index config
    raptor_base_url: str = "http://localhost:4242"
    raptor_top_k: int = 3
    raptor_search_levels: bool = True  # Search across multiple tree levels

    # GraphRAG graph-based index config
    graphrag_base_url: str = "http://localhost:4242"
    graphrag_top_k: int = 3
    graphrag_search_type: str = "hybrid"  # entity, community, or hybrid

    # Document storage
    document_store_path: str = "document_store.json"


@dataclass
class ChunkingConfig:
    """Document chunking configuration"""
    chunk_size: int = 2048  # Characters per chunk
    max_chunk_size: int = 1024  # Max size when respecting paragraph boundaries
    chunk_overlap: int = 200  # Overlap between chunks
    respect_paragraph_boundary: bool = True
    min_chunk_size: int = 100  # Minimum chunk size


@dataclass 
class AgentConfig:
    """Agent configuration"""
    max_iterations: int = 10  # Max reasoning iterations
    enable_reasoning_trace: bool = True
    enable_citations: bool = True
    strict_knowledge_base: bool = True  # Only answer from knowledge base
    conversation_history_limit: int = 20  # Max conversation turns to keep
    verbose: bool = True


@dataclass
class EvaluationConfig:
    """Evaluation configuration"""
    dataset_path: str = "evaluation/legal_qa_dataset.json"
    results_path: str = "evaluation/results"
    metrics: list = field(default_factory=lambda: ["accuracy", "relevance", "citation_quality"])


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

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

        # Override from env
        if provider := os.getenv("LLM_PROVIDER"):
            config.llm.provider = provider
        if model := os.getenv("LLM_MODEL"):
            config.llm.model = model
        if kb_type := os.getenv("KB_TYPE"):
            config.knowledge_base.type = KnowledgeBaseType(kb_type.lower())

        return config

evaluation/dataset_builder.py

"""Build evaluation dataset from Chinese legal documents"""

import json
import logging
from typing import List, Dict, Any
from pathlib import Path


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


class LegalDatasetBuilder:
    """Build evaluation dataset for Chinese legal Q&A"""

    def __init__(self):
        self.simple_cases = []
        self.complex_cases = []

    def create_simple_cases(self) -> List[Dict[str, Any]]:
        """Create simple direct legal questions"""
        simple_cases = [
            {
                "id": "simple_1",
                "question": "故意杀人罪判几年?",
                "expected_keywords": ["死刑", "无期徒刑", "十年以上有期徒刑"],
                "reference": "《中华人民共和国刑法》第二百三十二条",
                "difficulty": "easy"
            },
            {
                "id": "simple_2",
                "question": "盗窃罪的立案标准是什么?",
                "expected_keywords": ["一千元", "三千元", "数额较大"],
                "reference": "《中华人民共和国刑法》第二百六十四条",
                "difficulty": "easy"
            },
            {
                "id": "simple_3", 
                "question": "醉酒驾驶机动车如何处罚?",
                "expected_keywords": ["拘役", "罚金", "吊销驾照"],
                "reference": "《中华人民共和国刑法》第一百三十三条",
                "difficulty": "easy"
            },
            {
                "id": "simple_4",
                "question": "诈骗罪的量刑标准是什么?",
                "expected_keywords": ["三年以下", "三年以上十年以下", "十年以上"],
                "reference": "《中华人民共和国刑法》第二百六十六条",
                "difficulty": "easy"
            },
            {
                "id": "simple_5",
                "question": "故意伤害罪致人重伤的处罚是什么?",
                "expected_keywords": ["三年以上十年以下", "有期徒刑"],
                "reference": "《中华人民共和国刑法》第二百三十四条",
                "difficulty": "easy"
            },
            {
                "id": "simple_6",
                "question": "抢劫罪的加重情节有哪些?",
                "expected_keywords": ["入户抢劫", "多次抢劫", "抢劫数额巨大"],
                "reference": "《中华人民共和国刑法》第二百六十三条",
                "difficulty": "medium"
            },
            {
                "id": "simple_7",
                "question": "非法拘禁罪的构成要件是什么?",
                "expected_keywords": ["非法", "拘禁", "限制人身自由"],
                "reference": "《中华人民共和国刑法》第二百三十八条",
                "difficulty": "medium"
            },
            {
                "id": "simple_8",
                "question": "贪污罪的数额标准如何认定?",
                "expected_keywords": ["三万元", "二十万元", "三百万元"],
                "reference": "《中华人民共和国刑法》第三百八十三条",
                "difficulty": "medium"
            },
            {
                "id": "simple_9",
                "question": "交通肇事罪的立案标准是什么?",
                "expected_keywords": ["死亡一人", "重伤三人", "财产损失"],
                "reference": "《中华人民共和国刑法》第一百三十三条",
                "difficulty": "easy"
            },
            {
                "id": "simple_10",
                "question": "寻衅滋事罪如何处罚?",
                "expected_keywords": ["五年以下", "有期徒刑", "拘役", "管制"],
                "reference": "《中华人民共和国刑法》第二百九十三条",
                "difficulty": "easy"
            }
        ]

        return simple_cases

    def create_complex_cases(self) -> List[Dict[str, Any]]:
        """Create complex legal scenario questions"""
        complex_cases = [
            {
                "id": "complex_1",
                "question": """张某因与李某发生经济纠纷,持刀闯入李某家中,意图讨债。在争执过程中,张某用刀刺伤李某,
                            导致李某重伤。同时,张某还顺手拿走了李某家中的现金5万元。请问张某的行为应如何定性?
                            可能面临什么样的刑事处罚?""",
                "expected_analysis": ["入户抢劫", "故意伤害", "数罪并罚"],
                "reference": "《刑法》第二百三十四条、第二百六十三条",
                "difficulty": "hard",
                "requires_multi_query": True
            },
            {
                "id": "complex_2",
                "question": """王某系某国有企业财务主管,利用职务之便,通过虚开发票等手段,
                            将公司资金200万元转入其控制的账户。后王某用该资金进行股票投资,
                            获利50万元。案发后,王某主动退还全部赃款。请分析王某的法律责任。""",
                "expected_analysis": ["贪污罪", "挪用公款罪", "自首情节", "退赃"],
                "reference": "《刑法》第三百八十二条、第三百八十四条",
                "difficulty": "hard",
                "requires_multi_query": True
            },
            {
                "id": "complex_3",
                "question": """赵某酒后驾车,在市区超速行驶,撞倒正在过马路的行人陈某,
                            导致陈某当场死亡。赵某见状,驾车逃离现场。第二天,在家人劝说下,
                            赵某到公安机关投案自首。请问赵某涉嫌哪些犯罪?量刑时应考虑哪些因素?""",
                "expected_analysis": ["交通肇事罪", "危险驾驶罪", "逃逸", "自首"],
                "reference": "《刑法》第一百三十三条",
                "difficulty": "hard",
                "requires_multi_query": True
            },
            {
                "id": "complex_4",
                "question": """刘某通过网络平台发布虚假投资信息,声称可以保证高额回报,
                            先后骗取30名投资者共计500万元。其中,刘某将200万元用于个人挥霍,
                            300万元用于归还之前的债务。请问刘某的行为如何定性?可能的量刑是什么?""",
                "expected_analysis": ["诈骗罪", "数额特别巨大", "多人受害"],
                "reference": "《刑法》第二百六十六条",
                "difficulty": "hard",
                "requires_multi_query": True
            },
            {
                "id": "complex_5",
                "question": """孙某与钱某共谋盗窃某商场。孙某负责望风,钱某进入商场实施盗窃。
                            钱某在盗窃过程中被保安发现,为逃跑将保安打成轻伤。
                            最终二人盗窃财物价值8万元。请分析孙某和钱某各自的刑事责任。""",
                "expected_analysis": ["共同犯罪", "盗窃罪", "抢劫罪", "转化犯"],
                "reference": "《刑法》第二百六十四条、第二百六十九条",
                "difficulty": "hard",
                "requires_multi_query": True
            }
        ]

        return complex_cases

    def build_dataset(self, output_path: str = "legal_qa_dataset.json"):
        """Build and save the complete dataset"""
        dataset = {
            "simple_cases": self.create_simple_cases(),
            "complex_cases": self.create_complex_cases(),
            "metadata": {
                "total_cases": 15,
                "simple_count": 10,
                "complex_count": 5,
                "domain": "Chinese Criminal Law",
                "purpose": "Evaluate agentic vs non-agentic RAG performance"
            }
        }

        # Save dataset
        with open(output_path, 'w', encoding='utf-8') as f:
            json.dump(dataset, f, ensure_ascii=False, indent=2)

        logger.info(f"Dataset saved to {output_path}")
        return dataset


def create_legal_documents() -> List[Dict[str, str]]:
    """Create sample legal documents for the knowledge base"""
    documents = [
        {
            "doc_id": "criminal_law_homicide",
            "title": "刑法-故意杀人罪",
            "content": """第二百三十二条 【故意杀人罪】故意杀人的,处死刑、无期徒刑或者十年以上有期徒刑;
            情节较轻的,处三年以上十年以下有期徒刑。

            故意杀人罪是指故意非法剥夺他人生命的行为。该罪侵犯的客体是他人的生命权。
            法律依据是《中华人民共和国刑法》第二百三十二条。

            量刑标准:
            1. 情节严重的:死刑、无期徒刑或十年以上有期徒刑
            2. 情节较轻的:三年以上十年以下有期徒刑

            情节较轻通常包括:防卫过当、义愤杀人、被害人有过错等情形。"""
        },
        {
            "doc_id": "criminal_law_theft",
            "title": "刑法-盗窃罪",
            "content": """第二百六十四条 【盗窃罪】盗窃公私财物,数额较大的,或者多次盗窃、入户盗窃、
            携带凶器盗窃、扒窃的,处三年以下有期徒刑、拘役或者管制,并处或者单处罚金;
            数额巨大或者有其他严重情节的,处三年以上十年以下有期徒刑,并处罚金;
            数额特别巨大或者有其他特别严重情节的,处十年以上有期徒刑或者无期徒刑,并处罚金或者没收财产。

            盗窃罪的立案标准:
            1. 数额较大:一般为1000元至3000元以上
            2. 数额巨大:一般为3万元至10万元以上  
            3. 数额特别巨大:一般为30万元至50万元以上

            特殊情形:多次盗窃(2年内3次以上)、入户盗窃、携带凶器盗窃、扒窃的,
            不论数额大小,均构成盗窃罪。"""
        },
        {
            "doc_id": "criminal_law_fraud",
            "title": "刑法-诈骗罪",
            "content": """第二百六十六条 【诈骗罪】诈骗公私财物,数额较大的,处三年以下有期徒刑、
            拘役或者管制,并处或者单处罚金;数额巨大或者有其他严重情节的,
            处三年以上十年以下有期徒刑,并处罚金;数额特别巨大或者有其他特别严重情节的,
            处十年以上有期徒刑或者无期徒刑,并处罚金或者没收财产。

            诈骗罪的量刑标准:
            1. 数额较大(3千元至1万元以上):三年以下有期徒刑、拘役或者管制
            2. 数额巨大(3万元至10万元以上):三年以上十年以下有期徒刑
            3. 数额特别巨大(50万元以上):十年以上有期徒刑或者无期徒刑

            诈骗罪是指以非法占有为目的,用虚构事实或者隐瞒真相的方法,
            骗取数额较大的公私财物的行为。"""
        },
        {
            "doc_id": "criminal_law_robbery",
            "title": "刑法-抢劫罪",
            "content": """第二百六十三条 【抢劫罪】以暴力、胁迫或者其他方法抢劫公私财物的,
            处三年以上十年以下有期徒刑,并处罚金;有下列情形之一的,
            处十年以上有期徒刑、无期徒刑或者死刑,并处罚金或者没收财产:

            (一)入户抢劫的;
            (二)在公共交通工具上抢劫的;
            (三)抢劫银行或者其他金融机构的;
            (四)多次抢劫或者抢劫数额巨大的;
            (五)抢劫致人重伤、死亡的;
            (六)冒充军警人员抢劫的;
            (七)持枪抢劫的;
            (八)抢劫军用物资或者抢险、救灾、救济物资的。

            抢劫罪的加重处罚情节包括上述八种情形,有其中之一的,
            最低刑期为十年有期徒刑。"""
        },
        {
            "doc_id": "criminal_law_injury",
            "title": "刑法-故意伤害罪",
            "content": """第二百三十四条 【故意伤害罪】故意伤害他人身体的,处三年以下有期徒刑、
            拘役或者管制。犯前款罪,致人重伤的,处三年以上十年以下有期徒刑;
            致人死亡或者以特别残忍手段致人重伤造成严重残疾的,处十年以上有期徒刑、
            无期徒刑或者死刑。

            故意伤害罪的量刑:
            1. 故意伤害致人轻伤的:三年以下有期徒刑、拘役或者管制
            2. 故意伤害致人重伤的:三年以上十年以下有期徒刑
            3. 故意伤害致人死亡或特别残忍手段致残的:十年以上有期徒刑、无期徒刑或死刑

            重伤标准:使人肢体残废或者毁人容貌;使人丧失听觉、视觉或者其他器官功能;
            其他对于人身健康有重大伤害的。"""
        },
        {
            "doc_id": "criminal_law_traffic",
            "title": "刑法-交通肇事罪与危险驾驶罪",
            "content": """第一百三十三条 【交通肇事罪】违反交通运输管理法规,因而发生重大事故,
            致人重伤、死亡或者使公私财产遭受重大损失的,处三年以下有期徒刑或者拘役;
            交通运输肇事后逃逸或者有其他特别恶劣情节的,处三年以上七年以下有期徒刑;
            因逃逸致人死亡的,处七年以上有期徒刑。

            第一百三十三条之一 【危险驾驶罪】在道路上驾驶机动车,有下列情形之一的,
            处拘役,并处罚金:
            (一)追逐竞驶,情节恶劣的;
            (二)醉酒驾驶机动车的;
            (三)从事校车业务或者旅客运输,严重超过额定乘员载客,
                  或者严重超过规定时速行驶的;
            (四)违反危险化学品安全管理规定运输危险化学品,危及公共安全的。

            醉酒驾驶的认定标准:血液酒精含量达到80毫克/100毫升以上。"""
        },
        {
            "doc_id": "criminal_law_corruption",
            "title": "刑法-贪污罪",
            "content": """第三百八十二条 【贪污罪】国家工作人员利用职务上的便利,侵吞、窃取、
            骗取或者以其他手段非法占有公共财物的,是贪污罪。

            第三百八十三条 【贪污罪的处罚规定】对犯贪污罪的,根据情节轻重,分别依照下列规定处罚:

            (一)贪污数额较大或者有其他较重情节的,处三年以下有期徒刑或者拘役,并处罚金。
            (二)贪污数额巨大或者有其他严重情节的,处三年以上十年以下有期徒刑,并处罚金或者没收财产。
            (三)贪污数额特别巨大或者有其他特别严重情节的,处十年以上有期徒刑或者无期徒刑,
                  并处罚金或者没收财产;数额特别巨大,并使国家和人民利益遭受特别重大损失的,
                  处无期徒刑或者死刑,并处没收财产。

            贪污数额标准:
            1. 数额较大:三万元以上不满二十万元
            2. 数额巨大:二十万元以上不满三百万元
            3. 数额特别巨大:三百万元以上"""
        }
    ]

    return documents


if __name__ == "__main__":
    # Build evaluation dataset
    builder = LegalDatasetBuilder()
    dataset = builder.build_dataset("legal_qa_dataset.json")

    print(f"Dataset created with {len(dataset['simple_cases'])} simple cases and {len(dataset['complex_cases'])} complex cases")

    # Create legal documents
    documents = create_legal_documents()

    # Save documents
    with open("legal_documents.json", 'w', encoding='utf-8') as f:
        json.dump(documents, f, ensure_ascii=False, indent=2)

    print(f"Created {len(documents)} legal documents for knowledge base")

evaluation/evaluate.py

"""Evaluation framework for Agentic RAG system"""

import json
import logging
import time
from typing import List, Dict, Any, Optional
from pathlib import Path
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from config import Config
from agent import AgenticRAG


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


class RAGEvaluator:
    """Evaluate RAG system performance"""

    def __init__(self, config: Optional[Config] = None):
        self.config = config or Config.from_env()
        self.agent = AgenticRAG(self.config)
        self.results = {
            "agentic": [],
            "non_agentic": []
        }

    def load_dataset(self, dataset_path: str) -> Dict[str, Any]:
        """Load evaluation dataset"""
        with open(dataset_path, 'r', encoding='utf-8') as f:
            return json.load(f)

    def evaluate_response(self, 
                         response: str, 
                         test_case: Dict[str, Any]) -> Dict[str, Any]:
        """Evaluate a single response"""
        evaluation = {
            "case_id": test_case["id"],
            "question": test_case["question"],
            "response": response,
            "metrics": {}
        }

        # Check for expected keywords (for simple cases)
        if "expected_keywords" in test_case:
            keywords_found = []
            keywords_missing = []

            for keyword in test_case["expected_keywords"]:
                if keyword.lower() in response.lower():
                    keywords_found.append(keyword)
                else:
                    keywords_missing.append(keyword)

            evaluation["metrics"]["keyword_recall"] = len(keywords_found) / len(test_case["expected_keywords"])
            evaluation["metrics"]["keywords_found"] = keywords_found
            evaluation["metrics"]["keywords_missing"] = keywords_missing

        # Check for analysis points (for complex cases)
        if "expected_analysis" in test_case:
            analysis_found = []
            analysis_missing = []

            for point in test_case["expected_analysis"]:
                if point.lower() in response.lower():
                    analysis_found.append(point)
                else:
                    analysis_missing.append(point)

            evaluation["metrics"]["analysis_recall"] = len(analysis_found) / len(test_case["expected_analysis"])
            evaluation["metrics"]["analysis_found"] = analysis_found
            evaluation["metrics"]["analysis_missing"] = analysis_missing

        # Check for citations
        citation_count = response.count("[Doc:") + response.count("[Chunk:")
        evaluation["metrics"]["has_citations"] = citation_count > 0
        evaluation["metrics"]["citation_count"] = citation_count

        # Response length
        evaluation["metrics"]["response_length"] = len(response)

        # Check if response indicates no answer
        no_answer_indicators = ["无法回答", "没有找到", "知识库中没有", "cannot answer", "not found"]
        evaluation["metrics"]["gave_answer"] = not any(indicator in response.lower() for indicator in no_answer_indicators)

        return evaluation

    def run_test_case(self, test_case: Dict[str, Any], mode: str = "agentic") -> Dict[str, Any]:
        """Run a single test case"""
        logger.info(f"Running {mode} mode for case {test_case['id']}")

        start_time = time.time()

        try:
            if mode == "agentic":
                response = self.agent.query(test_case["question"], stream=False)
            else:
                response = self.agent.query_non_agentic(test_case["question"], stream=False)

            elapsed_time = time.time() - start_time

            # Clear history for next test
            self.agent.clear_history()

            # Evaluate response
            evaluation = self.evaluate_response(response, test_case)
            evaluation["mode"] = mode
            evaluation["elapsed_time"] = elapsed_time
            evaluation["difficulty"] = test_case.get("difficulty", "unknown")
            evaluation["success"] = True

        except Exception as e:
            logger.error(f"Error in test case {test_case['id']}: {e}")
            evaluation = {
                "case_id": test_case["id"],
                "question": test_case["question"],
                "mode": mode,
                "success": False,
                "error": str(e),
                "elapsed_time": time.time() - start_time
            }

        return evaluation

    def run_evaluation(self, dataset_path: str, output_dir: str = "results"):
        """Run full evaluation"""
        # Load dataset
        dataset = self.load_dataset(dataset_path)

        # Create output directory
        output_path = Path(output_dir)
        output_path.mkdir(exist_ok=True)

        # Combine all test cases
        all_cases = dataset["simple_cases"] + dataset["complex_cases"]

        # Run agentic mode
        logger.info("=" * 60)
        logger.info("Running AGENTIC mode evaluation")
        logger.info("=" * 60)

        agentic_results = []
        for test_case in all_cases:
            result = self.run_test_case(test_case, mode="agentic")
            agentic_results.append(result)
            time.sleep(1)  # Rate limiting

        # Run non-agentic mode
        logger.info("=" * 60)
        logger.info("Running NON-AGENTIC mode evaluation")
        logger.info("=" * 60)

        non_agentic_results = []
        for test_case in all_cases:
            result = self.run_test_case(test_case, mode="non_agentic")
            non_agentic_results.append(result)
            time.sleep(1)  # Rate limiting

        # Compute aggregate metrics
        agentic_metrics = self.compute_aggregate_metrics(agentic_results)
        non_agentic_metrics = self.compute_aggregate_metrics(non_agentic_results)

        # Save results
        results = {
            "dataset": dataset_path,
            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
            "config": {
                "llm_provider": self.config.llm.provider,
                "llm_model": self.agent.model,
                "kb_type": self.config.knowledge_base.type.value
            },
            "agentic": {
                "results": agentic_results,
                "metrics": agentic_metrics
            },
            "non_agentic": {
                "results": non_agentic_results,
                "metrics": non_agentic_metrics
            },
            "comparison": self.compare_modes(agentic_metrics, non_agentic_metrics)
        }

        # Save to file
        output_file = output_path / f"evaluation_results_{time.strftime('%Y%m%d_%H%M%S')}.json"
        with open(output_file, 'w', encoding='utf-8') as f:
            json.dump(results, f, ensure_ascii=False, indent=2)

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

        # Print summary
        self.print_summary(results)

        return results

    def compute_aggregate_metrics(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
        """Compute aggregate metrics from results"""
        metrics = {
            "total_cases": len(results),
            "successful_cases": sum(1 for r in results if r.get("success", False)),
            "failed_cases": sum(1 for r in results if not r.get("success", False)),
            "average_time": 0,
            "total_time": 0
        }

        # Separate by difficulty
        simple_results = [r for r in results if r.get("difficulty") == "easy"]
        medium_results = [r for r in results if r.get("difficulty") == "medium"]
        hard_results = [r for r in results if r.get("difficulty") == "hard"]

        # Compute metrics for successful cases
        successful_results = [r for r in results if r.get("success", False)]

        if successful_results:
            # Time metrics
            times = [r["elapsed_time"] for r in successful_results]
            metrics["average_time"] = sum(times) / len(times)
            metrics["total_time"] = sum(times)
            metrics["min_time"] = min(times)
            metrics["max_time"] = max(times)

            # Response quality metrics
            metrics["cases_with_citations"] = sum(1 for r in successful_results 
                                                 if r.get("metrics", {}).get("has_citations", False))
            metrics["cases_gave_answer"] = sum(1 for r in successful_results 
                                              if r.get("metrics", {}).get("gave_answer", False))

            # Average response length
            lengths = [r.get("metrics", {}).get("response_length", 0) for r in successful_results]
            metrics["average_response_length"] = sum(lengths) / len(lengths) if lengths else 0

            # Keyword/analysis recall (for cases that have them)
            keyword_recalls = [r["metrics"]["keyword_recall"] for r in successful_results 
                              if "keyword_recall" in r.get("metrics", {})]
            if keyword_recalls:
                metrics["average_keyword_recall"] = sum(keyword_recalls) / len(keyword_recalls)

            analysis_recalls = [r["metrics"]["analysis_recall"] for r in successful_results 
                               if "analysis_recall" in r.get("metrics", {})]
            if analysis_recalls:
                metrics["average_analysis_recall"] = sum(analysis_recalls) / len(analysis_recalls)

        # Metrics by difficulty
        for difficulty, diff_results in [("easy", simple_results), ("medium", medium_results), ("hard", hard_results)]:
            if diff_results:
                successful = [r for r in diff_results if r.get("success", False)]
                metrics[f"{difficulty}_success_rate"] = len(successful) / len(diff_results)

                if successful:
                    times = [r["elapsed_time"] for r in successful]
                    metrics[f"{difficulty}_average_time"] = sum(times) / len(times)

        return metrics

    def compare_modes(self, agentic_metrics: Dict[str, Any], non_agentic_metrics: Dict[str, Any]) -> Dict[str, Any]:
        """Compare agentic vs non-agentic performance"""
        comparison = {}

        # Success rate comparison
        comparison["success_rate_diff"] = (agentic_metrics.get("successful_cases", 0) / agentic_metrics["total_cases"] - 
                                          non_agentic_metrics.get("successful_cases", 0) / non_agentic_metrics["total_cases"])

        # Time comparison
        if "average_time" in agentic_metrics and "average_time" in non_agentic_metrics:
            comparison["time_ratio"] = agentic_metrics["average_time"] / non_agentic_metrics["average_time"]
            comparison["time_difference"] = agentic_metrics["average_time"] - non_agentic_metrics["average_time"]

        # Citation comparison
        if "cases_with_citations" in agentic_metrics and "cases_with_citations" in non_agentic_metrics:
            comparison["citation_rate_diff"] = (agentic_metrics["cases_with_citations"] / agentic_metrics["successful_cases"] - 
                                               non_agentic_metrics["cases_with_citations"] / non_agentic_metrics["successful_cases"])

        # Response quality comparison
        if "average_keyword_recall" in agentic_metrics and "average_keyword_recall" in non_agentic_metrics:
            comparison["keyword_recall_improvement"] = (agentic_metrics["average_keyword_recall"] - 
                                                       non_agentic_metrics["average_keyword_recall"])

        if "average_analysis_recall" in agentic_metrics and "average_analysis_recall" in non_agentic_metrics:
            comparison["analysis_recall_improvement"] = (agentic_metrics["average_analysis_recall"] - 
                                                        non_agentic_metrics["average_analysis_recall"])

        # Difficulty-specific comparison
        for difficulty in ["easy", "medium", "hard"]:
            key = f"{difficulty}_success_rate"
            if key in agentic_metrics and key in non_agentic_metrics:
                comparison[f"{difficulty}_success_improvement"] = (agentic_metrics[key] - non_agentic_metrics[key])

        return comparison

    def print_summary(self, results: Dict[str, Any]):
        """Print evaluation summary"""
        print("\n" + "=" * 80)
        print("EVALUATION SUMMARY")
        print("=" * 80)

        print(f"\nConfiguration:")
        print(f"  LLM Provider: {results['config']['llm_provider']}")
        print(f"  LLM Model: {results['config']['llm_model']}")
        print(f"  Knowledge Base: {results['config']['kb_type']}")

        print(f"\n{'='*40} AGENTIC MODE {'='*40}")
        self._print_mode_summary(results["agentic"]["metrics"])

        print(f"\n{'='*40} NON-AGENTIC MODE {'='*40}")
        self._print_mode_summary(results["non_agentic"]["metrics"])

        print(f"\n{'='*40} COMPARISON {'='*40}")
        comparison = results["comparison"]

        print(f"Success Rate Difference: {comparison.get('success_rate_diff', 0):.2%} (Agentic better)")

        if "time_ratio" in comparison:
            print(f"Time Ratio: {comparison['time_ratio']:.2f}x (Agentic/Non-Agentic)")
            print(f"Time Difference: {comparison['time_difference']:.2f} seconds")

        if "keyword_recall_improvement" in comparison:
            print(f"Keyword Recall Improvement: {comparison['keyword_recall_improvement']:.2%}")

        if "analysis_recall_improvement" in comparison:
            print(f"Analysis Recall Improvement: {comparison['analysis_recall_improvement']:.2%}")

        print("\nDifficulty-Specific Improvements:")
        for difficulty in ["easy", "medium", "hard"]:
            key = f"{difficulty}_success_improvement"
            if key in comparison:
                print(f"  {difficulty.capitalize()}: {comparison[key]:.2%}")

        print("=" * 80)

    def _print_mode_summary(self, metrics: Dict[str, Any]):
        """Print summary for a single mode"""
        print(f"Total Cases: {metrics['total_cases']}")
        print(f"Successful: {metrics['successful_cases']} ({metrics['successful_cases']/metrics['total_cases']:.1%})")
        print(f"Failed: {metrics['failed_cases']}")

        if "average_time" in metrics:
            print(f"Average Time: {metrics['average_time']:.2f} seconds")
            print(f"Total Time: {metrics['total_time']:.2f} seconds")

        if "cases_with_citations" in metrics:
            print(f"Cases with Citations: {metrics['cases_with_citations']} ({metrics['cases_with_citations']/metrics['successful_cases']:.1%})")

        if "average_keyword_recall" in metrics:
            print(f"Average Keyword Recall: {metrics['average_keyword_recall']:.2%}")

        if "average_analysis_recall" in metrics:
            print(f"Average Analysis Recall: {metrics['average_analysis_recall']:.2%}")

        # Difficulty breakdown
        print("\nBy Difficulty:")
        for difficulty in ["easy", "medium", "hard"]:
            success_key = f"{difficulty}_success_rate"
            time_key = f"{difficulty}_average_time"
            if success_key in metrics:
                print(f"  {difficulty.capitalize()}: {metrics[success_key]:.1%} success", end="")
                if time_key in metrics:
                    print(f", {metrics[time_key]:.2f}s avg", end="")
                print()


def main():
    """Main evaluation function"""
    import argparse

    parser = argparse.ArgumentParser(description="Evaluate Agentic RAG System")
    parser.add_argument("--dataset", type=str, default="legal_qa_dataset.json",
                       help="Path to evaluation dataset")
    parser.add_argument("--output", type=str, default="results",
                       help="Output directory for results")
    parser.add_argument("--provider", type=str, help="Override LLM provider")
    parser.add_argument("--model", type=str, help="Override LLM model")
    parser.add_argument("--kb-type", choices=["local", "dify"], help="Knowledge base type")

    args = parser.parse_args()

    # Configure
    config = Config.from_env()
    if args.provider:
        config.llm.provider = args.provider
    if args.model:
        config.llm.model = args.model
    if args.kb_type:
        from config import KnowledgeBaseType
        config.knowledge_base.type = KnowledgeBaseType(args.kb_type)

    # Run evaluation
    evaluator = RAGEvaluator(config)
    results = evaluator.run_evaluation(args.dataset, args.output)

    return results


if __name__ == "__main__":
    main()

index_local_laws.py

"""Script to chunk and index local legal documents from laws directory

This script:
1. Cleans up existing indexes
2. Reads legal documents from local laws directory
3. Chunks them with paragraph-aware boundaries (soft limit 1024, hard limit 2048)
4. Indexes them in the retrieval pipeline
"""

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

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

# Configuration
RETRIEVAL_PIPELINE_URL = "http://localhost:4242"  # Default retrieval pipeline URL
LAWS_DIR = Path("laws")  # Local laws directory

# Chunking configuration
SOFT_LIMIT = 1024  # Soft character limit
HARD_LIMIT = 2048  # Hard character limit
MIN_CHUNK_SIZE = 500  # Minimum chunk size


@dataclass
class LegalChunk:
    """Represents a chunk of legal document"""
    chunk_id: str
    doc_id: str
    doc_title: str
    category: str  # e.g., "宪法", "民法典"
    text: str
    chunk_index: int
    char_count: int
    metadata: Dict[str, Any]


class LocalLegalIndexer:
    """Handles chunking and indexing of local legal documents"""

    def __init__(self, laws_dir: Path = LAWS_DIR, pipeline_url: str = RETRIEVAL_PIPELINE_URL):
        self.laws_dir = laws_dir
        self.pipeline_url = pipeline_url
        self.stats = {
            "documents_processed": 0,
            "chunks_created": 0,
            "chunks_indexed": 0,
            "errors": 0,
            "categories_processed": set()
        }

        # Document store for tracking
        self.doc_store_path = Path("document_store.json")

        logger.info(f"Initialized indexer for local laws in {laws_dir}")
        logger.info(f"Pipeline URL: {pipeline_url}")

    def cleanup_existing_index(self):
        """Clean up existing indexes and document store"""
        logger.info("Cleaning up existing indexes...")

        # Clean local document store
        if self.doc_store_path.exists():
            try:
                # Load existing store to get document IDs
                with open(self.doc_store_path, 'r', encoding='utf-8') as f:
                    existing_docs = json.load(f)

                logger.info(f"Found {len(existing_docs)} existing documents in store")

                # Clear the store
                self.doc_store_path.unlink()
                logger.info("Cleared local document store")

            except Exception as e:
                logger.error(f"Error cleaning document store: {e}")

        # Try to clear the retrieval pipeline
        try:
            response = requests.delete(f"{self.pipeline_url}/clear")
            if response.status_code == 200:
                logger.info("Cleared retrieval pipeline index")
            else:
                logger.warning(f"Failed to clear pipeline: {response.status_code}")
        except Exception as e:
            logger.warning(f"Could not clear retrieval pipeline: {e}")

        logger.info("Cleanup complete")

    def get_all_legal_documents(self) -> List[Dict[str, Any]]:
        """Get all legal documents from local laws directory"""
        documents = []

        if not self.laws_dir.exists():
            logger.error(f"Laws directory not found: {self.laws_dir}")
            return documents

        # Iterate through category directories
        for category_dir in sorted(self.laws_dir.iterdir()):
            if not category_dir.is_dir():
                continue

            category_name = category_dir.name
            logger.info(f"Processing category: {category_name}")

            # Find all .md files in this category
            for md_file in category_dir.glob("*.md"):
                doc_info = {
                    "path": md_file,
                    "name": md_file.stem,  # filename without extension
                    "category": category_name,
                    "full_name": md_file.name
                }
                documents.append(doc_info)

        logger.info(f"Found {len(documents)} legal documents across {len(self.stats['categories_processed'])} categories")
        return documents

    def read_document(self, doc_info: Dict[str, Any]) -> Optional[str]:
        """Read a legal document from disk"""
        try:
            doc_path = doc_info["path"]
            content = doc_path.read_text(encoding='utf-8')
            logger.debug(f"Read {doc_info['name']} ({len(content)} chars)")
            return content
        except Exception as e:
            logger.error(f"Error reading {doc_info['name']}: {e}")
            self.stats["errors"] += 1
            return None

    def chunk_document_smart(self, text: str, doc_id: str, doc_title: str, category: str) -> List[LegalChunk]:
        """
        Smart chunking that respects paragraph boundaries with soft and hard limits.

        Strategy:
        - Accumulate paragraphs until soft limit is exceeded
        - Keep adding if next paragraph fits within hard limit
        - Cut at paragraph boundary when possible
        - Force split at hard limit if necessary
        """
        paragraphs = text.split('\n\n')
        chunks = []
        current_chunk = []
        current_size = 0

        for para in paragraphs:
            para = para.strip()
            if not para:
                continue

            para_size = len(para)

            # Handle oversized paragraphs
            if para_size > HARD_LIMIT:
                # Save current chunk if exists
                if current_chunk:
                    chunk_text = '\n\n'.join(current_chunk)
                    if len(chunk_text) >= MIN_CHUNK_SIZE:
                        chunks.append(self._create_chunk(
                            chunk_text, doc_id, doc_title, category, len(chunks)
                        ))
                    current_chunk = []
                    current_size = 0

                # Force split the oversized paragraph
                for i in range(0, para_size, SOFT_LIMIT):
                    sub_text = para[i:i + SOFT_LIMIT]
                    if len(sub_text) >= MIN_CHUNK_SIZE:
                        chunks.append(self._create_chunk(
                            sub_text, doc_id, doc_title, category, len(chunks)
                        ))
                continue

            # Check if adding this paragraph would exceed limits
            new_size = current_size + para_size + (4 if current_chunk else 0)  # Account for \n\n

            if new_size > SOFT_LIMIT and current_chunk:
                # Check if we can still fit it under hard limit
                if new_size <= HARD_LIMIT:
                    # Add it anyway (between soft and hard limit)
                    current_chunk.append(para)
                    current_size = new_size
                else:
                    # Save current chunk and start new one
                    chunk_text = '\n\n'.join(current_chunk)
                    if len(chunk_text) >= MIN_CHUNK_SIZE:
                        chunks.append(self._create_chunk(
                            chunk_text, doc_id, doc_title, category, len(chunks)
                        ))

                    # Start new chunk
                    current_chunk = [para]
                    current_size = para_size
            else:
                # Add to current chunk
                current_chunk.append(para)
                current_size = new_size

        # Save final chunk
        if current_chunk:
            chunk_text = '\n\n'.join(current_chunk)
            if len(chunk_text) >= MIN_CHUNK_SIZE:
                chunks.append(self._create_chunk(
                    chunk_text, doc_id, doc_title, category, len(chunks)
                ))

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

    def _create_chunk(self, text: str, doc_id: str, doc_title: str, category: str, chunk_index: int) -> LegalChunk:
        """Create a LegalChunk object"""
        chunk_id = f"{doc_id}_chunk_{chunk_index}"

        # Extract section info if available
        section_info = self._extract_section_info(text)

        return LegalChunk(
            chunk_id=chunk_id,
            doc_id=doc_id,
            doc_title=doc_title,
            category=category,
            text=text,
            chunk_index=chunk_index,
            char_count=len(text),
            metadata={
                "source": "local_laws",
                "document_type": "legal",
                "language": "zh-CN",
                "category": category,
                "section": section_info
            }
        )

    def _extract_section_info(self, text: str) -> Optional[str]:
        """Extract section/chapter/article information from legal text"""
        import re

        # Common patterns in Chinese legal documents
        patterns = [
            r'第[一二三四五六七八九十百千\d]+[章节条款篇编]',
            r'第[一二三四五六七八九十百千\d]+部分',
            r'[【\[]第[一二三四五六七八九十百千\d]+[章节条款篇编][】\]]'
        ]

        for pattern in patterns:
            match = re.search(pattern, text[:200])  # Check first 200 chars
            if match:
                return match.group()

        return None

    def index_chunk(self, chunk: LegalChunk) -> bool:
        """Index a chunk in the retrieval pipeline"""
        try:
            # Prepare the indexing request
            index_data = {
                "text": chunk.text,
                "doc_id": chunk.chunk_id,
                "metadata": {
                    **chunk.metadata,
                    "doc_title": chunk.doc_title,
                    "category": chunk.category,
                    "chunk_index": chunk.chunk_index,
                    "char_count": chunk.char_count
                }
            }

            # Send to retrieval pipeline
            response = requests.post(
                f"{self.pipeline_url}/index",
                json=index_data,
                headers={"Content-Type": "application/json"}
            )

            if response.status_code == 200:
                self.stats["chunks_indexed"] += 1
                return True
            else:
                logger.warning(f"Failed to index chunk {chunk.chunk_id}: {response.status_code}")
                return False

        except Exception as e:
            logger.error(f"Error indexing chunk {chunk.chunk_id}: {e}")
            self.stats["errors"] += 1
            return False

    def save_document_info(self, doc_info: Dict[str, Any], chunks: List[LegalChunk]):
        """Save document information to local store"""
        # Load existing store or create new
        if self.doc_store_path.exists():
            with open(self.doc_store_path, 'r', encoding='utf-8') as f:
                doc_store = json.load(f)
        else:
            doc_store = {}

        # Add document info
        doc_id = hashlib.md5(doc_info["full_name"].encode()).hexdigest()[:12]
        doc_store[doc_id] = {
            "title": doc_info["name"],
            "category": doc_info["category"],
            "file": str(doc_info["path"]),
            "chunks": len(chunks),
            "total_chars": sum(c.char_count for c in chunks),
            "indexed_at": time.strftime("%Y-%m-%d %H:%M:%S")
        }

        # Save store
        with open(self.doc_store_path, 'w', encoding='utf-8') as f:
            json.dump(doc_store, f, ensure_ascii=False, indent=2)

    def process_all_documents(self, max_docs: Optional[int] = None, categories: Optional[List[str]] = None):
        """Process all legal documents"""
        start_time = time.time()

        # Clean up first
        self.cleanup_existing_index()

        # Get all documents
        all_documents = self.get_all_legal_documents()

        # Filter by categories if specified
        if categories:
            all_documents = [d for d in all_documents if any(cat in d["category"] for cat in categories)]

        # Limit documents if specified
        if max_docs:
            all_documents = all_documents[:max_docs]

        logger.info(f"Processing {len(all_documents)} documents...")

        for i, doc_info in enumerate(all_documents):
            logger.info(f"\n[{i+1}/{len(all_documents)}] Processing: {doc_info['name']}")

            # Track category
            self.stats["categories_processed"].add(doc_info["category"])

            # Read document
            content = self.read_document(doc_info)
            if not content:
                continue

            # Generate document ID
            doc_id = hashlib.md5(doc_info["full_name"].encode()).hexdigest()[:12]

            # Chunk the document
            chunks = self.chunk_document_smart(
                content, 
                doc_id, 
                doc_info["name"],
                doc_info["category"]
            )
            self.stats["chunks_created"] += len(chunks)

            # Index each chunk
            indexed_count = 0
            for j, chunk in enumerate(chunks):
                if self.index_chunk(chunk):
                    indexed_count += 1

                # Progress update
                if (j + 1) % 10 == 0:
                    logger.debug(f"  Indexed {j + 1}/{len(chunks)} chunks")

            logger.info(f"  ✓ Indexed {indexed_count}/{len(chunks)} chunks successfully")

            # Save document info
            self.save_document_info(doc_info, chunks)

            self.stats["documents_processed"] += 1

        elapsed = time.time() - start_time

        # Print statistics
        self._print_statistics(elapsed)

    def _print_statistics(self, elapsed_time: float):
        """Print processing statistics"""
        print("\n" + "="*60)
        print("INDEXING COMPLETE")
        print("="*60)
        print(f"Time elapsed: {elapsed_time:.2f} seconds")
        print(f"Categories processed: {len(self.stats['categories_processed'])}")
        print(f"  - {', '.join(sorted(self.stats['categories_processed']))}")
        print(f"Documents processed: {self.stats['documents_processed']}")
        print(f"Chunks created: {self.stats['chunks_created']}")
        print(f"Chunks indexed: {self.stats['chunks_indexed']}")
        print(f"Errors: {self.stats['errors']}")

        if self.stats['chunks_created'] > 0:
            avg_chunks = self.stats['chunks_created'] / max(1, self.stats['documents_processed'])
            print(f"Average chunks per document: {avg_chunks:.1f}")

        if elapsed_time > 0 and self.stats['documents_processed'] > 0:
            docs_per_sec = self.stats['documents_processed'] / elapsed_time
            print(f"Processing speed: {docs_per_sec:.2f} docs/second")

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

    def verify_indexing(self, test_queries: Optional[List[str]] = None):
        """Verify that indexing worked by performing test searches"""
        if not test_queries:
            test_queries = [
                "民法典",
                "合同法",
                "劳动法",
                "刑法",
                "宪法"
            ]

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

        for query in test_queries:
            try:
                response = requests.post(
                    f"{self.pipeline_url}/search",
                    json={
                        "query": query,
                        "mode": "hybrid",
                        "top_k": 5,
                        "rerank_top_k": 3
                    }
                )

                if response.status_code == 200:
                    results = response.json()
                    print(f"\n✓ Test search for '{query}':")

                    if "results" in results:
                        print(f"  Found {len(results['results'])} results")
                        for i, result in enumerate(results['results'][:2]):
                            score = result.get('score', result.get('rerank_score', 'N/A'))
                            metadata = result.get('metadata', {})
                            print(f"  {i+1}. Score: {score}")
                            print(f"     Category: {metadata.get('category', 'Unknown')}")
                            print(f"     Doc: {metadata.get('doc_title', 'Unknown')}")
                            print(f"     Preview: {result.get('text', '')[:100]}...")
                    else:
                        print(f"  No results found")
                else:
                    print(f"✗ Test search for '{query}' failed: {response.status_code}")

            except Exception as e:
                print(f"✗ Error testing '{query}': {e}")

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


def main():
    """Main function"""
    import argparse

    parser = argparse.ArgumentParser(description="Index local legal documents into retrieval pipeline")
    parser.add_argument("--pipeline-url", default=RETRIEVAL_PIPELINE_URL, help="Retrieval pipeline URL")
    parser.add_argument("--max-docs", type=int, help="Maximum number of documents to process")
    parser.add_argument("--categories", nargs="+", help="Specific categories to process (e.g., '宪法' '民法典')")
    parser.add_argument("--no-cleanup", action="store_true", help="Don't clean existing indexes")
    parser.add_argument("--verify", action="store_true", help="Run verification tests after indexing")

    args = parser.parse_args()

    # Create indexer
    indexer = LocalLegalIndexer(pipeline_url=args.pipeline_url)

    # Skip cleanup if requested
    if args.no_cleanup:
        indexer.cleanup_existing_index = lambda: logger.info("Skipping cleanup (--no-cleanup flag)")

    # Process documents
    indexer.process_all_documents(
        max_docs=args.max_docs,
        categories=args.categories
    )

    # Run verification if requested
    if args.verify:
        indexer.verify_indexing()


if __name__ == "__main__":
    main()

main.py

"""Main entry point for Agentic RAG system"""

import os
import json
import logging
import argparse
from typing import Optional
from config import Config, KnowledgeBaseType
from agent import AgenticRAG
from chunking import DocumentIndexer


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


def setup_environment():
    """Setup environment and check requirements"""
    # Check for required API keys
    config = Config.from_env()

    # Check LLM API key
    try:
        api_key = config.llm.get_api_key(config.llm.provider)
        if not api_key:
            logger.warning(f"No API key found for provider {config.llm.provider}")
            logger.info("Please set the appropriate environment variable:")
            logger.info("  - MOONSHOT_API_KEY for Kimi")
            logger.info("  - ARK_API_KEY for Doubao")
            logger.info("  - SILICONFLOW_API_KEY for SiliconFlow")
            logger.info("  - OPENAI_API_KEY for OpenAI")
            return False
    except Exception as e:
        logger.error(f"Error checking API keys: {e}")
        return False

    # Check knowledge base setup
    if config.knowledge_base.type == KnowledgeBaseType.LOCAL:
        # Check if local retrieval pipeline is running
        import requests
        try:
            response = requests.get(f"{config.knowledge_base.local_base_url}/health")
            if response.status_code != 200:
                logger.warning("Local retrieval pipeline not responding")
                logger.info(f"Please ensure the retrieval pipeline is running at {config.knowledge_base.local_base_url}")
                logger.info("Run: cd ../retrieval-pipeline && python main.py")
        except:
            logger.warning("Cannot connect to local retrieval pipeline")
            logger.info("Will continue anyway - searches may fail")

    elif config.knowledge_base.type == KnowledgeBaseType.DIFY:
        if not config.knowledge_base.dify_api_key:
            logger.warning("Dify API key not set")
            logger.info("Please set DIFY_API_KEY environment variable")

    return True


def run_interactive_mode(agent: AgenticRAG, mode: str = "agentic"):
    """Run interactive query mode"""
    kb = agent.config.knowledge_base
    active_top_k = kb.offline_top_k if kb.type == KnowledgeBaseType.OFFLINE else kb.local_top_k
    print(f"\n{'='*60}")
    print(f"Agentic RAG System - {mode.capitalize()} Mode")
    print(f"Verbose: {'Enabled' if agent.config.agent.verbose else 'Disabled'} | KB: {kb.type.value} | Top-K: {active_top_k}")
    print(f"{'='*60}")
    print("Type 'quit' or 'exit' to stop")
    print("Type 'clear' to clear conversation history")
    print("Type 'mode' to switch between agentic/non-agentic modes")
    print(f"{'='*60}\n")

    current_mode = mode

    while True:
        try:
            user_input = input("\n[USER] > ").strip()

            if user_input.lower() in ['quit', 'exit']:
                print("\nGoodbye!")
                break

            if user_input.lower() == 'clear':
                agent.clear_history()
                print("Conversation history cleared.")
                continue

            if user_input.lower() == 'mode':
                current_mode = "non-agentic" if current_mode == "agentic" else "agentic"
                print(f"Switched to {current_mode} mode")
                continue

            if not user_input:
                continue

            # Process query
            print(f"\n[ASSISTANT ({current_mode})] > ", end="", flush=True)

            if current_mode == "agentic":
                response = agent.query(user_input, stream=True)
            else:
                response = agent.query_non_agentic(user_input, stream=True)

            # Handle streaming response
            if hasattr(response, '__iter__'):
                for chunk in response:
                    print(chunk, end="", flush=True)
                print()  # New line after response
            else:
                print(response)

        except KeyboardInterrupt:
            print("\n\nInterrupted. Type 'quit' to exit.")
        except Exception as e:
            logger.error(f"Error: {e}")
            print(f"\nError processing query: {e}")


def run_batch_mode(agent: AgenticRAG, queries_file: str, output_file: str, mode: str = "agentic"):
    """Run batch queries from file"""
    try:
        with open(queries_file, 'r', encoding='utf-8') as f:
            queries = [line.strip() for line in f if line.strip()]
    except Exception as e:
        logger.error(f"Error reading queries file: {e}")
        return

    results = []

    for i, query in enumerate(queries, 1):
        print(f"\n[{i}/{len(queries)}] Processing: {query[:100]}...")

        try:
            if mode == "agentic":
                response = agent.query(query, stream=False)
            else:
                response = agent.query_non_agentic(query, stream=False)

            results.append({
                "query": query,
                "response": response,
                "mode": mode
            })

        except Exception as e:
            logger.error(f"Error processing query: {e}")
            results.append({
                "query": query,
                "response": f"Error: {str(e)}",
                "mode": mode
            })

    # Save results
    try:
        with open(output_file, 'w', encoding='utf-8') as f:
            json.dump(results, f, ensure_ascii=False, indent=2)
        print(f"\nResults saved to {output_file}")
    except Exception as e:
        logger.error(f"Error saving results: {e}")


def run_comparison_mode(agent: AgenticRAG, query: str):
    """Run both modes and compare results"""
    print(f"\n{'='*60}")
    print("Comparison Mode - Running both Agentic and Non-Agentic")
    print(f"{'='*60}")
    print(f"Query: {query}")
    print(f"{'='*60}")

    # Run non-agentic mode
    print("\n[NON-AGENTIC MODE]")
    print("-" * 40)
    non_agentic_response = agent.query_non_agentic(query, stream=False)
    print(non_agentic_response)

    # Clear history for fair comparison
    agent.clear_history()

    # Run agentic mode
    print("\n[AGENTIC MODE]")
    print("-" * 40)
    agentic_response = agent.query(query, stream=False)
    print(agentic_response)

    print(f"\n{'='*60}")


def main():
    """Main function"""
    parser = argparse.ArgumentParser(
        description="智能体化 RAG 系统:对比『智能体化(多轮迭代检索)』与『非智能体化(单次检索)』两种范式。",
        epilog=(
            "示例:\n"
            "  python main.py --kb-type offline --query \"醉酒过失致人重伤且有盗窃前科如何量刑\"\n"
            "  python main.py --query \"故意杀人罪判几年\" --mode compare --kb-type offline\n"
            "  python compare_offline.py   # 纯离线检索对比,无需 API 与外部服务\n"
        ),
        formatter_class=argparse.RawTextHelpFormatter,
    )

    # 模式选择
    parser.add_argument("--mode", choices=["agentic", "non-agentic", "compare"],
                        default="agentic",
                        help="查询模式:agentic=智能体化多轮检索 / non-agentic=单次检索 / compare=同题对比(默认:agentic)")

    # 查询选项
    parser.add_argument("--query", type=str, help="单条查询问题;不指定则进入交互模式")
    parser.add_argument("--batch", type=str, help="批量查询文件路径(每行一个问题)")
    parser.add_argument("--output", type=str, default="results.json",
                        help="批量结果的输出文件路径(默认:results.json)")

    # 配置选项
    parser.add_argument("--provider", type=str, help="LLM 提供商(如 kimi / doubao / openai)")
    parser.add_argument("--model", type=str, help="LLM 模型名称(不指定则用提供商默认模型)")
    parser.add_argument("--kb-type", choices=["offline", "local", "dify"],
                        help="知识库后端:offline=内置离线 BM25(无需服务/无需 API)/ local=检索流水线服务 / dify=Dify API")
    parser.add_argument("--corpus", type=str,
                        help="离线后端的法律语料目录(仅 --kb-type offline 生效,默认:laws)")
    parser.add_argument("--top-k", type=int, dest="top_k",
                        help="检索深度:每次检索返回的分块数量(默认:offline=5,local=3)")
    parser.add_argument("--verbose", action="store_true", help="输出详细的 Agent 推理轨迹(默认开启)")
    parser.add_argument("--no-verbose", action="store_true", help="关闭详细日志输出")

    # 索引选项
    parser.add_argument("--index", type=str, help="待索引的文件或目录路径")
    parser.add_argument("--chunk-size", type=int, default=2048, help="索引时的分块大小(字符数,默认:2048)")

    args = parser.parse_args()

    # Setup environment
    if not setup_environment():
        logger.warning("Environment setup incomplete, continuing anyway...")

    # Load or create config
    config = Config.from_env()

    # Set verbose mode by default (can be disabled with --no-verbose)
    config.agent.verbose = True  # Default to verbose mode

    # Override config with command line args
    if args.provider:
        config.llm.provider = args.provider
    if args.model:
        config.llm.model = args.model
    if args.kb_type:
        config.knowledge_base.type = KnowledgeBaseType(args.kb_type)
    if args.corpus:
        config.knowledge_base.offline_corpus_path = args.corpus
    if args.top_k:
        # 同时设置离线与本地后端的检索深度,保持行为一致
        config.knowledge_base.offline_top_k = args.top_k
        config.knowledge_base.local_top_k = args.top_k

    # Handle verbose mode (default is True, can be disabled with --no-verbose)
    if args.no_verbose:
        config.agent.verbose = False
    elif args.verbose:
        config.agent.verbose = True  # Explicitly set if --verbose is used

    # Handle indexing if requested
    if args.index:
        print(f"\n{'='*60}")
        print("Indexing Documents")
        print(f"{'='*60}")

        config.chunking.chunk_size = args.chunk_size
        indexer = DocumentIndexer(config.knowledge_base, config.chunking)

        from pathlib import Path
        path = Path(args.index)

        if path.is_file():
            result = indexer.index_file(str(path))
        elif path.is_dir():
            result = indexer.index_directory(str(path))
        else:
            print(f"Path not found: {path}")
            return

        print(json.dumps(result, indent=2, ensure_ascii=False))
        print(f"{'='*60}\n")

    # Create agent
    agent = AgenticRAG(config)

    # Handle different execution modes
    if args.query and args.mode == "compare":
        # Comparison mode with single query
        run_comparison_mode(agent, args.query)

    elif args.query:
        # Single query mode
        kb = config.knowledge_base
        active_top_k = kb.offline_top_k if kb.type == KnowledgeBaseType.OFFLINE else kb.local_top_k
        print(f"\n[Query] {args.query}")
        print(f"[Mode] {args.mode}")
        print(f"[KB] {kb.type.value}")
        print(f"[Verbose] {'Enabled' if config.agent.verbose else 'Disabled'}")
        print(f"[Top-K] {active_top_k}")
        print("-" * 40)

        if args.mode == "agentic":
            response = agent.query(args.query, stream=False)
        else:
            response = agent.query_non_agentic(args.query, stream=False)

        print(response)

    elif args.batch:
        # Batch mode
        run_batch_mode(agent, args.batch, args.output, args.mode)

    else:
        # Interactive mode (default)
        run_interactive_mode(agent, args.mode)


if __name__ == "__main__":
    main()

offline_retriever.py

"""In-process offline retriever (BM25 over the local law corpus).

This backend makes the whole experiment runnable without the external
`retrieval-pipeline` HTTP service: it reads the Markdown law files under
``laws/``, splits them into article-level chunks (每一条法条一个 chunk), and
scores queries with Okapi BM25. Retrieval therefore runs fully offline with no
API key and no server; only the LLM answer-generation step (in ``agent.py``)
still needs a provider API.

Chinese text is tokenised with ``jieba`` when available, falling back to a
character uni/bi-gram tokeniser so the module works with only the standard
library installed.
"""

import os
import re
import math
import logging
from pathlib import Path
from typing import Dict, Any, List, Optional
from collections import Counter, defaultdict

logger = logging.getLogger(__name__)


# Article marker at the start of a line, e.g. 第二百三十五条 / 第一百三十三条之一
_ARTICLE_RE = re.compile(r"^第[一二三四五六七八九十百千零两0-9]+条(?:之[一二三四五六七八九十0-9]+)?")


def _tokenize(text: str) -> List[str]:
    """Tokenise mixed Chinese/English text.

    Prefers jieba; otherwise emits ASCII words plus Chinese character uni- and
    bi-grams, which is enough for lexical BM25 matching without extra deps.
    """
    try:
        import jieba  # type: ignore
        return [t for t in jieba.cut(text) if t.strip()]
    except Exception:
        tokens: List[str] = []
        for m in re.findall(r"[a-zA-Z0-9]+|[一-鿿]+", text):
            if m[0].isascii():
                tokens.append(m.lower())
            else:
                tokens.extend(list(m))  # unigrams
                tokens.extend(m[i:i + 2] for i in range(len(m) - 1))  # bigrams
        return tokens


class OfflineRetriever:
    """Okapi BM25 retriever over article-level chunks of the law corpus."""

    def __init__(self,
                 corpus_path: str = "laws",
                 k1: float = 1.5,
                 b: float = 0.75,
                 extensions: Optional[List[str]] = None):
        self.corpus_path = corpus_path
        self.k1 = k1
        self.b = b
        self.extensions = extensions or [".md", ".txt"]

        self.chunks: List[Dict[str, Any]] = []          # {doc_id, chunk_id, title, category, text}
        self.documents: Dict[str, Dict[str, Any]] = {}   # doc_id -> {title, category, file, content}
        self._doc_freqs: List[Counter] = []              # per-chunk term frequencies
        self._doc_lens: List[int] = []
        self._df: Dict[str, int] = defaultdict(int)      # document frequency per term
        self._idf: Dict[str, float] = {}
        self._avg_len: float = 0.0

        self._build_index()

    # ------------------------------------------------------------------ build
    def _iter_files(self):
        root = Path(self.corpus_path)
        if not root.exists():
            logger.warning(f"Offline corpus path not found: {root}")
            return
        for path in sorted(root.rglob("*")):
            if path.is_file() and path.suffix in self.extensions:
                yield path

    def _split_articles(self, content: str) -> List[str]:
        """Split a law document into article-level chunks.

        Falls back to blank-line paragraph grouping when the file has no
        ``第X条`` markers (e.g. non-statute documents).
        """
        lines = content.splitlines()
        articles: List[str] = []
        current: List[str] = []
        seen_article = False

        for line in lines:
            if _ARTICLE_RE.match(line.strip()):
                seen_article = True
                if current:
                    articles.append("\n".join(current).strip())
                current = [line]
            else:
                current.append(line)
        if current:
            articles.append("\n".join(current).strip())

        if not seen_article:
            # No article markers: group by blank lines into ~paragraph chunks.
            articles = [p.strip() for p in content.split("\n\n") if p.strip()]

        return [a for a in articles if a]

    def _build_index(self):
        for path in self._iter_files():
            try:
                content = path.read_text(encoding="utf-8")
            except Exception as e:
                logger.error(f"Error reading {path}: {e}")
                continue

            category = path.parent.name
            title = path.stem
            doc_id = f"{category}/{title}"
            self.documents[doc_id] = {
                "doc_id": doc_id,
                "title": title,
                "category": category,
                "file": str(path),
                "content": content,
            }

            for idx, article in enumerate(self._split_articles(content)):
                if len(article) < 4:
                    continue
                chunk_id = f"{doc_id}_chunk_{idx}"
                self.chunks.append({
                    "doc_id": doc_id,
                    "chunk_id": chunk_id,
                    "title": title,
                    "category": category,
                    "text": article,
                })

        # Build BM25 statistics.
        for chunk in self.chunks:
            tf = Counter(_tokenize(chunk["text"]))
            self._doc_freqs.append(tf)
            self._doc_lens.append(sum(tf.values()))
            for term in tf:
                self._df[term] += 1

        n = len(self.chunks)
        self._avg_len = (sum(self._doc_lens) / n) if n else 0.0
        for term, df in self._df.items():
            # BM25 idf with +1 to stay non-negative.
            self._idf[term] = math.log(1 + (n - df + 0.5) / (df + 0.5))

        logger.info(
            f"OfflineRetriever indexed {n} chunks from {len(self.documents)} "
            f"documents under '{self.corpus_path}'"
        )

    # ----------------------------------------------------------------- search
    def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
        """Return the ``top_k`` article chunks scored by BM25 for ``query``."""
        if not self.chunks:
            return []

        q_terms = _tokenize(query)
        scored: List[tuple] = []
        for i, tf in enumerate(self._doc_freqs):
            dl = self._doc_lens[i]
            score = 0.0
            for term in q_terms:
                f = tf.get(term)
                if not f:
                    continue
                idf = self._idf.get(term, 0.0)
                denom = f + self.k1 * (1 - self.b + self.b * dl / (self._avg_len or 1))
                score += idf * (f * (self.k1 + 1)) / denom
            if score > 0:
                scored.append((score, i))

        scored.sort(reverse=True)
        results: List[Dict[str, Any]] = []
        for score, i in scored[:top_k]:
            chunk = self.chunks[i]
            results.append({
                "doc_id": chunk["doc_id"],
                "chunk_id": chunk["chunk_id"],
                "text": chunk["text"],
                "score": float(score),
                "metadata": {
                    "title": chunk["title"],
                    "category": chunk["category"],
                    "source": "offline",
                },
            })
        return results

    def get_document(self, doc_id: str) -> Dict[str, Any]:
        """Return the full source document for ``doc_id``."""
        doc = self.documents.get(doc_id)
        if not doc:
            return {"error": f"Document {doc_id} not found"}
        return {
            "doc_id": doc_id,
            "content": doc["content"],
            "metadata": {
                "title": doc["title"],
                "category": doc["category"],
                "file": doc["file"],
                "source": "offline",
            },
        }

quickstart.py

#!/usr/bin/env python3
"""Quick start script for Agentic RAG system"""

import os
import sys
import json
from pathlib import Path


def check_environment():
    """Check if environment is properly configured"""
    print("🔍 Checking environment...")

    # Check for .env file
    if not Path(".env").exists() and Path(".env.example").exists():
        print("📝 Creating .env from .env.example")
        import shutil
        shutil.copy(".env.example", ".env")
        print("⚠️  Please edit .env and add your API keys")
        return False

    # Load environment variables
    from dotenv import load_dotenv
    load_dotenv()

    # Check for at least one API key
    providers = ["MOONSHOT_API_KEY", "ARK_API_KEY", "SILICONFLOW_API_KEY", 
                 "OPENAI_API_KEY", "OPENROUTER_API_KEY"]

    has_key = False
    for provider in providers:
        if os.getenv(provider):
            has_key = True
            print(f"✅ Found {provider}")
            break

    if not has_key:
        print("❌ No API keys found. Please set at least one in .env file:")
        print("   - MOONSHOT_API_KEY for Kimi")
        print("   - ARK_API_KEY for Doubao")
        print("   - SILICONFLOW_API_KEY for SiliconFlow")
        print("   - OPENAI_API_KEY for OpenAI")
        return False

    return True


def setup_demo_documents():
    """Create demo documents if they don't exist"""
    print("\n📚 Setting up demo documents...")

    eval_dir = Path("evaluation")
    eval_dir.mkdir(exist_ok=True)

    # Check if documents already exist
    doc_file = eval_dir / "legal_documents.json"
    dataset_file = eval_dir / "legal_qa_dataset.json"

    if not doc_file.exists() or not dataset_file.exists():
        print("📄 Generating legal documents and dataset...")
        os.chdir("evaluation")
        os.system("python dataset_builder.py")
        os.chdir("..")
        print("✅ Documents generated")
    else:
        print("✅ Documents already exist")

    return doc_file, dataset_file


def check_retrieval_pipeline():
    """Check if local retrieval pipeline is running"""
    print("\n🔌 Checking retrieval pipeline...")

    kb_type = os.getenv("KB_TYPE", "local")

    if kb_type == "local":
        import requests
        try:
            response = requests.get("http://localhost:4242/health", timeout=2)
            if response.status_code == 200:
                print("✅ Local retrieval pipeline is running")
                return True
        except:
            pass

        print("⚠️  Local retrieval pipeline is not running")
        print("    Please run in another terminal:")
        print("    cd ../retrieval-pipeline && python main.py")
        print("\n    Or use Dify by setting KB_TYPE=dify in .env")
        return False
    else:
        print(f"✅ Using {kb_type} knowledge base")
        return True


def index_documents(doc_file):
    """Index documents into knowledge base"""
    print("\n📝 Indexing documents...")

    # Check if already indexed
    store_file = Path("document_store.json")
    if store_file.exists():
        with open(store_file, 'r', encoding='utf-8') as f:
            store = json.load(f)
            if len(store) > 0:
                print(f"✅ Found {len(store)} documents already indexed")
                return True

    print("🔄 Indexing legal documents...")
    result = os.system(f"python chunking.py {doc_file}")

    if result == 0:
        print("✅ Documents indexed successfully")
        return True
    else:
        print("❌ Failed to index documents")
        return False


def run_demo():
    """Run interactive demo"""
    print("\n" + "="*60)
    print("🚀 Starting Agentic RAG Demo")
    print("="*60)

    print("\nDemo queries you can try:")
    print("1. 故意杀人罪判几年?")
    print("2. 盗窃罪的立案标准是什么?")
    print("3. 醉酒驾驶如何处罚?")
    print("4. 张某持刀入室抢劫并造成他人重伤,应如何定罪量刑?")

    print("\nCommands:")
    print("- 'mode' to switch between agentic/non-agentic")
    print("- 'clear' to clear conversation history")
    print("- 'quit' to exit")

    print("\nStarting in interactive mode...")
    print("-"*60)

    os.system("python main.py")


def run_comparison_demo():
    """Run comparison between agentic and non-agentic modes"""
    print("\n" + "="*60)
    print("🔄 Running Comparison Demo")
    print("="*60)

    queries = [
        "故意杀人罪判几年?",
        "张某因经济纠纷持刀闯入李某家中,刺伤李某致重伤并拿走5万元现金,应如何定罪?"
    ]

    for query in queries:
        print(f"\n📝 Query: {query}")
        os.system(f'python main.py --mode compare --query "{query}"')
        input("\nPress Enter to continue...")


def main():
    """Main quickstart function"""
    print("🎯 Agentic RAG System - Quick Start")
    print("="*60)

    # Check environment
    if not check_environment():
        print("\n❌ Please configure your environment first")
        sys.exit(1)

    # Setup demo documents
    doc_file, dataset_file = setup_demo_documents()

    # Check retrieval pipeline
    if not check_retrieval_pipeline():
        print("\n⚠️  Warning: Retrieval pipeline not available")
        print("    The system may not work properly")
        response = input("\nContinue anyway? (y/n): ")
        if response.lower() != 'y':
            sys.exit(0)

    # Index documents
    if not index_documents(doc_file):
        print("\n❌ Failed to index documents")
        sys.exit(1)

    # Menu
    print("\n" + "="*60)
    print("📋 Select an option:")
    print("="*60)
    print("1. Interactive Demo (chat with the system)")
    print("2. Comparison Demo (see agentic vs non-agentic)")
    print("3. Run Full Evaluation")
    print("4. Exit")

    choice = input("\nYour choice (1-4): ")

    if choice == "1":
        run_demo()
    elif choice == "2":
        run_comparison_demo()
    elif choice == "3":
        print("\n📊 Running full evaluation...")
        os.chdir("evaluation")
        os.system("python evaluate.py")
        os.chdir("..")
    elif choice == "4":
        print("\n👋 Goodbye!")
    else:
        print("\n❌ Invalid choice")


if __name__ == "__main__":
    # Install dependencies if needed
    try:
        import openai
        import requests
        from dotenv import load_dotenv
    except ImportError:
        print("📦 Installing required packages...")
        os.system("pip install -r requirements.txt")
        print("✅ Packages installed")

    main()

test_simple.py

#!/usr/bin/env python3
"""Simple test script for Agentic RAG system"""

import os
import json
from pathlib import Path
from dotenv import load_dotenv

# Load environment variables
load_dotenv()

def test_basic_functionality():
    """Test basic functionality of the system"""
    print("🧪 Testing Agentic RAG System")
    print("="*60)

    # Import modules
    try:
        from config import Config, KnowledgeBaseType
        from agent import AgenticRAG
        from tools import KnowledgeBaseTools
        from chunking import DocumentChunker, DocumentIndexer

        print("✅ All modules imported successfully")
    except ImportError as e:
        print(f"❌ Import error: {e}")
        return False

    # Test configuration
    print("\n📋 Testing Configuration...")
    try:
        config = Config.from_env()
        print(f"  Provider: {config.llm.provider}")
        print(f"  KB Type: {config.knowledge_base.type}")
        print(f"  Chunk Size: {config.chunking.chunk_size}")
        print("✅ Configuration loaded")
    except Exception as e:
        print(f"❌ Config error: {e}")
        return False

    # Test document chunking
    print("\n📄 Testing Document Chunking...")
    try:
        chunker = DocumentChunker(config.chunking)
        sample_text = """故意杀人罪是指故意非法剥夺他人生命的行为。

        根据《中华人民共和国刑法》第二百三十二条规定,故意杀人的,
        处死刑、无期徒刑或者十年以上有期徒刑;情节较轻的,
        处三年以上十年以下有期徒刑。

        量刑考虑因素包括犯罪动机、手段、后果等。"""

        chunks = chunker.chunk_text(sample_text, "test_doc")
        print(f"  Created {len(chunks)} chunks")
        print(f"  First chunk: {chunks[0]['text'][:100]}...")
        print("✅ Chunking works")
    except Exception as e:
        print(f"❌ Chunking error: {e}")
        return False

    # Test knowledge base tools
    print("\n🔧 Testing Knowledge Base Tools...")
    try:
        kb_tools = KnowledgeBaseTools(config.knowledge_base)

        # Add test document to store
        kb_tools.add_document(
            "test_doc_1",
            "故意杀人罪处死刑、无期徒刑或者十年以上有期徒刑。",
            {"source": "test"}
        )

        # Test document retrieval
        doc = kb_tools.get_document("test_doc_1")
        if "error" not in doc:
            print(f"  Retrieved document: {doc['doc_id']}")
            print("✅ Document storage works")
        else:
            print(f"⚠️  Document retrieval returned: {doc}")
    except Exception as e:
        print(f"❌ KB Tools error: {e}")
        return False

    # Test agent initialization
    print("\n🤖 Testing Agent Initialization...")
    try:
        agent = AgenticRAG(config)
        print(f"  Model: {agent.model}")
        print(f"  Provider: {config.llm.provider}")
        print("✅ Agent initialized")
    except Exception as e:
        print(f"❌ Agent initialization error: {e}")
        print("  Make sure you have set the appropriate API key in .env")
        return False

    # Test simple query (if API key is available)
    if os.getenv("MOONSHOT_API_KEY") or os.getenv("OPENAI_API_KEY"):
        print("\n💬 Testing Simple Query...")
        try:
            # Add some test data
            kb_tools.add_document(
                "criminal_law_test",
                """盗窃罪的立案标准:
                1. 数额较大:一般为1000元至3000元以上
                2. 多次盗窃:2年内盗窃3次以上
                3. 入户盗窃、携带凶器盗窃、扒窃不论数额""",
                {"type": "law"}
            )

            # Test non-agentic query (simpler, less likely to fail)
            response = agent.query_non_agentic("盗窃罪立案标准", stream=False)

            if response and len(response) > 10:
                print(f"  Response: {response[:200]}...")
                print("✅ Query processing works")
            else:
                print(f"⚠️  Response was empty or too short: {response}")
        except Exception as e:
            print(f"⚠️  Query error: {e}")
            print("  This might be due to retrieval pipeline not running")
    else:
        print("\n⚠️  Skipping query test (no API key found)")

    print("\n" + "="*60)
    print("🎉 Basic functionality test complete!")
    return True


def test_evaluation_dataset():
    """Test evaluation dataset generation"""
    print("\n📊 Testing Evaluation Dataset...")

    try:
        # Import dataset builder
        import sys
        sys.path.append("evaluation")
        from dataset_builder import LegalDatasetBuilder, create_legal_documents

        # Build dataset
        builder = LegalDatasetBuilder()
        simple_cases = builder.create_simple_cases()
        complex_cases = builder.create_complex_cases()

        print(f"  Simple cases: {len(simple_cases)}")
        print(f"  Complex cases: {len(complex_cases)}")
        print(f"  First simple case: {simple_cases[0]['question']}")

        # Create documents
        documents = create_legal_documents()
        print(f"  Legal documents: {len(documents)}")

        print("✅ Evaluation dataset works")
        return True

    except Exception as e:
        print(f"❌ Dataset error: {e}")
        return False


if __name__ == "__main__":
    print("🚀 Agentic RAG System - Test Suite")
    print("="*60)

    # Run tests
    success = test_basic_functionality()

    if success:
        test_evaluation_dataset()

    print("\n" + "="*60)
    if success:
        print("✅ All basic tests passed!")
        print("\nNext steps:")
        print("1. Make sure retrieval pipeline is running:")
        print("   cd ../retrieval-pipeline && python main.py")
        print("\n2. Run the quickstart:")
        print("   python quickstart.py")
        print("\n3. Or start interactive mode:")
        print("   python main.py")
    else:
        print("❌ Some tests failed. Please check the errors above.")
        print("\nCommon issues:")
        print("1. Missing API keys in .env file")
        print("2. Retrieval pipeline not running")
        print("3. Missing dependencies (run: pip install -r requirements.txt)")

test_structured_backends.py

"""
Test script for Agentic RAG with structured index backends (RAPTOR and GraphRAG).
"""

import asyncio
import logging
from config import Config, KnowledgeBaseType
from agent import AgenticRAG

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


def test_raptor_backend():
    """Test Agentic RAG with RAPTOR tree-based backend"""
    print("\n" + "="*60)
    print("Testing RAPTOR Tree-Based Backend")
    print("="*60)

    # Configure for RAPTOR
    config = Config.from_env()
    config.knowledge_base.type = KnowledgeBaseType.RAPTOR
    config.knowledge_base.raptor_base_url = "http://localhost:4242"
    config.knowledge_base.raptor_top_k = 5
    config.llm.provider = "kimi"  # Use your preferred provider

    # Initialize agent
    agent = AgenticRAG(config)

    # Test queries
    test_queries = [
        "What are the x86 general-purpose registers?",
        "How does the MOV instruction work in Intel architecture?",
        "Explain SIMD instructions and their purpose",
        "What are control registers CR0-CR4 used for?",
        "How do I use SSE instructions for parallel processing?"
    ]

    for query in test_queries:
        print(f"\nQuery: {query}")
        print("-" * 50)

        # Agentic mode (with tools)
        response = agent.query(query, stream=False)
        print(f"Response: {response[:500]}..." if len(response) > 500 else f"Response: {response}")

        # Clear history for next query
        agent.clear_history()


def test_graphrag_backend():
    """Test Agentic RAG with GraphRAG knowledge graph backend"""
    print("\n" + "="*60)
    print("Testing GraphRAG Knowledge Graph Backend")
    print("="*60)

    # Configure for GraphRAG
    config = Config.from_env()
    config.knowledge_base.type = KnowledgeBaseType.GRAPHRAG
    config.knowledge_base.graphrag_base_url = "http://localhost:4242"
    config.knowledge_base.graphrag_top_k = 5
    config.knowledge_base.graphrag_search_type = "hybrid"
    config.llm.provider = "kimi"  # Use your preferred provider

    # Initialize agent
    agent = AgenticRAG(config)

    # Test queries
    test_queries = [
        "What instructions modify the FLAGS register?",
        "Show me the relationship between MOV and LEA instructions",
        "What CPU features are related to virtualization?",
        "How are SSE and AVX instructions related?",
        "What components make up the execution environment?"
    ]

    for query in test_queries:
        print(f"\nQuery: {query}")
        print("-" * 50)

        # Agentic mode (with tools)
        response = agent.query(query, stream=False)
        print(f"Response: {response[:500]}..." if len(response) > 500 else f"Response: {response}")

        # Clear history for next query
        agent.clear_history()


def compare_backends():
    """Compare results from different backends for the same query"""
    print("\n" + "="*60)
    print("Comparing Different Backend Results")
    print("="*60)

    query = "Explain the Intel x86 instruction format and its components"

    backends = [
        (KnowledgeBaseType.RAPTOR, "RAPTOR Tree-Based", "http://localhost:4242"),
        (KnowledgeBaseType.GRAPHRAG, "GraphRAG Knowledge Graph", "http://localhost:4242")
    ]

    results = {}

    for backend_type, backend_name, base_url in backends:
        print(f"\n{backend_name}:")
        print("-" * 40)

        # Configure for backend
        config = Config.from_env()
        config.knowledge_base.type = backend_type

        if backend_type == KnowledgeBaseType.RAPTOR:
            config.knowledge_base.raptor_base_url = base_url
        elif backend_type == KnowledgeBaseType.GRAPHRAG:
            config.knowledge_base.graphrag_base_url = base_url
            config.knowledge_base.graphrag_search_type = "hybrid"

        config.llm.provider = "kimi"

        # Initialize agent
        agent = AgenticRAG(config)

        # Query and store result
        response = agent.query(query, stream=False)
        results[backend_name] = response

        print(f"Response preview: {response[:300]}...")

    # Compare results
    print("\n" + "="*60)
    print("Comparison Summary")
    print("="*60)

    for backend_name, response in results.items():
        print(f"\n{backend_name}:")
        print(f"  Response length: {len(response)} characters")
        print(f"  Citations found: {'[Doc:' in response or '[Chunk:' in response}")

        # Count tool calls (approximate)
        tool_indicators = ["knowledge_base_search", "get_document"]
        tool_count = sum(1 for indicator in tool_indicators if indicator in str(response))
        print(f"  Estimated tool calls: {tool_count}")


def test_non_agentic_mode():
    """Test non-agentic mode with structured backends"""
    print("\n" + "="*60)
    print("Testing Non-Agentic Mode with Structured Backends")
    print("="*60)

    query = "What are the different types of Intel CPU registers?"

    # Test with RAPTOR
    print("\nRAPTOR (Non-Agentic):")
    config = Config.from_env()
    config.knowledge_base.type = KnowledgeBaseType.RAPTOR
    config.knowledge_base.raptor_base_url = "http://localhost:4242"

    agent = AgenticRAG(config)
    response = agent.query_non_agentic(query, stream=False)
    print(f"Response: {response[:400]}...")

    # Test with GraphRAG
    print("\nGraphRAG (Non-Agentic):")
    config.knowledge_base.type = KnowledgeBaseType.GRAPHRAG
    config.knowledge_base.graphrag_base_url = "http://localhost:4242"

    agent = AgenticRAG(config)
    response = agent.query_non_agentic(query, stream=False)
    print(f"Response: {response[:400]}...")


def main():
    """Run all tests"""
    print("Agentic RAG with Structured Index Backends Test Suite")
    print("=" * 60)

    # Make sure the structured-index API is running on port 4242
    print("\nNote: Make sure the structured-index API is running on port 4242")
    print("Run: cd ../structured-index && python main.py serve")

    input("\nPress Enter to start tests...")

    try:
        # Run tests
        test_raptor_backend()
        test_graphrag_backend()
        compare_backends()
        test_non_agentic_mode()

        print("\n" + "="*60)
        print("All tests completed successfully!")
        print("="*60)

    except Exception as e:
        logger.error(f"Test failed: {e}")
        print("\nMake sure:")
        print("1. The structured-index API is running (python main.py serve)")
        print("2. Indexes have been built (python main.py build <document>)")
        print("3. Your API keys are configured in .env")


if __name__ == "__main__":
    main()

tools.py

"""Tools for knowledge base interaction"""

import json
import logging
import requests
from typing import Dict, Any, List, Optional
from dataclasses import dataclass
from config import KnowledgeBaseConfig, KnowledgeBaseType


logger = logging.getLogger(__name__)


@dataclass
class SearchResult:
    """Search result from knowledge base"""
    doc_id: str
    chunk_id: str
    text: str
    score: float
    metadata: Dict[str, Any] = None

    def to_dict(self) -> Dict[str, Any]:
        return {
            "doc_id": self.doc_id,
            "chunk_id": self.chunk_id,
            "text": self.text,
            "score": self.score,
            "metadata": self.metadata or {}
        }


class KnowledgeBaseTools:
    """Tools for interacting with knowledge base"""

    def __init__(self, config: KnowledgeBaseConfig):
        self.config = config
        self.document_store = {}  # In-memory store for documents
        self._offline_retriever = None  # Lazily built in-process BM25 index

        # Load document store if exists
        try:
            with open(config.document_store_path, 'r', encoding='utf-8') as f:
                self.document_store = json.load(f)
        except FileNotFoundError:
            logger.info("No existing document store found, starting fresh")
        except Exception as e:
            logger.error(f"Error loading document store: {e}")

    def save_document_store(self):
        """Save document store to disk"""
        try:
            with open(self.config.document_store_path, 'w', encoding='utf-8') as f:
                json.dump(self.document_store, f, ensure_ascii=False, indent=2)
        except Exception as e:
            logger.error(f"Error saving document store: {e}")

    def knowledge_base_search(self, query: str) -> List[Dict[str, Any]]:
        """
        Search the knowledge base with a natural language query.

        Args:
            query: Natural language query string

        Returns:
            List of matching document chunks with scores
        """
        try:
            if self.config.type == KnowledgeBaseType.OFFLINE:
                return self._search_offline(query)
            elif self.config.type == KnowledgeBaseType.LOCAL:
                return self._search_local(query)
            elif self.config.type == KnowledgeBaseType.DIFY:
                return self._search_dify(query)
            elif self.config.type == KnowledgeBaseType.RAPTOR:
                return self._search_raptor(query)
            elif self.config.type == KnowledgeBaseType.GRAPHRAG:
                return self._search_graphrag(query)
            else:
                raise ValueError(f"Unsupported knowledge base type: {self.config.type}")
        except Exception as e:
            logger.error(f"Error in knowledge base search: {e}")
            return []

    def _get_offline_retriever(self):
        """Lazily build the in-process BM25 retriever over the local corpus."""
        if self._offline_retriever is None:
            from offline_retriever import OfflineRetriever
            self._offline_retriever = OfflineRetriever(self.config.offline_corpus_path)
        return self._offline_retriever

    def _search_offline(self, query: str) -> List[Dict[str, Any]]:
        """Search the in-process BM25 index (no server, no API key required)."""
        retriever = self._get_offline_retriever()
        results = retriever.search(query, self.config.offline_top_k)
        logger.info(f"Offline BM25 search returned {len(results)} results")
        return results

    def _search_local(self, query: str) -> List[Dict[str, Any]]:
        """Search using local retrieval pipeline"""
        try:
            response = requests.post(
                f"{self.config.local_base_url}/search",
                json={
                    "query": query,
                    "mode": "hybrid",
                    "top_k": self.config.local_top_k,
                    "rerank": True
                }
            )
            response.raise_for_status()

            results = []
            data = response.json()

            # The retrieval pipeline returns results in different keys based on mode
            # For hybrid mode, we want the reranked_results
            search_results = data.get("reranked_results", [])

            # If no reranked results, fall back to dense or sparse results
            if not search_results:
                search_results = data.get("dense_results", [])
            if not search_results:
                search_results = data.get("sparse_results", [])

            for item in search_results:
                # Extract doc_id and chunk_id from the result
                doc_id = item.get("doc_id", "")
                chunk_id = item.get("chunk_id", f"{doc_id}_chunk_{len(results)}")

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

                result = SearchResult(
                    doc_id=doc_id,
                    chunk_id=chunk_id,
                    text=text,
                    score=score,
                    metadata=item.get("metadata", {})
                )
                results.append(result.to_dict())

            logger.info(f"Local search returned {len(results)} results")
            return results

        except requests.exceptions.RequestException as e:
            logger.error(f"Error connecting to local retrieval pipeline: {e}")
            return []

    def _search_dify(self, query: str) -> List[Dict[str, Any]]:
        """Search using Dify API"""
        if not self.config.dify_api_key:
            logger.error("Dify API key not configured")
            return []

        try:
            headers = {
                "Authorization": f"Bearer {self.config.dify_api_key}",
                "Content-Type": "application/json"
            }

            payload = {
                "query": query,
                "top_k": self.config.dify_top_k
            }

            if self.config.dify_dataset_id:
                payload["dataset_id"] = self.config.dify_dataset_id

            response = requests.post(
                f"{self.config.dify_base_url}/datasets/search",
                headers=headers,
                json=payload
            )
            response.raise_for_status()

            results = []
            data = response.json()

            for item in data.get("data", {}).get("records", []):
                doc_id = item.get("document_id", "")
                chunk_id = item.get("segment_id", f"{doc_id}_chunk_{len(results)}")

                result = SearchResult(
                    doc_id=doc_id,
                    chunk_id=chunk_id,
                    text=item.get("content", ""),
                    score=item.get("score", 0.0),
                    metadata=item.get("metadata", {})
                )
                results.append(result.to_dict())

            logger.info(f"Dify search returned {len(results)} results")
            return results

        except requests.exceptions.RequestException as e:
            logger.error(f"Error connecting to Dify API: {e}")
            return []

    def _search_raptor(self, query: str) -> List[Dict[str, Any]]:
        """Search using RAPTOR tree-based index"""
        try:
            response = requests.post(
                f"{self.config.raptor_base_url}/query",
                json={
                    "query": query,
                    "index_type": "raptor",
                    "top_k": self.config.raptor_top_k
                }
            )
            response.raise_for_status()

            results = []
            data = response.json()

            for i, item in enumerate(data.get("results", [])):
                # RAPTOR returns tree nodes with levels and summaries
                doc_id = item.get("node_id", f"raptor_node_{i}")
                chunk_id = f"{doc_id}_level_{item.get('level', 0)}"

                # Use summary if available, otherwise use text
                text_content = item.get("summary", item.get("text", ""))

                result = SearchResult(
                    doc_id=doc_id,
                    chunk_id=chunk_id,
                    text=text_content,
                    score=item.get("score", 0.0),
                    metadata={
                        "level": item.get("level", 0),
                        "source": "raptor"
                    }
                )
                results.append(result.to_dict())

            logger.info(f"RAPTOR search returned {len(results)} results")
            return results

        except requests.exceptions.RequestException as e:
            logger.error(f"Error connecting to RAPTOR index: {e}")
            return []

    def _search_graphrag(self, query: str) -> List[Dict[str, Any]]:
        """Search using GraphRAG knowledge graph index"""
        try:
            response = requests.post(
                f"{self.config.graphrag_base_url}/query",
                json={
                    "query": query,
                    "index_type": "graphrag",
                    "top_k": self.config.graphrag_top_k,
                    "search_type": self.config.graphrag_search_type
                }
            )
            response.raise_for_status()

            results = []
            data = response.json()

            for i, item in enumerate(data.get("results", [])):
                # GraphRAG returns entities or communities
                result_type = item.get("type", "unknown")

                if result_type == "entity":
                    doc_id = item.get("id", f"entity_{i}")
                    chunk_id = f"{doc_id}_{item.get('entity_type', 'unknown')}"
                    text_content = f"{item.get('name', '')}. {item.get('description', '')}"
                    metadata = {
                        "type": "entity",
                        "entity_type": item.get("entity_type"),
                        "related_entities": item.get("related_entities", [])
                    }
                else:  # community
                    doc_id = item.get("id", f"community_{i}")
                    chunk_id = f"{doc_id}_level_{item.get('level', 0)}"
                    text_content = item.get("summary", "")
                    metadata = {
                        "type": "community",
                        "level": item.get("level", 0),
                        "entity_count": item.get("entity_count", 0),
                        "sample_entities": item.get("sample_entities", [])
                    }

                result = SearchResult(
                    doc_id=doc_id,
                    chunk_id=chunk_id,
                    text=text_content,
                    score=item.get("score", 0.0),
                    metadata={**metadata, "source": "graphrag"}
                )
                results.append(result.to_dict())

            logger.info(f"GraphRAG search returned {len(results)} results")
            return results

        except requests.exceptions.RequestException as e:
            logger.error(f"Error connecting to GraphRAG index: {e}")
            return []

    def get_document(self, doc_id: str) -> Dict[str, Any]:
        """
        Retrieve the entire document from the knowledge base.

        Args:
            doc_id: Document ID

        Returns:
            Full document content and metadata
        """
        try:
            # First check local document store
            if doc_id in self.document_store:
                return self.document_store[doc_id]

            if self.config.type == KnowledgeBaseType.OFFLINE:
                return self._get_offline_retriever().get_document(doc_id)
            elif self.config.type == KnowledgeBaseType.LOCAL:
                return self._get_document_local(doc_id)
            elif self.config.type == KnowledgeBaseType.DIFY:
                return self._get_document_dify(doc_id)
            elif self.config.type == KnowledgeBaseType.RAPTOR:
                return self._get_document_raptor(doc_id)
            elif self.config.type == KnowledgeBaseType.GRAPHRAG:
                return self._get_document_graphrag(doc_id)
            else:
                raise ValueError(f"Unsupported knowledge base type: {self.config.type}")
        except Exception as e:
            logger.error(f"Error retrieving document {doc_id}: {e}")
            return {"error": f"Document {doc_id} not found"}

    def _get_document_local(self, doc_id: str) -> Dict[str, Any]:
        """Get document from local retrieval pipeline"""
        try:
            response = requests.get(
                f"{self.config.local_base_url}/documents/{doc_id}"
            )

            if response.status_code == 404:
                return {"error": f"Document {doc_id} not found"}

            response.raise_for_status()
            return response.json()

        except requests.exceptions.RequestException as e:
            logger.error(f"Error getting document from local pipeline: {e}")
            return {"error": str(e)}

    def _get_document_dify(self, doc_id: str) -> Dict[str, Any]:
        """Get document from Dify"""
        if not self.config.dify_api_key:
            return {"error": "Dify API key not configured"}

        try:
            headers = {
                "Authorization": f"Bearer {self.config.dify_api_key}",
                "Content-Type": "application/json"
            }

            response = requests.get(
                f"{self.config.dify_base_url}/documents/{doc_id}",
                headers=headers
            )

            if response.status_code == 404:
                return {"error": f"Document {doc_id} not found"}

            response.raise_for_status()
            return response.json()

        except requests.exceptions.RequestException as e:
            logger.error(f"Error getting document from Dify: {e}")
            return {"error": str(e)}

    def _get_document_raptor(self, doc_id: str) -> Dict[str, Any]:
        """Get document/node from RAPTOR index"""
        try:
            # For RAPTOR, we perform a targeted search for the specific node
            response = requests.post(
                f"{self.config.raptor_base_url}/query",
                json={
                    "query": f"node:{doc_id}",  # Specific node query
                    "index_type": "raptor",
                    "top_k": 1
                }
            )

            if response.status_code == 404:
                return {"error": f"Document {doc_id} not found"}

            response.raise_for_status()
            data = response.json()

            if data.get("results"):
                result = data["results"][0]
                return {
                    "doc_id": doc_id,
                    "content": result.get("text", ""),
                    "metadata": {
                        "summary": result.get("summary", ""),
                        "level": result.get("level", 0),
                        "source": "raptor"
                    }
                }

            return {"error": f"Document {doc_id} not found"}

        except requests.exceptions.RequestException as e:
            logger.error(f"Error getting document from RAPTOR: {e}")
            return {"error": str(e)}

    def _get_document_graphrag(self, doc_id: str) -> Dict[str, Any]:
        """Get entity or community from GraphRAG index"""
        try:
            # For GraphRAG, we perform a targeted search for the specific entity/community
            response = requests.post(
                f"{self.config.graphrag_base_url}/query",
                json={
                    "query": f"id:{doc_id}",  # Specific ID query
                    "index_type": "graphrag",
                    "top_k": 1,
                    "search_type": "hybrid"
                }
            )

            if response.status_code == 404:
                return {"error": f"Document {doc_id} not found"}

            response.raise_for_status()
            data = response.json()

            if data.get("results"):
                result = data["results"][0]
                content = ""
                metadata = {"source": "graphrag"}

                if result.get("type") == "entity":
                    content = f"{result.get('name', '')}\n\n{result.get('description', '')}"
                    metadata.update({
                        "type": "entity",
                        "entity_type": result.get("entity_type"),
                        "related_entities": result.get("related_entities", [])
                    })
                else:  # community
                    content = result.get("summary", "")
                    metadata.update({
                        "type": "community",
                        "level": result.get("level", 0),
                        "entity_count": result.get("entity_count", 0),
                        "sample_entities": result.get("sample_entities", [])
                    })

                return {
                    "doc_id": doc_id,
                    "content": content,
                    "metadata": metadata
                }

            return {"error": f"Document {doc_id} not found"}

        except requests.exceptions.RequestException as e:
            logger.error(f"Error getting document from GraphRAG: {e}")
            return {"error": str(e)}

    def add_document(self, doc_id: str, content: str, metadata: Optional[Dict] = None):
        """Add a document to the local store"""
        self.document_store[doc_id] = {
            "doc_id": doc_id,
            "content": content,
            "metadata": metadata or {}
        }
        self.save_document_store()


# Tool function definitions for agent
def get_tool_definitions() -> List[Dict[str, Any]]:
    """Get OpenAI-format tool definitions"""
    return [
        {
            "type": "function",
            "function": {
                "name": "knowledge_base_search",
                "description": "Search the knowledge base for relevant information using a natural language query. Returns top-matching document chunks.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "Natural language search query to find relevant information"
                        }
                    },
                    "required": ["query"]
                }
            }
        },
        {
            "type": "function", 
            "function": {
                "name": "get_document",
                "description": "Retrieve the complete content of a specific document from the knowledge base using its document ID.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "doc_id": {
                            "type": "string",
                            "description": "The unique identifier of the document to retrieve"
                        }
                    },
                    "required": ["doc_id"]
                }
            }
        }
    ]

evaluation/offline_qa.json

{
  "description": "离线检索对比数据集:用于在无需 LLM / 无需外部检索服务的情况下,量化对比『非智能体化 RAG(单次检索)』与『智能体化 RAG(多轮/分解检索)』的证据召回能力。每条问题标注了回答所必需的金标准法条(gold_articles,以法条编号精确匹配)。naive_query 为用户原始提问(单次检索直接使用);subqueries 为智能体经过思考后分解/改写出的检索式(多次检索后取并集)。金标准法条均已确认存在于 laws/ 语料中。",
  "metric": "证据召回率 = 命中的金标准法条数 / 金标准法条总数(某法条被命中当且仅当检索结果中存在以该法条编号开头的分块)",
  "cases": [
    {
      "id": "easy_1",
      "question": "故意伤害致人重伤的,如何处罚?",
      "difficulty": "easy",
      "naive_query": "故意伤害致人重伤的,如何处罚?",
      "subqueries": ["故意伤害 致人重伤 有期徒刑"],
      "gold_articles": ["第二百三十四条"]
    },
    {
      "id": "easy_2",
      "question": "正当防卫是怎么规定的?",
      "difficulty": "easy",
      "naive_query": "正当防卫是怎么规定的?",
      "subqueries": ["正当防卫 不负刑事责任"],
      "gold_articles": ["第二十条"]
    },
    {
      "id": "easy_3",
      "question": "醉酒驾驶机动车如何处罚?",
      "difficulty": "easy",
      "naive_query": "醉酒驾驶机动车如何处罚?",
      "subqueries": ["危险驾驶罪 醉酒 驾驶机动车 拘役"],
      "gold_articles": ["第一百三十三条之一"]
    },
    {
      "id": "hard_1",
      "question": "故意杀人罪判几年?",
      "difficulty": "hard",
      "naive_query": "故意杀人罪判几年?",
      "subqueries": ["故意杀人 死刑 无期徒刑"],
      "gold_articles": ["第二百三十二条"]
    },
    {
      "id": "hard_2",
      "question": "盗窃罪的立案标准是什么?",
      "difficulty": "hard",
      "naive_query": "盗窃罪的立案标准是什么?",
      "subqueries": ["盗窃 公私财物 数额较大 有期徒刑"],
      "gold_articles": ["第二百六十四条"]
    },
    {
      "id": "hard_3",
      "question": "诈骗罪的量刑标准是什么?",
      "difficulty": "hard",
      "naive_query": "诈骗罪的量刑标准是什么?",
      "subqueries": ["诈骗 公私财物 数额较大 有期徒刑 罚金"],
      "gold_articles": ["第二百六十六条"]
    },
    {
      "id": "hard_4",
      "question": "醉酒过失致人重伤且有盗窃前科,应如何量刑?",
      "difficulty": "hard",
      "naive_query": "醉酒过失致人重伤且有盗窃前科,应如何量刑?",
      "subqueries": [
        "过失伤害他人致人重伤 有期徒刑",
        "危险驾驶罪 醉酒 驾驶机动车",
        "累犯 从重处罚 过失犯罪"
      ],
      "gold_articles": ["第二百三十五条", "第一百三十三条之一", "第六十五条"]
    }
  ]
}