klieo-memory-qdrant 0.3.0

Qdrant-backed implementation of klieo-core's LongTermMemory.
Documentation
//! `QdrantLongTerm` — `LongTermMemory` over a single Qdrant collection
//! with scope filtering on the payload.
//!
//! Schema (per point):
//! - **id**: random UUID v4 (Qdrant accepts UUID strings as point IDs)
//! - **vector**: `Embedder::embed(fact.text)`
//! - **payload**:
//!   - `fact_id`     — caller-facing `FactId` (matches the UUID above)
//!   - `text`        — the fact body
//!   - `metadata`    — caller's opaque JSON, serialised to a string
//!   - `scope_kind`  — "workspace" | "agent" | "global"
//!   - `scope_value` — the scope's owner string (empty for global)
//!
//! `recall` issues `search_points` against the collection with a
//! `Filter` on `scope_kind` + `scope_value`, top-k by cosine.
//! `forget` deletes points by `fact_id` payload match.

use crate::client::QdrantHandle;
use crate::embedder::Embedder;
use crate::error::store_err;
use async_trait::async_trait;
use klieo_core::error::MemoryError;
use klieo_core::ids::FactId;
use klieo_core::memory::{Fact, LongTermMemory, Scope};
use qdrant_client::qdrant::{
    Condition, DeletePointsBuilder, Filter, PointStruct, SearchPointsBuilder, UpsertPointsBuilder,
    Value,
};
use std::collections::HashMap;
use std::sync::Arc;

/// Heuristic: does this Qdrant error indicate the collection isn't
/// present? `qdrant-client` collapses several internal error types
/// behind a single boxed enum, so a string-level inspection is the
/// least-fragile option that does not pin us to a specific client
/// version. `collection_exists` precheck is no longer needed because
/// `ensure_collection` is cached per-process (see [`QdrantHandle`]);
/// for callers like [`recall`] that may race ahead of the first
/// `remember`, this maps the natural error to an empty result.
fn is_missing_collection<E: std::fmt::Display>(error: &E) -> bool {
    let message = error.to_string().to_ascii_lowercase();
    message.contains("doesn't exist")
        || message.contains("does not exist")
        || message.contains("not found")
        || message.contains("not_found")
}

/// Qdrant-backed long-term semantic memory.
pub struct QdrantLongTerm {
    handle: QdrantHandle,
    embedder: Arc<dyn Embedder>,
}

impl QdrantLongTerm {
    pub(crate) fn new(handle: QdrantHandle, embedder: Arc<dyn Embedder>) -> Self {
        Self { handle, embedder }
    }

    /// Single shared collection name. Scope is a payload filter, not
    /// a per-collection sharding strategy — per-scope collections add
    /// operational complexity (creation races, dimension mismatches)
    /// without clear wins for the current trait surface.
    fn collection_name(&self) -> String {
        format!("{}_v1", self.handle.collection_prefix)
    }

    fn payload_for(scope: &Scope, fact: &Fact, fact_id: &str) -> HashMap<String, Value> {
        let (kind, value) = match scope {
            Scope::Workspace(s) => ("workspace", s.clone()),
            Scope::Agent(s) => ("agent", s.clone()),
            Scope::Global => ("global", String::new()),
        };
        let mut p = HashMap::new();
        p.insert("fact_id".into(), Value::from(fact_id.to_string()));
        p.insert("text".into(), Value::from(fact.text.clone()));
        p.insert("scope_kind".into(), Value::from(kind));
        p.insert("scope_value".into(), Value::from(value));
        // Metadata stored as a JSON-string payload field. Qdrant's
        // native nested-map support varies across client revisions;
        // string is the lowest-common-denominator round-trip.
        let metadata_str =
            serde_json::to_string(&fact.metadata).unwrap_or_else(|_| "null".to_string());
        p.insert("metadata".into(), Value::from(metadata_str));
        p
    }

    fn scope_filter(scope: &Scope) -> Filter {
        let (kind, value) = match scope {
            Scope::Workspace(s) => ("workspace", s.clone()),
            Scope::Agent(s) => ("agent", s.clone()),
            Scope::Global => ("global", String::new()),
        };
        Filter::must([
            Condition::matches("scope_kind", kind.to_string()),
            Condition::matches("scope_value", value),
        ])
    }
}

#[async_trait]
impl LongTermMemory for QdrantLongTerm {
    async fn remember(&self, scope: Scope, fact: Fact) -> Result<FactId, MemoryError> {
        let collection = self.collection_name();
        let dim = self.embedder.dimension() as u64;
        self.handle.ensure_collection(&collection, dim).await?;

        let vectors = self
            .embedder
            .embed(std::slice::from_ref(&fact.text))
            .await?;
        let vector = vectors
            .into_iter()
            .next()
            .ok_or_else(|| MemoryError::Embedding("embedder returned empty vec".into()))?;
        if vector.len() != dim as usize {
            return Err(MemoryError::Embedding(format!(
                "embedder produced {}-dim vector, expected {}",
                vector.len(),
                dim
            )));
        }

        // Mint a UUID — Qdrant accepts UUID strings as point IDs and
        // the same string round-trips through `FactId`.
        let id_string = uuid::Uuid::new_v4().to_string();
        let payload = Self::payload_for(&scope, &fact, &id_string);
        let point = PointStruct::new(id_string.clone(), vector, payload);

        self.handle
            .client
            .upsert_points(UpsertPointsBuilder::new(&collection, vec![point]).wait(true))
            .await
            .map_err(store_err)?;

        Ok(FactId::new(id_string))
    }

