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>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE personas DROP COLUMN reviewed_only_facts;
|
||||||
16
migrations/2026-05-10-000400_personas_reviewed_only/up.sql
Normal file
16
migrations/2026-05-10-000400_personas_reviewed_only/up.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Per-persona toggle: when true, agent reads only see facts whose
|
||||||
|
-- status is exactly 'reviewed' (human-verified). When false (the
|
||||||
|
-- default), agent reads see 'active' OR 'reviewed' — everything not
|
||||||
|
-- rejected or superseded.
|
||||||
|
--
|
||||||
|
-- The mobile app surfaces this as "Strict mode" on the persona
|
||||||
|
-- editor: useful when you want a persona's chat to be grounded
|
||||||
|
-- exclusively on the curated subset, e.g. for tasks where
|
||||||
|
-- hallucinated agent claims are particularly costly.
|
||||||
|
--
|
||||||
|
-- Note: this is separate from `include_all_memories` (which unions
|
||||||
|
-- across personas for hive-mind reads). Reviewed-only operates on
|
||||||
|
-- the status axis; include_all_memories operates on the persona-
|
||||||
|
-- scope axis. They compose freely.
|
||||||
|
|
||||||
|
ALTER TABLE personas ADD COLUMN reviewed_only_facts BOOLEAN NOT NULL DEFAULT 0;
|
||||||
@@ -89,6 +89,10 @@ pub struct InsightGenerator {
|
|||||||
// Knowledge memory
|
// Knowledge memory
|
||||||
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
||||||
|
|
||||||
|
// Persona settings (looked up by recall_facts_for_photo to honour
|
||||||
|
// the per-persona "reviewed-only" strict-mode toggle).
|
||||||
|
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
|
||||||
|
|
||||||
libraries: Vec<Library>,
|
libraries: Vec<Library>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +125,7 @@ impl InsightGenerator {
|
|||||||
tag_dao: Arc<Mutex<Box<dyn TagDao>>>,
|
tag_dao: Arc<Mutex<Box<dyn TagDao>>>,
|
||||||
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
|
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
|
||||||
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
||||||
|
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
|
||||||
libraries: Vec<Library>,
|
libraries: Vec<Library>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -137,6 +142,7 @@ impl InsightGenerator {
|
|||||||
tag_dao,
|
tag_dao,
|
||||||
face_dao,
|
face_dao,
|
||||||
knowledge_dao,
|
knowledge_dao,
|
||||||
|
persona_dao,
|
||||||
libraries,
|
libraries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2410,9 +2416,15 @@ Return ONLY the summary, nothing else."#,
|
|||||||
limit
|
limit
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Status scope mirrors recall_facts_for_photo: by default
|
||||||
|
// include both 'active' (agent-generated, not yet reviewed)
|
||||||
|
// and 'reviewed' (human-verified). The DAO list_entities only
|
||||||
|
// takes a single status string, so use 'all' here and filter
|
||||||
|
// out 'rejected' below — slightly broader but fine for the
|
||||||
|
// recall use case (entities are global, persona-agnostic).
|
||||||
let filter = EntityFilter {
|
let filter = EntityFilter {
|
||||||
entity_type,
|
entity_type,
|
||||||
status: Some("active".to_string()),
|
status: Some("all".to_string()),
|
||||||
search: name_search,
|
search: name_search,
|
||||||
limit,
|
limit,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -2423,12 +2435,17 @@ Return ONLY the summary, nothing else."#,
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("Unable to lock KnowledgeDao");
|
.expect("Unable to lock KnowledgeDao");
|
||||||
match kdao.list_entities(cx, filter) {
|
match kdao.list_entities(cx, filter) {
|
||||||
Ok((entities, _total)) if entities.is_empty() => {
|
Ok((entities, _total))
|
||||||
|
if entities
|
||||||
|
.iter()
|
||||||
|
.all(|e| e.status == "rejected") =>
|
||||||
|
{
|
||||||
"No known entities found matching the query.".to_string()
|
"No known entities found matching the query.".to_string()
|
||||||
}
|
}
|
||||||
Ok((entities, _total)) => {
|
Ok((entities, _total)) => {
|
||||||
let lines: Vec<String> = entities
|
let lines: Vec<String> = entities
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|e| e.status != "rejected")
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
format!(
|
format!(
|
||||||
"ID:{} | {} | {} | {} | confidence:{:.2}",
|
"ID:{} | {} | {} | {} | confidence:{:.2}",
|
||||||
@@ -2436,8 +2453,12 @@ Return ONLY the summary, nothing else."#,
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
if lines.is_empty() {
|
||||||
|
"No known entities found matching the query.".to_string()
|
||||||
|
} else {
|
||||||
format!("Known entities:\n{}", lines.join("\n"))
|
format!("Known entities:\n{}", lines.join("\n"))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(e) => format!("Error recalling entities: {:?}", e),
|
Err(e) => format!("Error recalling entities: {:?}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2462,6 +2483,22 @@ Return ONLY the summary, nothing else."#,
|
|||||||
|
|
||||||
log::info!("tool_recall_facts_for_photo: file_path={}", file_path);
|
log::info!("tool_recall_facts_for_photo: file_path={}", file_path);
|
||||||
|
|
||||||
|
// Resolve the persona's reviewed-only-mode flag once. If the
|
||||||
|
// persona row is missing (shouldn't happen — composite FK
|
||||||
|
// enforces existence on writes), fall back to the permissive
|
||||||
|
// default of active+reviewed.
|
||||||
|
let reviewed_only = {
|
||||||
|
let mut pdao = self
|
||||||
|
.persona_dao
|
||||||
|
.lock()
|
||||||
|
.expect("Unable to lock PersonaDao");
|
||||||
|
pdao.get_persona(cx, user_id, persona_id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|p| p.reviewed_only_facts)
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
let mut kdao = self
|
let mut kdao = self
|
||||||
.knowledge_dao
|
.knowledge_dao
|
||||||
.lock()
|
.lock()
|
||||||
@@ -2495,7 +2532,18 @@ Return ONLY the summary, nothing else."#,
|
|||||||
e.name, e.entity_type, role
|
e.name, e.entity_type, role
|
||||||
));
|
));
|
||||||
if let Ok(facts) = kdao.get_facts_for_entity(cx, entity_id, &persona_filter) {
|
if let Ok(facts) = kdao.get_facts_for_entity(cx, entity_id, &persona_filter) {
|
||||||
for f in facts.iter().filter(|f| f.status == "active") {
|
// Default scope: active + reviewed (everything not
|
||||||
|
// rejected / superseded). Strict mode trims to
|
||||||
|
// reviewed only — the persona has opted into seeing
|
||||||
|
// exclusively human-verified facts.
|
||||||
|
let allow = |s: &str| -> bool {
|
||||||
|
if reviewed_only {
|
||||||
|
s == "reviewed"
|
||||||
|
} else {
|
||||||
|
s == "active" || s == "reviewed"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for f in facts.iter().filter(|f| allow(&f.status)) {
|
||||||
let obj = if let Some(ref v) = f.object_value {
|
let obj = if let Some(ref v) = f.object_value {
|
||||||
v.clone()
|
v.clone()
|
||||||
} else if let Some(oid) = f.object_entity_id {
|
} else if let Some(oid) = f.object_entity_id {
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
||||||
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> =
|
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> =
|
||||||
Arc::new(Mutex::new(Box::new(SqliteFaceDao::new())));
|
Arc::new(Mutex::new(Box::new(SqliteFaceDao::new())));
|
||||||
|
let persona_dao: Arc<Mutex<Box<dyn image_api::database::PersonaDao>>> = Arc::new(
|
||||||
|
Mutex::new(Box::new(image_api::database::SqlitePersonaDao::new())),
|
||||||
|
);
|
||||||
|
|
||||||
// Pass the full library set so `resolve_full_path` probes every root,
|
// Pass the full library set so `resolve_full_path` probes every root,
|
||||||
// even when --library restricts the walk. A rel_path shared across
|
// even when --library restricts the walk. A rel_path shared across
|
||||||
@@ -203,6 +206,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tag_dao,
|
tag_dao,
|
||||||
face_dao,
|
face_dao,
|
||||||
knowledge_dao,
|
knowledge_dao,
|
||||||
|
persona_dao,
|
||||||
all_libs.clone(),
|
all_libs.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1420,6 +1420,7 @@ mod tests {
|
|||||||
include_all_memories: false,
|
include_all_memories: false,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
updated_at: 0,
|
updated_at: 0,
|
||||||
|
reviewed_only_facts: false,
|
||||||
})
|
})
|
||||||
.execute(c.deref_mut())
|
.execute(c.deref_mut())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -324,6 +324,10 @@ pub struct InsertPersona<'a> {
|
|||||||
pub include_all_memories: bool,
|
pub include_all_memories: bool,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
|
/// "Strict mode" — agent reads only see facts with status =
|
||||||
|
/// 'reviewed' (human-verified). Default false. See migration
|
||||||
|
/// 2026-05-10-000400.
|
||||||
|
pub reviewed_only_facts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Queryable, Clone, Debug)]
|
#[derive(Serialize, Queryable, Clone, Debug)]
|
||||||
@@ -337,6 +341,7 @@ pub struct Persona {
|
|||||||
pub include_all_memories: bool,
|
pub include_all_memories: bool,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
|
pub reviewed_only_facts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Insertable)]
|
#[derive(Insertable)]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub struct PersonaPatch {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub system_prompt: Option<String>,
|
pub system_prompt: Option<String>,
|
||||||
pub include_all_memories: Option<bool>,
|
pub include_all_memories: Option<bool>,
|
||||||
|
pub reviewed_only_facts: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of a bulk migration upload. Fields named to match the JSON
|
/// One row of a bulk migration upload. Fields named to match the JSON
|
||||||
@@ -164,6 +165,7 @@ impl PersonaDao for SqlitePersonaDao {
|
|||||||
include_all_memories: include_all,
|
include_all_memories: include_all,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
reviewed_only_facts: false,
|
||||||
})
|
})
|
||||||
.execute(conn.deref_mut())
|
.execute(conn.deref_mut())
|
||||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
||||||
@@ -211,6 +213,15 @@ impl PersonaDao for SqlitePersonaDao {
|
|||||||
.execute(conn.deref_mut())
|
.execute(conn.deref_mut())
|
||||||
.map_err(|e| anyhow::anyhow!("Update include_all error: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Update include_all error: {}", e))?;
|
||||||
}
|
}
|
||||||
|
if let Some(new_reviewed_only) = patch.reviewed_only_facts {
|
||||||
|
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
||||||
|
.set((
|
||||||
|
reviewed_only_facts.eq(new_reviewed_only),
|
||||||
|
updated_at.eq(now),
|
||||||
|
))
|
||||||
|
.execute(conn.deref_mut())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Update reviewed_only_facts error: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
personas
|
personas
|
||||||
.filter(user_id.eq(uid))
|
.filter(user_id.eq(uid))
|
||||||
@@ -384,6 +395,7 @@ mod tests {
|
|||||||
name: Some("Renamed".into()),
|
name: Some("Renamed".into()),
|
||||||
system_prompt: Some("new prompt".into()),
|
system_prompt: Some("new prompt".into()),
|
||||||
include_all_memories: None,
|
include_all_memories: None,
|
||||||
|
reviewed_only_facts: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -412,6 +424,7 @@ mod tests {
|
|||||||
name: None,
|
name: None,
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
include_all_memories: Some(true),
|
include_all_memories: Some(true),
|
||||||
|
reviewed_only_facts: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ diesel::table! {
|
|||||||
include_all_memories -> Bool,
|
include_all_memories -> Bool,
|
||||||
created_at -> BigInt,
|
created_at -> BigInt,
|
||||||
updated_at -> BigInt,
|
updated_at -> BigInt,
|
||||||
|
reviewed_only_facts -> Bool,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ pub struct PersonaView {
|
|||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
#[serde(rename = "updatedAt")]
|
#[serde(rename = "updatedAt")]
|
||||||
pub updated_at: i64,
|
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 {
|
impl From<Persona> for PersonaView {
|
||||||
@@ -47,6 +52,7 @@ impl From<Persona> for PersonaView {
|
|||||||
include_all_memories: p.include_all_memories,
|
include_all_memories: p.include_all_memories,
|
||||||
created_at: p.created_at,
|
created_at: p.created_at,
|
||||||
updated_at: p.updated_at,
|
updated_at: p.updated_at,
|
||||||
|
reviewed_only_facts: p.reviewed_only_facts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +78,8 @@ pub struct UpdatePersonaRequest {
|
|||||||
pub system_prompt: Option<String>,
|
pub system_prompt: Option<String>,
|
||||||
#[serde(default, rename = "includeAllMemories")]
|
#[serde(default, rename = "includeAllMemories")]
|
||||||
pub include_all_memories: Option<bool>,
|
pub include_all_memories: Option<bool>,
|
||||||
|
#[serde(default, rename = "reviewedOnlyFacts")]
|
||||||
|
pub reviewed_only_facts: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -249,6 +257,7 @@ async fn update_persona(
|
|||||||
name: body.name.clone(),
|
name: body.name.clone(),
|
||||||
system_prompt: body.system_prompt.clone(),
|
system_prompt: body.system_prompt.clone(),
|
||||||
include_all_memories: body.include_all_memories,
|
include_all_memories: body.include_all_memories,
|
||||||
|
reviewed_only_facts: body.reviewed_only_facts,
|
||||||
};
|
};
|
||||||
match dao.update_persona(&cx, uid, &pid, patch) {
|
match dao.update_persona(&cx, uid, &pid, patch) {
|
||||||
Ok(Some(p)) => HttpResponse::Ok().json(PersonaView::from(p)),
|
Ok(Some(p)) => HttpResponse::Ok().json(PersonaView::from(p)),
|
||||||
|
|||||||
@@ -207,6 +207,9 @@ impl Default for AppState {
|
|||||||
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
|
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
|
||||||
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
|
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
|
||||||
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
||||||
|
let persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>> = Arc::new(
|
||||||
|
Mutex::new(Box::new(crate::database::SqlitePersonaDao::new())),
|
||||||
|
);
|
||||||
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
|
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
|
||||||
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
|
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
|
||||||
|
|
||||||
@@ -236,6 +239,7 @@ impl Default for AppState {
|
|||||||
tag_dao.clone(),
|
tag_dao.clone(),
|
||||||
face_dao.clone(),
|
face_dao.clone(),
|
||||||
knowledge_dao,
|
knowledge_dao,
|
||||||
|
persona_dao,
|
||||||
libraries_vec.clone(),
|
libraries_vec.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -352,6 +356,9 @@ impl AppState {
|
|||||||
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
|
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
|
||||||
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
|
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
|
||||||
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
|
||||||
|
let persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>> = Arc::new(
|
||||||
|
Mutex::new(Box::new(crate::database::SqlitePersonaDao::new())),
|
||||||
|
);
|
||||||
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
|
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
|
||||||
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
|
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
|
||||||
|
|
||||||
@@ -378,6 +385,7 @@ impl AppState {
|
|||||||
tag_dao.clone(),
|
tag_dao.clone(),
|
||||||
face_dao.clone(),
|
face_dao.clone(),
|
||||||
knowledge_dao,
|
knowledge_dao,
|
||||||
|
persona_dao,
|
||||||
vec![test_lib],
|
vec![test_lib],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user