Files
ImageApi/src/personas.rs
Cameron Cordes 86c331571d knowledge: per-persona reviewed-only mode + agent reads include reviewed
Two coupled changes to the agent's recall surface:

1. Default scope expanded. recall_facts_for_photo and recall_entities
   used to filter to status='active' only — which silently dropped
   'reviewed' (human-verified) facts. Now they surface active +
   reviewed by default. Reviewed is strictly more trusted than
   active and shouldn't have been hidden. Rejected and superseded
   stay filtered.

2. New persona toggle `reviewed_only_facts` (BOOLEAN, default false,
   migration 2026-05-10-000400). When set, the agent's recall on
   that persona returns ONLY facts with status='reviewed' — strict
   mode for tasks where hallucinated agent claims are particularly
   costly. Wired:
   - schema.rs / Persona / InsertPersona / PersonaPatch grow the
     field.
   - PersonaView returns it as `reviewedOnlyFacts` (camelCase wire).
   - PUT /personas/{id} accepts it (mobile editor surfaces it).
   - InsightGenerator now carries a PersonaDao reference so
     recall_facts_for_photo can read the active persona's flag at
     start; one extra read per recall, cheap.

Composes with include_all_memories: that operates on the persona
*scope* axis (single vs hive), reviewed_only_facts on the *status*
axis. They're orthogonal.

Legacy persona rows pick up the default false on migration; no
behavior change unless explicitly toggled. The 4 existing persona
construction sites (one production, two tests, one InsertPersona in
knowledge_dao tests) all default the field. populate_knowledge bin
+ state.rs constructors also wire the new persona_dao arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:21:39 -04:00

348 lines
12 KiB
Rust

//! 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,
/// "Strict mode" — when true, the agent's recall_* tools return
/// only facts whose status is 'reviewed'. See migration
/// 2026-05-10-000400.
#[serde(rename = "reviewedOnlyFacts")]
pub reviewed_only_facts: bool,
}
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,
reviewed_only_facts: p.reviewed_only_facts,
}
}
}
#[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>,
#[serde(default, rename = "reviewedOnlyFacts")]
pub reviewed_only_facts: 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");
// Built-in personas are owned by the migration; the canonical voice
// text lives in source. A client renaming or rewriting the prompt
// here would diverge from what new users get seeded with and hide
// the operator's actual customization (their own custom persona)
// from the picker. `include_all_memories` stays editable on
// built-ins — that's a per-user preference, not the persona's
// identity. Mirrors the same guard delete_persona enforces below.
match dao.get_persona(&cx, uid, &pid) {
Ok(Some(p)) if p.is_built_in => {
let editing_identity =
body.name.is_some() || body.system_prompt.is_some();
if editing_identity {
return HttpResponse::Conflict().json(serde_json::json!({
"error": "Cannot edit name or systemPrompt of a built-in persona"
}));
}
}
Ok(None) => {
return HttpResponse::NotFound()
.json(serde_json::json!({"error": "Persona not found"}));
}
Err(e) => {
log::error!("update_persona lookup error: {:?}", e);
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}));
}
Ok(Some(_)) => {}
}
let patch = PersonaPatch {
name: body.name.clone(),
system_prompt: body.system_prompt.clone(),
include_all_memories: body.include_all_memories,
reviewed_only_facts: body.reviewed_only_facts,
};
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"}))
}
}
}