Skip to main content

GuruCloud KB SDK

Python SDK for the GuruCloud Knowledge Bank API — a multi-dimensional, semantic vector store. Every Knowledge Bank has a configurable dimension schema: each dimension is its own embedding space, and search is a weighted combination across the dimensions you choose.

pip install gurucloud-kb

Authenticate

from gurucloud_kb import GuruCloudClient

client = GuruCloudClient(api_key="kb_your_api_key")   # keys start with "kb_"

API keys carry scopes: read (search, list), write (add/update entries), admin (create/delete KBs, change the schema).


Mental model (read this first)

A Knowledge Bank is not a single vector index. It is a set of named dimensions, each of which is embedded separately:

Concept What it is
Dimension A named field that gets its own embedding(s). The default KB has content, useful_for, relevant_systems, relevant_tasks.
single dimension One vector per entry (e.g. content).
multi dimension A list of values, each embedded; matches ANY value (e.g. relevant_systems).
text_only dimension Stored (in entry metadata) but NOT embedded; non-semantic exact-match filtering of IDs / tags / enums. Searching it applies an exact-match filter rather than ranking.
Weighted search You query several dimensions at once; each has a weight; scores combine into one ranked result.
metadata_filters Exact (non-semantic) JSONB filtering layered on top of the semantic ranking.

You can use the 4 default dimensions, or define your own semantic dimensions when you create a KB.


Create a KB with custom semantic dimensions

Pass a dimension_schema to create_kb. Each dimension becomes its own embedding space and is automatically searchable.

kb = client.create_kb(
    name="support-kb",
    description="Resolved support tickets",
    dimension_schema={
        "version": 1,
        "combination_mode": "weighted_sum",
        "dimensions": [
            # one vector per entry, required
            {"name": "symptom", "display_name": "Symptom",
             "description": "What the user reported",
             "dimension_type": "single", "required": True, "default_weight": 1.5},

            {"name": "resolution", "display_name": "Resolution",
             "description": "How it was fixed",
             "dimension_type": "single", "required": True},

            # a list field — each value embedded, matches ANY
            {"name": "products", "display_name": "Products",
             "description": "Affected products",
             "dimension_type": "multi", "max_items": 8},

            # stored but NOT embedded — exact-match filter only (e.g. region code)
            {"name": "region", "display_name": "Region",
             "description": "Data-center region (exact match)",
             "dimension_type": "text_only"},
        ],
    },
)
print(kb.id, kb.name)

If you omit dimension_schema, you get the default 4-dimension schema (content, useful_for, relevant_systems, relevant_tasks).

Embedding model. Every dimension in a KB shares one model (text-embedding-3-small, 1536-dim). The model is not currently selectable through the SDK.

Update the name / description (and the agent-facing instructions)

A KB's description is its canonical "what is this / when should I use it" text. It is not just metadata: it's the text agents receive at the MCP handshake as initialize.instructions, and it's the description field returned by get_mcp_server_definition(). Update it in place — there is no separate setter for those agent-facing surfaces, and they refresh from this one field:

kb.update(description="Resolved support tickets. Query before triage; cite ticket IDs.")
kb.update(name="Support KB", description="...")     # both at once (write scope)
client.update_kb(kb.id, description="...")          # equivalent, by id

After an update, the next agent that connects sees the new text as initialize.instructions, and get_mcp_server_definition()["description"] returns it too. Pass description="" to clear it (the server-def description then falls back to the KB name).

REST API. PATCH /api/v1/kb/banks/{id} with {"description": "..."}.

Accumulate-only KBs (never overwrite or delete)

By default, deduplication may merge a near-duplicate into an existing entry (update) or merge-and-replace conflicting entries (conflict). For an observation / "connect-the-dots" KB — where every signal should be kept and a later rollup should coexist with the entries it summarizes — set allow_updates=False:

kb = client.create_kb("signals", allow_updates=False)   # at creation
kb.set_allow_updates(False)                              # or toggle later (write scope)

With allow_updates=False the dedup LLM's update/conflict verdicts are downgraded to new, so existing entries are never overwritten or deleted — the KB only accumulates. Exact duplicates are still skipped (redundant). The flag lives on the KB's schema (round-trips through get_schema()/update_schema()); the default is True, which preserves the historical merge behavior.

REST API. The same control is available without the SDK: POST /api/v1/kb/banks accepts "allow_updates": false, and PATCH /api/v1/kb/banks/{id} with {"allow_updates": false} toggles it.

Choose which fields the MCP tools return (response_fields)

By default a KB's MCP tools (query_knowledge_bank, narrate) return only id + content per result, to keep agent context lean. Opt a KB into returning extra keys on every result:

kb = client.create_kb("curated", response_fields=["useful_for", "source"])  # at creation
kb.set_response_fields(["useful_for", "relevant_systems", "source"])         # or set later (write scope)
kb.set_response_fields(None)                                                 # reset to id + content only

response_fields is additive: id + content are always present and the listed keys are added when an entry carries them. Allowed values are the standard result fields (useful_for, relevant_systems, relevant_tasks, relevant_file_paths, source, metadata, combined_score, created_at, updated_at) plus the KB's own dimension names; unknown keys are rejected. The list lives on the KB's schema and only affects the MCP response shape — the REST API and stored data always carry every field.

REST API. POST /api/v1/kb/banks accepts "mcp_response_fields": [...], and PATCH /api/v1/kb/banks/{id} with {"mcp_response_fields": [...]} (or null to reset) toggles it.

Evolve the schema later (admin scope)

kb.get_schema()                       # current KBDimensionSchema
kb.validate_schema(new_schema)        # returns warnings, applies nothing
kb.update_schema(new_schema)          # replace the whole schema
kb.add_dimension({"name": "severity_notes", "display_name": "Severity",
                  "description": "Severity context", "dimension_type": "single"})
kb.remove_dimension("severity_notes")

Add entries

Provide a value for each dimension. single dimensions take a string; multi dimensions take a list of strings.

kb.add_entry({
    "dimensions": {
        "symptom": "Login loops back to the sign-in page after SSO",
        "resolution": "Cleared the stale SAML session cookie on the gateway",
        "products": ["web app", "mobile app"],
        "region": "us-east-1",   # text_only — stored in metadata, exact-match only
    },
    "metadata": {"status": "resolved", "severity": "high"},
})

# Batch ingest (deduplicates by default)
kb.ingest([{ "dimensions": {...} }, { "dimensions": {...} }])

add_entry / ingest are synchronous — they return the stored entry (or raise on failure), so the call itself tells you the write landed. This differs from the agent-facing MCP report_learning tool, which queues the write (it becomes searchable shortly after). An optional check_learning_status tool to confirm a queued write landed is configurable per deployment (off by default).

List entries by what they carry

list_entries pages newest-first and takes the same non-semantic filters search has, applied server-side before pagination, so a filter-only browse never depends on which page you loaded:

kb.list_entries(metadata_filters={"created_by": "alice"})      # exact match
kb.list_entries(metadata_filters={"tags": ["billing"]})        # list CONTAINS
kb.list_entries(source="mcp_tools", created_after="2026-10-01", limit=200)

Which keys and values exist is not a guess: metadata_facets() reports every metadata key the bank's entries actually carry, with entry counts, distinct counts and the most common values, plus the source column facet.

facets = kb.metadata_facets(max_values=25)
for f in facets["facets"]:
    print(f["key"], f["entry_count"], f["distinct_count"], f["values"][:3])
# created_by 812 4 [{'value': 'alice', 'type': 'string', 'count': 500, 'in_list': False}, …]
facets["source"]["values"]            # [{'value': 'mcp_tools', 'count': 3916, …}, …]

Each value carries its JSON type ("string", "number", "boolean") so you rebuild the typed filter ({"is_gotcha": True}, not "true"), and in_list when the value was an element of a list ({"tags": ["billing"]}). Keys whose values are objects are listed without values; they cannot be filtered on. The same source= filter is accepted by search_expanded and, as the "source" key, by a dict search request.

Update an entry in place

update_entry changes an existing entry and re-embeds what changed, so there is no need to delete and re-add. Pass the typed keyword arguments:

kb.update_entry(
    entry_id,
    content="Login loops back to the sign-in page after SSO (Okta tenants only)",
    metadata={"status": "dismissed"},        # merged: other keys are kept
    systems=["gateway", "okta"],             # full replacement of relevant_systems
)
kb.update_entry(entry_id, add_tasks=["sso-triage"], remove_tasks=["onboarding"])

