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>
348 lines
12 KiB
Rust
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"}))
|
|
}
|
|
}
|
|
}
|