c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Waiting to run
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Python Unittest Coverage / test (macos-15, 3.11) (push) Waiting to run
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Waiting to run
Python Unittest Coverage / test (windows-latest, 3.11) (push) Waiting to run
Web UI / check (push) Waiting to run
445 lines
15 KiB
Python
445 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
# pylint: disable=protected-access
|
|
"""End-to-end wiring test for the knowledge-base upload pipeline.
|
|
|
|
Boots the full FastAPI app via :func:`create_app` against fakeredis +
|
|
in-memory KB-side fakes, then drives the upload → status → list →
|
|
delete flow through ``TestClient``. Verifies that:
|
|
|
|
* ``create_app`` wires the new ``blob_store`` / dispatcher / sweeper /
|
|
service into ``app.state`` correctly;
|
|
* the upload endpoint streams bytes into the blob store, persists a
|
|
``pending`` record, and dispatches the worker;
|
|
* the in-process worker drives the record to ``ready`` through the
|
|
parse → chunk → index phases;
|
|
* the status / list endpoints surface the lifecycle correctly;
|
|
* delete tears down the vector-store and storage records together.
|
|
"""
|
|
import asyncio
|
|
import tempfile
|
|
from typing import Any
|
|
from unittest.async_case import IsolatedAsyncioTestCase
|
|
|
|
import fakeredis.aioredis
|
|
from fastapi.testclient import TestClient
|
|
|
|
from agentscope.app import create_app
|
|
from agentscope.app.rag.blob_store import LocalBlobStore
|
|
from agentscope.app.rag.knowledge_base_manager import (
|
|
KnowledgeBaseManagerBase,
|
|
KnowledgeBaseNotFoundError,
|
|
)
|
|
from agentscope.app.rag.knowledge_base_manager._dimension_policy import (
|
|
DimensionPolicy,
|
|
DimensionPolicyKind,
|
|
)
|
|
from agentscope.app.message_bus import RedisMessageBus
|
|
from agentscope.app.storage import (
|
|
EmbeddingModelConfig,
|
|
KnowledgeBaseRecord,
|
|
RedisStorage,
|
|
)
|
|
from agentscope.app.workspace_manager._base import WorkspaceManagerBase
|
|
from agentscope.rag import VectorStoreBase
|
|
from agentscope.rag._vdb._vector_store import (
|
|
DocumentSummary,
|
|
VectorRecord,
|
|
VectorSearchResult,
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Test doubles
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
class _FakeVectorStore(VectorStoreBase):
|
|
"""In-memory vector store — records are kept by collection.
|
|
|
|
The worker never reads back from it, so there is no need for real
|
|
similarity math; we only have to honour the ``insert`` / ``delete``
|
|
/ ``has_collection`` contract.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._collections: dict[str, list[VectorRecord]] = {}
|
|
|
|
async def create_collection(self, name: str, dimensions: int) -> None:
|
|
self._collections.setdefault(name, [])
|
|
|
|
async def delete_collection(self, name: str) -> None:
|
|
self._collections.pop(name, None)
|
|
|
|
async def has_collection(self, name: str) -> bool:
|
|
return name in self._collections
|
|
|
|
async def insert(
|
|
self,
|
|
collection: str,
|
|
records: list[VectorRecord],
|
|
) -> None:
|
|
self._collections.setdefault(collection, []).extend(records)
|
|
|
|
async def delete(self, collection: str, document_id: str) -> None:
|
|
bucket = self._collections.get(collection)
|
|
if bucket is None:
|
|
return
|
|
self._collections[collection] = [
|
|
r for r in bucket if r.document_id != document_id
|
|
]
|
|
|
|
async def search(
|
|
self,
|
|
collection: str,
|
|
query_vector: list[float],
|
|
top_k: int = 5,
|
|
metadata_filter: dict[str, Any] | None = None,
|
|
) -> list[VectorSearchResult]:
|
|
return []
|
|
|
|
async def list_documents(
|
|
self,
|
|
collection: str,
|
|
metadata_filter: dict[str, Any] | None = None,
|
|
) -> list[DocumentSummary]:
|
|
return []
|
|
|
|
|
|
class _FakeKnowledge:
|
|
"""Minimal stand-in for :class:`KnowledgeBase` used by the worker.
|
|
|
|
Bypasses embedding-model construction — instead just funnels the
|
|
chunks into the bound :class:`_FakeVectorStore` with a fixed
|
|
zero-vector so ``insert_document`` succeeds end-to-end.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
vector_store: _FakeVectorStore,
|
|
collection_name: str,
|
|
) -> None:
|
|
self._vector_store = vector_store
|
|
self._collection_name = collection_name
|
|
|
|
async def insert_document(
|
|
self,
|
|
chunks: list,
|
|
document_id: str | None = None,
|
|
document_metadata: dict | None = None,
|
|
) -> str:
|
|
"""Pretend to embed and insert ``chunks`` into the bound store.
|
|
|
|
The fake skips the real embedding step — it stamps a single
|
|
scalar vector on every record so the upload pipeline can be
|
|
exercised without an embedding model.
|
|
|
|
Args:
|
|
chunks (`list`):
|
|
The parsed and chunked document content.
|
|
document_id (`str | None`, optional):
|
|
Caller-supplied document id; the fake just echoes it
|
|
back rather than generating a UUID.
|
|
document_metadata (`dict | None`, optional):
|
|
Document-level metadata; ignored — the upload tests
|
|
don't assert on metadata propagation.
|
|
|
|
Returns:
|
|
`str`:
|
|
The (caller-supplied) document id, or ``""`` when
|
|
none was passed.
|
|
"""
|
|
del document_metadata # unused — see docstring
|
|
records = [
|
|
VectorRecord(
|
|
vector=[0.0],
|
|
document_id=document_id or "",
|
|
chunk=chunk,
|
|
)
|
|
for chunk in chunks
|
|
]
|
|
await self._vector_store.insert(self._collection_name, records)
|
|
return document_id or ""
|
|
|
|
async def delete_document(self, document_id: str) -> None:
|
|
"""Remove every record for ``document_id`` from the bound store.
|
|
|
|
Args:
|
|
document_id (`str`):
|
|
The document whose records should be deleted.
|
|
"""
|
|
await self._vector_store.delete(self._collection_name, document_id)
|
|
|
|
async def search(self, queries: list, top_k: int = 5) -> list:
|
|
"""Return an empty result list — search is out of scope here.
|
|
|
|
Args:
|
|
queries (`list`):
|
|
The query inputs; ignored.
|
|
top_k (`int`, defaults to ``5``):
|
|
The maximum result count; ignored.
|
|
|
|
Returns:
|
|
`list`:
|
|
Always empty — the upload tests do not exercise
|
|
retrieval.
|
|
"""
|
|
del queries, top_k # unused — see docstring
|
|
return []
|
|
|
|
|
|
class _FakeKbManager(KnowledgeBaseManagerBase):
|
|
"""KB manager that uses the storage + a fake vector store directly.
|
|
|
|
Skips the real ``CollectionPerKbManager`` so we don't need a live
|
|
embedding model — the indexing pipeline only requires
|
|
``insert_document`` / ``delete_document``, which the
|
|
:class:`_FakeKnowledge` returned here implements directly.
|
|
"""
|
|
|
|
async def get_dimension_policy(self) -> DimensionPolicy:
|
|
return DimensionPolicy(kind=DimensionPolicyKind.ANY, dimension=None)
|
|
|
|
async def create_knowledge_base(
|
|
self,
|
|
user_id: str,
|
|
name: str,
|
|
description: str,
|
|
embedding_model_config: EmbeddingModelConfig,
|
|
) -> KnowledgeBaseRecord:
|
|
record = KnowledgeBaseRecord(
|
|
user_id=user_id,
|
|
name=name,
|
|
description=description,
|
|
embedding_model_config=embedding_model_config,
|
|
collection_name="",
|
|
)
|
|
record.collection_name = f"kb_{record.id}"
|
|
await self._vector_store.create_collection(
|
|
name=record.collection_name,
|
|
dimensions=embedding_model_config.dimensions,
|
|
)
|
|
return await self._storage.upsert_knowledge_base(user_id, record)
|
|
|
|
async def delete_knowledge_base(
|
|
self,
|
|
user_id: str,
|
|
knowledge_base_id: str,
|
|
) -> bool:
|
|
record = await self._storage.get_knowledge_base(
|
|
user_id,
|
|
knowledge_base_id,
|
|
)
|
|
if record is None:
|
|
return False
|
|
await self._vector_store.delete_collection(record.collection_name)
|
|
return await self._storage.delete_knowledge_base(
|
|
user_id,
|
|
knowledge_base_id,
|
|
)
|
|
|
|
async def get_knowledge(
|
|
self,
|
|
user_id: str,
|
|
knowledge_base_id: str,
|
|
) -> _FakeKnowledge:
|
|
record = await self._storage.get_knowledge_base(
|
|
user_id,
|
|
knowledge_base_id,
|
|
)
|
|
if record is None:
|
|
raise KnowledgeBaseNotFoundError(
|
|
f"Knowledge base {knowledge_base_id!r} not found.",
|
|
)
|
|
return _FakeKnowledge(
|
|
vector_store=self._vector_store,
|
|
collection_name=record.collection_name,
|
|
)
|
|
|
|
|
|
class _NoopWorkspaceManager(WorkspaceManagerBase):
|
|
"""Workspace manager that does nothing — the KB pipeline never
|
|
touches it, but ``create_app`` requires one to be wired in."""
|
|
|
|
async def get_workspace(self, *args: Any, **kwargs: Any) -> Any:
|
|
"""Fake implementation."""
|
|
raise NotImplementedError
|
|
|
|
async def close(self, workspace_id: str) -> None:
|
|
"""Fake implementation."""
|
|
return None
|
|
|
|
async def close_all(self) -> None:
|
|
"""Fake implementation."""
|
|
return None
|
|
|
|
|
|
def _make_storage(fr: fakeredis.aioredis.FakeRedis) -> RedisStorage:
|
|
"""Build a RedisStorage already bound to *fr*.
|
|
|
|
Pre-populates ``_client`` so the lifespan's ``__aenter__`` no-ops on
|
|
the connection-pool side and just reuses our fakeredis handle.
|
|
"""
|
|
|
|
class _FakeStorage(RedisStorage):
|
|
async def __aenter__(self) -> "_FakeStorage":
|
|
self._client = fr
|
|
return self
|
|
|
|
async def aclose(self) -> None:
|
|
self._client = None
|
|
|
|
return _FakeStorage()
|
|
|
|
|
|
def _make_bus(fr: fakeredis.aioredis.FakeRedis) -> RedisMessageBus:
|
|
"""Build a RedisMessageBus bound to *fr* (same trick as in the bus
|
|
tests)."""
|
|
|
|
class _FakeBus(RedisMessageBus):
|
|
async def __aenter__(self) -> "_FakeBus":
|
|
self._client = fr
|
|
return self
|
|
|
|
async def aclose(self) -> None:
|
|
self._client = None
|
|
|
|
return _FakeBus()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Tests
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
class KnowledgeBaseUploadFlowTest(IsolatedAsyncioTestCase):
|
|
"""End-to-end wiring of the upload pipeline through ``TestClient``."""
|
|
|
|
async def asyncSetUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self._fr = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
|
self._vector_store = _FakeVectorStore()
|
|
storage = _make_storage(self._fr)
|
|
message_bus = _make_bus(self._fr)
|
|
|
|
self._app = create_app(
|
|
storage=storage,
|
|
message_bus=message_bus,
|
|
workspace_manager=_NoopWorkspaceManager(),
|
|
knowledge_base_manager=_FakeKbManager(
|
|
storage=storage,
|
|
vector_store=self._vector_store,
|
|
),
|
|
blob_store=LocalBlobStore(root_dir=self._tmp.name),
|
|
)
|
|
# Seed a knowledge base directly through storage so we don't
|
|
# have to mock the manager's create flow over HTTP.
|
|
kb_record = KnowledgeBaseRecord(
|
|
user_id="user-1",
|
|
name="kb",
|
|
description="",
|
|
embedding_model_config=EmbeddingModelConfig(
|
|
type="openai_credential",
|
|
credential_id="cred-1",
|
|
model="text-embedding-3-small",
|
|
dimensions=1,
|
|
),
|
|
collection_name="",
|
|
)
|
|
kb_record.collection_name = f"kb_{kb_record.id}"
|
|
await self._vector_store.create_collection(
|
|
kb_record.collection_name,
|
|
1,
|
|
)
|
|
|
|
# Drop the seed record into fakeredis directly — we need the KB
|
|
# in place before lifespan starts the sweeper.
|
|
storage._client = self._fr
|
|
await storage.upsert_knowledge_base("user-1", kb_record)
|
|
storage._client = None
|
|
self._kb_id = kb_record.id
|
|
|
|
async def asyncTearDown(self) -> None:
|
|
await self._fr.aclose()
|
|
self._tmp.cleanup()
|
|
|
|
async def test_upload_drives_document_to_ready(self) -> None:
|
|
"""Upload a small text file and observe the lifecycle."""
|
|
headers = {"X-User-ID": "user-1"}
|
|
with TestClient(self._app) as client:
|
|
files = {
|
|
"file": (
|
|
"hello.txt",
|
|
b"hello world\n" * 16,
|
|
"text/plain",
|
|
),
|
|
}
|
|
resp = client.post(
|
|
f"/knowledge_bases/{self._kb_id}/documents",
|
|
files=files,
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(resp.status_code, 201, resp.text)
|
|
body = resp.json()
|
|
document_id = body["document_id"]
|
|
self.assertEqual(body["filename"], "hello.txt")
|
|
self.assertIn(body["status"], ("pending", "ready"))
|
|
|
|
# Wait for the in-process worker to drive the record to
|
|
# ``ready``. We poll with a generous overall timeout — the
|
|
# actual work is < 100 ms but CI machines can be slow.
|
|
deadline = 5.0
|
|
poll = 0.05
|
|
elapsed = 0.0
|
|
final_status = body["status"]
|
|
while elapsed < deadline:
|
|
resp = client.get(
|
|
f"/knowledge_bases/{self._kb_id}/documents/status",
|
|
params={"ids": document_id},
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(resp.status_code, 200, resp.text)
|
|
items = resp.json()["items"]
|
|
self.assertEqual(len(items), 1, items)
|
|
final_status = items[0]["status"]
|
|
if final_status in ("ready", "error"):
|
|
break
|
|
await asyncio.sleep(poll)
|
|
elapsed += poll
|
|
self.assertEqual(final_status, "ready", items)
|
|
|
|
# Listing shows the document with the same ``ready`` state.
|
|
resp = client.get(
|
|
f"/knowledge_bases/{self._kb_id}/documents",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(resp.status_code, 200, resp.text)
|
|
documents = resp.json()["documents"]
|
|
self.assertEqual(len(documents), 1)
|
|
self.assertEqual(documents[0]["id"], document_id)
|
|
self.assertEqual(documents[0]["status"], "ready")
|
|
self.assertGreaterEqual(documents[0]["chunk_count"], 1)
|
|
|
|
# Delete and confirm the listing comes back empty.
|
|
resp = client.delete(
|
|
f"/knowledge_bases/{self._kb_id}/documents/{document_id}",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(resp.status_code, 204, resp.text)
|
|
resp = client.get(
|
|
f"/knowledge_bases/{self._kb_id}/documents",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(resp.json()["documents"], [])
|
|
|
|
async def test_status_for_unknown_id_is_silently_skipped(self) -> None:
|
|
"""Asking for a non-existent doc returns an empty items list."""
|
|
headers = {"X-User-ID": "user-1"}
|
|
with TestClient(self._app) as client:
|
|
resp = client.get(
|
|
f"/knowledge_bases/{self._kb_id}/documents/status",
|
|
params={"ids": "does-not-exist"},
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(resp.status_code, 200, resp.text)
|
|
self.assertEqual(resp.json()["items"], [])
|