Remove the environment overrides and the last unnamed-database wording

HAIKU_RAG_DB and DB_PATH are gone: a capability covers what the
configuration places or the db_path it is given, and the app backend and
the AG-UI example load their configuration as the CLI does. The compose
files point HAIKU_RAG_CONFIG_PATH at the mounted haiku.rag.yaml, which
places the database at /data where DB_VOLUME is mounted; the backend
refuses a configured set since it serves one database. The chat scopes a
selection by source only over a set and names databases on filter rows
only across several. Docstrings, docs and test fixtures stop describing an
unnamed database; every database a search, listing or citation reports
carries a name.
This commit is contained in:
Yiorgis Gozadinos 2026-09-03 14:15:08 +03:00
parent 34180a0fd1
commit a04a16c717
No known key found for this signature in database
29 changed files with 171 additions and 146 deletions

View file

@ -6,6 +6,12 @@
- `lancedb.uri`. Write `lancedb.databases: {NAME: <location>}`; a config carrying - `lancedb.uri`. Write `lancedb.databases: {NAME: <location>}`; a config carrying
`uri` fails to load with that message. `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 ### Changed

View file

@ -2,8 +2,9 @@
ANTHROPIC_API_KEY=your-anthropic-key ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key OPENAI_API_KEY=your-openai-key
# Database path # Host path of the LanceDB database, mounted at /data where haiku.rag.yaml
DB_PATH=/path/to/your/haiku.rag.lancedb # places it
DB_VOLUME=./data/haiku.rag.lancedb
# Optional: Ollama base URL (if using local models) # Optional: Ollama base URL (if using local models)
# Use host.docker.internal to reach Ollama running on the host machine # Use host.docker.internal to reach Ollama running on the host machine

View file

@ -40,7 +40,8 @@ A conversational RAG interface built with [CopilotKit](https://copilotkit.ai/) a
| Variable | Description | Required | | 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 | | `ANTHROPIC_API_KEY` | Anthropic API key | One LLM key required |
| `OPENAI_API_KEY` | OpenAI 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 | | `OLLAMA_BASE_URL` | Ollama server URL (default: `http://host.docker.internal:11434`) | For local models |

View file

