Bridge Persona recall through memory cache
This commit is contained in:
parent
d38e0ba218
commit
3d831a515f
4 changed files with 330 additions and 1 deletions
|
|
@ -8,12 +8,14 @@ use epiphany_core::GhostlightValue;
|
|||
use epiphany_core::GhostlightWorld;
|
||||
use epiphany_core::PersonaIdentity;
|
||||
use epiphany_core::PersonaInterpreterInput;
|
||||
use epiphany_core::PersonaMemoryCacheConfig;
|
||||
use epiphany_core::PersonaProjectorInput;
|
||||
use epiphany_core::PersonaTurnInput;
|
||||
use epiphany_core::build_persona_interpreter_prompt;
|
||||
use epiphany_core::build_persona_memory_chunks;
|
||||
use epiphany_core::build_persona_projector_prompt;
|
||||
use epiphany_core::build_persona_turn_prompt;
|
||||
use epiphany_core::render_persona_memory_recall_with_cache;
|
||||
use epiphany_core::render_persona_semantic_memory_recall;
|
||||
use epiphany_state_model::EpiphanyMemoryContextPacket;
|
||||
use epiphany_state_model::EpiphanyMemoryFreshnessStatus;
|
||||
|
|
@ -99,9 +101,44 @@ fn main() {
|
|||
.iter()
|
||||
.all(|chunk| !chunk.text.contains("sealed private note"))
|
||||
);
|
||||
let bridge = render_persona_memory_recall_with_cache(
|
||||
&persona_memory_entry(),
|
||||
"state/agents.msgpack#Persona",
|
||||
"typed-contract zeal before public speech",
|
||||
4,
|
||||
Some(&EpiphanyMemoryContextPacket {
|
||||
id: "memctx-smoke-fallback".to_string(),
|
||||
query_id: "persona-smoke-fallback".to_string(),
|
||||
nodes: vec![EpiphanyMemoryNode {
|
||||
id: "node-smoke-fallback".to_string(),
|
||||
title: "Smoke fallback memory".to_string(),
|
||||
claim: "Fallback typed memory graph context remains available.".to_string(),
|
||||
action_implication: "Do not pretend live Qdrant was required for this smoke."
|
||||
.to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
&PersonaMemoryCacheConfig {
|
||||
qdrant_url: "http://127.0.0.1:1".to_string(),
|
||||
qdrant_api_key: None,
|
||||
qdrant_timeout_ms: 1,
|
||||
ollama_base_url: "http://127.0.0.1:1".to_string(),
|
||||
ollama_model: "qwen3-embedding:0.6b".to_string(),
|
||||
ollama_timeout_ms: 1,
|
||||
collection_name: "epiphany_persona_memory_smoke".to_string(),
|
||||
query_instruction: "smoke".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(bridge.status, "fallback");
|
||||
assert!(
|
||||
bridge
|
||||
.rendered_recall
|
||||
.contains("Fallback typed memory graph context")
|
||||
);
|
||||
|
||||
println!(
|
||||
"status=ok recallSource=typed-memory-graph qdrantCache=persona-memory-module-wired liveQdrant=not-required personaLayers=projector,persona,interpreter privateStateExposed=false"
|
||||
"status=ok recallSource=typed-memory-graph qdrantCache=persona-memory-bridge-wired liveQdrant=not-required personaLayers=projector,persona,interpreter privateStateExposed=false"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -609,9 +609,11 @@ pub use persona_memory_cache::PERSONA_MEMORY_CORPUS_KIND;
|
|||
pub use persona_memory_cache::PersonaMemoryCacheConfig;
|
||||
pub use persona_memory_cache::PersonaMemoryCacheIndexReceipt;
|
||||
pub use persona_memory_cache::PersonaMemoryChunk;
|
||||
pub use persona_memory_cache::PersonaMemoryRecallRender;
|
||||
pub use persona_memory_cache::PersonaMemorySearchHit;
|
||||
pub use persona_memory_cache::build_persona_memory_chunks;
|
||||
pub use persona_memory_cache::index_persona_memory_chunks;
|
||||
pub use persona_memory_cache::render_persona_memory_recall_with_cache;
|
||||
pub use persona_memory_cache::search_persona_memory_cache;
|
||||
pub use persona_turn::PERSONA_INTERPRETER_PROMPT_SCHEMA_VERSION;
|
||||
pub use persona_turn::PERSONA_PROJECTOR_PROMPT_SCHEMA_VERSION;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
use crate::EpiphanyAgentMemoryEntry;
|
||||
use crate::persona_turn::render_persona_semantic_memory_recall;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use epiphany_state_model::EpiphanyMemoryContextPacket;
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::blocking::ClientBuilder;
|
||||
|
|
@ -107,6 +109,22 @@ pub struct PersonaMemoryCacheIndexReceipt {
|
|||
pub private_state_exposed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonaMemoryRecallRender {
|
||||
pub schema_version: String,
|
||||
pub status: String,
|
||||
pub cache_status: String,
|
||||
pub identity_id: String,
|
||||
pub role_id: String,
|
||||
pub chunk_count: usize,
|
||||
pub hit_count: usize,
|
||||
pub rendered_recall: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
pub private_state_exposed: bool,
|
||||
}
|
||||
|
||||
pub fn build_persona_memory_chunks(
|
||||
entry: &EpiphanyAgentMemoryEntry,
|
||||
source_state_ref: impl Into<String>,
|
||||
|
|
@ -289,6 +307,110 @@ pub fn search_persona_memory_cache(
|
|||
.collect())
|
||||
}
|
||||
|
||||
pub fn render_persona_memory_recall_with_cache(
|
||||
entry: &EpiphanyAgentMemoryEntry,
|
||||
source_state_ref: impl Into<String>,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
fallback_context: Option<&EpiphanyMemoryContextPacket>,
|
||||
config: &PersonaMemoryCacheConfig,
|
||||
) -> PersonaMemoryRecallRender {
|
||||
let chunks = build_persona_memory_chunks(entry, source_state_ref);
|
||||
let identity_id = entry.agent.agent_id.clone();
|
||||
let role_id = entry.role_id.clone();
|
||||
if chunks.is_empty() {
|
||||
return fallback_persona_memory_recall(
|
||||
identity_id,
|
||||
role_id,
|
||||
0,
|
||||
"empty-cache-source".to_string(),
|
||||
"No public Persona memory chunks were available for cache recall.".to_string(),
|
||||
fallback_context,
|
||||
);
|
||||
}
|
||||
|
||||
match index_persona_memory_chunks(&chunks, config)
|
||||
.and_then(|_| search_persona_memory_cache(&identity_id, query, limit.max(1), config))
|
||||
{
|
||||
Ok(hits) if !hits.is_empty() => PersonaMemoryRecallRender {
|
||||
schema_version: PERSONA_MEMORY_CACHE_SCHEMA_VERSION.to_string(),
|
||||
status: "ok".to_string(),
|
||||
cache_status: "qdrant-hit".to_string(),
|
||||
identity_id,
|
||||
role_id,
|
||||
chunk_count: chunks.len(),
|
||||
hit_count: hits.len(),
|
||||
rendered_recall: render_persona_memory_cache_hits(&hits),
|
||||
warnings: Vec::new(),
|
||||
private_state_exposed: false,
|
||||
},
|
||||
Ok(_) => fallback_persona_memory_recall(
|
||||
identity_id,
|
||||
role_id,
|
||||
chunks.len(),
|
||||
"qdrant-empty".to_string(),
|
||||
"Qdrant Persona-memory cache ran but returned no matching hits.".to_string(),
|
||||
fallback_context,
|
||||
),
|
||||
Err(error) => fallback_persona_memory_recall(
|
||||
identity_id,
|
||||
role_id,
|
||||
chunks.len(),
|
||||
"qdrant-unavailable".to_string(),
|
||||
format!("Qdrant Persona-memory cache unavailable: {error:#}"),
|
||||
fallback_context,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_persona_memory_cache_hits(hits: &[PersonaMemorySearchHit]) -> String {
|
||||
let mut lines = vec![
|
||||
"These are derived Qdrant Persona-memory cache hits from typed Persona state. They are hints, not durable authority; typed memory remains the owner.".to_string(),
|
||||
];
|
||||
for (index, hit) in hits.iter().enumerate() {
|
||||
lines.push(format!(
|
||||
"- {}. {} / {} score={:.3}: {}",
|
||||
index + 1,
|
||||
hit.chunk.persona_name,
|
||||
hit.chunk.memory_kind,
|
||||
hit.score,
|
||||
collapse_whitespace(&hit.chunk.text, 560)
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn fallback_persona_memory_recall(
|
||||
identity_id: String,
|
||||
role_id: String,
|
||||
chunk_count: usize,
|
||||
cache_status: String,
|
||||
warning: String,
|
||||
fallback_context: Option<&EpiphanyMemoryContextPacket>,
|
||||
) -> PersonaMemoryRecallRender {
|
||||
let rendered_recall = if let Some(packet) = fallback_context {
|
||||
let fallback = render_persona_semantic_memory_recall(packet);
|
||||
format!("{warning}\nFalling back to typed memory graph context:\n{fallback}")
|
||||
} else {
|
||||
format!(
|
||||
"{warning}\n- semantic Persona memory recall unavailable; use projected state and direct room evidence only"
|
||||
)
|
||||
};
|
||||
|
||||
PersonaMemoryRecallRender {
|
||||
schema_version: PERSONA_MEMORY_CACHE_SCHEMA_VERSION.to_string(),
|
||||
status: "fallback".to_string(),
|
||||
cache_status,
|
||||
identity_id,
|
||||
role_id,
|
||||
chunk_count,
|
||||
hit_count: 0,
|
||||
rendered_recall,
|
||||
warnings: vec![warning],
|
||||
private_state_exposed: false,
|
||||
}
|
||||
}
|
||||
|
||||
struct PersonaMemoryChunkInput<'a> {
|
||||
identity_id: &'a str,
|
||||
role_id: &'a str,
|
||||
|
|
@ -711,6 +833,8 @@ mod tests {
|
|||
use crate::agent_memory::GhostlightMemory;
|
||||
use crate::agent_memory::GhostlightValue;
|
||||
use crate::agent_memory::GhostlightWorld;
|
||||
use epiphany_state_model::EpiphanyMemoryContextPacket;
|
||||
use epiphany_state_model::EpiphanyMemoryNode;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
|
|
@ -846,6 +970,172 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_memory_recall_bridge_renders_qdrant_hits() -> Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let qdrant = runtime.block_on(MockServer::start());
|
||||
let ollama = runtime.block_on(MockServer::start());
|
||||
let config = PersonaMemoryCacheConfig {
|
||||
qdrant_url: qdrant.uri(),
|
||||
qdrant_api_key: None,
|
||||
qdrant_timeout_ms: 5_000,
|
||||
ollama_base_url: ollama.uri(),
|
||||
ollama_model: "qwen3-embedding:0.6b".to_string(),
|
||||
ollama_timeout_ms: 5_000,
|
||||
collection_name: "epiphany_persona_memory_bridge_test".to_string(),
|
||||
query_instruction: DEFAULT_QUERY_INSTRUCTION.to_string(),
|
||||
};
|
||||
let chunks = build_persona_memory_chunks(&persona_entry(), "state/agents.msgpack#Persona");
|
||||
|
||||
runtime.block_on(async {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/collections/epiphany_persona_memory_bridge_test/exists"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": { "exists": true }
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&qdrant)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/collections/epiphany_persona_memory_bridge_test/points/delete"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": { "status": "acknowledged" }
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&qdrant)
|
||||
.await;
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/collections/epiphany_persona_memory_bridge_test/points"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": { "status": "acknowledged" }
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&qdrant)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/collections/epiphany_persona_memory_bridge_test/points/query"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": {
|
||||
"points": [{
|
||||
"score": 0.97,
|
||||
"payload": chunks[0],
|
||||
}]
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&qdrant)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/embed"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"embeddings": chunks.iter().map(|_| vec![0.1_f32, 0.2_f32, 0.3_f32]).collect::<Vec<_>>()
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&ollama)
|
||||
.await;
|
||||
});
|
||||
|
||||
let render = render_persona_memory_recall_with_cache(
|
||||
&persona_entry(),
|
||||
"state/agents.msgpack#Persona",
|
||||
"clean contracts",
|
||||
4,
|
||||
None,
|
||||
&config,
|
||||
);
|
||||
assert_eq!(render.status, "ok");
|
||||
assert_eq!(render.cache_status, "qdrant-hit");
|
||||
assert!(
|
||||
render
|
||||
.rendered_recall
|
||||
.contains("Qdrant Persona-memory cache hits")
|
||||
);
|
||||
assert!(render.rendered_recall.contains("Public machine-saint"));
|
||||
assert!(!render.rendered_recall.contains("sealed private ache"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_memory_recall_bridge_falls_back_to_memory_graph_context() -> Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let qdrant = runtime.block_on(MockServer::start());
|
||||
let ollama = runtime.block_on(MockServer::start());
|
||||
let config = PersonaMemoryCacheConfig {
|
||||
qdrant_url: qdrant.uri(),
|
||||
qdrant_api_key: None,
|
||||
qdrant_timeout_ms: 5_000,
|
||||
ollama_base_url: ollama.uri(),
|
||||
ollama_model: "qwen3-embedding:0.6b".to_string(),
|
||||
ollama_timeout_ms: 5_000,
|
||||
collection_name: "epiphany_persona_memory_bridge_fallback".to_string(),
|
||||
query_instruction: DEFAULT_QUERY_INSTRUCTION.to_string(),
|
||||
};
|
||||
let chunks = build_persona_memory_chunks(&persona_entry(), "state/agents.msgpack#Persona");
|
||||
|
||||
runtime.block_on(async {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/embed"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"embeddings": chunks.iter().map(|_| vec![0.1_f32, 0.2_f32, 0.3_f32]).collect::<Vec<_>>()
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&ollama)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(
|
||||
"/collections/epiphany_persona_memory_bridge_fallback/exists",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
|
||||
"status": "unavailable"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&qdrant)
|
||||
.await;
|
||||
});
|
||||
|
||||
let fallback = EpiphanyMemoryContextPacket {
|
||||
id: "memctx-fallback".to_string(),
|
||||
query_id: "persona-fallback".to_string(),
|
||||
nodes: vec![EpiphanyMemoryNode {
|
||||
id: "node-fallback".to_string(),
|
||||
title: "Fallback typed memory".to_string(),
|
||||
claim: "Typed memory graph recall remains available when Qdrant is down."
|
||||
.to_string(),
|
||||
action_implication: "Use fallback context without pretending cache was live."
|
||||
.to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let render = render_persona_memory_recall_with_cache(
|
||||
&persona_entry(),
|
||||
"state/agents.msgpack#Persona",
|
||||
"clean contracts",
|
||||
4,
|
||||
Some(&fallback),
|
||||
&config,
|
||||
);
|
||||
assert_eq!(render.status, "fallback");
|
||||
assert_eq!(render.cache_status, "qdrant-unavailable");
|
||||
assert!(
|
||||
render
|
||||
.rendered_recall
|
||||
.contains("Falling back to typed memory graph context")
|
||||
);
|
||||
assert!(
|
||||
render
|
||||
.rendered_recall
|
||||
.contains("Typed memory graph recall remains available")
|
||||
);
|
||||
assert!(!render.private_state_exposed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persona_entry() -> EpiphanyAgentMemoryEntry {
|
||||
EpiphanyAgentMemoryEntry {
|
||||
schema_version: "ghostlight.agent_state.v0".to_string(),
|
||||
|
|
|
|||
Binary file not shown.
Reference in a new issue