feat: tree summarizer uses local AI exclusively (#518)

* refactor: update module visibility and enhance summarization logic

- Changed the visibility of the `ollama_api` module to `pub(crate)` to restrict its access to the current crate, improving encapsulation.
- Refactored the `run_summarization` and `propagate_node` functions to pass the chat model ID from the configuration, ensuring consistent usage of the model across summarization tasks.
- Enhanced the `summarize_to_limit` function to accept the model parameter, allowing for more flexible and dynamic summarization based on the specified model.
- Updated the `create_provider` function to enforce the requirement of enabling local AI for the tree summarizer, ensuring proper configuration before execution.

* fix: update Ollama provider credential handling

- Changed the credential parameter in the `create_local_ai_provider` function from `None` to `Some("ollama")`, clarifying that while Ollama does not require authentication, a non-None credential is necessary for the provider's configuration.

* feat: add tree summarization script for memory namespaces

- Introduced a new script `tree-summarizer-run-all.sh` that automates the process of running tree summarization across all memory namespaces.
- The script discovers namespaces from the specified workspace and supports various commands including `run`, `status`, `query`, and `rebuild`.
- Enhanced error handling and logging for improved usability and debugging during summarization tasks.

* style: apply cargo fmt to tree summarizer

* refactor: improve argument handling and output summary in tree summarizer script

- Refactored the argument construction in `tree-summarizer-run-all.sh` to use an array for better handling of command-line arguments.
- Enhanced the output summary to provide a clearer report of succeeded and failed namespace processing.
- Updated the while loop to read from `NAMESPACES` directly, improving readability and efficiency.

* fix: address PR review — array args, subshell loop, retry wrapper, narrow export

- Script: use bash array for args instead of string to prevent word-splitting
- Script: use here-string loop so SUCCEEDED/FAILED propagate to parent shell
- ops.rs: wrap Ollama provider in ReliableProvider for retry/backoff
- local_ai/mod.rs: keep ollama_api private, re-export only OLLAMA_BASE_URL
This commit is contained in:
Steven Enamakel
2026-04-12 18:05:58 -07:00
committed by GitHub
parent 172cd0da4c
commit 01a948c18d
4 changed files with 269 additions and 23 deletions
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# tree-summarizer-run-all.sh — Run tree summarization for every memory namespace.
#
# Discovers namespaces by listing directories under the workspace's
# memory/namespaces/ folder, then runs the tree-summarizer for each one.
#
# Usage:
# bash scripts/tree-summarizer-run-all.sh # run (drain buffer + summarize)
# bash scripts/tree-summarizer-run-all.sh status # show status for all trees
# bash scripts/tree-summarizer-run-all.sh query [node_id] # query all trees
# bash scripts/tree-summarizer-run-all.sh rebuild # rebuild all trees from leaves
#
# Options:
# -v, --verbose Enable debug logging
# --workspace DIR Override OPENHUMAN_WORKSPACE
# --binary PATH Override the openhuman-core binary path
set -euo pipefail
# ── Defaults ───────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERBOSE=""
SUBCOMMAND="run"
NODE_ID=""
# Resolve binary: staged sidecar → debug build → release build
resolve_binary() {
local arch
arch="$(uname -m)"
case "$arch" in
arm64|aarch64) arch="aarch64-apple-darwin" ;;
x86_64) arch="x86_64-apple-darwin" ;;
*) arch="$arch-unknown-linux-gnu" ;;
esac
for bin in \
"$REPO_ROOT/app/src-tauri/binaries/openhuman-core-$arch" \
"$REPO_ROOT/target/debug/openhuman-core" \
"$REPO_ROOT/target/release/openhuman-core"; do
if [ -x "$bin" ]; then
echo "$bin"
return
fi
done
echo >&2 "error: could not find openhuman-core binary. Build with: cargo build --bin openhuman-core"
exit 1
}
OPENHUMAN_BIN="${OPENHUMAN_BIN:-$(resolve_binary)}"
# Resolve workspace: env var → active user → first user dir
resolve_workspace() {
if [ -n "${OPENHUMAN_WORKSPACE:-}" ]; then
echo "$OPENHUMAN_WORKSPACE"
return
fi
# Try the active user workspace
local active_user_file="$HOME/.openhuman/active_user.toml"
if [ -f "$active_user_file" ]; then
local user_id
user_id=$(sed -n 's/^user_id *= *"\([^"]*\)".*/\1/p' "$active_user_file" 2>/dev/null || true)
if [ -n "$user_id" ] && [ -d "$HOME/.openhuman/users/$user_id/workspace" ]; then
echo "$HOME/.openhuman/users/$user_id/workspace"
return
fi
fi
# Fallback: first user directory with a workspace
for user_dir in "$HOME"/.openhuman/users/*/; do
if [ -d "${user_dir}workspace" ]; then
echo "${user_dir}workspace"
return
fi
done
echo >&2 "error: could not resolve OPENHUMAN_WORKSPACE. Set it explicitly."
exit 1
}
export OPENHUMAN_WORKSPACE="${OPENHUMAN_WORKSPACE:-$(resolve_workspace)}"
# ── Parse args ─────────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
-v|--verbose)
VERBOSE="-v"
shift
;;
--workspace)
export OPENHUMAN_WORKSPACE="$2"
shift 2
;;
--binary)
OPENHUMAN_BIN="$2"
shift 2
;;
run|status|query|rebuild)
SUBCOMMAND="$1"
shift
# For query, grab optional node_id
if [ "$SUBCOMMAND" = "query" ] && [ $# -gt 0 ]; then
case "$1" in
-*) ;; # skip flags
*) NODE_ID="$1"; shift ;;
esac
fi
;;
-h|--help)
sed -n '2,/^$/{ s/^# //; s/^#$//; p }' "$0"
exit 0
;;
*)
echo >&2 "unknown argument: $1"
exit 1
;;
esac
done
# ── Discover namespaces ────────────────────────────────────────────────
NAMESPACES_DIR="$OPENHUMAN_WORKSPACE/memory/namespaces"
if [ ! -d "$NAMESPACES_DIR" ]; then
echo "No namespaces directory found at $NAMESPACES_DIR"
exit 0
fi
NAMESPACES=$(find "$NAMESPACES_DIR" -mindepth 1 -maxdepth 1 -type d | while read -r d; do basename "$d"; done | sort)
if [ -z "$NAMESPACES" ]; then
echo "No memory namespaces found."
exit 0
fi
NS_COUNT=$(echo "$NAMESPACES" | wc -l | tr -d ' ')
NS_LIST=$(echo "$NAMESPACES" | tr '\n' ' ')
echo "Found $NS_COUNT namespace(s): $NS_LIST"
echo "Workspace: $OPENHUMAN_WORKSPACE"
echo "Binary: $OPENHUMAN_BIN"
echo "Command: tree-summarizer $SUBCOMMAND"
echo "---"
# ── Strip ASCII art banner from output ─────────────────────────────────
strip_banner() {
grep -v '▗\|▐\|▝\|▀\|█\|Contribute\|OpenHuman core' | grep -v '^[[:space:]]*$'
}
# ── Run for each namespace ─────────────────────────────────────────────
FAILED=0
SUCCEEDED=0
while IFS= read -r ns; do
echo ""
echo "=== [$ns] ==="
args=("$SUBCOMMAND" "$ns")
if [ "$SUBCOMMAND" = "query" ] && [ -n "$NODE_ID" ]; then
args+=("$NODE_ID")
fi
if [ -n "$VERBOSE" ]; then
args+=("$VERBOSE")
fi
if output=$("$OPENHUMAN_BIN" tree-summarizer "${args[@]}" 2>&1); then
echo "$output" | strip_banner | head -40
SUCCEEDED=$((SUCCEEDED + 1))
else
echo "$output" | strip_banner | tail -5
echo " ^^^ FAILED"
FAILED=$((FAILED + 1))
fi
done <<< "$NAMESPACES"
echo ""
echo "---"
echo "Done. $SUCCEEDED succeeded, $FAILED failed out of $NS_COUNT namespace(s)."
if [ "$FAILED" -gt 0 ]; then
exit 1
fi
+1
View File
@@ -15,6 +15,7 @@ pub mod sentiment;
mod install;
pub(crate) mod model_ids;
mod ollama_api;
pub(crate) use ollama_api::OLLAMA_BASE_URL;
mod parse;
pub(crate) mod paths;
mod service;
+37 -16
View File
@@ -14,8 +14,6 @@ use crate::openhuman::tree_summarizer::types::{
TreeStatus,
};
/// The model hint passed to the provider for summarization tasks.
const SUMMARIZATION_MODEL: &str = "hint:fast";
const SUMMARIZATION_TEMP: f64 = 0.3;
/// Maximum characters for a summary response (hard limit enforced after LLM call).
@@ -81,12 +79,14 @@ pub async fn run_summarization(
combined
};
let model = &config.local_ai.chat_model_id;
let hour_summary = summarize_to_limit(
provider,
&to_summarize,
NodeLevel::Hour.max_tokens(),
"hour",
hour_id,
model,
)
.await
.context("summarize hour leaf")?;
@@ -138,9 +138,16 @@ pub async fn run_summarization(
] {
for (node_id, node_level) in &all_propagation_ids {
if *node_level == level && seen.insert(node_id.clone()) {
propagate_node(config, provider, namespace, node_id, level)
.await
.with_context(|| format!("propagate {node_id}"))?;
propagate_node(
config,
provider,
namespace,
node_id,
level,
&config.local_ai.chat_model_id,
)
.await
.with_context(|| format!("propagate {node_id}"))?;
}
}
}
@@ -233,16 +240,25 @@ pub async fn rebuild_tree(
}
// Propagate bottom-up: days, then months, then years, then root
let model = &config.local_ai.chat_model_id;
for day_id in &day_ids {
propagate_node(config, provider, namespace, day_id, NodeLevel::Day).await?;
propagate_node(config, provider, namespace, day_id, NodeLevel::Day, model).await?;
}
for month_id in &month_ids {
propagate_node(config, provider, namespace, month_id, NodeLevel::Month).await?;
propagate_node(
config,
provider,
namespace,
month_id,
NodeLevel::Month,
model,
)
.await?;
}
for year_id in &year_ids {
propagate_node(config, provider, namespace, year_id, NodeLevel::Year).await?;
propagate_node(config, provider, namespace, year_id, NodeLevel::Year, model).await?;
}
propagate_node(config, provider, namespace, "root", NodeLevel::Root).await?;
propagate_node(config, provider, namespace, "root", NodeLevel::Root, model).await?;
let final_status = store::get_tree_status(config, namespace)?;
@@ -268,6 +284,7 @@ async fn propagate_node(
namespace: &str,
node_id: &str,
level: NodeLevel,
model: &str,
) -> Result<()> {
let children = store::read_children(config, namespace, node_id)?;
if children.is_empty() {
@@ -305,7 +322,15 @@ async fn propagate_node(
combined_tokens,
max_tokens
);
summarize_to_limit(provider, &combined, max_tokens, level.as_str(), node_id).await?
summarize_to_limit(
provider,
&combined,
max_tokens,
level.as_str(),
node_id,
model,
)
.await?
};
let now = Utc::now();
@@ -351,6 +376,7 @@ async fn summarize_to_limit(
max_tokens: u32,
level_name: &str,
node_id: &str,
model: &str,
) -> Result<String> {
let max_chars = (max_tokens as usize) * 4;
let system_prompt = format!(
@@ -365,12 +391,7 @@ async fn summarize_to_limit(
);
let response = provider
.chat_with_system(
Some(&system_prompt),
content,
SUMMARIZATION_MODEL,
SUMMARIZATION_TEMP,
)
.chat_with_system(Some(&system_prompt), content, model, SUMMARIZATION_TEMP)
.await
.with_context(|| {
format!("LLM summarization failed for node {node_id} (level={level_name})")
+42 -7
View File
@@ -4,7 +4,6 @@ use chrono::{DateTime, Utc};
use serde_json::{json, Value};
use crate::openhuman::config::Config;
use crate::openhuman::providers;
use crate::openhuman::tree_summarizer::{engine, store, types::*};
use crate::rpc::RpcOutcome;
@@ -148,10 +147,46 @@ pub async fn tree_summarizer_rebuild(
fn create_provider(
config: &Config,
) -> Result<Box<dyn crate::openhuman::providers::traits::Provider>, String> {
providers::create_resilient_provider(
config.api_key.as_deref(),
config.api_url.as_deref(),
&config.reliability,
)
.map_err(|e| format!("failed to create provider: {e}"))
// Tree summarization runs exclusively on local AI to keep memory
// processing private and offline — no backend calls.
if !config.local_ai.enabled {
return Err("tree summarizer requires local_ai to be enabled in config".to_string());
}
create_local_ai_provider(config)
}
/// Create a provider backed by the local Ollama instance for summarization,
/// wrapped in `ReliableProvider` for retry/backoff on transient failures.
fn create_local_ai_provider(
config: &Config,
) -> Result<Box<dyn crate::openhuman::providers::traits::Provider>, String> {
use crate::openhuman::local_ai::OLLAMA_BASE_URL;
use crate::openhuman::providers::compatible::{AuthStyle, OpenAiCompatibleProvider};
use crate::openhuman::providers::reliable::ReliableProvider;
let base_url = format!("{}/v1", OLLAMA_BASE_URL);
let inner = OpenAiCompatibleProvider::new_no_responses_fallback(
"ollama-local",
&base_url,
Some("ollama"), // Ollama ignores auth but the provider requires a non-None credential
AuthStyle::Bearer,
);
let providers: Vec<(
String,
Box<dyn crate::openhuman::providers::traits::Provider>,
)> = vec![("ollama-local".to_string(), Box::new(inner))];
let reliable = ReliableProvider::new(
providers,
config.reliability.provider_retries,
config.reliability.provider_backoff_ms,
);
tracing::debug!(
"[tree_summarizer] using local Ollama provider at {} with model '{}'",
base_url,
config.local_ai.chat_model_id
);
Ok(Box::new(reliable))
}