diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 02f8acdf..9170460d 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 @@ -32,7 +32,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel - pip install -e ".[dev,core]" + pip install -e "./reme4[dev,core]" - name: Run tests4 unit tests run: | diff --git a/reme4/components/__init__.py b/reme4/components/__init__.py index 8b7af531..f58e6740 100644 --- a/reme4/components/__init__.py +++ b/reme4/components/__init__.py @@ -1,10 +1,9 @@ """Components""" -from . import as_llm -from . import as_llm_formatter -from . import as_token_counter +from . import llm from . import client from . import embedding +from . import embedding_store from . import file_catalog from . import file_graph from . import file_parser @@ -28,11 +27,10 @@ __all__ = [ "PromptHandler", "RuntimeContext", # base components - "as_llm", - "as_llm_formatter", - "as_token_counter", + "llm", "client", "embedding", + "embedding_store", "file_catalog", "file_graph", "file_parser", diff --git a/reme4/components/as_llm/__init__.py b/reme4/components/as_llm/__init__.py deleted file mode 100644 index 82ae553f..00000000 --- a/reme4/components/as_llm/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -"""AgentScope LLM model wrappers.""" - -from agentscope.model import AnthropicChatModel, ChatModelBase, OpenAIChatModel - -from ..base_component import BaseComponent -from ..component_registry import R -from ...enumeration import ComponentEnum - - -class BaseAsLLM(BaseComponent): - """Base wrapper for AgentScope chat models. Builds ``self.model`` in ``_start``.""" - - component_type = ComponentEnum.AS_LLM - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self.model: ChatModelBase | None = None - - async def _close(self) -> None: - self.model = None - - -@R.register("openai") -class OpenAIAsLLM(BaseAsLLM): - """OpenAI chat model wrapper.""" - - async def _start(self) -> None: - self.model = OpenAIChatModel(**self.kwargs) - - async def _close(self) -> None: - if self.model is not None: - assert isinstance(self.model, OpenAIChatModel) - await self.model.client.close() - - -@R.register("anthropic") -class AnthropicAsLLM(BaseAsLLM): - """Anthropic chat model wrapper.""" - - async def _start(self) -> None: - self.model = AnthropicChatModel(**self.kwargs) - - async def _close(self) -> None: - if self.model is not None: - assert isinstance(self.model, AnthropicChatModel) - await self.model.client.close() - - -__all__ = [ - "BaseAsLLM", - "OpenAIAsLLM", - "AnthropicAsLLM", -] diff --git a/reme4/components/as_llm_formatter/__init__.py b/reme4/components/as_llm_formatter/__init__.py deleted file mode 100644 index 81e4fe37..00000000 --- a/reme4/components/as_llm_formatter/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -"""AgentScope LLM formatter wrappers.""" - -from agentscope.formatter import AnthropicChatFormatter, FormatterBase - -from .reme_openai_chat_formatter import ReMeOpenAIChatFormatter -from ..base_component import BaseComponent -from ..component_registry import R -from ...enumeration import ComponentEnum - - -class BaseAsLLMFormatter(BaseComponent): - """Base wrapper for AgentScope formatters. Builds ``self.formatter`` in ``_start``.""" - - component_type = ComponentEnum.AS_LLM_FORMATTER - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self.formatter: FormatterBase | None = None - - async def _close(self) -> None: - self.formatter = None - - -@R.register("openai") -class AsOpenAIChatFormatter(BaseAsLLMFormatter): - """OpenAI chat formatter wrapper (uses ReMe extensions).""" - - async def _start(self) -> None: - self.formatter = ReMeOpenAIChatFormatter(**self.kwargs) - - -@R.register("anthropic") -class AsAnthropicChatFormatter(BaseAsLLMFormatter): - """Anthropic chat formatter wrapper.""" - - async def _start(self) -> None: - self.formatter = AnthropicChatFormatter(**self.kwargs) - - -__all__ = [ - "BaseAsLLMFormatter", - "AsOpenAIChatFormatter", - "AsAnthropicChatFormatter", -] diff --git a/reme4/components/as_llm_formatter/reme_openai_chat_formatter.py b/reme4/components/as_llm_formatter/reme_openai_chat_formatter.py deleted file mode 100644 index a40f977d..00000000 --- a/reme4/components/as_llm_formatter/reme_openai_chat_formatter.py +++ /dev/null @@ -1,141 +0,0 @@ -"""OpenAI chat formatter with ReMe extensions: image promotion and reasoning_content.""" - -import json -from typing import Any - -from agentscope.formatter import OpenAIChatFormatter - -# noinspection PyProtectedMember -from agentscope.formatter._openai_formatter import ( - _format_openai_image_block, - _to_openai_audio_data, -) -from agentscope.message import Msg, TextBlock, ImageBlock, URLSource - - -def _format_openai_video_block(video_block: dict) -> dict[str, Any]: - """Convert a video block to OpenAI ``video_url`` content.""" - source = video_block["source"] - if source["type"] == "url": - url = source["url"] - elif source["type"] == "base64": - url = f"data:{source['media_type']};base64,{source['data']}" - else: - raise ValueError(f"Unsupported video source type: {source['type']}") - return {"type": "video_url", "video_url": {"url": url}} - - -class ReMeOpenAIChatFormatter(OpenAIChatFormatter): - """OpenAIChatFormatter + tool-result image promotion + reasoning_content passthrough.""" - - async def _format(self, msgs: list[Msg]) -> list[dict[str, Any]]: - """Format ``Msg`` list into OpenAI chat-completion message dicts.""" - self.assert_list_of_msgs(msgs) - - messages: list[dict] = [] - i = 0 - while i < len(msgs): - msg = msgs[i] - content_blocks = [] - tool_calls = [] - reasoning_content_blocks = [] - - for block in msg.get_content_blocks(): - typ = block.get("type") - - if typ == "text": - content_blocks.append({**block}) - - elif typ == "thinking": - reasoning_content_blocks.append({**block}) - - elif typ == "tool_use": - tool_calls.append( - { - "id": block.get("id"), - "type": "function", - "function": { - "name": block.get("name"), - "arguments": json.dumps(block.get("input", {}), ensure_ascii=False), - }, - }, - ) - - elif typ == "tool_result": - textual_output, multimodal_data = self.convert_tool_result_to_string(block["output"]) - messages.append( - { - "role": "tool", - "tool_call_id": block.get("id"), - "content": textual_output, - "name": block.get("name"), - }, - ) - - # OpenAI tool messages can't carry images; promote to a follow-up user message. - promoted_blocks = [] - for url, multimodal_block in multimodal_data: - if multimodal_block["type"] == "image" and self.promote_tool_result_images: - promoted_blocks.extend( - [ - TextBlock(type="text", text=f"\n- The image from '{url}': "), - ImageBlock(type="image", source=URLSource(type="url", url=url)), - ], - ) - - if promoted_blocks: - promoted_blocks = [ - TextBlock( - type="text", - text="The following are the image contents from the tool " - f"result of '{block['name']}':", - ), - *promoted_blocks, - TextBlock(type="text", text=""), - ] - msgs.insert( - i + 1, - Msg(name="user", content=promoted_blocks, role="user"), - ) - - elif typ == "image": - content_blocks.append(_format_openai_image_block(block)) - - elif typ == "audio": - # Skip assistant audio — not a valid input modality. - if msg.role == "assistant": - continue - content_blocks.append( - { - "type": "input_audio", - "input_audio": _to_openai_audio_data(block["source"]), - }, - ) - - elif typ == "video": - # Skip assistant video — not a valid input modality. - if msg.role == "assistant": - continue - content_blocks.append(_format_openai_video_block(block)) - - msg_openai = { - "role": msg.role, - "name": msg.name, - "content": content_blocks or None, - } - - if tool_calls: - msg_openai["tool_calls"] = tool_calls - - # Merge thinking blocks into reasoning_content for compatible models. - if reasoning_content_blocks: - reasoning_msg = "\n".join(r.get("thinking", "") for r in reasoning_content_blocks) - if reasoning_msg: - msg_openai["reasoning_content"] = reasoning_msg - - if msg_openai["content"] or msg_openai.get("tool_calls"): - messages.append(msg_openai) - - i += 1 - - return messages diff --git a/reme4/components/as_token_counter/__init__.py b/reme4/components/as_token_counter/__init__.py deleted file mode 100644 index 36bef1bc..00000000 --- a/reme4/components/as_token_counter/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""AgentScope token counter wrappers.""" - -from agentscope.token import TokenCounterBase - -from .estimate_token_counter import EstimatedTokenCounter -from ..base_component import BaseComponent -from ..component_registry import R -from ...enumeration import ComponentEnum - - -class BaseAsTokenCounter(BaseComponent): - """Base wrapper for AgentScope token counters. Builds ``self.token_counter`` in ``_start``.""" - - component_type = ComponentEnum.AS_TOKEN_COUNTER - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self.token_counter: TokenCounterBase | None = None - - async def _close(self) -> None: - self.token_counter = None - - -@R.register("estimated") -class EstimatedAsTokenCounter(BaseAsTokenCounter): - """Character-based estimated token counter — fast but approximate.""" - - async def _start(self) -> None: - self.token_counter = EstimatedTokenCounter(**self.kwargs) - - -__all__ = [ - "BaseAsTokenCounter", - "EstimatedAsTokenCounter", -] diff --git a/reme4/components/as_token_counter/estimate_token_counter.py b/reme4/components/as_token_counter/estimate_token_counter.py deleted file mode 100644 index b5566dec..00000000 --- a/reme4/components/as_token_counter/estimate_token_counter.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Character-based token-count estimator.""" - -from agentscope.token import TokenCounterBase - - -class EstimatedTokenCounter(TokenCounterBase): - """Approximate token count as ``encoded_byte_len / divisor``. - - Cheap proxy when exact counts aren't needed; use the model's real - tokenizer for accuracy. - """ - - def __init__(self, estimate_divisor: float = 4, encoding: str = "utf-8"): - if estimate_divisor <= 0: - raise ValueError("estimate_divisor must be positive") - self.estimate_divisor: float = estimate_divisor - self.encoding: str = encoding - - async def count(self, text: str, **_kwargs) -> int: - """Estimated token count for ``text``.""" - return int(len(text.encode(self.encoding)) / self.estimate_divisor + 0.5) diff --git a/reme4/components/embedding/__init__.py b/reme4/components/embedding/__init__.py index bf2d4b48..c8c4289b 100644 --- a/reme4/components/embedding/__init__.py +++ b/reme4/components/embedding/__init__.py @@ -1,6 +1,93 @@ -"""Embedding model implementations.""" +"""AgentScope embedding model wrappers.""" -from .base_embedding_model import BaseEmbeddingModel -from .openai_embedding_model import OpenAIEmbeddingModel +from agentscope.embedding import ( + DashScopeMultiModalEmbedding as _AsDashScopeMultiModalEmbedding, + DashScopeTextEmbedding, + EmbeddingModelBase, + GeminiTextEmbedding, + OllamaTextEmbedding, + OpenAITextEmbedding, +) -__all__ = ["BaseEmbeddingModel", "OpenAIEmbeddingModel"] +from ..base_component import BaseComponent +from ..component_registry import R +from ...enumeration import ComponentEnum + + +class BaseEmbedding(BaseComponent): + """Base wrapper for AgentScope embedding models. Builds ``self.model`` in ``_start``.""" + + component_type = ComponentEnum.EMBEDDING + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.model: EmbeddingModelBase | None = None + + @property + def dimensions(self) -> int: + """Return the embedding dimension size.""" + assert self.model is not None + return self.model.dimensions + + async def __call__(self, text: list[str], **kwargs) -> list[list[float]]: + assert self.model is not None + response = await self.model(text, **kwargs) # pylint: disable=not-callable + return response.embeddings + + async def _close(self) -> None: + self.model = None + + +@R.register("openai") +class OpenAIEmbedding(BaseEmbedding): + """OpenAI embedding model wrapper.""" + + async def _start(self) -> None: + self.model = OpenAITextEmbedding(**self.kwargs) + + async def _close(self) -> None: + if self.model is not None: + assert isinstance(self.model, OpenAITextEmbedding) + await self.model.client.close() + + +@R.register("dashscope") +class DashScopeEmbedding(BaseEmbedding): + """DashScope text embedding model wrapper.""" + + async def _start(self) -> None: + self.model = DashScopeTextEmbedding(**self.kwargs) + + +@R.register("dashscope_multimodal") +class DashScopeMultiModalEmbedding(BaseEmbedding): + """DashScope multimodal embedding model wrapper.""" + + async def _start(self) -> None: + self.model = _AsDashScopeMultiModalEmbedding(**self.kwargs) + + +@R.register("gemini") +class GeminiEmbedding(BaseEmbedding): + """Gemini embedding model wrapper.""" + + async def _start(self) -> None: + self.model = GeminiTextEmbedding(**self.kwargs) + + +@R.register("ollama") +class OllamaEmbedding(BaseEmbedding): + """Ollama embedding model wrapper.""" + + async def _start(self) -> None: + self.model = OllamaTextEmbedding(**self.kwargs) + + +__all__ = [ + "BaseEmbedding", + "OpenAIEmbedding", + "DashScopeEmbedding", + "DashScopeMultiModalEmbedding", + "GeminiEmbedding", + "OllamaEmbedding", +] diff --git a/reme4/components/embedding/openai_embedding_model.py b/reme4/components/embedding/openai_embedding_model.py deleted file mode 100644 index a03c4a01..00000000 --- a/reme4/components/embedding/openai_embedding_model.py +++ /dev/null @@ -1,52 +0,0 @@ -"""OpenAI-compatible async embedding model.""" - -from openai import AsyncOpenAI - -from .base_embedding_model import BaseEmbeddingModel -from ..component_registry import R - - -@R.register("openai") -class OpenAIEmbeddingModel(BaseEmbeddingModel): - """Embedding model backed by any OpenAI-compatible API.""" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._client: AsyncOpenAI | None = None - - async def _start(self) -> None: - """Initialize async OpenAI client.""" - self._client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url, **self.kwargs) - await super()._start() - - async def _close(self) -> None: - """Close the async OpenAI client.""" - if self._client: - await self._client.close() - self._client = None - await super()._close() - - async def _get_embeddings(self, input_text: list[str], **kwargs) -> list[list[float] | None]: - """Call the embeddings API and return results aligned to input order.""" - if self._client is None: - raise RuntimeError("Client not initialized. Call _start() first.") - - create_kwargs: dict = {"model": self.model_name, "input": input_text, **kwargs} - if self.pass_dimensions: - create_kwargs["dimensions"] = self.dimensions - - completion = await self._client.embeddings.create(**create_kwargs) - - # Map API results back to input order - result: list[list[float] | None] = [None] * len(input_text) - for emb in completion.data: - if 0 <= emb.index < len(input_text): - vec = emb.embedding or getattr(emb, "dense_embedding", None) - if vec is not None: - result[emb.index] = list(vec) - else: - self.logger.warning(f"Empty embedding at index {emb.index}") - else: - self.logger.warning(f"Index {emb.index} out of range for input length {len(input_text)}") - - return result diff --git a/reme4/components/embedding_store/__init__.py b/reme4/components/embedding_store/__init__.py new file mode 100644 index 00000000..40a089af --- /dev/null +++ b/reme4/components/embedding_store/__init__.py @@ -0,0 +1,6 @@ +"""Embedding store implementations.""" + +from .base_embedding_store import BaseEmbeddingStore +from .local_embedding_store import LocalEmbeddingStore + +__all__ = ["BaseEmbeddingStore", "LocalEmbeddingStore"] diff --git a/reme4/components/embedding_store/base_embedding_store.py b/reme4/components/embedding_store/base_embedding_store.py new file mode 100644 index 00000000..7f7d46e2 --- /dev/null +++ b/reme4/components/embedding_store/base_embedding_store.py @@ -0,0 +1,54 @@ +"""Base embedding store with abstract interface for caching and retrieval.""" + +from abc import abstractmethod + +import numpy as np + +from ..base_component import BaseComponent +from ...enumeration import ComponentEnum +from ...schema import EmbNode + + +class BaseEmbeddingStore(BaseComponent): + """Abstract embedding store interface. + + Subclasses implement caching, persistence, and delegate actual embedding + computation to a bound ``embedding`` component. + """ + + component_type = ComponentEnum.EMBEDDING_STORE + + def __init__( + self, + max_batch_size: int = 10, + max_input_length: int = 8192, + max_retries: int = 3, + **kwargs, + ): + super().__init__(**kwargs) + self.max_batch_size = max_batch_size + self.max_input_length = max_input_length + self.max_retries = max_retries + self.is_healthy: bool = True + + @abstractmethod + async def health_check(self, timeout: float = 2.0) -> bool: + """Probe the provider; sets and returns is_healthy.""" + + async def get_embedding(self, input_text: str, **kwargs) -> np.ndarray | None: + """Embed a single text; returns None if the provider yields nothing.""" + results = await self.get_embeddings([input_text], **kwargs) + return results[0] if results else None + + @abstractmethod + async def get_embeddings(self, input_text: list[str], **kwargs) -> list[np.ndarray | None]: + """Get embeddings for texts.""" + + async def get_node_embeddings(self, nodes: list[EmbNode], **kwargs) -> list[EmbNode]: + """Embed each node's text in-place and return the same list.""" + embeddings = await self.get_embeddings([n.text for n in nodes], **kwargs) + if len(embeddings) == len(nodes): + for node, vec in zip(nodes, embeddings): + if vec is not None: + node.embedding = vec + return nodes diff --git a/reme4/components/embedding/base_embedding_model.py b/reme4/components/embedding_store/local_embedding_store.py similarity index 67% rename from reme4/components/embedding/base_embedding_model.py rename to reme4/components/embedding_store/local_embedding_store.py index b11071a3..186a4c43 100644 --- a/reme4/components/embedding/base_embedding_model.py +++ b/reme4/components/embedding_store/local_embedding_store.py @@ -1,73 +1,64 @@ -"""Base embedding model with LRU cache and disk persistence.""" +"""Local embedding store with LRU cache and disk persistence.""" import asyncio import hashlib -import os -from abc import abstractmethod from collections import OrderedDict from pathlib import Path import numpy as np -from ..base_component import BaseComponent -from ...enumeration import ComponentEnum -from ...schema import EmbNode +from .base_embedding_store import BaseEmbeddingStore +from ..component_registry import R +from ..embedding import BaseEmbedding Miss = tuple[int, str, str] # (result_index, text, cache_key) -class BaseEmbeddingModel(BaseComponent): - """Embedding model with LRU cache, disk persistence, and serial batching.""" +@R.register("local") +class LocalEmbeddingStore(BaseEmbeddingStore): + """Embedding store with LRU cache, disk persistence, and serial batching. - component_type = ComponentEnum.EMBEDDING_MODEL + Delegates actual embedding computation to a bound ``embedding`` component. + """ def __init__( self, - api_key: str | None = None, - base_url: str | None = None, - model_name: str = "", - dimensions: int = 1024, - pass_dimensions: bool = False, - max_batch_size: int = 10, - max_input_length: int = 8192, + embedding: str = "default", max_cache_size: int = 10000, enable_cache: bool = True, cache_version: str = "v1", - max_retries: int = 3, **kwargs, ): super().__init__(**kwargs) - self.api_key = api_key or os.environ.get("EMBEDDING_API_KEY", "") - self.base_url = base_url or os.environ.get("EMBEDDING_BASE_URL", "") - self.model_name = model_name - self.dimensions = dimensions - self.pass_dimensions = pass_dimensions - self.max_batch_size = max_batch_size - self.max_input_length = max_input_length + self.embedding = self.bind(embedding, BaseEmbedding, optional=False) self.max_cache_size = max_cache_size self.enable_cache = enable_cache self.cache_version = cache_version - self.max_retries = max_retries self._cache: OrderedDict[str, np.ndarray] = OrderedDict() - self._key_suffix = f"|{model_name}|{dimensions}".encode() - self.is_healthy: bool = True + self._key_suffix: bytes = b"" + + @property + def dimensions(self) -> int: + """Return the embedding dimension size.""" + assert self.embedding is not None, "embedding component not bound" + return self.embedding.dimensions @property def cache_path(self) -> Path: - """Path of the persisted embedding cache, namespaced by name and version.""" - return self.vault_metadata_path / "embedding_cache" / f"{self.name}_{self.cache_version}.npz" + """Return the path to the disk cache file.""" + return self.component_metadata_path / f"{self.name}_{self.cache_version}.npz" async def _start(self) -> None: + self._key_suffix = f"|{self.dimensions}".encode() await self.load() async def _close(self) -> None: await self.dump() async def health_check(self, timeout: float = 2.0) -> bool: - """Probe the provider; sets and returns is_healthy.""" - tag = f"[EMBEDDING HEALTH CHECK] name={self.name} model={self.model_name}" + tag = f"[EMBEDDING HEALTH CHECK] name={self.name}" try: - result = await asyncio.wait_for(self._get_embeddings(["ping"]), timeout=timeout) + result = await asyncio.wait_for(self.embedding(["ping"]), timeout=timeout) if not result or result[0] is None: raise RuntimeError("empty embedding") self.is_healthy = True @@ -82,39 +73,19 @@ class BaseEmbeddingModel(BaseComponent): # -- Public API -- - async def get_embedding(self, input_text: str, **kwargs) -> np.ndarray | None: - """Embed a single text; returns None if the provider yields nothing.""" - results = await self.get_embeddings([input_text], **kwargs) - return results[0] if results else None - async def get_embeddings(self, input_text: list[str], **kwargs) -> list[np.ndarray | None]: - """Get embeddings for texts. Cache hits return immediately; misses run in serial batches.""" texts = [self._truncate(t) for t in input_text] results, misses = self._partition_by_cache(texts) if misses: await self._fill_misses(misses, results, **kwargs) return results - async def get_node_embeddings(self, nodes: list[EmbNode], **kwargs) -> list[EmbNode]: - """Embed each node's text in-place and return the same list.""" - embeddings = await self.get_embeddings([n.text for n in nodes], **kwargs) - if len(embeddings) == len(nodes): - for node, vec in zip(nodes, embeddings): - if vec is not None: - node.embedding = vec - return nodes - - @abstractmethod - async def _get_embeddings(self, input_text: list[str], **kwargs) -> list[list[float] | None]: - """Get raw embeddings from the underlying provider.""" - # -- Batching -- def _truncate(self, text: str) -> str: return text if len(text) <= self.max_input_length else text[: self.max_input_length] def _partition_by_cache(self, texts: list[str]) -> tuple[list[np.ndarray | None], list[Miss]]: - """Split texts into pre-filled results (hits) and a miss list to compute.""" results: list[np.ndarray | None] = [None] * len(texts) misses: list[Miss] = [] for idx, text in enumerate(texts): @@ -127,7 +98,6 @@ class BaseEmbeddingModel(BaseComponent): return results, misses async def _fill_misses(self, misses: list[Miss], results: list[np.ndarray | None], **kwargs) -> None: - """Compute miss embeddings in serial batches and write into results + cache.""" size = self.max_batch_size batches = [misses[i : i + size] for i in range(0, len(misses), size)] for batch in batches: @@ -136,7 +106,6 @@ class BaseEmbeddingModel(BaseComponent): self._cache_put(key, emb) async def _compute_batch(self, batch: list[Miss], **kwargs) -> list[tuple[int, str, np.ndarray]]: - """Call provider for one batch with retry; returns [(idx, key, embedding)].""" texts = [text for _, text, _ in batch] embeddings = await self._call_with_retry(texts, **kwargs) if not embeddings or len(embeddings) != len(texts): @@ -150,10 +119,9 @@ class BaseEmbeddingModel(BaseComponent): return out async def _call_with_retry(self, texts: list[str], **kwargs) -> list[list[float] | None] | None: - """Call provider with exponential backoff on transient errors.""" for attempt in range(self.max_retries): try: - result = await self._get_embeddings(texts, **kwargs) + result = await self.embedding(texts, **kwargs) if result and len(result) == len(texts): return result except (TimeoutError, ConnectionError, OSError): @@ -197,7 +165,6 @@ class BaseEmbeddingModel(BaseComponent): # -- Persistence -- async def load(self) -> None: - """Load cached embeddings from disk (npz); replaces in-memory cache.""" self._cache.clear() if not self.enable_cache or not self.cache_path.exists(): return @@ -219,7 +186,6 @@ class BaseEmbeddingModel(BaseComponent): self.logger.info(f"Loaded {len(self._cache)} embeddings from {self.cache_path}") async def dump(self) -> None: - """Persist in-memory cache to disk (npz).""" if not self.enable_cache or not self._cache: return await asyncio.to_thread(self._dump_sync) diff --git a/reme4/components/file_store/faiss_local_file_store.py b/reme4/components/file_store/faiss_local_file_store.py index a0dd3c89..32ace19a 100644 --- a/reme4/components/file_store/faiss_local_file_store.py +++ b/reme4/components/file_store/faiss_local_file_store.py @@ -55,7 +55,7 @@ class FaissLocalFileStore(LocalFileStore): @property def _dim(self) -> int: - return self.embedding_model.dimensions if self.embedding_model is not None else 0 + return self.embedding_store.dimensions if self.embedding_store is not None else 0 def _new_index(self): return self._faiss.IndexFlatIP(self._dim) @@ -111,7 +111,7 @@ class FaissLocalFileStore(LocalFileStore): async def load(self) -> None: """Load chunks via the parent, then attach FAISS state (sidecar or rebuild).""" await super().load() - if self.embedding_model is None or self._dim == 0: + if self.embedding_store is None or self._dim == 0: self._faiss_index = None return if not await self._try_load_sidecar(): @@ -147,7 +147,7 @@ class FaissLocalFileStore(LocalFileStore): async def dump(self) -> None: """Persist chunks JSONL via the parent, then write the FAISS sidecar atomically.""" await super().dump() - if self._faiss_index is None or self.embedding_model is None: + if self._faiss_index is None or self.embedding_store is None: return try: self._compact_if_needed() @@ -180,7 +180,7 @@ class FaissLocalFileStore(LocalFileStore): } await super().upsert(files) - if self._faiss_index is None or self.embedding_model is None: + if self._faiss_index is None or self.embedding_store is None: return self._sync_index_after_upsert(files, old_ids_by_path) @@ -220,7 +220,7 @@ class FaissLocalFileStore(LocalFileStore): async def clear(self) -> None: await super().clear() - self._faiss_index = self._new_index() if self.embedding_model is not None else None + self._faiss_index = self._new_index() if self.embedding_store is not None else None self._id_map = [] self._id_to_row = {} self._tombstones.clear() @@ -230,13 +230,13 @@ class FaissLocalFileStore(LocalFileStore): # -- search ----------------------------------------------------------- async def vector_search(self, query: str, limit: int, search_filter: dict) -> list[FileChunk]: - if self.embedding_model is None or not query or self._faiss_index is None: + if self.embedding_store is None or not query or self._faiss_index is None: return [] if self._faiss_index.ntotal == 0: return [] try: - query_embedding = await self.embedding_model.get_embedding(query) + query_embedding = await self.embedding_store.get_embedding(query) except Exception as e: self._disable_embedding(f"search: {type(e).__name__}: {e}") return [] diff --git a/reme4/components/file_store/local_file_store.py b/reme4/components/file_store/local_file_store.py index 261dedf0..3d653954 100644 --- a/reme4/components/file_store/local_file_store.py +++ b/reme4/components/file_store/local_file_store.py @@ -5,7 +5,7 @@ import numpy as np from .base_file_store import BaseFileStore from ..component_registry import R -from ..embedding import BaseEmbeddingModel +from ..embedding_store import BaseEmbeddingStore from ..file_graph import BaseFileGraph from ..keyword_index import BaseKeywordIndex from ...enumeration import LinkScopeEnum @@ -17,7 +17,7 @@ from ...utils import batch_cosine_similarity class LocalFileStore(BaseFileStore): """In-memory file store with deferred JSONL persistence. - Composes three subcomponents: ``embedding_model`` for vector retrieval, + Composes three subcomponents: ``embedding_store`` for vector retrieval, ``keyword_index`` for full-text retrieval, and ``file_graph`` for node / link storage. ``file_graph`` is mandatory; at least one of embedding / keyword must be present. @@ -25,7 +25,7 @@ class LocalFileStore(BaseFileStore): def __init__( self, - embedding_model: str = "default", + embedding_store: str = "default", keyword_index: str = "default", file_graph: str = "default", encoding: str = "utf-8", @@ -33,16 +33,16 @@ class LocalFileStore(BaseFileStore): **kwargs, ): super().__init__(**kwargs) - from ..embedding import OpenAIEmbeddingModel + from ..embedding_store import LocalEmbeddingStore from ..file_graph import LocalFileGraph from ..keyword_index import BM25Index - if not embedding_model and not keyword_index: - raise ValueError("At least one of embedding_model or keyword_index must be set.") + if not embedding_store and not keyword_index: + raise ValueError("At least one of embedding_store or keyword_index must be set.") if not file_graph: raise ValueError("file_graph is required for LocalFileStore.") - self.embedding_model = self.bind(embedding_model, BaseEmbeddingModel, default_factory=OpenAIEmbeddingModel) + self.embedding_store = self.bind(embedding_store, BaseEmbeddingStore, default_factory=LocalEmbeddingStore) self.keyword_index = self.bind(keyword_index, BaseKeywordIndex, default_factory=BM25Index) self.file_graph = self.bind(file_graph, BaseFileGraph, default_factory=LocalFileGraph) @@ -56,9 +56,9 @@ class LocalFileStore(BaseFileStore): async def _start(self) -> None: self.component_metadata_path.mkdir(parents=True, exist_ok=True) await super()._start() - if self.embedding_model is not None and not await self.embedding_model.health_check(): + if self.embedding_store is not None and not await self.embedding_store.health_check(): self.logger.warning(f"{self.name}: embedding unhealthy, vector disabled") - self.embedding_model = None + self.embedding_store = None await self.load() async def _close(self) -> None: @@ -68,10 +68,10 @@ class LocalFileStore(BaseFileStore): def _disable_embedding(self, reason: str) -> None: """Drop embedding after a runtime failure; keyword search still works.""" - if self.embedding_model is None: + if self.embedding_store is None: return self.logger.error(f"{self.name}: embedding disabled, {reason}") - self.embedding_model = None + self.embedding_store = None # -- persistence ---------------------------------------------------------- @@ -148,7 +148,7 @@ class LocalFileStore(BaseFileStore): a new chunk reusing the same id avoids a redundant embedding call. """ cached: dict[str, np.ndarray] = {} - if not (old_node and self.embedding_model): + if not (old_node and self.embedding_store): return cached for cid in old_node.chunk_ids: old = self.file_chunks.pop(cid, None) @@ -162,7 +162,7 @@ class LocalFileStore(BaseFileStore): cached: dict[str, np.ndarray], needs_embed: list[FileChunk], ) -> None: - if not self.embedding_model or chunk.embedding is not None: + if not self.embedding_store or chunk.embedding is not None: return if chunk.id in cached: chunk.embedding = cached[chunk.id] @@ -170,10 +170,10 @@ class LocalFileStore(BaseFileStore): needs_embed.append(chunk) async def _embed_pending(self, chunks: list[FileChunk]) -> None: - if not (chunks and self.embedding_model): + if not (chunks and self.embedding_store): return try: - await self.embedding_model.get_node_embeddings(chunks) + await self.embedding_store.get_node_embeddings(chunks) except Exception as e: self._disable_embedding(f"upsert: {type(e).__name__}: {e}") @@ -221,11 +221,11 @@ class LocalFileStore(BaseFileStore): # -- search --------------------------------------------------------------- async def vector_search(self, query: str, limit: int, search_filter: dict) -> list[FileChunk]: - if self.embedding_model is None or not query: + if self.embedding_store is None or not query: return [] try: - query_embedding = await self.embedding_model.get_embedding(query) + query_embedding = await self.embedding_store.get_embedding(query) except Exception as e: self._disable_embedding(f"search: {type(e).__name__}: {e}") return [] diff --git a/reme4/components/llm/__init__.py b/reme4/components/llm/__init__.py new file mode 100644 index 00000000..e2d212b4 --- /dev/null +++ b/reme4/components/llm/__init__.py @@ -0,0 +1,112 @@ +"""LLM model wrappers for AgentScope.""" + +from agentscope.credential import ( + AnthropicCredential, + CredentialBase, + DashScopeCredential, + DeepSeekCredential, + GeminiCredential, + MoonshotCredential, + OllamaCredential, + OpenAICredential, + XAICredential, +) +from agentscope.model import ChatModelBase + +from ..base_component import BaseComponent +from ..component_registry import R +from ...enumeration import ComponentEnum + + +class BaseLLM(BaseComponent): + """Base wrapper for AgentScope chat models. + + Subclasses set ``credential_cls`` and inherit ``_start`` / ``_close``. + """ + + component_type = ComponentEnum.LLM + credential_cls: type[CredentialBase] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.model: ChatModelBase | None = None + + async def _start(self) -> None: + kwargs = dict(self.kwargs) + credential = self.credential_cls(**kwargs.pop("credential", {})) + model_cls = credential.get_chat_model_class() + params_dict = kwargs.pop("parameters", None) + parameters = model_cls.Parameters(**params_dict) if params_dict else None + self.model = model_cls(credential=credential, parameters=parameters, **kwargs) + + async def _close(self) -> None: + self.model = None + + +@R.register("openai") +class OpenAILLM(BaseLLM): + """OpenAI chat model wrapper.""" + + credential_cls = OpenAICredential + + +@R.register("anthropic") +class AnthropicLLM(BaseLLM): + """Anthropic chat model wrapper.""" + + credential_cls = AnthropicCredential + + +@R.register("dashscope") +class DashScopeLLM(BaseLLM): + """DashScope chat model wrapper.""" + + credential_cls = DashScopeCredential + + +@R.register("deepseek") +class DeepSeekLLM(BaseLLM): + """DeepSeek chat model wrapper.""" + + credential_cls = DeepSeekCredential + + +@R.register("gemini") +class GeminiLLM(BaseLLM): + """Gemini chat model wrapper.""" + + credential_cls = GeminiCredential + + +@R.register("moonshot") +class MoonshotLLM(BaseLLM): + """Moonshot chat model wrapper.""" + + credential_cls = MoonshotCredential + + +@R.register("ollama") +class OllamaLLM(BaseLLM): + """Ollama chat model wrapper.""" + + credential_cls = OllamaCredential + + +@R.register("xai") +class XAILLM(BaseLLM): + """xAI chat model wrapper.""" + + credential_cls = XAICredential + + +__all__ = [ + "BaseLLM", + "OpenAILLM", + "AnthropicLLM", + "DashScopeLLM", + "DeepSeekLLM", + "GeminiLLM", + "MoonshotLLM", + "OllamaLLM", + "XAILLM", +] diff --git a/reme4/config/default.yaml b/reme4/config/default.yaml index 4f1f5552..611dff78 100644 --- a/reme4/config/default.yaml +++ b/reme4/config/default.yaml @@ -1,12 +1,12 @@ -service: - backend: http - vault_dir: .reme daily_dir: daily digest_dir: digest resource_dir: "" # language: zh +service: + backend: http + jobs: update_store_index_loop: backend: background @@ -436,7 +436,7 @@ components: default: backend: regex - embedding_model: + embedding: default: backend: ${EMBEDDING_BACKEND:-openai} api_key: ${EMBEDDING_API_KEY:-} @@ -444,17 +444,26 @@ components: model_name: ${EMBEDDING_MODEL_NAME:-text-embedding-v4} dimensions: 1024 - as_llm: + embedding_store: default: - backend: ${LLM_BACKEND:-anthropic} - model_name: ${LLM_MODEL_NAME:-glm-5} - api_key: ${LLM_API_KEY:-} - client_kwargs: - base_url: ${LLM_BASE_URL:-https://dashscope.aliyuncs.com/apps/anthropic} + backend: local + embedding: default - as_llm_formatter: + llm: default: backend: ${LLM_BACKEND:-anthropic} + model: ${LLM_MODEL_NAME:-glm-5.1} + stream: true + context_size: 200000 + max_retries: 3 + retry_delay: 1.0 + credential: + api_key: ${LLM_API_KEY:-} + base_url: ${LLM_BASE_URL:-https://dashscope.aliyuncs.com/apps/anthropic} + parameters: + max_tokens: 100000 + thinking_enable: true + thinking_budget: 38000 file_graph: default: @@ -479,7 +488,7 @@ components: default: backend: local store_name: local - # embedding_model: default - embedding_model: "" + # embedding_store: default + embedding_store: "" keyword_index: default file_graph: default diff --git a/reme4/enumeration/component_enum.py b/reme4/enumeration/component_enum.py index 315c760c..db07dad3 100644 --- a/reme4/enumeration/component_enum.py +++ b/reme4/enumeration/component_enum.py @@ -8,13 +8,11 @@ class ComponentEnum(str, Enum): BASE = "base" - AS_LLM = "as_llm" + LLM = "llm" - AS_LLM_FORMATTER = "as_llm_formatter" + EMBEDDING = "embedding" - AS_TOKEN_COUNTER = "as_token_counter" - - EMBEDDING_MODEL = "embedding_model" + EMBEDDING_STORE = "embedding_store" FILE_PARSER = "file_parser" diff --git a/reme4/steps/base_step.py b/reme4/steps/base_step.py index 462d9f18..17587e51 100644 --- a/reme4/steps/base_step.py +++ b/reme4/steps/base_step.py @@ -5,14 +5,10 @@ from abc import abstractmethod, ABC from pathlib import Path from typing import TypeVar, TYPE_CHECKING -from agentscope.formatter import FormatterBase -from agentscope.message import TextBlock from agentscope.model import ChatModelBase -from agentscope.token import TokenCounterBase -from agentscope.tool import Toolkit, ToolResponse +from agentscope.tool import Toolkit, FunctionTool from ..components.base_component import ComponentMixin -from ..components.embedding import BaseEmbeddingModel from ..components.file_parser import BaseFileParser from ..components.file_store import BaseFileStore from ..components.prompt_handler import PromptHandler @@ -35,7 +31,7 @@ class Ref: Replaces the ``@property`` + ``_resolve()`` boilerplate with a single class-level declaration:: - as_llm = Ref(ChatModelBase, ComponentEnum.AS_LLM, "model") + llm = Ref(ChatModelBase, ComponentEnum.LLM, "model") file_store = Ref(BaseFileStore, ComponentEnum.FILE_STORE) Resolution follows a 3-source fallback identical to the old ``_resolve``: @@ -105,6 +101,9 @@ class BaseStep(ComponentMixin, ABC): component_type = ComponentEnum.STEP + llm: ChatModelBase = Ref(ChatModelBase, ComponentEnum.LLM, "model") + file_store: BaseFileStore = Ref(BaseFileStore, ComponentEnum.FILE_STORE) + def __new__(cls, *args, **kwargs): # Snapshot init args so copy() can rebuild an equivalent instance later. instance = object.__new__(cls) @@ -140,14 +139,6 @@ class BaseStep(ComponentMixin, ABC): self.prompt.load_prompt_by_class(cls) self.prompt.load_prompt_dict(prompt_dict) - # ----- Component references (resolved lazily on first access) ---------- - - as_llm: ChatModelBase = Ref(ChatModelBase, ComponentEnum.AS_LLM, "model") - as_llm_formatter: FormatterBase = Ref(FormatterBase, ComponentEnum.AS_LLM_FORMATTER, "formatter") - as_token_counter: TokenCounterBase = Ref(TokenCounterBase, ComponentEnum.AS_TOKEN_COUNTER, "token_counter") - file_store: BaseFileStore = Ref(BaseFileStore, ComponentEnum.FILE_STORE) - embedding: BaseEmbeddingModel = Ref(BaseEmbeddingModel, ComponentEnum.EMBEDDING_MODEL) - @abstractmethod async def execute(self): """Run the step's logic against ``self.context``.""" @@ -226,20 +217,15 @@ class BaseStep(ComponentMixin, ABC): if job is None: raise RuntimeError(f"Job {job_name} not found") - async def run_job(**_kwargs) -> ToolResponse: + async def run_job(**_kwargs) -> str: response = await job(**{**_kwargs, **kwargs}) - return ToolResponse(content=[TextBlock(type="text", text=response.answer)]) + return response.answer - toolkit.register_tool_function( - tool_func=run_job, - func_name=job_name, - func_description=job.description, - json_schema={ - "type": "function", - "function": { - "name": job_name, - "description": job.description, - "parameters": job.parameters, - }, - }, + tool = FunctionTool( + func=run_job, + name=job_name, + description=job.description, ) + if job.parameters: + tool.input_schema = job.parameters + toolkit.tool_groups[0].tools.append(tool) diff --git a/reme4/steps/common/health_check.py b/reme4/steps/common/health_check.py index 7bdf182d..cb83e234 100644 --- a/reme4/steps/common/health_check.py +++ b/reme4/steps/common/health_check.py @@ -57,11 +57,15 @@ def _mb_str(*objs) -> str: def _embedding_status(comp) -> dict: cache = getattr(comp, "_embedding_cache", {}) or {} + try: + dims = comp.dimensions + except Exception: + dims = None return { "is_started": comp.is_started, "is_healthy": getattr(comp, "is_healthy", None), "model_name": getattr(comp, "model_name", None), - "dimensions": getattr(comp, "dimensions", None), + "dimensions": dims, "cache_size": len(cache), "memory": _mb_str(cache), } @@ -124,7 +128,7 @@ def _keyword_index_status(comp) -> dict: _HANDLERS = { - ComponentEnum.EMBEDDING_MODEL: _embedding_status, + ComponentEnum.EMBEDDING_STORE: _embedding_status, ComponentEnum.FILE_GRAPH: _file_graph_status, ComponentEnum.FILE_STORE: _file_store_status, ComponentEnum.KEYWORD_INDEX: _keyword_index_status, @@ -140,7 +144,7 @@ def _is_healthy(ctype: ComponentEnum, status: dict) -> bool: """Unstarted = unhealthy; embedding model also requires is_healthy != False.""" if not status.get("is_started"): return False - if ctype is ComponentEnum.EMBEDDING_MODEL and status.get("is_healthy") is False: + if ctype is ComponentEnum.EMBEDDING_STORE and status.get("is_healthy") is False: return False return True diff --git a/reme4/steps/common/llm_demo.py b/reme4/steps/common/llm_demo.py index fc6dda06..06ec6403 100644 --- a/reme4/steps/common/llm_demo.py +++ b/reme4/steps/common/llm_demo.py @@ -1,34 +1,38 @@ -"""Demo step that drives a ReActAgent via BaseStep.as_llm/as_llm_formatter.""" +"""Demo step that drives an Agent via BaseStep.llm.""" -from agentscope.agent import ReActAgent +from typing import Type + +from agentscope.agent import Agent +from agentscope.state import AgentState from agentscope.message import Msg, TextBlock -from agentscope.tool import Toolkit, ToolResponse +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.tool import FunctionTool, Toolkit +from pydantic import BaseModel from ..base_step import BaseStep from ...components import R -def _add(a: float, b: float) -> ToolResponse: +def add(a: float, b: float) -> str: """Add two numbers and return the sum. Args: a: first addend b: second addend """ - return ToolResponse(content=[TextBlock(type="text", text=str(a + b))]) + return str(a + b) @R.register("llm_demo_step") class LLMDemoStep(BaseStep): - """Drive a ReActAgent powered by ``self.as_llm`` / ``self.as_llm_formatter``. + """Drive an Agent powered by ``self.llm``. Inputs (from RuntimeContext): query (str, required): user message content. sys_prompt (str, optional): system prompt for the agent. use_add_tool (bool, optional): register the ``add`` tool when True. - console_enabled (bool, optional): mirror agent output to stdout. - Output (written to context.response.answer):fa + Output (written to context.response.answer): The agent's final reply text. """ @@ -39,32 +43,41 @@ class LLMDemoStep(BaseStep): query: str = self.context.get("query", "") sys_prompt: str = self.context.get("sys_prompt") or self.DEFAULT_SYS_PROMPT use_add_tool: bool = bool(self.context.get("use_add_tool", False)) - console_enabled: bool = bool(self.context.get("console_enabled", False)) + structured_model: Type[BaseModel] | None = self.context.get("structured_model") if not query: self.context.response.success = False self.context.response.answer = "Skipped: empty query" return self.context.response - toolkit = Toolkit() - if use_add_tool: - toolkit.register_tool_function(_add) + toolkit = Toolkit(tools=[FunctionTool(add)]) if use_add_tool else Toolkit() - agent = ReActAgent( + agent = Agent( name=self.name, - sys_prompt=sys_prompt, - model=self.as_llm, - formatter=self.as_llm_formatter, + system_prompt=sys_prompt, + model=self.llm, toolkit=toolkit, + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), ) - agent.set_console_output_enabled(console_enabled) response: Msg = await agent.reply( - Msg(name="user", role="user", content=query), + Msg(name="user", role="user", content=[TextBlock(text=query)]), ) text = (response.get_text_content() or "").strip() self.logger.info(f"[{self.name}] response: {text!r}") + structured_content: dict | None = None + if structured_model is not None: + structured_resp = await self.llm.generate_structured_output( + agent.state.context, + structured_model=structured_model, + ) + structured_content = structured_resp.content + self.context.response.success = True self.context.response.answer = text self.context.response.metadata.update( @@ -73,6 +86,7 @@ class LLMDemoStep(BaseStep): "sys_prompt": sys_prompt, "use_add_tool": use_add_tool, "response": text, + "structured_output": structured_content, }, ) return self.context.response diff --git a/reme4/steps/common/stream_llm_demo.py b/reme4/steps/common/stream_llm_demo.py new file mode 100644 index 00000000..3ff647fb --- /dev/null +++ b/reme4/steps/common/stream_llm_demo.py @@ -0,0 +1,136 @@ +"""Demo step that drives an Agent via BaseStep.llm with streaming output.""" + +import json + +from agentscope.agent import Agent +from agentscope.event import ( + TextBlockDeltaEvent, + ThinkingBlockDeltaEvent, + ToolCallStartEvent, + ToolCallDeltaEvent, + ToolResultTextDeltaEvent, + ModelCallEndEvent, + ReplyStartEvent, +) +from agentscope.message import Msg, TextBlock +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.state import AgentState +from agentscope.tool import FunctionTool, Toolkit + +from ..base_step import BaseStep +from ...components import R +from ...enumeration import ChunkEnum + + +def add(a: float, b: float) -> str: + """Add two numbers and return the sum. + + Args: + a: first addend + b: second addend + """ + return str(a + b) + + +@R.register("stream_llm_demo_step") +class StreamLLMDemoStep(BaseStep): + """Drive an Agent powered by ``self.llm`` with streaming output. + + When streaming is enabled on the context, text/thinking/tool events are + pushed chunk-by-chunk via ``self.context.add_stream_string``. + When streaming is not enabled, falls back to non-streaming ``agent.reply``. + + Inputs (from RuntimeContext): + query (str, required): user message content. + sys_prompt (str, optional): system prompt for the agent. + use_add_tool (bool, optional): register the ``add`` tool when True. + + Output (written to context.response.answer): + The agent's final reply text. + """ + + DEFAULT_SYS_PROMPT = "You are a concise assistant. Reply in one short sentence." + + async def execute(self): + assert self.context is not None + query: str = self.context.get("query", "") + sys_prompt: str = self.context.get("sys_prompt") or self.DEFAULT_SYS_PROMPT + use_add_tool: bool = bool(self.context.get("use_add_tool", False)) + + if not query: + self.context.response.success = False + self.context.response.answer = "Skipped: empty query" + return self.context.response + + toolkit = Toolkit(tools=[FunctionTool(add)]) if use_add_tool else Toolkit() + + agent = Agent( + name=self.name, + system_prompt=sys_prompt, + model=self.llm, + toolkit=toolkit, + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + input_msg = Msg(name="user", role="user", content=[TextBlock(text=query)]) + + if self.context.stream: + text = await self._stream_reply(agent, input_msg) + else: + response: Msg = await agent.reply(input_msg) + text = (response.get_text_content() or "").strip() + + self.logger.info(f"[{self.name}] response: {text!r}") + + self.context.response.success = True + self.context.response.answer = text + self.context.response.metadata.update( + { + "query": query, + "sys_prompt": sys_prompt, + "use_add_tool": use_add_tool, + "response": text, + }, + ) + return self.context.response + + async def _stream_reply(self, agent: Agent, input_msg: Msg) -> str: + """Stream agent reply events to the context stream queue.""" + assert self.context is not None + reply_msg: Msg | None = None + + async for event in agent.reply_stream(input_msg): + if isinstance(event, ReplyStartEvent): + reply_msg = Msg( + id=event.reply_id, + name=event.name, + role=event.role, + content=[], + ) + elif isinstance(event, TextBlockDeltaEvent): + await self.context.add_stream_string(event.delta, ChunkEnum.CONTENT) + elif isinstance(event, ThinkingBlockDeltaEvent): + await self.context.add_stream_string(event.delta, ChunkEnum.THINK) + elif isinstance(event, ToolCallStartEvent): + payload = json.dumps({"name": event.tool_call_name, "id": event.tool_call_id}) + await self.context.add_stream_string(payload, ChunkEnum.TOOL_CALL) + elif isinstance(event, ToolCallDeltaEvent): + await self.context.add_stream_string(event.delta, ChunkEnum.TOOL_CALL) + elif isinstance(event, ToolResultTextDeltaEvent): + await self.context.add_stream_string(event.delta, ChunkEnum.TOOL_RESULT) + elif isinstance(event, ModelCallEndEvent): + usage = json.dumps( + {"input_tokens": event.input_tokens, "output_tokens": event.output_tokens}, + ) + await self.context.add_stream_string(usage, ChunkEnum.USAGE) + + if reply_msg is not None: + reply_msg.append_event(event) + + if reply_msg is not None: + return (reply_msg.get_text_content() or "").strip() + return "" diff --git a/reme4/steps/evolve/_evolve.py b/reme4/steps/evolve/_evolve.py index 35e084f6..8a907e7e 100644 --- a/reme4/steps/evolve/_evolve.py +++ b/reme4/steps/evolve/_evolve.py @@ -2,9 +2,7 @@ import datetime import zoneinfo -from typing import Literal -from agentscope.agent import ReActAgent from agentscope.message import Msg @@ -26,18 +24,6 @@ def format_history(messages: list[Msg], include_timestamp: bool = True) -> str: if not text: continue speaker = msg.name or msg.role or "?" - header = f"[{speaker} @ {msg.timestamp}]" if include_timestamp else f"[{speaker}]" + header = f"[{speaker} @ {msg.created_at}]" if include_timestamp else f"[{speaker}]" lines.append(f"{header}\n{text}") return "\n\n".join(lines) or "(empty)" - - -class FlexReActAgent(ReActAgent): - """ReActAgent subclass that allows structured output without forcing tool_choice='required'.""" - - async def _reasoning( - self, - tool_choice: Literal["auto", "none", "required"] | None = None, - ) -> Msg: - if tool_choice == "required": - tool_choice = None - return await super()._reasoning(tool_choice) diff --git a/reme4/steps/evolve/auto_dream.py b/reme4/steps/evolve/auto_dream.py index 9b61f3ae..09511bcf 100644 --- a/reme4/steps/evolve/auto_dream.py +++ b/reme4/steps/evolve/auto_dream.py @@ -52,11 +52,13 @@ import zoneinfo from pathlib import Path from typing import Literal -from agentscope.message import Msg +from agentscope.agent import Agent +from agentscope.message import Msg, TextBlock +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.state import AgentState from agentscope.tool import Toolkit from pydantic import BaseModel, Field -from ._evolve import FlexReActAgent from ..base_step import BaseStep from ...components import R @@ -232,13 +234,11 @@ class Dreamer(BaseStep): def __init__( self, toolkit: Toolkit | None = None, - console_enabled: bool = False, timezone: str | None = None, **kwargs, ): super().__init__(**kwargs) self.toolkit = toolkit - self.console_enabled = console_enabled self.timezone = timezone def _now(self) -> datetime.datetime: @@ -255,7 +255,7 @@ class Dreamer(BaseStep): def _llm_available(self) -> bool: try: - return self.as_llm is not None + return self.llm is not None except Exception: return False @@ -287,18 +287,21 @@ class Dreamer(BaseStep): alongside its structured emission. """ toolkit = self._build_extract_toolkit() - agent = FlexReActAgent( + agent = Agent( name="reme_dreamer_extract", - model=self.as_llm, - sys_prompt=self.prompt_format( + model=self.llm, + system_prompt=self.prompt_format( "extract_system_prompt", vault_dir=str(vault_dir), buckets=", ".join(BUCKETS), ), - formatter=self.as_llm_formatter, toolkit=toolkit, + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), ) - agent.set_console_output_enabled(self.console_enabled) user_message = self.prompt_format( "extract_user_message", today=self._now().strftime("%Y-%m-%d"), @@ -306,13 +309,14 @@ class Dreamer(BaseStep): material_blob=material_blob, ) msg = await agent.reply( - Msg(name="reme", role="user", content=user_message), - structured_model=ExtractedUnits, + Msg(name="reme", role="user", content=[TextBlock(text=user_message)]), ) - # Structured output lands in msg.metadata as a dict matching ExtractedUnits. - # Empty / missing → no sub-units (Phase 2 will skip). - meta = msg.metadata if isinstance(msg.metadata, dict) else {} + structured_resp = await self.llm.generate_structured_output( + agent.state.context, + structured_model=ExtractedUnits, + ) + meta = structured_resp.content if isinstance(structured_resp.content, dict) else {} cleaned: list[dict] = [] for raw in meta.get("units") or []: if not isinstance(raw, dict): @@ -342,19 +346,22 @@ class Dreamer(BaseStep): bucket = unit.get("bucket") or "wiki" toolkit = self._build_integrate_toolkit() digest_dir = getattr(self.app_context.app_config, "digest_dir", "") - agent = FlexReActAgent( + agent = Agent( name=f"reme_dreamer_integrate_{unit.get('name', 'unit')}", - model=self.as_llm, - sys_prompt=self.prompt_format( + model=self.llm, + system_prompt=self.prompt_format( f"integrate_system_prompt_{bucket}", vault_dir=str(vault_dir), digest_dir=digest_dir, bucket=bucket, ), - formatter=self.as_llm_formatter, toolkit=toolkit, + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), ) - agent.set_console_output_enabled(self.console_enabled) user_message = self.prompt_format( "integrate_user_message", hint=hint or "(none)", @@ -363,12 +370,14 @@ class Dreamer(BaseStep): unit_summary=unit.get("summary", ""), material_blob=material_blob, ) - msg = await agent.reply( - Msg(name="reme", role="user", content=user_message), + await agent.reply( + Msg(name="reme", role="user", content=[TextBlock(text=user_message)]), + ) + structured_resp = await self.llm.generate_structured_output( + agent.state.context, structured_model=IntegrateOutcome, ) - meta = msg.metadata if isinstance(msg.metadata, dict) else {} - return IntegrateOutcome.model_validate(meta) + return IntegrateOutcome.model_validate(structured_resp.content) async def dream_one(self, path: str, hint: str = "") -> DreamResult: """Run the full extract + integrate pipeline on one vault-relative @@ -389,7 +398,7 @@ class Dreamer(BaseStep): used_llm=False, skipped=True, path=path, - error="no as_llm configured; dreaming requires an LLM", + error="no llm configured; dreaming requires an LLM", ) material_blob = _pack_material(self.file_store, path) diff --git a/reme4/steps/evolve/auto_memory.py b/reme4/steps/evolve/auto_memory.py index 4b70e311..4926cec6 100644 --- a/reme4/steps/evolve/auto_memory.py +++ b/reme4/steps/evolve/auto_memory.py @@ -17,8 +17,10 @@ Output (written to context.response): metadata: {path, created}. """ -from agentscope.agent import ReActAgent -from agentscope.message import Msg +from agentscope.agent import Agent +from agentscope.message import Msg, TextBlock +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.state import AgentState from agentscope.tool import Toolkit from ._evolve import format_history, now @@ -28,18 +30,23 @@ from ...components import R @R.register("auto_memory_step") class AutoMemoryStep(BaseStep): - """Record conversation facts into a daily note via a ReAct agent.""" + """Record conversation facts into a daily note via an Agent.""" - def __init__(self, console_enabled: bool = False, **kwargs): + def __init__(self, **kwargs): super().__init__(**kwargs) - self.console_enabled = console_enabled self.agent_tools: list[str] = ["read", "edit", "frontmatter_update", "write"] + @staticmethod + def _to_msg(item) -> Msg: + if isinstance(item, Msg): + return item + if isinstance(item, dict) and isinstance(item.get("content"), str): + item = {**item, "content": [{"type": "text", "text": item["content"]}]} + return Msg.model_validate(item) + async def execute(self): assert self.context is not None - messages: list[Msg] = [ - item if isinstance(item, Msg) else Msg.from_dict(item) for item in self.context.get("messages", []) - ] + messages: list[Msg] = [self._to_msg(item) for item in self.context.get("messages", [])] session_id: str = self.context.get("session_id", "") memory_hint: str = self.context.get("memory_hint", "") current = now(self.context.get("timezone")) @@ -62,14 +69,17 @@ class AutoMemoryStep(BaseStep): for job_name in self.agent_tools: self.add_as_tool(toolkit, job_name) - agent = ReActAgent( + agent = Agent( name="auto_memory", - model=self.as_llm, - sys_prompt=self.prompt_format("system_prompt"), - formatter=self.as_llm_formatter, + model=self.llm, + system_prompt=self.prompt_format("system_prompt"), toolkit=toolkit, + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), ) - agent.set_console_output_enabled(self.console_enabled) template_key = "user_message_create" if created else "user_message_update" user_message: str = self.prompt_format( @@ -81,7 +91,7 @@ class AutoMemoryStep(BaseStep): history=format_history(messages), ) - final_msg: Msg = await agent.reply(Msg(name="reme", role="user", content=user_message)) + final_msg: Msg = await agent.reply(Msg(name="reme", role="user", content=[TextBlock(text=user_message)])) self.context.response.success = True self.context.response.answer = (final_msg.get_text_content() or "").strip() diff --git a/reme4/utils/__init__.py b/reme4/utils/__init__.py index c18d4de7..2bbff23f 100644 --- a/reme4/utils/__init__.py +++ b/reme4/utils/__init__.py @@ -13,6 +13,7 @@ from .logger_utils import get_logger from .logo_utils import print_logo from .service_utils import find_reme, locate_reme, precheck_start, cli_find_reme from .similarity_utils import cosine_similarity, batch_cosine_similarity +from .token_utils import estimate_token_count __all__ = [ "hash_text", @@ -31,4 +32,5 @@ __all__ = [ "cli_find_reme", "cosine_similarity", "batch_cosine_similarity", + "estimate_token_count", ] diff --git a/reme4/utils/token_utils.py b/reme4/utils/token_utils.py new file mode 100644 index 00000000..6dbee7e0 --- /dev/null +++ b/reme4/utils/token_utils.py @@ -0,0 +1,10 @@ +"""Token count estimation.""" + + +def estimate_token_count( + text: str, + estimate_divisor: float = 4, + encoding: str = "utf-8", +) -> int: + """Estimate the number of tokens in *text* by byte length.""" + return int(len(text.encode(encoding)) / estimate_divisor + 0.5) diff --git a/tests4/integration/test_as_llm.py b/tests4/integration/test_as_llm.py deleted file mode 100644 index 909caab6..00000000 --- a/tests4/integration/test_as_llm.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Integration tests: drive ReActAgent through LLMDemoStep + Application wiring. - -Requires LLM_API_KEY (and optionally LLM_BASE_URL / LLM_MODEL_NAME) in the -environment or a .env file at the repo root. Hits the real Anthropic API. -""" - -import asyncio -import os -import tempfile - -from reme4 import Application -from reme4.config import resolve_app_config -from reme4.steps.common.llm_demo import LLMDemoStep -from reme4.utils import load_env - -load_env() - - -class _temp_chdir: - """chdir to path for the duration of the block; restore on exit.""" - - def __init__(self, path): - self.path = path - self._old = None - - def __enter__(self): - self._old = os.getcwd() - os.chdir(self.path) - return self - - def __exit__(self, *exc): - os.chdir(self._old) - - -async def _make_app() -> Application: - """Build and start an Application from the default config (LLM wired via env vars).""" - cfg = resolve_app_config(log_to_console=False, log_to_file=False, enable_logo=False) - app = Application(**cfg) - await app.start() - return app - - -def test_llm_demo_step_basic_chat(): - """LLMDemoStep drives ReActAgent through self.as_llm/as_llm_formatter.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): - app = await _make_app() - try: - step = LLMDemoStep(app_context=app.context) - response = await step( - query="What is 1 + 1? Reply with just the number.", - ) - text = (response.answer or "").strip() - print(f"\n[basic_chat] response: {text!r}") - assert text, "Empty assistant response" - assert "2" in text, f"Expected '2' in response, got: {text!r}" - print("✓ test_llm_demo_step_basic_chat passed") - finally: - await app.close() - - asyncio.run(run()) - - -def test_llm_demo_step_with_tool(): - """LLMDemoStep registers the add tool and the agent invokes it.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): - app = await _make_app() - try: - step = LLMDemoStep(app_context=app.context) - response = await step( - query="Use the add tool to compute 21 + 21 and report the result.", - sys_prompt="Use the `add` tool whenever the user asks to add numbers.", - use_add_tool=True, - ) - text = (response.answer or "").strip() - print(f"\n[with_tool] response: {text!r}") - assert "42" in text, f"Expected '42' in response, got: {text!r}" - print("✓ test_llm_demo_step_with_tool passed") - finally: - await app.close() - - asyncio.run(run()) - - -if __name__ == "__main__": - print("=== LLMDemoStep + ReActAgent integration tests ===") - test_llm_demo_step_basic_chat() - test_llm_demo_step_with_tool() - print("\nAll integration tests passed!") diff --git a/tests4/integration/test_auto_memory.py b/tests4/integration/test_auto_memory.py index 2af6ca74..13b7d52d 100644 --- a/tests4/integration/test_auto_memory.py +++ b/tests4/integration/test_auto_memory.py @@ -20,7 +20,7 @@ import tempfile from datetime import date as _date from pathlib import Path -from agentscope.agent import ReActAgent +from agentscope.agent import Agent from reme4 import Application from reme4.config import resolve_app_config @@ -168,7 +168,7 @@ def _read_text(p: Path) -> str: class _AgentMemoryRecorder: - """Monkey-patches ReActAgent.__init__ to capture every agent created inside + """Monkey-patches Agent.__init__ to capture every agent created inside the ``with`` block, then dumps each agent's memory to a jsonl file in DUMP_DIR on exit. """ @@ -177,13 +177,13 @@ class _AgentMemoryRecorder: """init""" self.dump_dir = dump_dir self.prefix = prefix - self.agents: list[ReActAgent] = [] + self.agents: list[Agent] = [] self._orig_init = None self.dumped_paths: list[Path] = [] def __enter__(self): - """Monkey-patch ReActAgent.__init__.""" - self._orig_init = ReActAgent.__init__ + """Monkey-patch Agent.__init__.""" + self._orig_init = Agent.__init__ agents = self.agents orig = self._orig_init @@ -191,25 +191,25 @@ class _AgentMemoryRecorder: orig(agent_self, *args, **kwargs) agents.append(agent_self) - ReActAgent.__init__ = _capturing_init + Agent.__init__ = _capturing_init return self def __exit__(self, *exc): """Restore the original __init__.""" - ReActAgent.__init__ = self._orig_init + Agent.__init__ = self._orig_init async def dump(self) -> list[Path]: - """Dump all agent memories.""" + """Dump all agent context histories.""" for stale in self.dump_dir.glob(f"{self.prefix}_*.jsonl"): stale.unlink() for idx, agent in enumerate(self.agents, 1): - messages = await agent.memory.get_memory() + messages = agent.state.context name = getattr(agent, "name", "agent") or "agent" out_path = self.dump_dir / f"{self.prefix}_{idx:02d}_{name}.jsonl" with out_path.open("w", encoding="utf-8") as f: for msg in messages: - f.write(json.dumps(msg.to_dict(), ensure_ascii=False, default=str) + "\n") + f.write(json.dumps(msg.model_dump(), ensure_ascii=False, default=str) + "\n") self.dumped_paths.append(out_path) return self.dumped_paths diff --git a/tests4/integration/test_embedding.py b/tests4/integration/test_embedding.py new file mode 100644 index 00000000..b04a7200 --- /dev/null +++ b/tests4/integration/test_embedding.py @@ -0,0 +1,198 @@ +"""Integration tests: drive embedding store through Application wiring. + +Requires EMBEDDING_API_KEY (and optionally EMBEDDING_BACKEND / EMBEDDING_BASE_URL / +EMBEDDING_MODEL_NAME) in the environment or a .env file at the repo root. +Hits the real embedding API. +""" + +import asyncio +import os +import tempfile + +import numpy as np + +from reme4 import Application +from reme4.config import resolve_app_config +from reme4.enumeration import ComponentEnum +from reme4.schema import EmbNode +from reme4.utils import cosine_similarity, load_env + +load_env() + + +class _temp_chdir: + """chdir to path for the duration of the block; restore on exit.""" + + def __init__(self, path): + self.path = path + self._old = None + + def __enter__(self): + self._old = os.getcwd() + os.chdir(self.path) + return self + + def __exit__(self, *exc): + os.chdir(self._old) + + +async def _make_app() -> Application: + """Build and start an Application from the default config.""" + cfg = resolve_app_config(log_to_console=False, log_to_file=False, enable_logo=False) + app = Application(**cfg) + await app.start() + return app + + +def test_embedding_health_check(): + """health_check() returns True with a working API key.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + store = app.context.components[ComponentEnum.EMBEDDING_STORE]["default"] + result = await store.health_check(timeout=10.0) + assert result is True, f"health_check returned {result}" + assert store.is_healthy is True + print("✓ test_embedding_health_check passed") + finally: + await app.close() + + asyncio.run(run()) + + +def test_embedding_single_text(): + """Single text produces a valid embedding vector.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + store = app.context.components[ComponentEnum.EMBEDDING_STORE]["default"] + emb = await store.get_embedding("Hello, world!") + assert emb is not None, "get_embedding returned None" + assert emb.shape == (store.dimensions,), f"shape {emb.shape} != ({store.dimensions},)" + assert emb.dtype == np.float16, f"dtype {emb.dtype} != float16" + assert np.linalg.norm(emb) > 0, "embedding is a zero vector" + print(f"\n [single] len={len(emb)}, first5={emb[:5].tolist()}") + print("✓ test_embedding_single_text passed") + finally: + await app.close() + + asyncio.run(run()) + + +def test_embedding_multiple_texts(): + """Batch embedding returns correct count and shapes.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + store = app.context.components[ComponentEnum.EMBEDDING_STORE]["default"] + texts = ["cat", "dog", "house"] + results = await store.get_embeddings(texts) + assert len(results) == 3, f"expected 3 results, got {len(results)}" + for i, emb in enumerate(results): + assert emb is not None, f"result[{i}] is None" + assert emb.shape == (store.dimensions,), f"result[{i}] shape mismatch" + assert np.linalg.norm(emb) > 0, f"result[{i}] is a zero vector" + print(f"\n [{texts[i]}] len={len(emb)}, first5={emb[:5].tolist()}") + print("✓ test_embedding_multiple_texts passed") + finally: + await app.close() + + asyncio.run(run()) + + +def test_embedding_cache_hit(): + """Same text returns cached result on second call.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + store = app.context.components[ComponentEnum.EMBEDDING_STORE]["default"] + text = "test caching behavior" + emb1 = await store.get_embedding(text) + cache_size_after_first = len(store._cache) # pylint: disable=protected-access + + emb2 = await store.get_embedding(text) + cache_size_after_second = len(store._cache) # pylint: disable=protected-access + + assert emb1 is not None and emb2 is not None + assert cache_size_after_second == cache_size_after_first, "cache grew on second call" + assert np.array_equal(emb1, emb2), "cached embedding differs from original" + print(f"\n [cache] len={len(emb1)}, first5={emb1[:5].tolist()}") + print("✓ test_embedding_cache_hit passed") + finally: + await app.close() + + asyncio.run(run()) + + +def test_embedding_similarity(): + """Semantically similar texts have higher cosine similarity.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + store = app.context.components[ComponentEnum.EMBEDDING_STORE]["default"] + text_a = "The cat sat on the mat" + text_b = "A kitten rested on the rug" + text_c = "Quantum computing uses qubits for parallel computation" + + results = await store.get_embeddings([text_a, text_b, text_c]) + emb_a, emb_b, emb_c = results + + sim_ab = cosine_similarity(emb_a.tolist(), emb_b.tolist()) + sim_ac = cosine_similarity(emb_a.tolist(), emb_c.tolist()) + + print(f"\n sim(cat/kitten) = {sim_ab:.4f}") + print(f" sim(cat/quantum) = {sim_ac:.4f}") + + assert sim_ab > 0.4, f"similar texts sim={sim_ab:.4f}, expected > 0.4" + assert sim_ab > sim_ac, f"similar pair ({sim_ab:.4f}) not > dissimilar ({sim_ac:.4f})" + print("✓ test_embedding_similarity passed") + finally: + await app.close() + + asyncio.run(run()) + + +def test_embedding_node_embeddings(): + """get_node_embeddings fills embedding field on EmbNode objects.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + store = app.context.components[ComponentEnum.EMBEDDING_STORE]["default"] + nodes = [ + EmbNode(text="first node text"), + EmbNode(text="second node text"), + ] + result = await store.get_node_embeddings(nodes) + assert result is nodes, "get_node_embeddings should return the same list" + for i, node in enumerate(nodes): + assert node.embedding is not None, f"node[{i}].embedding is None" + assert node.embedding.shape == (store.dimensions,), f"node[{i}] shape mismatch" + print(f"\n [node{i}] len={len(node.embedding)}, first5={node.embedding[:5].tolist()}") + print("✓ test_embedding_node_embeddings passed") + finally: + await app.close() + + asyncio.run(run()) + + +if __name__ == "__main__": + print("=== Embedding integration tests ===") + test_embedding_health_check() + test_embedding_single_text() + test_embedding_multiple_texts() + test_embedding_cache_hit() + test_embedding_similarity() + test_embedding_node_embeddings() + print("\nAll embedding integration tests passed!") diff --git a/tests4/integration/test_llm.py b/tests4/integration/test_llm.py new file mode 100644 index 00000000..ef23c838 --- /dev/null +++ b/tests4/integration/test_llm.py @@ -0,0 +1,175 @@ +"""Integration tests: drive Agent through LLMDemoStep + Application wiring. + +Requires LLM_API_KEY (and optionally LLM_BASE_URL / LLM_MODEL_NAME) in the +environment or a .env file at the repo root. Hits the real Anthropic API. +""" + +import asyncio +import os +import tempfile +from typing import Literal + +from pydantic import BaseModel, Field + +from reme4 import Application +from reme4.config import resolve_app_config +from reme4.steps.common.llm_demo import LLMDemoStep +from reme4.utils import load_env + +load_env() + + +class _temp_chdir: + """chdir to path for the duration of the block; restore on exit.""" + + def __init__(self, path): + self.path = path + self._old = None + + def __enter__(self): + self._old = os.getcwd() + os.chdir(self.path) + return self + + def __exit__(self, *exc): + os.chdir(self._old) + + +async def _make_app() -> Application: + """Build and start an Application from the default config (LLM wired via env vars).""" + cfg = resolve_app_config(log_to_console=False, log_to_file=False, enable_logo=False) + app = Application(**cfg) + await app.start() + return app + + +def test_llm_demo_step_basic_chat(): + """LLMDemoStep drives Agent through self.llm.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + step = LLMDemoStep(app_context=app.context) + response = await step( + query="What is 1 + 1? Reply with just the number.", + ) + text = (response.answer or "").strip() + print(f"\n[basic_chat] response: {text!r}") + assert text, "Empty assistant response" + assert "2" in text, f"Expected '2' in response, got: {text!r}" + print("✓ test_llm_demo_step_basic_chat passed") + finally: + await app.close() + + asyncio.run(run()) + + +def test_llm_demo_step_with_tool(): + """LLMDemoStep registers the add tool and the agent invokes it.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + step = LLMDemoStep(app_context=app.context) + response = await step( + query="Use the add tool to compute 21 + 21 and report the result.", + sys_prompt="Use the `add` tool whenever the user asks to add numbers.", + use_add_tool=True, + ) + text = (response.answer or "").strip() + print(f"\n[with_tool] response: {text!r}") + assert "42" in text, f"Expected '42' in response, got: {text!r}" + print("✓ test_llm_demo_step_with_tool passed") + finally: + await app.close() + + asyncio.run(run()) + + +class MathResult(BaseModel): + """Structured output for a math computation.""" + + expression: str = Field(description="The math expression that was evaluated") + result: float = Field(description="The numeric result") + explanation: str = Field(description="Brief explanation of the computation") + + +class SentimentAnalysis(BaseModel): + """Structured output for sentiment analysis.""" + + sentiment: Literal["positive", "negative", "neutral"] = Field( + description="The overall sentiment of the text", + ) + confidence: float = Field( + description="Confidence score between 0 and 1", + ) + key_phrases: list[str] = Field( + description="Key phrases that indicate the sentiment", + ) + + +def test_llm_demo_step_structured_output(): + """LLMDemoStep generates structured output via generate_structured_output.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + step = LLMDemoStep(app_context=app.context) + response = await step( + query="What is 15 multiplied by 7? Show your work.", + sys_prompt="You are a math tutor. Solve the problem step by step.", + structured_model=MathResult, + ) + structured = response.metadata.get("structured_output") + print(f"\n[structured_output] result: {structured}") + assert structured is not None, "structured_output should not be None" + assert "result" in structured, "structured_output should have 'result' field" + assert structured["result"] == 105, f"Expected result=105, got: {structured['result']}" + assert "expression" in structured, "structured_output should have 'expression' field" + assert "explanation" in structured, "structured_output should have 'explanation' field" + print("✓ test_llm_demo_step_structured_output passed") + finally: + await app.close() + + asyncio.run(run()) + + +def test_llm_demo_step_structured_output_enum(): + """LLMDemoStep structured output with Literal/enum fields.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + step = LLMDemoStep(app_context=app.context) + response = await step( + query="Analyze the sentiment: 'I absolutely love this product! It exceeded all my expectations.'", + sys_prompt="You are a sentiment analysis expert. Analyze the given text.", + structured_model=SentimentAnalysis, + ) + structured = response.metadata.get("structured_output") + print(f"\n[structured_enum] result: {structured}") + assert structured is not None, "structured_output should not be None" + assert ( + structured["sentiment"] == "positive" + ), f"Expected sentiment='positive', got: {structured['sentiment']}" + assert 0 <= structured["confidence"] <= 1, f"Confidence should be 0-1, got: {structured['confidence']}" + assert isinstance(structured["key_phrases"], list), "key_phrases should be a list" + assert len(structured["key_phrases"]) > 0, "key_phrases should not be empty" + print("✓ test_llm_demo_step_structured_output_enum passed") + finally: + await app.close() + + asyncio.run(run()) + + +if __name__ == "__main__": + print("=== LLMDemoStep + Agent integration tests ===") + test_llm_demo_step_basic_chat() + test_llm_demo_step_with_tool() + test_llm_demo_step_structured_output() + test_llm_demo_step_structured_output_enum() + print("\nAll integration tests passed!") diff --git a/tests4/integration/test_stream_llm.py b/tests4/integration/test_stream_llm.py new file mode 100644 index 00000000..1afd4301 --- /dev/null +++ b/tests4/integration/test_stream_llm.py @@ -0,0 +1,217 @@ +"""Integration tests: stream Agent output through StreamLLMDemoStep. + +Requires LLM_API_KEY (and optionally LLM_BASE_URL / LLM_MODEL_NAME) in the +environment or a .env file at the repo root. Hits the real LLM API. +""" + +import asyncio +import os +import tempfile + +from reme4 import Application +from reme4.config import resolve_app_config +from reme4.enumeration import ChunkEnum +from reme4.schema import StreamChunk +from reme4.steps.common.stream_llm_demo import StreamLLMDemoStep +from reme4.utils import load_env + +load_env() + + +class _temp_chdir: + """chdir to path for the duration of the block; restore on exit.""" + + def __init__(self, path): + self.path = path + self._old = None + + def __enter__(self): + self._old = os.getcwd() + os.chdir(self.path) + return self + + def __exit__(self, *exc): + os.chdir(self._old) + + +async def _make_app() -> Application: + """Build and start an Application from the default config.""" + cfg = resolve_app_config(log_to_console=False, log_to_file=False, enable_logo=False) + app = Application(**cfg) + await app.start() + return app + + +async def _test_stream_llm_basic_chat(): + """StreamLLMDemoStep streams text chunks via add_stream_string.""" + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + step = StreamLLMDemoStep(app_context=app.context) + queue: asyncio.Queue = asyncio.Queue() + + response = await step( + stream_queue=queue, + query="What is 1 + 1? Reply with just the number.", + ) + + # Collect all chunks from the queue + chunks = [] + while not queue.empty(): + chunks.append(await queue.get()) + + # Should have received CONTENT chunks + content_chunks = [c for c in chunks if c.chunk_type == ChunkEnum.CONTENT] + print(f"\n[stream_basic] got {len(content_chunks)} CONTENT chunks") + assert len(content_chunks) > 0, "Expected at least one CONTENT chunk" + + # Final answer should be populated + text = (response.answer or "").strip() + print(f"[stream_basic] final answer: {text!r}") + assert text, "Empty assistant response" + assert "2" in text, f"Expected '2' in response, got: {text!r}" + + # Concatenated stream text should match the final answer + streamed_text = "".join(c.chunk for c in content_chunks) + assert streamed_text.strip() == text, f"Stream text mismatch: {streamed_text!r} vs {text!r}" + print("✓ test_stream_llm_basic_chat passed") + finally: + await app.close() + + +async def _test_stream_llm_with_tool(): + """StreamLLMDemoStep streams tool call events when tools are used.""" + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + step = StreamLLMDemoStep(app_context=app.context) + queue: asyncio.Queue = asyncio.Queue() + + response = await step( + stream_queue=queue, + query="Use the add tool to compute 21 + 21 and report the result.", + sys_prompt="Use the `add` tool whenever the user asks to add numbers.", + use_add_tool=True, + ) + + # Collect all chunks + chunks = [] + while not queue.empty(): + chunks.append(await queue.get()) + + tool_call_chunks = [c for c in chunks if c.chunk_type == ChunkEnum.TOOL_CALL] + tool_result_chunks = [c for c in chunks if c.chunk_type == ChunkEnum.TOOL_RESULT] + content_chunks = [c for c in chunks if c.chunk_type == ChunkEnum.CONTENT] + + print(f"\n[stream_tool] TOOL_CALL chunks: {len(tool_call_chunks)}") + print(f"[stream_tool] TOOL_RESULT chunks: {len(tool_result_chunks)}") + print(f"[stream_tool] CONTENT chunks: {len(content_chunks)}") + + assert len(tool_call_chunks) > 0, "Expected TOOL_CALL chunks" + assert len(tool_result_chunks) > 0, "Expected TOOL_RESULT chunks" + + text = (response.answer or "").strip() + print(f"[stream_tool] final answer: {text!r}") + assert "42" in text, f"Expected '42' in response, got: {text!r}" + print("✓ test_stream_llm_with_tool passed") + finally: + await app.close() + + +async def _test_stream_llm_fallback_no_stream(): + """Without stream_queue, falls back to non-streaming reply.""" + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + step = StreamLLMDemoStep(app_context=app.context) + response = await step( + query="What is 1 + 1? Reply with just the number.", + ) + text = (response.answer or "").strip() + print(f"\n[fallback] response: {text!r}") + assert text, "Empty assistant response" + assert "2" in text, f"Expected '2' in response, got: {text!r}" + print("✓ test_stream_llm_fallback_no_stream passed") + finally: + await app.close() + + +def test_stream_llm_basic_chat(): + """StreamLLMDemoStep streams text chunks via add_stream_string.""" + asyncio.run(_test_stream_llm_basic_chat()) + + +def test_stream_llm_with_tool(): + """StreamLLMDemoStep streams tool call events when tools are used.""" + asyncio.run(_test_stream_llm_with_tool()) + + +def test_stream_llm_fallback_no_stream(): + """Without stream_queue, falls back to non-streaming reply.""" + asyncio.run(_test_stream_llm_fallback_no_stream()) + + +async def _demo_stream_print(): + """Real-time streaming print demo — ask a longer question to see chunked output.""" + import sys # pylint: disable=import-outside-toplevel,redefined-outer-name + + with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp): + app = await _make_app() + try: + step = StreamLLMDemoStep(app_context=app.context) + queue: asyncio.Queue = asyncio.Queue() + + query = ( + "Please explain in detail how neural networks learn through backpropagation. " + "Include the chain rule, gradient descent, and give a concrete example with numbers." + ) + + async def consumer(): + """Print chunks to terminal in real-time.""" + while True: + chunk = await queue.get() + if chunk.done: + break + if chunk.chunk_type == ChunkEnum.CONTENT: + sys.stdout.write(chunk.chunk) + sys.stdout.flush() + elif chunk.chunk_type == ChunkEnum.THINK: + sys.stdout.write(f"\033[2m{chunk.chunk}\033[0m") + sys.stdout.flush() + elif chunk.chunk_type == ChunkEnum.TOOL_CALL: + sys.stdout.write(f"\n\033[33m[tool_call] {chunk.chunk}\033[0m") + sys.stdout.flush() + elif chunk.chunk_type == ChunkEnum.TOOL_RESULT: + sys.stdout.write(f"\033[32m{chunk.chunk}\033[0m") + sys.stdout.flush() + print() + + consumer_task = asyncio.create_task(consumer()) + + await step( + stream_queue=queue, + query=query, + sys_prompt="You are a knowledgeable AI teacher. Explain concepts thoroughly.", + ) + # Signal done so consumer exits + await queue.put(StreamChunk(chunk_type=ChunkEnum.DONE, chunk="", done=True)) + await consumer_task + finally: + await app.close() + + +async def _run_all(): + print("=== StreamLLMDemoStep integration tests ===") + await _test_stream_llm_basic_chat() + await _test_stream_llm_with_tool() + await _test_stream_llm_fallback_no_stream() + print("\nAll stream integration tests passed!") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "demo": + asyncio.run(_demo_stream_print()) + else: + asyncio.run(_run_all()) diff --git a/tests4/unit/test_background_steps.py b/tests4/unit/test_background_steps.py index 0b288a50..b3cd50bd 100644 --- a/tests4/unit/test_background_steps.py +++ b/tests4/unit/test_background_steps.py @@ -60,7 +60,7 @@ async def _make_scan_step( suffix_filters: list[str] | None = None, recursive: bool = True, ) -> tuple[ScanChangesStep, RuntimeContext, LocalFileStore, ChunkedFileParser]: - fs = LocalFileStore(name="test_store", embedding_model="") + fs = LocalFileStore(name="test_store", embedding_store="") parser = ChunkedFileParser() await fs.start() await parser.start() diff --git a/tests4/unit/test_base_component.py b/tests4/unit/test_base_component.py new file mode 100644 index 00000000..667de251 --- /dev/null +++ b/tests4/unit/test_base_component.py @@ -0,0 +1,344 @@ +"""Tests for BaseComponent, Dependency, and ComponentMixin.""" + +# pylint: disable=protected-access,missing-function-docstring,missing-class-docstring,attribute-defined-outside-init + +import asyncio +import os +import tempfile + +import pytest + +from reme4.components.base_component import BaseComponent, ComponentMixin, Dependency +from reme4.enumeration import ComponentEnum + + +# -- Test subclasses ---------------------------------------------------------- + + +class StubComponent(BaseComponent): + component_type = ComponentEnum.FILE_PARSER + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.start_count = 0 + self.close_count = 0 + + async def _start(self): + self.start_count += 1 + + async def _close(self): + self.close_count += 1 + + +class DepTarget(BaseComponent): + component_type = ComponentEnum.KEYWORD_INDEX + + +class RequiredDepTarget(BaseComponent): + component_type = ComponentEnum.FILE_GRAPH + + +# -- Dependency --------------------------------------------------------------- + + +def test_dependency_repr_optional(): + dep = Dependency(ComponentEnum.FILE_PARSER, "my_parser", optional=True) + assert "?" in repr(dep) + assert "file_parser" in repr(dep) + + +def test_dependency_repr_required(): + dep = Dependency(ComponentEnum.FILE_PARSER, "my_parser", optional=False) + assert "?" not in repr(dep) + + +def test_dependency_getattr_raises(): + dep = Dependency(ComponentEnum.FILE_PARSER, "my_parser") + with pytest.raises(RuntimeError, match="accessed before start"): + _ = dep.some_method + + +# -- bind --------------------------------------------------------------------- + + +def test_bind_returns_none_for_empty_name(): + result = BaseComponent.bind(None, DepTarget) + assert result is None + + result = BaseComponent.bind("", DepTarget) + assert result is None + + +def test_bind_returns_dependency_placeholder(): + result = BaseComponent.bind("my_index", DepTarget) + assert isinstance(result, Dependency) + assert result.ctype == ComponentEnum.KEYWORD_INDEX + assert result.name == "my_index" + + +def test_bind_rejects_base_component_type(): + class BadTarget(BaseComponent): + component_type = ComponentEnum.BASE + + with pytest.raises(TypeError, match="non-BASE"): + BaseComponent.bind("x", BadTarget) + + +def test_bind_rejects_no_component_type(): + class NoType: + pass + + with pytest.raises(TypeError, match="non-BASE"): + BaseComponent.bind("x", NoType) + + +def test_bind_with_default_factory(): + def factory(): + return DepTarget(name="default") + + result = BaseComponent.bind("idx", DepTarget, default_factory=factory) + assert isinstance(result, Dependency) + assert result.default_factory is factory + + +def test_bind_optional_flag(): + dep = BaseComponent.bind("idx", DepTarget, optional=False) + assert dep.optional is False + + +# -- dependencies property ---------------------------------------------------- + + +def test_dependencies_lists_unresolved(): + comp = StubComponent() + comp.dep1 = Dependency(ComponentEnum.KEYWORD_INDEX, "a") + comp.dep2 = Dependency(ComponentEnum.FILE_GRAPH, "b") + comp.normal_attr = "not a dep" + deps = comp.dependencies + assert len(deps) == 2 + + +# -- lifecycle ---------------------------------------------------------------- + + +def test_start_close_idempotent(): + async def run(): + comp = StubComponent() + await comp.start() + await comp.start() + assert comp.start_count == 1 + assert comp.is_started is True + + await comp.close() + await comp.close() + assert comp.close_count == 1 + assert comp.is_started is False + + asyncio.run(run()) + + +def test_restart(): + async def run(): + comp = StubComponent() + await comp.start() + await comp.restart() + assert comp.start_count == 2 + assert comp.close_count == 1 + assert comp.is_started is True + await comp.close() + + asyncio.run(run()) + + +def test_async_context_manager(): + async def run(): + comp = StubComponent() + async with comp as c: + assert c is comp + assert comp.is_started is True + assert comp.is_started is False + + asyncio.run(run()) + + +# -- standalone resolution ---------------------------------------------------- + + +def test_resolve_standalone_optional_becomes_none(): + async def run(): + comp = StubComponent() + comp.dep = BaseComponent.bind("idx", DepTarget) + await comp.start() + assert comp.dep is None + await comp.close() + + asyncio.run(run()) + + +def test_resolve_standalone_with_default_factory(): + async def run(): + comp = StubComponent() + comp.dep = BaseComponent.bind( + "idx", + DepTarget, + default_factory=lambda: DepTarget(name="auto"), + ) + await comp.start() + assert isinstance(comp.dep, DepTarget) + assert comp.dep.name == "auto" + assert comp.dep in comp._owned + await comp.close() + + asyncio.run(run()) + + +def test_resolve_standalone_required_no_factory_keeps_placeholder(): + async def run(): + comp = StubComponent() + comp.dep = BaseComponent.bind("idx", DepTarget, optional=False) + await comp.start() + assert isinstance(comp.dep, Dependency) + await comp.close() + + asyncio.run(run()) + + +# -- owned component lifecycle cascade ---------------------------------------- + + +def test_owned_components_started_and_closed(): + async def run(): + owned = StubComponent(name="owned") + parent = StubComponent(name="parent") + parent.dep = BaseComponent.bind( + "sub", + StubComponent, + default_factory=lambda: owned, + ) + await parent.start() + assert owned.is_started is True + + await parent.close() + assert owned.is_started is False + + asyncio.run(run()) + + +# -- context-bound resolution ------------------------------------------------- + + +def test_resolve_from_context(): + async def run(): + from reme4.components.application_context import ApplicationContext + + target = DepTarget(name="real_index") + ctx = ApplicationContext() + ctx.components = {ComponentEnum.KEYWORD_INDEX: {"real_index": target}} + + comp = StubComponent(app_context=ctx) + comp.dep = BaseComponent.bind("real_index", DepTarget) + await comp.start() + assert comp.dep is target + await comp.close() + + asyncio.run(run()) + + +def test_resolve_from_context_optional_missing(): + async def run(): + from reme4.components.application_context import ApplicationContext + + ctx = ApplicationContext() + ctx.components = {} + comp = StubComponent(app_context=ctx) + comp.dep = BaseComponent.bind("missing", DepTarget, optional=True) + await comp.start() + assert comp.dep is None + await comp.close() + + asyncio.run(run()) + + +def test_resolve_from_context_required_missing_raises(): + async def run(): + from reme4.components.application_context import ApplicationContext + + ctx = ApplicationContext() + ctx.components = {} + comp = StubComponent(app_context=ctx) + comp.dep = BaseComponent.bind("missing", RequiredDepTarget, optional=False) + with pytest.raises(ValueError, match="not found"): + await comp.start() + + asyncio.run(run()) + + +# -- ComponentMixin paths ----------------------------------------------------- + + +def test_vault_path_no_context(): + mixin = ComponentMixin() + from pathlib import Path + + assert mixin.vault_path == Path.cwd() + + +def test_to_vault_relative_inside_vault(): + with tempfile.TemporaryDirectory() as tmpdir: + old_cwd = os.getcwd() + os.chdir(tmpdir) + try: + mixin = ComponentMixin() + abs_path = mixin.vault_path / "sub" / "file.md" + rel = mixin.to_vault_relative(abs_path) + assert rel == str(abs_path.relative_to(mixin.vault_path)) + finally: + os.chdir(old_cwd) + + +def test_to_vault_relative_outside_vault(): + mixin = ComponentMixin() + result = mixin.to_vault_relative("/some/other/path") + assert result == "/some/other/path" + + +# -- vault metadata paths ----------------------------------------------------- + + +def test_vault_metadata_path_no_context(): + comp = StubComponent() + assert comp.vault_metadata_path.name == "metadata" + + +def test_component_metadata_path(): + comp = StubComponent() + assert comp.component_metadata_path.name == ComponentEnum.FILE_PARSER.value + + +if __name__ == "__main__": + print("\n=== BaseComponent Tests ===") + test_dependency_repr_optional() + test_dependency_repr_required() + test_dependency_getattr_raises() + test_bind_returns_none_for_empty_name() + test_bind_returns_dependency_placeholder() + test_bind_rejects_base_component_type() + test_bind_rejects_no_component_type() + test_bind_with_default_factory() + test_bind_optional_flag() + test_dependencies_lists_unresolved() + test_start_close_idempotent() + test_restart() + test_async_context_manager() + test_resolve_standalone_optional_becomes_none() + test_resolve_standalone_with_default_factory() + test_resolve_standalone_required_no_factory_keeps_placeholder() + test_owned_components_started_and_closed() + test_resolve_from_context() + test_resolve_from_context_optional_missing() + test_resolve_from_context_required_missing_raises() + test_vault_path_no_context() + test_to_vault_relative_outside_vault() + test_vault_metadata_path_no_context() + test_component_metadata_path() + print("\n所有测试通过!") diff --git a/tests4/unit/test_common_steps.py b/tests4/unit/test_common_steps.py index 136f8bb5..b6bfe52c 100644 --- a/tests4/unit/test_common_steps.py +++ b/tests4/unit/test_common_steps.py @@ -71,7 +71,7 @@ def _node(path: str, links: list[tuple[str, str | None, str | None]] | None = No async def _make_store(nodes: list[FileNode]) -> LocalFileStore: """LocalFileStore seeded with the given graph nodes (no files on disk).""" - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() if nodes: await store.file_graph.upsert_nodes(nodes) diff --git a/tests4/unit/test_component_registry.py b/tests4/unit/test_component_registry.py new file mode 100644 index 00000000..31e39bfb --- /dev/null +++ b/tests4/unit/test_component_registry.py @@ -0,0 +1,151 @@ +"""Tests for ComponentRegistry.""" + +# pylint: disable=missing-function-docstring,missing-class-docstring,protected-access,unused-argument + +import pytest + +from reme4.components.base_component import BaseComponent +from reme4.components.component_registry import ComponentRegistry +from reme4.enumeration import ComponentEnum + + +class _DummyComponent(BaseComponent): + component_type = ComponentEnum.FILE_PARSER + + +class _AnotherComponent(BaseComponent): + component_type = ComponentEnum.KEYWORD_INDEX + + +class _NoComponentType: + pass + + +class _BaseComponentType(BaseComponent): + component_type = ComponentEnum.BASE + + +# -- register & get ----------------------------------------------------------- + + +def test_register_direct_with_explicit_name(): + reg = ComponentRegistry() + reg.register(_DummyComponent, "my_parser") + assert reg.get(ComponentEnum.FILE_PARSER, "my_parser") is _DummyComponent + + +def test_register_direct_defaults_to_class_name(): + reg = ComponentRegistry() + reg.register(_DummyComponent) + assert reg.get(ComponentEnum.FILE_PARSER, "_DummyComponent") is _DummyComponent + + +def test_register_decorator(): + reg = ComponentRegistry() + + @reg.register("alias") + class MyParser(BaseComponent): + component_type = ComponentEnum.FILE_PARSER + + assert reg.get(ComponentEnum.FILE_PARSER, "alias") is MyParser + + +def test_register_overwrite_warns(caplog): + reg = ComponentRegistry() + reg.register(_DummyComponent, "dup") + reg.register(_DummyComponent, "dup") + assert reg.get(ComponentEnum.FILE_PARSER, "dup") is _DummyComponent + + +def test_register_rejects_missing_component_type(): + reg = ComponentRegistry() + with pytest.raises(TypeError, match="ComponentEnum"): + reg.register(_NoComponentType, "bad") + + +def test_register_rejects_empty_name(): + reg = ComponentRegistry() + with pytest.raises(ValueError, match="empty"): + reg._do_register(_DummyComponent, "") + + +def test_register_rejects_non_class_non_string(): + reg = ComponentRegistry() + with pytest.raises(TypeError, match="Expected a class or string"): + reg.register(42) + + +# -- get_all ------------------------------------------------------------------ + + +def test_get_all_returns_copy(): + reg = ComponentRegistry() + reg.register(_DummyComponent, "a") + reg.register(_AnotherComponent, "b") + + parsers = reg.get_all(ComponentEnum.FILE_PARSER) + assert parsers == {"a": _DummyComponent} + + indexes = reg.get_all(ComponentEnum.KEYWORD_INDEX) + assert indexes == {"b": _AnotherComponent} + + # Mutating the copy doesn't affect the registry. + parsers["hacked"] = _DummyComponent + assert "hacked" not in reg.get_all(ComponentEnum.FILE_PARSER) + + +def test_get_all_unknown_type_returns_empty(): + reg = ComponentRegistry() + assert not reg.get_all(ComponentEnum.LLM) + + +# -- get (miss) --------------------------------------------------------------- + + +def test_get_nonexistent_returns_none(): + reg = ComponentRegistry() + assert reg.get(ComponentEnum.FILE_PARSER, "nope") is None + + +# -- unregister --------------------------------------------------------------- + + +def test_unregister_existing(): + reg = ComponentRegistry() + reg.register(_DummyComponent, "x") + assert reg.unregister(ComponentEnum.FILE_PARSER, "x") is True + assert reg.get(ComponentEnum.FILE_PARSER, "x") is None + + +def test_unregister_missing_returns_false(): + reg = ComponentRegistry() + assert reg.unregister(ComponentEnum.FILE_PARSER, "nope") is False + + +# -- clear -------------------------------------------------------------------- + + +def test_clear(): + reg = ComponentRegistry() + reg.register(_DummyComponent, "a") + reg.register(_AnotherComponent, "b") + reg.clear() + assert not reg.get_all(ComponentEnum.FILE_PARSER) + assert not reg.get_all(ComponentEnum.KEYWORD_INDEX) + + +if __name__ == "__main__": + print("\n=== ComponentRegistry Tests ===") + test_register_direct_with_explicit_name() + test_register_direct_defaults_to_class_name() + test_register_decorator() + test_register_rejects_missing_component_type() + test_register_rejects_empty_name() + test_register_rejects_non_class_non_string() + test_get_all_returns_copy() + test_get_all_unknown_type_returns_empty() + test_get_nonexistent_returns_none() + test_unregister_existing() + test_unregister_missing_returns_false() + test_clear() + print("\n所有测试通过!") diff --git a/tests4/unit/test_crud_steps.py b/tests4/unit/test_crud_steps.py index ae53e375..f9fb26ef 100644 --- a/tests4/unit/test_crud_steps.py +++ b/tests4/unit/test_crud_steps.py @@ -67,7 +67,7 @@ class temp_chdir: async def _make_store(files: dict[str, str] | None = None) -> LocalFileStore: """LocalFileStore seeded with files on disk + registered in the graph.""" - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() nodes: list[FileNode] = [] for rel, content in (files or {}).items(): diff --git a/tests4/unit/test_daily_steps.py b/tests4/unit/test_daily_steps.py index b194be47..f40c8adf 100644 --- a/tests4/unit/test_daily_steps.py +++ b/tests4/unit/test_daily_steps.py @@ -73,7 +73,7 @@ async def _make_store_with_dailies(entries: list[tuple[str, str, str]]) -> Local ``daily//.md`` with a minimal ``name``-only frontmatter — no opinionated status / lifecycle axes. """ - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() for day, session_id, body in entries: day_dir = Path.cwd() / "daily" / day @@ -156,7 +156,7 @@ def test_daily_list_returns_path_session_id_metadata(): async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() await _seed_note( "2026-05-18", @@ -212,7 +212,7 @@ def test_daily_list_empty_when_no_daily_dir(): async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() step = daily_list_step.DailyListStep(file_store=store) await step(date="2026-05-18") @@ -439,7 +439,7 @@ def test_day_index_lists_each_note(): async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() await _seed_note("2026-05-18", "alpha", name="Alpha Project") await _seed_note("2026-05-18", "beta", name="Beta Project") @@ -461,7 +461,7 @@ def test_day_index_includes_note_descriptions(): async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() cases = [ ("alpha", "Alpha Project", "实现 JWT auth 中间件,迁移 session middleware"), @@ -516,7 +516,7 @@ def test_day_index_preserves_user_content_outside_marker(): async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() await _seed_note("2026-05-18", "alpha") reindex = daily_reindex_step.DailyReindexStep(file_store=store) diff --git a/tests4/unit/test_file_store.py b/tests4/unit/test_file_store.py deleted file mode 100644 index f27234f8..00000000 --- a/tests4/unit/test_file_store.py +++ /dev/null @@ -1,572 +0,0 @@ -"""Tests for LocalFileStore and FaissLocalFileStore.""" - -# pylint: disable=protected-access - -import asyncio -import hashlib -import importlib.util -import os -import tempfile -import warnings - -import numpy as np - -from reme4.components.embedding import BaseEmbeddingModel -from reme4.components.file_store import FaissLocalFileStore, LocalFileStore -from reme4.schema import FileChunk, FileNode - -warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba") -warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources") - -_FAISS_AVAILABLE = importlib.util.find_spec("faiss") is not None - - -class FakeEmbeddingModel(BaseEmbeddingModel): - """Deterministic stub: each lowercased word adds 1.0 at hash(word) % dim.""" - - def __init__(self, dimensions: int = 8, **kwargs): - super().__init__(model_name="fake", dimensions=dimensions, enable_cache=False, **kwargs) - - async def _get_embeddings(self, input_text, **kwargs): - out = [] - for t in input_text: - v = np.zeros(self.dimensions, dtype=np.float32) - for w in t.lower().split(): - idx = int.from_bytes(hashlib.md5(w.encode()).digest()[:2], "big") % self.dimensions - v[idx] += 1.0 - out.append(v.tolist()) - return out - - async def health_check(self, timeout: float = 2.0) -> bool: - self.is_healthy = True - return True - - -class temp_chdir: - """Context manager to temporarily chdir into a path and restore on exit.""" - - def __init__(self, path): - self.path = path - self.old = None - - def __enter__(self): - self.old = os.getcwd() - os.chdir(self.path) - return self - - def __exit__(self, *exc): - os.chdir(self.old) - - -async def make_store(store_name: str = "test_store", **kwargs) -> LocalFileStore: - """Build a started LocalFileStore with embedding disabled (no OpenAI dep).""" - store = LocalFileStore(name=store_name, embedding_model="", **kwargs) - await store.start() - return store - - -def make_file( - path: str, - text: str, - chunk_count: int = 1, -) -> tuple[FileNode, list[FileChunk]]: - """Build a (FileNode, [FileChunk]) tuple ready for upsert_file.""" - chunks = [ - FileChunk(id=f"{path}::chunk{i}", path=path, text=f"{text} part{i}", start_line=i, end_line=i + 1) - for i in range(chunk_count) - ] - node = FileNode(path=path, st_mtime=1.0, chunk_ids=[c.id for c in chunks]) - return node, chunks - - -def test_upsert_single_file(): - """upsert_file with a one-element list stores chunks and node.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - - node, chunks = make_file("a.md", "hello world", chunk_count=2) - await store.upsert([(node, chunks)]) - - # Chunks landed in memory - assert len(store.file_chunks) == 2 - assert {c.path for c in store.file_chunks.values()} == {"a.md"} - # Node landed in graph - nodes = await store.get_nodes(["a.md"]) - assert len(nodes) == 1 - assert sorted(nodes[0].chunk_ids) == sorted([c.id for c in chunks]) - - await store.close() - print("✓ test_upsert_single_file passed") - - asyncio.run(run()) - - -def test_upsert_multiple_files(): - """upsert_file accepts a list of tuples and indexes them all.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - - files = [make_file("a.md", "alpha"), make_file("b.md", "beta")] - await store.upsert(files) - - assert len(store.file_chunks) == 2 - paths = {n.path for n in await store.get_nodes()} - assert paths == {"a.md", "b.md"} - - await store.close() - print("✓ test_upsert_multiple_files passed") - - asyncio.run(run()) - - -def test_upsert_replaces_old_chunks(): - """Re-upserting the same path points the node at the new chunk set.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - - n1, c1 = make_file("a.md", "v1", chunk_count=2) - await store.upsert([(n1, c1)]) - - # Different chunks for the same path - n2 = FileNode(path="a.md", st_mtime=2.0) - c2 = [FileChunk(id="a.md::new", path="a.md", text="v2 only", start_line=0, end_line=1)] - n2.chunk_ids = [c.id for c in c2] - await store.upsert([(n2, c2)]) - - # The node now references the new chunk set, not the old one. - nodes = await store.get_nodes(["a.md"]) - assert nodes[0].chunk_ids == ["a.md::new"] - assert "a.md::new" in store.file_chunks - - await store.close() - print("✓ test_upsert_replaces_old_chunks passed") - - asyncio.run(run()) - - -def test_delete_by_path_single(): - """delete_by_path drops chunks and the node entry.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - - await store.upsert([make_file("a.md", "alpha"), make_file("b.md", "beta")]) - await store.delete("a.md") - - assert all(c.path != "a.md" for c in store.file_chunks.values()) - assert {n.path for n in await store.get_nodes()} == {"b.md"} - - await store.close() - print("✓ test_delete_by_path_single passed") - - asyncio.run(run()) - - -def test_delete_by_path_list(): - """delete_by_path accepts a list of paths.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - - await store.upsert( - [ - make_file("a.md", "alpha"), - make_file("b.md", "beta"), - make_file("c.md", "gamma"), - ], - ) - await store.delete(["a.md", "b.md"]) - - assert {n.path for n in await store.get_nodes()} == {"c.md"} - assert all(c.path == "c.md" for c in store.file_chunks.values()) - - await store.close() - print("✓ test_delete_by_path_list passed") - - asyncio.run(run()) - - -def test_delete_by_path_missing_is_noop(): - """Deleting a nonexistent path is a no-op.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - - await store.upsert([make_file("a.md", "alpha")]) - before = len(store.file_chunks) - await store.delete("ghost.md") - assert len(store.file_chunks) == before - - await store.close() - print("✓ test_delete_by_path_missing_is_noop passed") - - asyncio.run(run()) - - -def test_clear(): - """clear() empties chunks and the file graph.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - - await store.upsert([make_file("a.md", "alpha"), make_file("b.md", "beta")]) - await store.clear() - - assert store.file_chunks == {} - assert await store.get_nodes() == [] - - await store.close() - print("✓ test_clear passed") - - asyncio.run(run()) - - -def test_keyword_search(): - """keyword_search returns matching chunks ranked by BM25 score.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - - await store.upsert( - [ - make_file("a.md", "python programming language"), - make_file("b.md", "java programming language"), - make_file("c.md", "python data analysis"), - ], - ) - - results = await store.keyword_search("python", limit=5, search_filter={}) - paths = {r.path for r in results} - assert "a.md" in paths or "c.md" in paths - # Each result should carry a keyword score. - for r in results: - assert r.scores.get("keyword", 0) > 0 - - await store.close() - print("✓ test_keyword_search passed") - - asyncio.run(run()) - - -def test_keyword_search_empty_query(): - """Empty/whitespace queries return no results.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - await store.upsert([make_file("a.md", "hello")]) - - assert await store.keyword_search("", limit=5, search_filter={}) == [] - assert await store.keyword_search(" ", limit=5, search_filter={}) == [] - - await store.close() - print("✓ test_keyword_search_empty_query passed") - - asyncio.run(run()) - - -def test_vector_search_disabled_returns_empty(): - """Without an embedding model, vector_search returns [].""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - await store.upsert([make_file("a.md", "hello")]) - - assert store.embedding_model is None - assert await store.vector_search("hello", limit=5, search_filter={}) == [] - - await store.close() - print("✓ test_vector_search_disabled_returns_empty passed") - - asyncio.run(run()) - - -def test_persistence_roundtrip(): - """close() dumps chunks; a fresh store loads them from disk.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - s1 = await make_store() - await s1.upsert([make_file("a.md", "alpha"), make_file("b.md", "beta")]) - await s1.close() - - s2 = await make_store() - assert {c.path for c in s2.file_chunks.values()} == {"a.md", "b.md"} - # Graph should also be persisted independently via its own dump. - assert {n.path for n in await s2.get_nodes()} == {"a.md", "b.md"} - await s2.close() - print("✓ test_persistence_roundtrip passed") - - asyncio.run(run()) - - -def test_rebuild_links_delegates_to_graph(): - """rebuild_links() on the store delegates to the underlying file_graph.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_store() - - from reme4.schema import FileLink - - node = FileNode( - path="a.md", - st_mtime=1.0, - links=[FileLink(source_path="a.md", target_path="b.md")], - ) - chunks = [FileChunk(id="a::1", path="a.md", text="x", start_line=0, end_line=1)] - node.chunk_ids = [c.id for c in chunks] - await store.upsert([(node, chunks), make_file("b.md", "beta")]) - - await store.rebuild_links() - inlinks = await store.get_inlinks("b.md") - assert {lnk.source_path for lnk in inlinks} == {"a.md"} - - await store.close() - print("✓ test_rebuild_links_delegates_to_graph passed") - - asyncio.run(run()) - - -# --- FaissLocalFileStore tests -------------------------------------------------- - - -def _skip_if_no_faiss(name: str) -> bool: - if not _FAISS_AVAILABLE: - print(f"⊘ {name} skipped (faiss not installed)") - return True - return False - - -async def make_faiss_store(store_name: str = "test_faiss", **kwargs) -> FaissLocalFileStore: - """Build a started FaissLocalFileStore wired to FakeEmbeddingModel (no API calls).""" - store = FaissLocalFileStore(name=store_name, embedding_model="fake", **kwargs) - fake = FakeEmbeddingModel() - # Replace the unresolved Dependency placeholder with a concrete instance and - # let start() cascade lifecycle to it via _owned. - store.embedding_model = fake - store._owned.append(fake) - await store.start() - return store - - -def test_faiss_vector_search_basic(): - """vector_search returns chunks ranked by cosine similarity to the query.""" - if _skip_if_no_faiss("test_faiss_vector_search_basic"): - return - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_faiss_store() - await store.upsert( - [ - make_file("a.md", "alpha"), - make_file("b.md", "beta"), - make_file("c.md", "alpha gamma"), - ], - ) - - results = await store.vector_search("alpha", limit=3, search_filter={}) - assert results, "vector_search returned no results" - for r in results: - assert "vector" in r.scores - assert r.scores["score"] == r.scores["vector"] - # Top hit should match an "alpha"-bearing doc. - assert results[0].path in {"a.md", "c.md"} - # All distinct chunks (no duplicates from tombstones). - assert len({r.id for r in results}) == len(results) - - await store.close() - print("✓ test_faiss_vector_search_basic passed") - - asyncio.run(run()) - - -def test_faiss_persistence_roundtrip(): - """close() writes FAISS sidecar; a fresh store loads it without rebuilding.""" - if _skip_if_no_faiss("test_faiss_persistence_roundtrip"): - return - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - s1 = await make_faiss_store() - await s1.upsert([make_file("a.md", "alpha"), make_file("b.md", "beta")]) - r1 = await s1.vector_search("alpha", limit=2, search_filter={}) - assert s1.faiss_path.exists() is False # not yet dumped - await s1.close() - assert s1.faiss_path.exists() and s1.faiss_idmap_path.exists() - - s2 = await make_faiss_store() - assert s2._faiss_index is not None - assert s2._faiss_index.ntotal == 2 - r2 = await s2.vector_search("alpha", limit=2, search_filter={}) - assert [r.path for r in r2] == [r.path for r in r1] - await s2.close() - print("✓ test_faiss_persistence_roundtrip passed") - - asyncio.run(run()) - - -def test_faiss_delete_removes_from_search(): - """Deleting a file tombstones its chunks; subsequent search excludes them.""" - if _skip_if_no_faiss("test_faiss_delete_removes_from_search"): - return - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_faiss_store() - await store.upsert([make_file("a.md", "alpha"), make_file("b.md", "alpha beta")]) - - await store.delete("a.md") - results = await store.vector_search("alpha", limit=5, search_filter={}) - assert all(r.path != "a.md" for r in results) - # All tombstoned rows still present in id_map; live mapping shrank. - assert len(store._id_to_row) == 1 - assert len(store._tombstones) == 1 - - await store.close() - print("✓ test_faiss_delete_removes_from_search passed") - - asyncio.run(run()) - - -def test_faiss_upsert_replaces_vectors(): - """Re-upserting a path with new chunk ids tombstones the old vectors.""" - if _skip_if_no_faiss("test_faiss_upsert_replaces_vectors"): - return - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_faiss_store() - n1, c1 = make_file("a.md", "alpha", chunk_count=2) - await store.upsert([(n1, c1)]) - assert store._faiss_index.ntotal == 2 - assert len(store._tombstones) == 0 - - # New chunk ids for the same path → old ones become tombstones. - n2 = FileNode(path="a.md", st_mtime=2.0) - c2 = [FileChunk(id="a.md::new", path="a.md", text="gamma", start_line=0, end_line=1)] - n2.chunk_ids = [c.id for c in c2] - await store.upsert([(n2, c2)]) - - assert store._faiss_index.ntotal == 3 # 2 old + 1 new appended - assert len(store._tombstones) == 2 # both old rows tombstoned - assert "a.md::new" in store._id_to_row - - results = await store.vector_search("gamma", limit=5, search_filter={}) - assert results and results[0].id == "a.md::new" - - await store.close() - print("✓ test_faiss_upsert_replaces_vectors passed") - - asyncio.run(run()) - - -def test_faiss_clear_empties_index(): - """clear() resets FAISS state and removes sidecar files.""" - if _skip_if_no_faiss("test_faiss_clear_empties_index"): - return - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = await make_faiss_store() - await store.upsert([make_file("a.md", "alpha")]) - await store.dump() - assert store.faiss_path.exists() - - await store.clear() - assert store._faiss_index.ntotal == 0 - assert store._id_map == [] and store._id_to_row == {} - assert not store.faiss_path.exists() - assert not store.faiss_idmap_path.exists() - assert await store.vector_search("alpha", limit=5, search_filter={}) == [] - - await store.close() - print("✓ test_faiss_clear_empties_index passed") - - asyncio.run(run()) - - -def test_faiss_rebuild_when_sidecar_missing(): - """If the FAISS sidecar is missing on load, the index rebuilds from chunks JSONL.""" - if _skip_if_no_faiss("test_faiss_rebuild_when_sidecar_missing"): - return - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - s1 = await make_faiss_store() - await s1.upsert([make_file("a.md", "alpha"), make_file("b.md", "beta")]) - await s1.close() - - # Drop the FAISS sidecar but keep chunks JSONL — load() must rebuild. - s1.faiss_path.unlink() - s1.faiss_idmap_path.unlink() - - s2 = await make_faiss_store() - assert s2._faiss_index is not None and s2._faiss_index.ntotal == 2 - results = await s2.vector_search("alpha", limit=2, search_filter={}) - assert any(r.path == "a.md" for r in results) - await s2.close() - print("✓ test_faiss_rebuild_when_sidecar_missing passed") - - asyncio.run(run()) - - -def test_faiss_disabled_without_embedding(): - """embedding_model="" → FAISS path stays dormant; vector_search returns [].""" - if _skip_if_no_faiss("test_faiss_disabled_without_embedding"): - return - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - store = FaissLocalFileStore(name="disabled", embedding_model="") - await store.start() - await store.upsert([make_file("a.md", "alpha")]) - - assert store.embedding_model is None - assert store._faiss_index is None - assert await store.vector_search("alpha", limit=5, search_filter={}) == [] - - await store.close() - print("✓ test_faiss_disabled_without_embedding passed") - - asyncio.run(run()) - - -if __name__ == "__main__": - print("\n=== LocalFileStore Tests ===") - test_upsert_single_file() - test_upsert_multiple_files() - test_upsert_replaces_old_chunks() - test_delete_by_path_single() - test_delete_by_path_list() - test_delete_by_path_missing_is_noop() - test_clear() - test_keyword_search() - test_keyword_search_empty_query() - test_vector_search_disabled_returns_empty() - test_persistence_roundtrip() - test_rebuild_links_delegates_to_graph() - - print("\n=== FaissLocalFileStore Tests ===") - test_faiss_vector_search_basic() - test_faiss_persistence_roundtrip() - test_faiss_delete_removes_from_search() - test_faiss_upsert_replaces_vectors() - test_faiss_clear_empties_index() - test_faiss_rebuild_when_sidecar_missing() - test_faiss_disabled_without_embedding() - - print("\n所有测试通过!") diff --git a/tests4/unit/test_job.py b/tests4/unit/test_job.py new file mode 100644 index 00000000..b2e222fd --- /dev/null +++ b/tests4/unit/test_job.py @@ -0,0 +1,247 @@ +"""Tests for BaseJob and BackgroundJob.""" + +# pylint: disable=protected-access,missing-function-docstring,missing-class-docstring,no-self-argument,unused-argument + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from reme4.components.component_registry import ComponentRegistry +from reme4.components.job.background_job import BackgroundJob +from reme4.components.job.base_job import BaseJob +from reme4.schema import ComponentConfig + + +# -- helpers ------------------------------------------------------------------ + + +def _make_registry_and_context(step_classes=None): + """Build a fresh registry + minimal app_context for job tests.""" + reg = ComponentRegistry() + if step_classes: + for name, cls in step_classes.items(): + reg.register(cls, name) + + ctx = MagicMock() + ctx.components = {} + return reg, ctx + + +# -- BaseJob._resolve_step --------------------------------------------------- + + +def test_resolve_step_missing_backend(): + job = BaseJob(name="j") + job.app_context = MagicMock() + with pytest.raises(ValueError, match="missing the required 'backend'"): + job._resolve_step(ComponentConfig(backend="")) + + +def test_resolve_step_unregistered_backend(): + job = BaseJob(name="j") + job.app_context = MagicMock() + with pytest.raises(ValueError, match="Unregistered backend"): + job._resolve_step(ComponentConfig(backend="nonexistent_step")) + + +# -- BaseJob.__call__ error capture ------------------------------------------ + + +def test_call_captures_exception(): + async def run(): + failing_step = AsyncMock(side_effect=RuntimeError("boom")) + + job = BaseJob(name="j") + job.app_context = MagicMock() + job.step_specs = [] + job._build_steps = lambda: [failing_step] + + response = await job() + assert response.success is False + assert "boom" in response.answer + + asyncio.run(run()) + + +def test_call_runs_steps_in_order(): + async def run(): + call_order = [] + + async def step1(ctx): + call_order.append("s1") + + async def step2(ctx): + call_order.append("s2") + + job = BaseJob(name="j") + job.app_context = MagicMock() + job.step_specs = [] + job._build_steps = lambda: [step1, step2] + + response = await job() + assert response.success is True + assert call_order == ["s1", "s2"] + + asyncio.run(run()) + + +# -- BaseJob._start requires app_context ------------------------------------ + + +def test_start_without_app_context_raises(): + async def run(): + job = BaseJob(name="j") + with pytest.raises(RuntimeError, match="app_context must be provided"): + await job._start() + + asyncio.run(run()) + + +# -- BackgroundJob._backoff_delay -------------------------------------------- + + +def test_backoff_delay_increases(): + job = BackgroundJob( + name="bg", + backoff_base=1.0, + backoff_cap=60.0, + ) + delays = [job._backoff_delay(i) for i in range(10)] + # Delay should generally increase (with jitter, so we check trend). + assert delays[-1] >= delays[0] or delays[-1] == job.backoff_cap + + +def test_backoff_delay_capped(): + job = BackgroundJob( + name="bg", + backoff_base=1.0, + backoff_cap=10.0, + ) + for _ in range(100): + delay = job._backoff_delay(20) + assert delay <= job.backoff_cap + + +def test_backoff_delay_has_jitter(): + job = BackgroundJob( + name="bg", + backoff_base=1.0, + backoff_cap=60.0, + ) + delays = {job._backoff_delay(5) for _ in range(20)} + assert len(delays) > 1 + + +def test_backoff_delay_attempt_zero(): + job = BackgroundJob( + name="bg", + backoff_base=2.0, + backoff_cap=60.0, + ) + for _ in range(50): + delay = job._backoff_delay(0) + assert 0 < delay <= 2.0 * 1.5 + + +# -- BackgroundJob supervisor loop ------------------------------------------- + + +def test_supervisor_restarts_on_crash(): + async def run(): + call_count = 0 + stop = asyncio.Event() + + class CrashingJob(BackgroundJob): + async def __call__(self_, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise RuntimeError("crash") + stop.set() + + job = CrashingJob( + name="bg", + supervisor=True, + backoff_base=0.01, + backoff_cap=0.05, + ) + job._stop_event = stop + await job._run_with_supervisor() + assert call_count == 3 + + asyncio.run(run()) + + +def test_supervisor_disabled_propagates_exception(): + async def run(): + class FatalJob(BackgroundJob): + async def __call__(self_, **kwargs): + raise RuntimeError("fatal") + + job = FatalJob( + name="bg", + supervisor=False, + ) + job._stop_event = asyncio.Event() + with pytest.raises(RuntimeError, match="fatal"): + await job._run_with_supervisor() + + asyncio.run(run()) + + +# -- BackgroundJob._wait_or_stop ---------------------------------------------- + + +def test_wait_or_stop_returns_on_stop(): + async def run(): + job = BackgroundJob(name="bg") + job._stop_event = asyncio.Event() + job._stop_event.set() + await job._wait_or_stop(10.0) + + asyncio.run(run()) + + +# -- BackgroundJob._shutdown_task --------------------------------------------- + + +def test_shutdown_task_none(): + async def run(): + job = BackgroundJob(name="bg") + job._task = None + await job._shutdown_task() + + asyncio.run(run()) + + +def test_shutdown_task_cancels_on_timeout(): + async def run(): + async def hang_forever(): + await asyncio.sleep(999) + + job = BackgroundJob(name="bg", close_timeout=0.05) + job._task = asyncio.create_task(hang_forever()) + await job._shutdown_task() + assert job._task is None + + asyncio.run(run()) + + +if __name__ == "__main__": + print("\n=== Job Tests ===") + test_resolve_step_missing_backend() + test_resolve_step_unregistered_backend() + test_call_captures_exception() + test_call_runs_steps_in_order() + test_start_without_app_context_raises() + test_backoff_delay_increases() + test_backoff_delay_capped() + test_backoff_delay_has_jitter() + test_backoff_delay_attempt_zero() + test_supervisor_restarts_on_crash() + test_supervisor_disabled_propagates_exception() + test_wait_or_stop_returns_on_stop() + test_shutdown_task_none() + test_shutdown_task_cancels_on_timeout() + print("\n所有测试通过!") diff --git a/tests4/unit/test_link_expansion.py b/tests4/unit/test_link_expansion.py index 464af763..4fe8e177 100644 --- a/tests4/unit/test_link_expansion.py +++ b/tests4/unit/test_link_expansion.py @@ -50,7 +50,7 @@ async def _store_with(files: dict[str, dict]) -> LocalFileStore: ``description`` populate FileFrontMatter so neighbor meta lookups have something to surface. """ - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() nodes: list[FileNode] = [] root = Path.cwd() @@ -84,7 +84,7 @@ def test_expand_links_empty_paths_short_circuits(): async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() result = await expand_links(store, []) assert result == {} diff --git a/tests4/unit/test_prompt_handler.py b/tests4/unit/test_prompt_handler.py new file mode 100644 index 00000000..bd0fa90b --- /dev/null +++ b/tests4/unit/test_prompt_handler.py @@ -0,0 +1,304 @@ +"""Tests for PromptHandler.""" + +# pylint: disable=missing-function-docstring + +import json +import tempfile +from pathlib import Path + +import pytest +import yaml + +from reme4.components.prompt_handler import PromptHandler + + +# -- init & load_prompt_dict -------------------------------------------------- + + +def test_init_filters_non_string_values(): + ph = PromptHandler(greeting="hello", count=42, items=[1, 2]) + assert ph.data == {"greeting": "hello"} + + +def test_load_prompt_dict_basic(): + ph = PromptHandler() + ph.load_prompt_dict({"a": "alpha", "b": "beta"}) + assert ph.data == {"a": "alpha", "b": "beta"} + + +def test_load_prompt_dict_skips_non_string_values(): + ph = PromptHandler() + ph.load_prompt_dict({"good": "ok", "bad": 123}) + assert ph.data == {"good": "ok"} + + +def test_load_prompt_dict_overwrite_true(): + ph = PromptHandler(a="old") + ph.load_prompt_dict({"a": "new"}, overwrite=True) + assert ph.data["a"] == "new" + + +def test_load_prompt_dict_overwrite_false(): + ph = PromptHandler(a="old") + ph.load_prompt_dict({"a": "new"}, overwrite=False) + assert ph.data["a"] == "old" + + +def test_load_prompt_dict_none(): + ph = PromptHandler(a="old") + result = ph.load_prompt_dict(None) + assert result is ph + assert ph.data == {"a": "old"} + + +def test_load_prompt_dict_non_dict(): + ph = PromptHandler() + result = ph.load_prompt_dict("not a dict") + assert result is ph + assert ph.data == {} + + +# -- load from file ----------------------------------------------------------- + + +def test_load_prompt_by_file_yaml(): + data = {"greeting": "Hello {name}", "farewell": "Goodbye"} + with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as f: + yaml.dump(data, f) + f.flush() + ph = PromptHandler() + ph.load_prompt_by_file(f.name) + assert ph.data["greeting"] == "Hello {name}" + assert ph.data["farewell"] == "Goodbye" + Path(f.name).unlink() + + +def test_load_prompt_by_file_json(): + data = {"q1": "What is {topic}?"} + with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f: + json.dump(data, f) + f.flush() + ph = PromptHandler() + ph.load_prompt_by_file(f.name) + assert ph.data["q1"] == "What is {topic}?" + Path(f.name).unlink() + + +def test_load_prompt_by_file_none(): + ph = PromptHandler() + result = ph.load_prompt_by_file(None) + assert result is ph + + +def test_load_prompt_by_file_nonexistent(): + ph = PromptHandler() + result = ph.load_prompt_by_file("/nonexistent/path.yaml") + assert result is ph + assert ph.data == {} + + +def test_load_prompt_by_file_unsupported_extension(): + with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f: + f.write("hello") + f.flush() + ph = PromptHandler() + result = ph.load_prompt_by_file(f.name) + assert result is ph + assert ph.data == {} + Path(f.name).unlink() + + +# -- get_prompt & i18n -------------------------------------------------------- + + +def test_get_prompt_bare_key(): + ph = PromptHandler(greeting="Hello") + assert ph.get_prompt("greeting") == "Hello" + + +def test_get_prompt_strips(): + ph = PromptHandler(greeting=" Hello \n") + assert ph.get_prompt("greeting") == "Hello" + + +def test_get_prompt_missing_raises(): + ph = PromptHandler() + with pytest.raises(KeyError, match="not found"): + ph.get_prompt("missing") + + +def test_get_prompt_language_fallback(): + ph = PromptHandler(language="zh", greeting="Hello", greeting_zh="你好") + assert ph.get_prompt("greeting") == "你好" + + +def test_get_prompt_language_fallback_to_bare(): + ph = PromptHandler(language="zh", greeting="Hello") + assert ph.get_prompt("greeting") == "Hello" + + +def test_has_prompt(): + ph = PromptHandler(greeting="Hello") + assert ph.has_prompt("greeting") is True + assert ph.has_prompt("missing") is False + + +def test_has_prompt_with_language(): + ph = PromptHandler(language="en", greeting_en="Hi") + assert ph.has_prompt("greeting") is True + + +# -- list_prompts ------------------------------------------------------------- + + +def test_list_prompts_all(): + ph = PromptHandler(a="1", b_en="2", c_zh="3") + assert sorted(ph.list_prompts()) == ["a", "b_en", "c_zh"] + + +def test_list_prompts_filtered(): + ph = PromptHandler(a="1", b_en="2", c_en="3", d_zh="4") + assert sorted(ph.list_prompts("en")) == ["b_en", "c_en"] + + +# -- prompt_format (flag filtering) ------------------------------------------- + + +def test_flag_filter_keeps_matching(): + ph = PromptHandler() + ph.load_prompt_dict({"p": "[verbose] extra detail\nalways here"}) + result = ph.prompt_format("p", verbose=True) + assert "extra detail" in result + assert "always here" in result + + +def test_flag_filter_removes_non_matching(): + ph = PromptHandler() + ph.load_prompt_dict({"p": "[verbose] extra detail\nalways here"}) + result = ph.prompt_format("p", verbose=False) + assert "extra detail" not in result + assert "always here" in result + + +def test_flag_filter_default_false(): + ph = PromptHandler() + ph.load_prompt_dict({"p": "[debug] debug info\nbase"}) + # When no flags are passed at all, _apply_flag_filter is not called, + # so flagged lines are kept as-is (including the tag text after regex sub). + result = ph.prompt_format("p", debug=False) + assert "debug info" not in result + assert "base" in result + + +def test_flag_filter_unflagged_lines_always_kept(): + ph = PromptHandler() + ph.load_prompt_dict({"p": "line1\nline2\nline3"}) + result = ph.prompt_format("p") + assert "line1" in result + assert "line2" in result + assert "line3" in result + + +# -- prompt_format (variable substitution) ------------------------------------ + + +def test_format_variables(): + ph = PromptHandler() + ph.load_prompt_dict({"p": "Hello {name}, welcome to {place}"}) + result = ph.prompt_format("p", name="Alice", place="Wonderland") + assert result == "Hello Alice, welcome to Wonderland" + + +def test_format_missing_variable_raises(): + ph = PromptHandler() + ph.load_prompt_dict({"p": "Hello {name}"}) + with pytest.raises(ValueError, match="Missing format variables"): + ph.prompt_format("p") + + +def test_format_missing_variable_no_validate(): + ph = PromptHandler() + ph.load_prompt_dict({"p": "Hello {name}"}) + result = ph.prompt_format("p", validate=False) + assert "{name}" in result + + +def test_format_no_variables_no_error(): + ph = PromptHandler() + ph.load_prompt_dict({"p": "No vars here"}) + result = ph.prompt_format("p") + assert result == "No vars here" + + +# -- prompt_format (combined flags + variables) ------------------------------- + + +def test_format_flags_and_variables_combined(): + ph = PromptHandler() + ph.load_prompt_dict( + { + "p": "[verbose] Debug: {detail}\nResult: {answer}", + }, + ) + result = ph.prompt_format("p", verbose=True, detail="trace", answer="42") + assert "Debug: trace" in result + assert "Result: 42" in result + + +def test_format_flags_false_variable_not_needed(): + ph = PromptHandler() + ph.load_prompt_dict( + { + "p": "[verbose] Debug: {detail}\nResult: {answer}", + }, + ) + result = ph.prompt_format("p", verbose=False, answer="42") + assert "Debug" not in result + assert "Result: 42" in result + + +# -- repr --------------------------------------------------------------------- + + +def test_repr(): + ph = PromptHandler(language="en", a="1", b="2") + r = repr(ph) + assert "en" in r + assert "2" in r + + +if __name__ == "__main__": + print("\n=== PromptHandler Tests ===") + test_init_filters_non_string_values() + test_load_prompt_dict_basic() + test_load_prompt_dict_skips_non_string_values() + test_load_prompt_dict_overwrite_true() + test_load_prompt_dict_overwrite_false() + test_load_prompt_dict_none() + test_load_prompt_dict_non_dict() + test_load_prompt_by_file_yaml() + test_load_prompt_by_file_json() + test_load_prompt_by_file_none() + test_load_prompt_by_file_nonexistent() + test_load_prompt_by_file_unsupported_extension() + test_get_prompt_bare_key() + test_get_prompt_strips() + test_get_prompt_missing_raises() + test_get_prompt_language_fallback() + test_get_prompt_language_fallback_to_bare() + test_has_prompt() + test_has_prompt_with_language() + test_list_prompts_all() + test_list_prompts_filtered() + test_flag_filter_keeps_matching() + test_flag_filter_removes_non_matching() + test_flag_filter_default_false() + test_flag_filter_unflagged_lines_always_kept() + test_format_variables() + test_format_missing_variable_raises() + test_format_missing_variable_no_validate() + test_format_no_variables_no_error() + test_format_flags_and_variables_combined() + test_format_flags_false_variable_not_needed() + test_repr() + print("\n所有测试通过!") diff --git a/tests4/unit/test_read_image_steps.py b/tests4/unit/test_read_image_steps.py index 849d6649..335bd641 100644 --- a/tests4/unit/test_read_image_steps.py +++ b/tests4/unit/test_read_image_steps.py @@ -48,7 +48,7 @@ def _run(coro): async def _make_store() -> LocalFileStore: - store = LocalFileStore(name="t_img", embedding_model="") + store = LocalFileStore(name="t_img", embedding_store="") await store.start() return store diff --git a/tests4/unit/test_read_with_neighbors.py b/tests4/unit/test_read_with_neighbors.py index 45ae476d..6bce6dce 100644 --- a/tests4/unit/test_read_with_neighbors.py +++ b/tests4/unit/test_read_with_neighbors.py @@ -50,7 +50,7 @@ def _run(coro): async def _store_with(files: dict[str, dict]) -> LocalFileStore: """LocalFileStore seeded with files + parsed wikilinks + optional frontmatter.""" - store = LocalFileStore(name="t_read_neighbors", embedding_model="") + store = LocalFileStore(name="t_read_neighbors", embedding_store="") await store.start() nodes: list[FileNode] = [] root = Path.cwd() @@ -169,7 +169,7 @@ def test_read_with_neighbors_non_md_falls_through(): async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): (Path(tmp) / "notes.txt").write_text("plain text body", encoding="utf-8") - store = LocalFileStore(name="t_read_neighbors_nonmd", embedding_model="") + store = LocalFileStore(name="t_read_neighbors_nonmd", embedding_store="") await store.start() resp = await _read(store, step_kwargs={"with_neighbors": True}, path="notes.txt") assert resp.success is True diff --git a/tests4/unit/test_resource_steps.py b/tests4/unit/test_resource_steps.py index 9fba9f00..0815637f 100644 --- a/tests4/unit/test_resource_steps.py +++ b/tests4/unit/test_resource_steps.py @@ -49,7 +49,7 @@ class temp_chdir: async def _make_store() -> LocalFileStore: """Minimal LocalFileStore (embedding disabled). vault_path resolves to CWD.""" - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() return store diff --git a/tests4/unit/test_runtime_context.py b/tests4/unit/test_runtime_context.py new file mode 100644 index 00000000..04a8754e --- /dev/null +++ b/tests4/unit/test_runtime_context.py @@ -0,0 +1,181 @@ +"""Tests for RuntimeContext.""" + +# pylint: disable=protected-access,missing-function-docstring + +import asyncio + +import pytest + +from reme4.components.runtime_context import RuntimeContext +from reme4.enumeration import ChunkEnum + + +# -- dict-like access --------------------------------------------------------- + + +def test_getitem_setitem(): + ctx = RuntimeContext(foo="bar") + assert ctx["foo"] == "bar" + ctx["baz"] = 42 + assert ctx["baz"] == 42 + + +def test_getitem_missing_raises(): + ctx = RuntimeContext() + with pytest.raises(KeyError): + _ = ctx["nope"] + + +def test_contains(): + ctx = RuntimeContext(a=1) + assert "a" in ctx + assert "b" not in ctx + + +def test_delitem(): + ctx = RuntimeContext(a=1) + del ctx["a"] + assert "a" not in ctx + + +def test_get_with_default(): + ctx = RuntimeContext(a=1) + assert ctx.get("a") == 1 + assert ctx.get("b", "fallback") == "fallback" + assert ctx.get("b") is None + + +def test_update_merges_and_returns_self(): + ctx = RuntimeContext(a=1) + result = ctx.update({"b": 2, "c": 3}) + assert result is ctx + assert ctx["b"] == 2 + assert ctx["c"] == 3 + + +# -- from_context ------------------------------------------------------------- + + +def test_from_context_creates_new_when_none(): + ctx = RuntimeContext.from_context(None, x=10) + assert ctx["x"] == 10 + + +def test_from_context_reuses_existing(): + original = RuntimeContext(a=1) + reused = RuntimeContext.from_context(original, b=2) + assert reused is original + assert reused["a"] == 1 + assert reused["b"] == 2 + + +# -- apply_mapping ------------------------------------------------------------ + + +def test_apply_mapping_copies_values(): + ctx = RuntimeContext(src="hello") + result = ctx.apply_mapping({"src": "dst"}) + assert result is ctx + assert ctx["dst"] == "hello" + assert ctx["src"] == "hello" + + +def test_apply_mapping_skips_missing_source(): + ctx = RuntimeContext(a=1) + ctx.apply_mapping({"missing_key": "target"}) + assert "target" not in ctx + + +def test_apply_mapping_empty_is_noop(): + ctx = RuntimeContext(a=1) + result = ctx.apply_mapping({}) + assert result is ctx + + +# -- streaming ---------------------------------------------------------------- + + +def test_stream_property(): + ctx_no_queue = RuntimeContext() + assert ctx_no_queue.stream is False + + ctx_with_queue = RuntimeContext(stream_queue=asyncio.Queue()) + assert ctx_with_queue.stream is True + + +def test_enqueue_raises_without_queue(): + async def run(): + ctx = RuntimeContext() + with pytest.raises(RuntimeError, match="Stream queue not initialized"): + await ctx._enqueue(None) + + asyncio.run(run()) + + +def test_add_stream_string(): + async def run(): + q = asyncio.Queue() + ctx = RuntimeContext(stream_queue=q) + result = await ctx.add_stream_string("hello", ChunkEnum.CONTENT) + assert result is ctx + + chunk = q.get_nowait() + assert chunk.chunk == "hello" + assert chunk.chunk_type == ChunkEnum.CONTENT + assert chunk.done is False + + asyncio.run(run()) + + +def test_add_stream_done(): + async def run(): + q = asyncio.Queue() + ctx = RuntimeContext(stream_queue=q) + result = await ctx.add_stream_done() + assert result is ctx + + chunk = q.get_nowait() + assert chunk.chunk_type == ChunkEnum.DONE + assert chunk.done is True + + asyncio.run(run()) + + +# -- response ----------------------------------------------------------------- + + +def test_default_response(): + ctx = RuntimeContext() + assert ctx.response.success is True + assert ctx.response.answer == "" + + +def test_custom_response(): + from reme4.schema import Response + + resp = Response(answer="ok", success=False) + ctx = RuntimeContext(response=resp) + assert ctx.response is resp + assert ctx.response.success is False + + +if __name__ == "__main__": + print("\n=== RuntimeContext Tests ===") + test_getitem_setitem() + test_getitem_missing_raises() + test_contains() + test_delitem() + test_get_with_default() + test_update_merges_and_returns_self() + test_from_context_creates_new_when_none() + test_from_context_reuses_existing() + test_apply_mapping_copies_values() + test_apply_mapping_skips_missing_source() + test_apply_mapping_empty_is_noop() + test_stream_property() + test_enqueue_raises_without_queue() + test_add_stream_string() + test_add_stream_done() + test_default_response() + test_custom_response() + print("\n所有测试通过!") diff --git a/tests4/unit/test_wikilink_utils.py b/tests4/unit/test_wikilink_utils.py index c43aa510..c1f5007f 100644 --- a/tests4/unit/test_wikilink_utils.py +++ b/tests4/unit/test_wikilink_utils.py @@ -51,7 +51,7 @@ async def _store_with(files: dict[str, str]) -> LocalFileStore: Without the parsed links the reverse-index lookup yields nothing and retarget becomes a no-op. """ - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() nodes: list[FileNode] = [] root = Path.cwd() @@ -72,7 +72,7 @@ async def _store_with(files: dict[str, str]) -> LocalFileStore: async def _empty_store() -> LocalFileStore: - store = LocalFileStore(name="t", embedding_model="") + store = LocalFileStore(name="t", embedding_store="") await store.start() return store diff --git a/tests4/unit/test_write_metadata_lock.py b/tests4/unit/test_write_metadata_lock.py index 1cfb98f2..9b1b73c7 100644 --- a/tests4/unit/test_write_metadata_lock.py +++ b/tests4/unit/test_write_metadata_lock.py @@ -50,7 +50,7 @@ def _run(coro): async def _make_store() -> LocalFileStore: - store = LocalFileStore(name="t_write_meta", embedding_model="") + store = LocalFileStore(name="t_write_meta", embedding_store="") await store.start() return store