Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

### Added

- **`hydradb feedback` — report whether a query's results were actually useful.** `POST /feedback` had no CLI surface. It correlates on one key, the `request_id` from the query's `meta`, and nothing else about the original query is re-sent, so nothing has to be trusted from the client.

That key was unreachable: the wrapper's `_unwrap` returns `.data` and drops `meta`, so a successful query discarded its own request id before any caller saw it. `query` now carries it into the payload — additive, since `/query`'s data has no `request_id` of its own, so the documented `--output json` shape gains a key and loses none — and prints it with a copy-pasteable `hydradb feedback` line. It prints on an EMPTY result too: a query that found nothing is the case most worth reporting, and the one with no chunk ids to fall back on.

Ground truth (`--ground-truth-answer`, repeatable `--ground-truth-source-id`) is accepted alongside prose because it is machine-checkable. Source ids are trimmed and de-duplicated before sending, since they are scored and the same document listed twice would weight one piece of evidence as two. `--source` defaults to `user` rather than `agent`: a person at a terminal is a user, and the two populations are separated at write time.

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.

- **`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.
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,44 @@ hydradb query "What does the user prefer?" --kind memory
hydradb query "pricing AND enterprise" --operator and
```

Every query prints a `request_id`. That is the only key `feedback` correlates
on, so keep it if you intend to rate the answer:

```bash
hydradb --output json query "contract terms" | jq -r .request_id
```

---

### feedback

Report whether a query's results were actually useful. It correlates on the
`request_id` that query printed — nothing else about the original query is
re-sent.

| Flag | Meaning |
|---|---|
| `--feedback` / `-f` | What was right or wrong about the results |
| `--rating` | Overall verdict: `positive`, `negative`, `neutral` |
| `--ground-truth-answer` | The answer the query *should* have produced |
| `--ground-truth-source-id` | A source id that should have been retrieved (repeatable) |
| `--source` | Who is reporting: `user` (default), or `agent` for scripted runs |

Ground truth is worth far more than prose: it is machine-checkable, so it can
be scored automatically rather than read by a person.

```bash
hydradb feedback 8f1c0e8a-... --rating positive
hydradb feedback 8f1c0e8a-... -f "returned the 2023 policy, not the current one" --rating negative
hydradb feedback 8f1c0e8a-... \
--ground-truth-answer "Net 30, per the 2026 MSA" \
--ground-truth-source-id src_abc --ground-truth-source-id src_def
```

A submission needs at least one of `--feedback`, `--ground-truth-answer` or
`--ground-truth-source-id`; an empty one is refused locally rather than after a
round trip.

---

### ingest
Expand Down
95 changes: 94 additions & 1 deletion src/hydradb_cli/commands/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
VALID_MODES = {"fast", "thinking"}
VALID_OPERATORS = {"or", "and", "phrase"}
VALID_KINDS = {"knowledge", "memory"}
VALID_RATINGS = {"positive", "negative", "neutral"}
# Who is reporting. Validated locally for the same reason --rating is: the
# server rejects anything else, but only after a round trip, and a typo like
# "agnet" is worth catching before it costs one.
VALID_SOURCES = {"user", "agent"}
VALID_FETCH_MODES = {"content", "url", "both"}

