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:
309
src/personas.rs
Normal file
309
src/personas.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
//! HTTP handlers for the server-side persona store.
|
||||
//!
|
||||
//! Personas previously lived only in mobile AsyncStorage; this module
|
||||
//! elevates them so they can sync across devices and so the
|
||||
//! `entity_facts.persona_id` column has something to reference.
|
||||
//!
|
||||
//! Built-in personas (default / journal / factual) are seeded by the
|
||||
//! migration. Customs are created here and may be migrated up from a
|
||||
//! device's local store via `POST /personas/migrate`.
|
||||
|
||||
use actix_web::dev::{ServiceFactory, ServiceRequest};
|
||||
use actix_web::{App, HttpResponse, Responder, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::data::Claims;
|
||||
use crate::database::models::Persona;
|
||||
use crate::database::{ImportPersona, PersonaDao, PersonaPatch};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire shapes — camelCase out the door, snake_case from the DB.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PersonaView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "systemPrompt")]
|
||||
pub system_prompt: String,
|
||||
#[serde(rename = "isBuiltIn")]
|
||||
pub is_built_in: bool,
|
||||
#[serde(rename = "includeAllMemories")]
|
||||
pub include_all_memories: bool,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: i64,
|
||||
#[serde(rename = "updatedAt")]
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl From<Persona> for PersonaView {
|
||||
fn from(p: Persona) -> Self {
|
||||
Self {
|
||||
id: p.persona_id,
|
||||
name: p.name,
|
||||
system_prompt: p.system_prompt,
|
||||
is_built_in: p.is_built_in,
|
||||
include_all_memories: p.include_all_memories,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePersonaRequest {
|
||||
pub name: String,
|
||||
#[serde(rename = "systemPrompt")]
|
||||
pub system_prompt: String,
|
||||
/// Optional caller-provided id. When present (e.g. a client that
|
||||
/// already minted `"custom-1735124234"` locally and is upgrading from
|
||||
/// the AsyncStorage-only era), the server uses it; collisions return
|
||||
/// 409. When absent the server mints `"custom-<ms>"`.
|
||||
#[serde(default, rename = "personaId")]
|
||||
pub persona_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdatePersonaRequest {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, rename = "systemPrompt")]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default, rename = "includeAllMemories")]
|
||||
pub include_all_memories: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MigrateRequest {
|
||||
pub personas: Vec<MigratePersona>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MigratePersona {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "systemPrompt")]
|
||||
pub system_prompt: String,
|
||||
#[serde(default, rename = "isBuiltIn")]
|
||||
pub is_built_in: bool,
|
||||
#[serde(default, rename = "createdAt")]
|
||||
pub created_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MigrateResponse {
|
||||
pub inserted: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub type PersonaDaoData = web::Data<Mutex<Box<dyn PersonaDao>>>;
|
||||
|
||||
pub fn add_persona_services<T>(app: App<T>) -> App<T>
|
||||
where
|
||||
T: ServiceFactory<ServiceRequest, Config = (), Error = actix_web::Error, InitError = ()>,
|
||||
{
|
||||
app.service(
|
||||
web::scope("/personas")
|
||||
.service(web::resource("/migrate").route(web::post().to(migrate_personas)))
|
||||
.service(
|
||||
web::resource("")
|
||||
.route(web::get().to(list_personas))
|
||||
.route(web::post().to(create_persona)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/{persona_id}")
|
||||
.route(web::put().to(update_persona))
|
||||
.route(web::delete().to(delete_persona)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn user_id_from_claims(claims: &Claims) -> Option<i32> {
|
||||
claims.sub.parse::<i32>().ok()
|
||||
}
|
||||
|
||||
async fn list_personas(claims: Claims, dao: PersonaDaoData) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
match dao.list_personas(&cx, uid) {
|
||||
Ok(rows) => {
|
||||
let views: Vec<PersonaView> = rows.into_iter().map(PersonaView::from).collect();
|
||||
HttpResponse::Ok().json(views)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("list_personas error: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_persona(
|
||||
claims: Claims,
|
||||
body: web::Json<CreatePersonaRequest>,
|
||||
dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
|
||||
if body.name.trim().is_empty() {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "name is required"}));
|
||||
}
|
||||
if body.system_prompt.trim().is_empty() {
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "systemPrompt is required"}));
|
||||
}
|
||||
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
|
||||
let pid = match body.persona_id.as_deref() {
|
||||
Some(s) if !s.trim().is_empty() => s.to_string(),
|
||||
_ => format!("custom-{}", chrono::Utc::now().timestamp_millis()),
|
||||
};
|
||||
|
||||
if matches!(pid.as_str(), "default" | "journal" | "factual") {
|
||||
return HttpResponse::Conflict()
|
||||
.json(serde_json::json!({"error": "persona id collides with a built-in"}));
|
||||
}
|
||||
|
||||
// Pre-check existence so we can return 409 cleanly. The DB UNIQUE
|
||||
// would also catch it, but parsing Diesel's "constraint violation"
|
||||
// out of a generic DbError is uglier than a quick lookup.
|
||||
if let Ok(Some(_)) = dao.get_persona(&cx, uid, &pid) {
|
||||
return HttpResponse::Conflict()
|
||||
.json(serde_json::json!({"error": "persona already exists"}));
|
||||
}
|
||||
|
||||
match dao.create_persona(
|
||||
&cx,
|
||||
uid,
|
||||
&pid,
|
||||
&body.name,
|
||||
&body.system_prompt,
|
||||
false,
|
||||
false,
|
||||
) {
|
||||
Ok(p) => HttpResponse::Created().json(PersonaView::from(p)),
|
||||
Err(e) => {
|
||||
log::error!("create_persona error: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_persona(
|
||||
claims: Claims,
|
||||
path: web::Path<String>,
|
||||
body: web::Json<UpdatePersonaRequest>,
|
||||
dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
let pid = path.into_inner();
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
|
||||
let patch = PersonaPatch {
|
||||
name: body.name.clone(),
|
||||
system_prompt: body.system_prompt.clone(),
|
||||
include_all_memories: body.include_all_memories,
|
||||
};
|
||||
match dao.update_persona(&cx, uid, &pid, patch) {
|
||||
Ok(Some(p)) => HttpResponse::Ok().json(PersonaView::from(p)),
|
||||
Ok(None) => {
|
||||
HttpResponse::NotFound().json(serde_json::json!({"error": "Persona not found"}))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("update_persona error: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_persona(
|
||||
claims: Claims,
|
||||
path: web::Path<String>,
|
||||
dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
let pid = path.into_inner();
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
|
||||
match dao.get_persona(&cx, uid, &pid) {
|
||||
Ok(Some(p)) if p.is_built_in => {
|
||||
return HttpResponse::Conflict()
|
||||
.json(serde_json::json!({"error": "Cannot delete built-in persona"}));
|
||||
}
|
||||
Ok(None) => {
|
||||
return HttpResponse::NotFound()
|
||||
.json(serde_json::json!({"error": "Persona not found"}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("delete_persona lookup error: {:?}", e);
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Database error"}));
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
|
||||
match dao.delete_persona(&cx, uid, &pid) {
|
||||
Ok(_) => HttpResponse::NoContent().finish(),
|
||||
Err(e) => {
|
||||
log::error!("delete_persona error: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn migrate_personas(
|
||||
claims: Claims,
|
||||
body: web::Json<MigrateRequest>,
|
||||
dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let Some(uid) = user_id_from_claims(&claims) else {
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
||||
};
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||
|
||||
// Filter out built-in ids — those are already seeded by the
|
||||
// migration and re-importing them would be a no-op anyway thanks to
|
||||
// INSERT OR IGNORE, but skipping early avoids the UNIQUE round-trip.
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let rows: Vec<ImportPersona> = body
|
||||
.personas
|
||||
.iter()
|
||||
.filter(|p| !matches!(p.id.as_str(), "default" | "journal" | "factual"))
|
||||
.map(|p| ImportPersona {
|
||||
persona_id: p.id.clone(),
|
||||
name: p.name.clone(),
|
||||
system_prompt: p.system_prompt.clone(),
|
||||
is_built_in: p.is_built_in,
|
||||
created_at: p.created_at.unwrap_or(now),
|
||||
})
|
||||
.collect();
|
||||
|
||||
match dao.bulk_import(&cx, uid, &rows) {
|
||||
Ok(inserted) => HttpResponse::Ok().json(MigrateResponse { inserted }),
|
||||
Err(e) => {
|
||||
log::error!("migrate_personas error: {:?}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user