Commit graph

1038 commits

Author SHA1 Message Date
Yiorgis Gozadinos
de6da8b375
Pin what reciprocal rank fusion produces
`_fuse` ran in tests without anything asserting its output, so reversing the
sort or dropping the truncation changed nothing. Databases interleave by rank,
the score is the reciprocal of it, ties keep configured order, and the limit
cuts the fused list.

Every native score in one database beats every one in the other, so ranking by
score rather than position fails all four.
2026-08-27 16:01:28 +03:00
Yiorgis Gozadinos
5f795cb0cb
Let the MCP server be told which database once
`create_mcp_server` promised one database and accepted a scope covering a set,
where the write tools exist and fail on use. It refuses that now.

Resolving is the public factory's job, as it is `HaikuRAG`'s: `_covering` takes
a scope someone already resolved, so the configured name survives without a
`DatabaseScope` reaching the public signature.

The test that a scope decides the database asserted `all(...)` over a search
that could return nothing, which held whatever the server read. It reads the
listing instead, so alpha's documents being present and beta's absent both have
to be true.
2026-08-27 15:41:03 +03:00
Yiorgis Gozadinos
acea8cbaac
Prove the lend without reaching the embedder
The test searched through the capability, which embeds the query, so it passed
against a local Ollama and failed CI where there is none. What it exists to show
is that a lent client is what the capability reads through: `_ensure_rag`
returns it, and a full-text search through it names its results.
2026-08-27 15:26:09 +03:00
Yiorgis Gozadinos
c778fbf527
Refuse a path and sources together
`sources` was ignored beside a database path, so `sources=["nope"]` opened the
path and read as though the selection had been honoured. A path and a name
already conflict inside `DatabaseScope.resolve`; this is the same rule where a
caller can reach it.
2026-08-27 15:12:22 +03:00
Yiorgis Gozadinos
db7f0e0af6
Give the MCP server the database, not a description of it
`run_mcp` derived a path and a configuration for the server, and deriving drops
the configured name: MCP results and citations carried `source=None` where the
same database named through any other path carried "alpha".

`create_mcp_server` takes the resolved scope and opens through it, so the name
survives. Passing a path and configuration still works and resolves to the same
place.
2026-08-27 15:12:13 +03:00
Yiorgis Gozadinos
d93cb5c891
Refuse a name the client does not cover, however many it covers
`reader_for` returned itself before looking at the name, so an "alpha" client
answered `reader_for("beta")` with alpha's reader. A citation naming another
database would have been read from the wrong one.

Both paths decide through `clients_covering` now, so one database refuses a
wrong name the way a set already did.

The chat citation test said mounting lends the client without showing it; a
chat test shows it and the docstring points there.
2026-08-27 14:49:40 +03:00
Yiorgis Gozadinos
45a5e68da5
Say what reader_for and an empty sources mean
`reader_for` documented None as covering any database it could not place, but a
name outside the set raises `KeyError` like `clients_for` does: provenance
naming a database this client does not cover is wrong rather than absent. None
means one thing, a federated client given no name.

`sources=[]` means two things. On a search it selects nothing to search; on the
constructor it raises, since a client over no database can do nothing. Both are
written down now.

A capability reads through a lent client, so what a citation records is that
client's database and not the scope the capability was built with. Chat lends
one, and had no test saying so.
2026-08-27 14:41:26 +03:00
Yiorgis Gozadinos
474245ab59
Serve the database the MCP command selected
`run_mcp` passed `_path`, the local stand-in a URI-backed ref resolves to for
display. A path overrides `lancedb.uri`, so `--db-name` on an S3 database
served the local default. It passes the ref's own path now, None where a URI
placed the database, and `create_mcp_server` accepts that.

The client opened around the server is gone. It never served a request, and it
opened the scope rather than the derived path, so startup validated the remote
database while the server read the local one.
2026-08-27 14:41:16 +03:00
Yiorgis Gozadinos
9ed66e3a06
Search one database the ordinary way, however it was selected
`sources=["alpha"]` on a client covering a set went through fusion, which
scores position: a result the database ranked at 0.6549 was reported as
1/(60+rank). Embedding also moved ahead of the repository, so a filter matching
no document embedded the query anyway.