content= lands in the schema's primary dimension (content on the default schema, e.g. observation on a custom one). metadata= is a shallow merge. systems= / tasks= replace the whole tag list; add_* / remove_* edit it. A raw updates mapping keyed by the server's field names (update_content, update_useful_for, update_metadata, replace_systems, replace_tasks, add_systems, remove_systems, add_tasks, remove_tasks) is still accepted. Any other key raises ValueError before the request is sent, and the server rejects it as well (400 unknown_field); until 0.5.6 such keys were silently ignored while the call still returned success.


Simple — one string against content

results = kb.search("how does authentication work?")

Map each dimension to a query. The required per-dimension key is query_text, and weight scales that dimension's contribution. Scores are combined per combination_mode.

results = kb.search({
    "dimensions": {
        "symptom":  {"query_text": "login loops after SSO", "weight": 2.0},
        "products": {"query_text": "mobile app",            "weight": 0.5},
    },
    "combination_mode": "weighted_sum",
    "metadata_filters": {"status": "resolved"},   # exact, non-semantic
    "k": 10,
    "threshold": 0.35,
})

for r in results:
    print(r["combined_score"], r["dimensions"] if "dimensions" in r else r)

Per-dimension you can also override aggregation, top_k, and min_threshold:

"products": {"query_text": "mobile", "weight": 1.0,
             "aggregation": "max", "min_threshold": 0.2}

Exact-match filtering with text_only dimensions

A text_only dimension is matched exactly, not ranked. Pass it alongside at least one semantic (single/multi) dimension; its query_text folds into an exact-match filter (equivalent to a metadata_filters entry) and never contributes to the score:

results = kb.search({
    "dimensions": {
        "symptom": {"query_text": "login loops after SSO"},
        "region":  {"query_text": "us-east-1"},   # text_only → exact match
    },
})

A search containing only text_only dimensions is rejected (there is nothing to rank) — add a semantic (single/multi) dimension. The same is true of metadata_filters (below): it is an exact post-filter layered on the ranking, so every search still needs at least one semantic dimension. A text_only dimension defined with searchable=False is stored but cannot be used as a search filter.

Exact filtering with metadata_filters

metadata_filters is an exact (non-semantic) JSONB-containment filter applied on top of the semantic ranking — only entries whose metadata contains all the given key/values survive. Pair it with at least one semantic dimension:

results = kb.search({
    "dimensions": {"observation": {"query_text": "late delivery"}},
    "metadata_filters": {"order_id": "SO-1234", "type": "quality_issue"},
})

It narrows the ranked results; it does not rank on its own. To gather every entry for a key regardless of relevance, widen k and pass a broad semantic query alongside the filter.

Filter by time

Restrict results to a time window with a hard filter on entry timestamps (UTC) — it removes out-of-window entries without affecting the ranking. Bounds accept an ISO-8601 string or a datetime. For a string query they're keyword args:

from datetime import datetime, timedelta, timezone

# Only knowledge created in the last 30 days
recent = kb.search(
    "deployment pipeline",
    created_after=datetime.now(timezone.utc) - timedelta(days=30),
)

For a dict query, set the same keys inline:

results = kb.search({
    "dimensions": {"content": {"query_text": "deployment pipeline"}},
    "created_after": "2026-05-01T00:00:00Z",
    "created_before": "2026-06-01",          # bare date == 00:00:00Z
    "k": 10,
})

Available bounds: created_after, created_before, updated_after, updated_before. Each result includes created_at / updated_at so you can verify the window and sort by recency client-side.

Search request reference

Field Type Notes
dimensions {name: {query_text, weight, aggregation?, top_k?, min_threshold?}} At least one searchable dimension required. A text_only dimension here folds into an exact-match filter (and needs a single/multi dimension alongside it).
combination_mode weighted_sum | weighted_product | max | min | custom How dimension scores combine.
custom_formula str Required when combination_mode="custom"; SQL over <dim>_score.
metadata_filters dict Exact JSONB containment, e.g. {"status": "resolved"}.
category_filters [{tag, max_results, min_score}] Bucket results by metadata tag.
created_after / created_before str | datetime Hard filter on entry creation time (UTC, ISO-8601).
updated_after / updated_before str | datetime Hard filter on entry last-modified time (UTC, ISO-8601).
k, threshold int, float Result count / minimum combined score.

aggregation (for multi dimensions): max, avg, min, top_k_avg, sum, count.

The SDK also accepts the older spellings query (per dimension) and filters (top level) and rewrites them to query_text / metadata_filters for you — but prefer the canonical names above.

Expanded search — let a model fill the bank's dimensions

kb.search_expanded(query) has a small model read the bank's dimension descriptions and write the search text each dimension implies for the query, plus short descriptors. The platform then searches the raw query + descriptors in the primary content dimension (weight 3.0), every filled dimension at its schema weight, and the lexical arm on the raw words (so titles and identifiers stay findable). On a 47-query product-search truth set this took nDCG@10 from 0.60 (raw) to 0.81.

out = kb.search_expanded("td", k=10, metadata_filters={"published": True})

out["results"]                    # ranked entries, same shape as kb.search()
out["expansion"]["status"]        # "expanded" | "cached" | "empty" | "disabled" | "unconfigured" | "timeout" | "error"
out["expansion"]["dimensions"]    # {"genre": "tower defense", "mechanics": "place towers, ..."}
out["expansion"]["content_query"] # "td. tower defense, place towers along a path"
out["expansion"]["credential_source"]  # "bank" | "owner" | "platform" | "env" — who paid for the model call
out["search_request"]             # the effective multi-dimensional request that ran

It is never worse than kb.search(): when the model step cannot run (no credential, the time limit exceeded, provider error) the raw search runs and expansion.status says why.

speed picks a preset. Quality was equal within noise on the platform's 53-query truth set; they differ in time:

speed Model Reasoning Limit Typical
"fast" (default) platform fast model (gpt-5.4-nano) none 2.5 s ~0.8 s
"thorough" platform cheap model (gpt-6-luna) low 6 s ~3 s

Other options: content_weight (3.0), lexical (True), model, reasoning_effort and timeout_seconds (override the preset), context (extra guidance about the corpus or the searcher), exclude_dimensions, use_cache, expand=False (raw search through the same endpoint, for A/B), plus the time-window bounds of search(). The dimension descriptions are what the model reads — write them in the vocabulary you want searched.

The model call is billed to your provider account when you have stored a credential (below), otherwise to the platform.

Client credentials — pay for model calls with your own key

client.credentials.set("openai", api_key="sk-...", default_model="gpt-5.4-mini")

# Azure OpenAI: base_url is required; default_model is the deployment name
client.credentials.set(
    "azure_openai", api_key="...",
    base_url="https://my-resource.openai.azure.com/openai/v1",
    default_model="gpt-5.5",
    kb="Games",            # optional: only for this bank (overrides the owner-wide one)
)

client.credentials.list()          # never returns the key: fingerprint + last four chars
client.credentials.delete("openai")
client.credentials.status()        # {"configured": True, ...} — the platform can store keys

Keys are encrypted at rest on the platform and sent once over TLS. Storing a credential needs admin scope on the hosted API; on a self-hosted platform the operator must set KB_CREDENTIALS_ENCRYPTION_KEY (otherwise set() raises APIError 503 credential_store_unconfigured). Resolution order when a search runs: the bank-scoped credential, your owner-wide credential, the platform's own key.


Connect entries (knowledge graph)

Entries can be connected with typed, directed relations. A connection is first-class data in the bank: it has foreign keys to both entries, a source (who wrote it), a note and evidence, and every write or delete is audited. Search can fold connected results, the Explorer shows connections on each entry, and deleting an entry bridges the chains through it.

# detail -> hub, newer -> older
kb.connect(detail_id, hub_id, "part_of", note="the port and env recipe")
kb.connect(new_id, old_id, "supersedes")

# what an entry is connected to, both directions
conns = kb.connections(hub_id)
for e in conns["incoming"]:
    print(e["edge_type"], e["other"]["id"][:8], e["other"]["useful_for"])

# a neighbourhood for a graph view (1-3 hops)
g = kb.graph(hub_id, depth=2)

