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:
@@ -89,6 +89,10 @@ pub struct InsightGenerator {
|
||||
// Knowledge memory
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -121,6 +125,7 @@ impl InsightGenerator {
|
||||
tag_dao: Arc<Mutex<Box<dyn TagDao>>>,
|
||||
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
|
||||
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
|
||||
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
|
||||
libraries: Vec<Library>,
|
||||
) -> 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<String> = 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 {
|
||||
|
||||
Reference in New Issue
Block a user