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:
@@ -50,10 +50,21 @@ pub struct FactFilter {
|
||||
/// "active" | "reviewed" | "rejected" | "all"
|
||||
pub status: Option<String>,
|
||||
pub predicate: Option<String>,
|
||||
pub persona: PersonaFilter,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
/// Persona scoping for fact reads. `Single` filters to one persona's
|
||||
/// view; `All` is the hive-mind read used when a persona has
|
||||
/// `include_all_memories=true` in the personas table. Entities and
|
||||
/// photo-links are always shared and don't take a persona filter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PersonaFilter {
|
||||
Single(String),
|
||||
All,
|
||||
}
|
||||
|
||||
pub struct EntityPatch {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
@@ -144,6 +155,7 @@ pub trait KnowledgeDao: Sync + Send {
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
entity_id: i32,
|
||||
persona: &PersonaFilter,
|
||||
) -> Result<Vec<EntityFact>, DbError>;
|
||||
|
||||
fn list_facts(
|
||||
@@ -199,6 +211,7 @@ pub trait KnowledgeDao: Sync + Send {
|
||||
cx: &opentelemetry::Context,
|
||||
since: i64,
|
||||
limit: i64,
|
||||
persona: &PersonaFilter,
|
||||
) -> Result<RecentActivity, DbError>;
|
||||
}
|
||||
|
||||
@@ -584,10 +597,14 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
use schema::entity_facts::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("KnowledgeDao lock");
|
||||
|
||||
// Look for an identical active fact
|
||||
// Look for an identical active fact AUTHORED BY THE SAME
|
||||
// PERSONA. The same claim from a different persona is a
|
||||
// separate fact (each persona's voice/confidence is its own),
|
||||
// not a confidence bump on someone else's row.
|
||||
let mut dup_query = entity_facts
|
||||
.filter(subject_entity_id.eq(fact.subject_entity_id))
|
||||
.filter(predicate.eq(&fact.predicate))
|
||||
.filter(persona_id.eq(&fact.persona_id))
|
||||
.filter(status.ne("rejected"))
|
||||
.into_boxed();
|
||||
|
||||
@@ -640,14 +657,19 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
entity_id: i32,
|
||||
persona: &PersonaFilter,
|
||||
) -> Result<Vec<EntityFact>, DbError> {
|
||||
trace_db_call(cx, "query", "get_facts_for_entity", |_span| {
|
||||
use schema::entity_facts::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("KnowledgeDao lock");
|
||||
entity_facts
|
||||
let mut q = entity_facts
|
||||
.filter(subject_entity_id.eq(entity_id))
|
||||
.filter(status.ne("rejected"))
|
||||
.load::<EntityFact>(conn.deref_mut())
|
||||
.into_boxed();
|
||||
if let PersonaFilter::Single(pid) = persona {
|
||||
q = q.filter(persona_id.eq(pid.clone()));
|
||||
}
|
||||
q.load::<EntityFact>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
@@ -664,19 +686,27 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
let mut conn = self.connection.lock().expect("KnowledgeDao lock");
|
||||
|
||||
let mut query = entity_facts.into_boxed();
|
||||
let mut count_query = entity_facts.into_boxed();
|
||||
|
||||
if let Some(eid) = filter.entity_id {
|
||||
query = query.filter(subject_entity_id.eq(eid));
|
||||
count_query = count_query.filter(subject_entity_id.eq(eid));
|
||||
}
|
||||
let status_val = filter.status.as_deref().unwrap_or("active");
|
||||
if status_val != "all" {
|
||||
query = query.filter(status.eq(status_val));
|
||||
count_query = count_query.filter(status.eq(status_val));
|
||||
}
|
||||
if let Some(ref pred) = filter.predicate {
|
||||
query = query.filter(predicate.eq(pred));
|
||||
count_query = count_query.filter(predicate.eq(pred));
|
||||
}
|
||||
if let PersonaFilter::Single(ref pid) = filter.persona {
|
||||
query = query.filter(persona_id.eq(pid.clone()));
|
||||
count_query = count_query.filter(persona_id.eq(pid.clone()));
|
||||
}
|
||||
|
||||
let total: i64 = entity_facts
|
||||
let total: i64 = count_query
|
||||
.select(count_star())
|
||||
.first(conn.deref_mut())
|
||||
.unwrap_or(0);
|
||||
@@ -854,12 +884,14 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
cx: &opentelemetry::Context,
|
||||
since: i64,
|
||||
limit: i64,
|
||||
persona: &PersonaFilter,
|
||||
) -> Result<RecentActivity, DbError> {
|
||||
trace_db_call(cx, "query", "get_recent_activity", |_span| {
|
||||
use schema::entities::dsl as e;
|
||||
use schema::entity_facts::dsl as ef;
|
||||
let mut conn = self.connection.lock().expect("KnowledgeDao lock");
|
||||
|
||||
// Entities are shared — recency is global.
|
||||
let recent_entities = e::entities
|
||||
.filter(e::created_at.gt(since))
|
||||
.order(e::created_at.desc())
|
||||
@@ -867,8 +899,13 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.load::<Entity>(conn.deref_mut())
|
||||
.map_err(|err| anyhow::anyhow!("Query error: {}", err))?;
|
||||
|
||||
let recent_facts = ef::entity_facts
|
||||
let mut facts_q = ef::entity_facts
|
||||
.filter(ef::created_at.gt(since))
|
||||
.into_boxed();
|
||||
if let PersonaFilter::Single(pid) = persona {
|
||||
facts_q = facts_q.filter(ef::persona_id.eq(pid.clone()));
|
||||
}
|
||||
let recent_facts = facts_q
|
||||
.order(ef::created_at.desc())
|
||||
.limit(limit)
|
||||
.load::<EntityFact>(conn.deref_mut())
|
||||
|
||||
Reference in New Issue
Block a user