# remove one (audited)
kb.disconnect(edge_id, reason="wrong direction")

Relations: cites, see_also, part_of, supersedes, split_from, derived_from, corrects, clarifies, conflicts_with, precedes, requires, produces, owned_by. Write one only when you are sure of it; similarity alone is never a connection (the Explorer's Similar tab proposes cross-references, and an approved one becomes a see_also pair).

Declare the relations a bank allows

Every bank speaks the built-in relations above. A bank can declare its own on top of them, or replace them outright. Writes, retypes, the edge filter on list_connections and the connections parameter the bank's MCP tools advertise all follow the bank's vocabulary; a relation the bank does not hold is refused with unknown_edge_type (HTTP 400), naming the allowed ones.

kb.set_edge_relations([
    {"name": "applies_at", "description": "from applies at the playbook step to"},
    {"name": "step_of", "description": "from is a step of the procedure to",
     "transitive": True,         # bridging a deleted entry keeps the type
     "representative": "to"},    # the procedure stands for the group when search collapses
])
kb.connect(learning_id, step_entry_id, "applies_at", note="GAS-002")

vocab = kb.edge_relations()      # built-ins first (origin "builtin"), then declared
print([r["name"] for r in vocab["relations"]])

# a closed vocabulary: only these, plus the two the service itself writes
kb.set_edge_relations(
    [{"name": "applies_at"}, {"name": "see_also"}, {"name": "cites"}],
    replace_defaults=True,
)

Names are slugs (lowercase letters, digits and underscores, 2 to 32 characters). Declaring a built-in name re-describes it rather than adding a second one. With replace_defaults=True the list must include see_also and cites, because bridging, approved similar pairs and the citation backfill write them. Dropping a relation that stored connections still carry is refused with edge_types_in_use (HTTP 409); retype or delete those first. The declaration lives on the dimension schema (edge_relations, edge_relations_replace_defaults), so client.create_kb(...) and kb.update(...) accept it too. Requires write scope.

Backfill the connections a bank already implies

Entries that cite another entry by id in their text (kb:3f7a0db1, entry 3f7a0db1, a full UUID) and the typed metadata curation leaves behind (split_from, replaces, recovered_from_entry_id ...) are connections nobody wrote down as such. backfill_connections finds them:

report = kb.backfill_connections()               # dry run: plan and count
print(report["by_type"], report["unresolved"], report["ambiguous"])
report = kb.backfill_connections(dry_run=False)  # write them (re-running is safe)

search(..., collapse=True) fetches a wider pool, folds entries connected to each other into one representative each and refills the freed slots with further topics, so the same k covers more distinct topics. Each representative carries related pointers to what was folded into it:

for r in kb.search("run the service locally", k=8, collapse=True):
    print(r["id"][:8], r["content"][:60])
    for p in r.get("related", []):
        print("   ", p["relation"], p["direction"], p["id"][:8], p["text"], p.get("note"))

The top two hits are never folded into anything, supersedes / corrects make the newer entry the representative and part_of / split_from make the hub or parent the representative. Each result also lists its direct connections that the search did not return as pointers of kind "neighbor" (at most max_neighbors, note-carrying edges first); every pointer carries the edge's note, the writer's sentence on how that entry pertains, so a reader can decide whether to hop. A dict tunes it: collapse={"pool": 30, "guard_top_n": 2, "max_related": 6, "max_neighbors": 3, "pointer_text": "summary"}. pointer_text is what a pointer says about the entry it points at: summary (default; the entry's metadata.summary, one sentence on what it says, written by the service when an entry is added or edited, useful_for until then), useful_for, snippet or none.

Cluster entries

Group a KB's entries by any field. Each field is clustered independently and returned keyed by field, so one call can group "by topic" and "by customer" at once. The engine is chosen per field (method="auto"):

  • Single embedding dimensions (content, observation, …) → vector clustering (KMeans / Agglomerative / HDBSCAN over the stored vectors). When algorithm="auto" and no k is given, HDBSCAN runs first; if it degenerates (fewer than 3 clusters or >40% noise — common on single-domain KBs) the engine automatically re-runs KMeans with a heuristic k and says so in the field's note.
  • Multi-valued dimensions (tags, products, accounts, …) → entries are grouped by their values; an entry carrying two tags appears in both groups. Near-duplicate values merge per similarity_threshold.
  • Anything else (metadata.customer, source, a text_only dimension) → fuzzy string grouping, so near-duplicate values ("Acme Inc" ≈ "Acme, Inc.") merge. Exception: values that look like codes or IDs (WHITE12, SKU-4471) are grouped exactly and labeled by their dominant value — fuzzy-matching identifiers would merge distinct codes. Pass an explicit similarity_threshold to override.

Omit fields to cluster the KB's primary embedding dimension (its first required single dimension) — the right default for custom-schema KBs, whose content column has no embedding.

result = kb.cluster(
    fields=["observation", "themes", "metadata.customer_no"],
    method="auto",              # vector / value-grouping / fuzzy per field
    algorithm="auto",           # vector: HDBSCAN, KMeans fallback on collapse
    similarity_threshold=0.85,  # fuzzy cutoff (1.0 = exact grouping)
    label=True,                 # opt-in names; one batched LLM call per field
    label_sample_size=5,        # representatives per cluster fed to the namer
)

for field_result in result["results"]:
    print(field_result["field"], field_result["method"], field_result["cluster_count"])
    if field_result.get("note"):
        print("  note:", field_result["note"])   # engine advisories land here
    for group in field_result["clusters"]:
        print("  ", group.get("label") or group.get("key"), "→", group["size"])

Labeling: the 30 largest clusters of a field are named in a single batched LLM call; smaller clusters fall back to their dominant value (fuzzy) or keyword terms (vector). The tokens spent are reported per field in field_result["label_usage"] (model, input_tokens, output_tokens) so labeling cost is always visible. ID-like fields skip the LLM entirely.

Cap how large any one cluster may grow with max_cluster_size (absolute entry count) and/or max_cluster_fraction (share of the clustered scope, 0..1]) — when both are given the stricter cap wins. Oversized vector clusters are recursively split server-side (KMeans within the cluster) until every cluster fits; oversized fuzzy groups split into exact-value groups (a single value repeated past the cap cannot be split — the field's note says so); groups of a multi-valued dimension are never split, since their size is the value's usage count. Fields in one call are clustered concurrently server-side, so one three-field request beats three single-field requests on wall-clock.

result = kb.cluster(
    fields=["observation"],
    algorithm="kmeans", k=8,
    max_cluster_size=50,        # no cluster larger than 50 entries…
    max_cluster_fraction=0.2,   # …or 20% of the scope, whichever is stricter
)

Outliers (HDBSCAN noise) don't have to stay a catch-all. outlier_strategy controls what happens to them on vector fields: "keep" (default) leaves them uncategorized; "reassign" absorbs each noise entry into its nearest cluster when it lies within that cluster's own spread; "subcluster" re-clusters the noise into new clusters flagged from_noise so nothing is left uncategorized. Every vector cluster also reports mean_member_distance and a low_cohesion flag marking clusters whose spread is an outlier vs their peers — likely catch-alls worth a follow-up subcluster pass.

result = kb.cluster(fields=["observation"], outlier_strategy="subcluster")
for group in result["results"][0]["clusters"]:
    if group.get("from_noise") or group.get("low_cohesion"):
        print("refined:", group["size"], group.get("keywords"))

By default a cluster's returned members are its most central entries. Pass member_sample="diverse" to get the nearest-centroid anchor plus greedy farthest-point picks instead, so fringe sub-themes reach the sample — useful when members feed a namer or summarizer that should see the whole cluster, not just its dense core (vector fields only):

result = kb.cluster(
    fields=["observation"],
    max_members_per_cluster=12,
    member_sample="diverse",
)

Cluster only the results of a search by passing the same shape as kb.search:

result = kb.cluster(
    fields=["observation"],
    search={"dimensions": {"observation": {"query_text": "billing error"}}, "k": 500},
)

Cluster request reference

Field Type Notes
fields [str] Fields to cluster. Omit → the KB's primary embedding dimension. metadata.<key> reads a metadata value; a bare name resolves to a dimension or metadata key.
method auto | vector | fuzzy auto picks by field type (single embedding dim → vector, multi dim → value grouping, else fuzzy). vector on a non-embedding or multi field is a 400.
algorithm auto | kmeans | agglomerative | hdbscan Vector only. kmeans/agglomerative require k. auto without k = HDBSCAN with automatic KMeans fallback on degenerate results; explicit hdbscan never falls back.
k int Cluster count for kmeans/agglomerative. Omit for auto/hdbscan.
min_cluster_size int HDBSCAN minimum cluster size (default 5). Low values fragment single-domain KBs into many tiny clusters.
metric cosine | euclidean Vector distance (default cosine).
similarity_threshold float Fuzzy cutoff 0..1 (default 0.85; 1.0 = exact). ID-like values force exact grouping unless you pass this explicitly.
search SearchRequest Optional — cluster only matching entries.
scope_limit int Max entries clustered when no search (default 2000).
include_members / max_members_per_cluster bool / int Per-cluster member output.
label bool Generate a short label per cluster (off by default — free & deterministic). When on, the 30 largest clusters of a field are named in ONE batched LLM call; the tail gets dominant-value/keyword labels.
label_sample_size int When label=True, representatives per cluster fed to the namer — nearest-centroid for vector, most-distinct values for fuzzy (default 5).

Each result is a FieldClusterResult with clusters: [ClusterGroup], plus: note (engine advisories: algorithm fallback, exact ID grouping, un-embedded field warnings), label_usage (LLM token accounting when labeling ran), silhouette_score (vector), and each fuzzy group's key (dominant value) and distinct values.


Similar pairs — find duplicates and conflicts (beta)

similar_pairs() returns the entries that sit closest to each other in one embedding dimension, best first, with both entries' text: every entry in scope probes its nearest neighbours in the index, so a whole bank is checked in one call (16 s for 7,800 entries; the newest 2,000 in about 3 s).

out = kb.similar_pairs(threshold=0.9, limit=50)            # newest 2,000 entries, 3 neighbours each
for p in out["pairs"]:
    print(round(p["similarity"], 3), p["a"]["id"], "|", p["b"]["id"])
out["stats"]   # {"entries_scanned": 2000, "pairs_above_threshold": 12, "truncated": False, "duration_ms": 3300, ...}

kb.similar_pairs(max_entries=20000)                           # the whole bank
kb.similar_pairs(scope={"metadata_filters": {"kind": "rule"}}) # only rules probe; neighbours may be any entry

judge_pairs() asks a model what each pair IS: duplicate (the same facts twice), conflicting (they disagree on what is true or what to do), related (same topic, both needed) or distinct, with a confidence, a one-line reason and a suggested action (merge, keep_both, review_conflict, delete_one). One model call per request, at most 25 pairs, paid by your stored credential when you have one; every call is audited on the platform. Model failures come back in status, never raised.

verdicts = kb.judge_pairs([(p["a"]["id"], p["b"]["id"]) for p in out["pairs"][:20]], speed="thorough")
for j in verdicts["judgements"]:
    print(j["verdict"], round(j["confidence"], 2), j["suggested_action"], "-", j["reason"])

With propose=True (the default) every judgement also carries a concrete proposal the judge wrote under the bank's rules (one durable fact per entry, corrections-only lossless merges, companions stay separate with a pointer, pure subsets go, conflicts are reviewed): kind is merge, keep_separate, trim_overlap, cross_reference, delete_one or review_conflict, with a rationale and, per entry it would change, the minimal edits the judge wrote ({"find": "<exact passage>", "replace": "<new text>"}; an empty find appends a paragraph) plus the resulting content the platform computed from them (edits_apply_cleanly is False when an edit no longer matches the entry). Pairs are judged concurrently, one model call each; judge_models on the scan result names the default judge model per speed. Once a person approves a proposal, apply_pair_proposal() executes it through the ordinary update (re-embed) and delete paths, skips protected entries, snapshots each entry first and audits the application:

j = verdicts["judgements"][0]
if j["proposal"]["kind"] in ("merge", "delete_one"):
    out = kb.apply_pair_proposal(verdicts["judgement_id"], j["a_id"], j["b_id"], j["proposal"], reason="duplicate confirmed")
    out["status"], [c["status"] for c in out["changes"]]     # "applied", ["applied", "applied"]

The Explorer's Similar tab shows the same pairs side by side, judges them on demand and shows each proposal (current vs proposed text) with Approve & apply and Dismiss.

Retrieval assertions (search-quality regression tests)

Pin queries that MUST retrieve a given entry, then re-check them any time — your KB's retrieval quality becomes testable instead of vibes:

# "This query should find this entry" — baseline rank/score captured now.
kb.add_assertion(entry_id=entry["id"], query="how do we handle refunds?",
                 notes="core support flow")

kb.list_assertions()                  # all assertions + their baselines
report = kb.run_retrieval_eval()      # re-run every assertion against live search
# → per-assertion current rank/score vs baseline, pass/fail, regressions

kb.get_assertion(assertion_id)        # one assertion's detail
kb.delete_assertion(assertion_id)     # retire it

query accepts a plain string (searches the primary dimension) or the same per-dimension shape as kb.search. Run the eval after schema changes, bulk ingests, or dedup sweeps to catch retrieval regressions. Requires write scope to create/delete; read to list and evaluate.


Playbooks (procedures returned whole)

Entries are atomic facts found by semantic top-k. A playbook is the other contract: a distinct, named, ordered procedure that is matched as a whole on its title + when_to_use and then returned complete — every step, in order — so an agent never runs a runbook with a step missing. A bank keeps one playbook per task.

# Discover: rank the bank's playbooks by fit to the task at hand
hits = kb.list_playbooks("file kanban cards during the hourly PM sweep")
slug = hits["playbooks"][0]["slug"]

# Fetch the whole procedure (steps in order; cited entries inlined)
playbook = kb.get_playbook(slug)
for step in playbook["steps"]:
    print(step["position"], step["title"], step["body"])

# Write / replace (versioned; every write is snapshotted)
kb.upsert_playbook(
    "pm-hourly-sweep-filing",
    title="PM hourly sweep: filing cards under workstreams",
    when_to_use="Running the hourly PM sweep on a board and deciding which "
                "workstream and milestone each unfiled card belongs to",
    steps=[
        {"title": "List unfiled cards", "body": "kanban_search_tasks(unfiled=true)"},
        {"title": "Assign each card", "body": "kanban_assign_task_to_phase …",
         "kb_entry_id": "<entry holding the filing rule>"},   # optional citation
    ],
    change_note="initial version",
)

kb.list_playbook_versions("pm-hourly-sweep-filing")   # newest first, full snapshots
kb.get_playbook_stats()                                # {"active": n, "draft": n, "superseded": n}
kb.delete_playbook("pm-hourly-sweep-filing")           # snapshots are retained

Branching. A step may carry a stable key, a kind (action, decision or end) and next, a list of transitions {"to": <key>, "when": <condition>, "limit": <n>}. A step without next flows to the next one. The bank checks the graph on write (every target resolves, a decision has two or more distinct conditions, every step is reachable, a loop back needs a limit) and get_playbook returns it under graph.

kb.upsert_playbook(
    "well-review",
    title="Daily well review",
    when_to_use="Working a flagged well: which checks apply and how to disposition it",
    steps=[
        {"key": "W-001", "title": "Identify the issue", "body": "..."},
        {"key": "W-002", "kind": "decision", "title": "Well test or meter?", "body": "...",
         "next": [{"to": "W-WT", "when": "measured by well test"},
                  {"to": "W-MTR", "when": "has its own meter"}]},
        {"key": "W-WT", "title": "Well test review", "body": "...", "next": [{"to": "W-END"}]},
        {"key": "W-MTR", "title": "Meter trend review", "body": "...", "next": [{"to": "W-END"}]},
        {"key": "W-END", "kind": "end", "title": "Disposition", "body": "..."},
    ],
)

Runs. A run walks a playbook on a subject and keeps the trail. It is pinned to the playbook version it started on and only accepts legal transitions.

from gurucloud_kb import PlaybookRunError

run = kb.start_playbook_run("well-review", subject="well 7")
run = kb.advance_playbook_run(run["id"], "Gas down 40% over three days")   # linear step
try:
    run = kb.advance_playbook_run(run["id"], "Has an EFM meter", next_key="W-MTR", reason="metered")
except PlaybookRunError as e:      # illegal_transition | loop_limit | run_not_running | ambiguous_next
    print(e.code, [t["to"] for t in e.legal])

kb.get_playbook_run(run["id"])["trail"]                 # every observation and branch taken
kb.list_playbook_runs("well-review", state="running")
kb.list_playbook_runs(subject="WELL-7")          # every run on one subject, across playbooks
kb.advance_playbook_run(run["id"], "Last test is 5 months old",
                        verdict="flagged", evidence={"last_test": "2026-05-02"})
kb.advance_playbook_run(run["id"], "Well sold", abandon=True, abandon_reason="no longer needed")

Overlap guard. An upsert_playbook whose when_to_use scores at or above the bank's threshold (default 0.82 cosine) against another active playbook raises PlaybookOverlapError with .candidates. Extend the existing slug instead, give yours a genuinely different trigger, retire the old one with supersedes_slug="old-slug", or pass force=True when the tasks really are distinct.

Slugs are kebab-case (2–80 chars). Step positions are assigned from list order. The same three operations are exposed to agents as the MCP tools list_playbooks / get_playbook / upsert_playbook, and query_knowledge_bank names matching playbooks under matched_playbooks.


Explore a KB in the browser

The SDK ships the Knowledge Bank Explorer — the same UI the hosted app serves at /knowledgebank/<kb_id>/explore — and can serve it locally:

export GURUCLOUD_KB_API_KEY=kb_...
gurucloud-kb ui --kb "My KB"          # opens http://127.0.0.1:8765/?kb=My%20KB
gurucloud-kb ui                       # no --kb: pick from the banks your key can see

or from Python:

client.serve_ui("My KB")              # blocks until Ctrl-C; open_browser=False to stay headless

A small stdlib server serves the bundled HTML/JS from this package and proxies the UI's calls through this client, so the API key never reaches the browser and nothing is installed beyond gurucloud-kb. It works against the hosted API and against a self-hosted platform (--base-url https://<platform> with the service token as the key). The self-hosted platform image also serves the same UI itself at /ui.

Tabs: Entries (weighted multi-dimension search, a Filters panel built from metadata_facets(): the metadata keys the entries really carry, their top values, the source and a created-date range, applied to browsing, plain search and LLM-powered search; exact-match selects for text-only dimensions such as a business bank's kind; add / edit / delete; "Promote to playbook"), Map (cluster the bank's embeddings with kb.cluster() and see every entry placed by similarity, with playbooks drawn as step chains linked to the entries they cite), Playbooks, Schema, Quality (assertions, retrieval-eval runs), History (recent queries, deduplication events, entry event log) and Connect (MCP config, server definition, generated tools).

kb.list_recent_queries(limit=20)   # {"queries": [{query_text, duration_ms, result_count, filters_used, query_source, created_at}, …], "limit": 20}

Manage KBs and API keys (client scope)

client.list_kbs()                     # every KB your key can reach → [KBInfo]
kb = client.get_kb("kb-uuid")         # bind a handle (fetches info)
kb = client.get_kb("Support KB")      # ...or by exact bank name (409 if ambiguous)
client.update_kb("kb-uuid", name="Support KB", description="...")
client.delete_kb("kb-uuid")           # admin scope — irreversible

# API keys (admin scope): mint scoped keys for services / teammates.
key = client.create_api_key(name="reporting-bot", scopes=["read"])
client.list_api_keys()
client.delete_api_key(key["id"])

client.get_mcp_server_definition("kb-uuid")   # same payload as kb.get_mcp_server_definition()
client.get_mcp_server_definition("Support KB")   # by name; the payload carries kb_id + kb_name
client.close()                        # or use `with GuruCloudClient(...) as client:`

Use the KB as an MCP server (agent injection)

mcp_def = kb.get_mcp_server_definition()
agent_config = {
    "mcpServers": {
        mcp_def["server_name"]: {
            "type": mcp_def["type"],
            "url": mcp_def["url"],
            "headers": {"Authorization": f"Bearer {api_key}"},
        }
    }
}
# Or mint a dedicated never-expiring token (admin scope):
pat = kb.generate_pat(token_name="My Agent")

mcp_def["available_tools"] is exactly what the server serves on tools/list: query_knowledge_bank, report_learning, get_kb_entry, edit_kb_entry, delete_kb_entry, list_playbooks, get_playbook, upsert_playbook — minus whatever the server's own read-only / allow / block configuration hides — so a gateway can pass the definition through without hard-coding tool names. The definition also carries kb_id and kb_name. query_knowledge_bank and report_learning are generated from your schema — every searchable dimension becomes a <dimension>_query parameter automatically.

Self-hosted platform

A licensed in-tenant deployment of the Knowledge Bank platform (the kb-platform container image, >= 1.1.0) serves the same API and the same per-bank MCP servers itself. Point the client at it with the platform's service token — no kb_ key is involved:

client = GuruCloudClient(
    api_key=platform_service_token,
    base_url="https://kb-platform.<your-container-apps-domain>",
)
kb = client.get_kb("spog-desk-prod-tx")          # bank NAME or UUID
mcp_def = kb.get_mcp_server_definition()         # url = <platform>/kb/<id>/mcp

The definition is the platform's own MCP server for that bank: mount it as is (the Bearer token is the same service token). Its available_tools is exactly what the server's tools/list returns — query_knowledge_bank, report_learning, get_kb_entry, edit_kb_entry, delete_kb_entry, generated from the bank's schema — so a gateway that passes the definition through never needs to hard-code tool names. Platform images >= 1.2.0 also serve list_playbooks / get_playbook / upsert_playbook.

For a consumer that keeps one server attached and picks the bank per call, client.get_platform_mcp_server_definition() returns the bank-addressed server (<platform>/mcp; every tool takes kb = bank name or UUID, plus list_knowledge_banks / get_knowledge_bank_info).

Not available on a self-hosted platform (the call raises APIError 501): generate_pat(), API-key management, retrieval assertions / evaluation runs, and deduplication events.


Async

The async client mirrors the sync API exactly — every method is awaitable.

from gurucloud_kb import AsyncGuruCloudClient

async with AsyncGuruCloudClient(api_key="kb_...") as client:
    kb = await client.get_kb("your-kb-uuid")
    results = await kb.search({
        "dimensions": {"content": {"query_text": "JWT", "weight": 1.0}},
        "k": 5,
    })

Typed contracts

All request/response shapes are exported as TypedDicts / Literals for editor + agent autocompletion:

from gurucloud_kb import (
    DimensionConfig, DimensionSchema, DimensionType,
    DimensionQuery, SearchRequest, CombinationMode, Aggregation,
    CategoryFilter, EntryInput, EntryResult, KBInfo,
)

Module registry harness (gurucloud_kb.modreg)

The per-repository module registry automation keeps one Knowledge Bank entry per canonical shared module and retires duplicates only after proving the replacement equivalent (recorded calls → byte-for-byte replay → differential fuzzing → production shadow-compare). Its deterministic harness ships in this package so the automation can run in any repository:

pip install "gurucloud-kb[modreg]"          # psycopg2 (ledger) + hypothesis (fuzz)
python -m gurucloud_kb.modreg --help        # scan, purity, replay, fuzz, corpus-stats, corpus-to-test, ledger …
python -m gurucloud_kb.modreg scan --root . --json
python -m pytest -p gurucloud_kb.modreg.trace_plugin ...   # harvest a call corpus from your own tests

scan, purity, replay, corpus-stats and corpus-to-test need only the standard library; ledger needs the modreg extra's psycopg2 and fuzz its Hypothesis. The canonical source lives in the gurucloudai monorepo under services/module_registry/ and is mirrored here byte for byte; agents do not edit the harness, they file a card.


Changelog

0.10.0

  • Per-bank edge relations. kb.edge_relations() returns the bank's effective relation vocabulary and kb.set_edge_relations([...], replace_defaults=False) declares it (async mirrors on AsyncKnowledgeBank). New types EdgeRelationConfig, EdgeRelation, EdgeRelationVocabulary, BuiltinEdgeType and BUILTIN_EDGE_TYPES. EdgeType widens from a Literal to str, so a declared relation type-checks in kb.connect. The Explorer's connect form lists the bank's own relations. Needs a platform that serves /edge-relations (hosted GuruCloud, or kb-platform 1.8.0 and later); older platforms answer 404 and the Explorer falls back to the built-ins.

0.9.0

  • Explorer Graph tab. Search the bank the way an agent does (top-K, collapse on/off, neighbors per result) and see the answer twice: as the agent reads it (ranked results, related pointers with relation, kind, text and note, the collapse summary, raw JSON on demand) and as a graph (retrieved entries, the entries the pointers name, the edges between them, optionally every other connection of the results, one-hop expansion from any node). With no query the tab draws the whole bank as constellations: every entry_edges row, laid out component by component with no overlapping nodes. UI bundle only; no SDK API change.

0.8.0

  • Entry connections (knowledge graph). kb.connect(from, to, relation), kb.add_connections([...]), kb.connections(entry_id), kb.disconnect(edge_id), kb.list_connections(...), kb.connection_stats(), kb.graph(root, depth=...) and kb.backfill_connections(dry_run=...) over the new per-bank entry_edges table (typed, directed, foreign keys to both entries, audited). Deleting an entry bridges A -> B -> C to A -> C.
  • search(..., collapse=True) folds connected results into one representative each with related pointers and refills the freed slots. Every pointer carries the edge's note (how that entry pertains) and a kind: folded (was in the pool) or neighbor (one hop off the results, not fetched; max_neighbors per result, default 3). The hosted query_knowledge_bank tool collapses by default.
  • Explorer: connections on the entry detail (add, remove, open the other entry) and a graph view of an entry's neighbourhood.

0.7.2

  • Explorer: collapsible Filters panel. The Entries tab's Filters card starts collapsed (it is tall), its header line says how many metadata keys the bank has and how many filters are active, and a click expands it; the choice is remembered per bank. Active filters stay visible as removable chips above the list either way.

0.7.1

  • Explorer: multi-select and bulk actions. Every entry card has a checkbox and the Entries tab a bulk bar: Select all shown, Set metadata… (one key and a typed value merged into every selected entry through the per-entry update) and Delete… (confirmed, then one audited delete per entry).
  • Explorer: key finder. The Filters panel gets a Find a key input (type part of a metadata key to get its select) and a Who acted on it row listing the bank's *_by / reviewer / owner keys, so a bank with hundreds of keys still filters by the person who approved, decided or filed an entry.
  • No SDK API change; the bundle is served by gurucloud-kb ui and by the platform image, whose /ui modules now revalidate on every load.

0.7.0

  • Similar pairs (beta). Judgements carry a concrete proposal (kind, rationale, minimal find/replace edits per changed entry with the resulting content) written under the bank's rules, judged one pair per concurrent model call; kb.apply_pair_proposal(judgement_id, a_id, b_id, proposal, reason=) executes an approved one (audited, protected entries skipped, snapshots returned).
  • Similar pairs (beta). kb.similar_pairs(field=, threshold=, neighbors=, limit=, max_entries=, scope=) (sync and async) returns the bank's most similar entry pairs from the embeddings with both entries' text and scan stats; kb.judge_pairs(pairs, speed=, model=, reasoning_effort=, timeout_seconds=, context=) has a model classify each pair as duplicate / conflicting / related / distinct with a reason and a suggested action (audited on the platform; failures reported in status). New types SimilarPairsResult, SimilarPair, PairEntry, PairJudgement, JudgePairsResult. The Explorer gains a Similar tab. Needs a hosted API or kb-platform image that serves POST /banks/{kb}/similar-pairs.

0.6.0

  • Filter entries by what they actually carry. kb.list_entries(...) (sync and async) takes metadata_filters= (JSONB containment; a list value means "contains"), source= and the created_/updated_/event_ time bounds, applied server-side before pagination. New kb.metadata_facets(max_values=, max_keys=) returns every metadata key the bank's entries carry with entry counts, distinct counts and the most common typed values, plus the source facet (types MetadataFacets, MetadataFacet, MetadataFacetValue, SourceFacet). search_expanded(..., source=) and a "source" key in a dict search request filter on the source column. The Explorer's Entries tab gains a Filters panel built from the facets. Needs a hosted API or a kb-platform image that serves GET /banks/{kb}/metadata-facets.

0.5.6

  • update_entry has typed keyword arguments and refuses unknown fields. kb.update_entry(entry_id, content=..., useful_for=..., metadata=..., systems=..., tasks=..., add_systems=..., remove_systems=..., add_tasks=..., remove_tasks=...) (sync and async) maps to the server's update fields; metadata= merges. The raw updates mapping is still accepted, but a key outside the server's vocabulary now raises ValueError client-side, as does giving a field both ways or giving nothing to change. New EntryUpdate type. Matches the hosted API and kb-platform images built after 1.6.0, which now reject unknown update fields (400 unknown_field / validation_error) instead of ignoring them and answering success.

0.5.5

  • Playbook metadata patching and a guard that refuses only new overlaps (matches kb-platform 1.6.0). upsert_playbook(metadata_merge={...}) sets the given metadata keys on top of the stored object and deletes a key given as None, without a prior read; it is refused together with metadata. A metadata replace on an existing playbook now keeps the reserved keys verdicts and derived_from unless they are set to None, and the result reports them under metadata_kept. The overlap guard refuses a write only when it would newly overlap another active playbook: editing a playbook whose sibling already overlapped it no longer raises PlaybookOverlapError, and the result lists that sibling under existing_overlaps (PlaybookOverlapCandidate, with prior_similarity and already_overlapping). Older services ignore metadata_merge; pass metadata with the full object against them.

0.5.4

  • Processes beside playbooks. A stored row has a genre: procedure (a playbook, the agent's own ordered procedure, walkable as a run) or process (business process flow documentation: how the client's people and systems move an order, request or exception from trigger to outcome). list_playbooks(genre=...), get_playbook_stats(genre=...) and upsert_playbook(genre="process", ...) pass it through; a process step carries process={"actor", "system", "needs", "hands_to", "exceptions", "source"} (ProcessStepDetail; the service refuses a non-end step without an actor) and a process can never be started as a run. Business banks expose processes through the MCP tools list_processes, get_process, document_process, list_process_versions and retire_process. The Explorer's Playbooks tab gains a genre filter, a hand-off table on process detail and process fields in the editor.

0.5.3

  • modreg: ledger summary bootstrap flag. bootstrap_complete is now the bootstrap_complete_at watermark (the same authority as pr_budget and the run mode), so a repository with nothing to register no longer reports bootstrap_complete: false beside pr_budget.bootstrap_complete: true. The crawl-side fact is reported separately as crawl_drained (no pending crawl items).

0.5.2

  • Verdict vocabulary. A playbook may declare the verdicts its steps are recorded with: metadata={"verdicts": ["pass", "flagged", "not_applicable"]}. A run of that playbook then refuses a step recorded without one of them: PlaybookRunError with code verdict_required or invalid_verdict and allowed_verdicts. The run's current_step carries verdicts. Playbooks without a list accept any verdict, as before.

0.5.1

  • Runs on a subject. list_playbook_runs() no longer needs a slug: kb.list_playbook_runs(subject="WELL-7") returns every run on that subject across the bank's playbooks (each row names its slug).
  • Verdict and evidence per step. advance_playbook_run(..., verdict="flagged", evidence={...}) stores a short structured result and the evidence behind it on the trail row; both come back on RunStepRecord.

0.5.0

  • Branching playbooks. Steps carry a stable key, a kind (action | decision | end) and next transitions (to, when, limit); a playbook read returns its graph. New types StepKind, StepTransition, PlaybookGraph. Linear playbooks are unchanged.
  • Playbook runs. start_playbook_run, advance_playbook_run, get_playbook_run and list_playbook_runs on KB and AsyncKB walk a playbook on a subject and keep the trail. A refused advance raises PlaybookRunError (illegal_transition, loop_limit, run_not_running, ambiguous_next) carrying the legal next steps.
  • Explorer. The playbook page draws the branch graph and lists runs with their trails.

0.4.0

  • Module registry harness. New gurucloud_kb.modreg package (the module_registry automation's scan / purity / tracer / replay / fuzz / shadow / ledger tools) with the modreg optional-dependency group, so the automation runs in repositories other than the platform monorepo: pip install "gurucloud-kb[modreg]" then python -m gurucloud_kb.modreg. No change to the KB client API.

0.3.0

  • Expanded search speed presets. speed="fast" (default, ~0.8 s) or "thorough" (~3 s), plus reasoning_effort and timeout_seconds overrides; expansion.speed / expansion.reasoning_effort report what ran.
  • Expanded search. kb.search_expanded(query, ...) (sync + async): a small model reads the bank's dimension descriptions and fills one query per dimension; the platform searches the raw query + descriptors in the content dimension (weight 3.0), every filled dimension at its schema weight, and the lexical arm on the raw words. Returns {"results", "expansion", "search_request"}; expansion.status reports fallbacks, so it is never worse than search(). Needs kb-platform >= 1.4.0 self-hosted.
  • Client credentials. client.credentials.set / list / delete / status (sync + async) store your own OpenAI or Azure OpenAI key, encrypted on the platform, so expanded search bills your account; optional per-bank scope (kb=). Listings carry a fingerprint and hint, never the key.
  • Explorer: "LLM-powered search" toggle. The Entries search card has an off-by-default checkbox, remembered per bank. When it is on, one query box goes to /search/expanded, and a panel shows what the model searched in each dimension, which key paid, and why it fell back to the plain search when it did. Works on every host: gurucloud-kb ui, the hosted Explorer, and the self-hosted platform /ui.
  • New typed contracts: ExpandedSearchResult, ExpansionInfo, ExpansionStatus, ClientCredentialInfo, CredentialProvider, CredentialSource, CredentialStoreStatus.

0.2.6

  • Map: entries are visible inside their clusters. Cluster and outlier boxes now draw with a light fill. They were painted solid, which hid the entry dots (Cytoscape ignores the alpha in an rgba() colour and uses background-opacity).
  • Map: "Set aside misfits" toggle. The option was always on with no control in the toolbar, so entries that fit no cluster were left off the map. Uncheck it to place every entry. The choice is remembered per bank.

0.2.5

  • Live stats in the Knowledge Banks table on every host. gurucloud-kb ui and the self-hosted platform /ui now feed the table's playbooks, 7d / 24h query volume, no-hit rate and recency columns: the SDK server proxies the new hosted GET /api/v1/kb/bank-stats, and the platform maps /api/kb-explorer-bank-stats onto its own GET /api/v1/kb/bank-stats (/kb/stats-summary). The bundle reads the feed from data-stats-url and accepts both the bare and the {"data": ...} envelope.

0.2.4

  • Knowledge Banks table: access + row actions. Bank rows carry an access value (owner / granted / admin) when the host's bank list provides one; a granted bank shows a "granted" chip and no Clear / Delete. Hosted-app extras switched on by data-create-url, data-api-keys-url and data-tier-url on #kbx-root: header buttons, a plan badge, and per-row Connect / Clear / Delete actions with confirmation dialogs. The SDK server and the platform /ui are unchanged (no attributes, no actions).

0.2.3

Cluster naming and sizing, changed after a measured evaluation on a 2,000-entry bank (an LLM judge scoring names against members the namer never saw): names fit 30% of a cluster's unseen members before, 48% after; the core of a cluster 41% before, 63% after.

  • Behaviour change (service-side, affects every client on upgrade of the platform, not of this package): with algorithm="auto" and no k, the automatic cluster count is no longer capped at 10. It is max(sqrt(n/2), n / target_cluster_size) with target_cluster_size defaulting to 35, so 2,000 entries give about 57 clusters instead of 10. Pass k= (or a larger target_cluster_size) to keep a coarse view. If you cache cluster titles keyed on member sets, expect one re-naming pass.
  • kb.cluster(target_cluster_size=...) — entries per cluster the automatic count aims for.
  • kb.cluster(label_sample="spread" | "nearest") — which members the namer sees. The service default is now "spread" (centre out toward the edge) with 8 samples; "nearest" restores the old core-only sample. label_sample_size is now sent only when you pass it (it used to force 5).
  • kb.cluster(fields=["*"]) — cluster across every embedding dimension, combined with the schema's search weights (the weighted-sum search score is exactly the cosine of the weighted concatenation, so "close" means the same thing it means in a query). Limited to 20,000 entries per call.
  • Cluster descriptions. With label=True each cluster now also carries a one-sentence description. This was the largest measured gain: a name alone fit 40% of a cluster's members, name plus description 55-58%. The namer also reads each sampled entry's useful_for when it has one. batch size for naming is 20 clusters per call.
  • kb.cluster(peel_misfits=True) — sets aside entries that sit closer to another cluster than their own (negative silhouette); with outlier_strategy="subcluster" they are regrouped and the ones that fit nowhere are left unplaced. Measured: names fit 51% of unseen members against 39%, with 22% of entries unplaced. Opt-in; the Explorer Map turns it on.
  • Cluster keywords are now distinctive terms (class-based TF-IDF across the clusters, up to 8, unigrams and bigrams) instead of raw word counts.
  • Limits removed: max_members_per_cluster and label_sample_size have no upper bound; every cluster is LLM-named (the 2,000-cluster ceiling is now an optional operator setting); label length trim 60 → 120 characters.
  • More robust naming: labels are keyed by cluster number, so a model miscount costs one cluster its name instead of a whole 40-cluster batch.

0.2.2

  • Knowledge Banks table. The explorer's bank picker (gurucloud-kb ui with no --kb, the platform /ui, and the hosted app) is now a sortable table instead of a card grid: filter by name / description / id, sort any column (unknown values sink to the bottom), a totals row, and banks with no entries and no playbooks hidden behind a "Show empty banks" toggle. When the host offers a per-bank live-stats feed (data-stats-url; the hosted app's /api/kb-explorer/banks/stats, backed by the KB service's GET /kb/stats-summary) the table also shows playbooks, 7d / 24h query volume, no-hit rate and last query / last entry recency; without one it stands on the bank list's own columns.

0.2.1

  • Explorer header counts. The "Total queries" tile is the bank's lifetime query count (from /info); the latency tiles are labelled as the KB service's last-hour window ("Avg query (1h)", "P95 query (1h)") and read "—" when nothing ran in that window instead of a misleading 0.0 ms. The Entries tab caption adds the bank's live total ("… · 4,110 in bank") when more entries exist than are loaded. Pure view-model in ui/js/stats_view.js.

0.2.0

  • Bundled Knowledge Bank Explorer. The package now ships the explorer UI (gurucloud_kb/ui) and serves it locally: gurucloud-kb ui [--kb ID_OR_NAME] (new console script; also python -m gurucloud_kb ui) or client.serve_ui(kb). A stdlib server proxies the UI's calls through the SDK transport so the API key stays server-side. New public helpers serve_ui() / start_ui_server() (the latter binds without blocking, for embedding and tests). Works against the hosted API and self-hosted platforms; vendored Cytoscape/dagre make the Map tab work offline.
  • kb.list_recent_queries(limit=50) (sync + async) — the bank's most recent searches with duration, result count, filters and query_source, via the new GET /banks/{kb}/queries endpoint. Typed as RecentQueryList.
  • Transport: post/put/patch/delete accept params= (used by the UI proxy).

0.1.19

  • Non-destructive playbook edits — upsert_playbook() now leaves status and metadata out of the request when they are None (the new default for status, previously "active"). The service keeps the stored values for an existing slug and defaults a new playbook to active / {}, so a read → change-one-step → write round-trip no longer publishes a draft or wipes metadata. Pass metadata={} to clear it, or status=... to change it. Requires a platform serving the same contract (hosted, or self-hosted image ≥ 1.3.0); older services still treat the absent fields as before.
  • delete_playbook(slug, reason=..., changed_by=...) — optional audit fields forwarded as query params; the service appends a tombstone version (deleted: true, delete_reason) so list_playbook_versions() still shows what was removed and why. The response gains tombstone_version.

0.1.18

  • available_tools is the served surface — the hosted get_mcp_server_definition() now lists every tool the bank's MCP server serves on tools/list (query_knowledge_bank, report_learning, get_kb_entry, edit_kb_entry, delete_kb_entry, list_playbooks, get_playbook, upsert_playbook), honouring the server's own read-only / allow / block configuration, instead of a hard-coded two-tool list. A gateway can pass the definition through without hard-coding tool names.
  • Address a bank by name — every hosted /banks/{kb}/... route (and so client.get_kb(...), client.get_mcp_server_definition(...) and every KnowledgeBank method) accepts the bank's exact name as well as its kb_id, matching the self-hosted platform contract. An ambiguous name is a 409 ambiguous_bank_name (APIError) listing the candidate ids.
  • kb_id + kb_name on the definition — MCPServerDefinition carries both identifiers (hosted and self-hosted, platform image >= 1.2.1), so a registry keyed on names recovers the id from the definition itself. Requires the hosted API of 2026-09-08 or later.

0.1.17

  • Playbooks — kb.list_playbooks(query=None, status="active", limit=25, min_score=0.0), kb.get_playbook(slug), kb.upsert_playbook(slug, title=, when_to_use=, steps=, …), kb.delete_playbook(slug), kb.list_playbook_versions(slug), kb.get_playbook_stats() (sync + async). A playbook is a distinct, named, ordered procedure stored beside a bank's entries and returned whole; upsert_playbook is versioned and raises the new PlaybookOverlapError (409 playbook_overlap, .candidates) when it collides with another active playbook. New typed contracts: Playbook, PlaybookList, PlaybookSummary, PlaybookStep, PlaybookStepInput, PlaybookWriteResult, PlaybookVersion, PlaybookStats, LinkedEntry, OverlapCandidate, PlaybookStatus. Requires the hosted API of 2026-09-08 or a self-hosted platform image >= 1.2.0.

0.1.16

  • Self-hosted platform support — GuruCloudClient(base_url=<your platform>, api_key=<platform service token>) works against a licensed in-tenant Knowledge Bank platform (kb-platform image >= 1.1.0). The kb_ key-prefix rule now applies only to the hosted base_url; a self-hosted deployment authenticates with its own service token. kb.get_mcp_server_definition() returns the platform's own MCP server URL for the bank (<platform>/kb/{kb_id}/mcp), and the new client.get_platform_mcp_server_definition() returns the bank-addressed server (<platform>/mcp, tools take kb). See "Self-hosted platform".

0.1.15

  • Tunable reassign radius — cluster() (sync + async) accepts reassign_percentile (50-100) controlling how far outside a cluster's core outlier_strategy="reassign" will absorb noise (a noise entry joins its nearest cluster only within that percentile of the cluster's own member-to-centroid distances). The server default also moved from p90 to p99, measured to absorb most genuinely-belonging noise while staying shy on corpora whose noise is truly off-topic. Omitted from the request when not set, so older servers keep working.

0.1.14

  • Diverse member sampling — cluster() (sync + async) accepts member_sample ("nearest" | "diverse"). "diverse" returns the nearest-centroid anchor plus greedy farthest-point picks so fringe sub-themes reach the member sample instead of only the cluster core. Requires the 2026-09-01 server deploy; omitted from the request at its default, so older servers keep working.

0.1.13

  • Outlier refinement — cluster() (sync + async) accepts outlier_strategy ("keep" | "reassign" | "subcluster") so vector noise can be absorbed into nearby clusters or re-clustered into its own from_noise clusters instead of staying a catch-all. Vector clusters also report mean_member_distance and a low_cohesion flag marking likely catch-all clusters. Requires the 2026-09-01 server deploy; the parameter is omitted from the request at its default, so older servers keep working.

0.1.12

  • Cluster-size caps — cluster() (sync + async) accepts max_cluster_size (absolute entries per cluster) and max_cluster_fraction (share of the clustered scope, 0..1]; stricter wins). Oversized vector clusters are recursively split server-side so the caps always hold; fuzzy groups split into exact-value groups; multi-valued dimension groups are exempt (noted). Requires the 2026-08-31 server deploy.
  • Multiple fields in one cluster() call are now clustered concurrently server-side — one three-field request beats three single-field requests.
  • __version__ re-synced with pyproject.toml (had been stuck at 0.1.8).

0.1.11

  • Batch ingest returns created entry ids — ingest() responses carry entry_ids: one created id per input position (None for rows that errored), so callers can map inputs to created entries. Typing/docs only on the client; requires the 2026-08-24 server deploy (PR #3402).

0.1.10

  • Entry reads now carry custom dimension values and timestamps — the API's list_entries(), get_entry(), and search() results include a dimensions object with every non-default embedded dimension's value(s) (SINGLE → str, MULTI → list[str]; empty {} on default-schema KBs), and created_at / updated_at are populated on list/get responses (previously null). EntryResult documents the new key. No client behavior change — typing/docs only; requires the 2026-07-03 server deploy or later.

0.1.9

  • kb.cluster() / await kb.cluster() omit fields when not given — the server now picks the KB's primary embedding dimension (first required SINGLE dimension), so custom-schema KBs no longer degrade to fuzzy grouping over a raw content column that has no embedding. Server-side improvements shipped alongside: MULTI dimensions (tags, products, ...) are now clusterable (entries grouped by their values), degenerate auto-HDBSCAN results fall back to KMeans with a heuristic k, ID-like values are grouped exactly with dominant-value labels, and each field result carries an optional note plus label_usage token accounting.

0.1.8

  • Removed DimensionConfig.show_in_results — the deprecated, inert per-dimension flag (deprecated in 0.1.3) has been removed. It never controlled MCP output; use response_fields (mcp_response_fields) to choose the keys the MCP tools return. Stored schemas that still carry the key keep parsing — the unknown field is ignored.

0.1.7

  • label_sample_size on kb.cluster() / await kb.cluster() — when label=True, how many representative entries per cluster are sent to the labeler (default 5). Server-side, cluster naming became one batched LLM call per field instead of one call per cluster, so labeled clustering no longer slows linearly with cluster count and names come out mutually distinct.

0.1.6

  • kb.update() / await kb.update() — update a KB's name and/or description in place (write scope; also client.update_kb(kb_id, ...)). The description drives both the agent-facing initialize.instructions and the description returned by get_mcp_server_definition() (see Update the name / description above) — there is no separate setter for those surfaces.
  • Corrected stale docstrings that claimed KB-management methods "return a token".

0.1.5

  • Restore the py.typed marker (PEP 561) so type checkers pick up the SDK's inline types. It was inadvertently dropped in 0.1.2–0.1.4; the Typing :: Typed classifier had been advertised without it. No API changes.

0.1.4

  • kb.cluster() / await kb.cluster() — group a KB's entries by any field. Embedding dimensions cluster by vector similarity (KMeans / Agglomerative / HDBSCAN); metadata/text fields cluster by fuzzy string match. Cluster the whole KB or a search result set; results are returned keyed per field. New typed contracts: ClusteringResult, FieldClusterResult, ClusterGroup, ClusterMember, ClusterScope, ClusterMethod, ClusterAlgorithm. Backed by POST /api/v1/kb/banks/{id}/cluster.

0.1.3

  • response_fields / kb.set_response_fields() — choose which extra keys a KB's MCP tools return beyond id + content (see Choose which fields the MCP tools return above). Set on create_kb(...) or later via kb.set_response_fields([...]); needs write scope. Backed by mcp_response_fields on DimensionSchema.
  • allow_updates / kb.set_allow_updates() — accumulate-only KBs (dedup never merges/overwrites).
  • Deprecated DimensionConfig.show_in_results — this per-dimension flag is not enforced and never controlled MCP output; use response_fields (mcp_response_fields) instead. The field is kept only for backward compatibility of stored schemas.

Metadata

Release files for gurucloud-kb 0.10.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for gurucloud-kb 0.10.0
File Size Uploaded Provenance
gurucloud_kb-0.10.0.tar.gz 620.5 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for gurucloud-kb 0.10.0
File Interpreter ABI Platform
gurucloud_kb-0.10.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.2 MB

Release files / gurucloud_kb-0.10.0.tar.gz

Download URL gurucloud_kb-0.10.0.tar.gz
Size 620.5 kB
Tags Source
SHA-256 checksum
How to use checksums
e2ca2fa90559cef2f38b9d09437ff0dd2df96c433d0b41b8b29fe9afed92fdf1
BLAKE2b-256 checksum
How to use checksums
186252592a2c64ac1a222fcc64b9a38f97ffebb7f25695dfc4bfd2dffecf729a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.12

Release files / gurucloud_kb-0.10.0-py3-none-any.whl

Download URL gurucloud_kb-0.10.0-py3-none-any.whl
Size 607.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
dd2efd8b2d1732cf04f5abb83f6dfb8a2813ecacd4b15e5c5ad07761266fc9cb
BLAKE2b-256 checksum
How to use checksums
c25042af30ddf3587bdbc22836f3f9f7e97bb95442852bd7be19da3fb53e9237
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.12

Security for gurucloud-kb 0.10.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page