A selection of one now runs the single-database search. Fusion reconciles
rankings from separate indexes, and one ranking has nothing to reconcile.

A reranker that returns chunks it built rather than the ones it was given loses
which database each came from, since ownership is by identity. That is named
now instead of surfacing as a KeyError, and stated on `RerankerBase._rerank`.
2026-08-27 14:41:10 +03:00
Yiorgis Gozadinos
87367759f9
Say what makes a cited chunk id ambiguous
The documented rule was existence in more than one selected database. The rule
is narrower and does not need to be wider: a copy the search never returned
grounded nothing, so the retrieved result is the citation and its database is
recorded rather than guessed.

The page now states the whole rule, including the fallback's own check for an
id no search returned. Two retrieved copies are refused, and so is an id
already cited from another database in the conversation. The case between them
had no test.
2026-08-27 14:09:01 +03:00
Yiorgis Gozadinos
206d29b74a
Let the configuration place the ingester's database
The ingester manufactured `data_dir / haiku.rag.lancedb` whenever `--db` was
absent and passed it to HaikuRAG. A path is now an explicit override that
clears the configured URI, so a `lancedb.uri` deployment wrote to local disk
while the control plane reported on the remote.

`IngesterApp` resolves the databases it works on once, in its constructor, and
both the client it opens and the control plane read that scope. `--db` names
one directly and nothing stands in for it, so a configured set is refused
rather than guessed, and `AmbiguousDatabaseError` joins the errors `cli()`
turns into a clean exit.
2026-08-27 14:00:21 +03:00
Yiorgis Gozadinos
b8bf846bb7
Cite a repeated chunk from its last occurrence
Collision detection replaced the lookup's dict comprehension with
`setdefault`, which also flipped a chunk found by several searches from its
last occurrence to its first. The copies differ in everything the expansion
window decides, figures included, so that silently changed what a citation
renders.

The rules are separate now: a repeated (source, chunk_id) takes the later
result, a chunk_id under two sources is still refused.
2026-08-27 13:25:06 +03:00
Yiorgis Gozadinos
8a7cfb949f
Match capability instructions to the run's collection scope
The collection block was chosen when the capability was built, so a run narrowed
through `state.sources` to one collection was still told how to attribute across
collections it could not reach, while its results correctly carried no
`Collection:` line.

`get_instructions` composes it per run instead, from `state.sources` where the
question narrowed the conversation and from the lent client or the scope
otherwise. Order is preamble, base instructions, collection block.
2026-08-27 12:41:15 +03:00
Yiorgis Gozadinos
0d7810c78a
Render collection identity only for multi-collection searches
`format_for_agent` named the database whenever one was named, so a search over
a single named database carried a line with nothing to distinguish. It now takes
`include_collection` from the caller, which decides from the search selection
rather than from the hits: a search that could have drawn on two collections
names them even when everything came back from one.

`Collection:` at the model boundary, database in configuration and
administration. `source` on results, documents, citations and analysis
dictionaries is unchanged.
2026-08-27 12:41:05 +03:00
Yiorgis Gozadinos
c8a9b5df50
Keep the sandbox's connection open for the owners it hands out
`_documents` took its owner clients inside an ephemeral connection and used
them after that connection closed, then stored them for the file reads that
follow, so every standalone multi-database read went through a closed database.

A sandbox covering a set now retains the client it opened until `close()`. One
database has no owners and keeps its connection no longer than the read that
opened it, so a write from elsewhere is still visible to the next read.
2026-08-27 11:40:43 +03:00
Yiorgis Gozadinos
9210642f65
Report where a named remote database actually is
A URI-backed database is constructed with a local `db_path` nothing connects
to, and the info modal reported that, so `--db-name` on an S3 database named a
local directory. `location` reads the configuration instead, and the modal
labels it as one.

`InfoModal`'s `db_path` argument is gone: both callers passed None, so only a
test reached the branch that used it.
2026-08-27 11:40:35 +03:00
Yiorgis Gozadinos
cfb880e8a0
Keep every database a cancelled fan-out opened
`sessions_for` recorded what opened from `gather`'s results, which arrive only
when it runs to completion. Cancelling it discarded them, so a database that
opened while a sibling was still pending was never recorded and `aclose` never
closed it. `return_exceptions=True` covers a failing child, not a cancelled
parent.

