Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 82 additions & 15 deletions crates/uteke-core/src/edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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,
Expand Down Expand Up @@ -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<Option<String>, 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<Vec<String>, 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<Vec<String>, 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,
Expand Down Expand Up @@ -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
Expand All @@ -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)?)),
)
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 40 additions & 2 deletions crates/uteke-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -1485,6 +1487,8 @@ impl Uteke {
last_accessed: None,
created_at: None,
updated_at: None,
linked_doc_slugs: None,
linked_memory_ids: None,
})
.collect())
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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<Vec<String>, 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<Vec<String>, 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).
Expand Down
13 changes: 13 additions & 0 deletions crates/uteke-core/src/memory/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<chrono::DateTime<chrono::Utc>>,
// ── 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<Vec<String>>,
/// 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<Vec<String>>,
}

/// Filter for unified search — which sources to query (#531).
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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}");
Expand Down
51 changes: 51 additions & 0 deletions crates/uteke-server/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ pub fn route(uteke: &Mutex<Uteke>, 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);
Expand Down Expand Up @@ -1251,6 +1253,55 @@ pub fn route(uteke: &Mutex<Uteke>, 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::<MemoryDocRefsReq>(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::<DocMemRefsReq>(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);
Expand Down