""" 知识闭环端到端测试 验证 执行日志 → 知识提取 → 知识存储 → 检索 → 注入 Prompt 的完整闭环。 """ import pytest from unittest.mock import patch, MagicMock @pytest.mark.unit @pytest.mark.knowledge class TestKnowledgeExtractionPipeline: """知识提取管道:日志 → LLM 提取 → KnowledgeEntry 存入""" def test_agent_execution_log_has_extraction_flag(self, db_session): """AgentExecutionLog 模型包含 knowledge_extracted 字段""" from app.models.agent_execution_log import AgentExecutionLog import uuid log = AgentExecutionLog( id=str(uuid.uuid4()), agent_name="test_agent", input_text="如何优化数据库查询性能?", output_text="可以通过建立索引、优化SQL语句、使用连接池等方式提升性能。", success=True, iterations_used=3, tool_calls_made=5, ) db_session.add(log) db_session.commit() assert log.knowledge_extracted is False assert log.success is True assert log.agent_name == "test_agent" def test_extract_from_execution_with_llm(self, db_session): """知识提取器从执行日志中提取知识(mock LLM)""" from app.services.knowledge_extractor import KnowledgeExtractor import json log_data = { "input_text": "Nginx 502 Bad Gateway 错误应该如何排查和修复?请给出完整步骤", "output_text": "先检查后端服务是否正常运行,再查看 Nginx 错误日志定位 upstream 连接问题,确认超时配置和缓冲区大小是否合理。", "success": True, "tool_chain": [{"name": "web_search", "result": "ok"}], "iterations_used": 3, "tool_calls_made": 2, } mock_llm_response = json.dumps({ "title": "Nginx 502 错误排查方法", "category": "best_practice", "tags": ["nginx", "502", "排查"], "situation": "Nginx 返回 502 Bad Gateway", "solution": "1.检查后端服务状态 2.查看error.log 3.调整超时配置", "caveats": "注意区分 502 和 504 的不同排查路径", "confidence": 0.85, }) extractor = KnowledgeExtractor(llm_model="test-model") with patch.object(extractor, "_sync_llm_call", return_value=mock_llm_response): result = extractor.extract_from_execution(log_data) assert result is not None assert result["title"] == "Nginx 502 错误排查方法" assert result["category"] == "best_practice" assert result["confidence"] == 0.85 assert len(result["tags"]) == 3 assert "检查后端服务状态" in result["solution"] def test_extract_skips_low_confidence(self, db_session): """低置信度 (<0.3) 的知识应被跳过""" from app.services.knowledge_extractor import KnowledgeExtractor import json log_data = { "input_text": "测试问题" * 10, "output_text": "测试回答" * 20, "success": True, "tool_chain": [], "iterations_used": 1, "tool_calls_made": 0, } mock_llm_response = json.dumps({ "title": "低质量知识", "category": "insight", "tags": [], "situation": "测试", "solution": "测试", "caveats": "", "confidence": 0.15, }) extractor = KnowledgeExtractor(llm_model="test-model") with patch.object(extractor, "_sync_llm_call", return_value=mock_llm_response): result = extractor.extract_from_execution(log_data) assert result is None def test_extract_skips_marked_skip(self, db_session): """LLM 标记 skip 时应跳过""" from app.services.knowledge_extractor import KnowledgeExtractor import json log_data = { "input_text": "你好" * 10, "output_text": "你好!有什么可以帮助你的吗?" * 10, "success": True, "tool_chain": [], "iterations_used": 1, "tool_calls_made": 0, } mock_llm_response = json.dumps({"skip": True, "reason": "对话过于简单"}) extractor = KnowledgeExtractor(llm_model="test-model") with patch.object(extractor, "_sync_llm_call", return_value=mock_llm_response): result = extractor.extract_from_execution(log_data) assert result is None def test_extract_skips_short_content(self, db_session): """输入/输出太短时应跳过(不调用 LLM)""" from app.services.knowledge_extractor import KnowledgeExtractor log_data = { "input_text": "hi", "output_text": "hello", "success": True, "tool_chain": [], "iterations_used": 1, "tool_calls_made": 0, } extractor = KnowledgeExtractor() result = extractor.extract_from_execution(log_data) assert result is None def test_knowledge_entry_created_from_extraction(self, db_session): """提取的知识应正确存入 KnowledgeEntry""" from app.models.knowledge_entry import KnowledgeEntry import uuid entry = KnowledgeEntry( title="MySQL 死锁重试策略", category="bug_fix", tags=["mysql", "deadlock", "retry"], situation="高并发下 MySQL 发生死锁", solution="捕获 DeadlockError 后休眠随机毫秒数再重试,最多3次", caveats="幂等操作才可安全重试", source_execution_ids=[str(uuid.uuid4())], source_agent_name="db_admin_agent", embedding_text="MySQL 死锁重试策略 高并发下 MySQL 发生死锁 捕获 DeadlockError 后休眠随机毫秒数再重试", extracted_by="llm_auto", confidence=0.9, ) db_session.add(entry) db_session.commit() assert entry.id is not None assert entry.category == "bug_fix" assert entry.confidence == 0.9 assert entry.retrieval_count == 0 assert entry.success_rate is None assert entry.is_active is True def test_pipeline_dedup_by_title(self, db_session): """管道去重:同名标题的知识条目不重复创建""" from app.models.knowledge_entry import KnowledgeEntry title = "唯一标题去重测试" entry1 = KnowledgeEntry(title=title, category="insight", extracted_by="llm_auto", confidence=0.8) db_session.add(entry1) db_session.commit() existing = db_session.query(KnowledgeEntry).filter( KnowledgeEntry.title == title ).first() assert existing is not None assert existing.id == entry1.id @pytest.mark.unit @pytest.mark.knowledge class TestKnowledgeRetrievalPipeline: """知识检索管道:关键词搜索 → 排序 → 格式化注入""" def test_query_by_keyword(self, db_session): """关键词搜索应能通过 LIKE 查询找到匹配条目""" from app.models.knowledge_entry import KnowledgeEntry from sqlalchemy import or_ entry = KnowledgeEntry( title="Redis 缓存穿透防护", category="best_practice", situation="大量请求查询不存在的缓存key导致DB压力", solution="使用布隆过滤器或缓存空值(短TTL)", caveats="布隆过滤器有误判率", extracted_by="llm_auto", confidence=0.9, ) db_session.add(entry) db_session.commit() # 模拟 retriever 的关键词 LIKE 查询 keywords = ["Redis", "缓存穿透"] conditions = [] for kw in keywords: like_pat = f"%{kw}%" conditions.append(KnowledgeEntry.title.like(like_pat)) conditions.append(KnowledgeEntry.situation.like(like_pat)) conditions.append(KnowledgeEntry.solution.like(like_pat)) results = db_session.query(KnowledgeEntry).filter( KnowledgeEntry.is_active == True, or_(*conditions), ).all() assert len(results) >= 1 assert results[0].title == "Redis 缓存穿透防护" def test_retrieval_increments_count(self, db_session): """检索后 retrieval_count 应 +1""" from app.models.knowledge_entry import KnowledgeEntry entry = KnowledgeEntry( title="测试检索计数", category="insight", situation="测试场景", solution="测试方案", extracted_by="llm_auto", confidence=0.8, retrieval_count=0, ) db_session.add(entry) db_session.commit() entry_id = entry.id # 模拟检索计数更新 entry.retrieval_count = (entry.retrieval_count or 0) + 1 db_session.commit() updated = db_session.query(KnowledgeEntry).filter(KnowledgeEntry.id == entry_id).first() assert updated is not None assert updated.retrieval_count == 1 def test_retrieve_respects_top_k(self, db_session): """检索应限制返回 top_k 条""" from app.models.knowledge_entry import KnowledgeEntry from sqlalchemy import or_ for i in range(5): entry = KnowledgeEntry( title=f"共享关键词条目{i}", category="insight", situation=f"场景{i}", solution=f"方案{i}", extracted_by="llm_auto", confidence=0.5 + i * 0.1, ) db_session.add(entry) db_session.commit() # 模拟 top_k 限制 top_k = 3 conditions = [] for kw in ["共享关键词条目"]: like_pat = f"%{kw}%" conditions.append(KnowledgeEntry.title.like(like_pat)) results = db_session.query(KnowledgeEntry).filter( KnowledgeEntry.is_active == True, or_(*conditions), ).limit(top_k).all() assert len(results) <= top_k def test_retrieve_filter_by_category(self, db_session): """应按类别过滤检索结果""" from app.models.knowledge_entry import KnowledgeEntry entry_bug = KnowledgeEntry(title="公共关键词Bug条目", category="bug_fix", situation="bug", solution="fix", extracted_by="llm_auto", confidence=0.8) entry_bp = KnowledgeEntry(title="公共关键词最佳实践", category="best_practice", situation="practice", solution="do", extracted_by="llm_auto", confidence=0.9) db_session.add_all([entry_bug, entry_bp]) db_session.commit() # 模拟类别过滤 results = db_session.query(KnowledgeEntry).filter( KnowledgeEntry.is_active == True, KnowledgeEntry.category == "bug_fix", ).all() for r in results: assert r.category == "bug_fix" def test_format_for_prompt(self, db_session): """知识条目格式化应为 Markdown 格式""" from app.services.knowledge_retriever import KnowledgeRetriever entries = [{ "id": "test-1", "title": "测试知识", "category": "best_practice", "tags": ["test"], "situation": "测试场景", "solution": "执行以下步骤\n1. 步骤A\n2. 步骤B", "caveats": "注意安全", "confidence": 0.9, }] retriever = KnowledgeRetriever() formatted = retriever.format_for_prompt(entries) assert "相关知识库经验" in formatted assert "测试知识" in formatted assert "置信度: 90%" in formatted assert "步骤A" in formatted assert "注意安全" in formatted def test_inject_knowledge_appends_to_prompt(self, db_session): """inject_knowledge 应检索并追加知识到 system prompt""" from app.models.knowledge_entry import KnowledgeEntry from app.services.knowledge_retriever import KnowledgeRetriever # 存入知识条目 entry = KnowledgeEntry( title="注入测试条目", category="insight", situation="注入测试", solution="应出现在结果中", extracted_by="llm_auto", confidence=0.9, ) db_session.add(entry) db_session.commit() # 使用 mock retrieve 返回已知条目 mock_entries = [{ "id": str(entry.id), "title": "注入测试条目", "category": "insight", "tags": [], "situation": "注入测试", "solution": "应出现在结果中", "caveats": "", "confidence": 0.9, }] retriever = KnowledgeRetriever() with patch.object(retriever, "retrieve", return_value=mock_entries): system_prompt = "你是一个有用的助手。" result = retriever.inject_knowledge(system_prompt, "注入测试") assert result.startswith(system_prompt) assert "相关知识库经验" in result assert "注入测试条目" in result def test_empty_retrieval_returns_original_prompt(self, db_session): """无匹配时 inject_knowledge 应返回原始的 system prompt""" from app.services.knowledge_retriever import KnowledgeRetriever retriever = KnowledgeRetriever() with patch.object(retriever, "retrieve", return_value=[]): system_prompt = "原始系统提示" result = retriever.inject_knowledge(system_prompt, "不存在的关键词") assert result == system_prompt def test_knowledge_with_tags_stored(self, db_session): """知识标签应正确存储""" from app.models.knowledge_entry import KnowledgeEntry tags = ["python", "async", "性能优化"] entry = KnowledgeEntry( title="Python 异步性能优化", category="optimization", tags=tags, situation="高并发异步任务", solution="使用 asyncio.gather 并发执行", extracted_by="llm_auto", confidence=0.85, ) db_session.add(entry) db_session.commit() refreshed = db_session.query(KnowledgeEntry).filter(KnowledgeEntry.id == entry.id).first() assert refreshed is not None assert "python" in refreshed.tags assert len(refreshed.tags) == 3 @pytest.mark.unit @pytest.mark.knowledge class TestKnowledgeLoopEndToEnd: """知识闭环端到端:执行→提取→检索→注入→验证""" def test_full_loop_log_to_retrieval(self, db_session): """端到端:模拟执行日志 → 提取知识 → 检索 → 注入 Prompt""" from app.models.agent_execution_log import AgentExecutionLog from app.models.knowledge_entry import KnowledgeEntry from app.services.knowledge_extractor import KnowledgeExtractor from app.services.knowledge_retriever import KnowledgeRetriever import json import uuid # Step 1: 创建执行日志 log = AgentExecutionLog( id=str(uuid.uuid4()), agent_name="devops_agent", input_text="生产环境 Redis 连接池耗尽导致服务不可用,如何预防?", output_text=( "1. 设置合理的 max_connections 上限\n" "2. 配置连接空闲超时和最大等待时间\n" "3. 添加连接池监控告警(可用连接数 < 20% 时告警)\n" "4. 使用连接池预热避免冷启动雪崩" ), success=True, iterations_used=4, tool_calls_made=3, ) db_session.add(log) db_session.commit() # Step 2: 模拟知识提取(mock LLM) mock_result = json.dumps({ "title": "Redis 连接池耗尽预防策略", "category": "best_practice", "tags": ["redis", "连接池", "高可用"], "situation": "生产环境 Redis 连接池耗尽", "solution": "设置max_connections上限+空闲超时+监控告警+连接池预热", "caveats": "max_connections 需要根据实例规格合理设置", "confidence": 0.92, }) extractor = KnowledgeExtractor() log_data = { "input_text": log.input_text, "output_text": log.output_text, "success": log.success, "tool_chain": [], "iterations_used": log.iterations_used, "tool_calls_made": log.tool_calls_made, } with patch.object(extractor, "_sync_llm_call", return_value=mock_result): knowledge = extractor.extract_from_execution(log_data) assert knowledge is not None assert knowledge["category"] == "best_practice" assert knowledge["confidence"] == 0.92 # Step 3: 创建 KnowledgeEntry entry = KnowledgeEntry( title=knowledge["title"], category=knowledge["category"], tags=knowledge["tags"], situation=knowledge["situation"], solution=knowledge["solution"], caveats=knowledge["caveats"], source_execution_ids=[str(log.id)], source_agent_name=log.agent_name, embedding_text=f"{knowledge['title']} {knowledge['situation']} {knowledge['solution']}", extracted_by="llm_auto", confidence=knowledge["confidence"], ) db_session.add(entry) db_session.commit() # Step 4: 通过 DB 查询验证知识已存储 stored = db_session.query(KnowledgeEntry).filter( KnowledgeEntry.title == "Redis 连接池耗尽预防策略" ).first() assert stored is not None assert stored.category == "best_practice" assert stored.source_execution_ids == [str(log.id)] assert stored.source_agent_name == "devops_agent" # Step 5: 格式化 Prompt(不用真实的 retriever,直接测试 format_for_prompt) retriever = KnowledgeRetriever() mock_entries = [{ "id": str(stored.id), "title": stored.title, "category": stored.category, "tags": stored.tags, "situation": stored.situation, "solution": stored.solution, "caveats": stored.caveats, "confidence": stored.confidence, }] enriched = retriever.format_for_prompt(mock_entries) assert "Redis 连接池耗尽预防策略" in enriched assert "相关知识库经验" in enriched # Step 6: 验证格式化后的内容可注入 system prompt system_prompt = "你是一个运维助手。" final_with_mock = system_prompt + enriched assert system_prompt in final_with_mock assert "相关知识库经验" in final_with_mock assert "Redis 连接池耗尽预防策略" in final_with_mock def test_multiple_executions_accumulate_knowledge(self, db_session): """多次执行应累积多条知识""" from app.models.knowledge_entry import KnowledgeEntry import uuid entries_data = [ ("Nginx配置优化", "optimization", 0.9), ("MySQL慢查询优化", "optimization", 0.85), ("Docker内存泄漏处理", "bug_fix", 0.8), ("API限流策略", "best_practice", 0.95), ("日志收集最佳实践", "best_practice", 0.88), ] for title, cat, conf in entries_data: entry = KnowledgeEntry( title=title, category=cat, extracted_by="llm_auto", confidence=conf, embedding_text=title, source_execution_ids=[str(uuid.uuid4())], ) db_session.add(entry) db_session.commit() all_entries = db_session.query(KnowledgeEntry).all() assert len(all_entries) >= 5 optimizations = [e for e in all_entries if e.category == "optimization"] assert len(optimizations) >= 2 best_practices = [e for e in all_entries if e.category == "best_practice"] assert len(best_practices) >= 2 def test_knowledge_entry_has_all_required_fields(self, db_session): """KnowledgeEntry 应包含所有闭环所需的字段""" from app.models.knowledge_entry import KnowledgeEntry import uuid entry = KnowledgeEntry( title="完整字段测试", category="insight", tags=["a", "b", "c"], situation="场景描述", solution="解决方案", caveats="注意事项", source_execution_ids=[str(uuid.uuid4()), str(uuid.uuid4())], source_agent_name="source_agent", source_model="deepseek-chat", embedding_text="embedding text", extracted_by="llm_auto", confidence=0.75, retrieval_count=5, success_rate=0.8, is_active=True, ) db_session.add(entry) db_session.commit() refreshed = db_session.query(KnowledgeEntry).filter(KnowledgeEntry.id == entry.id).first() assert refreshed is not None assert refreshed.title == "完整字段测试" assert refreshed.confidence == 0.75 assert refreshed.retrieval_count == 5 assert refreshed.success_rate == 0.8 assert refreshed.is_active is True assert len(refreshed.source_execution_ids) == 2 assert len(refreshed.tags) == 3 def test_deactivated_knowledge_not_retrieved(self, db_session): """is_active=False 的条目不应被检索到""" from app.models.knowledge_entry import KnowledgeEntry inactive = KnowledgeEntry( title="已停用知识点", category="insight", situation="不应出现", solution="不应出现", extracted_by="llm_auto", confidence=0.9, is_active=False, ) db_session.add(inactive) db_session.commit() # 查询活跃条目不应包含已停用的 active_results = db_session.query(KnowledgeEntry).filter( KnowledgeEntry.is_active == True, KnowledgeEntry.title == "已停用知识点", ).all() assert len(active_results) == 0 # 但停用条目仍在数据库中 all_results = db_session.query(KnowledgeEntry).filter( KnowledgeEntry.title == "已停用知识点", ).all() assert len(all_results) == 1 assert all_results[0].is_active is False def test_extraction_pipeline_marks_log_processed(self, db_session): """提取管道处理后应标记日志为已提取""" from app.models.agent_execution_log import AgentExecutionLog import uuid log = AgentExecutionLog( id=str(uuid.uuid4()), agent_name="test_agent", input_text="生产环境数据库连接超时如何排查?", output_text="逐步排查:1.检查网络连通性 2.检查连接池配置 3.检查数据库负载 4.检查慢查询", success=True, iterations_used=2, tool_calls_made=4, ) db_session.add(log) db_session.commit() # 模拟提取后的标记 log.knowledge_extracted = True db_session.commit() refreshed = db_session.query(AgentExecutionLog).filter( AgentExecutionLog.id == log.id ).first() assert refreshed is not None assert refreshed.knowledge_extracted is True