Files
xarmian 6fdd8816f3 feat(playbook): match free text to an active playbook via the decision provider (TASK-3120) (#1429)
* feat(server): POST /playbooks/match — typed-decision Choice over active playbooks (TASK-3120)

PLAN-3114 unit 5. A synchronous, read-only endpoint: given free text, asks
the workspace's typed-decision provider which active (non-draft,
non-deprecated) playbook it best matches, with a reserved "none" option so
unrelated text gets an honest answer instead of a forced pick — and, as a
side effect, keeps a workspace with exactly one active playbook askable
(decision.Question.Validate needs >=2 Choice options).

- decision.Runner gains a nil-safe Provider() accessor: this endpoint needs
  a plain synchronous Ask, not the owed-jobs pipeline Evaluate drives.
- No provider configured -> 404 decision_provider_unavailable (callers fall
  back to slug/trigger routing). A provider error or an out-of-set answer
  the provider was never offered -> 502 decision_provider_error, distinct
  from the 404 case so a caller can tell "try again" from "not configured".
  Zero active playbooks -> 200 with a stated reason, no provider call.
  Over the provider's 255-option ceiling (including "none") -> refused
  rather than silently dropping playbooks and answering from an
  incomplete set.
- listPlaybookItems is factored out of handleListPlaybooks so list and
  match share one visibility-scoped item source and can't drift on which
  playbooks a caller can see.
- 30s timeout on the synchronous provider call, tighter than the
  provider's own worst-case retry budget (~330s) since an HTTP caller
  blocks on this one directly, unlike the async decision tick.
- content_state_population_test.go's BUG-3033 sweep gets a new ruling: the
  PlaybookSummary call here feeds the provider prompt only, never the
  response, so there's no reader-facing text to mark.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(cli): pad playbook match "<text>" (TASK-3120)

CLI + client-method half of PLAN-3114 unit 5 — POST /playbooks/match.
Table output prints the matched ref/title (or "none"), confidence, model,
and a probabilities table sorted descending; --format json passes the
response through unmodified. Read-only, side-effect-free.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* docs(skill): route to playbook match as rung 3, offer-only (TASK-3120)

PLAN-3114 unit 5's skill half. Exact invocation-slug match and trigger
match stay rungs 1-2; match is consulted only when the user is plainly
asking for a procedure/workflow and both of those missed — never on
ordinary conversation, since every call spends provider money. Any match
returned is offered, never auto-run, at any confidence: TASK-3137's replay
of this same provider's attention question set found no validated
threshold, so nothing here invents one for this question either. A 404
(no provider configured), a "none" choice, or any other error falls back
silently to today's slug/trigger-only behavior.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* chore(mcp): de-indent padItemToolDescription's continuation lines (TASK-3120)

Whitespace-only — git diff -w on this file is empty. The description's
continuation lines carried 18 leading spaces purely for visual alignment
under each action's "  name — " bullet in the Go source; that padding is
emitted verbatim into the tools/list JSON, where it conveys nothing an
LLM reading the tool description gets any value from.