`_open` now registers its own session, so what opened is reachable however the
fan-out ends.
2026-08-27 11:40:24 +03:00
Yiorgis Gozadinos
b2c617f9f2
Name the example databases as the documentation does
`medic` and `st` become `papers` and `notes`.
2026-08-27 11:06:34 +03:00
Yiorgis Gozadinos
dfa5f8027e
Drop a patch that cannot detect what it guards
`database_lines` reports through the connection the client already holds and
never calls `connect_lancedb`, so patching it detects nothing; `asked ==
[connection]` is what proves the reuse.

It was not merely dead. The patch was installed by name, and the next
`monkeypatch.setattr` triggered the first import of `haiku.rag.store.info`,
whose module body binds `connect_lancedb` — capturing the fake into a
namespace teardown does not restore, failing four `test_info` tests under
`-n0`.
2026-08-27 11:06:29 +03:00
Yiorgis Gozadinos
0e0ce8d3f0
Fix document-filter searches after the initial load
Every load looked up the loading indicator, which only the first page has,
so pressing enter in the search box raised `NoMatches`. The indicator is a
child of the list `remove_children()` already clears.

A document's id and search text travel on a `DocumentCheckbox` instead of
being assigned onto a `Checkbox` behind type suppressions. The positional
widget ids are gone; nothing queried them.
2026-08-27 11:06:22 +03:00
Yiorgis Gozadinos
ea6b864f6e
Mark a truncated preview instead of cutting it silently 2026-08-27 10:31:49 +03:00
Yiorgis Gozadinos
1995a7da36
Name the database of a result by what the operation covers
`_rich_print_search_result` asked how many databases were configured, so
`--db-name alpha` labelled every line with the database the caller had just
named. Citations already ask the client what it covers, which is the same
question the scope answers.
2026-08-26 16:55:56 +03:00
Yiorgis Gozadinos
b76c5c7db0
Show the configuration in the shape a config file has
`settings` printed each top-level block as one Python dict repr, so nesting
was invisible, long paths wrapped mid-token and `PosixPath(...)` leaked. It
renders as YAML now, which is what `haiku.rag.yaml` is written in.
2026-08-26 16:55:56 +03:00
Yiorgis Gozadinos
ab0bdff14c
Print only the document fields there are
`list` does not load content, which is where the docling blobs live, so the
`content:` header announced a field the command declined to fetch. A document
without a uri or metadata printed `uri: None` and `meta: {}`, where `title`
was already omitted when absent.
2026-08-26 16:48:27 +03:00
Yiorgis Gozadinos
db5f61d740
Rename the database selector to --db-name
`--database` and `--db` read as one word abbreviated but take different
things, a configured name against a filesystem path, and they sit in
different positions: `haiku-rag --db /path info` fails with `No such option`.
The CLI already says "database" to the user everywhere it prints one, so the
name it selects by should say the same. Unreleased, so no deprecation.

