Files

1212 lines
42 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.
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
from typing import Any
import httpx
import pytest
from app.gateway import wiki_directory_constraints as constraints
from app.gateway.wiki_directory_constraints import (
ensure_directory_paths,
ensure_fixed_parent_directories,
normalize_directory_paths,
normalize_parent_directories,
reconcile_fixed_parent_directories,
)
from deerflow.integrations.weknora.client import WeKnoraClient
class FakeConflict(RuntimeError):
status_code = 409
class FakeWeKnora:
def __init__(
self,
*,
folders: list[dict[str, Any]] | None = None,
pages: list[dict[str, Any]] | None = None,
folder_envelope: str = "folders",
) -> None:
self.folders = [dict(item) for item in (folders or [])]
self.pages = [dict(item) for item in (pages or [])]
self.folder_envelope = folder_envelope
self.calls: list[tuple[Any, ...]] = []
self._next_id = 1
async def list_wiki_folders(
self,
knowledge_base_id: str,
*,
parent_id: str = "",
page_types: list[str] | None = None,
) -> Any:
self.calls.append(("list_folders", knowledge_base_id, parent_id))
rows = [dict(item) for item in self.folders if str(item.get("parent_id") or "") == parent_id]
if self.folder_envelope == "data-list":
return {"data": rows}
if self.folder_envelope == "data-folders":
return {"data": {"folders": rows}}
return {"parent_id": parent_id, "folders": rows}
async def create_wiki_folder(self, knowledge_base_id: str, *, name: str, parent_id: str = "") -> dict[str, Any]:
self.calls.append(("create_folder", knowledge_base_id, name, parent_id))
if any(str(item.get("parent_id") or "") == parent_id and item.get("name") == name for item in self.folders):
raise FakeConflict("folder already exists")
folder = {"id": f"created-{self._next_id}", "name": name, "parent_id": parent_id}
self._next_id += 1
self.folders.append(folder)
return dict(folder)
async def update_wiki_folder(self, knowledge_base_id: str, folder_id: str, changes: dict[str, Any]) -> dict[str, Any]:
self.calls.append(("update_folder", knowledge_base_id, folder_id, dict(changes)))
folder = next(item for item in self.folders if item["id"] == folder_id)
target_parent = str(changes.get("parent_id") if changes.get("move_parent") else folder.get("parent_id") or "")
target_name = str(changes.get("name", folder.get("name") or ""))
if any(
item["id"] != folder_id
and str(item.get("parent_id") or "") == target_parent
and item.get("name") == target_name
for item in self.folders
):
raise FakeConflict("folder conflict")
if "name" in changes:
folder["name"] = changes["name"]
if changes.get("move_parent"):
folder["parent_id"] = str(changes.get("parent_id") or "")
return dict(folder)
async def delete_wiki_folder(self, knowledge_base_id: str, folder_id: str) -> None:
self.calls.append(("delete_folder", knowledge_base_id, folder_id))
self.folders = [item for item in self.folders if item["id"] != folder_id]
async def list_wiki_pages(
self,
knowledge_base_id: str,
*,
page: int,
page_size: int,
folder_id: str | None = None,
) -> dict[str, Any]:
self.calls.append(("list_pages", knowledge_base_id, page, page_size, folder_id))
rows = self.pages if folder_id is None else [item for item in self.pages if str(item.get("folder_id") or "") == folder_id]
start = (page - 1) * page_size
batch = [dict(item) for item in rows[start : start + page_size]]
return {"data": {"pages": batch, "total": len(rows), "page": page, "page_size": page_size}}
async def move_wiki_page(self, knowledge_base_id: str, slug: str, folder_id: str) -> dict[str, Any]:
self.calls.append(("move_page", knowledge_base_id, slug, folder_id))
page = next(item for item in self.pages if item["slug"] == slug)
page["folder_id"] = folder_id
return dict(page)
async def get_wiki_page(
self,
knowledge_base_id: str,
slug: str,
) -> dict[str, Any]:
self.calls.append(("get_page", knowledge_base_id, slug))
return dict(next(item for item in self.pages if item["slug"] == slug))
async def update_wiki_page(
self,
knowledge_base_id: str,
slug: str,
changes: dict[str, Any],
) -> dict[str, Any]:
self.calls.append(("update_page", knowledge_base_id, slug, dict(changes)))
page = next(item for item in self.pages if item["slug"] == slug)
# The production WeKnora version binds PUT to a fresh WikiPage and
# replaces these fields. Missing keys therefore become zero values;
# modelling PATCH via ``dict.update`` would hide destructive callers.
defaults: dict[str, Any] = {
"title": "",
"content": "",
"summary": "",
"page_type": "",
"status": "",
"aliases": [],
"parent_slug": "",
"folder_id": "",
"sort_order": 0,
"source_refs": [],
"chunk_refs": [],
"page_metadata": {},
}
page.update(
{
key: changes.get(key, default)
for key, default in defaults.items()
}
)
return dict(page)
async def delete_wiki_page(self, knowledge_base_id: str, slug: str) -> None:
self.calls.append(("delete_page", knowledge_base_id, slug))
self.pages = [item for item in self.pages if item["slug"] != slug]
async def rebuild_wiki_links(self, knowledge_base_id: str) -> None:
self.calls.append(("rebuild_links", knowledge_base_id))
def test_normalize_parent_directories_preserves_order_and_enforces_limits() -> None:
assert normalize_parent_directories([" 政治 ", "", "经济", "政治", " 军事 "]) == ["政治", "经济", "军事"]
assert normalize_parent_directories(None) == []
for unsafe in ("政治/制度", "军事\\体系", "经济|产业", "社会/文化", "科技|创新", "含\t制表符"):
with pytest.raises(ValueError):
normalize_parent_directories([unsafe])
with pytest.raises(ValueError, match="100 项"):
normalize_parent_directories([f"目录{index}" for index in range(101)])
with pytest.raises(ValueError, match="100 个字符"):
normalize_parent_directories(["章" * 101])
with pytest.raises(ValueError, match="6000"):
normalize_parent_directories(["章" * 100 for _ in range(61)])
def test_normalize_directory_paths_builds_canonical_parent_first_tree() -> None:
assert normalize_directory_paths(
[
[" 政策法规 ", " 国内政策 "],
["政策法规"],
["政策法规", "国内政策", "申报流程"],
["政策法规", "国内政策"],
]
) == [
["政策法规"],
["政策法规", "国内政策"],
["政策法规", "国内政策", "申报流程"],
]
# Equivalent root spellings keep the first display form for descendants.
assert normalize_directory_paths([["A"], ["a", "二级"]]) == [
["A"],
["A", "二级"],
]
with pytest.raises(ValueError, match="1~3"):
normalize_directory_paths([["一", "二", "三", "四"]])
with pytest.raises(ValueError, match="不能是"):
normalize_directory_paths([[".."]])
def test_focus_classifier_parser_ignores_in_band_reasoning_json() -> None:
text = (
'<think>Do not parse this example: {"decisions":{}}.</think>\n'
'{"decisions":{"page:noise":{"keep":false,"directory":""}}}'
)
assert constraints._parse_focus_classifier_response(
text,
{"page:noise"},
{"政策 / 目标"},
) == {"page:noise": {"keep": False, "directory": ""}}
@pytest.mark.asyncio
async def test_default_focus_classifier_retries_only_omitted_items(
monkeypatch: pytest.MonkeyPatch,
) -> None:
prompts: list[str] = []
responses = [
{
"decisions": {
"page:one": {
"keep": True,
"directory": "政策 / 目标",
}
}
},
{
"decisions": {
"page:two": {"keep": False, "directory": ""},
"page:three": {
"keep": True,
"directory": "政策 / 目标",
},
}
},
]
class Model:
async def ainvoke(self, prompt: str) -> SimpleNamespace:
prompts.append(prompt)
return SimpleNamespace(
content=json.dumps(responses.pop(0), ensure_ascii=False)
)
import deerflow.models
monkeypatch.setattr(
deerflow.models,
"create_chat_model",
lambda **_: Model(),
)
items = [
{
"item_id": f"page:{name}",
"kind": "page",
"title": name,
}
for name in ("one", "two", "three")
]
result = await constraints._default_focus_classifier(
items,
"只保留目标",
["政策 / 目标"],
)
assert result == {
"page:one": {"keep": True, "directory": "政策 / 目标"},
"page:two": {"keep": False, "directory": ""},
"page:three": {"keep": True, "directory": "政策 / 目标"},
}
assert len(prompts) == 2
assert "page:one" in prompts[0]
assert "page:one" not in prompts[1]
assert "page:two" in prompts[1]
assert "page:three" in prompts[1]
@pytest.mark.asyncio
async def test_default_focus_classifier_resolves_stubborn_omission_individually(
monkeypatch: pytest.MonkeyPatch,
) -> None:
prompts: list[str] = []
responses = [
{"decisions": {}},
{"decisions": {}},
{"keep": False, "directory": ""},
]
class Model:
async def ainvoke(self, prompt: str) -> SimpleNamespace:
prompts.append(prompt)
return SimpleNamespace(
content=json.dumps(responses.pop(0), ensure_ascii=False)
)
import deerflow.models
monkeypatch.setattr(
deerflow.models,
"create_chat_model",
lambda **_: Model(),
)
result = await constraints._default_focus_classifier(
[{"item_id": "page:noise", "kind": "page", "title": "无关噪声"}],
"只保留量化目标",
["政策 / 目标"],
)
assert result == {"page:noise": {"keep": False, "directory": ""}}
assert len(prompts) == 3
assert "There is exactly one page" in prompts[-1]
@pytest.mark.asyncio
async def test_ensure_directory_paths_recursively_creates_each_level_once() -> None:
client = FakeWeKnora()
paths = [
["政策法规"],
["政策法规", "国内政策"],
["政策法规", "国内政策", "申报流程"],
]
first = await ensure_directory_paths(client, "kb-one", paths)
second = await ensure_directory_paths(client, "kb-one", paths)
assert first == second
assert list(first) == [
"政策法规",
"政策法规 / 国内政策",
"政策法规 / 国内政策 / 申报流程",
]
assert [call for call in client.calls if call[0] == "create_folder"] == [
("create_folder", "kb-one", "政策法规", ""),
("create_folder", "kb-one", "国内政策", first["政策法规"]),
(
"create_folder",
"kb-one",
"申报流程",
first["政策法规 / 国内政策"],
),
]
@pytest.mark.asyncio
async def test_ensure_directory_paths_adopts_empty_folders_after_conflict() -> None:
"""WeKnora hides empty folders outside its merged page-type view.
The first read for each parent also simulates a concurrent-create race:
list returns no row, create reports 409, and the conflict re-read exposes
the already-created empty folder.
"""
class EmptyFolderRaceClient:
def __init__(self) -> None:
self.folders = [
{
"id": "existing-root",
"name": "国内新闻",
"parent_id": "",
},
{
"id": "existing-child",
"name": "政策发布",
"parent_id": "existing-root",
},
]
self.list_count: dict[str, int] = {}
self.create_calls: list[tuple[str, str]] = []
async def list_wiki_folders(
self,
knowledge_base_id: str,
*,
parent_id: str = "",
page_types: list[str] | None = None,
) -> dict[str, Any]:
assert knowledge_base_id == "kb-news"
assert page_types == [
"summary",
"entity",
"concept",
"index",
"synthesis",
"comparison",
"article",
]
count = self.list_count.get(parent_id, 0) + 1
self.list_count[parent_id] = count
rows = [
dict(item)
for item in self.folders
if str(item.get("parent_id") or "") == parent_id
]
# A stale first read creates the exact list/create race that the
# conflict recovery path must tolerate.
if count == 1:
rows = []
return {"parent_id": parent_id, "folders": rows}
async def create_wiki_folder(
self,
knowledge_base_id: str,
*,
name: str,
parent_id: str = "",
) -> dict[str, Any]:
assert knowledge_base_id == "kb-news"
self.create_calls.append((name, parent_id))
raise FakeConflict("wiki folder name conflict")
client = EmptyFolderRaceClient()
paths = [["国内新闻"], ["国内新闻", "政策发布"]]
first = await ensure_directory_paths(client, "kb-news", paths)
second = await ensure_directory_paths(client, "kb-news", paths)
assert first == second == {
"国内新闻": "existing-root",
"国内新闻 / 政策发布": "existing-child",
}
assert client.create_calls == [
("国内新闻", ""),
("政策发布", "existing-root"),
]
@pytest.mark.asyncio
async def test_ensure_directory_paths_matches_existing_name_by_nfkc_casefold() -> None:
client = FakeWeKnora(
folders=[{"id": "root-a", "name": "A", "parent_id": ""}],
)
result = await ensure_directory_paths(client, "kb-one", [["a"]])
assert result == {"a": "root-a"}
assert not [call for call in client.calls if call[0] == "create_folder"]
@pytest.mark.asyncio
async def test_empty_constraints_make_no_remote_calls() -> None:
client = FakeWeKnora()
assert await ensure_fixed_parent_directories(client, "kb-one", None) == {}
result = await reconcile_fixed_parent_directories(
client,
{"weknora_id": "kb-one", "wiki_parent_directories": []},
)
assert result == {
"roots": {},
"created_roots": 0,
"moved_folders": 0,
"renamed_folders": 0,
"moved_pages": 0,
}
assert client.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize("folder_envelope", ["folders", "data-folders", "data-list"])
async def test_ensure_fixed_roots_rebuilds_only_missing_names(folder_envelope: str) -> None:
client = FakeWeKnora(
folders=[{"id": "root-a", "name": "政治", "parent_id": ""}],
folder_envelope=folder_envelope,
)
first = await ensure_fixed_parent_directories(client, "kb-one", ["政治", "经济"])
second = await ensure_fixed_parent_directories(client, "kb-one", ["政治", "经济"])
assert list(first) == ["政治", "经济"]
assert first == second
assert [item["name"] for item in client.folders if not item.get("parent_id")] == ["政治", "经济"]
assert [call for call in client.calls if call[0] == "create_folder"] == [
("create_folder", "kb-one", "经济", "")
]
@pytest.mark.asyncio
async def test_legacy_directory_reference_does_not_move_folders_or_pages() -> None:
client = FakeWeKnora(
folders=[
{"id": "politics", "name": "政治", "parent_id": ""},
{"id": "existing-defense", "name": "军事", "parent_id": "politics"},
{"id": "extra-defense", "name": "军事", "parent_id": ""},
{"id": "extra-defense-child", "name": "装备", "parent_id": "extra-defense"},
{"id": "extra-market", "name": "市场", "parent_id": ""},
],
pages=[
{"slug": "index", "title": "Wiki Index", "page_type": "index", "folder_id": ""},
{"slug": "directory", "title": "目录", "page_type": "summary", "folder_id": ""},
{"slug": "weekly-report", "title": "每周经济报告", "page_type": "summary", "folder_id": ""},
],
)
classified: list[str] = []
async def classifier(item: dict[str, Any], roots: list[str]) -> str:
classified.append(str(item["item_id"]))
assert roots == ["政治", "经济"]
if item.get("name") == "军事":
return "政治"
return "经济"
first = await reconcile_fixed_parent_directories(
client,
{"weknora_id": "kb-one", "wiki_parent_directories": ["政治", "经济"]},
classifier=classifier,
)
second = await reconcile_fixed_parent_directories(
client,
{"weknora_id": "kb-one", "wiki_parent_directories": ["政治", "经济"]},
classifier=classifier,
)
assert first["created_roots"] == 0
assert first["moved_folders"] == 0
assert first["renamed_folders"] == 0
assert first["moved_pages"] == 0
assert second == first
assert classified == []
top_level = {item["name"]: item["id"] for item in client.folders if not item.get("parent_id")}
assert set(top_level) == {"政治", "军事", "市场"}
moved_defense = next(item for item in client.folders if item["id"] == "extra-defense")
assert moved_defense["parent_id"] == ""
assert moved_defense["name"] == "军事"
assert next(item for item in client.folders if item["id"] == "extra-defense-child")["parent_id"] == "extra-defense"
assert next(item for item in client.folders if item["id"] == "extra-market")["parent_id"] == ""
assert next(item for item in client.pages if item["slug"] == "weekly-report")["folder_id"] == ""
assert next(item for item in client.pages if item["slug"] == "index")["folder_id"] == ""
assert next(item for item in client.pages if item["slug"] == "directory")["folder_id"] == ""
@pytest.mark.asyncio
async def test_directory_reference_preserves_article_only_extra_folder() -> None:
class PageTypeFilteringFolders(FakeWeKnora):
async def list_wiki_folders(
self,
knowledge_base_id: str,
*,
parent_id: str = "",
page_types: list[str] | None = None,
) -> dict[str, Any]:
self.calls.append(("list_folders", knowledge_base_id, parent_id))
allowed_types = set(page_types or [])
rows = []
for folder in self.folders:
if str(folder.get("parent_id") or "") != parent_id:
continue
folder_id = str(folder.get("id") or "")
if any(
str(page.get("folder_id") or "") == folder_id
and str(page.get("page_type") or "") in allowed_types
for page in self.pages
):
rows.append(dict(folder))
return {"parent_id": parent_id, "folders": rows}
client = PageTypeFilteringFolders(
folders=[
{
"id": "manual-extra",
"name": "人工目录",
"parent_id": "",
}
],
pages=[
{
"slug": "manual-article",
"title": "人工维护页",
"content": "由管理员手工创建。",
"page_type": "article",
"folder_id": "manual-extra",
}
],
)
result = await reconcile_fixed_parent_directories(
client,
{
"weknora_id": "kb-one",
"wiki_directory_paths": [["允许目录"]],
"wiki_focus_instructions": "",
},
)
assert result["moved_pages"] == 0
assert {folder["name"] for folder in client.folders} == {"人工目录"}
assert client.pages[0]["folder_id"] == "manual-extra"
assert client.calls == []
@pytest.mark.asyncio
async def test_directory_reference_does_not_limit_strict_focus_filter() -> None:
client = FakeWeKnora(
folders=[
{"id": "root-policy", "name": "政策", "parent_id": ""},
{"id": "leaf-target", "name": "目标", "parent_id": "root-policy"},
{"id": "extra-root", "name": "石窟寺", "parent_id": ""},
{"id": "extra-child", "name": "保护利用", "parent_id": "extra-root"},
],
pages=[
{
"slug": "concept/charging-target",
"title": "2027年充电设施目标",
"summary": "设施总量与公共充电容量",
"page_type": "concept",
"folder_id": "extra-root",
"source_refs": ["doc-a"],
},
{
"slug": "concept/cave-temple",
"title": "石窟寺保护利用",
"summary": "文物保护背景材料",
"page_type": "concept",
"folder_id": "extra-child",
"source_refs": ["doc-b"],
},
{
"slug": "extra-directory",
"title": "石窟寺目录",
"page_type": "directory",
"folder_id": "extra-root",
},
],
)
focus_calls = 0
async def focus_classifier(
items: list[dict[str, Any]],
focus: str,
allowed_paths: list[str],
) -> dict[str, dict[str, Any]]:
nonlocal focus_calls
focus_calls += 1
assert focus == "只提取明确年份和充电设施量化目标"
assert allowed_paths == []
assert {item["item_id"] for item in items} == {
"page:concept/charging-target",
"page:concept/cave-temple",
}
return {
"page:concept/charging-target": {
"keep": True,
"directory": "",
},
"page:concept/cave-temple": {"keep": False, "directory": ""},
}
result = await reconcile_fixed_parent_directories(
client,
{
"weknora_id": "kb-one",
"wiki_directory_paths": [["政策"], ["政策", "目标"]],
"wiki_focus_instructions": "只提取明确年份和充电设施量化目标",
},
focus_classifier=focus_classifier,
)
second = await reconcile_fixed_parent_directories(
client,
{
"weknora_id": "kb-one",
"wiki_directory_paths": [["政策"], ["政策", "目标"]],
"wiki_focus_instructions": "只提取明确年份和充电设施量化目标",
},
focus_classifier=focus_classifier,
)
assert result["focus_enforced"] is True
assert result["strict_hierarchy_enforced"] is False
assert result["moved_pages"] == 0
assert result["deleted_pages"] == 1
assert result["deleted_folders"] == 0
assert {folder["id"] for folder in client.folders} == {
"root-policy",
"leaf-target",
"extra-root",
"extra-child",
}
assert focus_calls == 1
assert second["moved_pages"] == 0
assert second["deleted_pages"] == 0
assert second["deleted_folders"] == 0
assert [page["slug"] for page in client.pages] == [
"concept/charging-target",
"extra-directory",
]
kept_page = client.pages[0]
assert kept_page["folder_id"] == "extra-root"
marker = kept_page["page_metadata"]["deerflow_generation_constraints"]
assert marker["schema"] == 1
assert marker["keep"] is True
assert marker["directory"] == ""
assert len([call for call in client.calls if call[0] == "update_page"]) == 1
assert ("rebuild_links", "kb-one") in client.calls
@pytest.mark.asyncio
async def test_focus_marker_preserves_full_page_across_replacement_put() -> None:
original = {
"slug": "concept/data-infrastructure",
"title": "数据基础设施",
"content": "到2029年建成主体结构,到2035年形成完善体系。",
"summary": "建设目标与时间表",
"page_type": "concept",
"status": "published",
"aliases": ["数据底座"],
"parent_slug": "concept/digital-china",
"folder_id": "target-folder",
"sort_order": 7,
"source_refs": ["doc-one|建设指引"],
"chunk_refs": ["chunk-one"],
"page_metadata": {"language": "zh-CN"},
}
client = FakeWeKnora(
folders=[{"id": "target-folder", "name": "建设指引", "parent_id": ""}],
pages=[original],
)
async def keep_page(
items: list[dict[str, Any]],
_: str,
__: list[str],
) -> dict[str, dict[str, Any]]:
return {
str(items[0]["item_id"]): {
"keep": True,
"directory": "建设指引",
}
}
await reconcile_fixed_parent_directories(
client,
{
"weknora_id": "kb-one",
"wiki_directory_paths": [["建设指引"]],
"wiki_focus_instructions": "只保留建设目标和明确时间节点",
},
focus_classifier=keep_page,
)
page = client.pages[0]
for key in (
"title",
"content",
"summary",
"page_type",
"status",
"aliases",
"parent_slug",
"folder_id",
"sort_order",
"source_refs",
"chunk_refs",
):
assert page[key] == original[key]
assert page["page_metadata"]["language"] == "zh-CN"
assert (
page["page_metadata"]["deerflow_generation_constraints"]["keep"]
is True
)
update_calls = [call for call in client.calls if call[0] == "update_page"]
assert len(update_calls) == 1
payload = update_calls[0][3]
assert {
"title",
"content",
"summary",
"page_type",
"status",
"aliases",
"parent_slug",
"folder_id",
"sort_order",
"source_refs",
"chunk_refs",
"page_metadata",
} <= payload.keys()
@pytest.mark.asyncio
async def test_focus_only_constraint_filters_pages_without_changing_directories() -> None:
client = FakeWeKnora(
folders=[{"id": "existing-folder", "name": "原有目录", "parent_id": ""}],
pages=[
{
"slug": "keep",
"title": "2029目标",
"folder_id": "existing-folder",
"source_refs": ["doc-one"],
},
{
"slug": "drop",
"title": "无关人物",
"folder_id": "existing-folder",
"source_refs": ["doc-one"],
},
]
)
async def focus_classifier(
items: list[dict[str, Any]],
focus: str,
allowed_paths: list[str],
) -> dict[str, dict[str, Any]]:
assert focus == "只提取2029年建设目标"
assert allowed_paths == []
return {
"page:keep": {"keep": True, "directory": ""},
"page:drop": {"keep": False, "directory": ""},
}
result = await reconcile_fixed_parent_directories(
client,
{
"weknora_id": "kb-one",
"wiki_directory_paths": [],
"wiki_focus_instructions": "只提取2029年建设目标",
},
focus_classifier=focus_classifier,
)
assert [page["slug"] for page in client.pages] == ["keep"]
assert client.pages[0]["folder_id"] == "existing-folder"
assert [folder["id"] for folder in client.folders] == ["existing-folder"]
assert result["deleted_pages"] == 1
assert result["moved_pages"] == 0
assert result["deleted_folders"] == 0
@pytest.mark.asyncio
async def test_focus_uses_full_page_body_invalidates_marker_and_protects_manual_pages() -> None:
class SummaryOnlyListing(FakeWeKnora):
async def list_wiki_pages(
self,
knowledge_base_id: str,
*,
page: int,
page_size: int,
folder_id: str | None = None,
) -> dict[str, Any]:
result = await super().list_wiki_pages(
knowledge_base_id,
page=page,
page_size=page_size,
folder_id=folder_id,
)
for row in result["data"]["pages"]:
row.pop("content", None)
return result
client = SummaryOnlyListing(
pages=[
{
"slug": "generated",
"title": "综合说明",
"summary": "正文包含具体目标",
"content": "到2029年完成数据基础设施建设。",
"page_type": "concept",
"source_refs": ["doc-one"],
},
{
"slug": "manual",
"title": "人工维护页",
"summary": "管理员手工保存",
"content": "与重点无关,但不得由重点约束删除。",
"page_type": "article",
},
]
)
classified_content: list[str] = []
async def classify(
items: list[dict[str, Any]],
_: str,
__: list[str],
) -> dict[str, dict[str, Any]]:
assert [item["item_id"] for item in items] == ["page:generated"]
content = str(items[0]["content_excerpt"])
classified_content.append(content)
return {
"page:generated": {
"keep": "2029" in content,
"directory": "",
}
}
mapping = {
"weknora_id": "kb-one",
"wiki_directory_paths": [],
"wiki_focus_instructions": "只保留2029年建设目标",
}
first = await reconcile_fixed_parent_directories(
client,
mapping,
focus_classifier=classify,
)
assert first["deleted_pages"] == 0
assert classified_content == ["到2029年完成数据基础设施建设。"]
assert [call for call in client.calls if call[0] == "get_page"] == [
("get_page", "kb-one", "generated")
]
generated = next(page for page in client.pages if page["slug"] == "generated")
generated["content"] = "正文现已改为石窟寺保护背景材料。"
second = await reconcile_fixed_parent_directories(
client,
mapping,
focus_classifier=classify,
)
assert second["deleted_pages"] == 1
assert classified_content == [
"到2029年完成数据基础设施建设。",
"正文现已改为石窟寺保护背景材料。",
]
assert [page["slug"] for page in client.pages] == ["manual"]
assert [call for call in client.calls if call[0] == "get_page"] == [
("get_page", "kb-one", "generated"),
("get_page", "kb-one", "generated"),
]
@pytest.mark.asyncio
async def test_focus_page_details_use_bounded_concurrency() -> None:
class SlowDetails(FakeWeKnora):
in_flight = 0
max_in_flight = 0
async def get_wiki_page(
self,
knowledge_base_id: str,
slug: str,
) -> dict[str, Any]:
self.in_flight += 1
self.max_in_flight = max(self.max_in_flight, self.in_flight)
try:
await asyncio.sleep(0.001)
return await super().get_wiki_page(knowledge_base_id, slug)
finally:
self.in_flight -= 1
client = SlowDetails(
pages=[
{
"slug": f"generated-{index:02d}",
"title": f"目标 {index}",
"content": "2029年建设目标",
"page_type": "concept",
"source_refs": [f"doc-{index}"],
}
for index in range(12)
]
)
async def keep_all(
items: list[dict[str, Any]],
_: str,
__: list[str],
) -> dict[str, dict[str, Any]]:
return {
str(item["item_id"]): {"keep": True, "directory": ""}
for item in items
}
await reconcile_fixed_parent_directories(
client,
{
"weknora_id": "kb-one",
"wiki_directory_paths": [],
"wiki_focus_instructions": "只保留建设目标",
},
focus_classifier=keep_all,
)
assert 1 < client.max_in_flight <= constraints._PAGE_DETAIL_CONCURRENCY
@pytest.mark.asyncio
async def test_incomplete_focus_decisions_do_not_delete_or_move_pages() -> None:
client = FakeWeKnora(
folders=[{"id": "root", "name": "目标", "parent_id": ""}],
pages=[
{
"slug": "one",
"title": "一",
"folder_id": "",
"source_refs": ["doc-one"],
},
{
"slug": "two",
"title": "二",
"folder_id": "",
"source_refs": ["doc-two"],
},
],
)
async def incomplete(*_: Any) -> dict[str, dict[str, Any]]:
return {"page:one": {"keep": False, "directory": "目标"}}
with pytest.raises(RuntimeError, match="incomplete"):
await reconcile_fixed_parent_directories(
client,
{
"weknora_id": "kb-one",
"wiki_directory_paths": [["目标"]],
"wiki_focus_instructions": "硬约束",
},
focus_classifier=incomplete,
)
assert {page["slug"] for page in client.pages} == {"one", "two"}
assert not [call for call in client.calls if call[0] in {"delete_page", "move_page"}]
@pytest.mark.asyncio
async def test_completed_focus_batches_are_cached_before_a_later_batch_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = FakeWeKnora(
folders=[{"id": "leaf", "name": "目标", "parent_id": ""}],
pages=[
{
"slug": "one",
"title": "2029目标",
"folder_id": "",
"source_refs": ["doc-one"],
},
{
"slug": "two",
"title": "无关噪声",
"folder_id": "",
"source_refs": ["doc-two"],
},
],
)
monkeypatch.setattr(constraints, "_FOCUS_CLASSIFIER_BATCH_SIZE", 1)
calls: list[list[str]] = []
async def fail_second_batch(
items: list[dict[str, Any]],
*_: Any,
**__: Any,
) -> dict[str, dict[str, Any]]:
item_ids = [str(item["item_id"]) for item in items]
calls.append(item_ids)
if item_ids == ["page:one"]:
return {
"page:one": {"keep": True, "directory": "目标"}
}
raise RuntimeError("later batch failed")
monkeypatch.setattr(
constraints,
"_classify_focus_pages",
fail_second_batch,
)
mapping = {
"weknora_id": "kb-one",
"wiki_directory_paths": [["目标"]],
"wiki_focus_instructions": "只保留2029目标",
}
with pytest.raises(RuntimeError, match="later batch failed"):
await reconcile_fixed_parent_directories(client, mapping)
assert calls == [["page:one"], ["page:two"]]
assert (
client.pages[0]["page_metadata"]["deerflow_generation_constraints"][
"keep"
]
is True
)
assert not [
call for call in client.calls if call[0] in {"delete_page", "move_page"}
]
calls.clear()
async def finish_remaining(
items: list[dict[str, Any]],
*_: Any,
**__: Any,
) -> dict[str, dict[str, Any]]:
item_ids = [str(item["item_id"]) for item in items]
calls.append(item_ids)
return {"page:two": {"keep": False, "directory": ""}}
monkeypatch.setattr(
constraints,
"_classify_focus_pages",
finish_remaining,
)
result = await reconcile_fixed_parent_directories(client, mapping)
assert calls == [["page:two"]]
assert result["deleted_pages"] == 1
assert [page["slug"] for page in client.pages] == ["one"]
assert client.pages[0]["folder_id"] == ""
@pytest.mark.asyncio
async def test_legacy_multi_root_reference_does_not_invoke_classifier(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = FakeWeKnora(
folders=[
{"id": "root-a", "name": "甲", "parent_id": ""},
{"id": "root-b", "name": "乙", "parent_id": ""},
{"id": "extra", "name": "应归乙", "parent_id": ""},
{"id": "fallback", "name": "未知", "parent_id": ""},
],
pages=[{"slug": "page-one", "title": "乙类页面", "page_type": "summary", "folder_id": ""}],
)
batches: list[list[str]] = []
async def fake_default_classifier(items: list[dict[str, Any]], roots: list[str]) -> dict[str, str]:
assert roots == ["甲", "乙"]
batches.append([str(item["item_id"]) for item in items])
return {
"folder:extra": "乙",
"folder:fallback": "不存在的根",
"page:page-one": "乙",
}
monkeypatch.setattr(constraints, "_default_classifier", fake_default_classifier)
result = await reconcile_fixed_parent_directories(
client,
{"weknora_id": "kb-one", "wiki_parent_directories": ["甲", "乙"]},
)
assert result["moved_folders"] == 0
assert result["moved_pages"] == 0
assert batches == []
assert next(item for item in client.folders if item["id"] == "extra")["parent_id"] == ""
assert next(item for item in client.folders if item["id"] == "fallback")["parent_id"] == ""
assert client.pages[0]["folder_id"] == ""
@pytest.mark.asyncio
async def test_weknora_client_folder_and_page_placement_contracts() -> None:
seen: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
path = request.url.raw_path.decode().split("?", 1)[0]
seen.append((request.method, path))
if request.method == "GET" and path.endswith("/wiki/folders"):
assert request.url.params["parent_id"] == "parent-one"
assert request.url.params["page_types"] == "entity,concept"
return httpx.Response(200, json={"data": [{"id": "child", "name": "Child"}]})
if request.method == "PUT" and path.endswith("/wiki/folders/folder%2Fone"):
assert json.loads(request.content) == {"parent_id": "root-one", "move_parent": True}
return httpx.Response(200, json={"data": {"id": "folder/one", "parent_id": "root-one"}})
if request.method == "DELETE" and path.endswith("/wiki/folders/folder%2Fone"):
return httpx.Response(204)
if request.method == "PUT" and path.endswith("/wiki/move-page"):
assert json.loads(request.content) == {"slug": "entity/acme", "folder_id": "root-one"}
return httpx.Response(200, json={"data": {"slug": "entity/acme", "folder_id": "root-one"}})
if request.method == "GET" and path.endswith("/wiki/pages"):
if request.url.params.get("query") == "unfiltered":
assert "folder_id" not in request.url.params
else:
assert "folder_id" in request.url.params
assert request.url.params["folder_id"] == ""
return httpx.Response(200, json={"pages": [], "total": 0, "page": 1, "page_size": 50})
return httpx.Response(404)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
folders = await client.list_wiki_folders("kb-one", parent_id="parent-one", page_types=["entity", "concept"])
updated = await client.update_wiki_folder("kb-one", "folder/one", {"parent_id": "root-one"})
await client.delete_wiki_folder("kb-one", "folder/one")
moved = await client.move_wiki_page("kb-one", "entity/acme", "root-one")
await client.list_wiki_pages("kb-one", folder_id="")
await client.list_wiki_pages("kb-one", query="unfiltered")
assert folders["folders"] == [{"id": "child", "name": "Child"}]
assert updated["parent_id"] == "root-one"
assert moved["folder_id"] == "root-one"
assert seen == [
("GET", "/api/v1/knowledgebase/kb-one/wiki/folders"),
("PUT", "/api/v1/knowledgebase/kb-one/wiki/folders/folder%2Fone"),
("DELETE", "/api/v1/knowledgebase/kb-one/wiki/folders/folder%2Fone"),
("PUT", "/api/v1/knowledgebase/kb-one/wiki/move-page"),
("GET", "/api/v1/knowledgebase/kb-one/wiki/pages"),
("GET", "/api/v1/knowledgebase/kb-one/wiki/pages"),
]
@pytest.mark.asyncio
async def test_weknora_client_preserves_folder_conflict_status() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
409,
json={"message": "wiki folder name conflict"},
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler)
) as http_client:
client = WeKnoraClient(
base_url="http://weknora.local:8080",
http_client=http_client,
)
with pytest.raises(Exception) as caught:
await client.create_wiki_folder(
"kb-one",
name="国内新闻",
parent_id="",
)
assert getattr(caught.value, "status_code", None) == 409