From 10199658819abff4504e984dac4dafb6bfda37a1 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 15 Jul 2026 15:49:06 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20memory=E2=86=94document=20cross-ent?= =?UTF-8?q?ity=20linking=20via=20[[doc-slug]]=20wikilinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When [[slug]] in memory content doesn't match any memory, Uteke now checks if it matches a document slug. If so, creates a 'references_doc' edge (auto-backlinked as 'referenced_by'). New API: - POST /memory/doc-refs → document slugs referenced by a memory - POST /doc/mem-refs → memory IDs referencing a document - Uteke::recall_memories_for_document(slug) - Uteke::recall_documents_for_memory(memory_id) - Store::edge_targets() / edge_sources() — generic typed edge queries UnifiedSearchResult enriched with: - linked_doc_slugs (for memory results) - linked_memory_ids (for document results) Refs #689 --- crates/uteke-core/src/edges.rs | 98 +++++++++++++++++++++++---- crates/uteke-core/src/lib.rs | 43 +++++++++++- crates/uteke-core/src/memory/types.rs | 13 ++++ crates/uteke-server/src/handlers.rs | 60 +++++++++++++++- 4 files changed, 196 insertions(+), 18 deletions(-) diff --git a/crates/uteke-core/src/edges.rs b/crates/uteke-core/src/edges.rs index 15f3d23..39129f2 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,37 @@ 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..37206ae 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,37 @@ 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..a669100 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,9 +1253,65 @@ 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") => { + let body = match read_json(req.as_reader()) { + Ok(b) => b, + Err(e) => return ctx.error_response_for(req, 400, e), + }; + let memory_id = body + .get("memory_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if memory_id.is_empty() { + return ctx.error_response_for(req, 400, "memory_id is required"); + } + match uteke.recall_documents_for_memory(memory_id) { + Ok(slugs) => ctx.ok_response_for( + req, + &serde_json::json!({ + "memory_id": memory_id, + "doc_slugs": slugs, + }), + ), + Err(e) => { + error!("memory/doc-refs error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + + // POST /doc/mem-refs — get memory IDs that reference a document + (Method::Post, "/doc/mem-refs") => { + let body = match read_json(req.as_reader()) { + Ok(b) => b, + Err(e) => return ctx.error_response_for(req, 400, e), + }; + let doc_slug = body + .get("doc_slug") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if doc_slug.is_empty() { + return ctx.error_response_for(req, 400, "doc_slug is required"); + } + match uteke.recall_memories_for_document(doc_slug) { + Ok(memory_ids) => ctx.ok_response_for( + req, + &serde_json::json!({ + "doc_slug": doc_slug, + "memory_ids": memory_ids, + }), + ), + Err(e) => { + error!("doc/mem-refs error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + // ── Tags: List with counts ─────────────────────────────────────── (Method::Get, p) if p == "/tags" || p.starts_with("/tags?") => { - let ns = parse_query_namespace(&path); match uteke.tags_with_counts(ns.as_deref()) { Ok(tags) => ctx.ok_response_for(req, &tags), Err(e) => { From 978c9f2edff7833d68e390a971b3753bfd5e3007 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 15 Jul 2026 16:02:30 +0700 Subject: [PATCH 2/2] fix: use read_body + typed deserialization, add missing ns binding - Replace read_json() (nonexistent) with read_body() pattern in /memory/doc-refs and /doc/mem-refs handlers - Add missing let ns = parse_query_namespace(&path) in /tags handler - Apply cargo fmt --- crates/uteke-core/src/edges.rs | 3 +- crates/uteke-core/src/lib.rs | 7 ++- crates/uteke-server/src/handlers.rs | 81 +++++++++++++---------------- 3 files changed, 41 insertions(+), 50 deletions(-) diff --git a/crates/uteke-core/src/edges.rs b/crates/uteke-core/src/edges.rs index 39129f2..e7c5cc0 100644 --- a/crates/uteke-core/src/edges.rs +++ b/crates/uteke-core/src/edges.rs @@ -740,8 +740,7 @@ impl crate::Uteke { // 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())); + resolved.push((doc_id, EDGE_REFERENCES_DOC.to_string())); } Ok(None) => { tracing::debug!( diff --git a/crates/uteke-core/src/lib.rs b/crates/uteke-core/src/lib.rs index 37206ae..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_REFERENCES_DOC, - 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}; @@ -1652,8 +1652,7 @@ impl Uteke { Some(d) => d.id, None => return Ok(Vec::new()), }; - self.store - .edge_sources(&doc_id, EDGE_REFERENCES_DOC) + self.store.edge_sources(&doc_id, EDGE_REFERENCES_DOC) } /// Recall document slugs referenced by a memory via `[[doc-slug]]` wikilinks. diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index a669100..3b022f8 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -1256,62 +1256,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") => { - let body = match read_json(req.as_reader()) { - Ok(b) => b, - Err(e) => return ctx.error_response_for(req, 400, e), - }; - let memory_id = body - .get("memory_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if memory_id.is_empty() { - return ctx.error_response_for(req, 400, "memory_id is required"); + #[derive(Deserialize)] + struct MemoryDocRefsReq { + memory_id: String, } - match uteke.recall_documents_for_memory(memory_id) { - Ok(slugs) => ctx.ok_response_for( - req, - &serde_json::json!({ - "memory_id": memory_id, - "doc_slugs": slugs, - }), - ), - Err(e) => { - error!("memory/doc-refs error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } + 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") => { - let body = match read_json(req.as_reader()) { - Ok(b) => b, - Err(e) => return ctx.error_response_for(req, 400, e), - }; - let doc_slug = body - .get("doc_slug") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if doc_slug.is_empty() { - return ctx.error_response_for(req, 400, "doc_slug is required"); + #[derive(Deserialize)] + struct DocMemRefsReq { + doc_slug: String, } - match uteke.recall_memories_for_document(doc_slug) { - Ok(memory_ids) => ctx.ok_response_for( - req, - &serde_json::json!({ - "doc_slug": doc_slug, - "memory_ids": memory_ids, - }), - ), - Err(e) => { - error!("doc/mem-refs error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } + 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); match uteke.tags_with_counts(ns.as_deref()) { Ok(tags) => ctx.ok_response_for(req, &tags), Err(e) => {