mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
```
feat: add file parser support and search filtering - Add file_parser component with default implementation - Introduce SearchFilter schema for path and tag filtering - Implement filter functionality in BaseFileStore and LocalFileStore - Update file watcher to use parser-based filtering instead of suffix filters - Register new FILE_PARSER component enum - Add test_data directory to gitignore refactor: improve component imports and initialization - Fix relative imports in application.py - Add file_parser import to component init - Initialize registry dict when component type doesn't exist - Remove circular import in HttpService by using string annotation - Update config yaml to use proper component names refactor: enhance file watcher architecture - Replace MdFileWatcher with more flexible FullFileWatcher and LightFileWatcher - Remove suffix-based filtering in favor of parser-based approach - Update BaseFileWatcher to resolve parsers from app context - Remove unused watch_filter method refactor: update ReMe core functionality - Remove memory_path creation - Simplify dream and proactive methods to return empty strings - Update config defaults for HTTP service and component backends docs: update component configuration in paw.yaml - Change service backend from cmd to http - Rename components to use correct singular forms - Add default file parser and file watcher configurations - Set up local file store with default settings ```
This commit is contained in:
parent
b202fc3fba
commit
00018c0ff8
30 changed files with 749 additions and 269 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -42,4 +42,5 @@ meta_memory/*
|
|||
**/data/*.json
|
||||
*.db
|
||||
memories/*
|
||||
.reme/*
|
||||
.reme/*
|
||||
/test_data
|
||||
|
|
@ -4,7 +4,7 @@ import asyncio
|
|||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from enumeration import ComponentEnum
|
||||
from .enumeration import ComponentEnum
|
||||
from .component import BaseComponent, ApplicationContext
|
||||
from .schema import Response, StreamChunk
|
||||
from .utils import execute_stream_task, print_logo, get_logger
|
||||
|
|
@ -122,10 +122,10 @@ class Application(BaseComponent):
|
|||
stream_queue = asyncio.Queue()
|
||||
task = asyncio.create_task(job(stream_queue=stream_queue, app_context=self.context, **kwargs))
|
||||
async for chunk in execute_stream_task(
|
||||
stream_queue=stream_queue,
|
||||
task=task,
|
||||
task_name=name,
|
||||
output_format="chunk",
|
||||
stream_queue=stream_queue,
|
||||
task=task,
|
||||
task_name=name,
|
||||
output_format="chunk",
|
||||
):
|
||||
assert isinstance(chunk, StreamChunk)
|
||||
yield chunk
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ from .runtime_context import RuntimeContext
|
|||
|
||||
from . import as_llm
|
||||
from . import as_llm_formatter
|
||||
from . import as_token_counter
|
||||
from . import client
|
||||
from . import embedding
|
||||
from . import file_parser
|
||||
from . import file_store
|
||||
from . import file_watcher
|
||||
from . import job
|
||||
|
|
@ -28,9 +28,9 @@ __all__ = [
|
|||
# base components
|
||||
"as_llm",
|
||||
"as_llm_formatter",
|
||||
|
||||
"client",
|
||||
"embedding",
|
||||
"file_parser",
|
||||
"file_store",
|
||||
"file_watcher",
|
||||
"job",
|
||||
|
|
|
|||
|
|
@ -41,4 +41,4 @@ class EstimatedAsTokenCounter(BaseAsTokenCounter):
|
|||
__all__ = [
|
||||
"BaseAsTokenCounter",
|
||||
"EstimatedAsTokenCounter",
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -31,13 +31,13 @@ class BaseStep(BaseComponent):
|
|||
return instance
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "",
|
||||
language: str = "",
|
||||
prompt_dict: dict[str, str] | None = None,
|
||||
input_mapping: dict[str, str] | None = None,
|
||||
output_mapping: dict[str, str] | None = None,
|
||||
**kwargs,
|
||||
self,
|
||||
name: str = "",
|
||||
language: str = "",
|
||||
prompt_dict: dict[str, str] | None = None,
|
||||
input_mapping: dict[str, str] | None = None,
|
||||
output_mapping: dict[str, str] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize step configurations."""
|
||||
super().__init__(**kwargs)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ class ComponentRegistry:
|
|||
raise ValueError("Component name cannot be empty")
|
||||
|
||||
component_type = cls.component_type
|
||||
if component_type not in self._registry:
|
||||
self._registry[component_type] = {}
|
||||
if name in self._registry[component_type]:
|
||||
self.logger.warning(f"Component '{name}' already registered for {component_type}, overwriting")
|
||||
|
||||
|
|
|
|||
11
reme2/component/file_parser/__init__.py
Normal file
11
reme2/component/file_parser/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""File parser implementations for different file formats."""
|
||||
|
||||
from .base_file_parser import BaseFileParser
|
||||
from .default_file_parser import DefaultFileParser
|
||||
from .md_file_parser import MdFileParser
|
||||
|
||||
__all__ = [
|
||||
"BaseFileParser",
|
||||
"DefaultFileParser",
|
||||
"MdFileParser",
|
||||
]
|
||||
41
reme2/component/file_parser/base_file_parser.py
Normal file
41
reme2/component/file_parser/base_file_parser.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Abstract base class for file parsers."""
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from ..base_component import BaseComponent
|
||||
from ...enumeration import ComponentEnum
|
||||
from ...schema import FileChunk, FileMetadata
|
||||
|
||||
|
||||
class BaseFileParser(BaseComponent):
|
||||
"""Abstract base class for file format parsers.
|
||||
|
||||
Each parser declares which file suffixes it handles and implements
|
||||
the parse method to produce FileMetadata and FileChunks.
|
||||
"""
|
||||
|
||||
component_type = ComponentEnum.FILE_PARSER
|
||||
|
||||
suffixes: list[str] = []
|
||||
|
||||
def __init__(self, chunk_tokens: int = 400, chunk_overlap: int = 80, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.chunk_tokens = chunk_tokens
|
||||
self.chunk_overlap = chunk_overlap
|
||||
|
||||
async def _start(self, app_context=None):
|
||||
pass
|
||||
|
||||
async def _close(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def parse(self, path: str) -> tuple[FileMetadata, list[FileChunk]]:
|
||||
"""Parse a file into metadata and chunks.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the file.
|
||||
|
||||
Returns:
|
||||
Tuple of (FileMetadata, list of FileChunks).
|
||||
"""
|
||||
64
reme2/component/file_parser/default_file_parser.py
Normal file
64
reme2/component/file_parser/default_file_parser.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Default file parser for unknown file types."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from .base_file_parser import BaseFileParser
|
||||
from ..component_registry import R
|
||||
from ...schema import FileChunk, FileMetadata
|
||||
from ...utils import hash_text, chunk_markdown
|
||||
|
||||
|
||||
@R.register("default")
|
||||
class DefaultFileParser(BaseFileParser):
|
||||
"""Fallback parser for unknown file types.
|
||||
|
||||
Attempts to read as text and chunk. If the file is binary,
|
||||
stores metadata only with no chunks.
|
||||
"""
|
||||
|
||||
suffixes = []
|
||||
|
||||
def __init__(self, encoding: str = "utf-8", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.encoding = encoding
|
||||
|
||||
async def parse(self, path: str) -> tuple[FileMetadata, list[FileChunk]]:
|
||||
file_path = Path(path)
|
||||
|
||||
def _read_file():
|
||||
stat = file_path.stat()
|
||||
raw = file_path.read_bytes()
|
||||
try:
|
||||
content = raw.decode(self.encoding)
|
||||
content_hash = hash_text(content)
|
||||
return stat, content_hash, content
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
binary_hash = hashlib.sha256(raw).hexdigest()
|
||||
return stat, binary_hash, None
|
||||
|
||||
stat, file_hash, content = await asyncio.to_thread(_read_file)
|
||||
|
||||
file_meta = FileMetadata(
|
||||
hash=file_hash,
|
||||
mtime_ms=stat.st_mtime * 1000,
|
||||
size=stat.st_size,
|
||||
path=str(file_path.absolute()),
|
||||
content=content,
|
||||
)
|
||||
|
||||
chunks: list[FileChunk] = []
|
||||
if content:
|
||||
chunks = (
|
||||
chunk_markdown(
|
||||
content,
|
||||
file_meta.path,
|
||||
self.chunk_tokens,
|
||||
self.chunk_overlap,
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
file_meta.chunk_count = len(chunks)
|
||||
return file_meta, chunks
|
||||
55
reme2/component/file_parser/md_file_parser.py
Normal file
55
reme2/component/file_parser/md_file_parser.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Markdown file parser."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
|
||||
from .base_file_parser import BaseFileParser
|
||||
from ..component_registry import R
|
||||
from ...schema import FileChunk, FileMetadata
|
||||
from ...utils import hash_text, chunk_markdown
|
||||
|
||||
|
||||
@R.register("md")
|
||||
class MdFileParser(BaseFileParser):
|
||||
"""Parser for Markdown files with YAML frontmatter support."""
|
||||
|
||||
suffixes = [".md", ".markdown"]
|
||||
|
||||
def __init__(self, encoding: str = "utf-8", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.encoding = encoding
|
||||
|
||||
async def parse(self, path: str) -> tuple[FileMetadata, list[FileChunk]]:
|
||||
file_path = Path(path)
|
||||
|
||||
def _read_and_parse():
|
||||
raw = file_path.read_text(encoding=self.encoding)
|
||||
post = frontmatter.loads(raw)
|
||||
stat = file_path.stat()
|
||||
return stat, dict(post.metadata), post.content
|
||||
|
||||
stat, metadata, content = await asyncio.to_thread(_read_and_parse)
|
||||
|
||||
file_meta = FileMetadata(
|
||||
hash=hash_text(content),
|
||||
mtime_ms=stat.st_mtime * 1000,
|
||||
size=stat.st_size,
|
||||
path=str(file_path.absolute()),
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
chunks = (
|
||||
chunk_markdown(
|
||||
content,
|
||||
file_meta.path,
|
||||
self.chunk_tokens,
|
||||
self.chunk_overlap,
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
file_meta.chunk_count = len(chunks)
|
||||
return file_meta, chunks
|
||||
|
|
@ -7,7 +7,7 @@ from pathlib import Path
|
|||
from ..base_component import BaseComponent
|
||||
from ..embedding import BaseEmbeddingModel
|
||||
from ...enumeration import ComponentEnum
|
||||
from ...schema import FileChunk, FileMetadata
|
||||
from ...schema import FileChunk, FileMetadata, SearchFilter
|
||||
|
||||
|
||||
class BaseFileStore(BaseComponent):
|
||||
|
|
@ -145,12 +145,36 @@ class BaseFileStore(BaseComponent):
|
|||
async def get_file_chunks(self, path: str) -> list[FileChunk]:
|
||||
"""Get all chunks for a file."""
|
||||
|
||||
def _apply_filter(
|
||||
self,
|
||||
chunks: list[FileChunk],
|
||||
search_filter: SearchFilter | None,
|
||||
file_metadata: dict[str, FileMetadata] | None = None,
|
||||
) -> list[FileChunk]:
|
||||
"""Apply search filter to a list of chunks.
|
||||
|
||||
Args:
|
||||
chunks: Candidate chunks to filter.
|
||||
search_filter: Filter conditions.
|
||||
file_metadata: File-level metadata lookup (path -> FileMetadata).
|
||||
Used for tag filtering since tags are file-level, not chunk-level.
|
||||
"""
|
||||
if not search_filter or search_filter.is_empty():
|
||||
return chunks
|
||||
fm = file_metadata or {}
|
||||
return [c for c in chunks if search_filter.match(c.path, fm[c.path].metadata if c.path in fm else None)]
|
||||
|
||||
@abstractmethod
|
||||
async def vector_search(self, query: str, limit: int) -> list[FileChunk]:
|
||||
async def vector_search(self, query: str, limit: int, search_filter: SearchFilter | None = None) -> list[FileChunk]:
|
||||
"""Perform vector similarity search."""
|
||||
|
||||
@abstractmethod
|
||||
async def keyword_search(self, query: str, limit: int) -> list[FileChunk]:
|
||||
async def keyword_search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int,
|
||||
search_filter: SearchFilter | None = None,
|
||||
) -> list[FileChunk]:
|
||||
"""Perform full-text/keyword search."""
|
||||
|
||||
@abstractmethod
|
||||
|
|
@ -160,6 +184,7 @@ class BaseFileStore(BaseComponent):
|
|||
limit: int,
|
||||
vector_weight: float = 0.7,
|
||||
candidate_multiplier: float = 3.0,
|
||||
search_filter: SearchFilter | None = None,
|
||||
) -> list[FileChunk]:
|
||||
"""Perform hybrid search combining vector and keyword results.
|
||||
|
||||
|
|
@ -168,6 +193,7 @@ class BaseFileStore(BaseComponent):
|
|||
limit: Maximum number of results.
|
||||
vector_weight: Weight for vector scores (0.0-1.0).
|
||||
candidate_multiplier: Multiplier for candidate pool size.
|
||||
search_filter: Optional filter for paths/tags.
|
||||
|
||||
Returns:
|
||||
FileChunk list with score populated, sorted by relevance.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import numpy as np
|
|||
|
||||
from .base_file_store import BaseFileStore
|
||||
from ..component_registry import R
|
||||
from ...schema import FileChunk, FileMetadata
|
||||
from ...schema import FileChunk, FileMetadata, SearchFilter
|
||||
from ...utils import batch_cosine_similarity
|
||||
|
||||
|
||||
|
|
@ -72,9 +72,7 @@ class LocalFileStore(BaseFileStore):
|
|||
|
||||
async def _save_metadata(self) -> None:
|
||||
"""Persist file metadata to JSON file with atomic write."""
|
||||
raw = {
|
||||
path: meta.model_dump(exclude={"content", "metadata"}, mode="json") for path, meta in self._files.items()
|
||||
}
|
||||
raw = {path: meta.model_dump(exclude={"content"}, mode="json") for path, meta in self._files.items()}
|
||||
content = json.dumps(raw, indent=2, ensure_ascii=False)
|
||||
temp_path = self._metadata_file.with_suffix(".tmp")
|
||||
try:
|
||||
|
|
@ -97,7 +95,7 @@ class LocalFileStore(BaseFileStore):
|
|||
f"LocalFileStore '{self.store_name}' ready: "
|
||||
f"{len(self._chunks)} chunks, metadata at {self._metadata_file}",
|
||||
)
|
||||
await super()._start()
|
||||
await super()._start(app_context)
|
||||
|
||||
async def _close(self) -> None:
|
||||
"""Flush state to disk and clear memory."""
|
||||
|
|
@ -111,14 +109,12 @@ class LocalFileStore(BaseFileStore):
|
|||
|
||||
async def upsert_file(self, file_meta: FileMetadata, chunks: list[FileChunk]) -> None:
|
||||
"""Insert or update a file and its chunks."""
|
||||
if not chunks:
|
||||
return
|
||||
|
||||
await self.delete_file(file_meta.path)
|
||||
chunks = await self.get_chunk_embeddings(chunks)
|
||||
|
||||
for chunk in chunks:
|
||||
self._chunks[chunk.id] = chunk
|
||||
if chunks:
|
||||
chunks = await self.get_chunk_embeddings(chunks)
|
||||
for chunk in chunks:
|
||||
self._chunks[chunk.id] = chunk
|
||||
|
||||
self._files[file_meta.path] = FileMetadata(
|
||||
hash=file_meta.hash,
|
||||
|
|
@ -126,6 +122,7 @@ class LocalFileStore(BaseFileStore):
|
|||
size=file_meta.size,
|
||||
path=file_meta.path,
|
||||
chunk_count=len(chunks),
|
||||
metadata=file_meta.metadata,
|
||||
)
|
||||
|
||||
async def delete_file(self, path: str) -> None:
|
||||
|
|
@ -170,6 +167,7 @@ class LocalFileStore(BaseFileStore):
|
|||
size=file_meta.size,
|
||||
path=file_meta.path,
|
||||
chunk_count=file_meta.chunk_count,
|
||||
metadata=file_meta.metadata,
|
||||
)
|
||||
|
||||
async def get_file_chunks(self, path: str) -> list[FileChunk]:
|
||||
|
|
@ -180,7 +178,7 @@ class LocalFileStore(BaseFileStore):
|
|||
|
||||
# -- Search -------------------------------------------------------------
|
||||
|
||||
async def vector_search(self, query: str, limit: int) -> list[FileChunk]:
|
||||
async def vector_search(self, query: str, limit: int, search_filter: SearchFilter | None = None) -> list[FileChunk]:
|
||||
"""Cosine-similarity vector search over in-memory embeddings."""
|
||||
if not self.vector_enabled or not query:
|
||||
return []
|
||||
|
|
@ -189,7 +187,11 @@ class LocalFileStore(BaseFileStore):
|
|||
if not query_embedding:
|
||||
return []
|
||||
|
||||
candidates = [c for c in self._chunks.values() if c.embedding]
|
||||
candidates = self._apply_filter(
|
||||
[c for c in self._chunks.values() if c.embedding],
|
||||
search_filter,
|
||||
self._files,
|
||||
)
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
|
|
@ -226,7 +228,12 @@ class LocalFileStore(BaseFileStore):
|
|||
results.sort(key=lambda r: r.score, reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
async def keyword_search(self, query: str, limit: int) -> list[FileChunk]:
|
||||
async def keyword_search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int,
|
||||
search_filter: SearchFilter | None = None,
|
||||
) -> list[FileChunk]:
|
||||
"""Keyword search via substring matching."""
|
||||
if not self.fts_enabled or not query:
|
||||
return []
|
||||
|
|
@ -239,8 +246,10 @@ class LocalFileStore(BaseFileStore):
|
|||
words_lower = [w.lower() for w in words]
|
||||
n_words = len(words)
|
||||
|
||||
filtered_chunks = self._apply_filter(list(self._chunks.values()), search_filter, self._files)
|
||||
|
||||
results = []
|
||||
for chunk in self._chunks.values():
|
||||
for chunk in filtered_chunks:
|
||||
text_lower = chunk.text.lower()
|
||||
match_count = sum(1 for w in words_lower if w in text_lower)
|
||||
if match_count == 0:
|
||||
|
|
@ -271,6 +280,7 @@ class LocalFileStore(BaseFileStore):
|
|||
limit: int,
|
||||
vector_weight: float = 0.7,
|
||||
candidate_multiplier: float = 3.0,
|
||||
search_filter: SearchFilter | None = None,
|
||||
) -> list[FileChunk]:
|
||||
"""Hybrid search combining vector and keyword results."""
|
||||
assert 0.0 <= vector_weight <= 1.0
|
||||
|
|
@ -279,8 +289,8 @@ class LocalFileStore(BaseFileStore):
|
|||
text_weight = 1.0 - vector_weight
|
||||
|
||||
if self.vector_enabled and self.fts_enabled:
|
||||
keyword_results = await self.keyword_search(query, candidates)
|
||||
vector_results = await self.vector_search(query, candidates)
|
||||
keyword_results = await self.keyword_search(query, candidates, search_filter)
|
||||
vector_results = await self.vector_search(query, candidates, search_filter)
|
||||
|
||||
if not keyword_results:
|
||||
return vector_results[:limit]
|
||||
|
|
@ -295,9 +305,9 @@ class LocalFileStore(BaseFileStore):
|
|||
)
|
||||
return merged[:limit]
|
||||
elif self.vector_enabled:
|
||||
return await self.vector_search(query, limit)
|
||||
return await self.vector_search(query, limit, search_filter)
|
||||
elif self.fts_enabled:
|
||||
return await self.keyword_search(query, limit)
|
||||
return await self.keyword_search(query, limit, search_filter)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""File watcher implementations for monitoring file system changes."""
|
||||
|
||||
from .base_file_watcher import BaseFileWatcher
|
||||
from .md_file_watcher import MdFileWatcher
|
||||
from .full_file_watcher import FullFileWatcher
|
||||
from .light_file_watcher import LightFileWatcher
|
||||
|
||||
__all__ = [
|
||||
"BaseFileWatcher",
|
||||
"MdFileWatcher",
|
||||
"FullFileWatcher",
|
||||
"LightFileWatcher",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from pathlib import Path
|
|||
from watchfiles import Change, awatch
|
||||
|
||||
from ..base_component import BaseComponent
|
||||
from ..file_parser import BaseFileParser
|
||||
from ..file_store import BaseFileStore
|
||||
from ...enumeration import ComponentEnum
|
||||
|
||||
|
|
@ -16,7 +17,7 @@ class BaseFileWatcher(BaseComponent):
|
|||
|
||||
Provides file monitoring with:
|
||||
- watchfiles integration for efficient change detection
|
||||
- Suffix-based filtering
|
||||
- Parser-based file filtering
|
||||
- Auto-restart on failure
|
||||
- Optional index rebuild on start
|
||||
"""
|
||||
|
|
@ -26,34 +27,23 @@ class BaseFileWatcher(BaseComponent):
|
|||
def __init__(
|
||||
self,
|
||||
watch_paths: list[str] | str,
|
||||
suffix_filters: list[str] | None = None,
|
||||
recursive: bool = False,
|
||||
debounce: int = 2000,
|
||||
chunk_tokens: int = 400,
|
||||
chunk_overlap: int = 80,
|
||||
file_store: str = "default",
|
||||
default_parser: str | None = None,
|
||||
rebuild_index_on_start: bool = True,
|
||||
poll_delay_ms: int = 2000,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize file watcher configuration.
|
||||
|
||||
Args:
|
||||
watch_paths: Paths to watch for changes.
|
||||
suffix_filters: File suffix filters (e.g., ['.py', '.txt']).
|
||||
recursive: Whether to watch directories recursively.
|
||||
debounce: Debounce time in milliseconds.
|
||||
chunk_tokens: Token size for chunking.
|
||||
chunk_overlap: Overlap size for chunks.
|
||||
file_store: Name of the file store component.
|
||||
rebuild_index_on_start: Clear index and rescan files on start.
|
||||
poll_delay_ms: Polling delay in milliseconds.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._file_store_name: str = file_store
|
||||
self._default_parser_name: str | None = default_parser
|
||||
self.file_store: BaseFileStore | None = None
|
||||
self._suffix_to_parser: dict[str, BaseFileParser] = {}
|
||||
self._default_parser: BaseFileParser | None = None
|
||||
self.watch_paths: list[str] = [watch_paths] if isinstance(watch_paths, str) else watch_paths
|
||||
self.suffix_filters: list[str] = suffix_filters or []
|
||||
self.recursive: bool = recursive
|
||||
self.debounce: int = debounce
|
||||
self.chunk_tokens: int = chunk_tokens
|
||||
|
|
@ -68,6 +58,7 @@ class BaseFileWatcher(BaseComponent):
|
|||
"""Resolve file_store and start watching task."""
|
||||
if self._file_store_name:
|
||||
assert app_context is not None, "app_context must be provided"
|
||||
|
||||
stores = app_context.components.get(ComponentEnum.FILE_STORE, {})
|
||||
if self._file_store_name not in stores:
|
||||
raise ValueError(f"File store '{self._file_store_name}' not found.")
|
||||
|
|
@ -76,6 +67,20 @@ class BaseFileWatcher(BaseComponent):
|
|||
raise TypeError(f"Expected BaseFileStore, got {type(store).__name__}")
|
||||
self.file_store = store
|
||||
|
||||
parsers = app_context.components.get(ComponentEnum.FILE_PARSER, {})
|
||||
for parser in parsers.values():
|
||||
if isinstance(parser, BaseFileParser):
|
||||
for suffix in parser.suffixes:
|
||||
self._suffix_to_parser[suffix] = parser
|
||||
|
||||
if self._default_parser_name and self._default_parser_name in parsers:
|
||||
parser = parsers[self._default_parser_name]
|
||||
if isinstance(parser, BaseFileParser):
|
||||
self._default_parser = parser
|
||||
|
||||
if not self._suffix_to_parser and not self._default_parser:
|
||||
self.logger.warning("No file parsers registered")
|
||||
|
||||
async def _initialize_and_watch():
|
||||
if self.rebuild_index_on_start and self.file_store:
|
||||
await self.file_store.clear_all()
|
||||
|
|
@ -100,18 +105,10 @@ class BaseFileWatcher(BaseComponent):
|
|||
self._watch_task = None
|
||||
self._stop_event.clear()
|
||||
self.file_store = None
|
||||
self._suffix_to_parser.clear()
|
||||
self._default_parser = None
|
||||
self.logger.info("Stopped watching")
|
||||
|
||||
def watch_filter(self, _change: Change, path: str) -> bool:
|
||||
"""Filter files by suffix. Returns True if no filters configured."""
|
||||
if not self.suffix_filters:
|
||||
return True
|
||||
|
||||
for suffix in self.suffix_filters:
|
||||
if path.endswith("." + suffix.strip(".")):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _scan_existing_files(self):
|
||||
"""Scan existing files and add them as Change.added."""
|
||||
if not self.file_store:
|
||||
|
|
@ -127,17 +124,12 @@ class BaseFileWatcher(BaseComponent):
|
|||
continue
|
||||
|
||||
if watch_path.is_file():
|
||||
if self.watch_filter(Change.added, str(watch_path)):
|
||||
existing_files.add((Change.added, str(watch_path)))
|
||||
existing_files.add((Change.added, str(watch_path)))
|
||||
elif watch_path.is_dir():
|
||||
if self.recursive:
|
||||
for file_path in watch_path.rglob("*"):
|
||||
if file_path.is_file() and self.watch_filter(Change.added, str(file_path)):
|
||||
existing_files.add((Change.added, str(file_path)))
|
||||
else:
|
||||
for file_path in watch_path.iterdir():
|
||||
if file_path.is_file() and self.watch_filter(Change.added, str(file_path)):
|
||||
existing_files.add((Change.added, str(file_path)))
|
||||
iterator = watch_path.rglob("*") if self.recursive else watch_path.iterdir()
|
||||
for file_path in iterator:
|
||||
if file_path.is_file():
|
||||
existing_files.add((Change.added, str(file_path)))
|
||||
|
||||
if existing_files:
|
||||
self.logger.info(f"[SCAN_ON_START] Found {len(existing_files)} existing files")
|
||||
|
|
@ -180,7 +172,6 @@ class BaseFileWatcher(BaseComponent):
|
|||
self.logger.info(f"Starting watch on: {valid_paths}")
|
||||
async for changes in awatch(
|
||||
*valid_paths,
|
||||
watch_filter=self.watch_filter,
|
||||
recursive=self.recursive,
|
||||
debounce=self.debounce,
|
||||
poll_delay_ms=self.poll_delay_ms,
|
||||
|
|
|
|||
48
reme2/component/file_watcher/full_file_watcher.py
Normal file
48
reme2/component/file_watcher/full_file_watcher.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Full file watcher for all supported file types."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from watchfiles import Change
|
||||
|
||||
from .base_file_watcher import BaseFileWatcher
|
||||
from ..component_registry import R
|
||||
|
||||
|
||||
@R.register("full")
|
||||
class FullFileWatcher(BaseFileWatcher):
|
||||
"""Watches all supported file types and delegates parsing to registered parsers."""
|
||||
|
||||
async def _on_changes(self, changes: set[tuple[Change, str]]):
|
||||
"""Handle file changes by delegating to appropriate parsers."""
|
||||
if not self.file_store:
|
||||
self.logger.warning("File store not initialized, skipping changes")
|
||||
return
|
||||
|
||||
for change_type, path in changes:
|
||||
try:
|
||||
if change_type in (Change.added, Change.modified):
|
||||
suffix = Path(path).suffix.lower()
|
||||
parser = self._suffix_to_parser.get(suffix, self._default_parser)
|
||||
if not parser:
|
||||
self.logger.debug(f"No parser available for file type {suffix}: {path}, skipping")
|
||||
continue
|
||||
|
||||
file_meta, chunks = await parser.parse(path)
|
||||
await self.file_store.upsert_file(file_meta, chunks)
|
||||
self.logger.info(f"Upserted {len(chunks)} chunks for {file_meta.path}")
|
||||
|
||||
elif change_type == Change.deleted:
|
||||
await self.file_store.delete_file(path)
|
||||
self.logger.info(f"Deleted {path}")
|
||||
|
||||
else:
|
||||
self.logger.warning(f"Unknown change type: {change_type}")
|
||||
|
||||
self.logger.info(f"File {change_type} changed: {path}")
|
||||
|
||||
except FileNotFoundError:
|
||||
self.logger.warning(f"File not found: {path}, skipping")
|
||||
except PermissionError:
|
||||
self.logger.warning(f"Permission denied: {path}, skipping")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing {path}: {e}", exc_info=True)
|
||||
53
reme2/component/file_watcher/light_file_watcher.py
Normal file
53
reme2/component/file_watcher/light_file_watcher.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Lightweight file watcher for Markdown files only."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from watchfiles import Change
|
||||
|
||||
from .base_file_watcher import BaseFileWatcher
|
||||
from ..component_registry import R
|
||||
|
||||
|
||||
@R.register("light")
|
||||
class LightFileWatcher(BaseFileWatcher):
|
||||
"""Watches only Markdown files and delegates parsing to registered parsers."""
|
||||
|
||||
async def _on_changes(self, changes: set[tuple[Change, str]]):
|
||||
"""Handle file changes by filtering for Markdown files only."""
|
||||
if not self.file_store:
|
||||
self.logger.warning("File store not initialized, skipping changes")
|
||||
return
|
||||
|
||||
# Filter changes to only include Markdown files
|
||||
md_changes = {
|
||||
(change_type, path) for change_type, path in changes if Path(path).suffix.lower() in [".md", ".markdown"]
|
||||
}
|
||||
|
||||
for change_type, path in md_changes:
|
||||
try:
|
||||
if change_type in (Change.added, Change.modified):
|
||||
# Use Markdown parser for Markdown files
|
||||
parser = self._suffix_to_parser.get(".md", self._default_parser)
|
||||
if not parser:
|
||||
self.logger.warning(f"No parser available for Markdown file: {path}")
|
||||
continue
|
||||
|
||||
file_meta, chunks = await parser.parse(path)
|
||||
await self.file_store.upsert_file(file_meta, chunks)
|
||||
self.logger.info(f"Upserted {len(chunks)} chunks for {file_meta.path}")
|
||||
|
||||
elif change_type == Change.deleted:
|
||||
await self.file_store.delete_file(path)
|
||||
self.logger.info(f"Deleted {path}")
|
||||
|
||||
else:
|
||||
self.logger.warning(f"Unknown change type: {change_type}")
|
||||
|
||||
self.logger.info(f"Markdown file {change_type} changed: {path}")
|
||||
|
||||
except FileNotFoundError:
|
||||
self.logger.warning(f"File not found: {path}, skipping")
|
||||
except PermissionError:
|
||||
self.logger.warning(f"Permission denied: {path}, skipping")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing {path}: {e}", exc_info=True)
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
"""Markdown file watcher for synchronization."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from watchfiles import Change
|
||||
|
||||
from .base_file_watcher import BaseFileWatcher
|
||||
from ..component_registry import R
|
||||
from ...schema import FileMetadata
|
||||
from ...utils import hash_text, chunk_markdown
|
||||
|
||||
|
||||
@R.register("md")
|
||||
class MdFileWatcher(BaseFileWatcher):
|
||||
"""Markdown file watcher that syncs .md files to memory store."""
|
||||
|
||||
def __init__(self, encoding: str = "utf-8", **kwargs):
|
||||
"""Initialize Markdown file watcher.
|
||||
|
||||
Args:
|
||||
encoding: File encoding.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.encoding = encoding
|
||||
|
||||
async def _on_changes(self, changes: set[tuple[Change, str]]):
|
||||
"""Handle file changes with full synchronization."""
|
||||
if not self.file_store:
|
||||
self.logger.warning("File store not initialized, skipping changes")
|
||||
return
|
||||
|
||||
for change_type, path in changes:
|
||||
try:
|
||||
if change_type in [Change.added, Change.modified]:
|
||||
file_meta = await self._build_file_metadata(path)
|
||||
chunks = (
|
||||
chunk_markdown(
|
||||
file_meta.content,
|
||||
file_meta.path,
|
||||
self.chunk_tokens,
|
||||
self.chunk_overlap,
|
||||
)
|
||||
or []
|
||||
)
|
||||
if chunks:
|
||||
chunks = await self.file_store.get_chunk_embeddings(chunks)
|
||||
file_meta.chunk_count = len(chunks)
|
||||
|
||||
await self.file_store.delete_file(file_meta.path)
|
||||
self.logger.info(f"delete_file {file_meta.path}")
|
||||
|
||||
await self.file_store.upsert_file(file_meta, chunks)
|
||||
self.logger.info(f"Upserted {file_meta.chunk_count} chunks for {file_meta.path}")
|
||||
|
||||
elif change_type == Change.deleted:
|
||||
await self.file_store.delete_file(path)
|
||||
self.logger.info(f"Deleted {path}")
|
||||
|
||||
else:
|
||||
self.logger.warning(f"Unknown change type: {change_type}")
|
||||
|
||||
self.logger.info(f"File {change_type} changed: {path}")
|
||||
|
||||
except FileNotFoundError:
|
||||
self.logger.warning(f"File not found: {path}, skipping")
|
||||
except PermissionError:
|
||||
self.logger.warning(f"Permission denied: {path}, skipping")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing {path}: {e}", exc_info=True)
|
||||
|
||||
async def _build_file_metadata(self, path: str) -> FileMetadata:
|
||||
"""Build FileMetadata from file path."""
|
||||
file_path = Path(path)
|
||||
|
||||
def _read_file_sync():
|
||||
return file_path.stat(), file_path.read_text(encoding=self.encoding)
|
||||
|
||||
stat, content = await asyncio.to_thread(_read_file_sync)
|
||||
return FileMetadata(
|
||||
hash=hash_text(content),
|
||||
mtime_ms=stat.st_mtime * 1000,
|
||||
size=stat.st_size,
|
||||
path=str(file_path.absolute()),
|
||||
content=content,
|
||||
)
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
"""Abstract base class for service implementations."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ class HttpService(BaseService):
|
|||
server-sent events (SSE) for real-time streaming.
|
||||
"""
|
||||
|
||||
from ...application import Application
|
||||
# Removed the circular import - using string annotation instead
|
||||
# from ...application import Application
|
||||
|
||||
def __init__(self, host: str = REME_DEFAULT_HOST, port: int = REME_DEFAULT_PORT, **kwargs):
|
||||
"""Initialize the HTTP service.
|
||||
|
|
@ -97,7 +98,7 @@ class HttpService(BaseService):
|
|||
else:
|
||||
self._add_job(job)
|
||||
|
||||
def build_service(self, app: Application) -> None:
|
||||
def build_service(self, app: "Application") -> None:
|
||||
"""Build the FastAPI application with CORS middleware.
|
||||
|
||||
Args:
|
||||
|
|
@ -129,7 +130,7 @@ class HttpService(BaseService):
|
|||
)
|
||||
self.service.post("/health")(lambda: {"status": "healthy"})
|
||||
|
||||
def start_service(self, app: Application) -> None:
|
||||
def start_service(self, app: "Application") -> None:
|
||||
"""Start the HTTP server.
|
||||
|
||||
Args:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ reme version
|
|||
reme vault="My Vault"
|
||||
|
||||
|
||||
# daily
|
||||
# daily
|
||||
daily:path
|
||||
reme daily:xxx
|
||||
|
||||
|
|
@ -28,12 +28,12 @@ reme list file/path
|
|||
reme search query="search term" limit=10 tag="[]" score=0.1 copy=true
|
||||
|
||||
# property
|
||||
reme property:read
|
||||
reme property:read
|
||||
reme property:update file="My Note" status=done xx=xxx
|
||||
reme property:delete keys="[xxxx, xxxx]"
|
||||
|
||||
# 全局所有标签
|
||||
reme tags
|
||||
reme tags
|
||||
|
||||
# show link
|
||||
reme backlinks file="My Note"
|
||||
|
|
@ -3,7 +3,7 @@ log_to_console: true
|
|||
log_to_file: false
|
||||
|
||||
service:
|
||||
backend: cmd
|
||||
backend: http
|
||||
|
||||
jobs:
|
||||
- backend: base
|
||||
|
|
@ -22,18 +22,19 @@ jobs:
|
|||
backend: xxx
|
||||
|
||||
components:
|
||||
as_llms:
|
||||
as_llm:
|
||||
default:
|
||||
backend: openai
|
||||
model_name: qwen3.6-plus
|
||||
|
||||
as_llm_formatters:
|
||||
as_llm_formatter:
|
||||
default:
|
||||
backend: openai
|
||||
|
||||
embedding_models:
|
||||
embedding_model:
|
||||
default:
|
||||
backend: openai
|
||||
model_name: text-embedding-v3
|
||||
dimensions: 1024
|
||||
use_dimensions: false
|
||||
enable_cache: true
|
||||
|
|
@ -41,15 +42,23 @@ components:
|
|||
max_cache_size: 2000
|
||||
max_input_length: 8192
|
||||
|
||||
file_stores:
|
||||
file_parser:
|
||||
md:
|
||||
backend: md
|
||||
default:
|
||||
backend: chroma
|
||||
backend: default
|
||||
|
||||
file_store:
|
||||
default:
|
||||
backend: local
|
||||
embedding_model: default
|
||||
store_name: "reme"
|
||||
db_path: ".reme/store"
|
||||
|
||||
file_watchers:
|
||||
file_watcher:
|
||||
default:
|
||||
backend: full
|
||||
file_store: default
|
||||
suffix_filters: [ ".md" ]
|
||||
recursive: false
|
||||
default_parser: default
|
||||
watch_paths: ["./test_data"]
|
||||
recursive: true
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class ComponentEnum(str, Enum):
|
|||
|
||||
EMBEDDING_MODEL = "embedding_model"
|
||||
|
||||
FILE_PARSER = "file_parser"
|
||||
|
||||
FILE_STORE = "file_store"
|
||||
|
||||
FILE_WATCHER = "file_watcher"
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@ class FileIO:
|
|||
return str(self.working_dir / file_path)
|
||||
|
||||
async def read_file( # pylint: disable=too-many-return-statements
|
||||
self,
|
||||
file_path: str,
|
||||
start_line: int | None = None,
|
||||
end_line: int | None = None,
|
||||
self,
|
||||
file_path: str,
|
||||
start_line: int | None = None,
|
||||
end_line: int | None = None,
|
||||
) -> ToolResponse:
|
||||
"""Read a file. Relative paths resolve from WORKING_DIR.
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ class FileIO:
|
|||
)
|
||||
|
||||
# Extract selected lines
|
||||
selected_content = "\n".join(all_lines[s - 1: e])
|
||||
selected_content = "\n".join(all_lines[s - 1 : e])
|
||||
|
||||
# Apply smart truncation (consistent with shell output format)
|
||||
text = truncate_text_output(
|
||||
|
|
@ -154,14 +154,13 @@ class FileIO:
|
|||
if text == selected_content and e < total:
|
||||
content_bytes = len(text.encode("utf-8"))
|
||||
notice = (
|
||||
TRUNCATION_NOTICE_MARKER +
|
||||
f"\nThe output above was truncated."
|
||||
f"\nThe full content is saved to the file "
|
||||
f"and contains {total} lines in total."
|
||||
f"\nThis excerpt starts at line {s} and "
|
||||
f"covers the next {content_bytes} bytes."
|
||||
"\nIf the current content is not enough, "
|
||||
f"call `read_file` with file_path={file_path} start_line={e + 1} to read more."
|
||||
TRUNCATION_NOTICE_MARKER + f"\nThe output above was truncated."
|
||||
f"\nThe full content is saved to the file "
|
||||
f"and contains {total} lines in total."
|
||||
f"\nThis excerpt starts at line {s} and "
|
||||
f"covers the next {content_bytes} bytes."
|
||||
"\nIf the current content is not enough, "
|
||||
f"call `read_file` with file_path={file_path} start_line={e + 1} to read more."
|
||||
)
|
||||
text = text + notice
|
||||
|
||||
|
|
@ -180,9 +179,9 @@ class FileIO:
|
|||
)
|
||||
|
||||
async def write_file(
|
||||
self,
|
||||
file_path: str,
|
||||
content: str,
|
||||
self,
|
||||
file_path: str,
|
||||
content: str,
|
||||
) -> ToolResponse:
|
||||
"""Create or overwrite a file. Relative paths resolve from working_dir.
|
||||
|
||||
|
|
@ -227,10 +226,10 @@ class FileIO:
|
|||
|
||||
# pylint: disable=too-many-return-statements
|
||||
async def edit_file(
|
||||
self,
|
||||
file_path: str,
|
||||
old_text: str,
|
||||
new_text: str,
|
||||
self,
|
||||
file_path: str,
|
||||
old_text: str,
|
||||
new_text: str,
|
||||
) -> ToolResponse:
|
||||
"""Find-and-replace text in a file. All occurrences of old_text are
|
||||
replaced with new_text. Relative paths resolve from working_dir.
|
||||
|
|
@ -315,9 +314,9 @@ class FileIO:
|
|||
)
|
||||
|
||||
async def append_file(
|
||||
self,
|
||||
file_path: str,
|
||||
content: str,
|
||||
self,
|
||||
file_path: str,
|
||||
content: str,
|
||||
) -> ToolResponse:
|
||||
"""Append content to the end of a file. Relative paths resolve from
|
||||
working_dir.
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ from ..constants import (
|
|||
|
||||
|
||||
def _truncate_fresh(
|
||||
text: str,
|
||||
start_line: int,
|
||||
total_lines: int,
|
||||
max_bytes: int,
|
||||
file_path: str | None,
|
||||
encoding: str,
|
||||
text: str,
|
||||
start_line: int,
|
||||
total_lines: int,
|
||||
max_bytes: int,
|
||||
file_path: str | None,
|
||||
encoding: str,
|
||||
) -> str:
|
||||
"""Truncate fresh text (no prior truncation marker) by bytes with line integrity.
|
||||
|
||||
|
|
@ -66,21 +66,20 @@ def _truncate_fresh(
|
|||
return result
|
||||
|
||||
notice = (
|
||||
TRUNCATION_NOTICE_MARKER
|
||||
+ f"\nThe output above was truncated."
|
||||
f"\nThe full content is saved to the file and contains {total_lines} lines in total."
|
||||
f"\nThis excerpt starts at line {start_line} and covers the next {max_bytes} bytes."
|
||||
f"\nIf the current content is not enough, call `read_file` with file_path={file_path or ''} "
|
||||
f"start_line={read_from} to read more."
|
||||
TRUNCATION_NOTICE_MARKER + f"\nThe output above was truncated."
|
||||
f"\nThe full content is saved to the file and contains {total_lines} lines in total."
|
||||
f"\nThis excerpt starts at line {start_line} and covers the next {max_bytes} bytes."
|
||||
f"\nIf the current content is not enough, call `read_file` with file_path={file_path or ''} "
|
||||
f"start_line={read_from} to read more."
|
||||
)
|
||||
|
||||
return result + notice
|
||||
|
||||
|
||||
def _retruncate(
|
||||
text: str,
|
||||
max_bytes: int,
|
||||
encoding: str,
|
||||
text: str,
|
||||
max_bytes: int,
|
||||
encoding: str,
|
||||
) -> str:
|
||||
"""Re-truncate text that was previously truncated (contains TRUNCATION_NOTICE_MARKER).
|
||||
|
||||
|
|
@ -133,12 +132,12 @@ def _retruncate(
|
|||
|
||||
|
||||
def truncate_text_output(
|
||||
text: str,
|
||||
start_line: int = 1,
|
||||
total_lines: int = 0,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
file_path: str | None = None,
|
||||
encoding: str = "utf-8",
|
||||
text: str,
|
||||
start_line: int = 1,
|
||||
total_lines: int = 0,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
file_path: str | None = None,
|
||||
encoding: str = "utf-8",
|
||||
) -> str:
|
||||
"""Truncate file output by bytes with line integrity.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import json
|
|||
from ..component import R
|
||||
from ..component.base_step import BaseStep
|
||||
from ..enumeration import ComponentEnum
|
||||
from ..schema import SearchFilter
|
||||
|
||||
|
||||
@R.register("memory_search")
|
||||
|
|
@ -33,17 +34,26 @@ class MemorySearch(BaseStep):
|
|||
max_results: int = self.context.get("max_results", 5)
|
||||
|
||||
assert query, "Query cannot be empty"
|
||||
assert isinstance(min_score, float | int) and 0.0 <= min_score <= 1.0, \
|
||||
f"min_score must be between 0 and 1, got {min_score}"
|
||||
assert isinstance(max_results, int) and max_results > 0, \
|
||||
f"max_results must be a positive integer, got {max_results}"
|
||||
assert (
|
||||
isinstance(min_score, float | int) and 0.0 <= min_score <= 1.0
|
||||
), f"min_score must be between 0 and 1, got {min_score}"
|
||||
assert (
|
||||
isinstance(max_results, int) and max_results > 0
|
||||
), f"max_results must be a positive integer, got {max_results}"
|
||||
|
||||
filter_paths: list[str] | None = self.context.get("paths") or None
|
||||
filter_tags: list[str] | None = self.context.get("tags") or None
|
||||
exclude_paths: list[str] | None = self.context.get("exclude_paths") or None
|
||||
search_filter = None
|
||||
if filter_paths or filter_tags or exclude_paths:
|
||||
search_filter = SearchFilter(paths=filter_paths, tags=filter_tags, exclude_paths=exclude_paths)
|
||||
|
||||
# Use hybrid_search from file_store
|
||||
results = await self.file_store.hybrid_search(
|
||||
query=query,
|
||||
limit=max_results,
|
||||
vector_weight=self.vector_weight,
|
||||
candidate_multiplier=self.candidate_multiplier,
|
||||
search_filter=search_filter,
|
||||
)
|
||||
|
||||
# Filter by min_score
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from agentscope.agent import ReActAgent
|
|||
from agentscope.message import Msg
|
||||
from agentscope.token import HuggingFaceTokenCounter
|
||||
from agentscope.tool import Toolkit
|
||||
from loguru import logger
|
||||
|
||||
from ..component import BaseStep
|
||||
from ..schema import AsMsgStat, AsBlockStat
|
||||
|
|
@ -17,16 +18,16 @@ class Summarizer(BaseStep):
|
|||
"""Summarizer step for summarizing memory messages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
working_dir: str,
|
||||
memory_dir: str,
|
||||
memory_compact_threshold: int,
|
||||
toolkit: Toolkit | None = None,
|
||||
console_enabled: bool = False,
|
||||
timezone: str | None = None,
|
||||
add_thinking_block: bool = True,
|
||||
as_token_counter: HuggingFaceTokenCounter | None = None,
|
||||
**kwargs,
|
||||
self,
|
||||
working_dir: str,
|
||||
memory_dir: str,
|
||||
memory_compact_threshold: int,
|
||||
toolkit: Toolkit | None = None,
|
||||
console_enabled: bool = False,
|
||||
timezone: str | None = None,
|
||||
add_thinking_block: bool = True,
|
||||
as_token_counter: HuggingFaceTokenCounter | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the summarizer step.
|
||||
|
||||
|
|
@ -51,7 +52,6 @@ class Summarizer(BaseStep):
|
|||
self.add_thinking_block: bool = add_thinking_block
|
||||
self._as_token_counter: HuggingFaceTokenCounter | None = as_token_counter
|
||||
|
||||
|
||||
def _get_current_datetime(self) -> datetime.datetime:
|
||||
"""Get current datetime with timezone, fallback to local time if timezone is invalid."""
|
||||
if self.timezone:
|
||||
|
|
@ -227,10 +227,10 @@ class Summarizer(BaseStep):
|
|||
return total
|
||||
|
||||
async def _format_msgs_to_str(
|
||||
self,
|
||||
messages: list[Msg],
|
||||
memory_compact_threshold: int,
|
||||
include_thinking: bool = True,
|
||||
self,
|
||||
messages: list[Msg],
|
||||
memory_compact_threshold: int,
|
||||
include_thinking: bool = True,
|
||||
) -> str:
|
||||
"""Format list of messages to a single formatted string.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,20 +20,19 @@ from .utils import run_coro_safely
|
|||
|
||||
class ReMe(Application):
|
||||
"""ReMe memory management application."""
|
||||
memory_path = working_path / "memory"
|
||||
memory_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def summarize(
|
||||
self,
|
||||
messages: list[Msg],
|
||||
as_llm: str | ChatModelBase = "default",
|
||||
as_llm_formatter: str | FormatterBase = "default",
|
||||
as_token_counter: str | TokenCounterBase | HuggingFaceTokenCounter = "default",
|
||||
toolkit: Toolkit | None = None,
|
||||
language: str = "zh",
|
||||
max_input_length: float = 128 * 1024,
|
||||
compact_ratio: float = 0.7,
|
||||
timezone: str | None = None,
|
||||
add_thinking_block: bool = True,
|
||||
self,
|
||||
messages: list[Msg],
|
||||
as_llm: str | ChatModelBase = "default",
|
||||
as_llm_formatter: str | FormatterBase = "default",
|
||||
as_token_counter: str | TokenCounterBase | HuggingFaceTokenCounter = "default",
|
||||
toolkit: Toolkit | None = None,
|
||||
language: str = "zh",
|
||||
max_input_length: float = 128 * 1024,
|
||||
compact_ratio: float = 0.7,
|
||||
timezone: str | None = None,
|
||||
add_thinking_block: bool = True,
|
||||
) -> str:
|
||||
"""Summarize and compact memory messages.
|
||||
|
||||
|
|
@ -87,6 +86,7 @@ class ReMe(Application):
|
|||
async def memory_search(self, query: str, max_results: int = 5, min_score: float = 0.1) -> str:
|
||||
"""Search memory for relevant entries."""
|
||||
from .file_based.memory_search import MemorySearch
|
||||
|
||||
try:
|
||||
search_step = MemorySearch()
|
||||
self.logger.info(f"Running memory search with {query} {max_results} {min_score}")
|
||||
|
|
@ -95,26 +95,28 @@ class ReMe(Application):
|
|||
return str(e)
|
||||
|
||||
async def dream(
|
||||
self,
|
||||
as_llm: str | ChatModelBase = "default",
|
||||
as_llm_formatter: str | FormatterBase = "default",
|
||||
as_token_counter: str | TokenCounterBase = "default",
|
||||
toolkit: Toolkit | None = None,
|
||||
language: str = "zh",
|
||||
timezone: str | None = None,
|
||||
self,
|
||||
as_llm: str | ChatModelBase = "default",
|
||||
as_llm_formatter: str | FormatterBase = "default",
|
||||
as_token_counter: str | TokenCounterBase = "default",
|
||||
toolkit: Toolkit | None = None,
|
||||
language: str = "zh",
|
||||
timezone: str | None = None,
|
||||
) -> str:
|
||||
"""Process and consolidate memories in background."""
|
||||
return ""
|
||||
|
||||
async def proactive(
|
||||
self,
|
||||
as_llm: str | ChatModelBase = "default",
|
||||
as_llm_formatter: str | FormatterBase = "default",
|
||||
as_token_counter: str | TokenCounterBase = "default",
|
||||
toolkit: Toolkit | None = None,
|
||||
language: str = "zh",
|
||||
timezone: str | None = None,
|
||||
self,
|
||||
as_llm: str | ChatModelBase = "default",
|
||||
as_llm_formatter: str | FormatterBase = "default",
|
||||
as_token_counter: str | TokenCounterBase = "default",
|
||||
toolkit: Toolkit | None = None,
|
||||
language: str = "zh",
|
||||
timezone: str | None = None,
|
||||
) -> str:
|
||||
"""Generate proactive memory insights."""
|
||||
return ""
|
||||
|
||||
|
||||
class ReMeLight(ReMe):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from .file_chunk import FileChunk
|
|||
from .file_metadata import FileMetadata
|
||||
from .request import Request
|
||||
from .response import Response
|
||||
from .search_filter import SearchFilter
|
||||
from .stream_chunk import StreamChunk
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -20,5 +21,6 @@ __all__ = [
|
|||
"FileMetadata",
|
||||
"Request",
|
||||
"Response",
|
||||
"SearchFilter",
|
||||
"StreamChunk",
|
||||
]
|
||||
|
|
|
|||
36
reme2/schema/search_filter.py
Normal file
36
reme2/schema/search_filter.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""Search filter schema for constraining search results."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchFilter(BaseModel):
|
||||
"""Filter conditions for search operations.
|
||||
|
||||
All specified conditions are combined with AND logic.
|
||||
Within paths/exclude_paths, items are combined with OR logic.
|
||||
Within tags, items are combined with AND logic (all must match).
|
||||
"""
|
||||
|
||||
paths: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Include only chunks whose path starts with any of these prefixes",
|
||||
)
|
||||
tags: list[str] | None = Field(default=None, description="Include only chunks containing ALL specified tags")
|
||||
exclude_paths: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Exclude chunks whose path starts with any of these prefixes",
|
||||
)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not self.paths and not self.tags and not self.exclude_paths
|
||||
|
||||
def match(self, path: str, metadata: dict | None = None) -> bool:
|
||||
if self.paths and not any(path.startswith(p) for p in self.paths):
|
||||
return False
|
||||
if self.exclude_paths and any(path.startswith(p) for p in self.exclude_paths):
|
||||
return False
|
||||
if self.tags:
|
||||
chunk_tags = set((metadata or {}).get("tags", []))
|
||||
if not all(t in chunk_tags for t in self.tags):
|
||||
return False
|
||||
return True
|
||||
201
tests/test_reme2.py
Normal file
201
tests/test_reme2.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from reme2.config.config_parser import _load_yaml
|
||||
from reme2.reme import ReMe
|
||||
|
||||
|
||||
async def test_full_watcher():
|
||||
"""测试full watcher模式 - 处理所有文件类型"""
|
||||
print("\n=== 测试 Full Watcher 模式 ===")
|
||||
|
||||
config = _load_yaml("paw")
|
||||
|
||||
# 设置API密钥
|
||||
config["components"]["embedding_model"]["default"]["api_key"] = os.environ["EMBEDDING_API_KEY"]
|
||||
config["components"]["embedding_model"]["default"]["base_url"] = os.environ["EMBEDDING_BASE_URL"]
|
||||
|
||||
config["components"]["as_llm"]["default"]["api_key"] = os.environ["LLM_API_KEY"]
|
||||
config["components"]["as_llm"]["default"]["base_url"] = os.environ["LLM_BASE_URL"]
|
||||
config["components"]["as_llm"]["default"]["model_name"] = os.environ["LLM_MODEL_NAME"]
|
||||
|
||||
# 创建临时目录用于测试
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# 创建测试文件
|
||||
md_file = temp_path / "test.md"
|
||||
txt_file = temp_path / "test.txt"
|
||||
py_file = temp_path / "test.py"
|
||||
|
||||
md_content = """# Test Markdown File
|
||||
|
||||
This is a test markdown file for full watcher.
|
||||
|
||||
## Section
|
||||
Some content here about Python programming.
|
||||
"""
|
||||
|
||||
txt_content = """Plain text file for testing full watcher.
|
||||
Contains some interesting content about machine learning AI."""
|
||||
|
||||
py_content = '''"""
|
||||
Test Python file for full watcher.
|
||||
Demonstrates Python programming concepts.
|
||||
"""
|
||||
def hello_world():
|
||||
"""A simple function."""
|
||||
print("Hello, world!")
|
||||
# Machine learning related code
|
||||
return True
|
||||
'''
|
||||
|
||||
md_file.write_text(md_content)
|
||||
txt_file.write_text(txt_content)
|
||||
py_file.write_text(py_content)
|
||||
|
||||
# 更新配置使用临时目录
|
||||
config["components"]["file_watcher"]["default"]["watch_paths"] = [str(temp_path)]
|
||||
config["components"]["file_watcher"]["default"]["recursive"] = False
|
||||
|
||||
reme = ReMe(**config)
|
||||
|
||||
# Start components (embedding, file_store, file_parser, file_watcher)
|
||||
await reme.start()
|
||||
|
||||
# Wait for watcher to scan existing files
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Test: list indexed files
|
||||
from reme2.enumeration import ComponentEnum
|
||||
|
||||
store = reme.context.components[ComponentEnum.FILE_STORE]["default"]
|
||||
|
||||
files = await store.list_files()
|
||||
print(f"\n=== Indexed files by Full Watcher ({len(files)}) ===")
|
||||
for f in files:
|
||||
meta = await store.get_file_metadata(f)
|
||||
chunks = await store.get_file_chunks(f)
|
||||
print(f" {f} — {meta.chunk_count} chunks, metadata={meta.metadata}")
|
||||
|
||||
# Test: keyword search
|
||||
results = await store.keyword_search("Python programming", limit=5)
|
||||
print(f"\n=== Keyword search 'Python programming' ({len(results)} results) ===")
|
||||
for r in results:
|
||||
print(f" [{r.score:.2f}] {r.path}:{r.start_line}-{r.end_line} — {r.text[:80]}")
|
||||
|
||||
# Test: hybrid search
|
||||
results = await store.hybrid_search("machine learning AI", limit=5)
|
||||
print(f"\n=== Hybrid search 'machine learning AI' ({len(results)} results) ===")
|
||||
for r in results:
|
||||
print(f" [{r.score:.2f}] {r.path}:{r.start_line}-{r.end_line} — {r.text[:80]}")
|
||||
|
||||
await reme.close()
|
||||
print("\n=== Full Watcher 测试完成 ===")
|
||||
|
||||
|
||||
async def test_light_watcher():
|
||||
"""测试light watcher模式 - 只处理markdown文件"""
|
||||
print("\n=== 测试 Light Watcher 模式 ===")
|
||||
|
||||
config = _load_yaml("paw")
|
||||
|
||||
# 设置API密钥
|
||||
config["components"]["embedding_model"]["default"]["api_key"] = os.environ["EMBEDDING_API_KEY"]
|
||||
config["components"]["embedding_model"]["default"]["base_url"] = os.environ["EMBEDDING_BASE_URL"]
|
||||
|
||||
config["components"]["as_llm"]["default"]["api_key"] = os.environ["LLM_API_KEY"]
|
||||
config["components"]["as_llm"]["default"]["base_url"] = os.environ["LLM_BASE_URL"]
|
||||
config["components"]["as_llm"]["default"]["model_name"] = os.environ["LLM_MODEL_NAME"]
|
||||
|
||||
# 更改配置使用light watcher
|
||||
config["components"]["file_watcher"]["default"]["backend"] = "light"
|
||||
|
||||
# 创建临时目录用于测试
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# 创建测试文件 - 包含markdown和其他类型文件
|
||||
md_file = temp_path / "light_test.md"
|
||||
txt_file = temp_path / "light_test.txt"
|
||||
py_file = temp_path / "light_test.py"
|
||||
|
||||
md_content = """# Light Test Markdown File
|
||||
|
||||
This is a test markdown file for light watcher.
|
||||
|
||||
## Section
|
||||
Only markdown files should be processed by light watcher.
|
||||
"""
|
||||
|
||||
txt_content = "This text file should be ignored by light watcher."
|
||||
py_content = "# This Python file should be ignored by light watcher"
|
||||
|
||||
md_file.write_text(md_content)
|
||||
txt_file.write_text(txt_content)
|
||||
py_file.write_text(py_content)
|
||||
|
||||
# 更新配置使用临时目录
|
||||
config["components"]["file_watcher"]["default"]["watch_paths"] = [str(temp_path)]
|
||||
config["components"]["file_watcher"]["default"]["recursive"] = False
|
||||
|
||||
reme = ReMe(**config)
|
||||
|
||||
# Start components
|
||||
await reme.start()
|
||||
|
||||
# Wait for watcher to scan existing files
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Test: list indexed files - should only contain markdown files
|
||||
from reme2.enumeration import ComponentEnum
|
||||
|
||||
store = reme.context.components[ComponentEnum.FILE_STORE]["default"]
|
||||
|
||||
files = await store.list_files()
|
||||
print(f"\n=== Indexed files by Light Watcher ({len(files)}) ===")
|
||||
for f in files:
|
||||
# 验证只有markdown文件被索引
|
||||
file_path = Path(f)
|
||||
if file_path.suffix.lower() in [".md", ".markdown"]:
|
||||
meta = await store.get_file_metadata(f)
|
||||
chunks = await store.get_file_chunks(f)
|
||||
print(f" {f} — {meta.chunk_count} chunks, metadata={meta.metadata}")
|
||||
else:
|
||||
print(f" ERROR: Non-markdown file indexed: {f}")
|
||||
|
||||
print(f"\nExpected: Only markdown files should be indexed. Actually indexed: {len(files)} files")
|
||||
|
||||
# Test: search
|
||||
results = await store.keyword_search("light watcher", limit=5)
|
||||
print(f"\n=== Keyword search 'light watcher' ({len(results)} results) ===")
|
||||
for r in results:
|
||||
print(f" [{r.score:.2f}] {r.path}:{r.start_line}-{r.end_line} — {r.text[:80]}")
|
||||
|
||||
await reme.close()
|
||||
print("\n=== Light Watcher 测试完成 ===")
|
||||
|
||||
|
||||
async def main():
|
||||
"""主函数,运行所有测试"""
|
||||
print("开始测试ReMe系统的Full和Light Watcher模式")
|
||||
|
||||
try:
|
||||
await test_full_watcher()
|
||||
await test_light_watcher()
|
||||
print("\n✅ 所有测试完成!")
|
||||
except Exception as e:
|
||||
print(f"\n❌ 测试失败: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Loading…
Add table
Reference in a new issue