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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions hindsight-api-slim/hindsight_api/api/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2325,7 +2325,7 @@ class MentalModelListResponse(BaseModel):
class KnowledgeNode(BaseModel):
"""A node in the knowledge-base tree — a folder or a page.

Pages carry ``description``/``tags`` from their backing mental model. The
Pages carry ``description``/``tags``/``trigger`` from their backing mental model. The
knowledge base is client-managed (CRUD); ``managed`` lets a client tag a node
as system-owned vs. hand-authored.
"""
Expand All @@ -2338,6 +2338,10 @@ class KnowledgeNode(BaseModel):
managed: bool = Field(default=False, description="Client-set flag: true = system-owned, false = hand-authored.")
description: str | None = Field(default=None, description="Page source query (the page's `description`).")
tags: list[str] = FieldWithDefault(list)
trigger: MentalModelTrigger | None = Field(
default=None,
description="Page refresh trigger, including optional compound tag filters.",
)
timestamp: str | None = Field(default=None, description="Last refresh (page) or last update (folder).")
is_stale: bool | None = Field(
default=None,
Expand Down Expand Up @@ -2380,10 +2384,15 @@ class UpdateNodeRequest(BaseModel):
name: str | None = None
parent_id: str | None = None
# Page-only options (updated on the backing mental model). Changing
# source_query schedules an async refresh so the page rebuilds.
# source_query schedules an async refresh so the page rebuilds; trigger changes only
# change the refresh scope and do not rebuild content by themselves.
source_query: str | None = None
tags: list[str] | None = None
max_tokens: int | None = None
trigger: MentalModelTrigger | None = Field(
default=None,
description="Page refresh trigger. Omit to leave it unchanged; explicit null is rejected.",
)


class CreateKnowledgePageResponse(BaseModel):
Expand Down Expand Up @@ -2450,6 +2459,7 @@ def _knowledge_node_model(node: dict[str, Any]) -> KnowledgeNode:
managed=bool(node.get("managed")),
description=node.get("source_query") if is_page else None,
tags=list(node.get("tags") or []) if is_page else [],
trigger=node.get("trigger") if is_page else None,
timestamp=(node.get("last_refreshed_at") if is_page else node.get("updated_at")),
is_stale=node.get("is_stale") if is_page else None,
)
Expand Down Expand Up @@ -5787,7 +5797,7 @@ async def api_get_knowledge_page(
response_model=KnowledgeNode,
summary="Rename/move a knowledge-base node or update a page's options",
description="Rename a node (set `name`), move it under another folder (set `parent_id`, null "
"for the root), and/or update a page's options (`source_query`, `tags`, `max_tokens`). "
"for the root), and/or update a page's options (`source_query`, `tags`, `max_tokens`, `trigger`). "
"Changing `source_query` schedules an async refresh so the page rebuilds against the new question.",
operation_id="update_knowledge_node",
tags=["Knowledge Base"],
Expand All @@ -5802,6 +5812,11 @@ async def api_update_knowledge_node(
try:
updated: dict[str, Any] | None = None
did_change = False
if "trigger" in body.model_fields_set and body.trigger is None:
raise HTTPException(
status_code=400,
detail="trigger cannot be null; omit it to leave the trigger unchanged",
)
if body.name is not None:
did_change = True
updated = await app.state.memory.rename_knowledge_node(
Expand All @@ -5816,7 +5831,7 @@ async def api_update_knowledge_node(
)
# Page options live on the backing mental model; each applies only when
# present in the body (so tags=[] clears, distinct from "not provided").
page_fields = {"source_query", "tags", "max_tokens"} & body.model_fields_set
page_fields = {"source_query", "tags", "max_tokens", "trigger"} & body.model_fields_set
if page_fields:
did_change = True
updated = await app.state.memory.update_knowledge_page(
Expand All @@ -5825,6 +5840,7 @@ async def api_update_knowledge_node(
source_query=body.source_query if "source_query" in page_fields else None,
tags=body.tags if "tags" in page_fields else None,
max_tokens=body.max_tokens if "max_tokens" in page_fields else None,
trigger=body.trigger.model_dump() if "trigger" in page_fields and body.trigger else None,
request_context=request_context,
)
# A new source query means the content is stale — rebuild it.
Expand All @@ -5836,7 +5852,8 @@ async def api_update_knowledge_node(
)
if not did_change:
raise HTTPException(
status_code=400, detail="Provide name, parent_id, source_query, tags, and/or max_tokens to update"
status_code=400,
detail="Provide name, parent_id, source_query, tags, max_tokens, and/or trigger to update",
)
if updated is None:
raise HTTPException(status_code=404, detail=f"Knowledge node '{node_id}' not found")
Expand Down
12 changes: 11 additions & 1 deletion hindsight-api-slim/hindsight_api/engine/memory_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -13972,6 +13972,16 @@ def _row_to_knowledge_node(row) -> dict[str, Any]:
if "mm_tags" in row:
node["tags"] = list(row["mm_tags"] or [])
node["source_query"] = row["mm_source_query"]
# asyncpg has no JSONB codec on the application pool, and Oracle
# returns JSON CLOBs as text for aliased columns such as mm_trigger.
# Normalize both database representations before the HTTP model sees it.
trigger = row["mm_trigger"]
if isinstance(trigger, str):
try:
trigger = json.loads(trigger)
except json.JSONDecodeError:
trigger = None
node["trigger"] = trigger
node["last_refreshed_at"] = row["mm_last_refreshed_at"].isoformat() if row["mm_last_refreshed_at"] else None
return node

Expand All @@ -13981,7 +13991,7 @@ def _row_to_knowledge_node(row) -> dict[str, Any]:
_KP_PAGE_SELECT = (
"kp.id, kp.bank_id, kp.parent_id, kp.kind, kp.name, kp.mental_model_id, "
"kp.sort_order, kp.managed, kp.created_at, kp.updated_at, "
"mm.tags AS mm_tags, mm.source_query AS mm_source_query, "
"mm.tags AS mm_tags, mm.source_query AS mm_source_query, mm.trigger AS mm_trigger, "
"mm.last_refreshed_at AS mm_last_refreshed_at"
)

Expand Down
60 changes: 60 additions & 0 deletions hindsight-api-slim/tests/test_knowledge_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
without consolidation.
"""

import json
import urllib.parse
import uuid
from datetime import datetime, timedelta, timezone
Expand All @@ -30,6 +31,33 @@ def _enc(bank_id: str) -> str:
return urllib.parse.quote(bank_id, safe="")


def test_row_to_knowledge_node_decodes_string_trigger() -> None:
trigger = {
"mode": "delta",
"tag_groups": [{"tags": ["knowledge:decision"], "match": "all_strict"}],
}
row = {
"id": "kp-1",
"bank_id": "bank-1",
"parent_id": None,
"kind": "page",
"name": "Decisions",
"mental_model_id": "mm-1",
"sort_order": 0,
"managed": False,
"created_at": None,
"updated_at": None,
"mm_tags": ["knowledge:decision"],
"mm_source_query": "What decisions were made?",
"mm_trigger": json.dumps(trigger),
"mm_last_refreshed_at": None,
}

node = MemoryEngine._row_to_knowledge_node(row)

assert node["trigger"] == trigger


class _RecordingValidator(OperationValidatorExtension):
"""A validator that records every bank read/write and rejects one operation.

Expand Down Expand Up @@ -533,22 +561,54 @@ async def test_rename_page_syncs_backing_model_and_search(self, api_client, kb_b

async def test_update_page_options(self, api_client, kb_bank):
bank_id, ids = kb_bank
trigger = {
"fact_types": ["world", "experience", "observation"],
"refresh_after_consolidation": True,
"tag_groups": [
{"tags": ["knowledge:decision"], "match": "all_strict"},
{"not": {"tags": ["knowledge:external"], "match": "any_strict"}},
],
}
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.orders}",
json={
"source_query": "summarize every order fact and its revenue",
"tags": ["type:runbook", "sales", "priority"],
"max_tokens": 2048,
"trigger": trigger,
},
)
assert resp.status_code == 200, resp.text
node = resp.json()
assert node["kind"] == "page"
assert set(node["tags"]) == {"type:runbook", "sales", "priority"}
assert node["trigger"]["tag_groups"] == trigger["tag_groups"]
# source_query persists — it surfaces as the `description` on the page.
page = (await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages/{ids.orders}")).json()
assert page["description"] == "summarize every order fact and its revenue"

async def test_update_rejects_explicit_null_trigger(self, api_client, kb_bank):
bank_id, ids = kb_bank
before = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")
assert before.status_code == 200, before.text
before_orders = next(
child for root in before.json()["roots"] for child in root.get("children", []) if child["id"] == ids.orders
)

resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.orders}",
json={"trigger": None},
)

assert resp.status_code == 400
assert "trigger cannot be null" in resp.json()["detail"]
after = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")
assert after.status_code == 200, after.text
after_orders = next(
child for root in after.json()["roots"] for child in root.get("children", []) if child["id"] == ids.orders
)
assert after_orders["trigger"] == before_orders["trigger"]

async def test_update_requires_a_field(self, api_client, kb_bank):
bank_id, ids = kb_bank
resp = await api_client.patch(
Expand Down
14 changes: 12 additions & 2 deletions hindsight-cli/src/commands/knowledge_base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! overlap. `hindsight fs` mirrors the same knowledge base read-only onto disk;
//! these commands are the read/write side.

use anyhow::Result;
use anyhow::{Context, Result};

use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
Expand Down Expand Up @@ -330,6 +330,7 @@ pub fn update(
source_query: Option<String>,
tags: Option<Vec<String>>,
max_tokens: Option<i64>,
trigger: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
Expand All @@ -338,9 +339,10 @@ pub fn update(
&& source_query.is_none()
&& tags.is_none()
&& max_tokens.is_none()
&& trigger.is_none()
{
anyhow::bail!(
"At least one of --name, --parent-id, --source-query, --tags, or --max-tokens must be provided"
"At least one of --name, --parent-id, --source-query, --tags, --max-tokens, or --trigger must be provided"
);
}

Expand All @@ -350,12 +352,20 @@ pub fn update(
None
};

let trigger = trigger
.map(|value| {
serde_json::from_str::<types::MentalModelTriggerInput>(&value)
.context("--trigger must be a valid MentalModelTrigger JSON object")
})
.transpose()?;

let request = types::UpdateNodeRequest {
name,
parent_id,
source_query,
tags,
max_tokens,
trigger,
};

let response = client.update_knowledge_node(bank_id, node_id, &request, verbose);
Expand Down
32 changes: 31 additions & 1 deletion hindsight-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,10 @@ enum KnowledgeBaseCommands {
/// Pages only: new maximum tokens for generated content
#[arg(long)]
max_tokens: Option<i64>,

/// Pages only: replace the complete refresh trigger as a JSON object
#[arg(long, value_name = "JSON")]
trigger: Option<String>,
},

/// Delete a folder or page and its whole subtree
Expand Down Expand Up @@ -1969,6 +1973,7 @@ fn run() -> Result<()> {
source_query,
tags,
max_tokens,
trigger,
} => commands::knowledge_base::update(
&client,
&bank_id,
Expand All @@ -1978,6 +1983,7 @@ fn run() -> Result<()> {
source_query,
tags,
max_tokens,
trigger,
verbose,
output_format,
),
Expand Down Expand Up @@ -2406,7 +2412,7 @@ fn handle_profile(cmd: ProfileCommands, output_format: OutputFormat) -> Result<(

#[cfg(test)]
mod tests {
use super::{Cli, Commands, OperationCommands};
use super::{Cli, Commands, KnowledgeBaseCommands, OperationCommands};
use clap::Parser;

#[test]
Expand Down Expand Up @@ -2434,4 +2440,28 @@ mod tests {
_ => panic!("expected operation delete command"),
}
}

#[test]
fn parses_knowledge_page_update_trigger_json() {
let cli = Cli::try_parse_from([
"hindsight",
"knowledge-base",
"update",
"bank-1",
"page-1",
"--trigger",
r#"{"tag_groups":[{"tags":["knowledge:external"],"match":"any_strict"}]}"#,
])
.expect("knowledge-base update trigger should be a valid command");

match cli.command {
Commands::KnowledgeBase(KnowledgeBaseCommands::Update { trigger, .. }) => {
assert_eq!(
trigger.as_deref(),
Some(r#"{"tag_groups":[{"tags":["knowledge:external"],"match":"any_strict"}]}"#)
);
}
_ => panic!("expected knowledge-base update command"),
}
}
}
Loading