diff --git a/CHANGELOG.md b/CHANGELOG.md index bf65962..bec5914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Hand-rolled rather than routed through `sdk.feedback.submit` (CONTRACT §2 rule 7). The SDK *has* the resource, but `submit` takes an undiscriminated union of `{feedback}` and `{ground_truth}`, and `ground_truth` is itself a union of `{answer}` and `{source_ids}`. Both unions dropped every field their branches share, `request_id` included. It happens to work today only because the models are `extra="allow"`, so the endpoint's required field travels as an accident of pydantic config at two levels of nesting — and sending an answer together with source ids means picking one branch and smuggling the other past it. When the spec is fixed, this is a one-file change back to the SDK. +- **Exact multi-title query filtering.** Repeat `hydradb query --title "…"` to resolve one or more complete document titles to source IDs before the normal semantic or keyword query runs. Matching is case-insensitive, punctuation such as commas is preserved, and combining titles with other server-side query filters narrows rather than widens the search. + - **`hydradb graph` — full Cypher over graph collections you own (BYOG).** HydraDB's graph database offering had no CLI surface at all: `query`, `ingest` and the rest address the memory and knowledge corpora, and the property graphs users model and own end to end were reachable only through the raw API. This adds `graph query`, `graph collections`, `graph load`, `graph database create/delete` and `graph collection delete`. Everything existing is untouched — the two stores are separate, and nothing crosses between them. `graph query` takes parameters through `--param k=v` (values parse as JSON when they can, so `--param n=3` is the number 3) or `--params-json`. `--output json` prints the rows verbatim, so `hydradb graph query ... | jq '.[].name'` works without unwrapping an envelope. diff --git a/README.md b/README.md index 5d27b9f..0692afa 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ This downloads the wheel for the latest [GitHub release](https://github.com/usec Install a specific version: ```bash -HYDRADB_CLI_VERSION=0.2.0 curl -fsSL https://cli.hydradb.com/install | bash +HYDRADB_CLI_VERSION=0.2.1 curl -fsSL https://cli.hydradb.com/install | bash ``` Force reinstall: @@ -70,7 +70,7 @@ HYDRADB_CLI_FORCE=1 curl -fsSL https://cli.hydradb.com/install | bash ### From a GitHub release ```bash -pip install https://github.com/usecortex/hydradb-cli/releases/download/v0.2.0/hydradb_cli-0.2.0-py3-none-any.whl +pip install https://github.com/usecortex/hydradb-cli/releases/download/v0.2.1/hydradb_cli-0.2.1-py3-none-any.whl ``` > **Note:** PyPI releases are paused. `pip install hydradb-cli` still resolves the older `0.1.0`, so use @@ -94,7 +94,7 @@ pip install -e ".[dev]" ```bash hydradb --version -# hydradb-cli 0.2.0 +# hydradb-cli 0.2.1 ``` If `hydradb` is not found, make sure your virtual environment is activated or that your Python scripts directory is on your `PATH`. @@ -225,12 +225,14 @@ Retrieve knowledge or memories — the single entry point for search. | `--recency-bias` | Preference for newer content (`0.0`–`1.0`) | | `--graph-context` / `--no-graph-context` | Include knowledge graph relations | | `--context` | Additional context to guide retrieval | +| `--title` | Exact document title to search inside; repeat the flag for multiple titles | ```bash hydradb query "What did the team say about pricing?" hydradb query "contract terms" --kind knowledge --mode thinking --max-results 20 hydradb query "What does the user prefer?" --kind memory hydradb query "pricing AND enterprise" --operator and +hydradb query "Who owns the rollout?" --title "Q3 Roadmap.md" --title "Smith, John" ``` Every query prints a `request_id`. That is the only key `feedback` correlates diff --git a/pyproject.toml b/pyproject.toml index 6ff19c9..f855c8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hydradb-cli" -version = "0.2.0" +version = "0.2.1" authors = [ { name = "HydraDB", email = "founders@hydradb.com" }, ] diff --git a/src/hydradb_cli/__init__.py b/src/hydradb_cli/__init__.py index ed58299..9406264 100644 --- a/src/hydradb_cli/__init__.py +++ b/src/hydradb_cli/__init__.py @@ -1,3 +1,3 @@ """HydraDB CLI — Agent-friendly command line interface for HydraDB.""" -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/src/hydradb_cli/commands/_impl.py b/src/hydradb_cli/commands/_impl.py index dba426d..b1a97b3 100644 --- a/src/hydradb_cli/commands/_impl.py +++ b/src/hydradb_cli/commands/_impl.py @@ -136,6 +136,7 @@ def do_query( recency_bias: float | None = None, graph_context: bool | None = None, additional_context: str | None = None, + titles: list[str] | None = None, acl: list[str] | None = None, tenant_id: str | None = None, sub_tenant_id: str | None = None, @@ -156,6 +157,18 @@ def do_query( if max_results < 1 or max_results > 50: print_error(f"--max-results must be between 1 and 50, got {max_results}.") + clean_titles: list[str] | None = None + if titles: + clean_titles = [] + seen_titles: set[str] = set() + for value in titles: + title = value.strip() + if not title: + print_error("--title cannot be empty or whitespace-only.") + key = title.lower() + if key not in seen_titles: + seen_titles.add(key) + clean_titles.append(title) tid = require_tenant_id(tenant_id) stid = resolve_sub_tenant_id(sub_tenant_id) wrapper = get_wrapper() @@ -173,6 +186,7 @@ def do_query( recency_bias=recency_bias, graph_context=graph_context, additional_context=additional_context, + titles=clean_titles, acl=acl, database=tid, collection=stid, diff --git a/src/hydradb_cli/commands/canonical.py b/src/hydradb_cli/commands/canonical.py index 343d526..909a010 100644 --- a/src/hydradb_cli/commands/canonical.py +++ b/src/hydradb_cli/commands/canonical.py @@ -57,6 +57,11 @@ def query( None, "--graph-context/--no-graph-context", help="Include knowledge graph relations." ), additional_context: str | None = typer.Option(None, "--context", help="Additional context to guide retrieval."), + titles: list[str] | None = typer.Option( + None, + "--title", + help="Exact document title to search inside; repeat for multiple titles.", + ), acl: list[str] | None = typer.Option( None, "--acl", @@ -79,6 +84,7 @@ def query( recency_bias=recency_bias, graph_context=graph_context, additional_context=additional_context, + titles=list(titles) if titles else None, acl=list(acl) if acl else None, tenant_id=tid, sub_tenant_id=stid, diff --git a/src/hydradb_cli/hydra/client.py b/src/hydradb_cli/hydra/client.py index 285d801..e0e346d 100644 --- a/src/hydradb_cli/hydra/client.py +++ b/src/hydradb_cli/hydra/client.py @@ -236,6 +236,7 @@ def query( graph_context: bool | None = None, additional_context: str | None = None, query_by: str | None = None, + titles: list[str] | None = None, acl: list[str] | None = None, database: str | None = None, collection: str | None = None, @@ -244,6 +245,37 @@ def query( Maps to the SDK's top-level ``client.query``; ``kind`` becomes ``type``. """ + database_name = self._w._require_database(database) + collection_name = self._w._resolve_collection(collection) + + # hydradb-sdk 2.1.4 predates the titles field and would reject the + # unknown keyword before sending a request. Use the wrapper's equivalent + # v2 JSON transport only for title-filtered queries until the generated + # SDK exposes it; ordinary queries remain on the SDK path. + if titles: + body = { + key: value + for key, value in { + "type": kind, + "query": query, + "operator": operator, + "max_results": max_results, + "mode": mode, + "alpha": alpha, + "recency_bias": recency_bias, + "graph_context": graph_context, + "additional_context": additional_context, + "query_by": query_by, + "titles": titles, + "acl": acl, + "database": database_name, + "collection": collection_name, + }.items() + if value is not None + } + result = self._w._raw_post("/query", json_body=body) + return result if isinstance(result, dict) else {} + resp = self._invoke( self._w._sdk.query, type=kind, @@ -257,8 +289,8 @@ def query( additional_context=additional_context, query_by=query_by, acl=acl, - database=self._w._require_database(database), - collection=self._w._resolve_collection(collection), + database=database_name, + collection=collection_name, ) data = _unwrap(resp) # Carry the request id into the payload rather than dropping it with diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 178999b..6c8cc6a 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -151,6 +151,37 @@ def test_query_json_shape(self): assert result.exit_code == 0 assert "chunks" in json.loads(result.output) + def test_query_forwards_repeatable_exact_titles(self): + _auth() + w = _wrapper(**{"context.query": {"chunks": []}}) + with _patch_wrapper(w): + result = runner.invoke( + app, + ["query", "ownership", "--title", "Smith, John", "--title", "Q3 Roadmap.md"], + ) + assert result.exit_code == 0 + assert w.context.query.call_args.kwargs["titles"] == ["Smith, John", "Q3 Roadmap.md"] + + def test_query_normalizes_and_deduplicates_titles(self): + _auth() + w = _wrapper(**{"context.query": {"chunks": []}}) + with _patch_wrapper(w): + result = runner.invoke( + app, + ["query", "ownership", "--title", " Q3 Roadmap.md ", "--title", "q3 roadmap.MD"], + ) + assert result.exit_code == 0 + assert w.context.query.call_args.kwargs["titles"] == ["Q3 Roadmap.md"] + + def test_query_rejects_blank_title(self): + _auth() + w = _wrapper() + with _patch_wrapper(w): + result = runner.invoke(app, ["query", "ownership", "--title", " "]) + assert result.exit_code != 0 + assert "--title cannot be empty or whitespace-only" in result.output + w.context.query.assert_not_called() + def test_query_empty_fails(self): _auth() with _patch_wrapper(_wrapper()): diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py index 49b747c..2a32f21 100644 --- a/tests/test_wrapper.py +++ b/tests/test_wrapper.py @@ -90,6 +90,29 @@ def test_query_unwraps_and_scopes(self): assert body["database"] == "db_test" assert body["collection"] == "col_test" + def test_query_with_titles_uses_raw_v2_body(self, monkeypatch): + captured = {} + w = HydraDB(token="x", base_url="http://test.local", database="db_test", collection="col_test") + + def post(url, **kwargs): + captured["url"] = url + captured.update(kwargs) + return httpx.Response( + 200, + json={"success": True, "data": {"chunks": []}, "meta": {}}, + request=httpx.Request("POST", url), + ) + + monkeypatch.setattr(httpx, "post", post) + result = w.context.query(query="q", titles=["Smith, John", "Q3 Roadmap.md"]) + + assert result == {"chunks": []} + assert captured["url"] == "http://test.local/query" + assert captured["json"]["titles"] == ["Smith, John", "Q3 Roadmap.md"] + assert captured["json"]["database"] == "db_test" + assert captured["json"]["collection"] == "col_test" + assert captured["headers"]["API-Version"] == "2" + class TestIngest: def test_ingest_memory_encodes_memories(self):