chore: import upstream snapshot with attribution
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:26 +08:00
commit 1443d3fdf9
732 changed files with 196602 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import pytest
from yuxi.knowledge.implementations.dify import DifyKB
class _FakeResponse:
def __init__(self, payload: dict):
self._payload = payload
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return self._payload
class _FakeAsyncClient:
def __init__(self, response_payload: dict | None = None, raises: Exception | None = None, **kwargs):
del kwargs
self._response_payload = response_payload or {}
self._raises = raises
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
return False
async def post(self, url: str, json: dict, headers: dict):
assert "/datasets/" in url
assert headers.get("Authorization", "").startswith("Bearer ")
if self._raises:
raise self._raises
assert json["retrieval_model"]["search_method"] == "semantic_search"
assert json["retrieval_model"]["top_k"] == 5
assert json["retrieval_model"]["reranking_enable"] is False
assert json["retrieval_model"]["score_threshold_enabled"] is True
assert json["retrieval_model"]["score_threshold"] == 0.3
return _FakeResponse(self._response_payload)
def test_dify_create_params_config_and_validation():
config = DifyKB.get_create_params_config()
keys = [option["key"] for option in config["options"]]
assert keys == ["dify_api_url", "dify_token", "dify_dataset_id"]
assert all(option["required"] for option in config["options"])
params = DifyKB.normalize_additional_params(
{
"dify_api_url": " https://api.dify.ai/v1 ",
"dify_token": " token ",
"dify_dataset_id": " dataset-123 ",
}
)
assert params == {
"dify_api_url": "https://api.dify.ai/v1",
"dify_token": "token",
"dify_dataset_id": "dataset-123",
}
assert "chunk_preset_id" not in params
def test_dify_validation_rejects_missing_or_invalid_params():
with pytest.raises(ValueError, match="Dify 参数缺失"):
DifyKB.normalize_additional_params({"dify_api_url": "https://api.dify.ai/v1"})
with pytest.raises(ValueError, match="必须以 /v1 结尾"):
DifyKB.normalize_additional_params(
{
"dify_api_url": "https://api.dify.ai",
"dify_token": "token",
"dify_dataset_id": "dataset-123",
}
)
@pytest.mark.asyncio
async def test_dify_kb_aquery_maps_records(monkeypatch, tmp_path):
kb = DifyKB(str(tmp_path))
slug = "kb_test_dify"
kb.databases_meta[slug] = {
"name": "dify-kb",
"description": "test",
"kb_type": "dify",
"query_params": {
"options": {
"search_mode": "vector",
"final_top_k": 5,
"score_threshold_enabled": True,
"similarity_threshold": 0.3,
}
},
"metadata": {
"dify_api_url": "https://api.dify.ai/v1",
"dify_token": "token",
"dify_dataset_id": "dataset-123",
},
}
payload = {
"records": [
{
"score": 0.98,
"segment": {
"id": "seg-1",
"position": 2,
"content": "hello world",
"document": {"id": "doc-1", "name": "Doc One"},
},
}
]
}
monkeypatch.setattr(
"yuxi.knowledge.implementations.dify.httpx.AsyncClient",
lambda **kwargs: _FakeAsyncClient(response_payload=payload, **kwargs),
)
result = await kb.aquery("hello", slug)
assert len(result) == 1
assert result[0]["content"] == "hello world"
assert result[0]["score"] == 0.98
assert result[0]["metadata"]["source"] == "Doc One"
assert result[0]["metadata"]["file_id"] == "doc-1"
assert result[0]["metadata"]["chunk_id"] == "seg-1"
assert result[0]["metadata"]["chunk_index"] == 2
@pytest.mark.asyncio
async def test_dify_kb_aquery_error_returns_empty(monkeypatch, tmp_path):
kb = DifyKB(str(tmp_path))
slug = "kb_test_dify_error"
kb.databases_meta[slug] = {
"name": "dify-kb",
"description": "test",
"kb_type": "dify",
"query_params": {"options": {}},
"metadata": {
"dify_api_url": "https://api.dify.ai/v1",
"dify_token": "token",
"dify_dataset_id": "dataset-123",
},
}
monkeypatch.setattr(
"yuxi.knowledge.implementations.dify.httpx.AsyncClient",
lambda **kwargs: _FakeAsyncClient(raises=RuntimeError("boom"), **kwargs),
)
result = await kb.aquery("hello", slug)
assert result == []
+574
View File
@@ -0,0 +1,574 @@
import types
import pytest
from pymilvus import CollectionSchema, DataType, FieldSchema, Function, FunctionType
from yuxi.knowledge.base import FileStatus
from yuxi.knowledge.chunking.ragflow_like.nlp import count_tokens
from yuxi.knowledge.implementations.milvus import (
CONTENT_ANALYZER_PARAMS,
CONTENT_SPARSE_FIELD,
VECTOR_METRIC_TYPE,
MilvusKB,
)
class FakeHit:
def __init__(self, content: str, distance: float):
self.distance = distance
self.entity = {
"content": content,
"chunk_id": "chunk-1",
"file_id": "file-1",
"chunk_index": 0,
}
class FakeCollection:
def __init__(self, distance: float = 0.8):
self.search_calls = []
self.hybrid_calls = []
self.insert_calls = []
self.distance = distance
def search(self, **kwargs):
self.search_calls.append(kwargs)
return [[FakeHit("BM25 result", self.distance)]]
def hybrid_search(self, **kwargs):
self.hybrid_calls.append(kwargs)
return [[FakeHit("Hybrid result", self.distance)]]
def insert(self, entities):
self.insert_calls.append(entities)
def make_kb(collection: FakeCollection) -> MilvusKB:
kb = MilvusKB.__new__(MilvusKB)
kb.databases_meta = {"db": {"embedding_model_spec": "test-provider:test-embedding"}}
kb._get_query_params = lambda kb_id: {}
kb._get_embedding_function = lambda embedding_model_spec, **kwargs: lambda texts: [[0.1, 0.2] for _ in texts]
async def get_collection(kb_id: str):
return collection
async def hydrate_chunk_sources(kb_id: str, chunks: list[dict]) -> None:
for chunk in chunks:
chunk["metadata"]["source"] = "demo.md"
kb._get_milvus_collection = get_collection
kb._hydrate_chunk_sources = hydrate_chunk_sources
return kb
def make_file_record(**overrides):
data = {
"file_id": "file-1",
"kb_id": "db",
"parent_id": None,
"filename": "demo.md",
"file_type": "md",
"path": "/tmp/demo.md",
"minio_url": None,
"markdown_file": "minio://parsed/db/file-1.md",
"status": FileStatus.PARSED,
"content_hash": None,
"file_size": 0,
"chunk_count": 0,
"token_count": 0,
"content_type": "file",
"processing_params": {},
"is_folder": False,
"error_message": None,
"created_by": None,
"updated_by": None,
"created_at": None,
"updated_at": None,
"original_filename": None,
}
data.update(overrides)
return types.SimpleNamespace(**data)
class FakeKnowledgeFileRepository:
def __init__(self, records: dict[str, types.SimpleNamespace]):
self.records = records
self.update_calls = []
self.conditional_update_calls = []
self.deleted = []
async def get_by_file_id(self, file_id: str):
return self.records.get(file_id)
async def update_fields_if_status(self, *, kb_id: str, file_id: str, allowed_statuses: set[str], data: dict):
record = self.records.get(file_id)
self.conditional_update_calls.append((kb_id, file_id, set(allowed_statuses), dict(data)))
if record is None or record.kb_id != kb_id or record.status not in allowed_statuses:
return None
for key, value in data.items():
setattr(record, key, value)
return record
async def update_fields(self, *, file_id: str, data: dict, kb_id: str | None = None):
record = self.records.get(file_id)
if record is None or (kb_id and record.kb_id != kb_id):
return None
for key, value in data.items():
setattr(record, key, value)
self.update_calls.append((file_id, kb_id, dict(data)))
return record
async def get_filenames_by_file_ids(self, *, kb_id: str, file_ids: list[str]):
return {
file_id: record.filename
for file_id in file_ids
if (record := self.records.get(file_id)) is not None and record.kb_id == kb_id
}
async def list_file_ids_by_filename_contains(self, *, kb_id: str, filename_pattern: str, limit: int = 10_000):
return [
file_id
for file_id, record in self.records.items()
if record.kb_id == kb_id and filename_pattern.lower() in record.filename.lower()
][:limit]
async def delete(self, file_id: str) -> None:
self.deleted.append(file_id)
self.records.pop(file_id, None)
def patch_file_repository(monkeypatch, file_repo: FakeKnowledgeFileRepository) -> None:
monkeypatch.setattr("yuxi.repositories.knowledge_file_repository.KnowledgeFileRepository", lambda: file_repo)
monkeypatch.setattr("yuxi.knowledge.implementations.milvus.KnowledgeFileRepository", lambda: file_repo)
def make_chunk(index: int, content: str = "content") -> dict:
return {
"id": f"id-{index}",
"chunk_id": f"chunk-{index}",
"file_id": "file-1",
"chunk_index": index,
"content": content,
}
def test_build_chunk_pg_records_preserves_extraction_result():
kb = MilvusKB.__new__(MilvusKB)
records = kb._build_chunk_pg_records(
"db",
[
{
"chunk_id": "chunk-1",
"file_id": "file-1",
"chunk_index": 0,
"content": "content",
"extraction_result": {"entities": ["alpha"]},
}
],
)
assert records[0]["extraction_result"] == {"entities": ["alpha"]}
async def test_embed_and_store_chunks_batches_embedding_and_insert():
kb = MilvusKB.__new__(MilvusKB)
chunks = [make_chunk(index, content=f"text-{index}") for index in range(450)]
embedding_calls = []
store_calls = []
async def embedding_function(texts):
embedding_calls.append(list(texts))
return [[float(len(text))] for text in texts]
async def insert_chunks_to_stores(kb_id, file_id, collection, batch_chunks, embeddings, **kwargs):
store_calls.append(
{
"kb_id": kb_id,
"file_id": file_id,
"chunks": list(batch_chunks),
"embeddings": list(embeddings),
"kwargs": kwargs,
}
)
kb._insert_chunks_to_stores = insert_chunks_to_stores
await kb._embed_and_store_chunks(
"db",
"file-1",
FakeCollection(),
chunks,
embedding_function,
chunk_batch_size=200,
)
assert [len(call) for call in embedding_calls] == [200, 200, 50]
assert [len(call["chunks"]) for call in store_calls] == [200, 200, 50]
assert store_calls[0]["chunks"][0]["chunk_id"] == "chunk-0"
assert store_calls[1]["chunks"][0]["chunk_id"] == "chunk-200"
assert store_calls[2]["chunks"][0]["chunk_id"] == "chunk-400"
assert all(call["kwargs"] == {} for call in store_calls)
def test_calculate_chunk_stats_counts_chunks_and_tokens():
kb = MilvusKB.__new__(MilvusKB)
chunks = [make_chunk(0, content="alpha beta"), make_chunk(1, content="中文")]
stats = kb._calculate_chunk_stats(chunks)
assert stats == {
"chunk_count": 2,
"token_count": count_tokens("alpha beta") + count_tokens("中文"),
}
async def test_index_file_persists_chunk_stats(monkeypatch):
kb = MilvusKB.__new__(MilvusKB)
kb.databases_meta = {"db": {"embedding_model_spec": "test-provider:test-embedding", "metadata": {}}}
file_repo = FakeKnowledgeFileRepository({"file-1": make_file_record()})
patch_file_repository(monkeypatch, file_repo)
collection = FakeCollection()
deleted_files = []
store_calls = []
refreshed_kbs = []
chunks = [make_chunk(0, content="alpha beta"), make_chunk(1, content="中文")]
async def get_collection(kb_id):
return collection
async def read_markdown(path):
return "# demo"
async def embedding_function(texts):
return [[0.1, 0.2] for _ in texts]
async def delete_file_chunks_only(kb_id, file_id):
deleted_files.append((kb_id, file_id))
async def embed_and_store_chunks(kb_id, file_id, collection_arg, chunk_records, embedding_fn):
store_calls.append((kb_id, file_id, collection_arg, list(chunk_records), embedding_fn))
async def refresh_database_stats(kb_id):
refreshed_kbs.append(kb_id)
return {}
kb._get_milvus_collection = get_collection
kb._read_markdown_from_minio = read_markdown
kb._split_text_into_chunks = lambda text, file_id, filename, params: chunks
kb._get_embedding_function = lambda embedding_model_spec: embedding_function
kb.delete_file_chunks_only = delete_file_chunks_only
kb._embed_and_store_chunks = embed_and_store_chunks
kb.refresh_database_stats = refresh_database_stats
result = await kb.index_file("db", "file-1", operator_id="user-1", params={})
assert deleted_files == [("db", "file-1")]
assert len(store_calls) == 1
assert [chunk["chunk_id"] for chunk in store_calls[0][3]] == ["chunk-0", "chunk-1"]
assert result["status"] == FileStatus.INDEXED
assert result["chunk_count"] == 2
assert result["token_count"] == count_tokens("alpha beta") + count_tokens("中文")
assert file_repo.records["file-1"].chunk_count == result["chunk_count"]
assert file_repo.conditional_update_calls[0][3]["status"] == FileStatus.INDEXING
assert file_repo.update_calls[-1][2]["status"] == FileStatus.INDEXED
assert refreshed_kbs == ["db"]
async def test_delete_file_chunks_only_resets_file_stats(monkeypatch):
repos = []
class FakeChunkRepo:
def __init__(self):
self.delete_calls = []
repos.append(self)
async def count_graph_indexed_by_file_id(self, file_id):
return 0
async def delete_by_file_id(self, file_id):
self.delete_calls.append(file_id)
return 2
monkeypatch.setattr("yuxi.knowledge.implementations.milvus.KnowledgeChunkRepository", FakeChunkRepo)
file_repo = FakeKnowledgeFileRepository(
{"file-1": make_file_record(chunk_count=2, token_count=10, status=FileStatus.INDEXED)}
)
patch_file_repository(monkeypatch, file_repo)
kb = MilvusKB.__new__(MilvusKB)
refreshed_kbs = []
async def get_collection(kb_id):
return None
async def refresh_database_stats(kb_id):
refreshed_kbs.append(kb_id)
return {}
kb._get_milvus_collection = get_collection
kb.refresh_database_stats = refresh_database_stats
await kb.delete_file_chunks_only("db", "file-1")
assert repos[0].delete_calls == ["file-1"]
assert file_repo.records["file-1"].chunk_count == 0
assert file_repo.records["file-1"].token_count == 0
assert file_repo.update_calls == [("file-1", "db", {"chunk_count": 0, "token_count": 0})]
assert refreshed_kbs == ["db"]
async def test_insert_chunks_to_stores_inserts_current_batch(monkeypatch):
repos = []
class FakeChunkRepo:
def __init__(self):
self.upsert_calls = []
self.delete_calls = []
repos.append(self)
async def batch_upsert(self, chunks):
self.upsert_calls.append(chunks)
return []
async def delete_by_file_id(self, file_id):
self.delete_calls.append(file_id)
return 0
monkeypatch.setattr("yuxi.knowledge.implementations.milvus.KnowledgeChunkRepository", FakeChunkRepo)
kb = MilvusKB.__new__(MilvusKB)
collection = FakeCollection()
chunks = [make_chunk(index) for index in range(3)]
embeddings = [[0.1, 0.2] for _ in chunks]
await kb._insert_chunks_to_stores("db", "file-1", collection, chunks, embeddings)
assert len(collection.insert_calls) == 1
assert collection.insert_calls[0][0] == ["id-0", "id-1", "id-2"]
assert collection.insert_calls[0][5] == embeddings
assert len(repos[0].upsert_calls) == 1
assert [record["chunk_id"] for record in repos[0].upsert_calls[0]] == ["chunk-0", "chunk-1", "chunk-2"]
async def test_insert_chunks_to_stores_rolls_back_file_when_milvus_insert_fails(monkeypatch):
repos = []
class FakeChunkRepo:
def __init__(self):
self.upsert_calls = []
self.delete_calls = []
repos.append(self)
async def batch_upsert(self, chunks):
self.upsert_calls.append(chunks)
return []
async def delete_by_file_id(self, file_id):
self.delete_calls.append(file_id)
return 0
class FailingCollection(FakeCollection):
def insert(self, entities):
super().insert(entities)
raise RuntimeError("milvus boom")
monkeypatch.setattr("yuxi.knowledge.implementations.milvus.KnowledgeChunkRepository", FakeChunkRepo)
kb = MilvusKB.__new__(MilvusKB)
collection = FailingCollection()
milvus_delete_calls = []
async def delete_file_chunks_from_milvus(collection_arg, file_id):
milvus_delete_calls.append((collection_arg, file_id))
kb._delete_file_chunks_from_milvus = delete_file_chunks_from_milvus
chunks = [make_chunk(index) for index in range(2)]
embeddings = [[0.1, 0.2] for _ in chunks]
with pytest.raises(RuntimeError, match="milvus boom"):
await kb._insert_chunks_to_stores("db", "file-1", collection, chunks, embeddings)
assert repos[0].delete_calls == ["file-1"]
assert milvus_delete_calls == [(collection, "file-1")]
async def test_update_content_uses_streaming_chunk_store(monkeypatch):
kb = MilvusKB.__new__(MilvusKB)
kb.databases_meta = {"db": {"embedding_model_spec": "test-provider:test-embedding", "metadata": {}}}
file_repo = FakeKnowledgeFileRepository(
{"file-1": make_file_record(markdown_file=None, status=FileStatus.INDEXED)}
)
patch_file_repository(monkeypatch, file_repo)
collection = FakeCollection()
refreshed_kbs = []
deleted_files = []
store_calls = []
async def get_collection(kb_id):
return collection
async def forbidden_embedding(texts):
raise AssertionError("update_content should not embed the whole file directly")
async def refresh_database_stats(kb_id):
refreshed_kbs.append(kb_id)
return {}
async def delete_file_chunks_only(kb_id, file_id):
deleted_files.append((kb_id, file_id))
async def embed_and_store_chunks(kb_id, file_id, collection_arg, chunks, embedding_function):
store_calls.append((kb_id, file_id, collection_arg, list(chunks), embedding_function))
async def parse_file(source, params):
return "# markdown"
kb._get_milvus_collection = get_collection
kb._get_embedding_function = lambda embedding_model_spec: forbidden_embedding
kb.refresh_database_stats = refresh_database_stats
kb._split_text_into_chunks = lambda text, file_id, filename, params: [make_chunk(0), make_chunk(1)]
kb.delete_file_chunks_only = delete_file_chunks_only
kb._embed_and_store_chunks = embed_and_store_chunks
monkeypatch.setattr("yuxi.knowledge.implementations.milvus.Parser.aparse", parse_file)
result = await kb.update_content("db", ["file-1"])
assert deleted_files == [("db", "file-1")]
assert len(store_calls) == 1
assert store_calls[0][2] is collection
assert [chunk["chunk_id"] for chunk in store_calls[0][3]] == ["chunk-0", "chunk-1"]
assert store_calls[0][4] is forbidden_embedding
assert result[0]["status"] == FileStatus.INDEXED
assert file_repo.records["file-1"].status == FileStatus.INDEXED
assert file_repo.update_calls[0][2]["status"] == FileStatus.INDEXING
assert file_repo.update_calls[-1][2]["status"] == FileStatus.INDEXED
assert refreshed_kbs == ["db"]
async def test_keyword_mode_uses_milvus_bm25_search():
collection = FakeCollection()
kb = make_kb(collection)
chunks = await kb.aquery(
"alpha beta",
"db",
search_mode="keyword",
bm25_top_k=7,
bm25_drop_ratio_search=0.2,
)
assert chunks[0]["content"] == "BM25 result"
assert chunks[0]["bm25_score"] == 0.8
search_call = collection.search_calls[0]
assert search_call["data"] == ["alpha beta"]
assert search_call["anns_field"] == CONTENT_SPARSE_FIELD
assert search_call["param"] == {
"metric_type": "BM25",
"params": {"drop_ratio_search": 0.2},
}
assert search_call["limit"] == 7
async def test_vector_mode_ignores_metric_type_override():
collection = FakeCollection()
kb = make_kb(collection)
chunks = await kb.aquery("vector query", "db", search_mode="vector", metric_type="L2")
assert chunks[0]["content"] == "BM25 result"
search_call = collection.search_calls[0]
assert search_call["anns_field"] == "embedding"
assert search_call["param"]["metric_type"] == VECTOR_METRIC_TYPE
async def test_hybrid_mode_uses_milvus_native_hybrid_search():
collection = FakeCollection()
kb = make_kb(collection)
chunks = await kb.aquery(
"hybrid query",
"db",
search_mode="hybrid",
final_top_k=3,
bm25_top_k=8,
vector_weight=0.6,
bm25_weight=0.4,
)
assert chunks[0]["content"] == "Hybrid result"
assert chunks[0]["hybrid_score"] == 0.8
hybrid_call = collection.hybrid_calls[0]
assert hybrid_call["limit"] == 3
assert hybrid_call["rerank"]._weights == [0.6, 0.4]
vector_request, bm25_request = hybrid_call["reqs"]
assert vector_request.anns_field == "embedding"
assert vector_request.data == [[0.1, 0.2]]
assert vector_request.param["metric_type"] == VECTOR_METRIC_TYPE
assert bm25_request.anns_field == CONTENT_SPARSE_FIELD
assert bm25_request.data == ["hybrid query"]
assert bm25_request.limit == 8
assert bm25_request.param["metric_type"] == "BM25"
async def test_hybrid_mode_filters_scores_below_similarity_threshold():
collection = FakeCollection(distance=0.1)
kb = make_kb(collection)
chunks = await kb.aquery(
"hybrid query",
"db",
search_mode="hybrid",
final_top_k=3,
similarity_threshold=0.2,
)
assert chunks == []
def test_query_params_config_uses_bm25_parameters():
kb = MilvusKB.__new__(MilvusKB)
config = kb.get_query_params_config("db")
option_keys = {option["key"] for option in config["options"]}
assert "keyword_top_k" not in option_keys
assert "metric_type" not in option_keys
assert {
"bm25_top_k",
"vector_weight",
"bm25_weight",
"bm25_drop_ratio_search",
} <= option_keys
search_mode = next(option for option in config["options"] if option["key"] == "search_mode")
descriptions = {option["value"]: option["description"] for option in search_mode["options"]}
assert "BM25" in descriptions["keyword"]
assert "BM25" in descriptions["hybrid"]
def test_collection_supports_bm25_requires_analyzed_content_sparse_field_and_function():
kb = MilvusKB.__new__(MilvusKB)
schema = CollectionSchema(
fields=[
FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=100, is_primary=True),
FieldSchema(
name="content",
dtype=DataType.VARCHAR,
max_length=65535,
enable_analyzer=True,
analyzer_params=CONTENT_ANALYZER_PARAMS,
),
FieldSchema(name=CONTENT_SPARSE_FIELD, dtype=DataType.SPARSE_FLOAT_VECTOR),
],
functions=[
Function(
name="content_bm25",
input_field_names=["content"],
output_field_names=[CONTENT_SPARSE_FIELD],
function_type=FunctionType.BM25,
)
],
)
collection = type("Collection", (), {"schema": schema})()
assert kb._collection_supports_bm25(collection)
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
import pytest
from yuxi.knowledge.implementations.notion import NOTION_DEFAULT_VERSION, NotionAPIError, NotionKB
PAGE_ID = "page-1"
DATA_SOURCE_ID = "ds-1"
PAGE = {
"object": "page",
"id": PAGE_ID,
"url": "https://www.notion.so/page-1",
"created_time": "2026-01-01T00:00:00.000Z",
"last_edited_time": "2026-01-02T00:00:00.000Z",
"parent": {"type": "data_source_id", "data_source_id": DATA_SOURCE_ID},
"properties": {
"Name": {"type": "title", "title": [{"plain_text": "Reasoning Paper"}]},
"Abstract": {"type": "rich_text", "rich_text": [{"plain_text": "Chain of thought reasoning"}]},
},
}
BLOCKS = {
PAGE_ID: [
{
"object": "block",
"id": "block-1",
"type": "paragraph",
"has_children": False,
"paragraph": {"rich_text": [{"plain_text": "This page discusses reasoning models."}]},
},
{
"object": "block",
"id": "block-2",
"type": "heading_2",
"has_children": False,
"heading_2": {"rich_text": [{"plain_text": "Evaluation"}]},
},
]
}
class _FakeNotionClient:
def __init__(self, token: str, notion_version: str) -> None:
self.token = token
self.notion_version = notion_version
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
return False
async def search_pages(self, query_text: str, limit: int) -> list[dict]:
del query_text, limit
return [PAGE]
async def query_data_source(self, data_source_id: str, limit: int) -> list[dict]:
del limit
assert data_source_id == DATA_SOURCE_ID
return [PAGE]
async def retrieve_page(self, page_id: str) -> dict:
assert page_id == PAGE_ID
return PAGE
async def retrieve_block_children(self, block_id: str, limit: int) -> list[dict]:
del limit
return BLOCKS.get(block_id, [])
class _FailingNotionClient(_FakeNotionClient):
async def search_pages(self, query_text: str, limit: int) -> list[dict]:
del query_text, limit
raise NotionAPIError("boom")
class _UnknownParentNotionClient(_FakeNotionClient):
async def retrieve_page(self, page_id: str) -> dict:
page = await super().retrieve_page(page_id)
return {**page, "parent": {"type": "page_id", "page_id": "other-page"}}
@pytest.fixture
def notion_kb(tmp_path):
kb = NotionKB(str(tmp_path))
kb.databases_meta["kb_notion"] = {
"name": "notion-kb",
"description": "test",
"kb_type": "notion",
"query_params": {"options": {}},
"metadata": {
"notion_token": "token",
"notion_data_source_id": DATA_SOURCE_ID,
"notion_version": NOTION_DEFAULT_VERSION,
},
}
return kb
def test_notion_create_params_config_and_validation(monkeypatch):
monkeypatch.delenv("NOTION_TOKEN", raising=False)
monkeypatch.delenv("NOTION_API_KEY", raising=False)
config = NotionKB.get_create_params_config()
keys = [option["key"] for option in config["options"]]
assert keys == ["notion_token", "notion_data_source_id", "notion_version"]
params = NotionKB.normalize_additional_params(
{
"notion_token": " token ",
"notion_data_source_id": " ds-1 ",
"notion_version": " 2026-03-11 ",
}
)
assert params == {
"notion_token": "token",
"notion_data_source_id": DATA_SOURCE_ID,
"notion_version": NOTION_DEFAULT_VERSION,
}
assert "chunk_preset_id" not in params
def test_notion_validation_accepts_env_token(monkeypatch):
monkeypatch.setenv("NOTION_TOKEN", "env-token")
params = NotionKB.normalize_additional_params({"notion_data_source_id": DATA_SOURCE_ID})
assert params["notion_token"] == ""
assert params["notion_data_source_id"] == DATA_SOURCE_ID
def test_notion_validation_rejects_missing_params(monkeypatch):
monkeypatch.delenv("NOTION_TOKEN", raising=False)
monkeypatch.delenv("NOTION_API_KEY", raising=False)
with pytest.raises(ValueError, match="notion_data_source_id"):
NotionKB.normalize_additional_params({"notion_token": "token"})
with pytest.raises(ValueError, match="notion_token"):
NotionKB.normalize_additional_params({"notion_data_source_id": DATA_SOURCE_ID})
@pytest.mark.asyncio
async def test_notion_kb_aquery_maps_pages(monkeypatch, notion_kb):
monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _FakeNotionClient)
result = await notion_kb.aquery("reasoning", "kb_notion")
assert len(result) == 1
assert "reasoning" in result[0]["content"].lower()
assert result[0]["score"] > 0
assert result[0]["metadata"]["source"] == "Reasoning Paper"
assert result[0]["metadata"]["file_id"] == PAGE_ID
assert result[0]["metadata"]["chunk_id"].startswith(f"{PAGE_ID}:")
assert result[0]["metadata"]["notion_url"] == "https://www.notion.so/page-1"
@pytest.mark.asyncio
async def test_notion_open_file_content_uses_page_markdown(monkeypatch, notion_kb):
monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _FakeNotionClient)
result = await notion_kb.open_file_content("kb_notion", PAGE_ID, offset=0, limit=3)
assert result["start_line"] == 1
assert result["end_line"] == 3
assert result["has_more_after"] is True
assert "Reasoning Paper" in result["content"]
@pytest.mark.asyncio
async def test_notion_find_file_content_uses_page_markdown(monkeypatch, notion_kb):
monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _FakeNotionClient)
result = await notion_kb.find_file_content("kb_notion", PAGE_ID, ["models"], window_size=4)
assert result["match_mode"] == "keyword"
assert result["total_matches"] == 1
assert result["windows"]
assert "reasoning models" in result["windows"][0]["content"]
@pytest.mark.asyncio
async def test_notion_open_file_content_rejects_unknown_parent(monkeypatch, notion_kb):
monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _UnknownParentNotionClient)
with pytest.raises(ValueError, match="不属于当前 Data Source"):
await notion_kb.open_file_content("kb_notion", PAGE_ID)
@pytest.mark.asyncio
async def test_notion_kb_aquery_error_returns_empty(monkeypatch, notion_kb):
monkeypatch.setattr("yuxi.knowledge.implementations.notion._NotionClient", _FailingNotionClient)
result = await notion_kb.aquery("reasoning", "kb_notion")
assert result == []
@@ -0,0 +1,364 @@
from __future__ import annotations
import os
import sys
sys.path.append(os.getcwd())
from yuxi.knowledge.chunking.ragflow_like.dispatcher import chunk_markdown
from yuxi.knowledge.chunking.ragflow_like.nlp import bullets_category, count_tokens
from yuxi.knowledge.chunking.ragflow_like.utils.semantic_utils import split_sentences_chinese
from yuxi.knowledge.chunking.ragflow_like.presets import (
CHUNK_ENGINE_VERSION,
CHUNK_PRESET_IDS,
CHUNK_PRESETS,
get_chunk_preset_options,
get_default_chunk_parser_config,
map_to_internal_parser_id,
resolve_chunk_processing_params,
)
from yuxi.knowledge.utils.kb_utils import resolve_processing_params, sanitize_processing_params
def test_general_maps_to_naive() -> None:
assert map_to_internal_parser_id("general") == "naive"
def test_resolve_chunk_processing_params_priority() -> None:
resolved = resolve_chunk_processing_params(
kb_additional_params={
"chunk_preset_id": "book",
"chunk_parser_config": {"chunk_token_num": 300, "delimiter": "\\n"},
},
file_processing_params={
"chunk_preset_id": "qa",
"chunk_parser_config": {"delimiter": "###", "overlapped_percent": 5},
},
request_params={
"chunk_preset_id": "laws",
"chunk_parser_config": {"chunk_token_num": 666},
},
)
assert resolved["chunk_preset_id"] == "laws"
assert resolved["chunk_engine_version"] == CHUNK_ENGINE_VERSION
assert resolved["chunk_parser_config"] == {
"chunk_token_num": 666,
"delimiter": "###",
"overlapped_percent": 5,
}
def test_resolve_chunk_processing_params_returns_only_nested_keys() -> None:
resolved = resolve_chunk_processing_params(
kb_additional_params={"chunk_parser_config": {"chunk_token_num": 300}},
file_processing_params={},
request_params={},
)
assert resolved["chunk_parser_config"] == {"chunk_token_num": 300}
assert resolved["chunk_preset_id"] == "general"
assert resolved["chunk_engine_version"] == CHUNK_ENGINE_VERSION
assert len(resolved) == 3
def test_qa_chunking_from_markdown_headings() -> None:
content = """
# 问题一
这是答案一。
## 子问题
这是答案二。
""".strip()
chunks = chunk_markdown(
markdown_content=content,
file_id="file_1",
filename="faq.md",
processing_params={"chunk_preset_id": "qa", "chunk_parser_config": {}},
)
assert len(chunks) >= 1
assert "问题:" in chunks[0]["content"]
assert "回答:" in chunks[0]["content"]
def test_chunk_records_include_reserved_position_fields() -> None:
content = "第一段内容。\n\n第二段内容。"
chunks = chunk_markdown(
markdown_content=content,
file_id="file_pos",
filename="pos.md",
processing_params={
"chunk_preset_id": "separator",
"chunk_parser_config": {"delimiter": "\\n\\n"},
},
)
assert chunks[0]["start_char_pos"] == 0
assert chunks[0]["end_char_pos"] == len("第一段内容。")
assert chunks[0]["start_token_pos"] is None
assert chunks[0]["end_token_pos"] is None
assert "start_char_pos" in chunks[1]
def test_book_chunking_hierarchical_merge() -> None:
content = """
第一章 总则
第一节 适用范围
本规范适用于测试场景。
第二节 基本原则
应当遵循最小改动原则。
""".strip()
chunks = chunk_markdown(
markdown_content=content,
file_id="file_2",
filename="book.txt",
processing_params={"chunk_preset_id": "book", "chunk_parser_config": {"chunk_token_num": 256}},
)
assert len(chunks) >= 1
assert any("第一章" in ck["content"] for ck in chunks)
def test_book_chunking_should_apply_overlength_protection() -> None:
content = "\n".join(
[
"第一章 总则",
"第一节 适用范围",
"超长正文" * 1200,
"第二节 基本原则",
"应当遵循最小改动原则。",
]
)
max_chunk_tokens = 180
chunks = chunk_markdown(
markdown_content=content,
file_id="file_book_long",
filename="book.txt",
processing_params={
"chunk_preset_id": "book",
"chunk_parser_config": {"chunk_token_num": max_chunk_tokens, "delimiter": "\\n"},
},
)
assert len(chunks) > 1
assert max(count_tokens(ck["content"]) for ck in chunks) <= max_chunk_tokens
def test_split_sentences_chinese_should_keep_quote_boundary() -> None:
text = '他说:“你好。”然后问:“你在吗?”最后结束!'
sentences = split_sentences_chinese(text)
assert sentences == ["他说:“你好。”", "然后问:“你在吗?”", "最后结束!"]
def test_markdown_heading_has_higher_weight_in_bullet_category() -> None:
sections = [
"# 3.2 个人所得项目及计税、申报方式概括",
"一、关于季节工、临时工等费用税前扣除问题,以下规定继续执行。",
"二、根据现行规定,补贴收入应并入工资薪金所得。",
"(一)从超出国家规定比例支付的补贴,不属于免税福利费。",
]
# 命中 markdown 标题模式(BULLET_PATTERN 下标 4)时,应该优先选中该组。
assert bullets_category(sections) == 4
def test_mid_sentence_bullet_marker_should_not_be_treated_as_heading() -> None:
sections = [
"根据前述规则:一、这里是句中枚举,不是章节标题,不能被当成层级。",
"延续上文:(二)这里同样是正文中的枚举表达,不是独立标题。",
"## 3.4 交通补贴的个税处理",
]
assert bullets_category(sections) == 4
def test_chunk_preset_options_include_description() -> None:
options = get_chunk_preset_options()
assert [option["value"] for option in options] == list(CHUNK_PRESETS)
assert {option["value"] for option in options} == CHUNK_PRESET_IDS
assert all(isinstance(option.get("description"), str) and option["description"] for option in options)
def test_chunk_preset_defaults_only_include_strategy_specific_fields() -> None:
for preset_id in CHUNK_PRESET_IDS:
assert get_default_chunk_parser_config(preset_id) == {}
def test_laws_chunking_should_apply_overlength_protection() -> None:
lines = ["#### 中华人民共和国企业所得税法实施条例", "##### 微信扫一扫:分享"]
lines.extend(
[f"{i}条 企业所得税法实施细则说明,适用于测试场景,确保条文长度足够用于验证分块策略。" for i in range(1, 260)]
)
content = "\n".join(lines)
max_chunk_tokens = 180
chunks = chunk_markdown(
markdown_content=content,
file_id="file_laws_long",
filename="laws.docx",
processing_params={
"chunk_preset_id": "laws",
"chunk_parser_config": {
"chunk_token_num": max_chunk_tokens,
"overlapped_percent": 20,
"delimiter": "\\n",
},
},
)
assert len(chunks) > 1
assert max(count_tokens(ck["content"]) for ck in chunks) <= max_chunk_tokens
def test_laws_chunking_should_prefer_sentence_boundary_split() -> None:
line = "第一条 企业所得税法实施细则用于测试分块语义边界。"
content = line * 120
chunks = chunk_markdown(
markdown_content=content,
file_id="file_laws_sentence",
filename="laws.docx",
processing_params={
"chunk_preset_id": "laws",
"chunk_parser_config": {
"chunk_token_num": 120,
"overlapped_percent": 0,
"delimiter": "\\n",
},
},
)
assert len(chunks) > 1
for ck in chunks:
text = ck["content"].strip()
assert text
assert count_tokens(text) <= 120
def test_laws_chunking_should_prefer_article_level_before_item_level() -> None:
content = """
第六章 特别纳税调整
第一百零六条 企业所得税法第三十八条规定的可以指定扣缴义务人的情形,包括:
(一)在资金、经营、购销等方面存在直接或者间接的控制关系;
(二)可以代表企业实施其他具有约束力的行为。
第一百零七条 税务机关可以依法核定应纳税所得额。
""".strip()
chunks = chunk_markdown(
markdown_content=content,
file_id="file_laws_article",
filename="laws.docx",
processing_params={
"chunk_preset_id": "laws",
"chunk_parser_config": {
"chunk_token_num": 1000,
"overlapped_percent": 0,
"delimiter": "\\n",
},
},
)
# 只要条下款项没有被拆成独立碎片,即可满足“条级优先”的目标。
target_chunks = [ck["content"] for ck in chunks if "第一百零六条" in ck["content"]]
assert target_chunks
assert any("(一)" in chunk and "(二)" in chunk for chunk in target_chunks)
def test_laws_markdown_articles_should_not_collapse_into_chapter_chunk() -> None:
content = """
## 第一章 总则
- **第一条** 为了规范担保活动,保障债权实现,制定本法。
- **第二条** 在借贷活动中,当事人可以依法设定担保。
- **第三条** 担保活动应当遵循平等、自愿、公平和诚实信用原则。
""".strip()
chunks = chunk_markdown(
markdown_content=content,
file_id="file_laws_markdown_article",
filename="laws.md",
processing_params={
"chunk_preset_id": "laws",
"chunk_parser_config": {
"chunk_token_num": 120,
"overlapped_percent": 0,
"delimiter": "\\n",
},
},
)
first_article_chunks = [ck["content"] for ck in chunks if "第一条" in ck["content"]]
assert first_article_chunks
# 条级切分时,第一条与第二条不应被合并到同一块。
assert all("第二条" not in chunk for chunk in first_article_chunks)
assert max(count_tokens(ck["content"]) for ck in chunks) <= 120
def test_sanitize_processing_params_should_drop_non_persistent_fields() -> None:
sanitized = sanitize_processing_params(
{
"chunk_preset_id": "general",
"chunk_parser_config": {"chunk_token_num": 300},
"ocr_engine": "mineru_ocr",
"ocr_engine_config": {},
"auto_index": True,
"content_hashes": {"a.md": "hash-a"},
"enable_ocr": "mineru_ocr",
"_preprocessed_map": {"a.md": {"path": "/tmp/a.md"}},
}
)
assert sanitized == {
"chunk_preset_id": "general",
"chunk_parser_config": {"chunk_token_num": 300},
"ocr_engine": "mineru_ocr",
"ocr_engine_config": {},
}
def test_resolve_processing_params_keeps_ocr_fields_and_chunk_params() -> None:
resolved = resolve_processing_params(
kb_additional_params={
"chunk_preset_id": "book",
"chunk_parser_config": {"delimiter": "\n", "chunk_token_num": 300},
},
file_processing_params={
"ocr_engine": "mineru_ocr",
"ocr_engine_config": {"backend": "pipeline"},
"chunk_preset_id": "qa",
"chunk_parser_config": {"overlapped_percent": 10},
"content_hashes": {"a.md": "hash-a"},
},
request_params={
"auto_index": True,
"chunk_preset_id": "laws",
"chunk_parser_config": {"chunk_token_num": 666},
},
)
assert resolved["ocr_engine"] == "mineru_ocr"
assert resolved["ocr_engine_config"] == {"backend": "pipeline"}
assert resolved["chunk_preset_id"] == "laws"
assert resolved["chunk_parser_config"] == {
"delimiter": "\n",
"chunk_token_num": 666,
"overlapped_percent": 10,
}
assert "content_hashes" not in resolved
assert "enable_ocr" not in resolved
assert "auto_index" not in resolved
def test_resolve_processing_params_defaults_ocr_fields() -> None:
resolved = resolve_processing_params(
kb_additional_params={},
file_processing_params={"ocr_engine_config": "invalid", "enable_ocr": "mineru_ocr"},
)
assert resolved["ocr_engine"] == "rapid_ocr"
assert resolved["ocr_engine_config"] == {}
assert "enable_ocr" not in resolved