From 86c331571db5c8e685af063f1036ef79c4b1dd8a Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sun, 10 May 2026 20:21:39 -0400 Subject: [PATCH] knowledge: per-persona reviewed-only mode + agent reads include reviewed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../down.sql | 1 + .../up.sql | 16 ++++++ src/ai/insight_generator.rs | 56 +++++++++++++++++-- src/bin/populate_knowledge.rs | 4 ++ src/database/knowledge_dao.rs | 1 + src/database/models.rs | 5 ++ src/database/persona_dao.rs | 13 +++++ src/database/schema.rs | 1 + src/personas.rs | 9 +++ src/state.rs | 8 +++ 10 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 migrations/2026-05-10-000400_personas_reviewed_only/down.sql create mode 100644 migrations/2026-05-10-000400_personas_reviewed_only/up.sql diff --git a/migrations/2026-05-10-000400_personas_reviewed_only/down.sql b/migrations/2026-05-10-000400_personas_reviewed_only/down.sql new file mode 100644 index 0000000..46af3cf --- /dev/null +++ b/migrations/2026-05-10-000400_personas_reviewed_only/down.sql @@ -0,0 +1 @@ +ALTER TABLE personas DROP COLUMN reviewed_only_facts; diff --git a/migrations/2026-05-10-000400_personas_reviewed_only/up.sql b/migrations/2026-05-10-000400_personas_reviewed_only/up.sql new file mode 100644 index 0000000..0d8302e --- /dev/null +++ b/migrations/2026-05-10-000400_personas_reviewed_only/up.sql @@ -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; diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index adb6071..362c508 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -89,6 +89,10 @@ pub struct InsightGenerator { // Knowledge memory knowledge_dao: Arc>>, + // Persona settings (looked up by recall_facts_for_photo to honour + // the per-persona "reviewed-only" strict-mode toggle). + persona_dao: Arc>>, + libraries: Vec, } @@ -121,6 +125,7 @@ impl InsightGenerator { tag_dao: Arc>>, face_dao: Arc>>, knowledge_dao: Arc>>, + persona_dao: Arc>>, libraries: Vec, ) -> Self { Self { @@ -137,6 +142,7 @@ impl InsightGenerator { tag_dao, face_dao, knowledge_dao, + persona_dao, libraries, } } @@ -2410,9 +2416,15 @@ Return ONLY the summary, nothing else."#, 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 { entity_type, - status: Some("active".to_string()), + status: Some("all".to_string()), search: name_search, limit, offset: 0, @@ -2423,12 +2435,17 @@ Return ONLY the summary, nothing else."#, .lock() .expect("Unable to lock KnowledgeDao"); 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() } Ok((entities, _total)) => { let lines: Vec = entities .iter() + .filter(|e| e.status != "rejected") .map(|e| { format!( "ID:{} | {} | {} | {} | confidence:{:.2}", @@ -2436,7 +2453,11 @@ Return ONLY the summary, nothing else."#, ) }) .collect(); - format!("Known entities:\n{}", lines.join("\n")) + if lines.is_empty() { + "No known entities found matching the query.".to_string() + } else { + format!("Known entities:\n{}", lines.join("\n")) + } } 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); + // 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 .knowledge_dao .lock() @@ -2495,7 +2532,18 @@ Return ONLY the summary, nothing else."#, e.name, e.entity_type, role )); 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 { v.clone() } else if let Some(oid) = f.object_entity_id { diff --git a/src/bin/populate_knowledge.rs b/src/bin/populate_knowledge.rs index a7cdd27..f99b3f4 100644 --- a/src/bin/populate_knowledge.rs +++ b/src/bin/populate_knowledge.rs @@ -185,6 +185,9 @@ async fn main() -> anyhow::Result<()> { Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new()))); let face_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteFaceDao::new()))); + let persona_dao: Arc>> = Arc::new( + Mutex::new(Box::new(image_api::database::SqlitePersonaDao::new())), + ); // Pass the full library set so `resolve_full_path` probes every root, // even when --library restricts the walk. A rel_path shared across @@ -203,6 +206,7 @@ async fn main() -> anyhow::Result<()> { tag_dao, face_dao, knowledge_dao, + persona_dao, all_libs.clone(), ); diff --git a/src/database/knowledge_dao.rs b/src/database/knowledge_dao.rs index 5c3fc7f..68b3da4 100644 --- a/src/database/knowledge_dao.rs +++ b/src/database/knowledge_dao.rs @@ -1420,6 +1420,7 @@ mod tests { include_all_memories: false, created_at: 0, updated_at: 0, + reviewed_only_facts: false, }) .execute(c.deref_mut()) .unwrap(); diff --git a/src/database/models.rs b/src/database/models.rs index a233df1..a22e8fd 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -324,6 +324,10 @@ pub struct InsertPersona<'a> { pub include_all_memories: bool, pub created_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)] @@ -337,6 +341,7 @@ pub struct Persona { pub include_all_memories: bool, pub created_at: i64, pub updated_at: i64, + pub reviewed_only_facts: bool, } #[derive(Insertable)] diff --git a/src/database/persona_dao.rs b/src/database/persona_dao.rs index 8ea404d..72eb187 100644 --- a/src/database/persona_dao.rs +++ b/src/database/persona_dao.rs @@ -17,6 +17,7 @@ pub struct PersonaPatch { pub name: Option, pub system_prompt: Option, pub include_all_memories: Option, + pub reviewed_only_facts: Option, } /// 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, created_at: now, updated_at: now, + reviewed_only_facts: false, }) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Insert error: {}", e))?; @@ -211,6 +213,15 @@ impl PersonaDao for SqlitePersonaDao { .execute(conn.deref_mut()) .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 .filter(user_id.eq(uid)) @@ -384,6 +395,7 @@ mod tests { name: Some("Renamed".into()), system_prompt: Some("new prompt".into()), include_all_memories: None, + reviewed_only_facts: None, }, ) .unwrap() @@ -412,6 +424,7 @@ mod tests { name: None, system_prompt: None, include_all_memories: Some(true), + reviewed_only_facts: None, }, ) .unwrap() diff --git a/src/database/schema.rs b/src/database/schema.rs index 443ca64..34ea3f5 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -177,6 +177,7 @@ diesel::table! { include_all_memories -> Bool, created_at -> BigInt, updated_at -> BigInt, + reviewed_only_facts -> Bool, } } diff --git a/src/personas.rs b/src/personas.rs index 4b1fe53..c4ecf53 100644 --- a/src/personas.rs +++ b/src/personas.rs @@ -35,6 +35,11 @@ pub struct PersonaView { 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 for PersonaView { @@ -47,6 +52,7 @@ impl From for PersonaView { include_all_memories: p.include_all_memories, created_at: p.created_at, updated_at: p.updated_at, + reviewed_only_facts: p.reviewed_only_facts, } } } @@ -72,6 +78,8 @@ pub struct UpdatePersonaRequest { pub system_prompt: Option, #[serde(default, rename = "includeAllMemories")] pub include_all_memories: Option, + #[serde(default, rename = "reviewedOnlyFacts")] + pub reviewed_only_facts: Option, } #[derive(Deserialize)] @@ -249,6 +257,7 @@ async fn update_persona( 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)), diff --git a/src/state.rs b/src/state.rs index e0234a2..d6e3736 100644 --- a/src/state.rs +++ b/src/state.rs @@ -207,6 +207,9 @@ impl Default for AppState { Arc::new(Mutex::new(Box::new(SqliteTagDao::default()))); let knowledge_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new()))); + let persona_dao: Arc>> = Arc::new( + Mutex::new(Box::new(crate::database::SqlitePersonaDao::new())), + ); let face_dao: Arc>> = Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new()))); @@ -236,6 +239,7 @@ impl Default for AppState { tag_dao.clone(), face_dao.clone(), knowledge_dao, + persona_dao, libraries_vec.clone(), ); @@ -352,6 +356,9 @@ impl AppState { Arc::new(Mutex::new(Box::new(SqliteTagDao::default()))); let knowledge_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new()))); + let persona_dao: Arc>> = Arc::new( + Mutex::new(Box::new(crate::database::SqlitePersonaDao::new())), + ); let face_dao: Arc>> = Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new()))); @@ -378,6 +385,7 @@ impl AppState { tag_dao.clone(), face_dao.clone(), knowledge_dao, + persona_dao, vec![test_lib], );