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
`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

View file

@ -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

View file

@ -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 |

View file

@ -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()

View file

@ -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:

View file

@ -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"

View file

@ -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:

View file

@ -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 |

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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
```

View file

@ -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

View file

@ -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)

View file

@ -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),

View file

@ -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)

View file

@ -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, ...]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"))

View file

@ -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)

View file

@ -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,

View file

@ -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):

View file

@ -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

View file

@ -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"})

View file

@ -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))