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:
@@ -1,5 +1,5 @@
|
||||
use actix_web::dev::{ServiceFactory, ServiceRequest};
|
||||
use actix_web::{App, HttpResponse, Responder, web};
|
||||
use actix_web::{App, HttpRequest, HttpResponse, Responder, web};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
@@ -7,8 +7,38 @@ use std::sync::Mutex;
|
||||
use crate::data::Claims;
|
||||
use crate::database::models::{Entity, EntityFact, EntityPhotoLink};
|
||||
use crate::database::{
|
||||
EntityFilter, EntityPatch, FactFilter, FactPatch, KnowledgeDao, RecentActivity,
|
||||
EntityFilter, EntityPatch, FactFilter, FactPatch, KnowledgeDao, PersonaFilter, RecentActivity,
|
||||
};
|
||||
use crate::personas::PersonaDaoData;
|
||||
|
||||
/// Resolve the `X-Persona-Id` header into a `PersonaFilter`. Missing
|
||||
/// header → `'default'`. If the persona has `include_all_memories=true`,
|
||||
/// returns `PersonaFilter::All` so reads see the full hive-mind pool.
|
||||
/// On lookup failure (e.g. malformed JWT) returns `Single("default")` —
|
||||
/// safer than `All` because it preserves the historical baseline view.
|
||||
fn resolve_persona_filter(
|
||||
req: &HttpRequest,
|
||||
claims: &Claims,
|
||||
persona_dao: &PersonaDaoData,
|
||||
) -> PersonaFilter {
|
||||
let pid = req
|
||||
.headers()
|
||||
.get("X-Persona-Id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
let Ok(uid) = claims.sub.parse::<i32>() else {
|
||||
return PersonaFilter::Single(pid);
|
||||
};
|
||||
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = persona_dao.lock().expect("Unable to lock PersonaDao");
|
||||
match dao.get_persona(&cx, uid, &pid) {
|
||||
Ok(Some(p)) if p.include_all_memories => PersonaFilter::All,
|
||||
_ => PersonaFilter::Single(pid),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response types
|
||||
@@ -246,10 +276,13 @@ async fn list_entities<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
|
||||
async fn get_entity<D: KnowledgeDao + 'static>(
|
||||
_claims: Claims,
|
||||
req: HttpRequest,
|
||||
claims: Claims,
|
||||
id: web::Path<i32>,
|
||||
dao: web::Data<Mutex<D>>,
|
||||
persona_dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
|
||||
let cx = opentelemetry::Context::current();
|
||||
let entity_id = id.into_inner();
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
@@ -266,8 +299,8 @@ async fn get_entity<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch all facts (all statuses for audit)
|
||||
let raw_facts: Vec<EntityFact> = match dao.get_facts_for_entity(&cx, entity_id) {
|
||||
// Fetch all facts (all statuses for audit), scoped to the active persona.
|
||||
let raw_facts: Vec<EntityFact> = match dao.get_facts_for_entity(&cx, entity_id, &persona) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::error!("get_facts_for_entity error: {:?}", e);
|
||||
@@ -426,9 +459,11 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
|
||||
async fn list_facts<D: KnowledgeDao + 'static>(
|
||||
_claims: Claims,
|
||||
req: HttpRequest,
|
||||
claims: Claims,
|
||||
query: web::Query<FactListQuery>,
|
||||
dao: web::Data<Mutex<D>>,
|
||||
persona_dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let limit = query.limit.unwrap_or(50).min(200);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
@@ -438,11 +473,13 @@ async fn list_facts<D: KnowledgeDao + 'static>(
|
||||
Some("all") => None,
|
||||
Some(s) => Some(s.to_string()),
|
||||
};
|
||||
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
|
||||
|
||||
let filter = FactFilter {
|
||||
entity_id: query.entity_id,
|
||||
status: status_filter,
|
||||
predicate: query.predicate.clone(),
|
||||
persona,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
@@ -539,18 +576,21 @@ async fn delete_fact<D: KnowledgeDao + 'static>(
|
||||
}
|
||||
|
||||
async fn get_recent<D: KnowledgeDao + 'static>(
|
||||
_claims: Claims,
|
||||
req: HttpRequest,
|
||||
claims: Claims,
|
||||
query: web::Query<RecentQuery>,
|
||||
dao: web::Data<Mutex<D>>,
|
||||
persona_dao: PersonaDaoData,
|
||||
) -> impl Responder {
|
||||
let since = query
|
||||
.since
|
||||
.unwrap_or_else(|| Utc::now().timestamp() - 86400);
|
||||
let limit = query.limit.unwrap_or(20).min(100);
|
||||
|
||||
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
|
||||
let cx = opentelemetry::Context::current();
|
||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||
match dao.get_recent_activity(&cx, since, limit) {
|
||||
match dao.get_recent_activity(&cx, since, limit, &persona) {
|
||||
Ok(RecentActivity { entities, facts }) => {
|
||||
let entity_summaries: Vec<EntitySummary> =
|
||||
entities.into_iter().map(EntitySummary::from).collect();
|
||||
|
||||
Reference in New Issue
Block a user