De-indented by exactly 14 spaces on every line that had >= 18 (all 116 of
them, uniformly, since the file carries only three indentation levels:
0, 2, 18 — no deeper nesting to preserve a relative offset for).
Continuations now land at 4 (bullet's 2 + 2), so a reader can still tell
which action a line belongs to; a 1-space collapse would have inverted
that (continuation LESS indented than its own bullet).

tools/list: 55290 -> 53666 bytes (-1624). initialize: unchanged at 24530
(it carries no tool descriptions). Frees headroom against the 55296
budget for the pad_playbook.match action landing in the next commit —
budget constants themselves are untouched, per standing instruction.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(mcp): pad_playbook.match action, ToolSurfaceVersion 0.39 -> 0.41 (TASK-3120)

PLAN-3114 unit 5's MCP half, landing on top of the whitespace-only
catalog_item.go trim (previous commit) so this stays within the
tools/list budget with room to spare.

- catalog_playbook.go: new `text` param, new `match` action
  (passThrough — the CLI's positional arg is named "text", matching
  this tool's param, so no custom shaping is needed) plus a
  description bullet in padPlaybookToolDescription.
- tool_surface.go: `match` marked read-only (nothing stored, no job
  enqueued).
- dispatch_http_routes.go: `playbook match` routeSpec (POST, flat
  {text} body) so the remote /mcp transport can dispatch it in-process.
- version.go: ToolSurfaceVersion 0.39 -> 0.41. 0.40 is reserved for
  PR #1427 (the 0.32/0.33 precedent — a gap is cheaper than a
  collision). 0.41's own entry: pure ADDITIVE bump, same disposition
  as 0.13/0.28/0.11 — nothing existing moved.
- instructions.md, README.md, CLAUDE.md: version headings + the
  pad_playbook bullet/table-row/changelog-sentence updated per the
  drift tests (TestInstructionsMDCoversEveryCatalogAction,
  TestReadmeTableCoversEveryCatalogAction, the two version-match
  tests). instructions.md's addition is intentionally terse ("`match`
  finds a playbook from text.") — it's embedded in the `initialize`
  handshake payload, which has its own 24576-byte budget and had only
  46 bytes of headroom; a fuller explanation lives in the tools/list
  param/action docs instead, which have budget to spare after the trim.
- catalog_readonly_test.go, dispatch_http_parity_test.go: new
  `pad_playbook.match` entries in three hand-built fixture maps
  (expected cmdPath ×2, a hand-rolled cmdhelp stub, and the shared
  parity-drive fixture input) so the existing bijection/parity/dispatch
  guards actually exercise the new action instead of skipping it.

Bytes measured (go test ./internal/mcp -run Budget -v):
  tools/list:  55290 (baseline) -> 53666 (after the trim commit) ->
               54805 (after this commit) — 491 bytes of headroom left
               against the 55296 budget.
  initialize:  24530 (baseline, unchanged by the trim) -> 24574 (after
               this commit) — 2 bytes of headroom against the 24576
               budget. Flagged in the implementer report: this is now
               as tight as tools/list was before the trim, and nothing
               in this unit addresses it.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(server,decision): don't leak provider errors, classify oversized requests, fix none's wording (TASK-3120)

Lead's shallow-pass findings on cd7a41ed, applied after the MCP commit.

F1 (disclosure): the provider-error path answered with err.Error()
verbatim. typesafe.go's client surfaces the provider's raw 4xx body in
that string (APIError.Error()), which is the INSTANCE's own account
state with typesafe.ai — key validity, quota, billing wording — not
anything about the caller's request. This endpoint requires only
viewer access, so any workspace member could read it. Now logged
server-side in full (slog, workspace + err) and answered with a fixed
message plus a coarse details.reason ("timeout" for a context
deadline, "upstream" otherwise) via a new writeDecisionProviderError
helper. Test constructs a fake error containing a secret-looking
string and asserts it never reaches the response body.

F2 (misclassification): the request budget is 32000 tokens
(~80000 chars). At a couple hundred active playbooks with realistic
summaries, the Choice question's options alone can exceed it before
any state is even added — or leave no room for the input text. Both
were plain fmt.Errorf with no sentinel, so they fell into the generic
"provider failed" 502 path, telling a caller "try again" for a
request that will never fit no matter how many times it's retried.
decision/typesafe.go gains ErrRequestTooLarge (wrapped with %w onto
both pre-send refusals; existing message text unchanged, just
appended-to) alongside the existing ErrMaxTokensExceeded (the
provider's own after-the-fact refusal of a request fitState let
through) — both now map to a distinct 400 match_request_too_large.
New decision-package unit test builds a realistic 254-option Choice
(title + near-cap summary + trigger + slug per option, mirroring what
playbook-match could plausibly send) and confirms fitState refuses it
before the fake HTTP handler is ever called; server-side table test
covers both sentinels through the fake provider.

F3 (nit): the "none" option's description read "...listed above",
but Options is an unordered map — nothing puts them "above" it.
Reworded to "...any of the other options' procedures"; the pinned
exact-prompt test updated to match.

Also (lead's separate MCP-round instruction): decision.go's
maxChoiceOptions is now exported as MaxChoiceOptions, and the
server's over-limit refusal reads that constant instead of a copied
`255` literal, so the two ceilings cannot drift apart.

go test ./internal/decision/... ./internal/mcp/... ./internal/server/...
./cmd/pad/... ./internal/cli/... — all green. golangci-lint run ./...
— 0 issues. Full `go test ./...` run separately; final line in the
report.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* chore(mcp): cut pad_playbook.match's tools/list text to the approved +292 variant (TASK-3120)

The lead's shape-(a) approval was for the measured "+292 documented"
prototype, not the fuller wording ae38353c actually shipped
(+1139 bytes) — the trim commit exists to pay for the approved
action text, not to fund arbitrary new prose. Cut the `text` param to
one sentence (the 404-fallback advice lives once, in the action
bullet, not repeated in the param) and the `match` action bullet down
to four lines at consistent bullet+2 continuation indentation,
matching the rest of padPlaybookToolDescription's style (the previous
wording had drifted to 20-space continuation on its last two lines).

tools/list: 53666 (post-trim baseline) -> 54023 (+357, close to the
~300 target — wording differs from the original prototype's exact
text, so an exact match isn't expected). initialize: unchanged at
24574 (this file's bullet is tools/list-only; instructions.md is a
separate file, untouched here).

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(server): don't overclaim writeDecisionProviderError's timeout reason (TASK-3120)

The doc comment said "timeout" covered a cancelled caller request too,
but context.Canceled is a distinct error from context.DeadlineExceeded
and errors.Is(err, context.DeadlineExceeded) does not match it — that
case actually falls through to "upstream", contradicting what the
comment claimed. Narrowed the comment to deadline-only (the preferred
fix per review): a cancelled caller's own request also never reads
this response body, so the distinction was moot as well as wrong.
Code was already correct; only the comment overclaimed.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(cli,skill): pad playbook match needs -- before dash-led text (TASK-3120)

Codex round 2 finding on f31d5d64. Text starting with "-" (a dash-led
sentence, or literal text like "-ship it") is parsed as a flag by
cobra's default scanner, on both the CLI and stdio MCP (which shells
out to this same command). Pre-existing and class-wide — `pad item
search "-x"` fails the same way — so the general stdio fix across
every command is filed as BUG-3142 rather than done here; BuildCLIArgs
is untouched.

This command's own help now shows the workaround: an Example section
with `pad playbook match -- "-ship it"`. skills/pad/SKILL.md's rung-3
routing entry calls it the same way, with a one-line reason, and notes
the MCP form is unaffected (a structured `text` param, not a CLI
token).

New CLI test asserts BOTH directions: `playbook match "-ship it"`
(no --) fails at cobra's flag parser and never reaches the server;
`playbook match -- "-ship it"` succeeds and the server receives the
text with its leading dash intact. Ran both legs — see the test.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(skill): local stdio MCP is not exempt from the leading-dash flag parse (TASK-3120)

Codex round 4 finding. The rung-3 routing entry claimed the MCP form of
match was "unaffected" by BUG-3142, but that's only true for remote
/mcp, which keeps `text` a structured param. Local stdio MCP shells
out to `pad playbook match`, turning `text` into a CLI positional via
BuildCLIArgs (dispatch.go:435) — the same flag parser, the same bug.
Corrected: remote /mcp is safe as-is; local stdio MCP still hits it and
should prefer the CLI's `--` form when the text starts with `-`.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(cmdhelp): the example validator's flag scan must stop at "--" (TASK-3120)

CI red on #1429 (a2a5a853), Go/Go-PG/Nix all failing the same way:
TestRealPadTree_ExampleDriftValidator flagged the new `pad playbook
match -- "-ship it"` example's own dash-led TEXT ARGUMENT as an
unknown flag `--ship it`. Cobra stops flag parsing at a lone "--" —
that's the whole point of the token — but validateExample's scan
treated "--" as just another flag-shaped token: TrimLeft reduced it
to an empty name, which fell into the "skip, keep scanning" branch
instead of stopping the scan. Everything after "--" kept getting
flag-checked, so the terminated example's own positional text was
misread as a flag.

Fixed by checking `t == "--"` BEFORE the dash-stripping and breaking
the scan there, per review. A lone "-" (the stdin convention, e.g.
`pad item import -`) is a different token — it still reaches the
dash-stripping, still reduces to an empty name, and still just
`continue`s past — so it does not terminate the scan.

Three new unit tests in internal/cmdhelp cover all three legs: (a) a
dash-led positional after "--" produces no finding, (b) an unknown
flag BEFORE "--" is still caught — the break cannot hide drift ahead
of the terminator, and (c) a lone "-" does not stop the scan (an
unknown flag after it is still caught). Manually re-ran (a) with the
break removed to confirm it goes red without the fix, then restored
the real fix — ran, not just described: FAIL "a dash-led positional
after -- should not be reported as an unknown flag" without the break,
PASS with it.

The playbook match example itself is unchanged.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 11:28:57 -04:00

314 lines
13 KiB
Go

// Package decision provides a pluggable typed-decision provider: a small set
// of classification primitives (Choice, Score, Noul) answered in one request
// (retried on transient failure), with calibrated probabilities where the
// primitive has them.
//
// The package is a PROVIDER INTERFACE, not an integration with any one
// vendor. The only backend today is typesafe.ai's Jev model (see
// typesafe.go), selected by configuration.
//
// # Unconfigured means nil
//
// When no provider is configured, [New] returns a nil [Provider] and no
// error. Callers treat nil as "no decisions available" and behave exactly as
// they did before this package existed — there are deliberately no no-op
// implementations and no degraded paths, so that the absence of a provider is
// a visible branch at every call site rather than a silent difference in
// behaviour. See PLAN-3114 and TASK-3116.
package decision
import (
"context"
"errors"
"fmt"
"sort"
"strings"
)
// Kind names one of the three decision primitives.
//
// The wire values are the provider's, and the Go constants deliberately
// match them so a decoded answer needs no translation table.
type Kind string
const (
// KindChoice picks exactly one of a set of named options, and returns a
// probability per option plus a confidence.
KindChoice Kind = "choice"
// KindScore places the state on an ORDERED scale of levels, and returns
// a probability per level plus a confidence. See [Answer.Score] for the
// one genuinely surprising thing about this primitive.
KindScore Kind = "score"
// KindNoul returns the probability that a statement is true. It carries
// NO confidence and NO probabilities — see [Answer.Confidence].
KindNoul Kind = "noul"
)
// Question is one thing to ask about a state. Construct one with [Choice],
// [Score] or [Noul] rather than by literal: the three primitives take
// mutually incompatible criteria (a map, an ordered slice, and a pair of
// descriptions), so a hand-built literal can express a Choice carrying
// Score's levels, which no provider can answer.
type Question struct {
Kind Kind
// Instructions is the question in plain language.
Instructions string
// Options is Choice's criteria: option name -> what that option means.
// Empty for the other kinds.
Options map[string]string
// Levels is Score's criteria: an ORDERED slice, lowest first. The order
// is the scale, so it is part of the value and not a presentation
// detail. Empty for the other kinds.
Levels []string
// TrueDesc and FalseDesc are Noul's optional criteria, describing what
// makes the statement true and false respectively. Both empty is valid:
// the provider then works from Instructions alone.
TrueDesc, FalseDesc string
}
// Choice builds a Choice question. options maps each option name to a
// description of what that option means; at least two are required.
func Choice(instructions string, options map[string]string) Question {
return Question{Kind: KindChoice, Instructions: instructions, Options: options}
}
// Score builds a Score question over an ORDERED scale, lowest level first.
// At least two levels are required.
func Score(instructions string, levels []string) Question {
return Question{Kind: KindScore, Instructions: instructions, Levels: levels}
}
// Noul builds a Noul question — the probability that a statement is true.
// trueDesc and falseDesc are optional; pass "" for both to work from the
// instructions alone.
func Noul(instructions, trueDesc, falseDesc string) Question {
return Question{Kind: KindNoul, Instructions: instructions, TrueDesc: trueDesc, FalseDesc: falseDesc}
}
// MaxChoiceOptions is the provider's documented ceiling on Choice options.
// Exported (TASK-3120) so a caller building a Choice from caller-controlled
// input — the playbook-match endpoint, over active playbooks — can refuse
// an over-limit set with its OWN structured error before ever calling Ask,
// rather than letting Validate's plain error surface as an opaque 502. The
// two refusals read the same constant, so they cannot drift out of sync.
const MaxChoiceOptions = 255
// minLevels is the provider's documented floor on Score levels.
const minLevels = 2
// Validate reports whether the question is answerable as constructed.
//
// It rejects criteria belonging to a DIFFERENT kind as well as missing ones:
// a Choice carrying Levels is a caller who meant to call [Score], and
// silently dropping the levels would send a question the caller did not
// write.
func (q Question) Validate() error {
if strings.TrimSpace(q.Instructions) == "" {
return errors.New("decision: question instructions are empty")
}
switch q.Kind {
case KindChoice:
if len(q.Options) < 2 {
return fmt.Errorf("decision: choice question needs at least 2 options, got %d", len(q.Options))
}
if len(q.Options) > MaxChoiceOptions {
return fmt.Errorf("decision: choice question has %d options, limit is %d", len(q.Options), MaxChoiceOptions)
}
for name, desc := range q.Options {
if strings.TrimSpace(name) == "" {
return errors.New("decision: choice question has an empty option name")
}
if strings.TrimSpace(desc) == "" {
return fmt.Errorf("decision: choice option %q has an empty description", name)
}
}
if len(q.Levels) > 0 || q.TrueDesc != "" || q.FalseDesc != "" {
return errors.New("decision: choice question carries another kind's criteria (levels or true/false)")
}
case KindScore:
if len(q.Levels) < minLevels {
return fmt.Errorf("decision: score question needs at least %d levels, got %d", minLevels, len(q.Levels))
}
for i, l := range q.Levels {
if strings.TrimSpace(l) == "" {
return fmt.Errorf("decision: score level %d is empty", i)
}
}
if len(q.Options) > 0 || q.TrueDesc != "" || q.FalseDesc != "" {
return errors.New("decision: score question carries another kind's criteria (options or true/false)")
}
case KindNoul:
if len(q.Options) > 0 || len(q.Levels) > 0 {
return errors.New("decision: noul question carries another kind's criteria (options or levels)")
}
// An asymmetric pair is almost certainly a mistake — the provider
// reads criteria literally, so describing only one side biases the
// answer toward the side that was described.
if (q.TrueDesc == "") != (q.FalseDesc == "") {
return errors.New("decision: noul question describes only one of true/false; give both or neither")
}
default:
return fmt.Errorf("decision: unknown question kind %q", q.Kind)
}
return nil
}
// Answer is one question's answer. Which members are populated is decided by
// [Answer.Kind]; reading a member belonging to another kind is a caller bug
// that the zero value will not announce, which is why [Answer.Confidence] is
// a pointer.
type Answer struct {
// Kind echoes the primitive that produced this answer.
Kind Kind
// Choice is the selected option name, for [KindChoice] only.
Choice string
// Score is the position on the scale, for [KindScore] only.
//
// IT IS NOT A LEVEL INDEX. The provider returns the
// probability-weighted MEAN over level indices, so it is a float that
// usually falls BETWEEN levels: a measured answer with
// probabilities {"0":0.86,"1":0.14} returned score 0.14, which is
// 0.86*0 + 0.14*1 and not the index of any level. To recover "which
// level did it pick", take the argmax of Probabilities
// ([Answer.TopLevel]); truncating or int-casting Score gives the wrong
// level for any distribution whose mass does not sit on index 0.
// (Measured against jev-1.13.0; the arithmetic is on TASK-3116's trail.)
Score float64
// Noul is the probability that the statement is true, for [KindNoul]
// only. Note that this IS the answer — a Noul carries no separate
// confidence, so the distance from 0.5 is all the certainty there is.
Noul float64
// Legend maps a stringified level index to that level's name, for
// [KindScore] only. The provider echoes the Levels that were sent, so a
// stored answer stays interpretable after the question's levels are
// reworded. Its KEYS are checked against the levels sent; its names are
// echoed text and are not.
Legend map[string]string
// Probabilities is the distribution over outcomes. THE KEY VOCABULARY
// DEPENDS ON KIND: option NAMES for [KindChoice], stringified level
// INDICES ("0", "1", ...) for [KindScore]. Nil for [KindNoul], which
// has no distribution. A consumer that assumes one vocabulary and is
// handed the other reads a missing key as 0.0, so switch on Kind.
Probabilities map[string]float64
// Confidence is how sure the provider is, for [KindChoice] and
// [KindScore].
//
// It is a POINTER because [KindNoul] answers carry no confidence at
// all, and 0.0 is not a neutral stand-in for "absent" — it is the most
// extreme value the scale has. A caller following the provider's own
// recommended banding (act above 0.9, route to a human below 0.5) would
// read every Noul answer as minimum confidence and route all of them to
// a human, which states the opposite of the truth that the primitive
// has no opinion on confidence. A nil pointer forces that check.
Confidence *float64
}
// ConfidenceOr returns the answer's confidence, or def when the primitive
// carries none. Use it where a missing confidence has a defensible default;
// read [Answer.Confidence] directly where it does not.
func (a Answer) ConfidenceOr(def float64) float64 {
if a.Confidence == nil {
return def
}
return *a.Confidence
}
// TopLevel returns the highest-probability level of a Score answer: its index
// and its name from the legend. ok is false for any other kind, or when the
// answer carries no probabilities.
//
// An answer returned by [Provider.Ask] has had every probability key checked
// against the levels sent. An Answer built by hand has not: a key that does
// not parse is treated as a level with a very large index and whatever name
// the legend holds for it (often ""), and still yields ok=true.
//
// This is the correct way to ask "which level did it pick" — see
// [Answer.Score] for why that field is not it.
//
// The result is deterministic for ANY key set, not only for well-formed ones.
// Ties on probability resolve to the lowest level index, and ties on index
// resolve to the lexicographically smaller key — the second tie-break is
// needed because distinct key STRINGS can share an index ("1" and "01" both
// parse to 1) and because every unparseable key shares the same sentinel, so
// ordering on index alone leaves those cases decided by Go's randomised map
// iteration order.
func (a Answer) TopLevel() (index int, name string, ok bool) {
if a.Kind != KindScore || len(a.Probabilities) == 0 {
return 0, "", false
}
keys := make([]string, 0, len(a.Probabilities))
for k := range a.Probabilities {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
li, lj := levelIndex(keys[i]), levelIndex(keys[j])
if li != lj {
return li < lj
}
return keys[i] < keys[j]
})
best := keys[0]
for _, k := range keys[1:] {
if a.Probabilities[k] > a.Probabilities[best] {
best = k
}
}
return levelIndex(best), a.Legend[best], true
}
// Usage reports what one [Provider.Ask] call consumed and whether the
// provider had to shorten the state to fit.
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
// StateTruncated reports that the state was shortened at the provider
// boundary to fit the backend's token budget, so the answers describe
// LESS than the caller supplied.
//
// It lives on Usage rather than in its own return value because
// [Provider.Ask]'s signature is fixed by TASK-3116's contract, and
// Usage is already the "what did this call actually do" struct. A
// caller acting on a truncated answer should say so wherever it
// records the result.
StateTruncated bool `json:"state_truncated,omitempty"`
}
// Provider answers typed questions about a state.
//
// A nil Provider is the configured-off case and is not an error; see the
// package doc.
type Provider interface {
// Name is the backend's configuration name, e.g. "typesafe".
Name() string
// Model is the pinned model identifier this provider will use.
Model() string
// Ask answers every question about the same state in ONE request,
// retried on transient provider failures.
//
// state is marshalled as JSON, so a string, a map, or any
// JSON-marshallable struct is accepted; prefer the narrowest state that
// can answer the questions, because irrelevant state degrades accuracy.
//
// The returned map has one entry per question key. An error means NO
// answers: this is deliberately all-or-nothing, since a partial map
// would make "the provider had no opinion" indistinguishable from "the
// call half failed".
Ask(ctx context.Context, state any, questions map[string]Question) (map[string]Answer, Usage, error)
}