Add intranet retrieval diagnostics for AI writing

This commit is contained in:
fengxiangdebaozi committed 2026-06-29 10:21:10 +08:00
1 parent bcd656fb49
commit 4fce6f9104
4 files changed
+236 -22

No files matched your search

@@ -115,6 +115,31 @@ def _fallback_knowledge_skill() -> SimpleNamespace:
return SimpleNamespace(name=KNOWLEDGE_SEARCH_SKILL_NAME, description=_KNOWLEDGE_SKILL_FALLBACK_DESC)
def _unique_limited(values: list, limit: int) -> list:
"""Stable de-dup for short diagnostic lists."""
return list(dict.fromkeys(values or []))[:limit]
def _intranet_query_summary(rows: list[dict], limit: int = 5) -> str:
"""Compact per-query stats for the visible skill step message."""
parts: list[str] = []
for row in rows[:limit]:
query = str(row.get("query") or "")[:24] or "(空)"
elapsed_ms = row.get("elapsed_ms")
try:
elapsed = f"{float(elapsed_ms) / 1000:.1f}s" if elapsed_ms is not None else ""
except (TypeError, ValueError):
elapsed = ""
if row.get("error"):
reason = str(row.get("error"))[:80]
parts.append(f"{query}: 失败{f'/{elapsed}' if elapsed else ''}({reason})")
else:
parts.append(f"{query}: {int(row.get('count') or 0)} 条{f'/{elapsed}' if elapsed else ''}")
if len(rows) > limit:
parts.append(f"另 {len(rows) - limit} 个方向")
return ";".join(parts)
def _load_configured_skills() -> list:
"""解析素材收集专家 agent(ai-writing-researcher)配置的技能列表。
@@ -594,6 +619,20 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
timeout=_intranet_timeout_seconds(ai_writing_config),
)
endpoint_label = intranet_url or getattr(provider, "endpoint", "内置默认内网端点")
intranet_debug = {
"mode": "knowledge_base",
"configured": bool(intranet_url),
"endpoint": endpoint_label,
"knowledge_base": ai_writing_config.get("intranet_knowledge_base", "samples"),
"timeout": _intranet_timeout_seconds(ai_writing_config),
"status": "running",
"queries": [],
"calls": [],
"errors": [],
"raw_responses": [],
"result_count": 0,
"used_as_fallback": False,
}
# 用户输入框追加(is_user_query_search)时 keywords 已是 [用户原话],全部检索;
# 首次检索按 keyword_count 截断检索词。
search_keywords = keywords if is_user_query_search else keywords[:keyword_count]
@@ -605,20 +644,65 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"message": f"使用「{_KNOWLEDGE_SKILL_DISPLAY}」直连内网接口({endpoint_label})检索 {total} 个方向:{('、'.join(search_keywords[:3]))[:60]}",
"skill_name": KNOWLEDGE_SEARCH_SKILL_NAME,
})
async def _search_one_keyword(kw: str) -> tuple[str, list[SearchResult], str, int]:
started = time.monotonic()
try:
results = await provider.search(kw, max_results=per_keyword)
return kw, list(results or []), "", int((time.monotonic() - started) * 1000)
except Exception as e: # noqa: BLE001 — provider 理论上会吞异常,这里兜住意外冒泡
logger.warning("[knowledge-base] keyword=%r search failed: %s", kw, e)
return kw, [], f"{type(e).__name__}: {e}", int((time.monotonic() - started) * 1000)
gathered = await asyncio.gather(
*[provider.search(kw, max_results=per_keyword) for kw in search_keywords],
*[_search_one_keyword(kw) for kw in search_keywords],
return_exceptions=True,
)
for results in gathered:
if isinstance(results, Exception):
logger.warning("[knowledge-base] search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}")
for item in gathered:
if isinstance(item, Exception):
logger.warning("[knowledge-base] search task failed: %s", item)
message = f"{type(item).__name__}: {item}"
search_errors.append(message)
intranet_debug["queries"].append({"query": "(unknown)", "count": 0, "error": message})
continue
kw, results, error, elapsed_ms = item
row = {"query": kw, "count": len(results or []), "elapsed_ms": elapsed_ms}
if error:
row["error"] = error
search_errors.append(error)
intranet_debug["queries"].append(row)
new_results.extend(results or [])
search_errors.extend(getattr(provider, "errors", []))
provider_errors = _unique_limited(getattr(provider, "errors", []) or [], 5)
search_errors.extend(provider_errors)
intranet_debug["errors"] = provider_errors
intranet_debug["raw_responses"] = _unique_limited(getattr(provider, "raw_responses", []) or [], 3)
intranet_debug["calls"] = list(getattr(provider, "calls", []) or [])[:20]
intranet_debug["result_count"] = len(new_results)
if search_errors and new_results:
intranet_debug["status"] = "partial"
elif search_errors:
intranet_debug["status"] = "error"
elif new_results:
intranet_debug["status"] = "ok"
else:
intranet_debug["status"] = "empty"
query_summary = _intranet_query_summary(intranet_debug["queries"])
if search_errors:
uniq_errors = _unique_limited(search_errors, 3)
_emit_skill_step({
"type": "skill_error",
"message": f"「{_KNOWLEDGE_SKILL_DISPLAY}」内网接口调用异常:{';'.join(uniq_errors)}",
"skill_name": KNOWLEDGE_SEARCH_SKILL_NAME,
"reason": ";".join(uniq_errors),
})
result_message = (
f"「{_KNOWLEDGE_SKILL_DISPLAY}」(内网接口)调用完成,检索到 {len(new_results)} 条素材"
+ (f"({query_summary})" if query_summary else "")
)
_emit_skill_step({
"type": "skill_result",
"message": f"「{_KNOWLEDGE_SKILL_DISPLAY}」(内网接口)检索到 {len(new_results)} 条素材",
"message": result_message,
"skill_name": KNOWLEDGE_SEARCH_SKILL_NAME,
"matched_count": len(new_results),
})
@@ -846,6 +930,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"debug_force": intranet_debug_force,
"status": "pending" if intranet_url else "not_configured",
"queries": [], # 每个检索词:{query, count, error?}
"calls": [], # provider 每次真实 HTTP 调用,含简化词重试
"errors": [], # provider 聚合的报错
"raw_responses": [], # 解析失败时的原始响应片段
"result_count": 0, # 内网总命中条数
@@ -880,8 +965,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
continue
intranet_debug["queries"].append({"query": kw, "count": len(res or [])})
collected.extend(res or [])
intranet_debug["errors"] = list(dict.fromkeys(getattr(intranet, "errors", [])))[:5]
intranet_debug["raw_responses"] = list(dict.fromkeys(getattr(intranet, "raw_responses", [])))[:3]
intranet_debug["errors"] = _unique_limited(getattr(intranet, "errors", []) or [], 5)
intranet_debug["raw_responses"] = _unique_limited(getattr(intranet, "raw_responses", []) or [], 3)
intranet_debug["calls"] = list(getattr(intranet, "calls", []) or [])[:20]
intranet_debug["result_count"] = len(collected)
intranet_debug["status"] = "ran"
return collected
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import time
from typing import List
from .base import BaseSearchProvider, SearchResult, simplify_query
@@ -38,6 +39,16 @@ def _pick(d: dict, keys: tuple) -> str:
return ""
def _raw_snippet(body: str) -> str:
"""Return a bounded raw response snippet for diagnostics."""
body = (body or "").strip()
if not body:
return ""
if len(body) > _RAW_SNIPPET_LIMIT:
return body[:_RAW_SNIPPET_LIMIT] + "…(响应体过长,已截断)"
return body
class IntranetSearchProvider(BaseSearchProvider):
"""内网知识库搜索接口适配器。
@@ -61,15 +72,15 @@ class IntranetSearchProvider(BaseSearchProvider):
self.errors: list[str] = []
# 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示
self.raw_responses: list[str] = []
# 每一次真实 HTTP 调用的诊断明细。search() 可能会因 0 结果触发简化词重试,
# 因此一个业务关键词可能对应多条 calls。
self.calls: list[dict] = []
def _record_raw(self, body: str) -> None:
"""收集解析失败 / 结构异常时的原始响应体,供前端折叠查看。"""
body = (body or "").strip()
if not body:
return
if len(body) > _RAW_SNIPPET_LIMIT:
body = body[:_RAW_SNIPPET_LIMIT] + "…(响应体过长,已截断)"
self.raw_responses.append(body)
snippet = _raw_snippet(body)
if snippet:
self.raw_responses.append(snippet)
async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]:
results = await self._search_once(query, max_results)
@@ -92,40 +103,87 @@ class IntranetSearchProvider(BaseSearchProvider):
}
# 先拿到原始响应文本,JSON 解析失败时把它原样回传前端便于定位接口返回了什么
raw_text = ""
started = time.monotonic()
call: dict = {
"query": query,
"endpoint": self.endpoint,
"knowledge_base_name": self.knowledge_base_name,
"max_results": max_results,
"status": "pending",
"ok": False,
"count": 0,
}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(self.endpoint, json=payload)
call["http_status"] = getattr(resp, "status_code", None)
resp.raise_for_status()
raw_text = resp.text
data = resp.json()
except httpx.HTTPStatusError as e:
# 非 2xx:接口有响应但状态码异常,把响应体一并暴露给前端
logger.warning("intranet search failed for query=%r: %s", query, e)
self.errors.append(f"{type(e).__name__}: {e}")
self._record_raw(e.response.text if e.response is not None else "")
message = f"{type(e).__name__}: {e}"
self.errors.append(message)
body = e.response.text if e.response is not None else ""
call.update({
"status": "http_error",
"error": message,
"http_status": getattr(e.response, "status_code", None),
})
snippet = _raw_snippet(body)
if snippet:
call["raw_response"] = snippet
self._record_raw(body)
return []
except Exception as e:
# 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体
logger.warning("intranet search failed for query=%r: %s", query, e)
self.errors.append(f"{type(e).__name__}: {e}")
message = f"{type(e).__name__}: {e}"
self.errors.append(message)
call.update({"status": "error", "error": message})
snippet = _raw_snippet(raw_text)
if snippet:
call["raw_response"] = snippet
self._record_raw(raw_text)
return []
finally:
call["elapsed_ms"] = int((time.monotonic() - started) * 1000)
# 非 pending 表示已经在异常分支写好;成功分支会在 return 前覆盖并追加。
if call.get("status") != "pending":
self.calls.append(call)
# 正常情况下接口直接返回数组;兼容包了一层 data/results 的结构(含工作正常的
# knowledge-search-v2:{"success":true,"results":[...]})。
if isinstance(data, list):
items = data
elif isinstance(data, dict):
if "success" in data:
call["response_success"] = bool(data.get("success"))
if "count" in data:
call["response_count"] = data.get("count")
# 接口显式标失败时把整体回传前端便于定位
if data.get("success") is False:
self.errors.append(f"接口返回 success=false:{str(data.get('error') or '')[:200]}")
message = f"接口返回 success=false:{str(data.get('error') or '')[:200]}"
self.errors.append(message)
call.update({"status": "success_false", "error": message})
snippet = _raw_snippet(raw_text)
if snippet:
call["raw_response"] = snippet
self.calls.append(call)
self._record_raw(raw_text)
return []
items = data.get("data") or data.get("results") or data.get("documents") or []
else:
# 返回结构既不是数组也不是已知 dict 包装:视为异常,把原始响应回传前端
logger.warning("intranet search unexpected response type for query=%r: %s", query, type(data).__name__)
self.errors.append(f"返回结构异常:期望数组,实际为 {type(data).__name__}")
message = f"返回结构异常:期望数组,实际为 {type(data).__name__}"
self.errors.append(message)
call.update({"status": "bad_shape", "error": message})
snippet = _raw_snippet(raw_text)
if snippet:
call["raw_response"] = snippet
self.calls.append(call)
self._record_raw(raw_text)
return []
@@ -158,6 +216,17 @@ class IntranetSearchProvider(BaseSearchProvider):
source_type="intranet",
recUuid=_pick(merged, ("recUuid", "rec_uuid", "id")),
))
call.update({
"status": "ok" if results else "empty",
"ok": True,
"count": len(results),
"item_count": len(items) if isinstance(items, list) else 0,
})
if not results:
snippet = _raw_snippet(raw_text)
if snippet:
call["raw_response"] = snippet
self.calls.append(call)
return results
async def health_check(self) -> bool:
@@ -40,6 +40,9 @@ class MaterialPackage(TypedDict, total=False):
skill_search_used: bool
# 技能检索的步骤记录(前端展示"使用了 X 技能"卡片)
skill_search_steps: List[SkillStep]
# 内网知识库接口诊断:端点、每个检索词的命中数/耗时/错误、原始响应片段等。
# 用于排查 "检索很快返回 0 条" 时到底是否真正调用了内网接口。
intranet_debug: dict
# ── 大纲 ──────────────────────────────────────────────────────────────────────
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
@@ -19,16 +19,33 @@ class _FakeIntranet:
def __init__(self, **kw):
self.errors = []
self.raw_responses = []
self.calls = []
self.kw = kw
_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 = []
@@ -38,7 +55,7 @@ class _SkillProviderShouldNotRun:
raise AssertionError("knowledge_base 模式不应调用通用 provider / 技能检索")
def _patch(monkeypatch, intranet_url="http://es/api"):
def _patch(monkeypatch, intranet_url="http://es/api", intranet_cls=_FakeIntranet):
_FakeIntranet.instances = []
cfg = {
"max_materials": 20,
@@ -60,7 +77,7 @@ def _patch(monkeypatch, intranet_url="http://es/api"):
monkeypatch.setattr(R, "get_search_provider", lambda _cfg: _SkillProviderShouldNotRun())
monkeypatch.setattr(
"deerflow.agents.ai_writing.search.intranet_stub.IntranetSearchProvider",
_FakeIntranet,
intranet_cls,
)
@@ -85,6 +102,14 @@ async def test_knowledge_base_hits_intranet_directly(monkeypatch):
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
@@ -107,3 +132,34 @@ async def test_knowledge_base_does_not_orchestrate_skills(monkeypatch):
# 不抛异常即证明没走 _SkillProviderShouldNotRun
out = await R.researcher_node(_state(), {"configurable": {}})
assert out["material_package"]["materials"]
@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"])