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:
parent
34180a0fd1
commit
a04a16c717
29 changed files with 171 additions and 146 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
12
docs/apps.md
12
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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, ...]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue