diff --git a/crates/uteke-core/src/edges.rs b/crates/uteke-core/src/edges.rs index 15f3d23..e7c5cc0 100644 --- a/crates/uteke-core/src/edges.rs +++ b/crates/uteke-core/src/edges.rs @@ -32,6 +32,8 @@ pub const EDGE_SUPERSEDES: &str = "supersedes"; pub const EDGE_REPLIES_TO: &str = "replies_to"; /// Cosine-similarity auto-link edge (#401). pub const EDGE_SIMILAR_TO: &str = "similar_to"; +/// Edge type for `[[doc-slug]]` document references (#689). +pub const EDGE_REFERENCES_DOC: &str = "references_doc"; /// Possible duplicate edge — high cosine similarity (#401). pub const EDGE_POSSIBLE_DUPLICATE: &str = "possible_duplicate"; /// Inverse edge type created automatically by backlink auto-generation (#350). @@ -47,6 +49,7 @@ pub const EDGE_REFERENCED_BY: &str = "referenced_by"; /// infinite ping-pong. const BACKLINKED_EDGE_TYPES: &[&str] = &[ EDGE_REFERENCES, + EDGE_REFERENCES_DOC, EDGE_TAGGED_AS, EDGE_SUPERSEDES, EDGE_REPLIES_TO, @@ -446,6 +449,52 @@ impl Store { Ok(row) } + /// Resolve a slug to a document id (not memory). Returns `None` if no + /// document with that slug exists. Used by `wire_edges` for `[[doc-slug]]` + /// cross-entity linking (#689). + pub fn resolve_document_slug(&self, slug: &str) -> Result, Error> { + self.conn + .query_row( + "SELECT id FROM documents WHERE slug = ?1 LIMIT 1", + params![slug], + |r| r.get::<_, String>(0), + ) + .optional() + .map_err(|e| Error::db("resolve document slug", e)) + } + + /// Get all distinct target IDs for edges from `source_id` with a specific + /// edge_type. Used for cross-entity recall (#689). + pub fn edge_targets(&self, source_id: &str, edge_type: &str) -> Result, Error> { + let mut stmt = self + .conn + .prepare( + "SELECT DISTINCT target_id FROM memory_edges \ + WHERE source_id = ?1 AND edge_type = ?2", + ) + .map_err(|e| Error::db("prepare edge_targets", e))?; + let rows = stmt + .query_map(params![source_id, edge_type], |r| r.get::<_, String>(0)) + .map_err(|e| Error::db("query edge_targets", e))?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + + /// Get all distinct source IDs for edges pointing to `target_id` with a + /// specific edge_type. Used for cross-entity recall (#689). + pub fn edge_sources(&self, target_id: &str, edge_type: &str) -> Result, Error> { + let mut stmt = self + .conn + .prepare( + "SELECT DISTINCT source_id FROM memory_edges \ + WHERE target_id = ?1 AND edge_type = ?2", + ) + .map_err(|e| Error::db("prepare edge_sources", e))?; + let rows = stmt + .query_map(params![target_id, edge_type], |r| r.get::<_, String>(0)) + .map_err(|e| Error::db("query edge_sources", e))?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + /// Resolve a tag to the most recent memory id carrying that tag. pub fn resolve_tag_to_memory( &self, @@ -589,18 +638,18 @@ impl Store { // Collect all forward edges first to avoid holding a read cursor // while we write backlinks into the same table. // - // BACKLINKED_EDGE_TYPES is fixed at 4 entries (references, tagged_as, - // supersedes, replies_to) — we bind them positionally. + // BACKLINKED_EDGE_TYPES is fixed at 5 entries (references, references_doc, + // tagged_as, supersedes, replies_to) — we bind them positionally. debug_assert_eq!( BACKLINKED_EDGE_TYPES.len(), - 4, + 5, "update rebuild_backlinks SQL if backlinked types change" ); let mut stmt = self .conn .prepare( "SELECT source_id, target_id FROM memory_edges - WHERE edge_type IN (?1, ?2, ?3, ?4)", + WHERE edge_type IN (?1, ?2, ?3, ?4, ?5)", ) .map_err(|e| Error::db("prepare rebuild backlinks scan", e))?; let rows: Vec<(String, String)> = stmt @@ -610,6 +659,7 @@ impl Store { BACKLINKED_EDGE_TYPES[1], BACKLINKED_EDGE_TYPES[2], BACKLINKED_EDGE_TYPES[3], + BACKLINKED_EDGE_TYPES[4], ], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), ) @@ -679,19 +729,36 @@ impl crate::Uteke { let mut resolved: Vec<(String, String)> = Vec::new(); for r in refs { match r { - ExtractedRef::Slug(slug) => match self.store.resolve_slug(&slug, namespace) { - Ok(Some(target)) => { - if target != source_id { - resolved.push((target, EDGE_REFERENCES.to_string())); + ExtractedRef::Slug(slug) => { + match self.store.resolve_slug(&slug, namespace) { + Ok(Some(target)) => { + if target != source_id { + resolved.push((target, EDGE_REFERENCES.to_string())); + } + } + Ok(None) => { + // Slug not found in memories — check documents (#689). + match self.store.resolve_document_slug(&slug) { + Ok(Some(doc_id)) => { + resolved.push((doc_id, EDGE_REFERENCES_DOC.to_string())); + } + Ok(None) => { + tracing::debug!( + "Auto-edge: slug '{slug}' unresolved (no memory or document), skipped" + ); + } + Err(e) => { + tracing::warn!( + "Auto-edge document slug resolve failed for '{slug}': {e}" + ); + } + } + } + Err(e) => { + tracing::warn!("Auto-edge slug resolve failed for '{slug}': {e}"); } } - Ok(None) => { - tracing::debug!("Auto-edge: slug '{slug}' unresolved, skipped"); - } - Err(e) => { - tracing::warn!("Auto-edge slug resolve failed for '{slug}': {e}"); - } - }, + } ExtractedRef::Tag(tag) => match self.store.resolve_tag_to_memory(&tag, namespace) { Ok(Some(target)) => { if target != source_id { diff --git a/crates/uteke-core/src/lib.rs b/crates/uteke-core/src/lib.rs index 0f4b566..687de80 100644 --- a/crates/uteke-core/src/lib.rs +++ b/crates/uteke-core/src/lib.rs @@ -35,8 +35,8 @@ pub use chunker::{ }; pub use dream::{DreamPhase, DreamReport, PhaseResult, PhaseStatus}; pub use edges::{ - backlink_type_for, EdgeList, MemoryEdge, EDGE_REFERENCED_BY, EDGE_REFERENCES, EDGE_REPLIES_TO, - EDGE_SUPERSEDES, EDGE_TAGGED_AS, + backlink_type_for, EdgeList, MemoryEdge, EDGE_REFERENCED_BY, EDGE_REFERENCES, + EDGE_REFERENCES_DOC, EDGE_REPLIES_TO, EDGE_SUPERSEDES, EDGE_TAGGED_AS, }; pub use graph::{build_meta_relationship, is_relationship_meta, Relationship, VALID_REL_TYPES}; pub use graph::{GraphEdge, GraphNode, GraphPath, GraphStats, GraphStore, GraphTriple}; @@ -1435,6 +1435,8 @@ impl Uteke { last_accessed: m.last_accessed, created_at: Some(m.created_at), updated_at: Some(m.updated_at), + linked_doc_slugs: None, + linked_memory_ids: None, } }) .collect()) @@ -1485,6 +1487,8 @@ impl Uteke { last_accessed: None, created_at: None, updated_at: None, + linked_doc_slugs: None, + linked_memory_ids: None, }) .collect()) } @@ -1581,6 +1585,8 @@ impl Uteke { last_accessed: m.last_accessed, created_at: Some(m.created_at), updated_at: Some(m.updated_at), + linked_doc_slugs: None, + linked_memory_ids: None, } } else if let Some(dr) = doc_map.remove(&key) { UnifiedSearchResult { @@ -1616,6 +1622,8 @@ impl Uteke { last_accessed: None, created_at: None, updated_at: None, + linked_doc_slugs: None, + linked_memory_ids: None, } } else { unreachable!("RRF key must reference either mem_map or doc_map") @@ -1632,6 +1640,36 @@ impl Uteke { Ok(results) } + + // ── Cross-entity recall (#689) ────────────────────────────────────── + + /// Recall memories that reference a document via `[[doc-slug]]` wikilinks. + /// + /// Looks up `references_doc` edges where the document is the target. + /// Returns memory IDs (not full memories) for lightweight cross-referencing. + pub fn recall_memories_for_document(&self, doc_slug: &str) -> Result, Error> { + let doc_id = match self.store.get_document_by_slug(doc_slug)? { + Some(d) => d.id, + None => return Ok(Vec::new()), + }; + self.store.edge_sources(&doc_id, EDGE_REFERENCES_DOC) + } + + /// Recall document slugs referenced by a memory via `[[doc-slug]]` wikilinks. + /// + /// Looks up `references_doc` edges where the memory is the source. + /// Returns document slugs for human-readable cross-referencing. + pub fn recall_documents_for_memory(&self, memory_id: &str) -> Result, Error> { + let doc_ids = self.store.edge_targets(memory_id, EDGE_REFERENCES_DOC)?; + let mut slugs = Vec::with_capacity(doc_ids.len()); + for id in doc_ids { + let doc = self.store.get_document(&id)?; + if let Some(d) = doc { + slugs.push(d.slug); + } + } + Ok(slugs) + } } /// Graph visualization data (#408). diff --git a/crates/uteke-core/src/memory/types.rs b/crates/uteke-core/src/memory/types.rs index 098014e..41153d9 100644 --- a/crates/uteke-core/src/memory/types.rs +++ b/crates/uteke-core/src/memory/types.rs @@ -178,6 +178,15 @@ pub struct UnifiedSearchResult { /// When this memory was last updated. #[serde(default, skip_serializing_if = "Option::is_none")] pub updated_at: Option>, + // ── Cross-entity linking fields (#689) ────────────────────────── + /// Document slugs referenced by this memory via `[[doc-slug]]` wikilinks. + /// Populated for type=memory when cross-references exist. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linked_doc_slugs: Option>, + /// Memory IDs that reference this document. + /// Populated for type=document when cross-references exist. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linked_memory_ids: Option>, } /// Filter for unified search — which sources to query (#531). @@ -977,6 +986,8 @@ mod tests { last_accessed: Some(now), created_at: Some(now), updated_at: Some(now), + linked_doc_slugs: None, + linked_memory_ids: None, }; let json = serde_json::to_string(&result).unwrap(); // Verify result_type field is lowercase "memory" @@ -1033,6 +1044,8 @@ mod tests { last_accessed: None, created_at: None, updated_at: None, + linked_doc_slugs: None, + linked_memory_ids: None, }; let json = serde_json::to_string(&result).unwrap(); assert!(json.contains("\"result_type\":\"document\""), "got: {json}"); diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index ae6425e..3b022f8 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -62,6 +62,8 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< "/doc/get", "/doc/list", "/doc/search", + "/memory/doc-refs", + "/doc/mem-refs", "/orphans", ]; let is_read = method == Method::Get || read_only_post_paths.iter().any(|ep| path == *ep); @@ -1251,6 +1253,55 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< } } + // ── Cross-entity references (#689) ─────────────────────────────── + // POST /memory/doc-refs — get document slugs referenced by a memory + (Method::Post, "/memory/doc-refs") => { + #[derive(Deserialize)] + struct MemoryDocRefsReq { + memory_id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.recall_documents_for_memory(&req_data.memory_id) { + Ok(slugs) => ctx.ok_response_for( + req, + &serde_json::json!({ + "memory_id": req_data.memory_id, + "doc_slugs": slugs, + }), + ), + Err(e) => { + error!("memory/doc-refs error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } + } + + // POST /doc/mem-refs — get memory IDs that reference a document + (Method::Post, "/doc/mem-refs") => { + #[derive(Deserialize)] + struct DocMemRefsReq { + doc_slug: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.recall_memories_for_document(&req_data.doc_slug) { + Ok(memory_ids) => ctx.ok_response_for( + req, + &serde_json::json!({ + "doc_slug": req_data.doc_slug, + "memory_ids": memory_ids, + }), + ), + Err(e) => { + error!("doc/mem-refs error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } + } + // ── Tags: List with counts ─────────────────────────────────────── (Method::Get, p) if p == "/tags" || p.starts_with("/tags?") => { let ns = parse_query_namespace(&path);