chore: init monorepo snapshot
This commit is contained in:
@@ -0,0 +1,768 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script: Demonstrates usage of aquery_data FastAPI endpoint
|
||||
Query content: Who is the author of LightRAG
|
||||
|
||||
Updated to handle the new data format where:
|
||||
- Response includes status, message, data, and metadata fields at top level
|
||||
- Actual query results (entities, relationships, chunks, references) are nested under 'data' field
|
||||
- Includes backward compatibility with legacy format
|
||||
"""
|
||||
|
||||
import requests
|
||||
import time
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# API configuration
|
||||
API_KEY = "your-secure-api-key-here-123"
|
||||
BASE_URL = "http://localhost:9621"
|
||||
|
||||
# Unified authentication headers
|
||||
AUTH_HEADERS = {"Content-Type": "application/json", "X-API-Key": API_KEY}
|
||||
|
||||
|
||||
def validate_references_format(references: List[Dict[str, Any]]) -> bool:
|
||||
"""Validate the format of references list"""
|
||||
if not isinstance(references, list):
|
||||
print(f"❌ References should be a list, got {type(references)}")
|
||||
return False
|
||||
|
||||
for i, ref in enumerate(references):
|
||||
if not isinstance(ref, dict):
|
||||
print(f"❌ Reference {i} should be a dict, got {type(ref)}")
|
||||
return False
|
||||
|
||||
required_fields = ["reference_id", "file_path"]
|
||||
for field in required_fields:
|
||||
if field not in ref:
|
||||
print(f"❌ Reference {i} missing required field: {field}")
|
||||
return False
|
||||
|
||||
if not isinstance(ref[field], str):
|
||||
print(
|
||||
f"❌ Reference {i} field '{field}' should be string, got {type(ref[field])}"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def parse_streaming_response(
|
||||
response_text: str,
|
||||
) -> tuple[Optional[List[Dict]], List[str], List[str]]:
|
||||
"""Parse streaming response and extract references, response chunks, and errors"""
|
||||
references = None
|
||||
response_chunks = []
|
||||
errors = []
|
||||
|
||||
lines = response_text.strip().split("\n")
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("data: "):
|
||||
if line.startswith("data: "):
|
||||
line = line[6:] # Remove 'data: ' prefix
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
|
||||
if "references" in data:
|
||||
references = data["references"]
|
||||
if "response" in data:
|
||||
response_chunks.append(data["response"])
|
||||
if "error" in data:
|
||||
errors.append(data["error"])
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Skip non-JSON lines (like SSE comments)
|
||||
continue
|
||||
|
||||
return references, response_chunks, errors
|
||||
|
||||
|
||||
def test_query_endpoint_references():
|
||||
"""Test /query endpoint references functionality"""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Testing /query endpoint references functionality")
|
||||
print("=" * 60)
|
||||
|
||||
query_text = "who authored LightRAG"
|
||||
endpoint = f"{BASE_URL}/query"
|
||||
|
||||
# Test 1: References enabled (default)
|
||||
print("\n🧪 Test 1: References enabled (default)")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
json={"query": query_text, "mode": "mix", "include_references": True},
|
||||
headers=AUTH_HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
if "response" not in data:
|
||||
print("❌ Missing 'response' field")
|
||||
return False
|
||||
|
||||
if "references" not in data:
|
||||
print("❌ Missing 'references' field when include_references=True")
|
||||
return False
|
||||
|
||||
references = data["references"]
|
||||
if references is None:
|
||||
print("❌ References should not be None when include_references=True")
|
||||
return False
|
||||
|
||||
if not validate_references_format(references):
|
||||
return False
|
||||
|
||||
print(f"✅ References enabled: Found {len(references)} references")
|
||||
print(f" Response length: {len(data['response'])} characters")
|
||||
|
||||
# Display reference list
|
||||
if references:
|
||||
print(" 📚 Reference List:")
|
||||
for i, ref in enumerate(references, 1):
|
||||
ref_id = ref.get("reference_id", "Unknown")
|
||||
file_path = ref.get("file_path", "Unknown")
|
||||
print(f" {i}. ID: {ref_id} | File: {file_path}")
|
||||
|
||||
else:
|
||||
print(f"❌ Request failed: {response.status_code}")
|
||||
print(f" Error: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test 1 failed: {str(e)}")
|
||||
return False
|
||||
|
||||
# Test 2: References disabled
|
||||
print("\n🧪 Test 2: References disabled")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
json={"query": query_text, "mode": "mix", "include_references": False},
|
||||
headers=AUTH_HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
if "response" not in data:
|
||||
print("❌ Missing 'response' field")
|
||||
return False
|
||||
|
||||
references = data.get("references")
|
||||
if references is not None:
|
||||
print("❌ References should be None when include_references=False")
|
||||
return False
|
||||
|
||||
print("✅ References disabled: No references field present")
|
||||
print(f" Response length: {len(data['response'])} characters")
|
||||
|
||||
else:
|
||||
print(f"❌ Request failed: {response.status_code}")
|
||||
print(f" Error: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test 2 failed: {str(e)}")
|
||||
return False
|
||||
|
||||
print("\n✅ /query endpoint references tests passed!")
|
||||
return True
|
||||
|
||||
|
||||
def test_query_stream_endpoint_references():
|
||||
"""Test /query/stream endpoint references functionality"""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Testing /query/stream endpoint references functionality")
|
||||
print("=" * 60)
|
||||
|
||||
query_text = "who authored LightRAG"
|
||||
endpoint = f"{BASE_URL}/query/stream"
|
||||
|
||||
# Test 1: Streaming with references enabled
|
||||
print("\n🧪 Test 1: Streaming with references enabled")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
json={"query": query_text, "mode": "mix", "include_references": True},
|
||||
headers=AUTH_HEADERS,
|
||||
timeout=30,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Collect streaming response
|
||||
full_response = ""
|
||||
for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
|
||||
if chunk:
|
||||
# Ensure chunk is string type
|
||||
if isinstance(chunk, bytes):
|
||||
chunk = chunk.decode("utf-8")
|
||||
full_response += chunk
|
||||
|
||||
# Parse streaming response
|
||||
references, response_chunks, errors = parse_streaming_response(
|
||||
full_response
|
||||
)
|
||||
|
||||
if errors:
|
||||
print(f"❌ Errors in streaming response: {errors}")
|
||||
return False
|
||||
|
||||
if references is None:
|
||||
print("❌ No references found in streaming response")
|
||||
return False
|
||||
|
||||
if not validate_references_format(references):
|
||||
return False
|
||||
|
||||
if not response_chunks:
|
||||
print("❌ No response chunks found in streaming response")
|
||||
return False
|
||||
|
||||
print(f"✅ Streaming with references: Found {len(references)} references")
|
||||
print(f" Response chunks: {len(response_chunks)}")
|
||||
print(
|
||||
f" Total response length: {sum(len(chunk) for chunk in response_chunks)} characters"
|
||||
)
|
||||
|
||||
# Display reference list
|
||||
if references:
|
||||
print(" 📚 Reference List:")
|
||||
for i, ref in enumerate(references, 1):
|
||||
ref_id = ref.get("reference_id", "Unknown")
|
||||
file_path = ref.get("file_path", "Unknown")
|
||||
print(f" {i}. ID: {ref_id} | File: {file_path}")
|
||||
|
||||
else:
|
||||
print(f"❌ Request failed: {response.status_code}")
|
||||
print(f" Error: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test 1 failed: {str(e)}")
|
||||
return False
|
||||
|
||||
# Test 2: Streaming with references disabled
|
||||
print("\n🧪 Test 2: Streaming with references disabled")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
json={"query": query_text, "mode": "mix", "include_references": False},
|
||||
headers=AUTH_HEADERS,
|
||||
timeout=30,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Collect streaming response
|
||||
full_response = ""
|
||||
for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
|
||||
if chunk:
|
||||
# Ensure chunk is string type
|
||||
if isinstance(chunk, bytes):
|
||||
chunk = chunk.decode("utf-8")
|
||||
full_response += chunk
|
||||
|
||||
# Parse streaming response
|
||||
references, response_chunks, errors = parse_streaming_response(
|
||||
full_response
|
||||
)
|
||||
|
||||
if errors:
|
||||
print(f"❌ Errors in streaming response: {errors}")
|
||||
return False
|
||||
|
||||
if references is not None:
|
||||
print("❌ References should be None when include_references=False")
|
||||
return False
|
||||
|
||||
if not response_chunks:
|
||||
print("❌ No response chunks found in streaming response")
|
||||
return False
|
||||
|
||||
print("✅ Streaming without references: No references present")
|
||||
print(f" Response chunks: {len(response_chunks)}")
|
||||
print(
|
||||
f" Total response length: {sum(len(chunk) for chunk in response_chunks)} characters"
|
||||
)
|
||||
|
||||
else:
|
||||
print(f"❌ Request failed: {response.status_code}")
|
||||
print(f" Error: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test 2 failed: {str(e)}")
|
||||
return False
|
||||
|
||||
print("\n✅ /query/stream endpoint references tests passed!")
|
||||
return True
|
||||
|
||||
|
||||
def test_references_consistency():
|
||||
"""Test references consistency across all endpoints"""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Testing references consistency across endpoints")
|
||||
print("=" * 60)
|
||||
|
||||
query_text = "who authored LightRAG"
|
||||
query_params = {
|
||||
"query": query_text,
|
||||
"mode": "mix",
|
||||
"top_k": 10,
|
||||
"chunk_top_k": 8,
|
||||
"include_references": True,
|
||||
}
|
||||
|
||||
references_data = {}
|
||||
|
||||
# Test /query endpoint
|
||||
print("\n🧪 Testing /query endpoint")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/query", json=query_params, headers=AUTH_HEADERS, timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
references_data["query"] = data.get("references", [])
|
||||
print(f"✅ /query: {len(references_data['query'])} references")
|
||||
else:
|
||||
print(f"❌ /query failed: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ /query test failed: {str(e)}")
|
||||
return False
|
||||
|
||||
# Test /query/stream endpoint
|
||||
print("\n🧪 Testing /query/stream endpoint")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/query/stream",
|
||||
json=query_params,
|
||||
headers=AUTH_HEADERS,
|
||||
timeout=30,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
full_response = ""
|
||||
for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
|
||||
if chunk:
|
||||
# Ensure chunk is string type
|
||||
if isinstance(chunk, bytes):
|
||||
chunk = chunk.decode("utf-8")
|
||||
full_response += chunk
|
||||
|
||||
references, _, errors = parse_streaming_response(full_response)
|
||||
|
||||
if errors:
|
||||
print(f"❌ Errors: {errors}")
|
||||
return False
|
||||
|
||||
references_data["stream"] = references or []
|
||||
print(f"✅ /query/stream: {len(references_data['stream'])} references")
|
||||
else:
|
||||
print(f"❌ /query/stream failed: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ /query/stream test failed: {str(e)}")
|
||||
return False
|
||||
|
||||
# Test /query/data endpoint
|
||||
print("\n🧪 Testing /query/data endpoint")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/query/data",
|
||||
json=query_params,
|
||||
headers=AUTH_HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
query_data = data.get("data", {})
|
||||
references_data["data"] = query_data.get("references", [])
|
||||
print(f"✅ /query/data: {len(references_data['data'])} references")
|
||||
else:
|
||||
print(f"❌ /query/data failed: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ /query/data test failed: {str(e)}")
|
||||
return False
|
||||
|
||||
# Compare references consistency
|
||||
print("\n🔍 Comparing references consistency")
|
||||
print("-" * 40)
|
||||
|
||||
# Convert to sets of (reference_id, file_path) tuples for comparison
|
||||
def refs_to_set(refs):
|
||||
return set(
|
||||
(ref.get("reference_id", ""), ref.get("file_path", "")) for ref in refs
|
||||
)
|
||||
|
||||
query_refs = refs_to_set(references_data["query"])
|
||||
stream_refs = refs_to_set(references_data["stream"])
|
||||
data_refs = refs_to_set(references_data["data"])
|
||||
|
||||
# Check consistency
|
||||
consistency_passed = True
|
||||
|
||||
if query_refs != stream_refs:
|
||||
print("❌ References mismatch between /query and /query/stream")
|
||||
print(f" /query only: {query_refs - stream_refs}")
|
||||
print(f" /query/stream only: {stream_refs - query_refs}")
|
||||
consistency_passed = False
|
||||
|
||||
if query_refs != data_refs:
|
||||
print("❌ References mismatch between /query and /query/data")
|
||||
print(f" /query only: {query_refs - data_refs}")
|
||||
print(f" /query/data only: {data_refs - query_refs}")
|
||||
consistency_passed = False
|
||||
|
||||
if stream_refs != data_refs:
|
||||
print("❌ References mismatch between /query/stream and /query/data")
|
||||
print(f" /query/stream only: {stream_refs - data_refs}")
|
||||
print(f" /query/data only: {data_refs - stream_refs}")
|
||||
consistency_passed = False
|
||||
|
||||
if consistency_passed:
|
||||
print("✅ All endpoints return consistent references")
|
||||
print(f" Common references count: {len(query_refs)}")
|
||||
|
||||
# Display common reference list
|
||||
if query_refs:
|
||||
print(" 📚 Common Reference List:")
|
||||
for i, (ref_id, file_path) in enumerate(sorted(query_refs), 1):
|
||||
print(f" {i}. ID: {ref_id} | File: {file_path}")
|
||||
|
||||
return consistency_passed
|
||||
|
||||
|
||||
def test_aquery_data_endpoint():
|
||||
"""Test the /query/data endpoint"""
|
||||
|
||||
# Use unified configuration
|
||||
endpoint = f"{BASE_URL}/query/data"
|
||||
|
||||
# Query request
|
||||
query_request = {
|
||||
"query": "who authored LighRAG",
|
||||
"mode": "mix", # Use mixed mode to get the most comprehensive results
|
||||
"top_k": 20,
|
||||
"chunk_top_k": 15,
|
||||
"max_entity_tokens": 4000,
|
||||
"max_relation_tokens": 4000,
|
||||
"max_total_tokens": 16000,
|
||||
"enable_rerank": True,
|
||||
}
|
||||
|
||||
print("=" * 60)
|
||||
print("LightRAG aquery_data endpoint test")
|
||||
print(
|
||||
" Returns structured data including entities, relationships and text chunks"
|
||||
)
|
||||
print(" Can be used for custom processing and analysis")
|
||||
print("=" * 60)
|
||||
print(f"Query content: {query_request['query']}")
|
||||
print(f"Query mode: {query_request['mode']}")
|
||||
print(f"API endpoint: {endpoint}")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
# Send request
|
||||
print("Sending request...")
|
||||
start_time = time.time()
|
||||
|
||||
response = requests.post(
|
||||
endpoint, json=query_request, headers=AUTH_HEADERS, timeout=30
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
response_time = end_time - start_time
|
||||
|
||||
print(f"Response time: {response_time:.2f} seconds")
|
||||
print(f"HTTP status code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print_query_results(data)
|
||||
else:
|
||||
print(f"Request failed: {response.status_code}")
|
||||
print(f"Error message: {response.text}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ Connection failed: Please ensure LightRAG API service is running")
|
||||
print(" Start command: python -m lightrag.api.lightrag_server")
|
||||
except requests.exceptions.Timeout:
|
||||
print("❌ Request timeout: Query processing took too long")
|
||||
except Exception as e:
|
||||
print(f"❌ Error occurred: {str(e)}")
|
||||
|
||||
|
||||
def print_query_results(data: Dict[str, Any]):
|
||||
"""Format and print query results"""
|
||||
|
||||
# Check for new data format with status and message
|
||||
status = data.get("status", "unknown")
|
||||
message = data.get("message", "")
|
||||
|
||||
print(f"\n📋 Query Status: {status}")
|
||||
if message:
|
||||
print(f"📋 Message: {message}")
|
||||
|
||||
# Handle new nested data format
|
||||
query_data = data.get("data", {})
|
||||
|
||||
# Fallback to old format if new format is not present
|
||||
if not query_data and any(
|
||||
key in data for key in ["entities", "relationships", "chunks"]
|
||||
):
|
||||
print(" (Using legacy data format)")
|
||||
query_data = data
|
||||
|
||||
entities = query_data.get("entities", [])
|
||||
relationships = query_data.get("relationships", [])
|
||||
chunks = query_data.get("chunks", [])
|
||||
references = query_data.get("references", [])
|
||||
|
||||
print("\n📊 Query result statistics:")
|
||||
print(f" Entity count: {len(entities)}")
|
||||
print(f" Relationship count: {len(relationships)}")
|
||||
print(f" Text chunk count: {len(chunks)}")
|
||||
print(f" Reference count: {len(references)}")
|
||||
|
||||
# Print metadata (now at top level in new format)
|
||||
metadata = data.get("metadata", {})
|
||||
if metadata:
|
||||
print("\n🔍 Query metadata:")
|
||||
print(f" Query mode: {metadata.get('query_mode', 'unknown')}")
|
||||
|
||||
keywords = metadata.get("keywords", {})
|
||||
if keywords:
|
||||
high_level = keywords.get("high_level", [])
|
||||
low_level = keywords.get("low_level", [])
|
||||
if high_level:
|
||||
print(f" High-level keywords: {', '.join(high_level)}")
|
||||
if low_level:
|
||||
print(f" Low-level keywords: {', '.join(low_level)}")
|
||||
|
||||
processing_info = metadata.get("processing_info", {})
|
||||
if processing_info:
|
||||
print(" Processing info:")
|
||||
for key, value in processing_info.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
# Print entity information
|
||||
if entities:
|
||||
print("\n👥 Retrieved entities (first 5):")
|
||||
for i, entity in enumerate(entities[:5]):
|
||||
entity_name = entity.get("entity_name", "Unknown")
|
||||
entity_type = entity.get("entity_type", "Unknown")
|
||||
description = entity.get("description", "No description")
|
||||
file_path = entity.get("file_path", "Unknown source")
|
||||
reference_id = entity.get("reference_id", "No reference")
|
||||
|
||||
print(f" {i+1}. {entity_name} ({entity_type})")
|
||||
print(
|
||||
f" Description: {description[:100]}{'...' if len(description) > 100 else ''}"
|
||||
)
|
||||
print(f" Source: {file_path}")
|
||||
print(f" Reference ID: {reference_id}")
|
||||
print()
|
||||
|
||||
# Print relationship information
|
||||
if relationships:
|
||||
print("🔗 Retrieved relationships (first 5):")
|
||||
for i, rel in enumerate(relationships[:5]):
|
||||
src = rel.get("src_id", "Unknown")
|
||||
tgt = rel.get("tgt_id", "Unknown")
|
||||
description = rel.get("description", "No description")
|
||||
keywords = rel.get("keywords", "No keywords")
|
||||
file_path = rel.get("file_path", "Unknown source")
|
||||
reference_id = rel.get("reference_id", "No reference")
|
||||
|
||||
print(f" {i+1}. {src} → {tgt}")
|
||||
print(f" Keywords: {keywords}")
|
||||
print(
|
||||
f" Description: {description[:100]}{'...' if len(description) > 100 else ''}"
|
||||
)
|
||||
print(f" Source: {file_path}")
|
||||
print(f" Reference ID: {reference_id}")
|
||||
print()
|
||||
|
||||
# Print text chunk information
|
||||
if chunks:
|
||||
print("📄 Retrieved text chunks (first 3):")
|
||||
for i, chunk in enumerate(chunks[:3]):
|
||||
content = chunk.get("content", "No content")
|
||||
file_path = chunk.get("file_path", "Unknown source")
|
||||
chunk_id = chunk.get("chunk_id", "Unknown ID")
|
||||
reference_id = chunk.get("reference_id", "No reference")
|
||||
|
||||
print(f" {i+1}. Text chunk ID: {chunk_id}")
|
||||
print(f" Source: {file_path}")
|
||||
print(f" Reference ID: {reference_id}")
|
||||
print(
|
||||
f" Content: {content[:200]}{'...' if len(content) > 200 else ''}"
|
||||
)
|
||||
print()
|
||||
|
||||
# Print references information (new in updated format)
|
||||
if references:
|
||||
print("📚 References:")
|
||||
for i, ref in enumerate(references):
|
||||
reference_id = ref.get("reference_id", "Unknown ID")
|
||||
file_path = ref.get("file_path", "Unknown source")
|
||||
print(f" {i+1}. Reference ID: {reference_id}")
|
||||
print(f" File Path: {file_path}")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def compare_with_regular_query():
|
||||
"""Compare results between regular query and data query"""
|
||||
|
||||
query_text = "LightRAG的作者是谁"
|
||||
|
||||
print("\n🔄 Comparison test: Regular query vs Data query")
|
||||
print("-" * 60)
|
||||
|
||||
# Regular query
|
||||
try:
|
||||
print("1. Regular query (/query):")
|
||||
regular_response = requests.post(
|
||||
f"{BASE_URL}/query",
|
||||
json={"query": query_text, "mode": "mix"},
|
||||
headers=AUTH_HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if regular_response.status_code == 200:
|
||||
regular_data = regular_response.json()
|
||||
response_text = regular_data.get("response", "No response")
|
||||
print(
|
||||
f" Generated answer: {response_text[:300]}{'...' if len(response_text) > 300 else ''}"
|
||||
)
|
||||
else:
|
||||
print(f" Regular query failed: {regular_response.status_code}")
|
||||
if regular_response.status_code == 403:
|
||||
print(" Authentication failed - Please check API Key configuration")
|
||||
elif regular_response.status_code == 401:
|
||||
print(" Unauthorized - Please check authentication information")
|
||||
print(f" Error details: {regular_response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" Regular query error: {str(e)}")
|
||||
|
||||
|
||||
def run_all_reference_tests():
|
||||
"""Run all reference-related tests"""
|
||||
|
||||
print("\n" + "🚀" * 20)
|
||||
print("LightRAG References Test Suite")
|
||||
print("🚀" * 20)
|
||||
|
||||
all_tests_passed = True
|
||||
|
||||
# Test 1: /query endpoint references
|
||||
try:
|
||||
if not test_query_endpoint_references():
|
||||
all_tests_passed = False
|
||||
except Exception as e:
|
||||
print(f"❌ /query endpoint test failed with exception: {str(e)}")
|
||||
all_tests_passed = False
|
||||
|
||||
# Test 2: /query/stream endpoint references
|
||||
try:
|
||||
if not test_query_stream_endpoint_references():
|
||||
all_tests_passed = False
|
||||
except Exception as e:
|
||||
print(f"❌ /query/stream endpoint test failed with exception: {str(e)}")
|
||||
all_tests_passed = False
|
||||
|
||||
# Test 3: References consistency across endpoints
|
||||
try:
|
||||
if not test_references_consistency():
|
||||
all_tests_passed = False
|
||||
except Exception as e:
|
||||
print(f"❌ References consistency test failed with exception: {str(e)}")
|
||||
all_tests_passed = False
|
||||
|
||||
# Final summary
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST SUITE SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
if all_tests_passed:
|
||||
print("🎉 ALL TESTS PASSED!")
|
||||
print("✅ /query endpoint references functionality works correctly")
|
||||
print("✅ /query/stream endpoint references functionality works correctly")
|
||||
print("✅ References are consistent across all endpoints")
|
||||
print("\n🔧 System is ready for production use with reference support!")
|
||||
else:
|
||||
print("❌ SOME TESTS FAILED!")
|
||||
print("Please check the error messages above and fix the issues.")
|
||||
print("\n🔧 System needs attention before production deployment.")
|
||||
|
||||
return all_tests_passed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--references-only":
|
||||
# Run only the new reference tests
|
||||
success = run_all_reference_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
else:
|
||||
# Run original tests plus new reference tests
|
||||
print("Running original aquery_data endpoint test...")
|
||||
test_aquery_data_endpoint()
|
||||
|
||||
print("\nRunning comparison test...")
|
||||
compare_with_regular_query()
|
||||
|
||||
print("\nRunning new reference tests...")
|
||||
run_all_reference_tests()
|
||||
|
||||
print("\n💡 Usage tips:")
|
||||
print("1. Ensure LightRAG API service is running")
|
||||
print("2. Adjust base_url and authentication information as needed")
|
||||
print("3. Modify query parameters to test different retrieval strategies")
|
||||
print("4. Data query results can be used for further analysis and processing")
|
||||
print("5. Run with --references-only flag to test only reference functionality")
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/bin/bash
|
||||
|
||||
# LightRAG aquery_data endpoint test script
|
||||
# Use curl command to test the new /query/data endpoint and validate the new data format
|
||||
|
||||
echo "🚀 LightRAG aquery_data Endpoint Test (New Data Format Validation)"
|
||||
echo "=================================================="
|
||||
|
||||
# Base URL (adjust according to actual deployment)
|
||||
BASE_URL="http://localhost:9621"
|
||||
|
||||
# Color definitions
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test result statistics
|
||||
TOTAL_TESTS=0
|
||||
PASSED_TESTS=0
|
||||
FAILED_TESTS=0
|
||||
|
||||
# Function to validate success response format
|
||||
validate_success_response() {
|
||||
local response="$1"
|
||||
local test_name="$2"
|
||||
local expected_mode="$3"
|
||||
|
||||
echo -e "${BLUE}Validating $test_name response format...${NC}"
|
||||
|
||||
# Check if valid JSON
|
||||
if ! echo "$response" | jq . >/dev/null 2>&1; then
|
||||
echo -e "${RED}❌ Response is not valid JSON format${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate required fields
|
||||
local status=$(echo "$response" | jq -r '.status // "missing"')
|
||||
local message=$(echo "$response" | jq -r '.message // "missing"')
|
||||
local data_exists=$(echo "$response" | jq 'has("data")')
|
||||
local metadata_exists=$(echo "$response" | jq 'has("metadata")')
|
||||
|
||||
echo " Status: $status"
|
||||
echo " Message: $message"
|
||||
|
||||
# Validate data structure
|
||||
if [[ "$data_exists" == "true" ]]; then
|
||||
local entities_count=$(echo "$response" | jq '.data.entities | length // 0')
|
||||
local relationships_count=$(echo "$response" | jq '.data.relationships | length // 0')
|
||||
local chunks_count=$(echo "$response" | jq '.data.chunks | length // 0')
|
||||
local references_count=$(echo "$response" | jq '.data.references | length // 0')
|
||||
|
||||
echo " Data.entities: $entities_count"
|
||||
echo " Data.relationships: $relationships_count"
|
||||
echo " Data.chunks: $chunks_count"
|
||||
echo " Data.references: $references_count"
|
||||
else
|
||||
echo -e "${RED} ❌ Missing 'data' field${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate metadata
|
||||
if [[ "$metadata_exists" == "true" ]]; then
|
||||
local query_mode=$(echo "$response" | jq -r '.metadata.query_mode // "missing"')
|
||||
local keywords_exists=$(echo "$response" | jq 'has("metadata") and (.metadata | has("keywords"))')
|
||||
local processing_info_exists=$(echo "$response" | jq 'has("metadata") and (.metadata | has("processing_info"))')
|
||||
|
||||
echo " Metadata.query_mode: $query_mode"
|
||||
echo " Metadata.keywords: $keywords_exists"
|
||||
echo " Metadata.processing_info: $processing_info_exists"
|
||||
|
||||
# Validate if query mode matches
|
||||
if [[ "$expected_mode" != "" && "$query_mode" != "$expected_mode" ]]; then
|
||||
echo -e "${YELLOW} ⚠️ Query mode mismatch: expected '$expected_mode', actual '$query_mode'${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED} ❌ Missing 'metadata' field${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate status
|
||||
if [[ "$status" == "success" ]]; then
|
||||
echo -e "${GREEN} ✅ Response format validation passed${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED} ❌ Status is not 'success': $status${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to validate error response format
|
||||
validate_error_response() {
|
||||
local response="$1"
|
||||
local test_name="$2"
|
||||
|
||||
echo -e "${BLUE}Validating $test_name response format...${NC}"
|
||||
|
||||
# Check if valid JSON
|
||||
if ! echo "$response" | jq . >/dev/null 2>&1; then
|
||||
echo -e "${RED}❌ Response is not valid JSON format${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate required fields
|
||||
local status=$(echo "$response" | jq -r '.status // "missing"')
|
||||
local message=$(echo "$response" | jq -r '.message // "missing"')
|
||||
local data_exists=$(echo "$response" | jq 'has("data")')
|
||||
local metadata_exists=$(echo "$response" | jq 'has("metadata")')
|
||||
|
||||
echo " Status: $status"
|
||||
echo " Message: $message"
|
||||
|
||||
# Validate basic structure exists
|
||||
if [[ "$data_exists" != "true" ]]; then
|
||||
echo -e "${RED} ❌ Missing 'data' field${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$metadata_exists" != "true" ]]; then
|
||||
echo -e "${RED} ❌ Missing 'metadata' field${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " Data: {}"
|
||||
echo " Metadata: {}"
|
||||
|
||||
# Validate status should be failure
|
||||
if [[ "$status" == "failure" ]]; then
|
||||
echo -e "${GREEN} ✅ Error response format validation passed${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED} ❌ Status is not 'failure': $status${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run success test
|
||||
run_success_test() {
|
||||
local test_name="$1"
|
||||
local query_data="$2"
|
||||
local expected_mode="$3"
|
||||
local print_json="${4:-false}" # Optional parameter: whether to print JSON response (default: false)
|
||||
|
||||
echo ""
|
||||
echo "=================================="
|
||||
echo -e "${BLUE}$test_name${NC}"
|
||||
echo "=================================="
|
||||
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Send request
|
||||
echo "Sending request..."
|
||||
local response=$(curl -s -X POST "${BASE_URL}/query/data" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-secure-api-key-here-123" \
|
||||
-d "$query_data")
|
||||
|
||||
# Check if curl succeeded
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${RED}❌ Request failed - cannot connect to server${NC}"
|
||||
FAILED_TESTS=$((FAILED_TESTS + 1))
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Print JSON response if requested
|
||||
if [[ "$print_json" == "true" ]]; then
|
||||
echo ""
|
||||
echo "Response JSON:"
|
||||
echo "$response" | jq '.' 2>/dev/null || echo "$response"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Validate response
|
||||
if validate_success_response "$response" "$test_name" "$expected_mode"; then
|
||||
PASSED_TESTS=$((PASSED_TESTS + 1))
|
||||
echo -e "${GREEN}✅ $test_name test passed${NC}"
|
||||
else
|
||||
FAILED_TESTS=$((FAILED_TESTS + 1))
|
||||
echo -e "${RED}❌ $test_name test failed${NC}"
|
||||
echo "Raw response:"
|
||||
echo "$response" | jq '.' 2>/dev/null || echo "$response"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run error test
|
||||
run_error_test() {
|
||||
local test_name="$1"
|
||||
local query_data="$2"
|
||||
|
||||
echo ""
|
||||
echo "=================================="
|
||||
echo -e "${BLUE}$test_name${NC}"
|
||||
echo "=================================="
|
||||
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Send request
|
||||
echo "Sending request..."
|
||||
local response=$(curl -s -X POST "${BASE_URL}/query/data" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-secure-api-key-here-123" \
|
||||
-d "$query_data")
|
||||
|
||||
# Check if curl succeeded
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "${RED}❌ Request failed - cannot connect to server${NC}"
|
||||
FAILED_TESTS=$((FAILED_TESTS + 1))
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate response
|
||||
if validate_error_response "$response" "$test_name"; then
|
||||
PASSED_TESTS=$((PASSED_TESTS + 1))
|
||||
echo -e "${GREEN}✅ $test_name test passed${NC}"
|
||||
else
|
||||
FAILED_TESTS=$((FAILED_TESTS + 1))
|
||||
echo -e "${RED}❌ $test_name test failed${NC}"
|
||||
echo "Raw response:"
|
||||
echo "$response" | jq '.' 2>/dev/null || echo "$response"
|
||||
fi
|
||||
}
|
||||
|
||||
# Start tests
|
||||
echo "Starting tests for new /query/data endpoint data format..."
|
||||
echo ""
|
||||
|
||||
# Test 1: Basic query test (mix mode)
|
||||
run_success_test "1. Basic Query Test (mix mode)" '{
|
||||
"query": "What is GraphRAG",
|
||||
"mode": "mix",
|
||||
"top_k": 5
|
||||
}' "mix" "true" # Output full JSON
|
||||
|
||||
# Test 2: Detailed parameter query test (hybrid mode)
|
||||
run_success_test "2. Detailed Parameter Query Test (hybrid mode)" '{
|
||||
"query": "What is GraphRAG",
|
||||
"mode": "hybrid",
|
||||
"top_k": 5,
|
||||
"chunk_top_k": 8,
|
||||
"max_entity_tokens": 4000,
|
||||
"max_relation_tokens": 4000,
|
||||
"max_total_tokens": 16000,
|
||||
"enable_rerank": true,
|
||||
"response_type": "Multiple Paragraphs"
|
||||
}' "hybrid"
|
||||
|
||||
# Output test result statistics
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo -e "${BLUE}Test Result Statistics${NC}"
|
||||
echo "=================================================="
|
||||
echo -e "Total tests: ${BLUE}$TOTAL_TESTS${NC}"
|
||||
echo -e "Passed tests: ${GREEN}$PASSED_TESTS${NC}"
|
||||
echo -e "Failed tests: ${RED}$FAILED_TESTS${NC}"
|
||||
|
||||
if [[ $FAILED_TESTS -eq 0 ]]; then
|
||||
echo -e "${GREEN}🎉 All tests passed! New data format adaptation successful!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}⚠️ $FAILED_TESTS test(s) failed, please check the issues${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "💡 Usage Instructions:"
|
||||
echo "1. Ensure LightRAG API service is running (python -m lightrag.api.lightrag_server)"
|
||||
echo "2. Adjust BASE_URL as needed"
|
||||
echo "3. If authentication is required, add -H \"Authorization: Bearer your-token\""
|
||||
echo "4. Install jq for better JSON formatting output: brew install jq (macOS) or apt install jq (Ubuntu)"
|
||||
echo "5. Script will automatically validate new data format structure: status, message, data, metadata"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,855 @@
|
||||
"""
|
||||
LightRAG Ollama Compatibility Interface Test Script
|
||||
|
||||
This script tests the LightRAG's Ollama compatibility interface, including:
|
||||
1. Basic functionality tests (streaming and non-streaming responses)
|
||||
2. Query mode tests (local, global, naive, hybrid)
|
||||
3. Error handling tests (including streaming and non-streaming scenarios)
|
||||
|
||||
All responses use the JSON Lines format, complying with the Ollama API specification.
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import argparse
|
||||
import time
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class ErrorCode(Enum):
|
||||
"""Error codes for MCP errors"""
|
||||
|
||||
InvalidRequest = auto()
|
||||
InternalError = auto()
|
||||
|
||||
|
||||
class McpError(Exception):
|
||||
"""Base exception class for MCP errors"""
|
||||
|
||||
def __init__(self, code: ErrorCode, message: str):
|
||||
self.code = code
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"server": {
|
||||
"host": "localhost",
|
||||
"port": 9621,
|
||||
"model": "lightrag:latest",
|
||||
"timeout": 300,
|
||||
"max_retries": 1,
|
||||
"retry_delay": 1,
|
||||
},
|
||||
"test_cases": {
|
||||
"basic": {"query": "唐僧有几个徒弟"},
|
||||
"generate": {"query": "电视剧西游记导演是谁"},
|
||||
},
|
||||
}
|
||||
|
||||
# Example conversation history for testing
|
||||
EXAMPLE_CONVERSATION = [
|
||||
{"role": "user", "content": "你好"},
|
||||
{"role": "assistant", "content": "你好!我是一个AI助手,很高兴为你服务。"},
|
||||
{"role": "user", "content": "Who are you?"},
|
||||
{"role": "assistant", "content": "I'm a Knowledge base query assistant."},
|
||||
]
|
||||
|
||||
|
||||
class OutputControl:
|
||||
"""Output control class, manages the verbosity of test output"""
|
||||
|
||||
_verbose: bool = False
|
||||
|
||||
@classmethod
|
||||
def set_verbose(cls, verbose: bool) -> None:
|
||||
cls._verbose = verbose
|
||||
|
||||
@classmethod
|
||||
def is_verbose(cls) -> bool:
|
||||
return cls._verbose
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
"""Test result data class"""
|
||||
|
||||
name: str
|
||||
success: bool
|
||||
duration: float
|
||||
error: Optional[str] = None
|
||||
timestamp: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.now().isoformat()
|
||||
|
||||
|
||||
class TestStats:
|
||||
"""Test statistics"""
|
||||
|
||||
def __init__(self):
|
||||
self.results: List[TestResult] = []
|
||||
self.start_time = datetime.now()
|
||||
|
||||
def add_result(self, result: TestResult):
|
||||
self.results.append(result)
|
||||
|
||||
def export_results(self, path: str = "test_results.json"):
|
||||
"""Export test results to a JSON file
|
||||
Args:
|
||||
path: Output file path
|
||||
"""
|
||||
results_data = {
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"end_time": datetime.now().isoformat(),
|
||||
"results": [asdict(r) for r in self.results],
|
||||
"summary": {
|
||||
"total": len(self.results),
|
||||
"passed": sum(1 for r in self.results if r.success),
|
||||
"failed": sum(1 for r in self.results if not r.success),
|
||||
"total_duration": sum(r.duration for r in self.results),
|
||||
},
|
||||
}
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(results_data, f, ensure_ascii=False, indent=2)
|
||||
print(f"\nTest results saved to: {path}")
|
||||
|
||||
def print_summary(self):
|
||||
total = len(self.results)
|
||||
passed = sum(1 for r in self.results if r.success)
|
||||
failed = total - passed
|
||||
duration = sum(r.duration for r in self.results)
|
||||
|
||||
print("\n=== Test Summary ===")
|
||||
print(f"Start time: {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"Total duration: {duration:.2f} seconds")
|
||||
print(f"Total tests: {total}")
|
||||
print(f"Passed: {passed}")
|
||||
print(f"Failed: {failed}")
|
||||
|
||||
if failed > 0:
|
||||
print("\nFailed tests:")
|
||||
for result in self.results:
|
||||
if not result.success:
|
||||
print(f"- {result.name}: {result.error}")
|
||||
|
||||
|
||||
def make_request(
|
||||
url: str, data: Dict[str, Any], stream: bool = False, check_status: bool = True
|
||||
) -> requests.Response:
|
||||
"""Send an HTTP request with retry mechanism
|
||||
Args:
|
||||
url: Request URL
|
||||
data: Request data
|
||||
stream: Whether to use streaming response
|
||||
check_status: Whether to check HTTP status code (default: True)
|
||||
Returns:
|
||||
requests.Response: Response object
|
||||
|
||||
Raises:
|
||||
requests.exceptions.RequestException: Request failed after all retries
|
||||
requests.exceptions.HTTPError: HTTP status code is not 200 (when check_status is True)
|
||||
"""
|
||||
server_config = CONFIG["server"]
|
||||
max_retries = server_config["max_retries"]
|
||||
retry_delay = server_config["retry_delay"]
|
||||
timeout = server_config["timeout"]
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.post(url, json=data, stream=stream, timeout=timeout)
|
||||
if check_status and response.status_code != 200:
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.exceptions.RequestException as e:
|
||||
if attempt == max_retries - 1: # Last retry
|
||||
raise
|
||||
print(f"\nRequest failed, retrying in {retry_delay} seconds: {str(e)}")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
"""Load configuration file
|
||||
|
||||
First try to load from config.json in the current directory,
|
||||
if it doesn't exist, use the default configuration
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
"""
|
||||
config_path = Path("config.json")
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return DEFAULT_CONFIG
|
||||
|
||||
|
||||
def print_json_response(data: Dict[str, Any], title: str = "", indent: int = 2) -> None:
|
||||
"""Format and print JSON response data
|
||||
Args:
|
||||
data: Data dictionary to print
|
||||
title: Title to print
|
||||
indent: Number of spaces for JSON indentation
|
||||
"""
|
||||
if OutputControl.is_verbose():
|
||||
if title:
|
||||
print(f"\n=== {title} ===")
|
||||
print(json.dumps(data, ensure_ascii=False, indent=indent))
|
||||
|
||||
|
||||
# Global configuration
|
||||
CONFIG = load_config()
|
||||
|
||||
|
||||
def get_base_url(endpoint: str = "chat") -> str:
|
||||
"""Return the base URL for specified endpoint
|
||||
Args:
|
||||
endpoint: API endpoint name (chat or generate)
|
||||
Returns:
|
||||
Complete URL for the endpoint
|
||||
"""
|
||||
server = CONFIG["server"]
|
||||
return f"http://{server['host']}:{server['port']}/api/{endpoint}"
|
||||
|
||||
|
||||
def create_chat_request_data(
|
||||
content: str,
|
||||
stream: bool = False,
|
||||
model: str = None,
|
||||
conversation_history: List[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create chat request data
|
||||
Args:
|
||||
content: User message content
|
||||
stream: Whether to use streaming response
|
||||
model: Model name
|
||||
conversation_history: List of previous conversation messages
|
||||
history_turns: Number of history turns to include
|
||||
Returns:
|
||||
Dictionary containing complete chat request data
|
||||
"""
|
||||
messages = conversation_history or []
|
||||
messages.append({"role": "user", "content": content})
|
||||
|
||||
return {
|
||||
"model": model or CONFIG["server"]["model"],
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
|
||||
def create_generate_request_data(
|
||||
prompt: str,
|
||||
system: str = None,
|
||||
stream: bool = False,
|
||||
model: str = None,
|
||||
options: Dict[str, Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create generate request data
|
||||
Args:
|
||||
prompt: Generation prompt
|
||||
system: System prompt
|
||||
stream: Whether to use streaming response
|
||||
model: Model name
|
||||
options: Additional options
|
||||
Returns:
|
||||
Dictionary containing complete generate request data
|
||||
"""
|
||||
data = {
|
||||
"model": model or CONFIG["server"]["model"],
|
||||
"prompt": prompt,
|
||||
"stream": stream,
|
||||
}
|
||||
if system:
|
||||
data["system"] = system
|
||||
if options:
|
||||
data["options"] = options
|
||||
return data
|
||||
|
||||
|
||||
# Global test statistics
|
||||
STATS = TestStats()
|
||||
|
||||
|
||||
def run_test(func: Callable, name: str) -> None:
|
||||
"""Run a test and record the results
|
||||
Args:
|
||||
func: Test function
|
||||
name: Test name
|
||||
"""
|
||||
start_time = time.time()
|
||||
try:
|
||||
func()
|
||||
duration = time.time() - start_time
|
||||
STATS.add_result(TestResult(name, True, duration))
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
STATS.add_result(TestResult(name, False, duration, str(e)))
|
||||
raise
|
||||
|
||||
|
||||
def test_non_stream_chat() -> None:
|
||||
"""Test non-streaming call to /api/chat endpoint"""
|
||||
url = get_base_url()
|
||||
|
||||
# Send request with conversation history
|
||||
data = create_chat_request_data(
|
||||
CONFIG["test_cases"]["basic"]["query"],
|
||||
stream=False,
|
||||
conversation_history=EXAMPLE_CONVERSATION,
|
||||
)
|
||||
response = make_request(url, data)
|
||||
|
||||
# Print response
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Non-streaming call response ===")
|
||||
response_json = response.json()
|
||||
|
||||
# Print response content
|
||||
print_json_response(
|
||||
{"model": response_json["model"], "message": response_json["message"]},
|
||||
"Response content",
|
||||
)
|
||||
|
||||
|
||||
def test_stream_chat() -> None:
|
||||
"""Test streaming call to /api/chat endpoint
|
||||
|
||||
Use JSON Lines format to process streaming responses, each line is a complete JSON object.
|
||||
Response format:
|
||||
{
|
||||
"model": "lightrag:latest",
|
||||
"created_at": "2024-01-15T00:00:00Z",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Partial response content",
|
||||
"images": null
|
||||
},
|
||||
"done": false
|
||||
}
|
||||
|
||||
The last message will contain performance statistics, with done set to true.
|
||||
"""
|
||||
url = get_base_url()
|
||||
|
||||
# Send request with conversation history
|
||||
data = create_chat_request_data(
|
||||
CONFIG["test_cases"]["basic"]["query"],
|
||||
stream=True,
|
||||
conversation_history=EXAMPLE_CONVERSATION,
|
||||
)
|
||||
response = make_request(url, data, stream=True)
|
||||
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Streaming call response ===")
|
||||
output_buffer = []
|
||||
try:
|
||||
for line in response.iter_lines():
|
||||
if line: # Skip empty lines
|
||||
try:
|
||||
# Decode and parse JSON
|
||||
data = json.loads(line.decode("utf-8"))
|
||||
if data.get("done", True): # If it's the completion marker
|
||||
if (
|
||||
"total_duration" in data
|
||||
): # Final performance statistics message
|
||||
# print_json_response(data, "Performance statistics")
|
||||
break
|
||||
else: # Normal content message
|
||||
message = data.get("message", {})
|
||||
content = message.get("content", "")
|
||||
if content: # Only collect non-empty content
|
||||
output_buffer.append(content)
|
||||
print(
|
||||
content, end="", flush=True
|
||||
) # Print content in real-time
|
||||
except json.JSONDecodeError:
|
||||
print("Error decoding JSON from response line")
|
||||
finally:
|
||||
response.close() # Ensure the response connection is closed
|
||||
|
||||
# Print a newline
|
||||
print()
|
||||
|
||||
|
||||
def test_query_modes() -> None:
|
||||
"""Test different query mode prefixes
|
||||
|
||||
Supported query modes:
|
||||
- /local: Local retrieval mode, searches only in highly relevant documents
|
||||
- /global: Global retrieval mode, searches across all documents
|
||||
- /naive: Naive mode, does not use any optimization strategies
|
||||
- /hybrid: Hybrid mode (default), combines multiple strategies
|
||||
- /mix: Mix mode
|
||||
|
||||
Each mode will return responses in the same format, but with different retrieval strategies.
|
||||
"""
|
||||
url = get_base_url()
|
||||
modes = ["local", "global", "naive", "hybrid", "mix"]
|
||||
|
||||
for mode in modes:
|
||||
if OutputControl.is_verbose():
|
||||
print(f"\n=== Testing /{mode} mode ===")
|
||||
data = create_chat_request_data(
|
||||
f"/{mode} {CONFIG['test_cases']['basic']['query']}", stream=False
|
||||
)
|
||||
|
||||
# Send request
|
||||
response = make_request(url, data)
|
||||
response_json = response.json()
|
||||
|
||||
# Print response content
|
||||
print_json_response(
|
||||
{"model": response_json["model"], "message": response_json["message"]}
|
||||
)
|
||||
|
||||
|
||||
def create_error_test_data(error_type: str) -> Dict[str, Any]:
|
||||
"""Create request data for error testing
|
||||
Args:
|
||||
error_type: Error type, supported:
|
||||
- empty_messages: Empty message list
|
||||
- invalid_role: Invalid role field
|
||||
- missing_content: Missing content field
|
||||
|
||||
Returns:
|
||||
Request dictionary containing error data
|
||||
"""
|
||||
error_data = {
|
||||
"empty_messages": {"model": "lightrag:latest", "messages": [], "stream": True},
|
||||
"invalid_role": {
|
||||
"model": "lightrag:latest",
|
||||
"messages": [{"invalid_role": "user", "content": "Test message"}],
|
||||
"stream": True,
|
||||
},
|
||||
"missing_content": {
|
||||
"model": "lightrag:latest",
|
||||
"messages": [{"role": "user"}],
|
||||
"stream": True,
|
||||
},
|
||||
}
|
||||
return error_data.get(error_type, error_data["empty_messages"])
|
||||
|
||||
|
||||
def test_stream_error_handling() -> None:
|
||||
"""Test error handling for streaming responses
|
||||
|
||||
Test scenarios:
|
||||
1. Empty message list
|
||||
2. Message format error (missing required fields)
|
||||
|
||||
Error responses should be returned immediately without establishing a streaming connection.
|
||||
The status code should be 4xx, and detailed error information should be returned.
|
||||
"""
|
||||
url = get_base_url()
|
||||
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Testing streaming response error handling ===")
|
||||
|
||||
# Test empty message list
|
||||
if OutputControl.is_verbose():
|
||||
print("\n--- Testing empty message list (streaming) ---")
|
||||
data = create_error_test_data("empty_messages")
|
||||
response = make_request(url, data, stream=True, check_status=False)
|
||||
print(f"Status code: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
print_json_response(response.json(), "Error message")
|
||||
response.close()
|
||||
|
||||
# Test invalid role field
|
||||
if OutputControl.is_verbose():
|
||||
print("\n--- Testing invalid role field (streaming) ---")
|
||||
data = create_error_test_data("invalid_role")
|
||||
response = make_request(url, data, stream=True, check_status=False)
|
||||
print(f"Status code: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
print_json_response(response.json(), "Error message")
|
||||
response.close()
|
||||
|
||||
# Test missing content field
|
||||
if OutputControl.is_verbose():
|
||||
print("\n--- Testing missing content field (streaming) ---")
|
||||
data = create_error_test_data("missing_content")
|
||||
response = make_request(url, data, stream=True, check_status=False)
|
||||
print(f"Status code: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
print_json_response(response.json(), "Error message")
|
||||
response.close()
|
||||
|
||||
|
||||
def test_error_handling() -> None:
|
||||
"""Test error handling for non-streaming responses
|
||||
|
||||
Test scenarios:
|
||||
1. Empty message list
|
||||
2. Message format error (missing required fields)
|
||||
|
||||
Error response format:
|
||||
{
|
||||
"detail": "Error description"
|
||||
}
|
||||
|
||||
All errors should return appropriate HTTP status codes and clear error messages.
|
||||
"""
|
||||
url = get_base_url()
|
||||
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Testing error handling ===")
|
||||
|
||||
# Test empty message list
|
||||
if OutputControl.is_verbose():
|
||||
print("\n--- Testing empty message list ---")
|
||||
data = create_error_test_data("empty_messages")
|
||||
data["stream"] = False # Change to non-streaming mode
|
||||
response = make_request(url, data, check_status=False)
|
||||
print(f"Status code: {response.status_code}")
|
||||
print_json_response(response.json(), "Error message")
|
||||
|
||||
# Test invalid role field
|
||||
if OutputControl.is_verbose():
|
||||
print("\n--- Testing invalid role field ---")
|
||||
data = create_error_test_data("invalid_role")
|
||||
data["stream"] = False # Change to non-streaming mode
|
||||
response = make_request(url, data, check_status=False)
|
||||
print(f"Status code: {response.status_code}")
|
||||
print_json_response(response.json(), "Error message")
|
||||
|
||||
# Test missing content field
|
||||
if OutputControl.is_verbose():
|
||||
print("\n--- Testing missing content field ---")
|
||||
data = create_error_test_data("missing_content")
|
||||
data["stream"] = False # Change to non-streaming mode
|
||||
response = make_request(url, data, check_status=False)
|
||||
print(f"Status code: {response.status_code}")
|
||||
print_json_response(response.json(), "Error message")
|
||||
|
||||
|
||||
def test_non_stream_generate() -> None:
|
||||
"""Test non-streaming call to /api/generate endpoint"""
|
||||
url = get_base_url("generate")
|
||||
data = create_generate_request_data(
|
||||
CONFIG["test_cases"]["generate"]["query"], stream=False
|
||||
)
|
||||
|
||||
# Send request
|
||||
response = make_request(url, data)
|
||||
|
||||
# Print response
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Non-streaming generate response ===")
|
||||
response_json = response.json()
|
||||
|
||||
# Print response content
|
||||
print(json.dumps(response_json, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def test_stream_generate() -> None:
|
||||
"""Test streaming call to /api/generate endpoint"""
|
||||
url = get_base_url("generate")
|
||||
data = create_generate_request_data(
|
||||
CONFIG["test_cases"]["generate"]["query"], stream=True
|
||||
)
|
||||
|
||||
# Send request and get streaming response
|
||||
response = make_request(url, data, stream=True)
|
||||
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Streaming generate response ===")
|
||||
output_buffer = []
|
||||
try:
|
||||
for line in response.iter_lines():
|
||||
if line: # Skip empty lines
|
||||
try:
|
||||
# Decode and parse JSON
|
||||
data = json.loads(line.decode("utf-8"))
|
||||
if data.get("done", True): # If it's the completion marker
|
||||
if (
|
||||
"total_duration" in data
|
||||
): # Final performance statistics message
|
||||
break
|
||||
else: # Normal content message
|
||||
content = data.get("response", "")
|
||||
if content: # Only collect non-empty content
|
||||
output_buffer.append(content)
|
||||
print(
|
||||
content, end="", flush=True
|
||||
) # Print content in real-time
|
||||
except json.JSONDecodeError:
|
||||
print("Error decoding JSON from response line")
|
||||
finally:
|
||||
response.close() # Ensure the response connection is closed
|
||||
|
||||
# Print a newline
|
||||
print()
|
||||
|
||||
|
||||
def test_generate_with_system() -> None:
|
||||
"""Test generate with system prompt"""
|
||||
url = get_base_url("generate")
|
||||
data = create_generate_request_data(
|
||||
CONFIG["test_cases"]["generate"]["query"],
|
||||
system="你是一个知识渊博的助手",
|
||||
stream=False,
|
||||
)
|
||||
|
||||
# Send request
|
||||
response = make_request(url, data)
|
||||
|
||||
# Print response
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Generate with system prompt response ===")
|
||||
response_json = response.json()
|
||||
|
||||
# Print response content
|
||||
print_json_response(
|
||||
{
|
||||
"model": response_json["model"],
|
||||
"response": response_json["response"],
|
||||
"done": response_json["done"],
|
||||
},
|
||||
"Response content",
|
||||
)
|
||||
|
||||
|
||||
def test_generate_error_handling() -> None:
|
||||
"""Test error handling for generate endpoint"""
|
||||
url = get_base_url("generate")
|
||||
|
||||
# Test empty prompt
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Testing empty prompt ===")
|
||||
data = create_generate_request_data("", stream=False)
|
||||
response = make_request(url, data, check_status=False)
|
||||
print(f"Status code: {response.status_code}")
|
||||
print_json_response(response.json(), "Error message")
|
||||
|
||||
# Test invalid options
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Testing invalid options ===")
|
||||
data = create_generate_request_data(
|
||||
CONFIG["test_cases"]["basic"]["query"],
|
||||
options={"invalid_option": "value"},
|
||||
stream=False,
|
||||
)
|
||||
response = make_request(url, data, check_status=False)
|
||||
print(f"Status code: {response.status_code}")
|
||||
print_json_response(response.json(), "Error message")
|
||||
|
||||
|
||||
def test_generate_concurrent() -> None:
|
||||
"""Test concurrent generate requests"""
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_session():
|
||||
async with aiohttp.ClientSession() as session:
|
||||
yield session
|
||||
|
||||
async def make_request(session, prompt: str, request_id: int):
|
||||
url = get_base_url("generate")
|
||||
data = create_generate_request_data(prompt, stream=False)
|
||||
try:
|
||||
async with session.post(url, json=data) as response:
|
||||
if response.status != 200:
|
||||
error_msg = (
|
||||
f"Request {request_id} failed with status {response.status}"
|
||||
)
|
||||
if OutputControl.is_verbose():
|
||||
print(f"\n{error_msg}")
|
||||
raise McpError(ErrorCode.InternalError, error_msg)
|
||||
result = await response.json()
|
||||
if "error" in result:
|
||||
error_msg = (
|
||||
f"Request {request_id} returned error: {result['error']}"
|
||||
)
|
||||
if OutputControl.is_verbose():
|
||||
print(f"\n{error_msg}")
|
||||
raise McpError(ErrorCode.InternalError, error_msg)
|
||||
return result
|
||||
except Exception as e:
|
||||
error_msg = f"Request {request_id} failed: {str(e)}"
|
||||
if OutputControl.is_verbose():
|
||||
print(f"\n{error_msg}")
|
||||
raise McpError(ErrorCode.InternalError, error_msg)
|
||||
|
||||
async def run_concurrent_requests():
|
||||
prompts = ["第一个问题", "第二个问题", "第三个问题", "第四个问题", "第五个问题"]
|
||||
|
||||
async with get_session() as session:
|
||||
tasks = [
|
||||
make_request(session, prompt, i + 1) for i, prompt in enumerate(prompts)
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
success_results = []
|
||||
error_messages = []
|
||||
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
error_messages.append(f"Request {i+1} failed: {str(result)}")
|
||||
else:
|
||||
success_results.append((i + 1, result))
|
||||
|
||||
if error_messages:
|
||||
for req_id, result in success_results:
|
||||
if OutputControl.is_verbose():
|
||||
print(f"\nRequest {req_id} succeeded:")
|
||||
print_json_response(result)
|
||||
|
||||
error_summary = "\n".join(error_messages)
|
||||
raise McpError(
|
||||
ErrorCode.InternalError,
|
||||
f"Some concurrent requests failed:\n{error_summary}",
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
if OutputControl.is_verbose():
|
||||
print("\n=== Testing concurrent generate requests ===")
|
||||
|
||||
# Run concurrent requests
|
||||
try:
|
||||
results = asyncio.run(run_concurrent_requests())
|
||||
# all success, print out results
|
||||
for i, result in enumerate(results, 1):
|
||||
print(f"\nRequest {i} result:")
|
||||
print_json_response(result)
|
||||
except McpError:
|
||||
# error message already printed
|
||||
raise
|
||||
|
||||
|
||||
def get_test_cases() -> Dict[str, Callable]:
|
||||
"""Get all available test cases
|
||||
Returns:
|
||||
A dictionary mapping test names to test functions
|
||||
"""
|
||||
return {
|
||||
"non_stream": test_non_stream_chat,
|
||||
"stream": test_stream_chat,
|
||||
"modes": test_query_modes,
|
||||
"errors": test_error_handling,
|
||||
"stream_errors": test_stream_error_handling,
|
||||
"non_stream_generate": test_non_stream_generate,
|
||||
"stream_generate": test_stream_generate,
|
||||
"generate_with_system": test_generate_with_system,
|
||||
"generate_errors": test_generate_error_handling,
|
||||
"generate_concurrent": test_generate_concurrent,
|
||||
}
|
||||
|
||||
|
||||
def create_default_config():
|
||||
"""Create a default configuration file"""
|
||||
config_path = Path("config.json")
|
||||
if not config_path.exists():
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2)
|
||||
print(f"Default configuration file created: {config_path}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command line arguments"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LightRAG Ollama Compatibility Interface Testing",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Configuration file (config.json):
|
||||
{
|
||||
"server": {
|
||||
"host": "localhost", # Server address
|
||||
"port": 9621, # Server port
|
||||
"model": "lightrag:latest" # Default model name
|
||||
},
|
||||
"test_cases": {
|
||||
"basic": {
|
||||
"query": "Test query", # Basic query text
|
||||
"stream_query": "Stream query" # Stream query text
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-q",
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Silent mode, only display test result summary",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-a",
|
||||
"--ask",
|
||||
type=str,
|
||||
help="Specify query content, which will override the query settings in the configuration file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--init-config", action="store_true", help="Create default configuration file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default="",
|
||||
help="Test result output file path, default is not to output to a file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tests",
|
||||
nargs="+",
|
||||
choices=list(get_test_cases().keys()) + ["all"],
|
||||
default=["all"],
|
||||
help="Test cases to run, options: %(choices)s. Use 'all' to run all tests (except error tests)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
|
||||
# Set output mode
|
||||
OutputControl.set_verbose(not args.quiet)
|
||||
|
||||
# If query content is specified, update the configuration
|
||||
if args.ask:
|
||||
CONFIG["test_cases"]["basic"]["query"] = args.ask
|
||||
|
||||
# If specified to create a configuration file
|
||||
if args.init_config:
|
||||
create_default_config()
|
||||
exit(0)
|
||||
|
||||
test_cases = get_test_cases()
|
||||
|
||||
try:
|
||||
if "all" in args.tests:
|
||||
# Run all tests except error handling tests
|
||||
if OutputControl.is_verbose():
|
||||
print("\n【Chat API Tests】")
|
||||
run_test(test_non_stream_chat, "Non-streaming Chat Test")
|
||||
run_test(test_stream_chat, "Streaming Chat Test")
|
||||
run_test(test_query_modes, "Chat Query Mode Test")
|
||||
|
||||
if OutputControl.is_verbose():
|
||||
print("\n【Generate API Tests】")
|
||||
run_test(test_non_stream_generate, "Non-streaming Generate Test")
|
||||
run_test(test_stream_generate, "Streaming Generate Test")
|
||||
run_test(test_generate_with_system, "Generate with System Prompt Test")
|
||||
run_test(test_generate_concurrent, "Generate Concurrent Test")
|
||||
else:
|
||||
# Run specified tests
|
||||
for test_name in args.tests:
|
||||
if OutputControl.is_verbose():
|
||||
print(f"\n【Running Test: {test_name}】")
|
||||
run_test(test_cases[test_name], test_name)
|
||||
except Exception as e:
|
||||
print(f"\nAn error occurred: {str(e)}")
|
||||
finally:
|
||||
# Print test statistics
|
||||
STATS.print_summary()
|
||||
# If an output file path is specified, export the results
|
||||
if args.output:
|
||||
STATS.export_results(args.output)
|
||||
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Integration test suite for PostgreSQL retry mechanism using real database.
|
||||
|
||||
This test suite connects to a real PostgreSQL database using credentials from .env
|
||||
and tests the retry mechanism with actual network failures.
|
||||
|
||||
Prerequisites:
|
||||
1. PostgreSQL server running and accessible
|
||||
2. .env file with POSTGRES_* configuration
|
||||
3. asyncpg installed: pip install asyncpg
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
from unittest.mock import patch
|
||||
from lightrag.kg.postgres_impl import PostgreSQLDB
|
||||
|
||||
asyncpg = pytest.importorskip("asyncpg")
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(dotenv_path=".env", override=False)
|
||||
|
||||
|
||||
class TestPostgresRetryIntegration:
|
||||
"""Integration tests for PostgreSQL retry mechanism with real database."""
|
||||
|
||||
@pytest.fixture
|
||||
def db_config(self):
|
||||
"""Load database configuration from environment variables."""
|
||||
return {
|
||||
"host": os.getenv("POSTGRES_HOST", "localhost"),
|
||||
"port": int(os.getenv("POSTGRES_PORT", "5432")),
|
||||
"user": os.getenv("POSTGRES_USER", "postgres"),
|
||||
"password": os.getenv("POSTGRES_PASSWORD", ""),
|
||||
"database": os.getenv("POSTGRES_DATABASE", "postgres"),
|
||||
"workspace": os.getenv("POSTGRES_WORKSPACE", "test_retry"),
|
||||
"max_connections": int(os.getenv("POSTGRES_MAX_CONNECTIONS", "10")),
|
||||
# Connection retry configuration
|
||||
"connection_retry_attempts": min(
|
||||
10, int(os.getenv("POSTGRES_CONNECTION_RETRIES", "3"))
|
||||
),
|
||||
"connection_retry_backoff": min(
|
||||
5.0, float(os.getenv("POSTGRES_CONNECTION_RETRY_BACKOFF", "0.5"))
|
||||
),
|
||||
"connection_retry_backoff_max": min(
|
||||
60.0, float(os.getenv("POSTGRES_CONNECTION_RETRY_BACKOFF_MAX", "5.0"))
|
||||
),
|
||||
"pool_close_timeout": min(
|
||||
30.0, float(os.getenv("POSTGRES_POOL_CLOSE_TIMEOUT", "5.0"))
|
||||
),
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def test_env(self, monkeypatch):
|
||||
"""Set up test environment variables for retry configuration."""
|
||||
monkeypatch.setenv("POSTGRES_CONNECTION_RETRIES", "3")
|
||||
monkeypatch.setenv("POSTGRES_CONNECTION_RETRY_BACKOFF", "0.5")
|
||||
monkeypatch.setenv("POSTGRES_CONNECTION_RETRY_BACKOFF_MAX", "2.0")
|
||||
monkeypatch.setenv("POSTGRES_POOL_CLOSE_TIMEOUT", "3.0")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_connection_success(self, db_config, test_env):
|
||||
"""
|
||||
Test successful connection to real PostgreSQL database.
|
||||
|
||||
This validates that:
|
||||
1. Database credentials are correct
|
||||
2. Connection pool initializes properly
|
||||
3. Basic query works
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 1: Real Database Connection")
|
||||
print("=" * 80)
|
||||
print(
|
||||
f" → Connecting to {db_config['host']}:{db_config['port']}/{db_config['database']}"
|
||||
)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
try:
|
||||
# Initialize database connection
|
||||
await db.initdb()
|
||||
print(" ✓ Connection successful")
|
||||
|
||||
# Test simple query
|
||||
result = await db.query("SELECT 1 as test", multirows=False)
|
||||
assert result is not None
|
||||
assert result.get("test") == 1
|
||||
print(" ✓ Query executed successfully")
|
||||
|
||||
print("\n✅ Test passed: Real database connection works")
|
||||
print("=" * 80)
|
||||
finally:
|
||||
if db.pool:
|
||||
await db.pool.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simulated_transient_error_with_real_db(self, db_config, test_env):
|
||||
"""
|
||||
Test retry mechanism with simulated transient errors on real database.
|
||||
|
||||
Simulates connection failures on first 2 attempts, then succeeds.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 2: Simulated Transient Errors")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
attempt_count = {"value": 0}
|
||||
|
||||
# Original create_pool function
|
||||
original_create_pool = asyncpg.create_pool
|
||||
|
||||
async def mock_create_pool_with_failures(*args, **kwargs):
|
||||
"""Mock that fails first 2 times, then calls real create_pool."""
|
||||
attempt_count["value"] += 1
|
||||
print(f" → Connection attempt {attempt_count['value']}")
|
||||
|
||||
if attempt_count["value"] <= 2:
|
||||
print(" ✗ Simulating connection failure")
|
||||
raise asyncpg.exceptions.ConnectionFailureError(
|
||||
f"Simulated failure on attempt {attempt_count['value']}"
|
||||
)
|
||||
|
||||
print(" ✓ Allowing real connection")
|
||||
return await original_create_pool(*args, **kwargs)
|
||||
|
||||
try:
|
||||
# Patch create_pool to simulate failures
|
||||
with patch(
|
||||
"asyncpg.create_pool", side_effect=mock_create_pool_with_failures
|
||||
):
|
||||
await db.initdb()
|
||||
|
||||
assert (
|
||||
attempt_count["value"] == 3
|
||||
), f"Expected 3 attempts, got {attempt_count['value']}"
|
||||
assert db.pool is not None, "Pool should be initialized after retries"
|
||||
|
||||
# Verify database is actually working
|
||||
result = await db.query("SELECT 1 as test", multirows=False)
|
||||
assert result.get("test") == 1
|
||||
|
||||
print(
|
||||
f"\n✅ Test passed: Retry mechanism worked, connected after {attempt_count['value']} attempts"
|
||||
)
|
||||
print("=" * 80)
|
||||
finally:
|
||||
if db.pool:
|
||||
await db.pool.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_retry_with_real_db(self, db_config, test_env):
|
||||
"""
|
||||
Test query-level retry with simulated connection issues.
|
||||
|
||||
Tests that queries retry on transient failures by simulating
|
||||
a temporary database unavailability.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 3: Query-Level Retry")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
try:
|
||||
# First initialize normally
|
||||
await db.initdb()
|
||||
print(" ✓ Database initialized")
|
||||
|
||||
# Close the pool to simulate connection loss
|
||||
print(" → Simulating connection loss (closing pool)...")
|
||||
await db.pool.close()
|
||||
db.pool = None
|
||||
|
||||
# Now query should trigger pool recreation and retry
|
||||
print(" → Attempting query (should auto-reconnect)...")
|
||||
result = await db.query("SELECT 1 as test", multirows=False)
|
||||
|
||||
assert result.get("test") == 1, "Query should succeed after reconnection"
|
||||
assert db.pool is not None, "Pool should be recreated"
|
||||
|
||||
print(" ✓ Query succeeded after automatic reconnection")
|
||||
print("\n✅ Test passed: Auto-reconnection works correctly")
|
||||
print("=" * 80)
|
||||
finally:
|
||||
if db.pool:
|
||||
await db.pool.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_queries_with_real_db(self, db_config, test_env):
|
||||
"""
|
||||
Test concurrent queries to validate thread safety and connection pooling.
|
||||
|
||||
Runs multiple concurrent queries to ensure no deadlocks or race conditions.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 4: Concurrent Queries")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
try:
|
||||
await db.initdb()
|
||||
print(" ✓ Database initialized")
|
||||
|
||||
# Launch 10 concurrent queries
|
||||
num_queries = 10
|
||||
print(f" → Launching {num_queries} concurrent queries...")
|
||||
|
||||
async def run_query(query_id):
|
||||
result = await db.query(
|
||||
f"SELECT {query_id} as id, pg_sleep(0.1)", multirows=False
|
||||
)
|
||||
return result.get("id")
|
||||
|
||||
start_time = time.time()
|
||||
tasks = [run_query(i) for i in range(num_queries)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Check results
|
||||
successful = sum(1 for r in results if not isinstance(r, Exception))
|
||||
failed = sum(1 for r in results if isinstance(r, Exception))
|
||||
|
||||
print(f" → Completed in {elapsed:.2f}s")
|
||||
print(f" → Results: {successful} successful, {failed} failed")
|
||||
|
||||
assert (
|
||||
successful == num_queries
|
||||
), f"All {num_queries} queries should succeed"
|
||||
assert failed == 0, "No queries should fail"
|
||||
|
||||
print("\n✅ Test passed: All concurrent queries succeeded, no deadlocks")
|
||||
print("=" * 80)
|
||||
finally:
|
||||
if db.pool:
|
||||
await db.pool.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_close_timeout_real(self, db_config, test_env):
|
||||
"""
|
||||
Test pool close timeout protection with real database.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 5: Pool Close Timeout")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
try:
|
||||
await db.initdb()
|
||||
print(" ✓ Database initialized")
|
||||
|
||||
# Trigger pool reset (which includes close)
|
||||
print(" → Triggering pool reset...")
|
||||
start_time = time.time()
|
||||
await db._reset_pool()
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
print(f" ✓ Pool reset completed in {elapsed:.2f}s")
|
||||
assert db.pool is None, "Pool should be None after reset"
|
||||
assert (
|
||||
elapsed < db.pool_close_timeout + 1
|
||||
), "Reset should complete within timeout"
|
||||
|
||||
print("\n✅ Test passed: Pool reset handled correctly")
|
||||
print("=" * 80)
|
||||
finally:
|
||||
# Already closed in test
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configuration_from_env(self, db_config):
|
||||
"""
|
||||
Test that configuration is correctly loaded from environment variables.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 6: Environment Configuration")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
print(" → Configuration loaded:")
|
||||
print(f" • Host: {db.host}")
|
||||
print(f" • Port: {db.port}")
|
||||
print(f" • Database: {db.database}")
|
||||
print(f" • User: {db.user}")
|
||||
print(f" • Workspace: {db.workspace}")
|
||||
print(f" • Max Connections: {db.max}")
|
||||
print(f" • Retry Attempts: {db.connection_retry_attempts}")
|
||||
print(f" • Retry Backoff: {db.connection_retry_backoff}s")
|
||||
print(f" • Max Backoff: {db.connection_retry_backoff_max}s")
|
||||
print(f" • Pool Close Timeout: {db.pool_close_timeout}s")
|
||||
|
||||
# Verify required fields are present
|
||||
assert db.host, "Host should be configured"
|
||||
assert db.port, "Port should be configured"
|
||||
assert db.user, "User should be configured"
|
||||
assert db.database, "Database should be configured"
|
||||
|
||||
print("\n✅ Test passed: All configuration loaded correctly from .env")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
def run_integration_tests():
|
||||
"""Run all integration tests with detailed output."""
|
||||
print("\n" + "=" * 80)
|
||||
print("POSTGRESQL RETRY MECHANISM - INTEGRATION TESTS")
|
||||
print("Testing with REAL database from .env configuration")
|
||||
print("=" * 80)
|
||||
|
||||
# Check if database configuration exists
|
||||
if not os.getenv("POSTGRES_HOST"):
|
||||
print("\n⚠️ WARNING: No POSTGRES_HOST in .env file")
|
||||
print("Please ensure .env file exists with PostgreSQL configuration.")
|
||||
return
|
||||
|
||||
print("\nRunning integration tests...\n")
|
||||
|
||||
# Run pytest with verbose output
|
||||
pytest.main(
|
||||
[
|
||||
__file__,
|
||||
"-v",
|
||||
"-s", # Don't capture output
|
||||
"--tb=short", # Short traceback format
|
||||
"--color=yes",
|
||||
"-x", # Stop on first failure
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_integration_tests()
|
||||
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Test suite for write_json optimization
|
||||
|
||||
This test verifies:
|
||||
1. Fast path works for clean data (no sanitization)
|
||||
2. Slow path applies sanitization for dirty data
|
||||
3. Sanitization is done during encoding (memory-efficient)
|
||||
4. Reloading updates shared memory with cleaned data
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
from lightrag.utils import write_json, load_json, SanitizingJSONEncoder
|
||||
|
||||
|
||||
class TestWriteJsonOptimization:
|
||||
"""Test write_json optimization with two-stage approach"""
|
||||
|
||||
def test_fast_path_clean_data(self):
|
||||
"""Test that clean data takes the fast path without sanitization"""
|
||||
clean_data = {
|
||||
"name": "John Doe",
|
||||
"age": 30,
|
||||
"items": ["apple", "banana", "cherry"],
|
||||
"nested": {"key": "value", "number": 42},
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Write clean data - should return False (no sanitization)
|
||||
needs_reload = write_json(clean_data, temp_file)
|
||||
assert not needs_reload, "Clean data should not require sanitization"
|
||||
|
||||
# Verify data was written correctly
|
||||
loaded_data = load_json(temp_file)
|
||||
assert loaded_data == clean_data, "Loaded data should match original"
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_slow_path_dirty_data(self):
|
||||
"""Test that dirty data triggers sanitization"""
|
||||
# Create data with surrogate characters (U+D800 to U+DFFF)
|
||||
dirty_string = "Hello\ud800World" # Contains surrogate character
|
||||
dirty_data = {"text": dirty_string, "number": 123}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Write dirty data - should return True (sanitization applied)
|
||||
needs_reload = write_json(dirty_data, temp_file)
|
||||
assert needs_reload, "Dirty data should trigger sanitization"
|
||||
|
||||
# Verify data was written and sanitized
|
||||
loaded_data = load_json(temp_file)
|
||||
assert loaded_data is not None, "Data should be written"
|
||||
assert loaded_data["number"] == 123, "Clean fields should remain unchanged"
|
||||
# Surrogate character should be removed
|
||||
assert (
|
||||
"\ud800" not in loaded_data["text"]
|
||||
), "Surrogate character should be removed"
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_sanitizing_encoder_removes_surrogates(self):
|
||||
"""Test that SanitizingJSONEncoder removes surrogate characters"""
|
||||
data_with_surrogates = {
|
||||
"text": "Hello\ud800\udc00World", # Contains surrogate pair
|
||||
"clean": "Clean text",
|
||||
"nested": {"dirty_key\ud801": "value", "clean_key": "clean\ud802value"},
|
||||
}
|
||||
|
||||
# Encode using custom encoder
|
||||
encoded = json.dumps(
|
||||
data_with_surrogates, cls=SanitizingJSONEncoder, ensure_ascii=False
|
||||
)
|
||||
|
||||
# Verify no surrogate characters in output
|
||||
assert "\ud800" not in encoded, "Surrogate U+D800 should be removed"
|
||||
assert "\udc00" not in encoded, "Surrogate U+DC00 should be removed"
|
||||
assert "\ud801" not in encoded, "Surrogate U+D801 should be removed"
|
||||
assert "\ud802" not in encoded, "Surrogate U+D802 should be removed"
|
||||
|
||||
# Verify clean parts remain
|
||||
assert "Clean text" in encoded, "Clean text should remain"
|
||||
assert "clean_key" in encoded, "Clean keys should remain"
|
||||
|
||||
def test_nested_structure_sanitization(self):
|
||||
"""Test sanitization of deeply nested structures"""
|
||||
nested_data = {
|
||||
"level1": {
|
||||
"level2": {
|
||||
"level3": {"dirty": "text\ud800here", "clean": "normal text"},
|
||||
"list": ["item1", "item\ud801dirty", "item3"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
needs_reload = write_json(nested_data, temp_file)
|
||||
assert needs_reload, "Nested dirty data should trigger sanitization"
|
||||
|
||||
# Verify nested structure is preserved
|
||||
loaded_data = load_json(temp_file)
|
||||
assert "level1" in loaded_data
|
||||
assert "level2" in loaded_data["level1"]
|
||||
assert "level3" in loaded_data["level1"]["level2"]
|
||||
|
||||
# Verify surrogates are removed
|
||||
dirty_text = loaded_data["level1"]["level2"]["level3"]["dirty"]
|
||||
assert "\ud800" not in dirty_text, "Nested surrogate should be removed"
|
||||
|
||||
# Verify list items are sanitized
|
||||
list_items = loaded_data["level1"]["level2"]["list"]
|
||||
assert (
|
||||
"\ud801" not in list_items[1]
|
||||
), "List item surrogates should be removed"
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_unicode_non_characters_removed(self):
|
||||
"""Test that Unicode non-characters (U+FFFE, U+FFFF) don't cause encoding errors
|
||||
|
||||
Note: U+FFFE and U+FFFF are valid UTF-8 characters (though discouraged),
|
||||
so they don't trigger sanitization. They only get removed when explicitly
|
||||
using the SanitizingJSONEncoder.
|
||||
"""
|
||||
data_with_nonchars = {"text1": "Hello\ufffeWorld", "text2": "Test\uffffString"}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# These characters are valid UTF-8, so they take the fast path
|
||||
needs_reload = write_json(data_with_nonchars, temp_file)
|
||||
assert not needs_reload, "U+FFFE/U+FFFF are valid UTF-8 characters"
|
||||
|
||||
loaded_data = load_json(temp_file)
|
||||
# They're written as-is in the fast path
|
||||
assert loaded_data == data_with_nonchars
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_mixed_clean_dirty_data(self):
|
||||
"""Test data with both clean and dirty fields"""
|
||||
mixed_data = {
|
||||
"clean_field": "This is perfectly fine",
|
||||
"dirty_field": "This has\ud800issues",
|
||||
"number": 42,
|
||||
"boolean": True,
|
||||
"null_value": None,
|
||||
"clean_list": [1, 2, 3],
|
||||
"dirty_list": ["clean", "dirty\ud801item"],
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
needs_reload = write_json(mixed_data, temp_file)
|
||||
assert (
|
||||
needs_reload
|
||||
), "Mixed data with dirty fields should trigger sanitization"
|
||||
|
||||
loaded_data = load_json(temp_file)
|
||||
|
||||
# Clean fields should remain unchanged
|
||||
assert loaded_data["clean_field"] == "This is perfectly fine"
|
||||
assert loaded_data["number"] == 42
|
||||
assert loaded_data["boolean"]
|
||||
assert loaded_data["null_value"] is None
|
||||
assert loaded_data["clean_list"] == [1, 2, 3]
|
||||
|
||||
# Dirty fields should be sanitized
|
||||
assert "\ud800" not in loaded_data["dirty_field"]
|
||||
assert "\ud801" not in loaded_data["dirty_list"][1]
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_empty_and_none_strings(self):
|
||||
"""Test handling of empty and None values"""
|
||||
data = {
|
||||
"empty": "",
|
||||
"none": None,
|
||||
"zero": 0,
|
||||
"false": False,
|
||||
"empty_list": [],
|
||||
"empty_dict": {},
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
needs_reload = write_json(data, temp_file)
|
||||
assert (
|
||||
not needs_reload
|
||||
), "Clean empty values should not trigger sanitization"
|
||||
|
||||
loaded_data = load_json(temp_file)
|
||||
assert loaded_data == data, "Empty/None values should be preserved"
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_specific_surrogate_udc9a(self):
|
||||
"""Test specific surrogate character \\udc9a mentioned in the issue"""
|
||||
# Test the exact surrogate character from the error message:
|
||||
# UnicodeEncodeError: 'utf-8' codec can't encode character '\\udc9a'
|
||||
data_with_udc9a = {
|
||||
"text": "Some text with surrogate\udc9acharacter",
|
||||
"position": 201, # As mentioned in the error
|
||||
"clean_field": "Normal text",
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Write data - should trigger sanitization
|
||||
needs_reload = write_json(data_with_udc9a, temp_file)
|
||||
assert needs_reload, "Data with \\udc9a should trigger sanitization"
|
||||
|
||||
# Verify surrogate was removed
|
||||
loaded_data = load_json(temp_file)
|
||||
assert loaded_data is not None
|
||||
assert "\udc9a" not in loaded_data["text"], "\\udc9a should be removed"
|
||||
assert (
|
||||
loaded_data["clean_field"] == "Normal text"
|
||||
), "Clean fields should remain"
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_migration_with_surrogate_sanitization(self):
|
||||
"""Test that migration process handles surrogate characters correctly
|
||||
|
||||
This test simulates the scenario where legacy cache contains surrogate
|
||||
characters and ensures they are cleaned during migration.
|
||||
"""
|
||||
# Simulate legacy cache data with surrogate characters
|
||||
legacy_data_with_surrogates = {
|
||||
"cache_entry_1": {
|
||||
"return": "Result with\ud800surrogate",
|
||||
"cache_type": "extract",
|
||||
"original_prompt": "Some\udc9aprompt",
|
||||
},
|
||||
"cache_entry_2": {
|
||||
"return": "Clean result",
|
||||
"cache_type": "query",
|
||||
"original_prompt": "Clean prompt",
|
||||
},
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# First write the dirty data directly (simulating legacy cache file)
|
||||
# Use custom encoder to force write even with surrogates
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
legacy_data_with_surrogates,
|
||||
f,
|
||||
cls=SanitizingJSONEncoder,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# Load and verify surrogates were cleaned during initial write
|
||||
loaded_data = load_json(temp_file)
|
||||
assert loaded_data is not None
|
||||
|
||||
# The data should be sanitized
|
||||
assert (
|
||||
"\ud800" not in loaded_data["cache_entry_1"]["return"]
|
||||
), "Surrogate in return should be removed"
|
||||
assert (
|
||||
"\udc9a" not in loaded_data["cache_entry_1"]["original_prompt"]
|
||||
), "Surrogate in prompt should be removed"
|
||||
|
||||
# Clean data should remain unchanged
|
||||
assert (
|
||||
loaded_data["cache_entry_2"]["return"] == "Clean result"
|
||||
), "Clean data should remain"
|
||||
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
def test_empty_values_after_sanitization(self):
|
||||
"""Test that data with empty values after sanitization is properly handled
|
||||
|
||||
Critical edge case: When sanitization results in data with empty string values,
|
||||
we must use 'if cleaned_data is not None' instead of 'if cleaned_data' to ensure
|
||||
proper reload, since truthy check on dict depends on content, not just existence.
|
||||
"""
|
||||
# Create data where ALL values are only surrogate characters
|
||||
all_dirty_data = {
|
||||
"key1": "\ud800\udc00\ud801",
|
||||
"key2": "\ud802\ud803",
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Write dirty data - should trigger sanitization
|
||||
needs_reload = write_json(all_dirty_data, temp_file)
|
||||
assert needs_reload, "All-dirty data should trigger sanitization"
|
||||
|
||||
# Load the sanitized data
|
||||
cleaned_data = load_json(temp_file)
|
||||
|
||||
# Critical assertions for the edge case
|
||||
assert cleaned_data is not None, "Cleaned data should not be None"
|
||||
# Sanitization removes surrogates but preserves keys with empty values
|
||||
assert cleaned_data == {
|
||||
"key1": "",
|
||||
"key2": "",
|
||||
}, "Surrogates should be removed, keys preserved"
|
||||
# This dict is truthy because it has keys (even with empty values)
|
||||
assert cleaned_data, "Dict with keys is truthy"
|
||||
|
||||
# Test the actual edge case: empty dict
|
||||
empty_data = {}
|
||||
needs_reload2 = write_json(empty_data, temp_file)
|
||||
assert not needs_reload2, "Empty dict is clean"
|
||||
|
||||
reloaded_empty = load_json(temp_file)
|
||||
assert reloaded_empty is not None, "Empty dict should not be None"
|
||||
assert reloaded_empty == {}, "Empty dict should remain empty"
|
||||
assert (
|
||||
not reloaded_empty
|
||||
), "Empty dict evaluates to False (the critical check)"
|
||||
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests
|
||||
test = TestWriteJsonOptimization()
|
||||
|
||||
print("Running test_fast_path_clean_data...")
|
||||
test.test_fast_path_clean_data()
|
||||
print("✓ Passed")
|
||||
|
||||
print("Running test_slow_path_dirty_data...")
|
||||
test.test_slow_path_dirty_data()
|
||||
print("✓ Passed")
|
||||
|
||||
print("Running test_sanitizing_encoder_removes_surrogates...")
|
||||
test.test_sanitizing_encoder_removes_surrogates()
|
||||
print("✓ Passed")
|
||||
|
||||
print("Running test_nested_structure_sanitization...")
|
||||
test.test_nested_structure_sanitization()
|
||||
print("✓ Passed")
|
||||
|
||||
print("Running test_unicode_non_characters_removed...")
|
||||
test.test_unicode_non_characters_removed()
|
||||
print("✓ Passed")
|
||||
|
||||
print("Running test_mixed_clean_dirty_data...")
|
||||
test.test_mixed_clean_dirty_data()
|
||||
print("✓ Passed")
|
||||
|
||||
print("Running test_empty_and_none_strings...")
|
||||
test.test_empty_and_none_strings()
|
||||
print("✓ Passed")
|
||||
|
||||
print("Running test_specific_surrogate_udc9a...")
|
||||
test.test_specific_surrogate_udc9a()
|
||||
print("✓ Passed")
|
||||
|
||||
print("Running test_migration_with_surrogate_sanitization...")
|
||||
test.test_migration_with_surrogate_sanitization()
|
||||
print("✓ Passed")
|
||||
|
||||
print("Running test_empty_values_after_sanitization...")
|
||||
test.test_empty_values_after_sanitization()
|
||||
print("✓ Passed")
|
||||
|
||||
print("\n✅ All tests passed!")
|
||||
Reference in New Issue
Block a user