Also: without a reranker, raise `search.limit` with the number of databases
searched, since each contributes its best matches to a list truncated back
to the limit.
2026-08-26 16:48:27 +03:00
Yiorgis Gozadinos
ca2e28559e
Split the multi-database tests by subject
Two files of 1,405 and 814 lines become seven: scope resolution, lifecycle,
search, documents, expansion, citations and capabilities. `_config`, `_seed`
and the rest move to `helpers.py`, importable by the sandbox tests that share
them, and the package points VCR back at `tests/cassettes/multi_db/`.
2026-08-26 13:43:53 +03:00
Yiorgis Gozadinos
4c2bfc4fc1
Drop the indirection around what a client covers
`covers_several_databases` had one line of body and two call sites.
`_db_path_given` guarded a default path manufactured in `__init__` and
overwritten in `__aenter__`; `_requested_db_path` is what the caller asked
for, and the effective path falls out of the resolved scope.
2026-08-26 13:34:09 +03:00
Yiorgis Gozadinos
2b2de5a61f
Reuse the database a borrowed client already holds
`clients_for` hands back a client over a database the covering one owns, so
`async with` on it opened a second session and assigned it, and teardown
declined to close what this client did not open. Entry returns the client as
it stands.
2026-08-26 13:03:47 +03:00
Yiorgis Gozadinos
de8075fcd1
Point the S3 integration tests back at S3
They opened a client on `tmp_path / "unused"` with the bucket in the
configuration. An explicit path now selects the database, so the client
tests ran against the local disk and the two app tests failed. Nothing names
a path any more, and both helpers assert the connection is remote before
yielding, so a later precedence change cannot quietly localize them again.
2026-08-26 12:53:52 +03:00
Yiorgis Gozadinos
71e4e4a40a
Resolve a capability's databases once, into a scope
`resolve_db_path` manufactured the default path whenever `lancedb.databases`
was empty, and `covers_several_databases` read coverage back out of the
configuration, so a capability built without a client opened
`storage.data_dir/haiku.rag.lancedb` instead of what `lancedb.uri` placed.
The entry point resolves a `DatabaseScope` instead: instructions ask it what
it covers and `_ensure_rag` opens it through `HaikuRAG._covering`, so
coverage is decided once rather than encoded in a path and re-derived.
`Sandbox._covering` takes the scope the capability already resolved, beside
the public constructor that takes a path. The factory signatures are
unchanged.
2026-08-26 12:20:21 +03:00
Yiorgis Gozadinos
5b420a9b23
Take a schemeless lancedb.uri as a local path
Closes #582. `lancedb.databases` entries already classify a location by
whether it carries a scheme; `lancedb.uri` was taken as a URI whatever it
said, so a local path was opened as object storage and a mistyped one
became a new empty database instead of failing. Both settings now place a
database the same way, and `--db PATH` overrides either.
2026-08-26 12:20:09 +03:00
Yiorgis Gozadinos
49580228c2
Read the document's own database in the sandbox listing
`list_documents()` reached into the ownership map for a name the document
already carries. The map is still built, so a document id two databases
claim is still refused before anything mounts. A listing over one named
database now reports that name, as in-code `search()` already does.
2026-08-26 10:33:29 +03:00
Yiorgis Gozadinos
9a3e2b86ca
Read a database's stored settings once
`Store` already parses the settings blob on open, so keep it: the
inspector's info modal was opening the settings table, querying it and
parsing the JSON a second time, and `doctor` was asking
`SettingsRepository` for it on a store it already held. Creating a
database refreshes it, so a new one reports the version init wrote.

`gather_database_info` keeps its own parse: it goes around `Store` so a
pre-migration database still reports what it can.
2026-08-25 17:38:32 +03:00
Yiorgis Gozadinos
d9ac221ca0
Refuse a chunk id that names a chunk in two databases
A chunk id is unique within a database and says nothing across them, so a
database copied from another holds the same ids. `qualified_id` keys the
two in-memory identity sites on the database and the id together:
`merge_results` was dropping the second database's result when a query
repeated, and the arrival map that breaks fused score ties was ranking one
of the pair as the other.

Everything serialized records the id alone, so there ambiguity is refused
rather than qualified. `resolve_citations` raises `AmbiguousCitationError`
for a cited id held by two of the databases searched, where it used to
resolve to whichever result came last; `_register_citations` raises for one
already cited from another database in an earlier question. `_cite` turns
both into a `ModelRetry` asking for other evidence. The direct-id fallback
asks every database the question covers instead of taking the first that
answers, so an id no search returned is refused on the same terms.
`all_found` collects them and `first_found` reads its first, which document
reads keep doing on purpose.

Also drop a duplicated 0.77.0 heading from the changelog.
2026-08-25 17:38:25 +03:00
Yiorgis Gozadinos
05c204071d
Expand context through the database a result came from
Expansion branched on whether the client covered a set, so the
single-database half reached for repositories through a facade that may
have none. `expand_sources` groups results by database and hands each
group the session that owns it; `expand_context` and `visualize_chunk`
take that session, so `visualize_chunk` stops narrowing to one database
and discarding the result.

Inline `_fetch`, a pass-through to the chunk repository.
2026-08-25 16:11:31 +03:00
Yiorgis Gozadinos
d367b1eb5a
Resolve the databases a command works on, once
The CLI decides only what it knows — that --db and --database are the same
thing said twice, and whether a command reads more than one — and hands the
resolved scope down. Nothing rewrites the configuration, so a named database
keeps the name results and citations carry, and a remote one opens the URI
it was configured with rather than the local path standing in for it.

