commit
34c32f941c
16 changed files with 540 additions and 100 deletions
|
|
@ -1,6 +1,14 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Read-Only Mode**: Global `--read-only` CLI flag for safe database access without modifications
|
||||||
|
- Blocks all write operations at the Store layer
|
||||||
|
- Skips database upgrades and settings saves on open
|
||||||
|
- Excludes write tools (`add_document_*`, `delete_document`) from MCP server
|
||||||
|
- Disables file monitor with warning when `--read-only` is used with `serve --monitor`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **File Monitor Path Validation**: Monitor now validates directories exist before watching ([#204](https://github.com/ggozad/haiku.rag/issues/204))
|
- **File Monitor Path Validation**: Monitor now validates directories exist before watching ([#204](https://github.com/ggozad/haiku.rag/issues/204))
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ The `haiku-rag` CLI provides complete document management functionality.
|
||||||
Global options (must be specified before the command):
|
Global options (must be specified before the command):
|
||||||
|
|
||||||
- `--config` - Specify custom configuration file
|
- `--config` - Specify custom configuration file
|
||||||
|
- `--read-only` - Open database in read-only mode (blocks writes, skips upgrades)
|
||||||
- `--version` / `-v` - Show version and exit
|
- `--version` / `-v` - Show version and exit
|
||||||
|
|
||||||
Per-command options:
|
Per-command options:
|
||||||
|
|
@ -17,6 +18,7 @@ The `haiku-rag` CLI provides complete document management functionality.
|
||||||
```bash
|
```bash
|
||||||
haiku-rag --config /path/to/config.yaml list
|
haiku-rag --config /path/to/config.yaml list
|
||||||
haiku-rag --config /path/to/config.yaml list --db /path/to/custom.db
|
haiku-rag --config /path/to/config.yaml list --db /path/to/custom.db
|
||||||
|
haiku-rag --read-only search "query"
|
||||||
haiku-rag add -h
|
haiku-rag add -h
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -234,6 +236,9 @@ haiku-rag serve --monitor --mcp --agui
|
||||||
|
|
||||||
# Custom MCP port
|
# Custom MCP port
|
||||||
haiku-rag serve --mcp --mcp-port 9000
|
haiku-rag serve --mcp --mcp-port 9000
|
||||||
|
|
||||||
|
# Read-only mode (excludes write MCP tools, disables monitor)
|
||||||
|
haiku-rag --read-only serve --mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Server Mode](server.md) for details on available services.
|
See [Server Mode](server.md) for details on available services.
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,13 @@ haiku-rag serve --mcp --mcp-port 9000
|
||||||
|
|
||||||
# stdio transport (for Claude Desktop)
|
# stdio transport (for Claude Desktop)
|
||||||
haiku-rag serve --mcp --stdio
|
haiku-rag serve --mcp --stdio
|
||||||
|
|
||||||
|
# Read-only mode (excludes write tools)
|
||||||
|
haiku-rag --read-only serve --mcp --stdio
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Read-only mode:** When `--read-only` is specified, write tools (`add_document_from_file`, `add_document_from_url`, `add_document_from_text`, `delete_document`) are not registered. Only search and query tools remain available.
|
||||||
|
|
||||||
## Claude Desktop Integration
|
## Claude Desktop Integration
|
||||||
|
|
||||||
Add to your Claude Desktop configuration (`claude_desktop_config.json`):
|
Add to your Claude Desktop configuration (`claude_desktop_config.json`):
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,19 @@ async with HaikuRAG("path/to/database.lancedb", create=True) as client:
|
||||||
async with HaikuRAG("path/to/database.lancedb") as client:
|
async with HaikuRAG("path/to/database.lancedb") as client:
|
||||||
# Your code here
|
# Your code here
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Open in read-only mode (blocks writes, skips upgrades)
|
||||||
|
async with HaikuRAG("path/to/database.lancedb", read_only=True) as client:
|
||||||
|
results = await client.search("query") # Read operations work
|
||||||
|
# await client.create_document(...) # Would raise ReadOnlyError
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
Databases must be explicitly created with `create=True` or via `haiku-rag init` before use. Operations on non-existent databases will raise `FileNotFoundError`.
|
Databases must be explicitly created with `create=True` or via `haiku-rag init` before use. Operations on non-existent databases will raise `FileNotFoundError`.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
Read-only mode is useful for safely accessing databases without risk of modification. It blocks all write operations, skips database upgrades on open, and prevents settings from being saved.
|
||||||
|
|
||||||
## Document Management
|
## Document Management
|
||||||
|
|
||||||
### Creating Documents
|
### Creating Documents
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,12 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HaikuRAGApp:
|
class HaikuRAGApp:
|
||||||
def __init__(self, db_path: Path, config: AppConfig = Config):
|
def __init__(
|
||||||
|
self, db_path: Path, config: AppConfig = Config, read_only: bool = False
|
||||||
|
):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.read_only = read_only
|
||||||
self.console = Console()
|
self.console = Console()
|
||||||
|
|
||||||
async def init(self):
|
async def init(self):
|
||||||
|
|
@ -213,13 +216,17 @@ class HaikuRAGApp:
|
||||||
)
|
)
|
||||||
|
|
||||||
async def list_documents(self, filter: str | None = None):
|
async def list_documents(self, filter: str | None = None):
|
||||||
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
|
async with HaikuRAG(
|
||||||
|
db_path=self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as self.client:
|
||||||
documents = await self.client.list_documents(filter=filter)
|
documents = await self.client.list_documents(filter=filter)
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
self._rich_print_document(doc, truncate=True)
|
self._rich_print_document(doc, truncate=True)
|
||||||
|
|
||||||
async def add_document_from_text(self, text: str, metadata: dict | None = None):
|
async def add_document_from_text(self, text: str, metadata: dict | None = None):
|
||||||
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
|
async with HaikuRAG(
|
||||||
|
db_path=self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as self.client:
|
||||||
doc = await self.client.create_document(text, metadata=metadata)
|
doc = await self.client.create_document(text, metadata=metadata)
|
||||||
self._rich_print_document(doc, truncate=True)
|
self._rich_print_document(doc, truncate=True)
|
||||||
self.console.print(
|
self.console.print(
|
||||||
|
|
@ -229,7 +236,9 @@ class HaikuRAGApp:
|
||||||
async def add_document_from_source(
|
async def add_document_from_source(
|
||||||
self, source: str, title: str | None = None, metadata: dict | None = None
|
self, source: str, title: str | None = None, metadata: dict | None = None
|
||||||
):
|
):
|
||||||
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
|
async with HaikuRAG(
|
||||||
|
db_path=self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as self.client:
|
||||||
result = await self.client.create_document_from_source(
|
result = await self.client.create_document_from_source(
|
||||||
source, title=title, metadata=metadata
|
source, title=title, metadata=metadata
|
||||||
)
|
)
|
||||||
|
|
@ -246,7 +255,9 @@ class HaikuRAGApp:
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_document(self, doc_id: str):
|
async def get_document(self, doc_id: str):
|
||||||
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
|
async with HaikuRAG(
|
||||||
|
db_path=self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as self.client:
|
||||||
doc = await self.client.get_document_by_id(doc_id)
|
doc = await self.client.get_document_by_id(doc_id)
|
||||||
if doc is None:
|
if doc is None:
|
||||||
self.console.print(f"[red]Document with id {doc_id} not found.[/red]")
|
self.console.print(f"[red]Document with id {doc_id} not found.[/red]")
|
||||||
|
|
@ -254,7 +265,9 @@ class HaikuRAGApp:
|
||||||
self._rich_print_document(doc, truncate=False)
|
self._rich_print_document(doc, truncate=False)
|
||||||
|
|
||||||
async def delete_document(self, doc_id: str):
|
async def delete_document(self, doc_id: str):
|
||||||
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
|
async with HaikuRAG(
|
||||||
|
db_path=self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as self.client:
|
||||||
deleted = await self.client.delete_document(doc_id)
|
deleted = await self.client.delete_document(doc_id)
|
||||||
if deleted:
|
if deleted:
|
||||||
self.console.print(
|
self.console.print(
|
||||||
|
|
@ -268,7 +281,9 @@ class HaikuRAGApp:
|
||||||
async def search(
|
async def search(
|
||||||
self, query: str, limit: int | None = None, filter: str | None = None
|
self, query: str, limit: int | None = None, filter: str | None = None
|
||||||
):
|
):
|
||||||
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
|
async with HaikuRAG(
|
||||||
|
db_path=self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as self.client:
|
||||||
results = await self.client.search(query, limit=limit, filter=filter)
|
results = await self.client.search(query, limit=limit, filter=filter)
|
||||||
if not results:
|
if not results:
|
||||||
self.console.print("[yellow]No results found.[/yellow]")
|
self.console.print("[yellow]No results found.[/yellow]")
|
||||||
|
|
@ -280,7 +295,9 @@ class HaikuRAGApp:
|
||||||
"""Display visual grounding images for a chunk."""
|
"""Display visual grounding images for a chunk."""
|
||||||
from textual_image.renderable import Image as RichImage
|
from textual_image.renderable import Image as RichImage
|
||||||
|
|
||||||
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
|
async with HaikuRAG(
|
||||||
|
db_path=self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as self.client:
|
||||||
chunk = await self.client.chunk_repository.get_by_id(chunk_id)
|
chunk = await self.client.chunk_repository.get_by_id(chunk_id)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
self.console.print(f"[red]Chunk with id {chunk_id} not found.[/red]")
|
self.console.print(f"[red]Chunk with id {chunk_id} not found.[/red]")
|
||||||
|
|
@ -325,7 +342,9 @@ class HaikuRAGApp:
|
||||||
verbose: Show verbose output
|
verbose: Show verbose output
|
||||||
filter: SQL WHERE clause to filter documents
|
filter: SQL WHERE clause to filter documents
|
||||||
"""
|
"""
|
||||||
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client:
|
async with HaikuRAG(
|
||||||
|
db_path=self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as self.client:
|
||||||
try:
|
try:
|
||||||
citations = []
|
citations = []
|
||||||
if deep:
|
if deep:
|
||||||
|
|
@ -394,7 +413,9 @@ class HaikuRAGApp:
|
||||||
verbose: Show AG-UI event stream during execution
|
verbose: Show AG-UI event stream during execution
|
||||||
filter: SQL WHERE clause to filter documents
|
filter: SQL WHERE clause to filter documents
|
||||||
"""
|
"""
|
||||||
async with HaikuRAG(db_path=self.db_path, config=self.config) as client:
|
async with HaikuRAG(
|
||||||
|
db_path=self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as client:
|
||||||
try:
|
try:
|
||||||
self.console.print("[bold cyan]Starting research[/bold cyan]")
|
self.console.print("[bold cyan]Starting research[/bold cyan]")
|
||||||
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
|
self.console.print(f"[bold blue]Question:[/bold blue] {question}")
|
||||||
|
|
@ -485,7 +506,10 @@ class HaikuRAGApp:
|
||||||
|
|
||||||
async def rebuild(self, mode: RebuildMode = RebuildMode.FULL):
|
async def rebuild(self, mode: RebuildMode = RebuildMode.FULL):
|
||||||
async with HaikuRAG(
|
async with HaikuRAG(
|
||||||
db_path=self.db_path, config=self.config, skip_validation=True
|
db_path=self.db_path,
|
||||||
|
config=self.config,
|
||||||
|
skip_validation=True,
|
||||||
|
read_only=self.read_only,
|
||||||
) as client:
|
) as client:
|
||||||
try:
|
try:
|
||||||
documents = await client.list_documents()
|
documents = await client.list_documents()
|
||||||
|
|
@ -521,7 +545,10 @@ class HaikuRAGApp:
|
||||||
"""Run database maintenance: optimize and cleanup table history."""
|
"""Run database maintenance: optimize and cleanup table history."""
|
||||||
try:
|
try:
|
||||||
async with HaikuRAG(
|
async with HaikuRAG(
|
||||||
db_path=self.db_path, config=self.config, skip_validation=True
|
db_path=self.db_path,
|
||||||
|
config=self.config,
|
||||||
|
skip_validation=True,
|
||||||
|
read_only=self.read_only,
|
||||||
) as client:
|
) as client:
|
||||||
await client.vacuum()
|
await client.vacuum()
|
||||||
self.console.print(
|
self.console.print(
|
||||||
|
|
@ -534,7 +561,10 @@ class HaikuRAGApp:
|
||||||
"""Create vector index on the chunks table."""
|
"""Create vector index on the chunks table."""
|
||||||
try:
|
try:
|
||||||
async with HaikuRAG(
|
async with HaikuRAG(
|
||||||
db_path=self.db_path, config=self.config, skip_validation=True
|
db_path=self.db_path,
|
||||||
|
config=self.config,
|
||||||
|
skip_validation=True,
|
||||||
|
read_only=self.read_only,
|
||||||
) as client:
|
) as client:
|
||||||
row_count = client.store.chunks_table.count_rows()
|
row_count = client.store.chunks_table.count_rows()
|
||||||
self.console.print(f"Chunks in database: {row_count}")
|
self.console.print(f"Chunks in database: {row_count}")
|
||||||
|
|
@ -704,18 +734,27 @@ class HaikuRAGApp:
|
||||||
enable_agui: bool = False,
|
enable_agui: bool = False,
|
||||||
):
|
):
|
||||||
"""Start the server with selected services."""
|
"""Start the server with selected services."""
|
||||||
async with HaikuRAG(self.db_path, config=self.config) as client:
|
async with HaikuRAG(
|
||||||
|
self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
) as client:
|
||||||
tasks = []
|
tasks = []
|
||||||
|
|
||||||
# Start file monitor if enabled
|
# Start file monitor if enabled (not available in read-only mode)
|
||||||
if enable_monitor:
|
if enable_monitor:
|
||||||
|
if self.read_only:
|
||||||
|
logger.warning(
|
||||||
|
"File monitor disabled: cannot monitor files in read-only mode"
|
||||||
|
)
|
||||||
|
else:
|
||||||
monitor = FileWatcher(client=client, config=self.config)
|
monitor = FileWatcher(client=client, config=self.config)
|
||||||
monitor_task = asyncio.create_task(monitor.observe())
|
monitor_task = asyncio.create_task(monitor.observe())
|
||||||
tasks.append(monitor_task)
|
tasks.append(monitor_task)
|
||||||
|
|
||||||
# Start MCP server if enabled
|
# Start MCP server if enabled
|
||||||
if enable_mcp:
|
if enable_mcp:
|
||||||
server = create_mcp_server(self.db_path, config=self.config)
|
server = create_mcp_server(
|
||||||
|
self.db_path, config=self.config, read_only=self.read_only
|
||||||
|
)
|
||||||
|
|
||||||
async def run_mcp():
|
async def run_mcp():
|
||||||
if mcp_transport == "stdio":
|
if mcp_transport == "stdio":
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ cli = typer.Typer(
|
||||||
context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True
|
context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Module-level read-only flag set by callback
|
||||||
|
_read_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
def create_app(db: Path | None = None) -> HaikuRAGApp:
|
def create_app(db: Path | None = None) -> HaikuRAGApp:
|
||||||
"""Create HaikuRAGApp with loaded config and resolved database path.
|
"""Create HaikuRAGApp with loaded config and resolved database path.
|
||||||
|
|
@ -38,7 +41,7 @@ def create_app(db: Path | None = None) -> HaikuRAGApp:
|
||||||
"""
|
"""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
db_path = db if db else config.storage.data_dir / "haiku.rag.lancedb"
|
db_path = db if db else config.storage.data_dir / "haiku.rag.lancedb"
|
||||||
return HaikuRAGApp(db_path=db_path, config=config)
|
return HaikuRAGApp(db_path=db_path, config=config, read_only=_read_only)
|
||||||
|
|
||||||
|
|
||||||
async def check_version():
|
async def check_version():
|
||||||
|
|
@ -72,8 +75,15 @@ def main(
|
||||||
"--config",
|
"--config",
|
||||||
help="Path to YAML configuration file",
|
help="Path to YAML configuration file",
|
||||||
),
|
),
|
||||||
|
read_only: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--read-only",
|
||||||
|
help="Open database in read-only mode",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
"""haiku.rag CLI - Vector database RAG system"""
|
"""haiku.rag CLI - Vector database RAG system"""
|
||||||
|
global _read_only
|
||||||
|
_read_only = read_only
|
||||||
# Load config from --config, local folder, or default directory
|
# Load config from --config, local folder, or default directory
|
||||||
config_path = find_config_file(cli_path=config)
|
config_path = find_config_file(cli_path=config)
|
||||||
if config_path:
|
if config_path:
|
||||||
|
|
@ -353,7 +363,7 @@ def research(
|
||||||
from haiku.rag.cli_chat import interactive_research
|
from haiku.rag.cli_chat import interactive_research
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
|
||||||
client = HaikuRAG(db_path=app.db_path, config=app.config)
|
client = HaikuRAG(db_path=app.db_path, config=app.config, read_only=_read_only)
|
||||||
try:
|
try:
|
||||||
interactive_research(
|
interactive_research(
|
||||||
client=client,
|
client=client,
|
||||||
|
|
@ -521,7 +531,7 @@ def inspect(
|
||||||
raise typer.Exit(1) from e
|
raise typer.Exit(1) from e
|
||||||
|
|
||||||
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb"
|
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb"
|
||||||
run_inspector(db_path)
|
run_inspector(db_path, read_only=_read_only)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(
|
@cli.command(
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ class HaikuRAG:
|
||||||
config: AppConfig = Config,
|
config: AppConfig = Config,
|
||||||
skip_validation: bool = False,
|
skip_validation: bool = False,
|
||||||
create: bool = False,
|
create: bool = False,
|
||||||
|
read_only: bool = False,
|
||||||
):
|
):
|
||||||
"""Initialize the RAG client with a database path.
|
"""Initialize the RAG client with a database path.
|
||||||
|
|
||||||
|
|
@ -68,6 +69,7 @@ class HaikuRAG:
|
||||||
config: Configuration to use. Defaults to global Config.
|
config: Configuration to use. Defaults to global Config.
|
||||||
skip_validation: Whether to skip configuration validation on database load.
|
skip_validation: Whether to skip configuration validation on database load.
|
||||||
create: Whether to create the database if it doesn't exist.
|
create: Whether to create the database if it doesn't exist.
|
||||||
|
read_only: Whether to open the database in read-only mode.
|
||||||
"""
|
"""
|
||||||
self._config = config
|
self._config = config
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
|
|
@ -77,10 +79,16 @@ class HaikuRAG:
|
||||||
config=self._config,
|
config=self._config,
|
||||||
skip_validation=skip_validation,
|
skip_validation=skip_validation,
|
||||||
create=create,
|
create=create,
|
||||||
|
read_only=read_only,
|
||||||
)
|
)
|
||||||
self.document_repository = DocumentRepository(self.store)
|
self.document_repository = DocumentRepository(self.store)
|
||||||
self.chunk_repository = ChunkRepository(self.store)
|
self.chunk_repository = ChunkRepository(self.store)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_read_only(self) -> bool:
|
||||||
|
"""Whether the client is in read-only mode."""
|
||||||
|
return self.store.is_read_only
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
"""Async context manager entry."""
|
"""Async context manager entry."""
|
||||||
return self
|
return self
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,10 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover
|
||||||
Binding("c", "show_context", "Context", show=True),
|
Binding("c", "show_context", "Context", show=True),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, db_path: Path):
|
def __init__(self, db_path: Path, read_only: bool = False):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
|
self.read_only = read_only
|
||||||
self.client: HaikuRAG | None = None
|
self.client: HaikuRAG | None = None
|
||||||
|
|
||||||
def compose(self) -> "ComposeResult":
|
def compose(self) -> "ComposeResult":
|
||||||
|
|
@ -90,7 +91,9 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover
|
||||||
async def on_mount(self) -> None:
|
async def on_mount(self) -> None:
|
||||||
"""Initialize the app when mounted."""
|
"""Initialize the app when mounted."""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
self.client = HaikuRAG(db_path=self.db_path, config=config)
|
self.client = HaikuRAG(
|
||||||
|
db_path=self.db_path, config=config, read_only=self.read_only
|
||||||
|
)
|
||||||
await self.client.__aenter__()
|
await self.client.__aenter__()
|
||||||
|
|
||||||
# Load initial documents
|
# Load initial documents
|
||||||
|
|
@ -229,15 +232,18 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover
|
||||||
await self._switch_modal(ContextModal(chunk=chunk, client=self.client))
|
await self._switch_modal(ContextModal(chunk=chunk, client=self.client))
|
||||||
|
|
||||||
|
|
||||||
def run_inspector(db_path: Path | None = None) -> None: # pragma: no cover
|
def run_inspector(
|
||||||
|
db_path: Path | None = None, read_only: bool = False
|
||||||
|
) -> None: # pragma: no cover
|
||||||
"""Run the inspector TUI.
|
"""Run the inspector TUI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db_path: Path to the LanceDB database. If None, uses default from config.
|
db_path: Path to the LanceDB database. If None, uses default from config.
|
||||||
|
read_only: Whether to open the database in read-only mode.
|
||||||
"""
|
"""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
db_path = config.storage.data_dir / "haiku.rag.lancedb"
|
db_path = config.storage.data_dir / "haiku.rag.lancedb"
|
||||||
|
|
||||||
app = InspectorApp(db_path)
|
app = InspectorApp(db_path, read_only=read_only)
|
||||||
app.run()
|
app.run()
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,21 @@ class DocumentResult(BaseModel):
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
|
def create_mcp_server(
|
||||||
"""Create an MCP server with the specified database path."""
|
db_path: Path, config: AppConfig = Config, read_only: bool = False
|
||||||
|
) -> FastMCP:
|
||||||
|
"""Create an MCP server with the specified database path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the database file.
|
||||||
|
config: Configuration to use.
|
||||||
|
read_only: If True, write tools (add_document_*, delete_document) are not registered.
|
||||||
|
"""
|
||||||
mcp = FastMCP("haiku-rag")
|
mcp = FastMCP("haiku-rag")
|
||||||
|
|
||||||
|
# Write tools - only registered when not in read-only mode
|
||||||
|
if not read_only:
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def add_document_from_file(
|
async def add_document_from_file(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
|
|
@ -78,13 +89,23 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def delete_document(document_id: str) -> bool:
|
||||||
|
"""Delete a document by its ID."""
|
||||||
|
try:
|
||||||
|
async with HaikuRAG(db_path, config=config) as rag:
|
||||||
|
return await rag.delete_document(document_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Read tools - always registered
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def search_documents(
|
async def search_documents(
|
||||||
query: str, limit: int | None = None
|
query: str, limit: int | None = None
|
||||||
) -> list[SearchResult]:
|
) -> list[SearchResult]:
|
||||||
"""Search the RAG system for documents using hybrid search (vector similarity + full-text search)."""
|
"""Search the RAG system for documents using hybrid search (vector similarity + full-text search)."""
|
||||||
try:
|
try:
|
||||||
async with HaikuRAG(db_path, config=config) as rag:
|
async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
|
||||||
return await rag.search(query, limit=limit)
|
return await rag.search(query, limit=limit)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
@ -93,7 +114,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
|
||||||
async def get_document(document_id: str) -> DocumentResult | None:
|
async def get_document(document_id: str) -> DocumentResult | None:
|
||||||
"""Get a document by its ID."""
|
"""Get a document by its ID."""
|
||||||
try:
|
try:
|
||||||
async with HaikuRAG(db_path, config=config) as rag:
|
async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
|
||||||
document = await rag.get_document_by_id(document_id)
|
document = await rag.get_document_by_id(document_id)
|
||||||
|
|
||||||
if document is None:
|
if document is None:
|
||||||
|
|
@ -128,7 +149,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
|
||||||
List of DocumentResult instances matching the criteria.
|
List of DocumentResult instances matching the criteria.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with HaikuRAG(db_path, config=config) as rag:
|
async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
|
||||||
documents = await rag.list_documents(limit, offset, filter)
|
documents = await rag.list_documents(limit, offset, filter)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -146,15 +167,6 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def delete_document(document_id: str) -> bool:
|
|
||||||
"""Delete a document by its ID."""
|
|
||||||
try:
|
|
||||||
async with HaikuRAG(db_path, config=config) as rag:
|
|
||||||
return await rag.delete_document(document_id)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def ask_question(
|
async def ask_question(
|
||||||
question: str,
|
question: str,
|
||||||
|
|
@ -172,7 +184,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
|
||||||
The answer as a string.
|
The answer as a string.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with HaikuRAG(db_path, config=config) as rag:
|
async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
|
||||||
if deep:
|
if deep:
|
||||||
from haiku.rag.graph.research.dependencies import ResearchContext
|
from haiku.rag.graph.research.dependencies import ResearchContext
|
||||||
from haiku.rag.graph.research.graph import build_research_graph
|
from haiku.rag.graph.research.graph import build_research_graph
|
||||||
|
|
@ -222,7 +234,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
|
||||||
from haiku.rag.graph.research.graph import build_research_graph
|
from haiku.rag.graph.research.graph import build_research_graph
|
||||||
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
|
from haiku.rag.graph.research.state import ResearchDeps, ResearchState
|
||||||
|
|
||||||
async with HaikuRAG(db_path, config=config) as rag:
|
async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
|
||||||
graph = build_research_graph(config=config)
|
graph = build_research_graph(config=config)
|
||||||
context = ResearchContext(original_question=question)
|
context = ResearchContext(original_question=question)
|
||||||
state = ResearchState.from_config(context=context, config=config)
|
state = ResearchState.from_config(context=context, config=config)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from .engine import Store
|
from .engine import Store
|
||||||
|
from .exceptions import ReadOnlyError
|
||||||
from .models import Chunk, Document
|
from .models import Chunk, Document
|
||||||
|
|
||||||
__all__ = ["Store", "Chunk", "Document"]
|
__all__ = ["Store", "Chunk", "Document", "ReadOnlyError"]
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from pydantic import Field
|
||||||
|
|
||||||
from haiku.rag.config import AppConfig, Config
|
from haiku.rag.config import AppConfig, Config
|
||||||
from haiku.rag.embeddings import get_embedder
|
from haiku.rag.embeddings import get_embedder
|
||||||
|
from haiku.rag.store.exceptions import ReadOnlyError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -57,9 +58,11 @@ class Store:
|
||||||
config: AppConfig = Config,
|
config: AppConfig = Config,
|
||||||
skip_validation: bool = False,
|
skip_validation: bool = False,
|
||||||
create: bool = False,
|
create: bool = False,
|
||||||
|
read_only: bool = False,
|
||||||
):
|
):
|
||||||
self.db_path: Path = db_path
|
self.db_path: Path = db_path
|
||||||
self._config = config
|
self._config = config
|
||||||
|
self._read_only = read_only
|
||||||
self.embedder = get_embedder(config=self._config)
|
self.embedder = get_embedder(config=self._config)
|
||||||
self._vacuum_lock = asyncio.Lock()
|
self._vacuum_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
@ -87,6 +90,8 @@ class Store:
|
||||||
self._init_tables()
|
self._init_tables()
|
||||||
|
|
||||||
# Run upgrades only on existing databases, set version for new ones
|
# Run upgrades only on existing databases, set version for new ones
|
||||||
|
# Skip upgrades in read-only mode (they would fail anyway)
|
||||||
|
if not read_only:
|
||||||
if is_new_db:
|
if is_new_db:
|
||||||
self._set_initial_version()
|
self._set_initial_version()
|
||||||
else:
|
else:
|
||||||
|
|
@ -96,6 +101,16 @@ class Store:
|
||||||
if not skip_validation:
|
if not skip_validation:
|
||||||
self._validate_configuration()
|
self._validate_configuration()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_read_only(self) -> bool:
|
||||||
|
"""Whether the store is in read-only mode."""
|
||||||
|
return self._read_only
|
||||||
|
|
||||||
|
def _assert_writable(self) -> None:
|
||||||
|
"""Raise ReadOnlyError if the store is in read-only mode."""
|
||||||
|
if self._read_only:
|
||||||
|
raise ReadOnlyError("Cannot modify database in read-only mode")
|
||||||
|
|
||||||
async def vacuum(self, retention_seconds: int | None = None) -> None:
|
async def vacuum(self, retention_seconds: int | None = None) -> None:
|
||||||
"""Optimize and clean up old versions across all tables to reduce disk usage.
|
"""Optimize and clean up old versions across all tables to reduce disk usage.
|
||||||
|
|
||||||
|
|
@ -106,7 +121,12 @@ class Store:
|
||||||
Note:
|
Note:
|
||||||
If vacuum is already running, this method returns immediately without blocking.
|
If vacuum is already running, this method returns immediately without blocking.
|
||||||
Use asyncio.create_task(store.vacuum()) for non-blocking background execution.
|
Use asyncio.create_task(store.vacuum()) for non-blocking background execution.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReadOnlyError: If the store is in read-only mode.
|
||||||
"""
|
"""
|
||||||
|
self._assert_writable()
|
||||||
|
|
||||||
if self._has_cloud_config() and str(self._config.lancedb.uri).startswith(
|
if self._has_cloud_config() and str(self._config.lancedb.uri).startswith(
|
||||||
"db://"
|
"db://"
|
||||||
):
|
):
|
||||||
|
|
@ -317,7 +337,12 @@ class Store:
|
||||||
return "0.0.0"
|
return "0.0.0"
|
||||||
|
|
||||||
def set_haiku_version(self, version: str) -> None:
|
def set_haiku_version(self, version: str) -> None:
|
||||||
"""Updates the user version in settings."""
|
"""Updates the user version in settings.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReadOnlyError: If the store is in read-only mode.
|
||||||
|
"""
|
||||||
|
self._assert_writable()
|
||||||
settings_records = list(
|
settings_records = list(
|
||||||
self.settings_table.search().limit(1).to_pydantic(SettingsRecord)
|
self.settings_table.search().limit(1).to_pydantic(SettingsRecord)
|
||||||
)
|
)
|
||||||
|
|
@ -343,7 +368,12 @@ class Store:
|
||||||
)
|
)
|
||||||
|
|
||||||
def recreate_embeddings_table(self) -> None:
|
def recreate_embeddings_table(self) -> None:
|
||||||
"""Recreate the chunks table with current vector dimensions."""
|
"""Recreate the chunks table with current vector dimensions.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReadOnlyError: If the store is in read-only mode.
|
||||||
|
"""
|
||||||
|
self._assert_writable()
|
||||||
# Drop and recreate chunks table
|
# Drop and recreate chunks table
|
||||||
try:
|
try:
|
||||||
self.db.drop_table("chunks")
|
self.db.drop_table("chunks")
|
||||||
|
|
@ -373,7 +403,12 @@ class Store:
|
||||||
}
|
}
|
||||||
|
|
||||||
def restore_table_versions(self, versions: dict[str, int]) -> bool:
|
def restore_table_versions(self, versions: dict[str, int]) -> bool:
|
||||||
"""Restore tables to the provided versions using LanceDB's API."""
|
"""Restore tables to the provided versions using LanceDB's API.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReadOnlyError: If the store is in read-only mode.
|
||||||
|
"""
|
||||||
|
self._assert_writable()
|
||||||
self.documents_table.restore(int(versions["documents"]))
|
self.documents_table.restore(int(versions["documents"]))
|
||||||
self.chunks_table.restore(int(versions["chunks"]))
|
self.chunks_table.restore(int(versions["chunks"]))
|
||||||
self.settings_table.restore(int(versions["settings"]))
|
self.settings_table.restore(int(versions["settings"]))
|
||||||
|
|
|
||||||
4
haiku_rag_slim/haiku/rag/store/exceptions.py
Normal file
4
haiku_rag_slim/haiku/rag/store/exceptions.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
class ReadOnlyError(Exception):
|
||||||
|
"""Raised when a write operation is attempted on a read-only store."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
@ -42,6 +42,7 @@ class ChunkRepository:
|
||||||
Chunks must have embeddings set before calling this method.
|
Chunks must have embeddings set before calling this method.
|
||||||
Use client._ensure_chunks_embedded() to embed chunks if needed.
|
Use client._ensure_chunks_embedded() to embed chunks if needed.
|
||||||
"""
|
"""
|
||||||
|
self.store._assert_writable()
|
||||||
# Handle single chunk
|
# Handle single chunk
|
||||||
if isinstance(entity, Chunk):
|
if isinstance(entity, Chunk):
|
||||||
assert entity.document_id, "Chunk must have a document_id to be created"
|
assert entity.document_id, "Chunk must have a document_id to be created"
|
||||||
|
|
@ -126,6 +127,7 @@ class ChunkRepository:
|
||||||
|
|
||||||
Chunk must have embedding set before calling this method.
|
Chunk must have embedding set before calling this method.
|
||||||
"""
|
"""
|
||||||
|
self.store._assert_writable()
|
||||||
assert entity.id, "Chunk ID is required for update"
|
assert entity.id, "Chunk ID is required for update"
|
||||||
assert entity.embedding is not None, "Chunk must have an embedding"
|
assert entity.embedding is not None, "Chunk must have an embedding"
|
||||||
|
|
||||||
|
|
@ -145,6 +147,7 @@ class ChunkRepository:
|
||||||
|
|
||||||
async def delete(self, entity_id: str) -> bool:
|
async def delete(self, entity_id: str) -> bool:
|
||||||
"""Delete a chunk by its ID."""
|
"""Delete a chunk by its ID."""
|
||||||
|
self.store._assert_writable()
|
||||||
chunk = await self.get_by_id(entity_id)
|
chunk = await self.get_by_id(entity_id)
|
||||||
if chunk is None:
|
if chunk is None:
|
||||||
return False
|
return False
|
||||||
|
|
@ -181,6 +184,7 @@ class ChunkRepository:
|
||||||
|
|
||||||
async def delete_all(self) -> None:
|
async def delete_all(self) -> None:
|
||||||
"""Delete all chunks from the database."""
|
"""Delete all chunks from the database."""
|
||||||
|
self.store._assert_writable()
|
||||||
# Drop and recreate table to clear all data
|
# Drop and recreate table to clear all data
|
||||||
self.store.db.drop_table("chunks")
|
self.store.db.drop_table("chunks")
|
||||||
self.store.chunks_table = self.store.db.create_table(
|
self.store.chunks_table = self.store.db.create_table(
|
||||||
|
|
@ -193,6 +197,7 @@ class ChunkRepository:
|
||||||
|
|
||||||
async def delete_by_document_id(self, document_id: str) -> bool:
|
async def delete_by_document_id(self, document_id: str) -> bool:
|
||||||
"""Delete all chunks for a document."""
|
"""Delete all chunks for a document."""
|
||||||
|
self.store._assert_writable()
|
||||||
chunks = await self.get_by_document_id(document_id)
|
chunks = await self.get_by_document_id(document_id)
|
||||||
|
|
||||||
if not chunks:
|
if not chunks:
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ class DocumentRepository:
|
||||||
|
|
||||||
async def create(self, entity: Document) -> Document:
|
async def create(self, entity: Document) -> Document:
|
||||||
"""Create a document in the database."""
|
"""Create a document in the database."""
|
||||||
|
self.store._assert_writable()
|
||||||
# Generate new UUID
|
# Generate new UUID
|
||||||
doc_id = str(uuid4())
|
doc_id = str(uuid4())
|
||||||
|
|
||||||
|
|
@ -90,6 +91,7 @@ class DocumentRepository:
|
||||||
|
|
||||||
async def update(self, entity: Document) -> Document:
|
async def update(self, entity: Document) -> Document:
|
||||||
"""Update an existing document."""
|
"""Update an existing document."""
|
||||||
|
self.store._assert_writable()
|
||||||
from haiku.rag.store.models.document import invalidate_docling_document_cache
|
from haiku.rag.store.models.document import invalidate_docling_document_cache
|
||||||
|
|
||||||
assert entity.id, "Document ID is required for update"
|
assert entity.id, "Document ID is required for update"
|
||||||
|
|
@ -119,6 +121,7 @@ class DocumentRepository:
|
||||||
|
|
||||||
async def delete(self, entity_id: str) -> bool:
|
async def delete(self, entity_id: str) -> bool:
|
||||||
"""Delete a document by its ID."""
|
"""Delete a document by its ID."""
|
||||||
|
self.store._assert_writable()
|
||||||
from haiku.rag.store.models.document import invalidate_docling_document_cache
|
from haiku.rag.store.models.document import invalidate_docling_document_cache
|
||||||
|
|
||||||
# Check if document exists
|
# Check if document exists
|
||||||
|
|
@ -181,6 +184,7 @@ class DocumentRepository:
|
||||||
|
|
||||||
async def delete_all(self) -> None:
|
async def delete_all(self) -> None:
|
||||||
"""Delete all documents from the database."""
|
"""Delete all documents from the database."""
|
||||||
|
self.store._assert_writable()
|
||||||
# Delete all chunks first
|
# Delete all chunks first
|
||||||
await self.chunk_repository.delete_all()
|
await self.chunk_repository.delete_all()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ class SettingsRepository:
|
||||||
|
|
||||||
def save_current_settings(self) -> None:
|
def save_current_settings(self) -> None:
|
||||||
"""Save the current configuration to the database."""
|
"""Save the current configuration to the database."""
|
||||||
|
self.store._assert_writable()
|
||||||
current_config = self.store._config.model_dump(mode="json")
|
current_config = self.store._config.model_dump(mode="json")
|
||||||
|
|
||||||
# Check if settings exist
|
# Check if settings exist
|
||||||
|
|
|
||||||
289
tests/store/test_read_only.py
Normal file
289
tests/store/test_read_only.py
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.store import ReadOnlyError, Store
|
||||||
|
from haiku.rag.store.models import Chunk, Document
|
||||||
|
from haiku.rag.store.repositories.chunk import ChunkRepository
|
||||||
|
from haiku.rag.store.repositories.document import DocumentRepository
|
||||||
|
from haiku.rag.store.repositories.settings import SettingsRepository
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadOnlyError:
|
||||||
|
def test_read_only_error_is_exception(self):
|
||||||
|
"""ReadOnlyError should be a subclass of Exception."""
|
||||||
|
assert issubclass(ReadOnlyError, Exception)
|
||||||
|
|
||||||
|
def test_read_only_error_can_be_raised(self):
|
||||||
|
"""ReadOnlyError can be raised and caught."""
|
||||||
|
with pytest.raises(ReadOnlyError) as exc_info:
|
||||||
|
raise ReadOnlyError("Cannot modify database in read-only mode")
|
||||||
|
assert "read-only" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoreReadOnly:
|
||||||
|
def test_store_default_is_not_read_only(self, temp_db_path):
|
||||||
|
"""Store defaults to not read-only."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
assert store.is_read_only is False
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_store_can_be_created_read_only(self, temp_db_path):
|
||||||
|
"""Store can be created with read_only=True."""
|
||||||
|
# First create a normal store to initialize the database
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
# Now open in read-only mode
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
assert store.is_read_only is True
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_assert_writable_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""_assert_writable() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
store._assert_writable()
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_assert_writable_passes_when_not_read_only(self, temp_db_path):
|
||||||
|
"""_assert_writable() does not raise when read_only=False."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store._assert_writable() # Should not raise
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_vacuum_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""vacuum() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.get_event_loop().run_until_complete(store.vacuum())
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_set_haiku_version_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""set_haiku_version() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
store.set_haiku_version("1.0.0")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_recreate_embeddings_table_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""recreate_embeddings_table() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
store.recreate_embeddings_table()
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_restore_table_versions_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""restore_table_versions() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
versions = store.current_table_versions()
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
store.restore_table_versions(versions)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocumentRepositoryReadOnly:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""DocumentRepository.create() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
repo = DocumentRepository(store)
|
||||||
|
doc = Document(content="test content")
|
||||||
|
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
await repo.create(doc)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""DocumentRepository.update() raises ReadOnlyError when read_only=True."""
|
||||||
|
# First create a document
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
repo = DocumentRepository(store)
|
||||||
|
doc = Document(content="test content")
|
||||||
|
created_doc = await repo.create(doc)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
# Try to update in read-only mode
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
repo = DocumentRepository(store)
|
||||||
|
created_doc.content = "updated content"
|
||||||
|
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
await repo.update(created_doc)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""DocumentRepository.delete() raises ReadOnlyError when read_only=True."""
|
||||||
|
# First create a document
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
repo = DocumentRepository(store)
|
||||||
|
doc = Document(content="test content")
|
||||||
|
created_doc = await repo.create(doc)
|
||||||
|
assert created_doc.id is not None
|
||||||
|
doc_id = created_doc.id
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
# Try to delete in read-only mode
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
repo = DocumentRepository(store)
|
||||||
|
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
await repo.delete(doc_id)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_all_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""DocumentRepository.delete_all() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
repo = DocumentRepository(store)
|
||||||
|
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
await repo.delete_all()
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkRepositoryReadOnly:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""ChunkRepository.create() raises ReadOnlyError when read_only=True."""
|
||||||
|
# First create a document to have a valid document_id
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
doc_repo = DocumentRepository(store)
|
||||||
|
doc = Document(content="test content")
|
||||||
|
created_doc = await doc_repo.create(doc)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
repo = ChunkRepository(store)
|
||||||
|
chunk = Chunk(
|
||||||
|
content="test chunk",
|
||||||
|
document_id=created_doc.id,
|
||||||
|
embedding=[0.0] * store.embedder._vector_dim,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
await repo.create(chunk)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_by_document_id_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""ChunkRepository.delete_by_document_id() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
repo = ChunkRepository(store)
|
||||||
|
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
await repo.delete_by_document_id("some-id")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_all_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""ChunkRepository.delete_all() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
repo = ChunkRepository(store)
|
||||||
|
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
await repo.delete_all()
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsRepositoryReadOnly:
|
||||||
|
def test_save_current_settings_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""SettingsRepository.save_current_settings() raises ReadOnlyError when read_only=True."""
|
||||||
|
store = Store(temp_db_path, create=True)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
store = Store(temp_db_path, read_only=True)
|
||||||
|
repo = SettingsRepository(store)
|
||||||
|
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
repo.save_current_settings()
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientReadOnly:
|
||||||
|
def test_client_default_is_not_read_only(self, temp_db_path):
|
||||||
|
"""Client defaults to not read-only."""
|
||||||
|
client = HaikuRAG(temp_db_path, create=True)
|
||||||
|
assert client.is_read_only is False
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
def test_client_can_be_created_read_only(self, temp_db_path):
|
||||||
|
"""Client can be created with read_only=True."""
|
||||||
|
client = HaikuRAG(temp_db_path, create=True)
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
client = HaikuRAG(temp_db_path, read_only=True)
|
||||||
|
assert client.is_read_only is True
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_create_document_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""Client.create_document() raises ReadOnlyError when read_only=True."""
|
||||||
|
client = HaikuRAG(temp_db_path, create=True)
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, read_only=True) as client:
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
await client.create_document("test content")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_delete_document_raises_when_read_only(self, temp_db_path):
|
||||||
|
"""Client.delete_document() raises ReadOnlyError when read_only=True."""
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||||
|
doc = await client.create_document("test content")
|
||||||
|
assert doc.id is not None
|
||||||
|
doc_id = doc.id
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, read_only=True) as client:
|
||||||
|
with pytest.raises(ReadOnlyError):
|
||||||
|
await client.delete_document(doc_id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_search_works_when_read_only(self, temp_db_path):
|
||||||
|
"""Client.search() works in read-only mode."""
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||||
|
await client.create_document("test content about cats")
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, read_only=True) as client:
|
||||||
|
results = await client.search("cats")
|
||||||
|
assert len(results) > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_list_documents_works_when_read_only(self, temp_db_path):
|
||||||
|
"""Client.list_documents() works in read-only mode."""
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||||
|
await client.create_document("test content")
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, read_only=True) as client:
|
||||||
|
docs = await client.list_documents()
|
||||||
|
assert len(docs) == 1
|
||||||
Loading…
Reference in a new issue