mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
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:
Executable
+189
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user