Coerce a string db_path to Path at the Store and capability boundaries

The documented `HaikuRAG("knowledge.lancedb")` and `rag(db_path="my.lancedb")`
forms both raised `AttributeError: 'str' object has no attribute 'exists'`.
`Store.__init__` assigned its argument to a `Path`-annotated attribute without
coercing, and `resolve_db_path` returned a non-None argument unchanged. Every
runnable example wraps the path in `Path(...)`, which is why it survived.

Coerce in `Store.__init__` and `resolve_db_path`; widen the annotations on
`Store`, `HaikuRAG` and both `create_capability` factories to accept `str`.
This commit is contained in:
Yiorgis Gozadinos 2026-08-21 12:49:45 +03:00
parent def24a2434
commit 971ec0a5b0
No known key found for this signature in database
8 changed files with 46 additions and 11 deletions

View file

@ -3,6 +3,8 @@
### Fixed ### Fixed
- `Store`, `HaikuRAG` and `create_capability` coerce a string `db_path` to `Path`, as the documented
`HaikuRAG("knowledge.lancedb")` and `rag(db_path="my.lancedb")` forms require.
- A capability search that matches nothing returns `No results found.` instead of an empty string. - A capability search that matches nothing returns `No results found.` instead of an empty string.
- Repeating a search query within a question accumulates the results of both calls instead of replacing the earlier ones. - Repeating a search query within a question accumulates the results of both calls instead of replacing the earlier ones.
- `file://` URIs resolve to a Windows path through `url2pathname`: `file:///C:/docs/a.pdf` was read as `\C:\docs\a.pdf`, so ingestion reported `File does not exist` for every discovered file. A URI authority is kept as a UNC server/share (`file://server/share/a.pdf`) except `localhost`, which is dropped. - `file://` URIs resolve to a Windows path through `url2pathname`: `file:///C:/docs/a.pdf` was read as `\C:\docs\a.pdf`, so ingestion reported `File does not exist` for every discovered file. A URI authority is kept as a UNC server/share (`file://server/share/a.pdf`) except `localhost`, which is dropped.

View file

@ -70,9 +70,9 @@ def _nearest_known_id(chunk_id: str, known_ids: list[str]) -> str:
return match[0] if match else chunk_id return match[0] if match else chunk_id
def resolve_db_path(db_path: Path | None, config: AppConfig) -> Path: def resolve_db_path(db_path: Path | str | None, config: AppConfig) -> Path:
if db_path is not None: if db_path is not None:
return db_path return Path(db_path)
if env_db := os.environ.get("HAIKU_RAG_DB"): if env_db := os.environ.get("HAIKU_RAG_DB"):
return Path(env_db).expanduser() return Path(env_db).expanduser()
return config.storage.data_dir / "haiku.rag.lancedb" return config.storage.data_dir / "haiku.rag.lancedb"

View file

