personas: elevate to server with per-persona fact scoping
Move personas off the mobile client into ImageApi as first-class records, and scope entity_facts by persona so each one builds its own voice over a shared entity graph. The new include_all_memories flag lets a persona opt back into the full hive-mind pool for human browsing of /knowledge/*; agentic generation always stays in-voice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,11 @@ pub struct GeneratePhotoInsightRequest {
|
||||
/// falls back to `DEFAULT_FEWSHOT_INSIGHT_IDS`.
|
||||
#[serde(default)]
|
||||
pub fewshot_insight_ids: Option<Vec<i32>>,
|
||||
/// Active persona id for this generation. New facts are tagged with
|
||||
/// it (`entity_facts.persona_id`); recall during the agentic loop is
|
||||
/// scoped to it. Defaults to `"default"` when absent.
|
||||
#[serde(default)]
|
||||
pub persona_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -376,6 +381,13 @@ pub async fn generate_agentic_insight_handler(
|
||||
.collect()
|
||||
};
|
||||
|
||||
let persona_id = request
|
||||
.persona_id
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
span.set_attribute(KeyValue::new("persona_id", persona_id.clone()));
|
||||
|
||||
let result = insight_generator
|
||||
.generate_agentic_insight_for_photo(
|
||||
&normalized_path,
|
||||
@@ -390,6 +402,7 @@ pub async fn generate_agentic_insight_handler(
|
||||
request.backend.clone(),
|
||||
fewshot_examples,
|
||||
fewshot_ids,
|
||||
persona_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -645,6 +658,10 @@ pub struct ChatTurnHttpRequest {
|
||||
/// semantics. Also seeds the bootstrap path when no insight exists.
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
/// Active persona id for this turn. New facts/recalls scope to it.
|
||||
/// Defaults to `"default"` when missing.
|
||||
#[serde(default)]
|
||||
pub persona_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub amend: bool,
|
||||
/// When true, force the bootstrap path even if an insight already
|
||||
@@ -707,6 +724,7 @@ pub async fn chat_turn_handler(
|
||||
min_p: request.min_p,
|
||||
max_iterations: request.max_iterations,
|
||||
system_prompt: request.system_prompt.clone(),
|
||||
persona_id: request.persona_id.clone(),
|
||||
amend: request.amend,
|
||||
regenerate: request.regenerate,
|
||||
};
|
||||
@@ -923,6 +941,7 @@ pub async fn chat_stream_handler(
|
||||
min_p: request.min_p,
|
||||
max_iterations: request.max_iterations,
|
||||
system_prompt: request.system_prompt.clone(),
|
||||
persona_id: request.persona_id.clone(),
|
||||
amend: request.amend,
|
||||
regenerate: request.regenerate,
|
||||
};
|
||||
|
||||
@@ -50,6 +50,10 @@ pub struct ChatTurnRequest {
|
||||
/// In amend mode, persisted into the new insight row's system message.
|
||||
/// None / empty = no change.
|
||||
pub system_prompt: Option<String>,
|
||||
/// Active persona id for this turn. Tools that write to
|
||||
/// `entity_facts` tag the new rows with it; `recall_facts_for_photo`
|
||||
/// scopes its read to it. None defaults to `"default"`.
|
||||
pub persona_id: Option<String>,
|
||||
/// When true, write a new insight row (regenerating title) instead of
|
||||
/// updating training_messages on the existing row.
|
||||
pub amend: bool,
|
||||
@@ -231,6 +235,13 @@ impl InsightChatService {
|
||||
bail!("user_message exceeds 8192 chars");
|
||||
}
|
||||
|
||||
let active_persona = req
|
||||
.persona_id
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
span.set_attribute(KeyValue::new("persona_id", active_persona.clone()));
|
||||
|
||||
let normalized = normalize_path(&req.file_path);
|
||||
|
||||
// 1. Acquire the per-(library, file) async mutex. Two concurrent
|
||||
@@ -464,6 +475,7 @@ impl InsightChatService {
|
||||
&ollama_client,
|
||||
&image_base64,
|
||||
&normalized,
|
||||
&active_persona,
|
||||
&loop_cx,
|
||||
)
|
||||
.await;
|
||||
@@ -737,6 +749,11 @@ impl InsightChatService {
|
||||
insight: crate::database::models::PhotoInsight,
|
||||
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
|
||||
) -> Result<()> {
|
||||
let active_persona = req
|
||||
.persona_id
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let raw_history = insight.training_messages.as_ref().ok_or_else(|| {
|
||||
anyhow!("insight has no chat history; regenerate this insight in agentic mode")
|
||||
})?;
|
||||
@@ -826,6 +843,7 @@ impl InsightChatService {
|
||||
tools,
|
||||
&image_base64,
|
||||
&normalized,
|
||||
&active_persona,
|
||||
max_iterations,
|
||||
&tx,
|
||||
)
|
||||
@@ -915,6 +933,11 @@ impl InsightChatService {
|
||||
normalized: String,
|
||||
tx: tokio::sync::mpsc::Sender<ChatStreamEvent>,
|
||||
) -> Result<()> {
|
||||
let active_persona = req
|
||||
.persona_id
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let effective_backend = resolve_bootstrap_backend(req.backend.as_deref())?;
|
||||
let is_hybrid = effective_backend == "hybrid";
|
||||
|
||||
@@ -1008,6 +1031,7 @@ impl InsightChatService {
|
||||
tools,
|
||||
&image_base64,
|
||||
&normalized,
|
||||
&active_persona,
|
||||
max_iterations,
|
||||
&tx,
|
||||
)
|
||||
@@ -1157,6 +1181,7 @@ impl InsightChatService {
|
||||
tools: Vec<Tool>,
|
||||
image_base64: &Option<String>,
|
||||
normalized: &str,
|
||||
active_persona: &str,
|
||||
max_iterations: usize,
|
||||
tx: &tokio::sync::mpsc::Sender<ChatStreamEvent>,
|
||||
) -> Result<AgenticLoopOutcome> {
|
||||
@@ -1235,6 +1260,7 @@ impl InsightChatService {
|
||||
ollama_client,
|
||||
image_base64,
|
||||
normalized,
|
||||
active_persona,
|
||||
&cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1534,7 +1534,15 @@ Return ONLY the summary, nothing else."#,
|
||||
|
||||
// ── Tool executors for agentic loop ────────────────────────────────
|
||||
|
||||
/// Dispatch a tool call to the appropriate executor
|
||||
/// Dispatch a tool call to the appropriate executor.
|
||||
///
|
||||
/// `persona_id` identifies the persona this loop is generating for —
|
||||
/// `store_fact` tags new facts with it, `recall_facts_for_photo`
|
||||
/// filters reads to it (always Single in the agentic loop, even when
|
||||
/// the persona has `include_all_memories=true`; the hive-mind toggle
|
||||
/// is for human browsing of `/knowledge/*`, where mixing voices is
|
||||
/// the explicit goal — during generation the persona's own voice
|
||||
/// must stay clean).
|
||||
pub(crate) async fn execute_tool(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
@@ -1542,6 +1550,7 @@ Return ONLY the summary, nothing else."#,
|
||||
ollama: &OllamaClient,
|
||||
image_base64: &Option<String>,
|
||||
file_path: &str,
|
||||
persona_id: &str,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> String {
|
||||
let result = match tool_name {
|
||||
@@ -1556,9 +1565,15 @@ Return ONLY the summary, nothing else."#,
|
||||
"reverse_geocode" => self.tool_reverse_geocode(arguments).await,
|
||||
"get_personal_place_at" => self.tool_get_personal_place_at(arguments).await,
|
||||
"recall_entities" => self.tool_recall_entities(arguments, cx).await,
|
||||
"recall_facts_for_photo" => self.tool_recall_facts_for_photo(arguments, cx).await,
|
||||
"recall_facts_for_photo" => {
|
||||
self.tool_recall_facts_for_photo(arguments, persona_id, cx)
|
||||
.await
|
||||
}
|
||||
"store_entity" => self.tool_store_entity(arguments, ollama, cx).await,
|
||||
"store_fact" => self.tool_store_fact(arguments, file_path, cx).await,
|
||||
"store_fact" => {
|
||||
self.tool_store_fact(arguments, file_path, persona_id, cx)
|
||||
.await
|
||||
}
|
||||
"get_current_datetime" => Self::tool_get_current_datetime(),
|
||||
unknown => format!("Unknown tool: {}", unknown),
|
||||
};
|
||||
@@ -2391,8 +2406,11 @@ Return ONLY the summary, nothing else."#,
|
||||
async fn tool_recall_facts_for_photo(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
persona_id: &str,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> String {
|
||||
use crate::database::PersonaFilter;
|
||||
let persona_filter = PersonaFilter::Single(persona_id.to_string());
|
||||
let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
|
||||
Some(p) => p.to_string(),
|
||||
None => return "Error: missing required parameter 'file_path'".to_string(),
|
||||
@@ -2432,7 +2450,7 @@ Return ONLY the summary, nothing else."#,
|
||||
"Entity: {} ({}, role: {})",
|
||||
e.name, e.entity_type, role
|
||||
));
|
||||
if let Ok(facts) = kdao.get_facts_for_entity(cx, entity_id) {
|
||||
if let Ok(facts) = kdao.get_facts_for_entity(cx, entity_id, &persona_filter) {
|
||||
for f in facts.iter().filter(|f| f.status == "active") {
|
||||
let obj = if let Some(ref v) = f.object_value {
|
||||
v.clone()
|
||||
@@ -2577,6 +2595,7 @@ Return ONLY the summary, nothing else."#,
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
file_path: &str,
|
||||
persona_id: &str,
|
||||
cx: &opentelemetry::Context,
|
||||
) -> String {
|
||||
use crate::database::models::{InsertEntityFact, InsertEntityPhotoLink};
|
||||
@@ -2627,6 +2646,7 @@ Return ONLY the summary, nothing else."#,
|
||||
confidence: 0.6,
|
||||
status: "active".to_string(),
|
||||
created_at: chrono::Utc::now().timestamp(),
|
||||
persona_id: persona_id.to_string(),
|
||||
};
|
||||
|
||||
let mut kdao = self
|
||||
@@ -3176,6 +3196,7 @@ Return ONLY the summary, nothing else."#,
|
||||
backend: Option<String>,
|
||||
fewshot_examples: Vec<Vec<ChatMessage>>,
|
||||
fewshot_source_ids: Vec<i32>,
|
||||
persona_id: String,
|
||||
) -> Result<(Option<i32>, Option<i32>)> {
|
||||
let tracer = global_tracer();
|
||||
let current_cx = opentelemetry::Context::current();
|
||||
@@ -3652,6 +3673,7 @@ Return ONLY the summary, nothing else."#,
|
||||
&ollama_client,
|
||||
&image_base64,
|
||||
&file_path,
|
||||
&persona_id,
|
||||
&loop_cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
Reference in New Issue
Block a user