_STATUS_LABELS = {
Expand Down Expand Up @@ -69,10 +74,23 @@ def _execute(spinner_msg: str, call: Callable[[], Any]) -> Any:
# ── query ────────────────────────────────────────────────────────────────────


def _feedback_hint(r: dict) -> str:
"""The line that makes ``hydradb feedback`` reachable.

Printed verbatim so it can be copied, and printed on an EMPTY result too:
a query that found nothing is exactly the case most worth reporting, and
it is the one where there are no chunk ids to fall back on.
"""
request_id = r.get("request_id")
if not request_id:
return ""
return f'\n[dim]request_id: {request_id} · rate it: hydradb feedback {request_id} --feedback "..."[/dim]'


def _format_query_result(r: dict):
chunks = r.get("chunks") or []
if not chunks:
return "[dim]No relevant results found.[/dim]"
return "[dim]No relevant results found.[/dim]" + _feedback_hint(r)

panels: list[Any] = []
for i, chunk in enumerate(chunks, 1):
Expand All @@ -99,6 +117,10 @@ def _format_query_result(r: dict):
if query_paths:
panels.append(Text(f" Graph: {len(query_paths)} entity path(s) found.", style="dim"))

hint = _feedback_hint(r)
if hint:
panels.append(Text.from_markup(hint.lstrip("\n")))

header = Text(f" Found {len(chunks)} result(s)", style="bold")
return Group(header, *panels)

Expand Down Expand Up @@ -159,6 +181,77 @@ def do_query(
print_result(result, _format_query_result)


# ── feedback ─────────────────────────────────────────────────────────────────


def _format_feedback_result(r: dict):
# `recorded: false` means accepted but not durably stored. Reporting that
# as success would tell someone their evaluation run was captured when it
# was not.
if r.get("recorded") is False:
return Panel(
"[yellow]![/yellow] Feedback accepted but NOT durably stored.\n"
"[dim]The server answered without recording it; treat this run as uncaptured.[/dim]",
border_style="yellow",
padding=(0, 1),
)

lines = [f"[green]✓[/green] Feedback recorded for request {r.get('request_id', '(unknown)')}"]
if r.get("feedback_id"):
lines.append(f"[cyan]Feedback ID:[/cyan] {r['feedback_id']}")
if r.get("created_at"):
lines.append(f"[dim]{r['created_at']}[/dim]")
return Panel("\n".join(lines), border_style="green", padding=(0, 1))


def do_feedback(
request_id: str,
*,
feedback: str | None = None,
rating: str | None = None,
ground_truth_answer: str | None = None,
ground_truth_source_ids: list[str] | None = None,
source: str | None = None,
tenant_id: str | None = None,
sub_tenant_id: str | None = None,
) -> None:
if rating and rating not in VALID_RATINGS:
print_error(f"--rating must be one of: {', '.join(sorted(VALID_RATINGS))}. Got '{rating}'.")
if source and source not in VALID_SOURCES:
print_error(f"--source must be one of: {', '.join(sorted(VALID_SOURCES))}. Got '{source}'.")
if not request_id.strip():
print_error("REQUEST_ID cannot be empty. Run 'hydradb query' and use the request id it prints.")
# The wrapper guards this too, for anyone importing it as a library. But it
# reports a refusal as HydraDBClientError(0, ...), and status 0 is this
# codebase's marker for a TRANSPORT failure (errors.py uses it only for
# connect/timeout), which `handle_api_error` renders as "Connection error:".
# Caught here instead, the way --rating and --kind are, so a local refusal
# reads as one rather than blaming the network.
if not any((value or "").strip() for value in [feedback, ground_truth_answer, *(ground_truth_source_ids or [])]):
print_error(
"feedback needs something to record: pass --feedback, --ground-truth-answer, or --ground-truth-source-id."
)

tid = require_tenant_id(tenant_id)
stid = resolve_sub_tenant_id(sub_tenant_id)
wrapper = get_wrapper()

result = _execute(
"Recording feedback...",
lambda: wrapper.feedback.submit(
request_id=request_id,
feedback=feedback,
rating=rating,
ground_truth_answer=ground_truth_answer,
ground_truth_source_ids=ground_truth_source_ids,
source=source,
database=tid,
collection=stid,
),
)
print_result(result, _format_feedback_result)


# ── ingest ───────────────────────────────────────────────────────────────────


Expand Down
36 changes: 36 additions & 0 deletions src/hydradb_cli/commands/canonical.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,42 @@ def query(
)


def feedback(
request_id: str = typer.Argument(metavar="REQUEST_ID", help="The request id printed by 'hydradb query'."),
feedback_text: str | None = typer.Option(
None, "--feedback", "-f", help="What was right or wrong about the results."
),
rating: str | None = typer.Option(None, "--rating", help="Overall verdict: 'positive', 'negative', or 'neutral'."),
ground_truth_answer: str | None = typer.Option(
None, "--ground-truth-answer", help="The answer the query SHOULD have produced."
),
ground_truth_source_id: list[str] | None = typer.Option(
None,
"--ground-truth-source-id",
help="A source id that should have been retrieved, repeatable. Scored as a retrieval judgement.",
),
source: str | None = typer.Option(
None, "--source", help="Who is reporting: 'user' (default) or 'agent' for scripted runs."
),
Comment thread
gunmay-hydradb marked this conversation as resolved.
database: str | None = typer.Option(None, "--database", "-d", help="Database. Uses default if not specified."),
collection: str | None = typer.Option(None, "--collection", help="Collection."),
tenant_id: str | None = typer.Option(None, "--tenant-id", hidden=True),
sub_tenant_id: str | None = typer.Option(None, "--sub-tenant-id", hidden=True),
) -> None:
"""Report whether a query's results were actually useful."""
tid, stid = resolve_scope_flags(database, collection, tenant_id, sub_tenant_id)
_impl.do_feedback(
request_id,
feedback=feedback_text,
rating=rating,
ground_truth_answer=ground_truth_answer,
ground_truth_source_ids=list(ground_truth_source_id) if ground_truth_source_id else None,
source=source,
tenant_id=tid,
sub_tenant_id=stid,
)


def ingest(
files: list[str] | None = typer.Argument(None, help="Knowledge file path(s) to ingest."),
kind: str | None = typer.Option(None, "--kind", help="Kind to ingest: 'memory' (default) or 'knowledge'."),
Expand Down
Loading
Loading