@ -156,7 +156,7 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
def create_capability( def create_capability(
db_path: Path | None = None, db_path: Path | str | None = None,
config: AppConfig | None = None, config: AppConfig | None = None,
*, *,
defer_loading: bool = True, defer_loading: bool = True,

View file

@ -64,7 +64,7 @@ class RAGCapability(RAGCapabilityBase[RAGState]):
def create_capability( def create_capability(
db_path: Path | None = None, db_path: Path | str | None = None,
config: AppConfig | None = None, config: AppConfig | None = None,
*, *,
defer_loading: bool = True, defer_loading: bool = True,

View file

@ -66,7 +66,7 @@ class HaikuRAG:
def __init__( def __init__(
self, self,
db_path: Path | None = None, db_path: Path | str | None = None,
config: AppConfig | None = None, config: AppConfig | None = None,
skip_validation: bool = False, skip_validation: bool = False,
create: bool = False, create: bool = False,
@ -75,7 +75,8 @@ class HaikuRAG:
"""Initialize the RAG client with a database path. """Initialize the RAG client with a database path.
Args: Args:
db_path: Path to the database file. If None, uses config.storage.data_dir. db_path: Path or string path to the database file. If None, uses
config.storage.data_dir.
config: Configuration to use. Defaults to the current global config. config: Configuration to use. Defaults to the current 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.

View file

@ -170,14 +170,14 @@ class TagInfo:
class Store: class Store:
def __init__( def __init__(
self, self,
db_path: Path, db_path: Path | str,
config: AppConfig | None = None, config: AppConfig | None = None,
skip_validation: bool = False, skip_validation: bool = False,
create: bool = False, create: bool = False,
read_only: bool = False, read_only: bool = False,
skip_migration_check: bool = False, skip_migration_check: bool = False,
): ):
self.db_path: Path = db_path self.db_path: Path = Path(db_path)
self._config = config if config is not None else get_config() self._config = config if config is not None else get_config()
self._read_only = read_only self._read_only = read_only
self._create = create self._create = create
@ -191,7 +191,7 @@ class Store:
self._is_new_db = False self._is_new_db = False
if self._connection_mode == ConnectionMode.LOCAL: if self._connection_mode == ConnectionMode.LOCAL:
if not db_path.exists(): if not self.db_path.exists():
if not create: if not create:
raise FileNotFoundError( raise FileNotFoundError(
f"Database does not exist at {self.db_path.absolute()}. " f"Database does not exist at {self.db_path.absolute()}. "
@ -199,8 +199,8 @@ class Store:
) )
self._is_new_db = True self._is_new_db = True
# Ensure parent directories exist for new databases # Ensure parent directories exist for new databases
if not db_path.parent.exists(): if not self.db_path.parent.exists():
Path.mkdir(db_path.parent, parents=True) Path.mkdir(self.db_path.parent, parents=True)
# Create embedder (sync — no LanceDB needed) # Create embedder (sync — no LanceDB needed)
self.embedder = get_embedder(config=self._config) self.embedder = get_embedder(config=self._config)

View file

@ -1,4 +1,5 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -102,11 +103,33 @@ def test_capability_factories_resolve_environment_and_defaults(
config.storage.data_dir / "haiku.rag.lancedb" config.storage.data_dir / "haiku.rag.lancedb"
) )
for factory in (create_rag, create_analysis):
db_path = factory(db_path=str(temp_db_path), config=config).db_path
assert db_path == temp_db_path
assert isinstance(db_path, Path)
with patch("haiku.rag.config.get_config", return_value=config): with patch("haiku.rag.config.get_config", return_value=config):
assert create_rag().config is config assert create_rag().config is config
assert create_analysis().config is config assert create_analysis().config is config
@pytest.mark.asyncio
async def test_a_string_db_path_opens_a_store(temp_db_path):
"""Store calls `absolute()` and `exists()` on db_path, which a str lacks."""
from haiku.rag.client import HaikuRAG
config = AppConfig()
async with HaikuRAG(temp_db_path, config, create=True):
pass
capability = create_rag(db_path=str(temp_db_path), config=config)
try:
rag = await capability._ensure_rag()
assert rag.store.db_path == temp_db_path
finally:
await capability._close()
def test_domain_preamble_is_added_to_capability_instructions(temp_db_path): def test_domain_preamble_is_added_to_capability_instructions(temp_db_path):
config = AppConfig( config = AppConfig(
prompts=PromptsConfig(domain_preamble="The corpus contains solar manuals.") prompts=PromptsConfig(domain_preamble="The corpus contains solar manuals.")

View file

@ -30,6 +30,15 @@ def vcr_cassette_dir():
return str(Path(__file__).parent / "cassettes" / "test_client") return str(Path(__file__).parent / "cassettes" / "test_client")
@pytest.mark.asyncio
async def test_a_string_db_path_is_accepted(temp_db_path):
"""The documented `HaikuRAG("knowledge.lancedb")` form: Store calls
`exists()` and `absolute()` on db_path, which a str lacks."""
async with HaikuRAG(str(temp_db_path), create=True) as client:
assert client.store.db_path == temp_db_path
assert isinstance(client.store.db_path, Path)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prepare_document_from_docling_runs_off_event_loop_thread(monkeypatch): async def test_prepare_document_from_docling_runs_off_event_loop_thread(monkeypatch):
import haiku.rag.client.documents as documents import haiku.rag.client.documents as documents