@ -1,9 +1,7 @@
import asyncio import asyncio
import logging import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any from typing import Any
from ag_ui.core import EventType, StateSnapshotEvent 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.capabilities.rag import AGENT_PREAMBLE, RAGState, create_capability
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.config import load_yaml_config from haiku.rag.client.scope import DatabaseScope
from haiku.rag.config.models import AppConfig from haiku.rag.config import get_config
from haiku.rag.telemetry import configure as configure_telemetry from haiku.rag.telemetry import configure as configure_telemetry
from haiku.rag.utils import get_model from haiku.rag.utils import get_model
@ -40,19 +38,23 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Load config # The configuration places the database. This app serves one.
config_path = Path("/app/haiku.rag.yaml") config = get_config()
if config_path.exists(): scope = DatabaseScope.resolve(config)
yaml_data = load_yaml_config(config_path) if scope.covers_multiple:
config = AppConfig.model_validate(yaml_data) raise SystemExit(
else: f"lancedb.databases names {', '.join(scope.names)}; this app serves one "
config = AppConfig() "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}") logger.info(f"QA Provider: {config.qa.model.provider}, Model: {config.qa.model.name}")
# Only HaikuRAG client is a singleton (expensive to create) # Only HaikuRAG client is a singleton (expensive to create)
@ -71,7 +73,7 @@ async def get_client() -> HaikuRAG:
if _client is None: if _client is None:
async with _client_lock: async with _client_lock:
if _client is None: if _client is None:
client = HaikuRAG(db_path=db_path, config=config, create=True) client = HaikuRAG(config=config, create=True)
await client.__aenter__() await client.__aenter__()
_client = client _client = client
return _client return _client
@ -82,7 +84,7 @@ class AppDeps:
state: dict[str, Any] = field(default_factory=dict) 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( agent = Agent(
get_model(config.qa.model, config), get_model(config.qa.model, config),
@ -138,15 +140,15 @@ async def health_check(_: Request) -> JSONResponse:
"status": "healthy", "status": "healthy",
"qa_provider": config.qa.model.provider, "qa_provider": config.qa.model.provider,
"qa_model": config.qa.model.name, "qa_model": config.qa.model.name,
"db_path": str(db_path), "db_path": str(database.location),
"db_exists": db_path.exists(), "db_exists": _database_exists(),
} }
) )
async def list_documents(_: Request) -> JSONResponse: async def list_documents(_: Request) -> JSONResponse:
"""List all documents in the database.""" """List all documents in the database."""
if not db_path.exists(): if not _database_exists():
return JSONResponse({"documents": [], "error": "Database not found"}) return JSONResponse({"documents": [], "error": "Database not found"})
client = await get_client() client = await get_client()
@ -162,11 +164,11 @@ async def list_documents(_: Request) -> JSONResponse:
async def db_info(_: Request) -> JSONResponse: async def db_info(_: Request) -> JSONResponse:
"""Get database info and statistics.""" """Get database info and statistics."""
if not db_path.exists(): if not _database_exists():
return JSONResponse( return JSONResponse(
{ {
"exists": False, "exists": False,
"path": str(db_path), "path": str(database.location),
"documents": 0, "documents": 0,
"chunks": 0, "chunks": 0,
} }
@ -180,7 +182,7 @@ async def db_info(_: Request) -> JSONResponse:
return JSONResponse( return JSONResponse(
{ {
"exists": True, "exists": True,
"path": str(db_path), "path": str(database.location),
"documents": stats["documents"].get("num_rows", 0), "documents": stats["documents"].get("num_rows", 0),
"chunks": stats["chunks"].get("num_rows", 0), "chunks": stats["chunks"].get("num_rows", 0),
"documents_bytes": stats["documents"].get("total_bytes", 0), "documents_bytes": stats["documents"].get("total_bytes", 0),
@ -214,7 +216,7 @@ async def visualize_chunk(request: Request) -> JSONResponse:
if isinstance(parsed, list): if isinstance(parsed, list):
refs = [str(x) for x in parsed] 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) return JSONResponse({"error": "Database not found"}, status_code=404)
client = await get_client() client = await get_client()

View file

@ -11,13 +11,14 @@ services:
ports: ports:
- "127.0.0.1:8001:8000" - "127.0.0.1:8001:8000"
environment: environment:
- DB_PATH=/data - HAIKU_RAG_CONFIG_PATH=/app/haiku.rag.yaml
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
- LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-} - LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-}
volumes: 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 - ./backend:/app/src:ro
- ./haiku.rag.yaml:/app/haiku.rag.yaml:ro - ./haiku.rag.yaml:/app/haiku.rag.yaml:ro
extra_hosts: extra_hosts:

View file

@ -7,13 +7,14 @@ services:
ports: ports:
- "127.0.0.1:8001:8000" - "127.0.0.1:8001:8000"
environment: environment:
- DB_PATH=/data - HAIKU_RAG_CONFIG_PATH=/app/haiku.rag.yaml
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
- LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-} - LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-}
volumes: 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 - ./haiku.rag.yaml:/app/haiku.rag.yaml:ro
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View file

@ -1,6 +1,12 @@
# haiku.rag configuration for the chat app # haiku.rag configuration for the chat app
# Copy to haiku.rag.yaml and customize as needed # 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 configuration
qa: qa:
model: model:

View file

@ -40,8 +40,8 @@ Create a `.env` file in the `app/` directory:
ANTHROPIC_API_KEY=your-anthropic-key ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key OPENAI_API_KEY=your-openai-key
# Database path # Host path of the LanceDB database, mounted at /data
DB_PATH=/path/to/your/haiku.rag.lancedb DB_VOLUME=/path/to/your/haiku.rag.lancedb
# Optional: Ollama base URL (if using local models) # Optional: Ollama base URL (if using local models)
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
@ -50,16 +50,22 @@ OLLAMA_BASE_URL=http://localhost:11434
LOGFIRE_TOKEN=your-logfire-token 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 ```yaml
# app/haiku.rag.yaml # app/haiku.rag.yaml
lancedb:
databases:
haiku.rag: /data
qa: qa:
model: model:
provider: anthropic provider: anthropic
name: claude-sonnet-4-20250514 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 ## API endpoints
| Endpoint | Method | Description | | Endpoint | Method | Description |

View file

@ -145,6 +145,6 @@ Applications serving AG-UI should adapt the agent with Pydantic AI's `AGUIAdapte
## Database Selection ## 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. Passing a client through `rag=` bypasses this selection. The capability uses the databases covered by that client and does not close it.

View file

@ -235,7 +235,7 @@ results = await client.search("query") # every database
results = await client.search("query", sources=["papers"]) # one of them 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. 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.

View file

@ -44,7 +44,7 @@ class CapabilityRunResult:
cited_uris: list[str] = field(default_factory=list) cited_uris: list[str] = field(default_factory=list)
cited_chunk_ids: 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. # 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) cited_sources: list[str] = field(default_factory=list)
searched_uris: list[str] = field(default_factory=list) searched_uris: list[str] = field(default_factory=list)
n_searches: int = 0 n_searches: int = 0

View file

@ -485,8 +485,8 @@ def test_records_the_database_each_citation_came_from():
assert result.cited_sources == ["alpha", "beta", "alpha"] assert result.cited_sources == ["alpha", "beta", "alpha"]
def test_an_unnamed_database_records_no_source(): def test_a_hand_built_citation_without_a_source_records_an_empty_string():
"""One database names nothing: the field holds 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._base import EvidenceState
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord
from haiku.rag.store.models.citation import Citation from haiku.rag.store.models.citation import Citation

View file

@ -26,8 +26,8 @@ uv run python examples/custom_agent.py /path/to/db.lancedb
**Script:** `custom_agent_agui.py` **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 ```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
``` ```

View file

@ -8,13 +8,13 @@ Requirements:
- An Anthropic API key (for the QA model) or adjust the model below - An Anthropic API key (for the QA model) or adjust the model below
Usage: 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 dataclasses import dataclass, field
from pathlib import Path
from typing import Any from typing import Any
from ag_ui.core import EventType, StateSnapshotEvent 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.policy import create_capability as citation_policy
from haiku.rag.capabilities.rag import RAGState, create_capability from haiku.rag.capabilities.rag import RAGState, create_capability
db_path = os.environ.get("DB_PATH") capability = create_capability(defer_loading=False)
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)
@dataclass @dataclass

View file

@ -1,5 +1,4 @@
import asyncio import asyncio
import os
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from difflib import get_close_matches from difflib import get_close_matches
from pathlib import Path 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: def resolve_scope(db_path: Path | str | None, config: AppConfig) -> DatabaseScope:
"""The databases a capability covers, resolved once at its entry point. """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()
return DatabaseScope.resolve(config, database_path=db_path) return DatabaseScope.resolve(config, database_path=db_path)

View file

@ -427,7 +427,8 @@ class ChatApp(App):
def on_document_filter_modal_filter_changed(self, event: Any) -> None: def on_document_filter_modal_filter_changed(self, event: Any) -> None:
"""Scope the conversation to the selection: the filter carries the ids, """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 from haiku.rag.tools.filters import build_document_id_filter
@ -436,10 +437,9 @@ class ChatApp(App):
doc_filter = build_document_id_filter( doc_filter = build_document_id_filter(
sorted({doc_id for _, doc_id in event.selected}) sorted({doc_id for _, doc_id in event.selected})
) )
selected_sources = {source for source, _ in event.selected} selected_sources = sorted({source for source, _ in event.selected if source})
sources: list[str] | None = None covers_multiple = self.client is not None and self.client.covers_multiple
if selected_sources and None not in selected_sources: sources = selected_sources if covers_multiple and selected_sources else None
sources = sorted(s for s in selected_sources if s is not None)
for namespace, state_type in ( for namespace, state_type in (
(RAG_STATE_NAMESPACE, RAGState), (RAG_STATE_NAMESPACE, RAGState),
(ANALYSIS_STATE_NAMESPACE, AnalysisState), (ANALYSIS_STATE_NAMESPACE, AnalysisState),

View file

@ -24,15 +24,18 @@ class DocumentCheckbox(Checkbox):
self.doc_id = doc_id self.doc_id = doc_id
def _labelled(docs) -> list[tuple[str, str | None, str]]: def _labelled(
"""Each document's label, database and id, sorted by label. The database is docs, *, name_database: bool = False
named alongside the title, which a title alone does not say. Labels are ) -> list[tuple[str, str | None, str]]:
escaped: titles and database names are data, not Textual markup.""" """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 = [ rows = [
( (
escape( escape(
f"{doc.title or doc.uri or doc.id}" 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.source,
doc.id, doc.id,
@ -221,7 +224,9 @@ class DocumentFilterModal(ModalScreen):
DocumentCheckbox( DocumentCheckbox(
label, source, doc_id, value=(source, doc_id) in self._selected 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: if boxes:
await filter_list.mount_all(boxes) await filter_list.mount_all(boxes)

View file

@ -70,10 +70,7 @@ class DatabaseScope:
"""The databases an operation covers. """The databases an operation covers.
Resolved once, from configuration plus at most one selector, then passed Resolved once, from configuration plus at most one selector, then passed
down. Never empty. down. Never empty. Nothing here reads the environment.
Nothing here reads the environment: ``HAIKU_RAG_DB`` is the capability entry
point's to honour.
""" """
databases: tuple[DatabaseRef, ...] databases: tuple[DatabaseRef, ...]

View file

@ -146,10 +146,10 @@ class SearchResult(BaseModel):
include the metadata of any other chunks merged with it. Never part of include the metadata of any other chunks merged with it. Never part of
``format_for_agent`` output. ``format_for_agent`` output.
``source`` names the configured database a result came from: the name from ``source`` names the database a result came from: the name from
``lancedb.databases``, never a path or URI, so a location cannot travel in a ``lancedb.databases`` or a path's stem, never a path or URI, so a location
result, a citation or a log. It is None only where no database is named, as cannot travel in a result, a citation or a log. Every result a search
with the single ``lancedb.uri``. produces carries it; None only on a result built by hand.
""" """
content: str content: str

View file

@ -24,9 +24,9 @@ class Citation(BaseModel):
``chunk_ids`` lists the ids of all chunks whose expansion ranges merged ``chunk_ids`` lists the ids of all chunks whose expansion ranges merged
into the cited result (always includes ``chunk_id``). into the cited result (always includes ``chunk_id``).
``source`` names the configured database the cited chunk came from: the name ``source`` names the database the cited chunk came from: the name from
from ``lancedb.databases``, never a path or URI. It is None only where no ``lancedb.databases`` or a path's stem, never a path or URI. None only on a
database is named, as with the single ``lancedb.uri``. citation resolved from a hand-built result.
``doc_item_refs`` are the ``self_ref`` values of every item in the cited ``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 content the exact items the model saw. Visual grounding resolves bounding

View file

@ -14,10 +14,10 @@ class Document(BaseModel):
""" """
Represents a document with an ID, content, and metadata. Represents a document with an ID, content, and metadata.
``source`` names the configured database a document came from: the name ``source`` names the database a document came from: the name from
from ``lancedb.databases``, never a path or URI. It is None where no ``lancedb.databases`` or a path's stem, never a path or URI. Every document
database is named, as with the single ``lancedb.uri``, and is never a database returns carries it; it is never persisted, and None only on a
persisted. document built by hand.
""" """
id: str | None = None id: str | None = None

View file

@ -98,14 +98,10 @@ def _placed(capability) -> "Path | None":
return ref.db_path return ref.db_path
def test_capability_factories_resolve_environment_and_defaults( def test_capability_factories_resolve_defaults(temp_db_path, monkeypatch):
temp_db_path, monkeypatch """The configuration places the database; the environment plays no part."""
):
config = AppConfig() config = AppConfig()
monkeypatch.setenv("HAIKU_RAG_DB", str(temp_db_path)) 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)) == ( assert _placed(create_rag(config=config)) == (
config.storage.data_dir / "haiku.rag.lancedb" config.storage.data_dir / "haiku.rag.lancedb"
) )
@ -170,17 +166,6 @@ class TestACapabilityFollowsTheConfiguredLocation:
with pytest.raises(AmbiguousDatabaseError, match="notes"): with pytest.raises(AmbiguousDatabaseError, match="notes"):
factory(db_path=chosen, config=config) 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 @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):
@ -211,15 +196,15 @@ def test_domain_preamble_is_added_to_capability_instructions(temp_db_path):
def _single_database_client() -> AsyncMock: 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 `covers_multiple`, `source` and `clients_covering` answer as one database
database does; a bare AsyncMock answers every attribute with a truthy Mock. does; a bare AsyncMock answers every attribute with a truthy Mock.
""" """
client = AsyncMock() client = AsyncMock()
client.covers_multiple = False client.covers_multiple = False
client.source_names = () client.source_names = ("test",)
client.source = None client.source = "test"
client.clients_covering.return_value = [client] client.clients_covering.return_value = [client]
return client return client

View file

@ -159,8 +159,8 @@ def _make_mock_client():
# Covers one database; a bare AsyncMock answers `covers_multiple` with a # Covers one database; a bare AsyncMock answers `covers_multiple` with a
# truthy Mock. # truthy Mock.
mock_client.covers_multiple = False mock_client.covers_multiple = False
mock_client.source_names = () mock_client.source_names = ("test",)
mock_client.source = None mock_client.source = "test"
return mock_client return mock_client
@ -462,9 +462,9 @@ async def test_document_filter_updates_rag_state(temp_db_path: Path):
): ):
async with app.run_test(): async with app.run_test():
# The selection is document ids, so a repeated title cannot widen it. # The selection is document ids, so a repeated title cannot widen it.
selected = [ selected: list[tuple[str | None, str]] = [
(None, "6f1c2d4e-0000-4000-8000-000000000001"), ("test", "6f1c2d4e-0000-4000-8000-000000000001"),
(None, "6f1c2d4e-0000-4000-8000-000000000002"), ("test", "6f1c2d4e-0000-4000-8000-000000000002"),
] ]
app.on_document_filter_modal_filter_changed( app.on_document_filter_modal_filter_changed(
DocumentFilterModal.FilterChanged(selected) 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 == expected_filter
assert rag_state.document_filter is not None assert rag_state.document_filter is not None
assert "LIKE" not in rag_state.document_filter 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 assert rag_state.sources is None
# The state snapshot should also reflect the change # 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 @pytest.mark.asyncio
async def test_document_filter_narrows_sources_to_the_selection(temp_db_path: Path): 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 """Over a set, the filter carries ids and `sources` restricts the question
databases the selection names.""" to the databases the selection names."""
from haiku.rag.chat.app import RAG_STATE_NAMESPACE from haiku.rag.chat.app import RAG_STATE_NAMESPACE
from haiku.rag.chat.widgets.document_filter_modal import DocumentFilterModal from haiku.rag.chat.widgets.document_filter_modal import DocumentFilterModal
app, mock_client = _make_app_with_state(temp_db_path) 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 ( with (
patch("haiku.rag.chat.app.HaikuRAG") as _stub_rag, 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(): async with app.run_test():
# First set a filter # First set a filter
app.on_document_filter_modal_filter_changed( 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]) rag_state = RAGState.model_validate(app._state[RAG_STATE_NAMESPACE])
assert rag_state.document_filter is not None 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) box = DocumentCheckbox(label, source, doc_id, value=False)
assert str(box.label) == "Report [/red] (alpha [/x])" 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(): def test_a_citation_title_that_looks_like_markup_is_text():
from rich.text import Text from rich.text import Text
@ -1046,17 +1061,20 @@ class TestKeepingSelectionsReachable:
from haiku.rag.store.models.document import Document from haiku.rag.store.models.document import Document
picked = [ 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) for i in range(DOCUMENT_PAGE + 20)
] ]
by_id = {d.id: d for d in picked} by_id = {d.id: d for d in picked}
matched = [ 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 = AsyncMock()
client.covers_multiple = False client.covers_multiple = False
client.source_names = () client.source_names = ("test",)
client.count_documents.return_value = 5 client.count_documents.return_value = 5
async def listing(limit=None, offset=0, filter=None): async def listing(limit=None, offset=0, filter=None):
@ -1068,7 +1086,7 @@ class TestKeepingSelectionsReachable:
client.list_documents.side_effect = listing client.list_documents.side_effect = listing
modal = DocumentFilterModal( 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) app, _ = _make_app(temp_db_path, client)
with ( with (
@ -1115,14 +1133,16 @@ class TestKeepingSelectionsReachable:
from haiku.rag.store.models.document import Document from haiku.rag.store.models.document import Document
picked = [ 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) for i in range(DOCUMENT_PAGE + 1)
] ]
by_id = {d.id: d for d in picked} by_id = {d.id: d for d in picked}
client = AsyncMock() client = AsyncMock()
client.covers_multiple = False client.covers_multiple = False
client.source_names = () client.source_names = ("test",)
client.count_documents.return_value = 0 client.count_documents.return_value = 0
async def listing(limit=None, offset=0, filter=None): async def listing(limit=None, offset=0, filter=None):
@ -1134,7 +1154,7 @@ class TestKeepingSelectionsReachable:
client.list_documents.side_effect = listing client.list_documents.side_effect = listing
modal = DocumentFilterModal( 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) app, _ = _make_app(temp_db_path, client)
with ( with (
@ -1166,7 +1186,9 @@ class TestKeepingSelectionsReachable:
# The row is gone from the listing, not merely unchecked. # The row is gone from the listing, not merely unchecked.
assert "sel-0200" not in remaining assert "sel-0200" not in remaining
assert len(remaining) == DOCUMENT_PAGE 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. # The page it was on no longer exists, so the modal does not report it.
assert modal._page == 0 assert modal._page == 0
assert "page" not in footer assert "page" not in footer
@ -1186,10 +1208,10 @@ class TestKeepingSelectionsReachable:
client = AsyncMock() client = AsyncMock()
client.covers_multiple = False client.covers_multiple = False
client.source_names = () client.source_names = ("test",)
client.count_documents.return_value = DOCUMENT_PAGE * 2 client.count_documents.return_value = DOCUMENT_PAGE * 2
client.list_documents.return_value = [ client.list_documents.return_value = [
Document(id="d1", content="", title="One") Document(id="d1", content="", title="One", source="test")
] ]
modal = DocumentFilterModal(client=client) modal = DocumentFilterModal(client=client)
@ -1219,7 +1241,7 @@ class TestKeepingSelectionsReachable:
client = AsyncMock() client = AsyncMock()
client.covers_multiple = False client.covers_multiple = False
client.source_names = () client.source_names = ("test",)
client.list_documents.return_value = [] client.list_documents.return_value = []
client.count_documents.return_value = 0 client.count_documents.return_value = 0
@ -1254,10 +1276,10 @@ class TestKeepingSelectionsReachable:
client = AsyncMock() client = AsyncMock()
client.covers_multiple = False client.covers_multiple = False
client.source_names = () client.source_names = ("test",)
client.list_documents.return_value = [ client.list_documents.return_value = [
Document(id="id-one", content="", title="Capital region"), Document(id="id-one", content="", title="Capital region", source="test"),
Document(id="id-two", content="", title="Nobel laureates"), Document(id="id-two", content="", title="Nobel laureates", source="test"),
] ]
client.count_documents.return_value = DOCUMENT_PAGE * 2 client.count_documents.return_value = DOCUMENT_PAGE * 2
@ -1360,10 +1382,10 @@ class TestDocumentSearchFilter:
client = AsyncMock() client = AsyncMock()
client.covers_multiple = False client.covers_multiple = False
client.source_names = () client.source_names = ("test",)
client.list_documents.return_value = [ client.list_documents.return_value = [
Document(id="id-one", content="", title="Capital region"), Document(id="id-one", content="", title="Capital region", source="test"),
Document(id="id-two", content="", title="Nobel laureates"), Document(id="id-two", content="", title="Nobel laureates", source="test"),
] ]
client.count_documents.return_value = 2 client.count_documents.return_value = 2
@ -1379,7 +1401,9 @@ class TestDocumentSearchFilter:
assert len(list(modal.query(DocumentCheckbox))) == 2 assert len(list(modal.query(DocumentCheckbox))) == 2
client.list_documents.return_value = [ 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 client.count_documents.return_value = 1
await modal.on_input_submitted(Input.Submitted(Input(), "Nobel")) await modal.on_input_submitted(Input.Submitted(Input(), "Nobel"))

View file

@ -162,8 +162,9 @@ class TestCollectionIdentityForTheModel:
assert "Collection" not in result.format_for_agent() assert "Collection" not in result.format_for_agent()
def test_an_unnamed_collection_is_never_mentioned(self): def test_a_hand_built_result_without_a_source_is_never_labelled(self):
"""Nothing to name, whatever the caller asked for.""" """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") result = SearchResult(content="body", score=0.9, chunk_id="c1")
assert "Collection" not in result.format_for_agent(include_collection=True) assert "Collection" not in result.format_for_agent(include_collection=True)

View file

@ -299,7 +299,7 @@ class TestCitationSource:
assert citation.chunk_id == "c1" 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( result = SearchResult(
content="body", content="body",
score=0.9, score=0.9,

View file

@ -259,7 +259,8 @@ class TestLookupByIdentifier:
assert chunk is not None and chunk.content == "alpha one" assert chunk is not None and chunk.content == "alpha one"
@pytest.mark.asyncio @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: async with HaikuRAG(temp_db_path, create=True) as rag:
docling = DoclingDocument(name="one") docling = DoclingDocument(name="one")
docling.add_text(label=DocItemLabel.TEXT, text="body") 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_document_by_id(doc.id) is not None
assert await rag.get_chunk_by_id(held.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): with pytest.raises(UnknownDatabaseError):
await rag.get_document_by_id(doc.id, "alpha") await rag.get_document_by_id(doc.id, "alpha")
with pytest.raises(UnknownDatabaseError): with pytest.raises(UnknownDatabaseError):

View file

@ -475,8 +475,8 @@ class TestFailureNaming:
assert caught.value.__cause__ is None assert caught.value.__cause__ is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_an_unnamed_database_keeps_its_error(self, tmp_path): async def test_a_database_given_as_a_path_keeps_its_error(self, tmp_path):
"""Nothing named it, so there is no name to report.""" """The caller gave the path, so the error may name it."""
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
async with HaikuRAG(tmp_path / "nope.lancedb"): async with HaikuRAG(tmp_path / "nope.lancedb"):
pass pass

View file

@ -108,9 +108,8 @@ class TestResolution:
with pytest.raises(UnknownDatabaseError, match="haiku.rag"): with pytest.raises(UnknownDatabaseError, match="haiku.rag"):
DatabaseScope.resolve(config, database_name="haiku.rag") DatabaseScope.resolve(config, database_name="haiku.rag")
def test_the_environment_is_not_consulted(self, monkeypatch, tmp_path): def test_the_environment_is_not_consulted(self, monkeypatch):
"""HAIKU_RAG_DB is honoured by the capability entry point alone; """Resolution reads the configuration alone."""
resolution never reads the environment."""
monkeypatch.setenv("HAIKU_RAG_DB", "/data/from-the-environment.lancedb") monkeypatch.setenv("HAIKU_RAG_DB", "/data/from-the-environment.lancedb")
config = _config(databases={"alpha": "/data/alpha.lancedb"}) config = _config(databases={"alpha": "/data/alpha.lancedb"})

View file

@ -885,7 +885,7 @@ async def test_format_citations_rich_omits_the_database_for_one_database():
) )
client = AsyncMock() client = AsyncMock()
client.covers_multiple = False client.covers_multiple = False
client.source_names = () client.source_names = ("papers",)
output = _render_rich(await format_citations_rich([citation], client)) output = _render_rich(await format_citations_rich([citation], client))