diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d1de05..b3a82f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - `lancedb.uri`. Write `lancedb.databases: {NAME: }`; a config carrying `uri` fails to load with that message. +- `HAIKU_RAG_DB`. Capabilities cover the databases the configuration places, or + the `db_path` argument. +- `DB_PATH` in the `app/` backend and `examples/custom_agent_agui.py`. Both load + the configuration as the CLI does (`HAIKU_RAG_CONFIG_PATH`, `./haiku.rag.yaml`, + the platform directory); the compose files set + `HAIKU_RAG_CONFIG_PATH=/app/haiku.rag.yaml` and mount `DB_VOLUME` at `/data`. ### Changed diff --git a/app/.env.example b/app/.env.example index fe3b6353..259a0849 100644 --- a/app/.env.example +++ b/app/.env.example @@ -2,8 +2,9 @@ ANTHROPIC_API_KEY=your-anthropic-key OPENAI_API_KEY=your-openai-key -# Database path -DB_PATH=/path/to/your/haiku.rag.lancedb +# Host path of the LanceDB database, mounted at /data where haiku.rag.yaml +# places it +DB_VOLUME=./data/haiku.rag.lancedb # Optional: Ollama base URL (if using local models) # Use host.docker.internal to reach Ollama running on the host machine diff --git a/app/README.md b/app/README.md index 6d3cf695..61165fea 100644 --- a/app/README.md +++ b/app/README.md @@ -40,7 +40,8 @@ A conversational RAG interface built with [CopilotKit](https://copilotkit.ai/) a | Variable | Description | Required | |----------|-------------|----------| -| `DB_PATH` | Path to your haiku.rag LanceDB database | Yes | +| `DB_VOLUME` | Host path of the LanceDB database the compose files mount at `/data`, where `haiku.rag.yaml` places it (default `./data/haiku.rag.lancedb`) | No | +| `HAIKU_RAG_CONFIG_PATH` | The configuration file; the compose files set it to the mounted `/app/haiku.rag.yaml` | No | | `ANTHROPIC_API_KEY` | Anthropic API key | One LLM key required | | `OPENAI_API_KEY` | OpenAI API key | One LLM key required | | `OLLAMA_BASE_URL` | Ollama server URL (default: `http://host.docker.internal:11434`) | For local models | diff --git a/app/backend/main.py b/app/backend/main.py index 057af35f..e0ac155f 100644 --- a/app/backend/main.py +++ b/app/backend/main.py @@ -1,9 +1,7 @@ import asyncio import logging -import os from contextlib import asynccontextmanager from dataclasses import dataclass, field -from pathlib import Path from typing import Any from ag_ui.core import EventType, StateSnapshotEvent @@ -26,8 +24,8 @@ from haiku.rag.capabilities.policy import ( ) from haiku.rag.capabilities.rag import AGENT_PREAMBLE, RAGState, create_capability from haiku.rag.client import HaikuRAG -from haiku.rag.config import load_yaml_config -from haiku.rag.config.models import AppConfig +from haiku.rag.client.scope import DatabaseScope +from haiku.rag.config import get_config from haiku.rag.telemetry import configure as configure_telemetry from haiku.rag.utils import get_model @@ -40,19 +38,23 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -# Load config -config_path = Path("/app/haiku.rag.yaml") -if config_path.exists(): - yaml_data = load_yaml_config(config_path) - config = AppConfig.model_validate(yaml_data) -else: - config = AppConfig() +# The configuration places the database. This app serves one. +config = get_config() +scope = DatabaseScope.resolve(config) +if scope.covers_multiple: + raise SystemExit( + f"lancedb.databases names {', '.join(scope.names)}; this app serves one " + "database: configure exactly one entry" + ) +[database] = scope.databases -# Get DB path from environment -db_path_str = os.getenv("DB_PATH", "haiku_rag.lancedb") -db_path = Path(db_path_str) -logger.info(f"Database path: {db_path}") +def _database_exists() -> bool: + """A database behind a URI has no path to check.""" + return database.db_path is None or database.db_path.exists() + + +logger.info(f"Database: {database.name} at {database.location}") logger.info(f"QA Provider: {config.qa.model.provider}, Model: {config.qa.model.name}") # Only HaikuRAG client is a singleton (expensive to create) @@ -71,7 +73,7 @@ async def get_client() -> HaikuRAG: if _client is None: async with _client_lock: if _client is None: - client = HaikuRAG(db_path=db_path, config=config, create=True) + client = HaikuRAG(config=config, create=True) await client.__aenter__() _client = client return _client @@ -82,7 +84,7 @@ class AppDeps: state: dict[str, Any] = field(default_factory=dict) -capability = create_capability(db_path=db_path, config=config, defer_loading=False) +capability = create_capability(config=config, defer_loading=False) agent = Agent( get_model(config.qa.model, config), @@ -138,15 +140,15 @@ async def health_check(_: Request) -> JSONResponse: "status": "healthy", "qa_provider": config.qa.model.provider, "qa_model": config.qa.model.name, - "db_path": str(db_path), - "db_exists": db_path.exists(), + "db_path": str(database.location), + "db_exists": _database_exists(), } ) async def list_documents(_: Request) -> JSONResponse: """List all documents in the database.""" - if not db_path.exists(): + if not _database_exists(): return JSONResponse({"documents": [], "error": "Database not found"}) client = await get_client() @@ -162,11 +164,11 @@ async def list_documents(_: Request) -> JSONResponse: async def db_info(_: Request) -> JSONResponse: """Get database info and statistics.""" - if not db_path.exists(): + if not _database_exists(): return JSONResponse( { "exists": False, - "path": str(db_path), + "path": str(database.location), "documents": 0, "chunks": 0, } @@ -180,7 +182,7 @@ async def db_info(_: Request) -> JSONResponse: return JSONResponse( { "exists": True, - "path": str(db_path), + "path": str(database.location), "documents": stats["documents"].get("num_rows", 0), "chunks": stats["chunks"].get("num_rows", 0), "documents_bytes": stats["documents"].get("total_bytes", 0), @@ -214,7 +216,7 @@ async def visualize_chunk(request: Request) -> JSONResponse: if isinstance(parsed, list): refs = [str(x) for x in parsed] - if not db_path.exists(): + if not _database_exists(): return JSONResponse({"error": "Database not found"}, status_code=404) client = await get_client() diff --git a/app/docker-compose.dev.yml b/app/docker-compose.dev.yml index 5ecfa7f7..ace712ea 100644 --- a/app/docker-compose.dev.yml +++ b/app/docker-compose.dev.yml @@ -11,13 +11,14 @@ services: ports: - "127.0.0.1:8001:8000" environment: - - DB_PATH=/data + - HAIKU_RAG_CONFIG_PATH=/app/haiku.rag.yaml - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} - LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-} volumes: - - ${DB_PATH:-./data/haiku.rag.lancedb}:/data + # haiku.rag.yaml places the database at /data. + - ${DB_VOLUME:-./data/haiku.rag.lancedb}:/data - ./backend:/app/src:ro - ./haiku.rag.yaml:/app/haiku.rag.yaml:ro extra_hosts: diff --git a/app/docker-compose.yml b/app/docker-compose.yml index 305c0870..996351f5 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -7,13 +7,14 @@ services: ports: - "127.0.0.1:8001:8000" environment: - - DB_PATH=/data + - HAIKU_RAG_CONFIG_PATH=/app/haiku.rag.yaml - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} - LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-} volumes: - - ${DB_PATH:-./data/haiku.rag.lancedb}:/data + # haiku.rag.yaml places the database at /data. + - ${DB_VOLUME:-./data/haiku.rag.lancedb}:/data - ./haiku.rag.yaml:/app/haiku.rag.yaml:ro extra_hosts: - "host.docker.internal:host-gateway" diff --git a/app/haiku.rag.yaml.example b/app/haiku.rag.yaml.example index 40b508b3..d80a7793 100644 --- a/app/haiku.rag.yaml.example +++ b/app/haiku.rag.yaml.example @@ -1,6 +1,12 @@ # haiku.rag configuration for the chat app # Copy to haiku.rag.yaml and customize as needed +# The database. The compose files mount DB_VOLUME (default +# ./data/haiku.rag.lancedb) at /data. +lancedb: + databases: + haiku.rag: /data + # QA model configuration qa: model: diff --git a/docs/apps.md b/docs/apps.md index f2633602..a4c16f43 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -40,8 +40,8 @@ Create a `.env` file in the `app/` directory: ANTHROPIC_API_KEY=your-anthropic-key OPENAI_API_KEY=your-openai-key -# Database path -DB_PATH=/path/to/your/haiku.rag.lancedb +# Host path of the LanceDB database, mounted at /data +DB_VOLUME=/path/to/your/haiku.rag.lancedb # Optional: Ollama base URL (if using local models) OLLAMA_BASE_URL=http://localhost:11434 @@ -50,16 +50,22 @@ OLLAMA_BASE_URL=http://localhost:11434 LOGFIRE_TOKEN=your-logfire-token ``` -For full configuration, mount a `haiku.rag.yaml` file: +The mounted `haiku.rag.yaml` places the database at `/data` and configures the models; the compose files point `HAIKU_RAG_CONFIG_PATH` at it: ```yaml # app/haiku.rag.yaml +lancedb: + databases: + haiku.rag: /data + qa: model: provider: anthropic name: claude-sonnet-4-20250514 ``` +Outside compose, the backend loads its configuration like the CLI: `HAIKU_RAG_CONFIG_PATH`, then `./haiku.rag.yaml`, then the platform directory. + ## API endpoints | Endpoint | Method | Description | diff --git a/docs/capabilities/index.md b/docs/capabilities/index.md index e56cd40c..206cdc82 100644 --- a/docs/capabilities/index.md +++ b/docs/capabilities/index.md @@ -145,6 +145,6 @@ Applications serving AG-UI should adapt the agent with Pydantic AI's `AGUIAdapte ## Database Selection -RAG and analysis capabilities cover the databases the configuration places: [`lancedb.databases`](../configuration/storage.md#multiple-databases), or with nothing configured the default database `haiku.rag` under `storage.data_dir`. The `db_path` argument, or `HAIKU_RAG_DB`, places one database where the configuration places none; beside `lancedb.databases` either raises `AmbiguousDatabaseError`. +RAG and analysis capabilities cover the databases the configuration places: [`lancedb.databases`](../configuration/storage.md#multiple-databases), or with nothing configured the default database `haiku.rag` under `storage.data_dir`. The `db_path` argument places one database where the configuration places none; beside `lancedb.databases` it raises `AmbiguousDatabaseError`. Passing a client through `rag=` bypasses this selection. The capability uses the databases covered by that client and does not close it. diff --git a/docs/configuration/storage.md b/docs/configuration/storage.md index 34c41b87..99986cec 100644 --- a/docs/configuration/storage.md +++ b/docs/configuration/storage.md @@ -235,7 +235,7 @@ results = await client.search("query") # every database results = await client.search("query", sources=["papers"]) # one of them ``` -Candidates are combined into one ranked list with the configured reranker, or by cosine similarity to the query when reranking is disabled, with within-database rank breaking ties (full-text-only searches order by retrieval score). `SearchResult.source`, `Citation.source`, and `Document.source` contain the configured database name. The name is retained when a client covers only one named database. Databases configured through `lancedb.uri` are unnamed, so their `source` is `None`. +Candidates are combined into one ranked list with the configured reranker, or by cosine similarity to the query when reranking is disabled, with within-database rank breaking ties (full-text-only searches order by retrieval score). `SearchResult.source`, `Citation.source`, and `Document.source` carry the database name, for a set and for one database alike. The CLI labels results and citations only when the operation spans multiple databases. A command already narrowed with `--db-name` does not repeat the name on every result. diff --git a/evaluations/evaluations/capability_runner.py b/evaluations/evaluations/capability_runner.py index 4b85e821..59f517bd 100644 --- a/evaluations/evaluations/capability_runner.py +++ b/evaluations/evaluations/capability_runner.py @@ -44,7 +44,7 @@ class CapabilityRunResult: cited_uris: list[str] = field(default_factory=list) cited_chunk_ids: list[str] = field(default_factory=list) # The database each cited chunk came from, in the order they were cited. - # Empty string where the database is unnamed. + # Empty string for a citation built without a source. cited_sources: list[str] = field(default_factory=list) searched_uris: list[str] = field(default_factory=list) n_searches: int = 0 diff --git a/evaluations/tests/test_capability_runner.py b/evaluations/tests/test_capability_runner.py index f3909a99..0dc97eb7 100644 --- a/evaluations/tests/test_capability_runner.py +++ b/evaluations/tests/test_capability_runner.py @@ -485,8 +485,8 @@ def test_records_the_database_each_citation_came_from(): assert result.cited_sources == ["alpha", "beta", "alpha"] -def test_an_unnamed_database_records_no_source(): - """One database names nothing: the field holds an empty string.""" +def test_a_hand_built_citation_without_a_source_records_an_empty_string(): + """A citation built without a source is recorded as an empty string.""" from haiku.rag.capabilities._base import EvidenceState from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord from haiku.rag.store.models.citation import Citation diff --git a/examples/README.md b/examples/README.md index 51a9f3ec..4112e00e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,8 +26,8 @@ uv run python examples/custom_agent.py /path/to/db.lancedb **Script:** `custom_agent_agui.py` -A Starlette app that adapts a native RAG-capable agent to AG-UI. +A Starlette app that adapts a native RAG-capable agent to AG-UI. The configuration places the database (`HAIKU_RAG_CONFIG_PATH`, or `./haiku.rag.yaml`): ```bash -DB_PATH=/path/to/db.lancedb uv run uvicorn examples.custom_agent_agui:app --reload --port 8000 +uv run uvicorn examples.custom_agent_agui:app --reload --port 8000 ``` diff --git a/examples/custom_agent_agui.py b/examples/custom_agent_agui.py index 78f7b08e..9bbedc8a 100644 --- a/examples/custom_agent_agui.py +++ b/examples/custom_agent_agui.py @@ -8,13 +8,13 @@ Requirements: - An Anthropic API key (for the QA model) or adjust the model below Usage: - DB_PATH=/path/to/db.lancedb uv run uvicorn examples.custom_agent_agui:app --reload --port 8000 + uv run uvicorn examples.custom_agent_agui:app --reload --port 8000 + + The configuration places the database (HAIKU_RAG_CONFIG_PATH, or + ./haiku.rag.yaml). """ -import os -import sys from dataclasses import dataclass, field -from pathlib import Path from typing import Any from ag_ui.core import EventType, StateSnapshotEvent @@ -30,14 +30,7 @@ from haiku.rag.capabilities.compaction import create_capability as compaction from haiku.rag.capabilities.policy import create_capability as citation_policy from haiku.rag.capabilities.rag import RAGState, create_capability -db_path = os.environ.get("DB_PATH") -if not db_path: - print( - "Set DB_PATH environment variable to your haiku.rag database", file=sys.stderr - ) - sys.exit(1) - -capability = create_capability(db_path=Path(db_path), defer_loading=False) +capability = create_capability(defer_loading=False) @dataclass diff --git a/haiku_rag_slim/haiku/rag/capabilities/_base.py b/haiku_rag_slim/haiku/rag/capabilities/_base.py index 56c91c84..23901398 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/_base.py +++ b/haiku_rag_slim/haiku/rag/capabilities/_base.py @@ -1,5 +1,4 @@ import asyncio -import os from dataclasses import dataclass, field, replace from difflib import get_close_matches from pathlib import Path @@ -99,12 +98,7 @@ def _nearest_known_id(chunk_id: str, known_ids: list[str]) -> str: def resolve_scope(db_path: Path | str | None, config: AppConfig) -> DatabaseScope: - """The databases a capability covers, resolved once at its entry point. - - ``HAIKU_RAG_DB`` is read here and nowhere else. - """ - if db_path is None and (env_db := os.environ.get("HAIKU_RAG_DB")): - db_path = Path(env_db).expanduser() + """The databases a capability covers, resolved once at its entry point.""" return DatabaseScope.resolve(config, database_path=db_path) diff --git a/haiku_rag_slim/haiku/rag/chat/app.py b/haiku_rag_slim/haiku/rag/chat/app.py index b5dccbdb..8e112746 100644 --- a/haiku_rag_slim/haiku/rag/chat/app.py +++ b/haiku_rag_slim/haiku/rag/chat/app.py @@ -427,7 +427,8 @@ class ChatApp(App): def on_document_filter_modal_filter_changed(self, event: Any) -> None: """Scope the conversation to the selection: the filter carries the ids, - and `sources` restricts the search to the databases the selection names. + and over a set `sources` restricts the search to the databases the + selection names. One database needs no narrowing by source. """ from haiku.rag.tools.filters import build_document_id_filter @@ -436,10 +437,9 @@ class ChatApp(App): doc_filter = build_document_id_filter( sorted({doc_id for _, doc_id in event.selected}) ) - selected_sources = {source for source, _ in event.selected} - sources: list[str] | None = None - if selected_sources and None not in selected_sources: - sources = sorted(s for s in selected_sources if s is not None) + selected_sources = sorted({source for source, _ in event.selected if source}) + covers_multiple = self.client is not None and self.client.covers_multiple + sources = selected_sources if covers_multiple and selected_sources else None for namespace, state_type in ( (RAG_STATE_NAMESPACE, RAGState), (ANALYSIS_STATE_NAMESPACE, AnalysisState), diff --git a/haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py b/haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py index 88c6fdf6..1525c058 100644 --- a/haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py +++ b/haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py @@ -24,15 +24,18 @@ class DocumentCheckbox(Checkbox): self.doc_id = doc_id -def _labelled(docs) -> list[tuple[str, str | None, str]]: - """Each document's label, database and id, sorted by label. The database is - named alongside the title, which a title alone does not say. Labels are - escaped: titles and database names are data, not Textual markup.""" +def _labelled( + docs, *, name_database: bool = False +) -> list[tuple[str, str | None, str]]: + """Each document's label, database and id, sorted by label. Across several + databases the database is named alongside the title, which a title alone + does not say. Labels are escaped: titles and database names are data, not + Textual markup.""" rows = [ ( escape( f"{doc.title or doc.uri or doc.id}" - + (f" ({doc.source})" if doc.source else "") + + (f" ({doc.source})" if name_database and doc.source else "") ), doc.source, doc.id, @@ -221,7 +224,9 @@ class DocumentFilterModal(ModalScreen): DocumentCheckbox( label, source, doc_id, value=(source, doc_id) in self._selected ) - for label, source, doc_id in _labelled(docs) + for label, source, doc_id in _labelled( + docs, name_database=self.client.covers_multiple + ) ] if boxes: await filter_list.mount_all(boxes) diff --git a/haiku_rag_slim/haiku/rag/client/scope.py b/haiku_rag_slim/haiku/rag/client/scope.py index cb243016..1c487d24 100644 --- a/haiku_rag_slim/haiku/rag/client/scope.py +++ b/haiku_rag_slim/haiku/rag/client/scope.py @@ -70,10 +70,7 @@ class DatabaseScope: """The databases an operation covers. Resolved once, from configuration plus at most one selector, then passed - down. Never empty. - - Nothing here reads the environment: ``HAIKU_RAG_DB`` is the capability entry - point's to honour. + down. Never empty. Nothing here reads the environment. """ databases: tuple[DatabaseRef, ...] diff --git a/haiku_rag_slim/haiku/rag/store/models/chunk.py b/haiku_rag_slim/haiku/rag/store/models/chunk.py index e04fbb55..6c2b4215 100644 --- a/haiku_rag_slim/haiku/rag/store/models/chunk.py +++ b/haiku_rag_slim/haiku/rag/store/models/chunk.py @@ -146,10 +146,10 @@ class SearchResult(BaseModel): include the metadata of any other chunks merged with it. Never part of ``format_for_agent`` output. - ``source`` names the configured database a result came from: the name from - ``lancedb.databases``, never a path or URI, so a location cannot travel in a - result, a citation or a log. It is None only where no database is named, as - with the single ``lancedb.uri``. + ``source`` names the database a result came from: the name from + ``lancedb.databases`` or a path's stem, never a path or URI, so a location + cannot travel in a result, a citation or a log. Every result a search + produces carries it; None only on a result built by hand. """ content: str diff --git a/haiku_rag_slim/haiku/rag/store/models/citation.py b/haiku_rag_slim/haiku/rag/store/models/citation.py index e1f51486..15abdfd4 100644 --- a/haiku_rag_slim/haiku/rag/store/models/citation.py +++ b/haiku_rag_slim/haiku/rag/store/models/citation.py @@ -24,9 +24,9 @@ class Citation(BaseModel): ``chunk_ids`` lists the ids of all chunks whose expansion ranges merged into the cited result (always includes ``chunk_id``). - ``source`` names the configured database the cited chunk came from: the name - from ``lancedb.databases``, never a path or URI. It is None only where no - database is named, as with the single ``lancedb.uri``. + ``source`` names the database the cited chunk came from: the name from + ``lancedb.databases`` or a path's stem, never a path or URI. None only on a + citation resolved from a hand-built result. ``doc_item_refs`` are the ``self_ref`` values of every item in the cited content — the exact items the model saw. Visual grounding resolves bounding diff --git a/haiku_rag_slim/haiku/rag/store/models/document.py b/haiku_rag_slim/haiku/rag/store/models/document.py index 06407edf..44343a20 100644 --- a/haiku_rag_slim/haiku/rag/store/models/document.py +++ b/haiku_rag_slim/haiku/rag/store/models/document.py @@ -14,10 +14,10 @@ class Document(BaseModel): """ Represents a document with an ID, content, and metadata. - ``source`` names the configured database a document came from: the name - from ``lancedb.databases``, never a path or URI. It is None where no - database is named, as with the single ``lancedb.uri``, and is never - persisted. + ``source`` names the database a document came from: the name from + ``lancedb.databases`` or a path's stem, never a path or URI. Every document + a database returns carries it; it is never persisted, and None only on a + document built by hand. """ id: str | None = None diff --git a/tests/capabilities/test_capabilities.py b/tests/capabilities/test_capabilities.py index 8ea532ff..a482e5e5 100644 --- a/tests/capabilities/test_capabilities.py +++ b/tests/capabilities/test_capabilities.py @@ -98,14 +98,10 @@ def _placed(capability) -> "Path | None": return ref.db_path -def test_capability_factories_resolve_environment_and_defaults( - temp_db_path, monkeypatch -): +def test_capability_factories_resolve_defaults(temp_db_path, monkeypatch): + """The configuration places the database; the environment plays no part.""" config = AppConfig() monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path)) - assert _placed(create_rag(config=config)) == temp_db_path - - monkeypatch.delenv("HAIKU_RAG_DB") assert _placed(create_rag(config=config)) == ( config.storage.data_dir / "haiku.rag.lancedb" ) @@ -170,17 +166,6 @@ class TestACapabilityFollowsTheConfiguredLocation: with pytest.raises(AmbiguousDatabaseError, match="notes"): factory(db_path=chosen, config=config) - def test_the_environment_beside_the_configured_placement_is_refused( - self, tmp_path, monkeypatch - ): - from haiku.rag.store.exceptions import AmbiguousDatabaseError - - config = self._config(tmp_path, "s3://bucket/one.lancedb") - monkeypatch.setenv("HAIKU_RAG_DB", str(tmp_path / "from-env.lancedb")) - - with pytest.raises(AmbiguousDatabaseError, match="notes"): - create_rag(config=config) - @pytest.mark.asyncio async def test_a_string_db_path_opens_a_store(temp_db_path): @@ -211,15 +196,15 @@ def test_domain_preamble_is_added_to_capability_instructions(temp_db_path): def _single_database_client() -> AsyncMock: - """A stand-in for a client covering one unnamed database. + """A stand-in for a client covering one database. - `covers_multiple`, `source` and `clients_covering` answer as one unnamed - database does; a bare AsyncMock answers every attribute with a truthy Mock. + `covers_multiple`, `source` and `clients_covering` answer as one database + does; a bare AsyncMock answers every attribute with a truthy Mock. """ client = AsyncMock() client.covers_multiple = False - client.source_names = () - client.source = None + client.source_names = ("test",) + client.source = "test" client.clients_covering.return_value = [client] return client diff --git a/tests/chat/test_chat_app.py b/tests/chat/test_chat_app.py index cc5774cb..efa976f0 100644 --- a/tests/chat/test_chat_app.py +++ b/tests/chat/test_chat_app.py @@ -159,8 +159,8 @@ def _make_mock_client(): # Covers one database; a bare AsyncMock answers `covers_multiple` with a # truthy Mock. mock_client.covers_multiple = False - mock_client.source_names = () - mock_client.source = None + mock_client.source_names = ("test",) + mock_client.source = "test" return mock_client @@ -462,9 +462,9 @@ async def test_document_filter_updates_rag_state(temp_db_path: Path): ): async with app.run_test(): # The selection is document ids, so a repeated title cannot widen it. - selected = [ - (None, "6f1c2d4e-0000-4000-8000-000000000001"), - (None, "6f1c2d4e-0000-4000-8000-000000000002"), + selected: list[tuple[str | None, str]] = [ + ("test", "6f1c2d4e-0000-4000-8000-000000000001"), + ("test", "6f1c2d4e-0000-4000-8000-000000000002"), ] app.on_document_filter_modal_filter_changed( DocumentFilterModal.FilterChanged(selected) @@ -478,7 +478,7 @@ async def test_document_filter_updates_rag_state(temp_db_path: Path): assert rag_state.document_filter == expected_filter assert rag_state.document_filter is not None assert "LIKE" not in rag_state.document_filter - # An unnamed database leaves the question unscoped by source. + # One database leaves the question unscoped by source. assert rag_state.sources is None # The state snapshot should also reflect the change @@ -487,12 +487,15 @@ async def test_document_filter_updates_rag_state(temp_db_path: Path): @pytest.mark.asyncio async def test_document_filter_narrows_sources_to_the_selection(temp_db_path: Path): - """The filter carries ids, and `sources` restricts the question to the - databases the selection names.""" + """Over a set, the filter carries ids and `sources` restricts the question + to the databases the selection names.""" from haiku.rag.chat.app import RAG_STATE_NAMESPACE from haiku.rag.chat.widgets.document_filter_modal import DocumentFilterModal app, mock_client = _make_app_with_state(temp_db_path) + mock_client.covers_multiple = True + mock_client.source_names = ("alpha", "beta") + mock_client.source = None with ( patch("haiku.rag.chat.app.HaikuRAG") as _stub_rag, @@ -538,7 +541,7 @@ async def test_document_filter_cleared_when_empty(temp_db_path: Path): async with app.run_test(): # First set a filter app.on_document_filter_modal_filter_changed( - DocumentFilterModal.FilterChanged([(None, "AI Overview")]) + DocumentFilterModal.FilterChanged([("test", "AI Overview")]) ) rag_state = RAGState.model_validate(app._state[RAG_STATE_NAMESPACE]) assert rag_state.document_filter is not None @@ -817,11 +820,23 @@ class TestDocumentSelectionIdentity: ) ] - ((label, source, doc_id),) = _labelled(docs) + ((label, source, doc_id),) = _labelled(docs, name_database=True) box = DocumentCheckbox(label, source, doc_id, value=False) assert str(box.label) == "Report [/red] (alpha [/x])" + def test_one_database_is_not_named_on_its_labels(self): + """A single database names every document alike, so the label says + nothing a title does not.""" + from haiku.rag.chat.widgets.document_filter_modal import _labelled + from haiku.rag.store.models.document import Document + + docs = [Document(id="id-one", content="", title="Report", source="test")] + + ((label, _, _),) = _labelled(docs) + + assert label == "Report" + def test_a_citation_title_that_looks_like_markup_is_text(): from rich.text import Text @@ -1046,17 +1061,20 @@ class TestKeepingSelectionsReachable: from haiku.rag.store.models.document import Document picked = [ - Document(id=f"sel-{i:04d}", content="", title=f"Selected {i:04d}") + Document( + id=f"sel-{i:04d}", content="", title=f"Selected {i:04d}", source="test" + ) for i in range(DOCUMENT_PAGE + 20) ] by_id = {d.id: d for d in picked} matched = [ - Document(id=f"hit-{i}", content="", title=f"Hit {i}") for i in range(5) + Document(id=f"hit-{i}", content="", title=f"Hit {i}", source="test") + for i in range(5) ] client = AsyncMock() client.covers_multiple = False - client.source_names = () + client.source_names = ("test",) client.count_documents.return_value = 5 async def listing(limit=None, offset=0, filter=None): @@ -1068,7 +1086,7 @@ class TestKeepingSelectionsReachable: client.list_documents.side_effect = listing modal = DocumentFilterModal( - client=client, selected=[(None, d.id or "") for d in picked] + client=client, selected=[("test", d.id or "") for d in picked] ) app, _ = _make_app(temp_db_path, client) with ( @@ -1115,14 +1133,16 @@ class TestKeepingSelectionsReachable: from haiku.rag.store.models.document import Document picked = [ - Document(id=f"sel-{i:04d}", content="", title=f"Selected {i:04d}") + Document( + id=f"sel-{i:04d}", content="", title=f"Selected {i:04d}", source="test" + ) for i in range(DOCUMENT_PAGE + 1) ] by_id = {d.id: d for d in picked} client = AsyncMock() client.covers_multiple = False - client.source_names = () + client.source_names = ("test",) client.count_documents.return_value = 0 async def listing(limit=None, offset=0, filter=None): @@ -1134,7 +1154,7 @@ class TestKeepingSelectionsReachable: client.list_documents.side_effect = listing modal = DocumentFilterModal( - client=client, selected=[(None, d.id or "") for d in picked] + client=client, selected=[("test", d.id or "") for d in picked] ) app, _ = _make_app(temp_db_path, client) with ( @@ -1166,7 +1186,9 @@ class TestKeepingSelectionsReachable: # The row is gone from the listing, not merely unchecked. assert "sel-0200" not in remaining assert len(remaining) == DOCUMENT_PAGE - assert modal._selected == {(None, d.id) for d in picked} - {(None, "sel-0200")} + assert modal._selected == {("test", d.id) for d in picked} - { + ("test", "sel-0200") + } # The page it was on no longer exists, so the modal does not report it. assert modal._page == 0 assert "page" not in footer @@ -1186,10 +1208,10 @@ class TestKeepingSelectionsReachable: client = AsyncMock() client.covers_multiple = False - client.source_names = () + client.source_names = ("test",) client.count_documents.return_value = DOCUMENT_PAGE * 2 client.list_documents.return_value = [ - Document(id="d1", content="", title="One") + Document(id="d1", content="", title="One", source="test") ] modal = DocumentFilterModal(client=client) @@ -1219,7 +1241,7 @@ class TestKeepingSelectionsReachable: client = AsyncMock() client.covers_multiple = False - client.source_names = () + client.source_names = ("test",) client.list_documents.return_value = [] client.count_documents.return_value = 0 @@ -1254,10 +1276,10 @@ class TestKeepingSelectionsReachable: client = AsyncMock() client.covers_multiple = False - client.source_names = () + client.source_names = ("test",) client.list_documents.return_value = [ - Document(id="id-one", content="", title="Capital region"), - Document(id="id-two", content="", title="Nobel laureates"), + Document(id="id-one", content="", title="Capital region", source="test"), + Document(id="id-two", content="", title="Nobel laureates", source="test"), ] client.count_documents.return_value = DOCUMENT_PAGE * 2 @@ -1360,10 +1382,10 @@ class TestDocumentSearchFilter: client = AsyncMock() client.covers_multiple = False - client.source_names = () + client.source_names = ("test",) client.list_documents.return_value = [ - Document(id="id-one", content="", title="Capital region"), - Document(id="id-two", content="", title="Nobel laureates"), + Document(id="id-one", content="", title="Capital region", source="test"), + Document(id="id-two", content="", title="Nobel laureates", source="test"), ] client.count_documents.return_value = 2 @@ -1379,7 +1401,9 @@ class TestDocumentSearchFilter: assert len(list(modal.query(DocumentCheckbox))) == 2 client.list_documents.return_value = [ - Document(id="id-two", content="", title="Nobel laureates"), + Document( + id="id-two", content="", title="Nobel laureates", source="test" + ), ] client.count_documents.return_value = 1 await modal.on_input_submitted(Input.Submitted(Input(), "Nobel")) diff --git a/tests/multi_db/test_capabilities.py b/tests/multi_db/test_capabilities.py index 995910f1..4a827e7b 100644 --- a/tests/multi_db/test_capabilities.py +++ b/tests/multi_db/test_capabilities.py @@ -162,8 +162,9 @@ class TestCollectionIdentityForTheModel: assert "Collection" not in result.format_for_agent() - def test_an_unnamed_collection_is_never_mentioned(self): - """Nothing to name, whatever the caller asked for.""" + def test_a_hand_built_result_without_a_source_is_never_labelled(self): + """A result built by hand carries no source to name, whatever the + caller asked for.""" result = SearchResult(content="body", score=0.9, chunk_id="c1") assert "Collection" not in result.format_for_agent(include_collection=True) diff --git a/tests/multi_db/test_citations.py b/tests/multi_db/test_citations.py index 62f5d223..1c29974e 100644 --- a/tests/multi_db/test_citations.py +++ b/tests/multi_db/test_citations.py @@ -299,7 +299,7 @@ class TestCitationSource: assert citation.chunk_id == "c1" - def test_a_single_database_citation_has_no_source(self): + def test_a_citation_from_a_hand_built_result_has_no_source(self): result = SearchResult( content="body", score=0.9, diff --git a/tests/multi_db/test_documents.py b/tests/multi_db/test_documents.py index 83ad766e..2d1998b4 100644 --- a/tests/multi_db/test_documents.py +++ b/tests/multi_db/test_documents.py @@ -259,7 +259,8 @@ class TestLookupByIdentifier: assert chunk is not None and chunk.content == "alpha one" @pytest.mark.asyncio - async def test_an_unnamed_database_answers_to_no_name(self, temp_db_path): + async def test_a_database_at_a_path_answers_to_its_stem_alone(self, temp_db_path): + stem = temp_db_path.stem async with HaikuRAG(temp_db_path, create=True) as rag: docling = DoclingDocument(name="one") docling.add_text(label=DocItemLabel.TEXT, text="body") @@ -276,6 +277,8 @@ class TestLookupByIdentifier: assert await rag.get_document_by_id(doc.id) is not None assert await rag.get_chunk_by_id(held.id) is not None + assert await rag.get_document_by_id(doc.id, stem) is not None + assert await rag.get_chunk_by_id(held.id, stem) is not None with pytest.raises(UnknownDatabaseError): await rag.get_document_by_id(doc.id, "alpha") with pytest.raises(UnknownDatabaseError): diff --git a/tests/multi_db/test_lifecycle.py b/tests/multi_db/test_lifecycle.py index bc097958..f3b46f4e 100644 --- a/tests/multi_db/test_lifecycle.py +++ b/tests/multi_db/test_lifecycle.py @@ -475,8 +475,8 @@ class TestFailureNaming: assert caught.value.__cause__ is None @pytest.mark.asyncio - async def test_an_unnamed_database_keeps_its_error(self, tmp_path): - """Nothing named it, so there is no name to report.""" + async def test_a_database_given_as_a_path_keeps_its_error(self, tmp_path): + """The caller gave the path, so the error may name it.""" with pytest.raises(FileNotFoundError): async with HaikuRAG(tmp_path / "nope.lancedb"): pass diff --git a/tests/test_database_scope.py b/tests/test_database_scope.py index 942247d1..9516ab08 100644 --- a/tests/test_database_scope.py +++ b/tests/test_database_scope.py @@ -108,9 +108,8 @@ class TestResolution: with pytest.raises(UnknownDatabaseError, match="haiku.rag"): DatabaseScope.resolve(config, database_name="haiku.rag") - def test_the_environment_is_not_consulted(self, monkeypatch, tmp_path): - """HAIKU_RAG_DB is honoured by the capability entry point alone; - resolution never reads the environment.""" + def test_the_environment_is_not_consulted(self, monkeypatch): + """Resolution reads the configuration alone.""" monkeypatch.setenv("HAIKU_RAG_DB", "/data/from-the-environment.lancedb") config = _config(databases={"alpha": "/data/alpha.lancedb"}) diff --git a/tests/test_utils.py b/tests/test_utils.py index 5b233d31..82e2a877 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -885,7 +885,7 @@ async def test_format_citations_rich_omits_the_database_for_one_database(): ) client = AsyncMock() client.covers_multiple = False - client.source_names = () + client.source_names = ("papers",) output = _render_rich(await format_citations_rich([citation], client))