HaikuRAGApp, ChatApp and InspectorApp take that scope and nothing else.
Selection reaches the client through a private constructor, so the public
signature still takes a path or names.
2026-08-25 15:48:47 +03:00
Yiorgis Gozadinos
8db447e095
Ask the client what it covers
covers_multiple, source_names, source and reader_for replace the private
state seven modules were reading to work out how many databases they had.
The configured selection is kept intact, so entering a client twice derives
the same database rather than the last derivation.
2026-08-25 14:16:22 +03:00
Yiorgis Gozadinos
16b7319c48
Type writing against one database
Every implementation in documents.py and rebuild.py takes the session it
writes to, so a set cannot reach one: the facade narrows once and passes the
database on, rather than checking and carrying a union. Tests calling an
implementation directly go through `writing()`.
2026-08-25 10:09:23 +03:00
Yiorgis Gozadinos
028217b7c0
Ask the session which database an operation works on
One session field, whichever kind it is, with the covered databases derived
from it rather than kept beside it. `_single_session` returns the database a
write works on in place of ten guards that only asserted one existed, and the
single-database operations move to the session that owns them.
2026-08-25 09:28:20 +03:00
Yiorgis Gozadinos
e8390ca747
Compose a set out of single-database sessions
FederatedSession opens the databases a query covers and owns their
teardown; the client keeps the wrappers it hands out. A wrapper releases
what it built and never closes the database it borrowed.
2026-08-25 08:56:09 +03:00
Yiorgis Gozadinos
79a63a46d4
Give one database its own session
SingleDatabaseSession owns the store, the repositories and the vacuum
machinery, so nothing above has to ask whether it has a store. The client
keeps every name callers already use.
2026-08-24 17:13:45 +03:00
Yiorgis Gozadinos
fcfa4aefd8
Resolve the databases an operation covers, once
DatabaseScope.resolve reads configuration and at most one selector; a
DatabaseRef carries the configured name and a location already resolved to
a URI or a path, so a path a caller names is never reinterpreted. Nothing
consumes it yet.
2026-08-24 16:49:54 +03:00
Yiorgis Gozadinos
9fefcdb629
Refuse to create a database without naming one
`create=True` had nothing to act on across a set and was accepted anyway,
leaving the first query to fail on whichever database was missing.
2026-08-24 15:36:09 +03:00
Yiorgis Gozadinos
bddb32b469
Build the embedder for a set from configuration
An embedder is a function of configuration, not of a database, and the
databases in a selection share one, so a client covering a set builds it
on first use and closes it on teardown. Operations that need one database
say so instead of surfacing a missing store.
2026-08-24 15:28:21 +03:00
Yiorgis Gozadinos
a9e66b001b
Drop the database name from single-database document output
Naming one database on the command line points the configuration at it,
so no command that prints a document ever has a name to print.
2026-08-24 15:28:21 +03:00
Yiorgis Gozadinos
c284d86885
Resolve the search type once per search
An image query has no text to match, so it is vector-only whatever the
caller asked for, and full-text search embeds nothing, so it needs no
agreement on embedders.
2026-08-24 15:28:21 +03:00
Yiorgis Gozadinos
f35be76604
Find the owner of an id through one primitive
`first_found` replaces the client's private version and the capability's
sequential one. The info modal reports through the connection the client
already holds.
2026-08-24 15:27:59 +03:00
Yiorgis Gozadinos
307e250f29
Select documents to filter by id, not by displayed name
A title repeats within a corpus and across databases, so a substring
match on the displayed name widened the filter to documents the user did
not pick. The label names the database.
2026-08-24 15:27:59 +03:00
Yiorgis Gozadinos
16e0add64b
Embed a search query once for the whole selection
Each database owns an embedder, so embedding per database cost a round
trip each. One database still embeds inside the repository, which returns
early for a filter that matches nothing.
2026-08-24 15:27:59 +03:00
Yiorgis Gozadinos
c3182d0fb6
Require one embedder across databases searched together
Searching a set embeds the query once, so a database written with another
model answers from a different vector space.
2026-08-24 15:27:59 +03:00