# 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
307 lines
12 KiB
Python
307 lines
12 KiB
Python
"""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
|