Resolve a capability's databases once, into a scope
`resolve_db_path` manufactured the default path whenever `lancedb.databases` was empty, and `covers_several_databases` read coverage back out of the configuration, so a capability built without a client opened `storage.data_dir/haiku.rag.lancedb` instead of what `lancedb.uri` placed. The entry point resolves a `DatabaseScope` instead: instructions ask it what it covers and `_ensure_rag` opens it through `HaikuRAG._covering`, so coverage is decided once rather than encoded in a path and re-derived. `Sandbox._covering` takes the scope the capability already resolved, beside the public constructor that takes a path. The factory signatures are unchanged.
This commit is contained in:
parent
5b420a9b23
commit
71e4e4a40a
11 changed files with 233 additions and 54 deletions
|
|
@ -16,6 +16,7 @@
|
||||||
- `doctor`'s docling-serve probe sends `X-Api-Key`, so an instance requiring a key is reported reachable rather than unreachable.
|
- `doctor`'s docling-serve probe sends `X-Api-Key`, so an instance requiring a key is reported reachable rather than unreachable.
|
||||||
- The picture-description request to the public OpenAI endpoint sends `OPENAI_API_KEY`; it carried no authorization header.
|
- The picture-description request to the public OpenAI endpoint sends `OPENAI_API_KEY`; it carried no authorization header.
|
||||||
- `haiku-rag` prints the message and exits when the configured embedder does not match the database, instead of raising a traceback.
|
- `haiku-rag` prints the message and exits when the configured embedder does not match the database, instead of raising a traceback.
|
||||||
|
- A capability built without a client opens the databases the configuration places — `lancedb.uri` or the whole `lancedb.databases` set — instead of the default under `storage.data_dir`.
|
||||||
- A `lancedb.uri` with no scheme is a local path, as it already is in `lancedb.databases`: `haiku-rag init` creates it and every command that opens an existing database requires it to exist, where a missing path was opened as object storage and became an empty database. `--db PATH` overrides `lancedb.uri`.
|
- A `lancedb.uri` with no scheme is a local path, as it already is in `lancedb.databases`: `haiku-rag init` creates it and every command that opens an existing database requires it to exist, where a missing path was opened as object storage and became an empty database. `--db PATH` overrides `lancedb.uri`.
|
||||||
|
|
||||||
## [0.77.0] - 2026-08-21
|
## [0.77.0] - 2026-08-21
|
||||||
|
|
|
||||||
|
|
@ -143,10 +143,18 @@ Capabilities use a plain `state: dict[str, Any]` attribute on agent dependencies
|
||||||
|
|
||||||
Applications serving AG-UI should adapt the agent with Pydantic AI's `AGUIAdapter`. Native model and tool events require no haiku.rag-specific bridge.
|
Applications serving AG-UI should adapt the agent with Pydantic AI's `AGUIAdapter`. Native model and tool events require no haiku.rag-specific bridge.
|
||||||
|
|
||||||
## Database path
|
## Which databases a capability covers
|
||||||
|
|
||||||
Both factories resolve their database in this order:
|
Both factories resolve this once, in order:
|
||||||
|
|
||||||
1. The `db_path` argument.
|
1. The `db_path` argument, which covers that one database.
|
||||||
2. `HAIKU_RAG_DB`.
|
2. `HAIKU_RAG_DB`, the same way.
|
||||||
3. `config.storage.data_dir / "haiku.rag.lancedb"`.
|
3. [`lancedb.databases`](../configuration/storage.md#several-databases), covering the
|
||||||
|
whole configured set. A capability covering several says so in its instructions, so
|
||||||
|
the model can attribute evidence to one while it answers.
|
||||||
|
4. [`lancedb.uri`](../configuration/storage.md#changing-the-default-database-path),
|
||||||
|
covering the one database it places.
|
||||||
|
5. `config.storage.data_dir / "haiku.rag.lancedb"`.
|
||||||
|
|
||||||
|
Passing a live client through `rag=` overrides all of it: the capability reads what that
|
||||||
|
client covers, and never closes it.
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from haiku.rag.capabilities._tools import (
|
||||||
)
|
)
|
||||||
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord, EvidenceRef
|
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord, EvidenceRef
|
||||||
from haiku.rag.client import HaikuRAG, all_found
|
from haiku.rag.client import HaikuRAG, all_found
|
||||||
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.store.exceptions import AmbiguousCitationError
|
from haiku.rag.store.exceptions import AmbiguousCitationError
|
||||||
from haiku.rag.store.models.chunk import SearchResult
|
from haiku.rag.store.models.chunk import SearchResult
|
||||||
|
|
@ -87,20 +88,16 @@ 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 | str | None, config: AppConfig) -> Path | None:
|
def resolve_scope(db_path: Path | str | None, config: AppConfig) -> DatabaseScope:
|
||||||
"""The database a capability opens for itself, or None to let the client decide.
|
"""The databases a capability covers, resolved once at its entry point.
|
||||||
|
|
||||||
None where `lancedb.databases` names the databases: a path would name one of
|
``HAIKU_RAG_DB`` is read here and nowhere else. ``DatabaseScope`` is
|
||||||
them instead, and a capability nobody handed a client would search a single
|
environment-agnostic on purpose, so honouring the variable inside it would
|
||||||
database where the configuration says several.
|
silently extend it to every other caller.
|
||||||
"""
|
"""
|
||||||
if db_path is not None:
|
if db_path is None and (env_db := os.environ.get("HAIKU_RAG_DB")):
|
||||||
return Path(db_path)
|
db_path = Path(env_db).expanduser()
|
||||||
if env_db := os.environ.get("HAIKU_RAG_DB"):
|
return DatabaseScope.resolve(config, database_path=db_path)
|
||||||
return Path(env_db).expanduser()
|
|
||||||
if config.lancedb.databases:
|
|
||||||
return None
|
|
||||||
return config.storage.data_dir / "haiku.rag.lancedb"
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceState(BaseModel):
|
class EvidenceState(BaseModel):
|
||||||
|
|
@ -134,18 +131,15 @@ class EvidenceState(BaseModel):
|
||||||
self.searches.clear()
|
self.searches.clear()
|
||||||
|
|
||||||
|
|
||||||
def covers_several_databases(
|
def covers_several_databases(scope: DatabaseScope, rag: "HaikuRAG | None") -> bool:
|
||||||
db_path: Path | None, config: AppConfig, rag: "HaikuRAG | None"
|
|
||||||
) -> bool:
|
|
||||||
"""Whether the capability will read from more than one database.
|
"""Whether the capability will read from more than one database.
|
||||||
|
|
||||||
What the configuration names is not what a capability opens: an explicit
|
A lent client already covers what it covers; otherwise the scope says.
|
||||||
`db_path` opens that one database, and a lent client already knows what it
|
Instructions follow coverage, not configuration.
|
||||||
covers. Instructions follow coverage, not configuration.
|
|
||||||
"""
|
"""
|
||||||
if rag is not None:
|
if rag is not None:
|
||||||
return rag.covers_multiple
|
return rag.covers_multiple
|
||||||
return db_path is None and len(config.lancedb.databases) > 1
|
return scope.covers_multiple
|
||||||
|
|
||||||
|
|
||||||
def _awaits_the_model(messages: list[ModelMessage]) -> bool:
|
def _awaits_the_model(messages: list[ModelMessage]) -> bool:
|
||||||
|
|
@ -178,7 +172,7 @@ def _called_own_tool(messages: list[ModelMessage], tool_names: frozenset[str]) -
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
||||||
db_path: Path | None
|
scope: DatabaseScope
|
||||||
config: AppConfig
|
config: AppConfig
|
||||||
state_type: type[StateT]
|
state_type: type[StateT]
|
||||||
state_namespace: str
|
state_namespace: str
|
||||||
|
|
@ -415,7 +409,7 @@ class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
||||||
if self.rag is None:
|
if self.rag is None:
|
||||||
async with self.resource_lock:
|
async with self.resource_lock:
|
||||||
if self.rag is None:
|
if self.rag is None:
|
||||||
rag = HaikuRAG(self.db_path, config=self.config, read_only=True)
|
rag = HaikuRAG._covering(self.scope, self.config, read_only=True)
|
||||||
await rag.__aenter__()
|
await rag.__aenter__()
|
||||||
self.rag = rag
|
self.rag = rag
|
||||||
return self.rag
|
return self.rag
|
||||||
|
|
@ -644,5 +638,5 @@ __all__ = [
|
||||||
"CodeExecutionEntry",
|
"CodeExecutionEntry",
|
||||||
"RAGCapabilityBase",
|
"RAGCapabilityBase",
|
||||||
"covers_several_databases",
|
"covers_several_databases",
|
||||||
"resolve_db_path",
|
"resolve_scope",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from haiku.rag.capabilities._base import (
|
||||||
EvidenceState,
|
EvidenceState,
|
||||||
RAGCapabilityBase,
|
RAGCapabilityBase,
|
||||||
covers_several_databases,
|
covers_several_databases,
|
||||||
resolve_db_path,
|
resolve_scope,
|
||||||
)
|
)
|
||||||
from haiku.rag.capabilities._tools import merge_results
|
from haiku.rag.capabilities._tools import merge_results
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
|
|
@ -84,8 +84,8 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
||||||
if self.sandbox is None:
|
if self.sandbox is None:
|
||||||
rag = await self._ensure_rag()
|
rag = await self._ensure_rag()
|
||||||
assert self.state is not None
|
assert self.state is not None
|
||||||
self.sandbox = Sandbox(
|
self.sandbox = Sandbox._covering(
|
||||||
db_path=self.db_path,
|
scope=self.scope,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
context=AnalysisContext(
|
context=AnalysisContext(
|
||||||
filter=self.state.document_filter,
|
filter=self.state.document_filter,
|
||||||
|
|
@ -213,12 +213,12 @@ def create_capability(
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
analysis_model = config.analysis.model or config.qa.model
|
analysis_model = config.analysis.model or config.qa.model
|
||||||
resolved_db_path = resolve_db_path(db_path, config)
|
scope = resolve_scope(db_path, config)
|
||||||
instruction_text = instructions()
|
instruction_text = instructions()
|
||||||
if covers_several_databases(resolved_db_path, config, rag):
|
if covers_several_databases(scope, rag):
|
||||||
instruction_text += several_databases_instructions()
|
instruction_text += several_databases_instructions()
|
||||||
return AnalysisCapability(
|
return AnalysisCapability(
|
||||||
db_path=resolved_db_path,
|
scope=scope,
|
||||||
config=config,
|
config=config,
|
||||||
borrowed_rag=rag,
|
borrowed_rag=rag,
|
||||||
state_type=AnalysisState,
|
state_type=AnalysisState,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from haiku.rag.capabilities._base import (
|
||||||
EvidenceState,
|
EvidenceState,
|
||||||
RAGCapabilityBase,
|
RAGCapabilityBase,
|
||||||
covers_several_databases,
|
covers_several_databases,
|
||||||
resolve_db_path,
|
resolve_scope,
|
||||||
)
|
)
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
|
|
||||||
|
|
@ -116,12 +116,12 @@ def create_capability(
|
||||||
from haiku.rag.config import get_config
|
from haiku.rag.config import get_config
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
resolved_db_path = resolve_db_path(db_path, config)
|
scope = resolve_scope(db_path, config)
|
||||||
instruction_text = instructions()
|
instruction_text = instructions()
|
||||||
if covers_several_databases(resolved_db_path, config, rag):
|
if covers_several_databases(scope, rag):
|
||||||
instruction_text += several_databases_instructions()
|
instruction_text += several_databases_instructions()
|
||||||
return RAGCapability(
|
return RAGCapability(
|
||||||
db_path=resolved_db_path,
|
scope=scope,
|
||||||
config=config,
|
config=config,
|
||||||
borrowed_rag=rag,
|
borrowed_rag=rag,
|
||||||
state_type=RAGState,
|
state_type=RAGState,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ from collections.abc import AsyncIterator, Callable, Coroutine
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from itertools import zip_longest
|
from itertools import zip_longest
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
import pydantic_monty
|
import pydantic_monty
|
||||||
|
|
@ -23,9 +22,10 @@ from haiku.rag.store.models.chunk import SearchResult
|
||||||
from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem
|
from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -138,7 +138,7 @@ class Sandbox:
|
||||||
each read opens an ephemeral read-only connection.
|
each read opens an ephemeral read-only connection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_db_path: Path | None
|
_scope: "DatabaseScope"
|
||||||
_config: AppConfig
|
_config: AppConfig
|
||||||
_context: AnalysisContext
|
_context: AnalysisContext
|
||||||
_rag: "HaikuRAG | None"
|
_rag: "HaikuRAG | None"
|
||||||
|
|
@ -157,13 +157,52 @@ class Sandbox:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
db_path: Path | None,
|
db_path: "Path | str | None",
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
context: AnalysisContext,
|
context: AnalysisContext,
|
||||||
rag: "HaikuRAG | None" = None,
|
rag: "HaikuRAG | None" = None,
|
||||||
lock: "asyncio.Lock | None" = None,
|
lock: "asyncio.Lock | None" = None,
|
||||||
):
|
):
|
||||||
self._db_path = db_path
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
|
|
||||||
|
self._configure(
|
||||||
|
DatabaseScope.resolve(config, database_path=db_path),
|
||||||
|
config,
|
||||||
|
context,
|
||||||
|
rag,
|
||||||
|
lock,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _covering(
|
||||||
|
cls,
|
||||||
|
scope: "DatabaseScope",
|
||||||
|
config: AppConfig,
|
||||||
|
context: AnalysisContext,
|
||||||
|
rag: "HaikuRAG | None" = None,
|
||||||
|
lock: "asyncio.Lock | None" = None,
|
||||||
|
) -> "Sandbox":
|
||||||
|
"""A sandbox over databases someone already resolved.
|
||||||
|
|
||||||
|
Internal: the public constructor takes a path and resolves it, which is
|
||||||
|
its own job. This is for callers that did the resolving, as
|
||||||
|
``HaikuRAG._covering`` is. It sets the sandbox up directly rather than
|
||||||
|
through ``__init__``, so the scope it is handed is the only one resolved.
|
||||||
|
"""
|
||||||
|
sandbox = cls.__new__(cls)
|
||||||
|
sandbox._configure(scope, config, context, rag, lock)
|
||||||
|
return sandbox
|
||||||
|
|
||||||
|
def _configure(
|
||||||
|
self,
|
||||||
|
scope: "DatabaseScope",
|
||||||
|
config: AppConfig,
|
||||||
|
context: AnalysisContext,
|
||||||
|
rag: "HaikuRAG | None",
|
||||||
|
lock: "asyncio.Lock | None",
|
||||||
|
) -> None:
|
||||||
|
"""The state every sandbox starts with, however its scope was reached."""
|
||||||
|
self._scope = scope
|
||||||
self._config = config
|
self._config = config
|
||||||
self._context = context
|
self._context = context
|
||||||
self._rag = rag
|
self._rag = rag
|
||||||
|
|
@ -202,7 +241,7 @@ class Sandbox:
|
||||||
return
|
return
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
|
||||||
async with HaikuRAG(self._db_path, config=self._config, read_only=True) as rag:
|
async with HaikuRAG._covering(self._scope, self._config, read_only=True) as rag:
|
||||||
yield rag
|
yield rag
|
||||||
|
|
||||||
async def _documents(self) -> "tuple[list[Any], dict[str, HaikuRAG]]":
|
async def _documents(self) -> "tuple[list[Any], dict[str, HaikuRAG]]":
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from haiku.rag.capabilities.compaction import EvidenceCompactionCapability
|
||||||
from haiku.rag.capabilities.policy import CAPABILITY_ID as POLICY_ID
|
from haiku.rag.capabilities.policy import CAPABILITY_ID as POLICY_ID
|
||||||
from haiku.rag.capabilities.policy import CitationPolicyCapability
|
from haiku.rag.capabilities.policy import CitationPolicyCapability
|
||||||
from haiku.rag.capabilities.rag import RAGCapability, RAGState
|
from haiku.rag.capabilities.rag import RAGCapability, RAGState
|
||||||
|
from haiku.rag.client.scope import DatabaseRef
|
||||||
|
|
||||||
ALL_CAPABILITIES = [
|
ALL_CAPABILITIES = [
|
||||||
RAGCapability,
|
RAGCapability,
|
||||||
|
|
@ -46,7 +47,7 @@ def test_rag_capability_is_built_from_a_spec(temp_db_path):
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(capability, RAGCapability)
|
assert isinstance(capability, RAGCapability)
|
||||||
assert capability.db_path == temp_db_path
|
assert capability.scope.databases == (DatabaseRef.at(temp_db_path),)
|
||||||
assert capability.id == "haiku-rag"
|
assert capability.id == "haiku-rag"
|
||||||
assert capability.state_type is RAGState
|
assert capability.state_type is RAGState
|
||||||
assert capability.tool_names == {"rag_search", "rag_cite"}
|
assert capability.tool_names == {"rag_search", "rag_cite"}
|
||||||
|
|
@ -60,7 +61,7 @@ def test_analysis_capability_is_built_from_a_spec(temp_db_path):
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(capability, AnalysisCapability)
|
assert isinstance(capability, AnalysisCapability)
|
||||||
assert capability.db_path == temp_db_path
|
assert capability.scope.databases == (DatabaseRef.at(temp_db_path),)
|
||||||
assert capability.id == "haiku-rag-analysis"
|
assert capability.id == "haiku-rag-analysis"
|
||||||
assert capability.state_type is AnalysisState
|
assert capability.state_type is AnalysisState
|
||||||
assert capability.request_limit == 30
|
assert capability.request_limit == 30
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from haiku.rag.capabilities.ledger import (
|
||||||
)
|
)
|
||||||
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGCapability, RAGState
|
from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGCapability, RAGState
|
||||||
from haiku.rag.capabilities.rag import create_capability as create_rag
|
from haiku.rag.capabilities.rag import create_capability as create_rag
|
||||||
|
from haiku.rag.client.scope import DatabaseRef
|
||||||
from haiku.rag.config.models import AppConfig, PromptsConfig
|
from haiku.rag.config.models import AppConfig, PromptsConfig
|
||||||
from haiku.rag.sandbox import Sandbox, SandboxResult
|
from haiku.rag.sandbox import Sandbox, SandboxResult
|
||||||
from haiku.rag.store.models.chunk import Chunk, SearchResult
|
from haiku.rag.store.models.chunk import Chunk, SearchResult
|
||||||
|
|
@ -91,20 +92,26 @@ def test_analysis_capability_api(temp_db_path):
|
||||||
assert capability.request_limit == 30
|
assert capability.request_limit == 30
|
||||||
|
|
||||||
|
|
||||||
|
def _placed(capability) -> "Path | None":
|
||||||
|
"""Where a capability covering one local database will open it."""
|
||||||
|
[ref] = capability.scope.databases
|
||||||
|
return ref.db_path
|
||||||
|
|
||||||
|
|
||||||
def test_capability_factories_resolve_environment_and_defaults(
|
def test_capability_factories_resolve_environment_and_defaults(
|
||||||
temp_db_path, monkeypatch
|
temp_db_path, monkeypatch
|
||||||
):
|
):
|
||||||
config = AppConfig()
|
config = AppConfig()
|
||||||
monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path))
|
monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path))
|
||||||
assert create_rag(config=config).db_path == temp_db_path
|
assert _placed(create_rag(config=config)) == temp_db_path
|
||||||
|
|
||||||
monkeypatch.delenv("HAIKU_RAG_DB")
|
monkeypatch.delenv("HAIKU_RAG_DB")
|
||||||
assert create_rag(config=config).db_path == (
|
assert _placed(create_rag(config=config)) == (
|
||||||
config.storage.data_dir / "haiku.rag.lancedb"
|
config.storage.data_dir / "haiku.rag.lancedb"
|
||||||
)
|
)
|
||||||
|
|
||||||
for factory in (create_rag, create_analysis):
|
for factory in (create_rag, create_analysis):
|
||||||
db_path = factory(db_path=str(temp_db_path), config=config).db_path
|
db_path = _placed(factory(db_path=str(temp_db_path), config=config))
|
||||||
assert db_path == temp_db_path
|
assert db_path == temp_db_path
|
||||||
assert isinstance(db_path, Path)
|
assert isinstance(db_path, Path)
|
||||||
|
|
||||||
|
|
@ -113,6 +120,63 @@ def test_capability_factories_resolve_environment_and_defaults(
|
||||||
assert create_analysis().config is config
|
assert create_analysis().config is config
|
||||||
|
|
||||||
|
|
||||||
|
class TestACapabilityFollowsTheConfiguredLocation:
|
||||||
|
"""A capability nobody handed a client opens one for itself, and has to open
|
||||||
|
the database the configuration places rather than the default directory."""
|
||||||
|
|
||||||
|
def _config(self, tmp_path, uri: str) -> AppConfig:
|
||||||
|
from haiku.rag.config.models import LanceDBConfig, StorageConfig
|
||||||
|
|
||||||
|
return AppConfig(
|
||||||
|
lancedb=LanceDBConfig(uri=uri),
|
||||||
|
storage=StorageConfig(data_dir=tmp_path / "elsewhere"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_configured_uri_is_left_to_the_client(self, tmp_path):
|
||||||
|
"""A path overrides a configured location, so manufacturing one would
|
||||||
|
send the capability to the default directory instead of the bucket."""
|
||||||
|
located = tmp_path / "notes.lancedb"
|
||||||
|
for factory in (create_rag, create_analysis):
|
||||||
|
[local] = factory(
|
||||||
|
config=self._config(tmp_path, str(located))
|
||||||
|
).scope.databases
|
||||||
|
assert local == DatabaseRef.configured(None, str(located))
|
||||||
|
|
||||||
|
remote = self._config(tmp_path, "s3://bucket/one.lancedb")
|
||||||
|
[ref] = factory(config=remote).scope.databases
|
||||||
|
assert ref == DatabaseRef(None, "s3://bucket/one.lancedb", None)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_opens_the_database_the_uri_places(self, tmp_path):
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
|
||||||
|
located = tmp_path / "notes.lancedb"
|
||||||
|
config = self._config(tmp_path, str(located))
|
||||||
|
async with HaikuRAG(config=config, create=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
capability = create_rag(config=config)
|
||||||
|
rag = await capability._ensure_rag()
|
||||||
|
try:
|
||||||
|
assert rag.store.db_path == located
|
||||||
|
finally:
|
||||||
|
await capability._close()
|
||||||
|
|
||||||
|
def test_an_explicit_path_still_overrides_the_configured_uri(self, tmp_path):
|
||||||
|
config = self._config(tmp_path, str(tmp_path / "notes.lancedb"))
|
||||||
|
chosen = tmp_path / "chosen.lancedb"
|
||||||
|
|
||||||
|
assert _placed(create_rag(db_path=chosen, config=config)) == chosen
|
||||||
|
|
||||||
|
def test_the_environment_still_overrides_the_configured_uri(
|
||||||
|
self, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
config = self._config(tmp_path, "s3://bucket/one.lancedb")
|
||||||
|
monkeypatch.setenv("HAIKU_RAG_DB", str(tmp_path / "from-env.lancedb"))
|
||||||
|
|
||||||
|
assert _placed(create_rag(config=config)) == tmp_path / "from-env.lancedb"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_string_db_path_opens_a_store(temp_db_path):
|
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."""
|
"""Store calls `absolute()` and `exists()` on db_path, which a str lacks."""
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,12 @@ def test_chat_capabilities_read_the_named_database(tmp_path, monkeypatch):
|
||||||
run_chat(scope=DatabaseScope.resolve(config))
|
run_chat(scope=DatabaseScope.resolve(config))
|
||||||
[covering] = chat_app.call_args.kwargs["capabilities"]
|
[covering] = chat_app.call_args.kwargs["capabilities"]
|
||||||
|
|
||||||
assert named.db_path == tmp_path / "b.lancedb"
|
# The chat lends its own client, so this scope is the fallback: what matters
|
||||||
|
# is that it places the named database rather than the whole set.
|
||||||
|
[placed] = named.scope.databases
|
||||||
|
assert placed.db_path == tmp_path / "b.lancedb"
|
||||||
assert named.config.lancedb.databases == {}
|
assert named.config.lancedb.databases == {}
|
||||||
assert covering.db_path is None
|
assert covering.scope.names == ("a", "b")
|
||||||
assert set(covering.config.lancedb.databases) == {"a", "b"}
|
assert set(covering.config.lancedb.databases) == {"a", "b"}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import shutil
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.client.scope import DatabaseRef, DatabaseScope
|
||||||
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
||||||
from tests.test_multi_db import _config, _seed
|
from tests.test_multi_db import _config, _seed
|
||||||
|
|
||||||
|
|
@ -10,7 +11,7 @@ from tests.test_multi_db import _config, _seed
|
||||||
async def _mounted(rag, sources=None):
|
async def _mounted(rag, sources=None):
|
||||||
"""The sandbox's view of the corpus, and the sandbox itself."""
|
"""The sandbox's view of the corpus, and the sandbox itself."""
|
||||||
sandbox = Sandbox(
|
sandbox = Sandbox(
|
||||||
db_path=rag._db_path,
|
db_path=None,
|
||||||
config=rag._config,
|
config=rag._config,
|
||||||
context=AnalysisContext(sources=sources),
|
context=AnalysisContext(sources=sources),
|
||||||
rag=rag,
|
rag=rag,
|
||||||
|
|
@ -80,6 +81,74 @@ class TestDocumentsAcrossDatabases:
|
||||||
assert owners[doc.id].source in content
|
assert owners[doc.id].source in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheSandboxConstructors:
|
||||||
|
"""`Sandbox` is public and takes a path; `_covering` is for callers that
|
||||||
|
already resolved a scope, as `HaikuRAG._covering` is."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_public_constructor_resolves_the_path_it_is_given(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
sandbox = Sandbox(
|
||||||
|
db_path=tmp_path / "alpha.lancedb",
|
||||||
|
config=config,
|
||||||
|
context=AnalysisContext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sandbox._scope.databases == (DatabaseRef.at(tmp_path / "alpha.lancedb"),)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_path_covers_what_the_configuration_places(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
sandbox = Sandbox(db_path=None, config=config, context=AnalysisContext())
|
||||||
|
|
||||||
|
assert sandbox._scope.names == ("alpha", "beta")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_covering_resolves_nothing_of_its_own(self, tmp_path, monkeypatch):
|
||||||
|
"""Handed a scope, it must not reach resolution again: resolving twice
|
||||||
|
is what let a capability's databases and its sandbox's disagree."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
scope = DatabaseScope.resolve(config, database_name="alpha")
|
||||||
|
|
||||||
|
def _refuse(*args, **kwargs):
|
||||||
|
raise AssertionError("resolved a scope it was already given")
|
||||||
|
|
||||||
|
monkeypatch.setattr(DatabaseScope, "resolve", _refuse)
|
||||||
|
sandbox = Sandbox._covering(scope, config, AnalysisContext())
|
||||||
|
|
||||||
|
assert sandbox._scope is scope
|
||||||
|
assert sandbox._config is config
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheSandboxCoversWhatTheCapabilityCovers:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_capability_hands_over_the_scope_it_resolved(self, tmp_path):
|
||||||
|
"""The capability resolved its databases once. Letting the sandbox
|
||||||
|
resolve them again from the same configuration reaches a different
|
||||||
|
answer wherever a path or the environment named one of a set."""
|
||||||
|
from haiku.rag.capabilities.analysis import AnalysisState, create_capability
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
capability = create_capability(
|
||||||
|
db_path=tmp_path / "alpha.lancedb", config=config, defer_loading=False
|
||||||
|
)
|
||||||
|
capability.state = AnalysisState()
|
||||||
|
|
||||||
|
sandbox = await capability._ensure_sandbox()
|
||||||
|
try:
|
||||||
|
assert sandbox._scope is capability.scope
|
||||||
|
assert capability.scope.names == ()
|
||||||
|
finally:
|
||||||
|
await capability._close()
|
||||||
|
|
||||||
|
|
||||||
class TestExecutingAcrossDatabases:
|
class TestExecutingAcrossDatabases:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_code_reads_documents_from_every_database(self, tmp_path):
|
async def test_code_reads_documents_from_every_database(self, tmp_path):
|
||||||
|
|
@ -91,7 +160,7 @@ class TestExecutingAcrossDatabases:
|
||||||
|
|
||||||
async with HaikuRAG(config=config) as rag:
|
async with HaikuRAG(config=config) as rag:
|
||||||
sandbox = Sandbox(
|
sandbox = Sandbox(
|
||||||
db_path=rag._db_path,
|
db_path=None,
|
||||||
config=rag._config,
|
config=rag._config,
|
||||||
context=AnalysisContext(),
|
context=AnalysisContext(),
|
||||||
rag=rag,
|
rag=rag,
|
||||||
|
|
@ -121,7 +190,7 @@ class TestExecutingAcrossDatabases:
|
||||||
beta = (await rag.clients_for(["beta"]))[0]
|
beta = (await rag.clients_for(["beta"]))[0]
|
||||||
[outside] = await beta.document_repository.list_all(limit=1)
|
[outside] = await beta.document_repository.list_all(limit=1)
|
||||||
sandbox = Sandbox(
|
sandbox = Sandbox(
|
||||||
db_path=rag._db_path,
|
db_path=None,
|
||||||
config=rag._config,
|
config=rag._config,
|
||||||
context=AnalysisContext(sources=["alpha"]),
|
context=AnalysisContext(sources=["alpha"]),
|
||||||
rag=rag,
|
rag=rag,
|
||||||
|
|
|
||||||
|
|
@ -512,7 +512,7 @@ class TestStandaloneCapabilities:
|
||||||
await _seed(config, "beta", ["beta document about cats"])
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
capability = create_capability(config=config, defer_loading=False)
|
capability = create_capability(config=config, defer_loading=False)
|
||||||
assert capability.db_path is None
|
assert capability.scope.names == ("alpha", "beta")
|
||||||
run = await capability.for_run(make_context(Deps()))
|
run = await capability.for_run(make_context(Deps()))
|
||||||
try:
|
try:
|
||||||
formatted = await run._search("cats", limit=10)
|
formatted = await run._search("cats", limit=10)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue