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
|
- `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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
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
|
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 |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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, ...]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue