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())
|
||||
|
||||
@@ -49,6 +49,7 @@ pub mod insights_dao;
|
||||
pub mod knowledge_dao;
|
||||
pub mod location_dao;
|
||||
pub mod models;
|
||||
pub mod persona_dao;
|
||||
pub mod preview_dao;
|
||||
pub mod reconcile;
|
||||
pub mod schema;
|
||||
@@ -58,10 +59,11 @@ pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao};
|
||||
pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao};
|
||||
pub use insights_dao::{InsightDao, SqliteInsightDao};
|
||||
pub use knowledge_dao::{
|
||||
EntityFilter, EntityPatch, FactFilter, FactPatch, KnowledgeDao, RecentActivity,
|
||||
EntityFilter, EntityPatch, FactFilter, FactPatch, KnowledgeDao, PersonaFilter, RecentActivity,
|
||||
SqliteKnowledgeDao,
|
||||
};
|
||||
pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao};
|
||||
pub use persona_dao::{ImportPersona, PersonaDao, PersonaPatch, SqlitePersonaDao};
|
||||
pub use preview_dao::{PreviewDao, SqlitePreviewDao};
|
||||
pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::database::schema::{
|
||||
entities, entity_facts, entity_photo_links, favorites, image_exif, libraries, photo_insights,
|
||||
users, video_preview_clips,
|
||||
entities, entity_facts, entity_photo_links, favorites, image_exif, libraries, personas,
|
||||
photo_insights, users, video_preview_clips,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -238,6 +238,11 @@ pub struct InsertEntityFact {
|
||||
pub confidence: f32,
|
||||
pub status: String,
|
||||
pub created_at: i64,
|
||||
/// Which persona authored this fact. Shared entities, persona-tagged
|
||||
/// facts: each persona accumulates its own voice over the same
|
||||
/// real-world referents. Defaults to `'default'` for legacy rows
|
||||
/// (see migration 2026-05-09-000000).
|
||||
pub persona_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Queryable, Clone, Debug)]
|
||||
@@ -252,6 +257,7 @@ pub struct EntityFact {
|
||||
pub confidence: f32,
|
||||
pub status: String,
|
||||
pub created_at: i64,
|
||||
pub persona_id: String,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
@@ -274,6 +280,34 @@ pub struct EntityPhotoLink {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
// --- Personas ---
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = personas)]
|
||||
pub struct InsertPersona<'a> {
|
||||
pub user_id: i32,
|
||||
pub persona_id: &'a str,
|
||||
pub name: &'a str,
|
||||
pub system_prompt: &'a str,
|
||||
pub is_built_in: bool,
|
||||
pub include_all_memories: bool,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Queryable, Clone, Debug)]
|
||||
pub struct Persona {
|
||||
pub id: i32,
|
||||
pub user_id: i32,
|
||||
pub persona_id: String,
|
||||
pub name: String,
|
||||
pub system_prompt: String,
|
||||
pub is_built_in: bool,
|
||||
pub include_all_memories: bool,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = video_preview_clips)]
|
||||
pub struct InsertVideoPreviewClip {
|
||||
|
||||
384
src/database/persona_dao.rs
Normal file
384
src/database/persona_dao.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::database::models::{InsertPersona, Persona};
|
||||
use crate::database::schema;
|
||||
use crate::database::{DbError, DbErrorKind, connect};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
/// Patch shape for update_persona. None = leave field alone. Built-ins are
|
||||
/// allowed to flip `include_all_memories` but should reject name/prompt
|
||||
/// edits at the handler layer (built-in copy lives in the migration).
|
||||
pub struct PersonaPatch {
|
||||
pub name: Option<String>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub include_all_memories: Option<bool>,
|
||||
}
|
||||
|
||||
/// One row of a bulk migration upload. Fields named to match the JSON
|
||||
/// shape the mobile client uploads (`POST /personas/migrate`).
|
||||
pub struct ImportPersona {
|
||||
pub persona_id: String,
|
||||
pub name: String,
|
||||
pub system_prompt: String,
|
||||
pub is_built_in: bool,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
pub trait PersonaDao: Sync + Send {
|
||||
fn list_personas(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
user_id: i32,
|
||||
) -> Result<Vec<Persona>, DbError>;
|
||||
|
||||
fn get_persona(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
) -> Result<Option<Persona>, DbError>;
|
||||
|
||||
fn create_persona(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
name: &str,
|
||||
system_prompt: &str,
|
||||
is_built_in: bool,
|
||||
include_all_memories: bool,
|
||||
) -> Result<Persona, DbError>;
|
||||
|
||||
fn update_persona(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
patch: PersonaPatch,
|
||||
) -> Result<Option<Persona>, DbError>;
|
||||
|
||||
fn delete_persona(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
user_id: i32,
|
||||
persona_id: &str,
|
||||
) -> Result<bool, DbError>;
|
||||
|
||||
/// Idempotent bulk import. INSERT OR IGNORE on (user_id, persona_id)
|
||||
/// — re-uploading the same set is a no-op. Returns the number of rows
|
||||
/// actually inserted (skipped duplicates don't count).
|
||||
fn bulk_import(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
user_id: i32,
|
||||
personas: &[ImportPersona],
|
||||
) -> Result<usize, DbError>;
|
||||
}
|
||||
|
||||
pub struct SqlitePersonaDao {
|
||||
connection: Arc<Mutex<SqliteConnection>>,
|
||||
}
|
||||
|
||||
impl Default for SqlitePersonaDao {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SqlitePersonaDao {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connection: Arc::new(Mutex::new(connect())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
|
||||
Self { connection: conn }
|
||||
}
|
||||
}
|
||||
|
||||
impl PersonaDao for SqlitePersonaDao {
|
||||
fn list_personas(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
uid: i32,
|
||||
) -> Result<Vec<Persona>, DbError> {
|
||||
trace_db_call(cx, "query", "list_personas", |_span| {
|
||||
use schema::personas::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
personas
|
||||
.filter(user_id.eq(uid))
|
||||
.order(created_at.asc())
|
||||
.load::<Persona>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_persona(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
uid: i32,
|
||||
pid: &str,
|
||||
) -> Result<Option<Persona>, DbError> {
|
||||
trace_db_call(cx, "query", "get_persona", |_span| {
|
||||
use schema::personas::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
personas
|
||||
.filter(user_id.eq(uid))
|
||||
.filter(persona_id.eq(pid))
|
||||
.first::<Persona>(conn.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn create_persona(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
uid: i32,
|
||||
pid: &str,
|
||||
nm: &str,
|
||||
prompt: &str,
|
||||
builtin: bool,
|
||||
include_all: bool,
|
||||
) -> Result<Persona, DbError> {
|
||||
trace_db_call(cx, "insert", "create_persona", |_span| {
|
||||
use schema::personas::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
diesel::insert_into(personas)
|
||||
.values(InsertPersona {
|
||||
user_id: uid,
|
||||
persona_id: pid,
|
||||
name: nm,
|
||||
system_prompt: prompt,
|
||||
is_built_in: builtin,
|
||||
include_all_memories: include_all,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
||||
|
||||
personas
|
||||
.filter(user_id.eq(uid))
|
||||
.filter(persona_id.eq(pid))
|
||||
.first::<Persona>(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn update_persona(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
uid: i32,
|
||||
pid: &str,
|
||||
patch: PersonaPatch,
|
||||
) -> Result<Option<Persona>, DbError> {
|
||||
trace_db_call(cx, "update", "update_persona", |_span| {
|
||||
use schema::personas::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
// Apply each field as its own UPDATE — keeps types simple
|
||||
// (Diesel's tuple updates don't compose cleanly across optional
|
||||
// columns) and matches the pattern already in use for entities
|
||||
// (knowledge_dao.rs::update_entity).
|
||||
if let Some(ref new_name) = patch.name {
|
||||
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
||||
.set((name.eq(new_name), updated_at.eq(now)))
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Update name error: {}", e))?;
|
||||
}
|
||||
if let Some(ref new_prompt) = patch.system_prompt {
|
||||
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
||||
.set((system_prompt.eq(new_prompt), updated_at.eq(now)))
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Update prompt error: {}", e))?;
|
||||
}
|
||||
if let Some(new_include_all) = patch.include_all_memories {
|
||||
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
||||
.set((include_all_memories.eq(new_include_all), updated_at.eq(now)))
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Update include_all error: {}", e))?;
|
||||
}
|
||||
|
||||
personas
|
||||
.filter(user_id.eq(uid))
|
||||
.filter(persona_id.eq(pid))
|
||||
.first::<Persona>(conn.deref_mut())
|
||||
.optional()
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn delete_persona(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
uid: i32,
|
||||
pid: &str,
|
||||
) -> Result<bool, DbError> {
|
||||
trace_db_call(cx, "delete", "delete_persona", |_span| {
|
||||
use schema::personas::dsl::*;
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
||||
Ok(n > 0)
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn bulk_import(
|
||||
&mut self,
|
||||
cx: &opentelemetry::Context,
|
||||
uid: i32,
|
||||
rows: &[ImportPersona],
|
||||
) -> Result<usize, DbError> {
|
||||
trace_db_call(cx, "insert", "bulk_import_personas", |_span| {
|
||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let mut inserted = 0usize;
|
||||
|
||||
// INSERT OR IGNORE on the (user_id, persona_id) UNIQUE so
|
||||
// re-running migrate is a no-op for personas already on the
|
||||
// server.
|
||||
for p in rows {
|
||||
let n = diesel::sql_query(
|
||||
"INSERT OR IGNORE INTO personas (user_id, persona_id, name, system_prompt, \
|
||||
is_built_in, include_all_memories, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
)
|
||||
.bind::<diesel::sql_types::Integer, _>(uid)
|
||||
.bind::<diesel::sql_types::Text, _>(&p.persona_id)
|
||||
.bind::<diesel::sql_types::Text, _>(&p.name)
|
||||
.bind::<diesel::sql_types::Text, _>(&p.system_prompt)
|
||||
.bind::<diesel::sql_types::Bool, _>(p.is_built_in)
|
||||
.bind::<diesel::sql_types::BigInt, _>(p.created_at)
|
||||
.bind::<diesel::sql_types::BigInt, _>(now)
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
||||
inserted += n;
|
||||
}
|
||||
Ok(inserted)
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::test::in_memory_db_connection;
|
||||
|
||||
fn dao_with_user(username: &str) -> (SqlitePersonaDao, i32) {
|
||||
use crate::database::schema::users::dsl as u;
|
||||
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
|
||||
diesel::insert_into(u::users)
|
||||
.values((u::username.eq(username), u::password.eq("x")))
|
||||
.execute(conn.lock().unwrap().deref_mut())
|
||||
.unwrap();
|
||||
let user_id: i32 = u::users
|
||||
.filter(u::username.eq(username))
|
||||
.select(u::id)
|
||||
.first(conn.lock().unwrap().deref_mut())
|
||||
.unwrap();
|
||||
(SqlitePersonaDao::from_connection(conn), user_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_list_round_trip() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (mut dao, uid) = dao_with_user("alice");
|
||||
|
||||
// The migration seeds 3 built-ins for any existing user; alice
|
||||
// was created post-migration so she starts empty.
|
||||
let p = dao
|
||||
.create_persona(&cx, uid, "custom-1", "Custom A", "prompt A", false, false)
|
||||
.unwrap();
|
||||
assert_eq!(p.persona_id, "custom-1");
|
||||
assert_eq!(p.user_id, uid);
|
||||
assert!(!p.is_built_in);
|
||||
|
||||
let list = dao.list_personas(&cx, uid).unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].persona_id, "custom-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_constraint_blocks_duplicate_persona_id() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (mut dao, uid) = dao_with_user("bob");
|
||||
|
||||
dao.create_persona(&cx, uid, "x", "X", "p", false, false)
|
||||
.unwrap();
|
||||
let err = dao.create_persona(&cx, uid, "x", "X2", "p2", false, false);
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"second insert with same persona_id should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_import_is_idempotent() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (mut dao, uid) = dao_with_user("carol");
|
||||
|
||||
let rows = vec![
|
||||
ImportPersona {
|
||||
persona_id: "custom-a".into(),
|
||||
name: "A".into(),
|
||||
system_prompt: "p1".into(),
|
||||
is_built_in: false,
|
||||
created_at: 1,
|
||||
},
|
||||
ImportPersona {
|
||||
persona_id: "custom-b".into(),
|
||||
name: "B".into(),
|
||||
system_prompt: "p2".into(),
|
||||
is_built_in: false,
|
||||
created_at: 2,
|
||||
},
|
||||
];
|
||||
|
||||
let first = dao.bulk_import(&cx, uid, &rows).unwrap();
|
||||
assert_eq!(first, 2);
|
||||
let second = dao.bulk_import(&cx, uid, &rows).unwrap();
|
||||
assert_eq!(second, 0, "re-import should insert nothing");
|
||||
|
||||
assert_eq!(dao.list_personas(&cx, uid).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_toggles_include_all_memories() {
|
||||
let cx = opentelemetry::Context::new();
|
||||
let (mut dao, uid) = dao_with_user("dan");
|
||||
|
||||
dao.create_persona(&cx, uid, "j", "Journal", "p", true, false)
|
||||
.unwrap();
|
||||
let updated = dao
|
||||
.update_persona(
|
||||
&cx,
|
||||
uid,
|
||||
"j",
|
||||
PersonaPatch {
|
||||
name: None,
|
||||
system_prompt: None,
|
||||
include_all_memories: Some(true),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(updated.include_all_memories);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ diesel::table! {
|
||||
confidence -> Float,
|
||||
status -> Text,
|
||||
created_at -> BigInt,
|
||||
persona_id -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +160,20 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
personas (id) {
|
||||
id -> Integer,
|
||||
user_id -> Integer,
|
||||
persona_id -> Text,
|
||||
name -> Text,
|
||||
system_prompt -> Text,
|
||||
is_built_in -> Bool,
|
||||
include_all_memories -> Bool,
|
||||
created_at -> BigInt,
|
||||
updated_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
persons (id) {
|
||||
id -> Integer,
|
||||
@@ -249,6 +264,7 @@ diesel::joinable!(entity_photo_links -> libraries (library_id));
|
||||
diesel::joinable!(face_detections -> libraries (library_id));
|
||||
diesel::joinable!(face_detections -> persons (person_id));
|
||||
diesel::joinable!(image_exif -> libraries (library_id));
|
||||
diesel::joinable!(personas -> users (user_id));
|
||||
diesel::joinable!(persons -> entities (entity_id));
|
||||
diesel::joinable!(photo_insights -> libraries (library_id));
|
||||
diesel::joinable!(tagged_photo -> tags (tag_id));
|
||||
@@ -265,6 +281,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
image_exif,
|
||||
libraries,
|
||||
location_history,
|
||||
personas,
|
||||
persons,
|
||||
photo_insights,
|
||||
search_history,
|
||||
|
||||
Reference in New Issue
Block a user