Finish the comment pass, and escape document fields everywhere Rich renders
`_rich_print_document` escapes uri, title and metadata, the sibling of the escaped search-result renderer. The remaining comments and docstrings that narrated rejected alternatives, consequences or history now state the current invariant. The Sandbox class docstring names the held connection close() releases, and wrapped docs paragraphs join to one line.
This commit is contained in:
parent
09a7076b7e
commit
afdef92b5b
28 changed files with 102 additions and 124 deletions
|
|
@ -149,11 +149,8 @@ RAG and analysis capabilities select databases in this order:
|
||||||
|
|
||||||
1. The `db_path` argument.
|
1. The `db_path` argument.
|
||||||
2. `HAIKU_RAG_DB`.
|
2. `HAIKU_RAG_DB`.
|
||||||
3. [`lancedb.databases`](../configuration/storage.md#multiple-databases), which
|
3. [`lancedb.databases`](../configuration/storage.md#multiple-databases), which selects the full configured set.
|
||||||
selects the full configured set.
|
4. [`lancedb.uri`](../configuration/storage.md#changing-the-default-database-path), which selects one database.
|
||||||
4. [`lancedb.uri`](../configuration/storage.md#changing-the-default-database-path),
|
|
||||||
which selects one database.
|
|
||||||
5. `config.storage.data_dir / "haiku.rag.lancedb"`.
|
5. `config.storage.data_dir / "haiku.rag.lancedb"`.
|
||||||
|
|
||||||
Passing a client through `rag=` bypasses this selection. The capability uses the
|
Passing a client through `rag=` bypasses this selection. The capability uses the databases covered by that client and does not close it.
|
||||||
databases covered by that client and does not close it.
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ lancedb:
|
||||||
uri: "" # Empty for local, or db://, s3://, az://, gs://
|
uri: "" # Empty for local, or db://, s3://, az://, gs://
|
||||||
api_key: ""
|
api_key: ""
|
||||||
region: ""
|
region: ""
|
||||||
databases: {} # Name-to-location map to search multiple at once, instead of uri
|
databases: {} # Name-to-location map to search multiple at once; excludes uri
|
||||||
|
|
||||||
embeddings:
|
embeddings:
|
||||||
model:
|
model:
|
||||||
|
|
|
||||||
|
|
@ -92,10 +92,7 @@ You can override this with the `--db` option.
|
||||||
|
|
||||||
### Evaluating over Multiple Databases
|
### Evaluating over Multiple Databases
|
||||||
|
|
||||||
With [`lancedb.databases`](https://ggozad.github.io/haiku.rag/configuration/storage/#multiple-databases)
|
With [`lancedb.databases`](https://ggozad.github.io/haiku.rag/configuration/storage/#multiple-databases) configured, `evaluations run <dataset> --skip-db` benchmarks the full set. Retrieval, QA, and live conversations preserve the database name on results and citations. A configured set of one follows the same path and retains its name.
|
||||||
configured, `evaluations run <dataset> --skip-db` benchmarks the full set.
|
|
||||||
Retrieval, QA, and live conversations preserve the database name on results and
|
|
||||||
citations. A configured set of one follows the same path and retains its name.
|
|
||||||
|
|
||||||
Population writes one database and therefore requires `--db`:
|
Population writes one database and therefore requires `--db`:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -454,8 +454,7 @@ async def test_gold_prefix_run_answers_with_history(tmp_path):
|
||||||
|
|
||||||
|
|
||||||
def test_records_the_database_each_citation_came_from():
|
def test_records_the_database_each_citation_came_from():
|
||||||
"""A run over several databases has to record which one grounded the answer:
|
"""A run over several databases records which one grounded the answer."""
|
||||||
the distribution cannot be recovered from the report afterwards."""
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -148,8 +148,8 @@ class TestRetrievalSample:
|
||||||
|
|
||||||
|
|
||||||
class TestCoversASet:
|
class TestCoversASet:
|
||||||
"""A run over `lancedb.databases` must pass no path, since a path names one
|
"""A run over `lancedb.databases` passes no path: a path names one
|
||||||
database and wins over the configured set."""
|
database."""
|
||||||
|
|
||||||
def test_a_configured_set_is_covered(self):
|
def test_a_configured_set_is_covered(self):
|
||||||
from haiku.rag.config.models import AppConfig, LanceDBConfig
|
from haiku.rag.config.models import AppConfig, LanceDBConfig
|
||||||
|
|
@ -164,8 +164,8 @@ class TestCoversASet:
|
||||||
assert spec.uses_configured_databases(config) is True
|
assert spec.uses_configured_databases(config) is True
|
||||||
|
|
||||||
def test_a_named_path_overrides_the_set(self):
|
def test_a_named_path_overrides_the_set(self):
|
||||||
"""`--db` is documented as an override, so it names the one database to
|
"""`--db` names the one database to evaluate, whatever the
|
||||||
evaluate even when the configuration names several."""
|
configuration names."""
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
from haiku.rag.config.models import AppConfig, LanceDBConfig
|
from haiku.rag.config.models import AppConfig, LanceDBConfig
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,7 @@ class HaikuRAGApp:
|
||||||
def _connection(self) -> "tuple[AppConfig, Path]":
|
def _connection(self) -> "tuple[AppConfig, Path]":
|
||||||
"""How to open the one database this command works on, directly.
|
"""How to open the one database this command works on, directly.
|
||||||
|
|
||||||
Derived per database: opening one of a set against the set's own
|
Derived per database from its configured location.
|
||||||
configuration would reach the local path that stands in for it.
|
|
||||||
"""
|
"""
|
||||||
from haiku.rag.client.session import default_db_path
|
from haiku.rag.client.session import default_db_path
|
||||||
|
|
||||||
|
|
@ -85,9 +84,8 @@ class HaikuRAGApp:
|
||||||
def _is_local(self) -> bool:
|
def _is_local(self) -> bool:
|
||||||
"""True when the database is a local path, False for a URI.
|
"""True when the database is a local path, False for a URI.
|
||||||
|
|
||||||
Read from the database this command resolved to, not from the
|
Read from the resolved database: one named in `lancedb.databases` can
|
||||||
configuration: a database named in `lancedb.databases` can sit behind a
|
sit behind a URI while the configuration's own `uri` is empty.
|
||||||
URI while the configuration's own `uri` is empty.
|
|
||||||
"""
|
"""
|
||||||
return self._one.db_path is not None
|
return self._one.db_path is not None
|
||||||
|
|
||||||
|
|
@ -853,9 +851,8 @@ class HaikuRAGApp:
|
||||||
def show_settings(self):
|
def show_settings(self):
|
||||||
"""Display current configuration settings.
|
"""Display current configuration settings.
|
||||||
|
|
||||||
As YAML, which is the shape `haiku.rag.yaml` is written in, so what is
|
As YAML, the shape `haiku.rag.yaml` is written in. `mode="json"` keeps
|
||||||
read here is what would be written there. `mode="json"` keeps paths and
|
paths and enums out of their Python reprs.
|
||||||
enums out of their Python reprs.
|
|
||||||
"""
|
"""
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
@ -884,11 +881,15 @@ class HaikuRAGApp:
|
||||||
content = Markdown(doc.content)
|
content = Markdown(doc.content)
|
||||||
parts = [f"[repr.attrib_name]id[/repr.attrib_name]: {doc.id}"]
|
parts = [f"[repr.attrib_name]id[/repr.attrib_name]: {doc.id}"]
|
||||||
if doc.uri:
|
if doc.uri:
|
||||||
parts.append(f"[repr.attrib_name]uri[/repr.attrib_name]: {doc.uri}")
|
parts.append(f"[repr.attrib_name]uri[/repr.attrib_name]: {escape(doc.uri)}")
|
||||||
if doc.title:
|
if doc.title:
|
||||||
parts.append(f"[repr.attrib_name]title[/repr.attrib_name]: {doc.title}")
|
parts.append(
|
||||||
|
f"[repr.attrib_name]title[/repr.attrib_name]: {escape(doc.title)}"
|
||||||
|
)
|
||||||
if doc.metadata:
|
if doc.metadata:
|
||||||
parts.append(f"[repr.attrib_name]meta[/repr.attrib_name]: {doc.metadata}")
|
parts.append(
|
||||||
|
f"[repr.attrib_name]meta[/repr.attrib_name]: {escape(str(doc.metadata))}"
|
||||||
|
)
|
||||||
self.console.print(" ".join(parts))
|
self.console.print(" ".join(parts))
|
||||||
self.console.print(
|
self.console.print(
|
||||||
f"[repr.attrib_name]created at[/repr.attrib_name]: {doc.created_at} [repr.attrib_name]updated at[/repr.attrib_name]: {doc.updated_at}"
|
f"[repr.attrib_name]created at[/repr.attrib_name]: {doc.created_at} [repr.attrib_name]updated at[/repr.attrib_name]: {doc.updated_at}"
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,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. ``DatabaseScope`` is
|
``HAIKU_RAG_DB`` is read here and nowhere else.
|
||||||
environment-agnostic on purpose, so honouring the variable inside it would
|
|
||||||
silently extend it to every other caller.
|
|
||||||
"""
|
"""
|
||||||
if db_path is None and (env_db := os.environ.get("HAIKU_RAG_DB")):
|
if db_path is None and (env_db := os.environ.get("HAIKU_RAG_DB")):
|
||||||
db_path = Path(env_db).expanduser()
|
db_path = Path(env_db).expanduser()
|
||||||
|
|
@ -559,8 +557,8 @@ class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
||||||
synthetic: list[SearchResult] = []
|
synthetic: list[SearchResult] = []
|
||||||
documents: dict[tuple[str | None, str], Any] = {}
|
documents: dict[tuple[str | None, str], Any] = {}
|
||||||
for chunk_id in missing:
|
for chunk_id in missing:
|
||||||
# Every database that has it, not the first: the first
|
# All holders are asked: a collision shows only across
|
||||||
# alone cannot see that another had it too.
|
# every database's answer.
|
||||||
holders = await all_found(
|
holders = await all_found(
|
||||||
lookups, lambda owner: owner.get_chunk_by_id(chunk_id)
|
lookups, lambda owner: owner.get_chunk_by_id(chunk_id)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -300,9 +300,8 @@ class ChatApp(App):
|
||||||
refs = list(citation.picture_refs or [])
|
refs = list(citation.picture_refs or [])
|
||||||
if not refs:
|
if not refs:
|
||||||
continue
|
continue
|
||||||
# Evidence recorded before databases could be named carries no
|
# A sourceless citation across a set has no owner to fetch
|
||||||
# source, and across databases nothing places its pictures. The
|
# pictures from: it renders with its figure markers alone.
|
||||||
# citation still renders, with its figure markers.
|
|
||||||
owner = await self.client.reader_for(citation.source)
|
owner = await self.client.reader_for(citation.source)
|
||||||
if owner is None:
|
if owner is None:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -226,8 +226,8 @@ class DocumentFilterModal(ModalScreen):
|
||||||
if boxes:
|
if boxes:
|
||||||
await filter_list.mount_all(boxes)
|
await filter_list.mount_all(boxes)
|
||||||
else:
|
else:
|
||||||
# Otherwise the list is an empty box, indistinguishable from one
|
# An explicit message tells an empty listing from one still
|
||||||
# still loading.
|
# loading.
|
||||||
empty = (
|
empty = (
|
||||||
"Nothing selected." if self._listing_selected else "No documents match."
|
"Nothing selected." if self._listing_selected else "No documents match."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ def resolve_scope(
|
||||||
The CLI decides only what it alone knows: that `--db` and `--db-name` are
|
The CLI decides only what it alone knows: that `--db` and `--db-name` are
|
||||||
the same thing said twice, and whether this command can read more than one.
|
the same thing said twice, and whether this command can read more than one.
|
||||||
Everything else — an unknown name, a `lancedb.uri`, the default location —
|
Everything else — an unknown name, a `lancedb.uri`, the default location —
|
||||||
is `DatabaseScope.resolve`'s to answer, so there is one table and not two.
|
is `DatabaseScope.resolve`'s to answer.
|
||||||
"""
|
"""
|
||||||
from haiku.rag.client.scope import DatabaseScope
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,7 @@ async def all_found(
|
||||||
"""Every client for which `lookup` finds something, and what each found.
|
"""Every client for which `lookup` finds something, and what each found.
|
||||||
|
|
||||||
An id or a URI says nothing about which database holds it, so every one is
|
An id or a URI says nothing about which database holds it, so every one is
|
||||||
asked at once. Asking in turn would cost a round trip per database for an
|
asked at once.
|
||||||
identifier that is missing or held by the last of them.
|
|
||||||
"""
|
"""
|
||||||
found_by_client = await gather_all(*(lookup(client) for client in clients))
|
found_by_client = await gather_all(*(lookup(client) for client in clients))
|
||||||
return [
|
return [
|
||||||
|
|
@ -98,11 +97,7 @@ def _spell(embedding: tuple[str | None, str | None, int | None]) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _without_repeats(names: list[str]) -> list[str]:
|
def _without_repeats(names: list[str]) -> list[str]:
|
||||||
"""`names` in order, without repeats.
|
"""`names` in order, without repeats: a name selects its database once."""
|
||||||
|
|
||||||
A database named twice would be searched twice and fused as two rank lists,
|
|
||||||
which counts it double.
|
|
||||||
"""
|
|
||||||
return list(dict.fromkeys(names))
|
return list(dict.fromkeys(names))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -207,10 +202,10 @@ class HaikuRAG:
|
||||||
"""The client that can read `source` — itself, where it reads one
|
"""The client that can read `source` — itself, where it reads one
|
||||||
database.
|
database.
|
||||||
|
|
||||||
None only when a client covering a set is given no name, as for evidence
|
None only when a client covering a set is given no name. A name this
|
||||||
recorded before databases could be named. A name this client does not
|
client does not cover raises `UnknownDatabaseError`, decided by
|
||||||
cover raises `UnknownDatabaseError`, decided by `clients_covering`: one
|
`clients_covering`: one database answers a wrong name the same way a
|
||||||
database answers a wrong name the same way a set does.
|
set does.
|
||||||
"""
|
"""
|
||||||
if source is None:
|
if source is None:
|
||||||
return None if self.covers_multiple else self
|
return None if self.covers_multiple else self
|
||||||
|
|
@ -287,10 +282,9 @@ class HaikuRAG:
|
||||||
def reranker(self) -> "RerankerBase | None":
|
def reranker(self) -> "RerankerBase | None":
|
||||||
"""The configured reranker, built once and reused across searches.
|
"""The configured reranker, built once and reused across searches.
|
||||||
|
|
||||||
None when reranking is disabled. Local rerankers load model weights on
|
None when reranking is disabled. A client covering a database for
|
||||||
construction, so one per database in a set would load the same weights
|
another borrows that one's, built on the first query to reach any of
|
||||||
that many times over: a client covering a database for another borrows
|
them.
|
||||||
that one's, built on the first query to reach any of them.
|
|
||||||
"""
|
"""
|
||||||
if self._lender is not None:
|
if self._lender is not None:
|
||||||
return self._lender.reranker
|
return self._lender.reranker
|
||||||
|
|
@ -814,8 +808,7 @@ class HaikuRAG:
|
||||||
for owner in await self.clients_covering()
|
for owner in await self.clients_covering()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Round-robin, so a window shows every database. Concatenating lets
|
# Round-robin: a window shows every database.
|
||||||
# the first one fill the whole page and hide the rest.
|
|
||||||
merged = [
|
merged = [
|
||||||
doc for row in zip_longest(*groups) for doc in row if doc is not None
|
doc for row in zip_longest(*groups) for doc in row if doc is not None
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class DatabaseScope:
|
||||||
down. Never empty.
|
down. Never empty.
|
||||||
|
|
||||||
Nothing here reads the environment: ``HAIKU_RAG_DB`` is the capability entry
|
Nothing here reads the environment: ``HAIKU_RAG_DB`` is the capability entry
|
||||||
point's to honour, and reading it here would extend it to every caller.
|
point's to honour.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
databases: tuple[DatabaseRef, ...]
|
databases: tuple[DatabaseRef, ...]
|
||||||
|
|
@ -122,8 +122,7 @@ class DatabaseScope:
|
||||||
def select(self, names: list[str]) -> "DatabaseScope":
|
def select(self, names: list[str]) -> "DatabaseScope":
|
||||||
"""The databases in this scope named by `names`, in the order given.
|
"""The databases in this scope named by `names`, in the order given.
|
||||||
|
|
||||||
Repeats collapse: a database named twice would be searched twice and
|
Repeats collapse: a name selects its database once.
|
||||||
fused as two rank lists, which counts it double.
|
|
||||||
"""
|
"""
|
||||||
if not names:
|
if not names:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ async def search_sources(
|
||||||
return []
|
return []
|
||||||
selected = await client.clients_for(names)
|
selected = await client.clients_for(names)
|
||||||
if len(selected) == 1:
|
if len(selected) == 1:
|
||||||
# One database is an ordinary search: fusion would replace its hybrid
|
# One database is an ordinary search: it keeps the database's own
|
||||||
# scores with ranks, and embedding up front would defeat the late embed.
|
# hybrid scores and embeds late.
|
||||||
return await selected[0].search(
|
return await selected[0].search(
|
||||||
query, limit, search_type, filter, include_images
|
query, limit, search_type, filter, include_images
|
||||||
)
|
)
|
||||||
|
|
@ -101,8 +101,7 @@ async def search_sources(
|
||||||
client._require_one_embedder(selected)
|
client._require_one_embedder(selected)
|
||||||
|
|
||||||
# One over-fetch decision, one query vector and one reranker for the whole
|
# One over-fetch decision, one query vector and one reranker for the whole
|
||||||
# set: the databases share an embedder, and deciding per database would have
|
# set: the databases share an embedder.
|
||||||
# each consult its own reranker.
|
|
||||||
fetch_limit = _fetch_limit(client, query, limit)
|
fetch_limit = _fetch_limit(client, query, limit)
|
||||||
query_vector = await _embed_query(selected[0], query, resolved)
|
query_vector = await _embed_query(selected[0], query, resolved)
|
||||||
text = query if isinstance(query, str) else ""
|
text = query if isinstance(query, str) else ""
|
||||||
|
|
@ -316,8 +315,8 @@ async def _attach_picture_data(client: "HaikuRAG", chunks: list[Chunk]) -> None:
|
||||||
def _dedup_picture_chunks(results: list[SearchResult]) -> list[SearchResult]:
|
def _dedup_picture_chunks(results: list[SearchResult]) -> list[SearchResult]:
|
||||||
"""Collapse duplicate picture-only chunks to one result per ``self_ref``.
|
"""Collapse duplicate picture-only chunks to one result per ``self_ref``.
|
||||||
|
|
||||||
Keyed by database as well, since a database copied from another holds the same
|
Keyed by database as well: copies of a database hold the same document id,
|
||||||
document id: collapsing across them would drop one of two real results.
|
and each copy's picture is its own result.
|
||||||
|
|
||||||
A single picture can produce two chunks for the same self_ref: one whose
|
A single picture can produce two chunks for the same self_ref: one whose
|
||||||
vector is the text embedding of the picture's description, and one whose
|
vector is the text embedding of the picture's description, and one whose
|
||||||
|
|
|
||||||
|
|
@ -116,8 +116,8 @@ class SingleDatabaseSession:
|
||||||
raise
|
raise
|
||||||
failure = type(error).__name__
|
failure = type(error).__name__
|
||||||
if failure is not None:
|
if failure is not None:
|
||||||
# Raised outside the handler to discard the location-bearing
|
# Raised outside the handler: the exception carries neither a cause
|
||||||
# context, which `from None` would only stop printing.
|
# nor a location-bearing context.
|
||||||
raise SourceUnavailableError(
|
raise SourceUnavailableError(
|
||||||
f"database {self.source!r} could not be opened: {failure}"
|
f"database {self.source!r} could not be opened: {failure}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -161,8 +161,8 @@ class InfoModal(ModalScreen):
|
||||||
|
|
||||||
location = self.client.location
|
location = self.client.location
|
||||||
if location is None:
|
if location is None:
|
||||||
# Report each database independently so one failure does not hide
|
# Each database reports independently, failures included. Names
|
||||||
# the rest. Names only: a location belongs in the configuration.
|
# only: a location belongs in the configuration.
|
||||||
blocks = await asyncio.gather(
|
blocks = await asyncio.gather(
|
||||||
*(self._report(name) for name in sorted(self.client.source_names))
|
*(self._report(name) for name in sorted(self.client.source_names))
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ class Sandbox:
|
||||||
|
|
||||||
The session persists across ``execute()`` calls within the same Sandbox
|
The session persists across ``execute()`` calls within the same Sandbox
|
||||||
instance — variables carry over. Call ``close()`` to return the worker to
|
instance — variables carry over. Call ``close()`` to return the worker to
|
||||||
the pool and shut the pool down.
|
the pool, shut the pool down and release any held connection.
|
||||||
|
|
||||||
sandbox = Sandbox(db_path, config, context)
|
sandbox = Sandbox(db_path, config, context)
|
||||||
result = await sandbox.execute("x = await search('query')")
|
result = await sandbox.execute("x = await search('query')")
|
||||||
|
|
@ -285,9 +285,7 @@ class Sandbox:
|
||||||
groups = await gather_all(
|
groups = await gather_all(
|
||||||
*(owner.list_documents(filter=self._context.filter) for owner in owners)
|
*(owner.list_documents(filter=self._context.filter) for owner in owners)
|
||||||
)
|
)
|
||||||
# Interleaved, not concatenated: code that prints the listing is read
|
# Interleaved: a truncated listing still shows every database.
|
||||||
# through a truncated output, and concatenating shows one database's
|
|
||||||
# documents until the truncation, hiding that there are others.
|
|
||||||
docs = [doc for row in zip_longest(*groups) for doc in row if doc is not None]
|
docs = [doc for row in zip_longest(*groups) for doc in row if doc is not None]
|
||||||
return docs, self._holders(owners, groups)
|
return docs, self._holders(owners, groups)
|
||||||
|
|
||||||
|
|
@ -296,10 +294,7 @@ class Sandbox:
|
||||||
owners: "list[HaikuRAG]", groups: "list[list[Any]]"
|
owners: "list[HaikuRAG]", groups: "list[list[Any]]"
|
||||||
) -> "dict[str, HaikuRAG]":
|
) -> "dict[str, HaikuRAG]":
|
||||||
"""Map each document id to the database holding it, rejecting an id two
|
"""Map each document id to the database holding it, rejecting an id two
|
||||||
databases claim.
|
databases claim. The mount has one `/documents/{id}/` path per id.
|
||||||
|
|
||||||
The mount has one `/documents/{id}/` path per id, so a copied database
|
|
||||||
would put two documents on one path and the last would answer for both.
|
|
||||||
"""
|
"""
|
||||||
holders: dict[str, HaikuRAG] = {}
|
holders: dict[str, HaikuRAG] = {}
|
||||||
held_by: dict[str, str | None] = {}
|
held_by: dict[str, str | None] = {}
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,8 @@ def resolve_citations(
|
||||||
|
|
||||||
A chunk returned by more than one search resolves to its last occurrence.
|
A chunk returned by more than one search resolves to its last occurrence.
|
||||||
Raises ``AmbiguousCitationError`` instead when a cited id names a chunk in
|
Raises ``AmbiguousCitationError`` instead when a cited id names a chunk in
|
||||||
more than one of the databases searched, as after copying a database: a
|
more than one of the databases searched: a citation records the id alone
|
||||||
citation records the id alone, so resolving one would attribute the answer
|
and cannot say which.
|
||||||
to a database it may not have come from.
|
|
||||||
"""
|
"""
|
||||||
by_id: dict[str, SearchResult] = {}
|
by_id: dict[str, SearchResult] = {}
|
||||||
ambiguous: dict[str, set[str | None]] = {}
|
ambiguous: dict[str, set[str | None]] = {}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,8 @@ def _build_document_filter(document_name: str) -> str:
|
||||||
def build_document_id_filter(document_ids: list[str]) -> str | None:
|
def build_document_id_filter(document_ids: list[str]) -> str | None:
|
||||||
"""SQL filter matching exactly these documents, or None for an empty list.
|
"""SQL filter matching exactly these documents, or None for an empty list.
|
||||||
|
|
||||||
Unlike name matching, an id identifies one document: names repeat within a
|
Ids repeat only between copies of a database, where the same id names the
|
||||||
corpus and across databases, so a name filter can widen to documents the
|
same document in each.
|
||||||
caller did not pick. Ids repeat only between copies of a database, where the
|
|
||||||
same id names the same document in each.
|
|
||||||
"""
|
"""
|
||||||
if not document_ids:
|
if not document_ids:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,8 @@ def discovered(
|
||||||
|
|
||||||
|
|
||||||
def test_a_retained_picture_carries_the_source_it_came_from():
|
def test_a_retained_picture_carries_the_source_it_came_from():
|
||||||
"""Compaction re-fetches cited pictures later, so the capsule has to remember
|
"""A retained picture carries its database: compaction re-fetches cited
|
||||||
which database each came from."""
|
pictures through it."""
|
||||||
found = discovered(cited={"c1": [2]}, pictures={"c1": ["#/pictures/0"]})
|
found = discovered(cited={"c1": [2]}, pictures={"c1": ["#/pictures/0"]})
|
||||||
found = replace(
|
found = replace(
|
||||||
found,
|
found,
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,7 @@ def test_run_chat_covers_a_configured_set(tmp_path, monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_chat_capabilities_read_the_named_database(tmp_path, monkeypatch):
|
def test_chat_capabilities_read_the_named_database(tmp_path, monkeypatch):
|
||||||
"""`--db PATH` and `--db-name NAME` have to reach the capabilities too, or
|
"""`--db PATH` and `--db-name NAME` reach the capabilities too."""
|
||||||
they answer from the default database or the whole set."""
|
|
||||||
import haiku.rag.config as config_module
|
import haiku.rag.config as config_module
|
||||||
from haiku.rag.client.scope import DatabaseScope
|
from haiku.rag.client.scope import DatabaseScope
|
||||||
from haiku.rag.config import set_config
|
from haiku.rag.config import set_config
|
||||||
|
|
@ -821,9 +820,8 @@ class TestRenderingUnattributedPictures:
|
||||||
async def test_a_sourceless_picture_citation_does_not_fail_the_answer(
|
async def test_a_sourceless_picture_citation_does_not_fail_the_answer(
|
||||||
self, temp_db_path: Path
|
self, temp_db_path: Path
|
||||||
):
|
):
|
||||||
"""Evidence recorded before databases could be named carries no source,
|
"""A citation without a source has no picture owner across databases.
|
||||||
and across databases nothing places its pictures. The citation still
|
It renders with its figure markers."""
|
||||||
renders, with its figure markers."""
|
|
||||||
from haiku.rag.chat.app import RAG_STATE_NAMESPACE
|
from haiku.rag.chat.app import RAG_STATE_NAMESPACE
|
||||||
from haiku.rag.store.models.citation import Citation
|
from haiku.rag.store.models.citation import Citation
|
||||||
|
|
||||||
|
|
@ -1004,8 +1002,7 @@ class TestKeepingSelectionsReachable:
|
||||||
async def test_every_selection_is_reachable_and_the_page_stays_bounded(
|
async def test_every_selection_is_reachable_and_the_page_stays_bounded(
|
||||||
self, temp_db_path: Path
|
self, temp_db_path: Path
|
||||||
):
|
):
|
||||||
"""Selections accumulate across searches. Appending them to the results
|
"""Selections accumulate across searches and get their own listing,
|
||||||
loses the bound the page exists to keep, so they get their own listing,
|
|
||||||
paged the same way."""
|
paged the same way."""
|
||||||
from textual.widgets import Button, Static
|
from textual.widgets import Button, Static
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,8 @@ def vcr_cassette_dir(request):
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def query_embedding(monkeypatch):
|
def query_embedding(monkeypatch):
|
||||||
"""Vector search with no embedder behind it, recording the queries embedded.
|
"""Vector search with no embedder behind it, recording the queries
|
||||||
|
embedded."""
|
||||||
These tests are about which databases are asked and how often, not about
|
|
||||||
retrieval quality, and CI has no embedding endpoint.
|
|
||||||
"""
|
|
||||||
from haiku.rag.embeddings import EmbedderWrapper
|
from haiku.rag.embeddings import EmbedderWrapper
|
||||||
|
|
||||||
embedded: list[str] = []
|
embedded: list[str] = []
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,7 @@ class TestOpeningDatabases:
|
||||||
open_one = rag._session._open
|
open_one = rag._session._open
|
||||||
|
|
||||||
async def gated(name):
|
async def gated(name):
|
||||||
# Every open has to be in flight before any of them finishes, so
|
# Every open is in flight before any finishes.
|
||||||
# a serial loop cannot get past this and the wait times out.
|
|
||||||
await barrier.wait()
|
await barrier.wait()
|
||||||
await open_one(name)
|
await open_one(name)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@ class TestPlacingADatabase:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_set_cannot_place_evidence_that_names_no_database(self, tmp_path):
|
async def test_a_set_cannot_place_evidence_that_names_no_database(self, tmp_path):
|
||||||
"""Evidence recorded before databases could be named carries no source."""
|
"""A sourceless citation names no database a set could place."""
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
await _seed(config, "alpha", ["alpha one"])
|
await _seed(config, "alpha", ["alpha one"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,9 @@ class TestSerializingTheConnection:
|
||||||
|
|
||||||
|
|
||||||
class TestStandaloneAcrossDatabases:
|
class TestStandaloneAcrossDatabases:
|
||||||
"""Without a lent client the sandbox opens its own. The owners it hands out
|
"""Without a lent client the sandbox opens its own. The owners it hands
|
||||||
are stored for later file reads, so that connection has to outlive the call
|
out are stored for later file reads and outlive the call that produced
|
||||||
that produced them."""
|
them."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_owners_stay_open_for_later_reads(self, tmp_path):
|
async def test_owners_stay_open_for_later_reads(self, tmp_path):
|
||||||
|
|
@ -181,8 +181,7 @@ class TestTheSandboxConstructors:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_covering_resolves_nothing_of_its_own(self, tmp_path, monkeypatch):
|
async def test_covering_resolves_nothing_of_its_own(self, tmp_path, monkeypatch):
|
||||||
"""Handed a scope, it must not reach resolution again: resolving twice
|
"""Handed a scope, it resolves nothing of its own."""
|
||||||
is what let a capability's databases and its sandbox's disagree."""
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
await _seed(config, "alpha", ["alpha document about cats"])
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
scope = DatabaseScope.resolve(config, database_name="alpha")
|
scope = DatabaseScope.resolve(config, database_name="alpha")
|
||||||
|
|
@ -200,9 +199,8 @@ class TestTheSandboxConstructors:
|
||||||
class TestTheSandboxCoversWhatTheCapabilityCovers:
|
class TestTheSandboxCoversWhatTheCapabilityCovers:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_the_capability_hands_over_the_scope_it_resolved(self, tmp_path):
|
async def test_the_capability_hands_over_the_scope_it_resolved(self, tmp_path):
|
||||||
"""The capability resolved its databases once. Letting the sandbox
|
"""The sandbox covers the scope the capability resolved, as handed
|
||||||
resolve them again from the same configuration reaches a different
|
over."""
|
||||||
answer wherever a path or the environment named one of a set."""
|
|
||||||
from haiku.rag.capabilities.analysis import AnalysisState, create_capability
|
from haiku.rag.capabilities.analysis import AnalysisState, create_capability
|
||||||
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
|
@ -348,8 +346,7 @@ class TestCopiedDatabases:
|
||||||
class TestListingOrder:
|
class TestListingOrder:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_the_listing_interleaves_the_databases(self, tmp_path):
|
async def test_the_listing_interleaves_the_databases(self, tmp_path):
|
||||||
"""Code reads the listing through a truncated output, so concatenating
|
"""A truncated listing still shows documents from every database."""
|
||||||
shows one database's documents until the truncation and hides the rest."""
|
|
||||||
config = _config(tmp_path, ["alpha", "beta"])
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
await _seed(config, "alpha", [f"alpha {i}" for i in range(5)])
|
await _seed(config, "alpha", [f"alpha {i}" for i in range(5)])
|
||||||
await _seed(config, "beta", ["beta one"])
|
await _seed(config, "beta", ["beta one"])
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,23 @@ async def test_a_listing_prints_the_fields_it_has(app, client):
|
||||||
assert "content:" in printed
|
assert "content:" in printed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_document_renders_fields_that_look_like_markup(app):
|
||||||
|
"""Uris, titles and metadata render as text, not markup."""
|
||||||
|
doc = _doc(
|
||||||
|
"body",
|
||||||
|
uri="test://doc [/blue]",
|
||||||
|
title="The [/bold] Title",
|
||||||
|
metadata={"k": "[/red]"},
|
||||||
|
)
|
||||||
|
|
||||||
|
app._rich_print_document(doc)
|
||||||
|
|
||||||
|
printed = out(app)
|
||||||
|
assert "test://doc [/blue]" in printed
|
||||||
|
assert "The [/bold] Title" in printed
|
||||||
|
assert "[/red]" in printed
|
||||||
|
|
||||||
|
|
||||||
async def test_list_documents_prints_each_document(app, client):
|
async def test_list_documents_prints_each_document(app, client):
|
||||||
client.list_documents.return_value = [_doc("first"), _doc("second")]
|
client.list_documents.return_value = [_doc("first"), _doc("second")]
|
||||||
|
|
||||||
|
|
@ -443,8 +460,8 @@ def test_show_settings_renders_the_shape_a_config_file_has(tmp_path):
|
||||||
|
|
||||||
printed = app.console.export_text()
|
printed = app.console.export_text()
|
||||||
assert "PosixPath" not in printed
|
assert "PosixPath" not in printed
|
||||||
# A Python dict repr parses as YAML flow style, so parsing alone does not
|
# A dict repr parses as YAML flow style; the brace check tells the shapes
|
||||||
# distinguish a block per field from one flattened repr per field.
|
# apart.
|
||||||
assert "{'" not in printed
|
assert "{'" not in printed
|
||||||
assert "\n alpha: /tmp/a.lancedb" in printed
|
assert "\n alpha: /tmp/a.lancedb" in printed
|
||||||
body = printed.split("haiku.rag configuration", 1)[1]
|
body = printed.split("haiku.rag configuration", 1)[1]
|
||||||
|
|
@ -454,9 +471,8 @@ def test_show_settings_renders_the_shape_a_config_file_has(tmp_path):
|
||||||
|
|
||||||
|
|
||||||
def test_show_settings_survives_a_narrow_console_and_bracketed_values(tmp_path):
|
def test_show_settings_survives_a_narrow_console_and_bracketed_values(tmp_path):
|
||||||
"""Rich reads `[...]` as markup and wraps to the terminal, so a prompt
|
"""Values in `[...]` render verbatim and a long path stays on one
|
||||||
carrying an instruction tag rendered away or raised, and a long path was
|
parseable line, however narrow the console."""
|
||||||
broken across lines into something that no longer parsed."""
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
long_dir = tmp_path / "Application Support" / "haiku.rag" / "collections" / "one"
|
long_dir = tmp_path / "Application Support" / "haiku.rag" / "collections" / "one"
|
||||||
|
|
|
||||||
|
|
@ -125,8 +125,8 @@ class TestOneDatabaseCommands:
|
||||||
assert "--db PATH after it" in str(raised.value)
|
assert "--db PATH after it" in str(raised.value)
|
||||||
|
|
||||||
def test_the_refusal_names_the_databases_and_not_their_locations(self, monkeypatch):
|
def test_the_refusal_names_the_databases_and_not_their_locations(self, monkeypatch):
|
||||||
"""A location in an error message travels into logs and terminals; the
|
"""The refusal carries names only; a location in an error message
|
||||||
names exist so it does not have to."""
|
travels into logs and terminals."""
|
||||||
self._install(
|
self._install(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
alpha="s3://bucket/prefix/a.lancedb",
|
alpha="s3://bucket/prefix/a.lancedb",
|
||||||
|
|
@ -385,7 +385,7 @@ class TestResolvingTheDatabasePath:
|
||||||
|
|
||||||
|
|
||||||
class TestConfiguredLocalUri:
|
class TestConfiguredLocalUri:
|
||||||
"""`lancedb.uri` with a local path, the surface issue #582 reports."""
|
"""`lancedb.uri` with a local path."""
|
||||||
|
|
||||||
def _config_file(self, tmp_path, located: Path) -> Path:
|
def _config_file(self, tmp_path, located: Path) -> Path:
|
||||||
config_file = tmp_path / "haiku.rag.yaml"
|
config_file = tmp_path / "haiku.rag.yaml"
|
||||||
|
|
|
||||||
|
|
@ -230,8 +230,7 @@ async def test_app_info_uses_connect_lancedb_for_remote(tmp_path):
|
||||||
mock_db.list_tables = AsyncMock(return_value=mock_list_result)
|
mock_db.list_tables = AsyncMock(return_value=mock_list_result)
|
||||||
await app.info()
|
await app.info()
|
||||||
|
|
||||||
# The uri decides where it connects; the path argument is not read, so the
|
# The uri decides where it connects; the path argument is not read.
|
||||||
# claim here is the remote branch, not which placeholder path it carried.
|
|
||||||
mock_connect.assert_called_once()
|
mock_connect.assert_called_once()
|
||||||
assert mock_connect.call_args.args[0].lancedb.uri == "s3://bucket/path"
|
assert mock_connect.call_args.args[0].lancedb.uri == "s3://bucket/path"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -797,9 +797,8 @@ async def test_format_citations_rich_names_the_database_when_federating():
|
||||||
|
|
||||||
|
|
||||||
async def test_an_unattributable_picture_renders_its_marker(tmp_path):
|
async def test_an_unattributable_picture_renders_its_marker(tmp_path):
|
||||||
"""Evidence recorded before databases could be named carries no source, so
|
"""A citation without a source has no picture owner across databases. One
|
||||||
across databases nothing says which holds the picture. One unrenderable
|
unrenderable figure must not cost the answer."""
|
||||||
figure must not cost the answer."""
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from haiku.rag.store.models.citation import Citation
|
from haiku.rag.store.models.citation import Citation
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue