Files
deerflow-code/offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py
董宏凯 221e947488 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-dev
# Conflicts:
#	frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx
#	frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/app/gateway/services.py
#	offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py
2026-07-03 22:28:21 +08:00

307 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""researcher_node:检索类型 `knowledge_base`(知识库检索)= 直连内网 ES 接口。
弱模型识别不了技能时用这条路:不编排任何技能、不跑通用 provider,
直接调 ai_writing.intranet_search_url 内网知识库 ES 接口检索。
"""
from __future__ import annotations
from io import BytesIO
from types import SimpleNamespace
import pytest
from fastapi import UploadFile
from app.gateway.routers.ai_writing import _extract_sample_text
from deerflow.agents.ai_writing.nodes import researcher as R
from deerflow.agents.ai_writing.nodes import sample_analyzer as S
from deerflow.agents.ai_writing.search import get_search_provider
from deerflow.agents.ai_writing.search.base import SearchResult
from deerflow.agents.ai_writing.search.intranet_stub import IntranetSearchProvider
class _FakeIntranet:
instances: list = []
def __init__(self, **kw):
self.errors = []
self.raw_responses = []
self.calls = []
self.kw = kw
self.verify_ssl = kw.get("verify_ssl", False)
_FakeIntranet.instances.append(self)
async def search(self, query, max_results=5, **_):
self.calls.append({"query": query, "status": "ok", "ok": True, "count": 1, "elapsed_ms": 12})
return [SearchResult(
title=f"内网-{query}", content="内网正文", source="ES",
published_at="", url=f"http://es/{query}", source_type="intranet",
)]
class _EmptyIntranet(_FakeIntranet):
async def search(self, query, max_results=5, **_):
self.calls.append({"query": query, "status": "empty", "ok": True, "count": 0, "elapsed_ms": 9})
return []
class _FailingIntranet(_FakeIntranet):
async def search(self, query, max_results=5, **_):
msg = f"ConnectError: cannot connect for {query}"
self.errors.append(msg)
self.raw_responses.append("{\"success\": false, \"error\": \"cannot connect\"}")
self.calls.append({"query": query, "status": "error", "ok": False, "count": 0, "elapsed_ms": 3, "error": msg})
return []
class _SkillProviderShouldNotRun:
"""knowledge_base 模式不应触碰通用 provider —— 调到就让测试爆。"""
errors: list = []
raw_responses: list = []
async def search(self, query, max_results=5, **_): # pragma: no cover - 不应被调用
raise AssertionError("knowledge_base 模式不应调用通用 provider / 技能检索")
class _FakeResponse:
status_code = 200
text = '[{"title":"内网标题","content":"内网正文","url":"https://intranet/doc"}]'
def raise_for_status(self) -> None:
return None
def json(self):
return [{"title": "内网标题", "content": "内网正文", "url": "https://intranet/doc"}]
def _patch(monkeypatch, intranet_url="http://es/api", intranet_cls=_FakeIntranet, intranet_verify_ssl=False):
_FakeIntranet.instances = []
cfg = {
"max_materials": 20,
"intranet_search_url": intranet_url,
"intranet_knowledge_base": "samples",
"intranet_verify_ssl": intranet_verify_ssl,
}
monkeypatch.setattr(R, "get_ai_writing_config", lambda: cfg)
monkeypatch.setattr(R, "use_builtin_agent", lambda *_: False)
monkeypatch.setattr(R, "safe_get_stream_writer", lambda: (lambda *a, **k: None))
monkeypatch.setattr(R, "use_skill_search_agent", lambda: False)
async def fake_stream_llm_json(*a, **k):
return {"keywords": ["特朗普竞选"]}
monkeypatch.setattr(R, "_stream_llm_json", fake_stream_llm_json)
# knowledge_base 模式不该加载/编排技能,但仍打桩以防意外路径
monkeypatch.setattr(R, "_load_configured_skills",
lambda: [SimpleNamespace(name="knowledge-search-v2", description="内网检索")])
monkeypatch.setattr(R, "get_search_provider", lambda _cfg: _SkillProviderShouldNotRun())
monkeypatch.setattr(
"deerflow.agents.ai_writing.search.intranet_stub.IntranetSearchProvider",
intranet_cls,
)
def _state(material_source="knowledge_base"):
return {
"user_intent": "分析特朗普近期动向",
"article_type": "政治分析",
"target_audience": "普通读者",
"keyword_count": 1,
"material_source": material_source,
}
@pytest.mark.asyncio
async def test_knowledge_base_hits_intranet_directly(monkeypatch):
_patch(monkeypatch)
out = await R.researcher_node(_state(), {"configurable": {}})
mats = out["material_package"]["materials"]
assert mats, "知识库检索应返回内网素材"
assert all(m["source_type"] == "intranet" for m in mats)
assert mats[0]["title"] == "内网-特朗普竞选"
assert _FakeIntranet.instances, "应实例化 IntranetSearchProvider 直连内网接口"
# 走素材确认暂停点
assert out["status"] == "awaiting_material_confirm"
dbg = out["material_package"].get("intranet_debug")
assert dbg["mode"] == "knowledge_base"
assert dbg["status"] == "ok"
assert dbg["endpoint"] == "http://es/api"
assert dbg["result_count"] == 1
assert dbg["queries"][0]["query"] == "特朗普竞选"
assert dbg["queries"][0]["count"] == 1
assert dbg["calls"][0]["status"] == "ok"
@pytest.mark.asyncio
async def test_knowledge_base_no_url_falls_back_to_default_endpoint(monkeypatch):
"""未配置接口地址:仍实例化 IntranetSearchProvider(base_url 传空串,由 provider 内部
回退默认内网端点),内网直连依然可用——恢复旧版 search_provider=intranet 的行为。"""
_patch(monkeypatch, intranet_url="")
out = await R.researcher_node(_state(), {"configurable": {}})
mats = out["material_package"]["materials"]
assert mats, "无地址时应回退默认内网端点,仍返回素材"
assert all(m["source_type"] == "intranet" for m in mats)
assert _FakeIntranet.instances, "应实例化 IntranetSearchProvider 直连内网接口"
assert _FakeIntranet.instances[0].kw.get("base_url") == "", "空地址应原样下传,由 provider 回退默认端点"
@pytest.mark.asyncio
async def test_knowledge_base_passes_verify_ssl_config(monkeypatch):
_patch(monkeypatch, intranet_verify_ssl=True)
out = await R.researcher_node(_state(), {"configurable": {}})
assert _FakeIntranet.instances[0].kw.get("verify_ssl") is True
assert out["material_package"]["intranet_debug"]["verify_ssl"] is True
@pytest.mark.asyncio
async def test_knowledge_base_does_not_orchestrate_skills(monkeypatch):
"""knowledge_base 不编排技能:通用 provider 一旦被调用就会 raise。"""
_patch(monkeypatch)
# 不抛异常即证明没走 _SkillProviderShouldNotRun
out = await R.researcher_node(_state(), {"configurable": {}})
assert out["material_package"]["materials"]
@pytest.mark.asyncio
async def test_sample_analyzer_skips_normal_mode():
out = await S.sample_analyzer_node({"writing_mode_type": "normal"}, {"configurable": {}})
assert out == {}
@pytest.mark.asyncio
async def test_sample_analyzer_builds_style_profile(monkeypatch):
async def fake_stream_sample_analysis(*_args, **_kwargs):
return {
"summary": "短段落、评论式表达",
"tone": "克制、坚定",
"structure": ["先提出问题", "再分层论证"],
"language_features": ["多用判断句"],
"sentence_style": "短句为主",
"opening_style": "开门见山",
"ending_style": "总结升华",
"imitation_guidance": ["保持评论式节奏"],
"avoid_copying": ["不要照抄原句"],
"sample_outline": ["问题提出", "原因分析"],
}
monkeypatch.setattr(S, "_stream_sample_analysis", fake_stream_sample_analysis)
out = await S.sample_analyzer_node({
"writing_mode_type": "imitate",
"sample_text": "这是样文第一段。\n\n这是样文第二段。",
"sample_filename": "demo.md",
"user_intent": "写一篇新文章",
}, {"configurable": {"model_name": "m1"}})
assert out["status"] == "researching"
assert out["sample_style_profile"]["summary"] == "短段落、评论式表达"
assert out["sample_style_profile"]["tone"] == "克制、坚定"
assert any(e["type"] == "sample_profile_ready" for e in out["progress_events"])
def test_render_sample_style_section_contains_copy_guardrails():
section = S.render_sample_style_section({
"writing_mode_type": "imitate",
"imitate_strength": "strong",
"preserve_sample_structure": True,
"sample_style_profile": {
"summary": "评论体",
"tone": "正式",
"structure": ["开头提出问题"],
"language_features": ["短句"],
"sentence_style": "长短句结合",
"opening_style": "开门见山",
"ending_style": "总结",
"imitation_guidance": ["保留节奏"],
"avoid_copying": ["不要照抄"],
"sample_outline": ["引入", "展开"],
},
})
assert "样文仿写要求" in section
assert "不得连续照抄样文原句" in section
assert "评论体" in section
@pytest.mark.asyncio
async def test_extract_sample_text_reads_txt_upload():
upload = UploadFile(file=BytesIO(" 样文内容 ".encode("utf-8")), filename="sample.txt")
name, text = await _extract_sample_text(upload)
assert name == "sample.txt"
assert text == "样文内容"
@pytest.mark.asyncio
async def test_knowledge_base_empty_result_is_visible(monkeypatch):
"""接口成功但 0 条也要在返回里能看出来,而不是只显示"无素材"。"""
_patch(monkeypatch, intranet_cls=_EmptyIntranet)
out = await R.researcher_node(_state(), {"configurable": {}})
pkg = out["material_package"]
assert pkg["materials"] == []
dbg = pkg.get("intranet_debug")
assert dbg["status"] == "empty"
assert dbg["result_count"] == 0
assert dbg["queries"][0]["count"] == 0
assert dbg["calls"][0]["status"] == "empty"
steps = pkg["skill_search_steps"]
assert any(step["type"] == "skill_result" and "0 条" in step["message"] for step in steps)
@pytest.mark.asyncio
async def test_knowledge_base_failure_is_visible(monkeypatch):
"""provider 吞掉异常返回 [] 时,前端仍应看到报错步骤与 notice。"""
_patch(monkeypatch, intranet_cls=_FailingIntranet)
out = await R.researcher_node(_state(), {"configurable": {}})
pkg = out["material_package"]
dbg = pkg.get("intranet_debug")
assert dbg["status"] == "error"
assert dbg["errors"]
assert dbg["raw_responses"]
steps = pkg["skill_search_steps"]
assert any(step["type"] == "skill_error" for step in steps)
assert any(event["type"] == "notice" and event.get("raw") for event in out["progress_events"])
@pytest.mark.asyncio
async def test_intranet_search_disables_ssl_verification_for_internal_https(monkeypatch):
import httpx
seen: dict = {}
class _FakeAsyncClient:
def __init__(self, **kwargs):
seen.update(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, endpoint, json):
seen["endpoint"] = endpoint
seen["payload"] = json
return _FakeResponse()
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
provider = IntranetSearchProvider(base_url="https://intranet.example/search", knowledge_base_name="kb", timeout=300)
results = await provider.search("测试", max_results=1)
assert results
assert seen["verify"] is False
assert seen["trust_env"] is False
assert seen["endpoint"] == "https://intranet.example/search"
assert seen["payload"]["knowledge_base_name"] == "kb"
assert provider.calls[0]["verify_ssl"] is False
def test_intranet_verify_ssl_can_be_reenabled_from_config():
provider = get_search_provider({"search_provider": "intranet", "intranet_verify_ssl": "true"})
assert isinstance(provider, IntranetSearchProvider)
assert provider.verify_ssl is True