    async fn recall(&self, scope: Scope, query: &str, k: usize) -> Result<Vec<Fact>, MemoryError> {
        if k == 0 {
            return Ok(Vec::new());
        }
        let collection = self.collection_name();
        let dim = self.embedder.dimension() as u64;
        let query_vec = self
            .embedder
            .embed(&[query.to_string()])
            .await?
            .into_iter()
            .next()
            .ok_or_else(|| MemoryError::Embedding("embedder returned empty vec".into()))?;
        if query_vec.len() != dim as usize {
            return Err(MemoryError::Embedding(format!(
                "embedder produced {}-dim vector, expected {}",
                query_vec.len(),
                dim
            )));
        }

        let req = SearchPointsBuilder::new(&collection, query_vec, k as u64)
            .filter(Self::scope_filter(&scope))
            .with_payload(true);
        let res = match self.handle.client.search_points(req).await {
            Ok(r) => r,
            Err(e) if is_missing_collection(&e) => return Ok(Vec::new()),
            Err(e) => return Err(store_err(e)),
        };

        let mut out = Vec::with_capacity(res.result.len());
        for point in res.result {
            let text = point
                .payload
                .get("text")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string())
                .unwrap_or_default();
            let metadata = point
                .payload
                .get("metadata")
                .and_then(|v| v.as_str())
                .map(|s| {
                    serde_json::from_str::<serde_json::Value>(s).unwrap_or(serde_json::Value::Null)
                })
                .unwrap_or(serde_json::Value::Null);
            out.push(Fact { text, metadata });
        }
        Ok(out)
    }

    async fn forget(&self, id: FactId) -> Result<(), MemoryError> {
        let collection = self.collection_name();
        let filter = Filter::must([Condition::matches("fact_id", id.to_string())]);
        let res = self
            .handle
            .client
            .delete_points(
                DeletePointsBuilder::new(&collection)
                    .points(filter)
                    .wait(true),
            )
            .await;
        match res {
            Ok(_) => Ok(()),
            Err(e) if is_missing_collection(&e) => Ok(()),
            Err(e) => Err(store_err(e)),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[allow(dead_code)]
    fn _trait_object_safe(_: &dyn LongTermMemory) {}

    #[test]
    fn payload_carries_scope_text_and_fact_id() {
        let scope = Scope::Workspace("ws".into());
        let fact = Fact {
            text: "hi".into(),
            metadata: serde_json::Value::Null,
        };
        let p = QdrantLongTerm::payload_for(&scope, &fact, "fact-abc");
        assert_eq!(p.get("text").unwrap().as_str().unwrap(), "hi");
        assert_eq!(p.get("scope_kind").unwrap().as_str().unwrap(), "workspace");
        assert_eq!(p.get("scope_value").unwrap().as_str().unwrap(), "ws");
        assert_eq!(p.get("fact_id").unwrap().as_str().unwrap(), "fact-abc");
        assert_eq!(p.get("metadata").unwrap().as_str().unwrap(), "null");
    }

    #[test]
    fn payload_for_global_scope_uses_empty_string_value() {
        let fact = Fact {
            text: "g".into(),
            metadata: serde_json::Value::Null,
        };
        let p = QdrantLongTerm::payload_for(&Scope::Global, &fact, "fact-1");
        assert_eq!(p.get("scope_kind").unwrap().as_str().unwrap(), "global");
        assert_eq!(p.get("scope_value").unwrap().as_str().unwrap(), "");
    }

    /// Synthetic error type whose `Display` mimics the messages Qdrant
    /// returns. Keeps the heuristic tests independent of the live
    /// client.
    struct FakeErr(&'static str);
    impl std::fmt::Display for FakeErr {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            f.write_str(self.0)
        }
    }

    #[test]
    fn is_missing_collection_matches_doesnt_exist() {
        assert!(is_missing_collection(&FakeErr(
            "Status { code: NotFound, message: \"Collection 'klieo_facts_v1' doesn't exist\" }"
        )));
    }

    #[test]
    fn is_missing_collection_matches_does_not_exist() {
        assert!(is_missing_collection(&FakeErr(
            "collection 'klieo_facts_v1' does not exist"
        )));
    }

    #[test]
    fn is_missing_collection_matches_not_found_case_insensitive() {
        assert!(is_missing_collection(&FakeErr(
            "Status { code: NOT_FOUND, message: \"\" }"
        )));
        assert!(is_missing_collection(&FakeErr("resource Not Found")));
    }

    #[test]
    fn is_missing_collection_rejects_unrelated_errors() {
        assert!(!is_missing_collection(&FakeErr("dimension mismatch")));
        assert!(!is_missing_collection(&FakeErr("connection refused")));
        assert!(!is_missing_collection(&FakeErr(
            "permission denied for collection"
        )));
    }
}