mirror of
https://github.com/ruvnet/RuView
synced 2026-07-26 18:01:48 +00:00
Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
//! Heuristic named-entity recognition (NER) for extracting entities from text.
|
||||
//!
|
||||
//! This module performs lightweight, regex-free entity extraction suitable for
|
||||
//! processing screen captures and transcriptions. It recognises:
|
||||
//!
|
||||
//! - **URLs** (`https://...` / `http://...`)
|
||||
//! - **Email addresses** (`user@domain.tld`)
|
||||
//! - **Mentions** (`@handle`)
|
||||
//! - **Capitalized phrases** (two or more consecutive capitalized words -> proper nouns)
|
||||
|
||||
/// Extract `(label, name)` pairs from free-form `text`.
|
||||
///
|
||||
/// Labels returned:
|
||||
/// - `"Url"` for HTTP(S) URLs
|
||||
/// - `"Email"` for email-like patterns
|
||||
/// - `"Mention"` for `@handle` patterns
|
||||
/// - `"Person"` for capitalized multi-word phrases (heuristic proper noun)
|
||||
pub fn extract_entities(text: &str) -> Vec<(String, String)> {
|
||||
let mut entities: Vec<(String, String)> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// --- URL detection ---
|
||||
for word in text.split_whitespace() {
|
||||
let trimmed =
|
||||
word.trim_matches(|c: char| c == ',' || c == '.' || c == ')' || c == '(' || c == ';');
|
||||
if (trimmed.starts_with("http://") || trimmed.starts_with("https://"))
|
||||
&& trimmed.len() > 10
|
||||
&& seen.insert(("Url", trimmed.to_string()))
|
||||
{
|
||||
entities.push(("Url".to_string(), trimmed.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Email detection ---
|
||||
for word in text.split_whitespace() {
|
||||
let trimmed = word.trim_matches(|c: char| {
|
||||
c == ',' || c == '.' || c == ')' || c == '(' || c == ';' || c == '<' || c == '>'
|
||||
});
|
||||
if is_email_like(trimmed) && seen.insert(("Email", trimmed.to_string())) {
|
||||
entities.push(("Email".to_string(), trimmed.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- @mention detection ---
|
||||
for word in text.split_whitespace() {
|
||||
let trimmed =
|
||||
word.trim_matches(|c: char| c == ',' || c == '.' || c == ')' || c == '(' || c == ';');
|
||||
if trimmed.starts_with('@') && trimmed.len() > 1 {
|
||||
let handle = trimmed.to_string();
|
||||
if seen.insert(("Mention", handle.clone())) {
|
||||
entities.push(("Mention".to_string(), handle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Capitalized phrase detection (proper nouns) ---
|
||||
let cap_phrases = extract_capitalized_phrases(text);
|
||||
for phrase in cap_phrases {
|
||||
if seen.insert(("Person", phrase.clone())) {
|
||||
entities.push(("Person".to_string(), phrase));
|
||||
}
|
||||
}
|
||||
|
||||
entities
|
||||
}
|
||||
|
||||
/// Returns `true` if `s` looks like an email address (`local@domain.tld`).
|
||||
fn is_email_like(s: &str) -> bool {
|
||||
// Must contain exactly one '@', with non-empty parts on both sides,
|
||||
// and the domain part must contain at least one '.'.
|
||||
if let Some(at_pos) = s.find('@') {
|
||||
let local = &s[..at_pos];
|
||||
let domain = &s[at_pos + 1..];
|
||||
!local.is_empty()
|
||||
&& !domain.is_empty()
|
||||
&& domain.contains('.')
|
||||
&& !domain.starts_with('.')
|
||||
&& !domain.ends_with('.')
|
||||
&& local
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '+')
|
||||
&& domain
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '.' || c == '-')
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract sequences of two or more consecutive capitalized words as likely
|
||||
/// proper nouns. Filters out common sentence-starting words when they appear
|
||||
/// alone at what looks like a sentence boundary.
|
||||
fn extract_capitalized_phrases(text: &str) -> Vec<String> {
|
||||
let mut phrases = Vec::new();
|
||||
let words: Vec<&str> = text.split_whitespace().collect();
|
||||
|
||||
let mut i = 0;
|
||||
while i < words.len() {
|
||||
// Skip words that start a sentence (preceded by nothing or a sentence-ending punctuation).
|
||||
let word = words[i].trim_matches(|c: char| !c.is_alphanumeric());
|
||||
if is_capitalized(word) && word.len() > 1 {
|
||||
// Accumulate consecutive capitalized words.
|
||||
let start = i;
|
||||
let mut parts = vec![word.to_string()];
|
||||
i += 1;
|
||||
while i < words.len() {
|
||||
let next = words[i].trim_matches(|c: char| !c.is_alphanumeric());
|
||||
if is_capitalized(next) && next.len() > 1 {
|
||||
parts.push(next.to_string());
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Only take phrases of 2+ words (single capitalized words are too noisy).
|
||||
if parts.len() >= 2 {
|
||||
// Skip if the first word is at position 0 or follows a sentence terminator
|
||||
// and is a common article/pronoun. We still keep it if part of a longer
|
||||
// multi-word phrase that itself is capitalized.
|
||||
let is_sentence_start = start == 0
|
||||
|| words.get(start.wrapping_sub(1)).is_some_and(|prev| {
|
||||
prev.ends_with('.') || prev.ends_with('!') || prev.ends_with('?')
|
||||
});
|
||||
|
||||
if is_sentence_start && parts.len() == 2 && is_common_starter(&parts[0]) {
|
||||
// Skip - likely just a sentence starting with "The Xyz" etc.
|
||||
} else {
|
||||
let phrase = parts.join(" ");
|
||||
phrases.push(phrase);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
phrases
|
||||
}
|
||||
|
||||
/// Returns `true` if the first character of `word` is uppercase ASCII.
|
||||
fn is_capitalized(word: &str) -> bool {
|
||||
word.chars().next().is_some_and(|c| c.is_uppercase())
|
||||
}
|
||||
|
||||
/// Common sentence-starting words that are not proper nouns.
|
||||
fn is_common_starter(word: &str) -> bool {
|
||||
matches!(
|
||||
word.to_lowercase().as_str(),
|
||||
"the"
|
||||
| "a"
|
||||
| "an"
|
||||
| "this"
|
||||
| "that"
|
||||
| "these"
|
||||
| "those"
|
||||
| "it"
|
||||
| "i"
|
||||
| "we"
|
||||
| "they"
|
||||
| "he"
|
||||
| "she"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_urls() {
|
||||
let entities =
|
||||
extract_entities("Visit https://example.com/page and http://foo.bar/baz for info.");
|
||||
let urls: Vec<_> = entities.iter().filter(|(l, _)| l == "Url").collect();
|
||||
assert_eq!(urls.len(), 2);
|
||||
assert_eq!(urls[0].1, "https://example.com/page");
|
||||
assert_eq!(urls[1].1, "http://foo.bar/baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_emails() {
|
||||
let entities = extract_entities("Email alice@example.com or bob@company.org for help.");
|
||||
let emails: Vec<_> = entities.iter().filter(|(l, _)| l == "Email").collect();
|
||||
assert_eq!(emails.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_mentions() {
|
||||
let entities = extract_entities("Hey @alice and @bob-dev, check this out.");
|
||||
let mentions: Vec<_> = entities.iter().filter(|(l, _)| l == "Mention").collect();
|
||||
assert_eq!(mentions.len(), 2);
|
||||
assert_eq!(mentions[0].1, "@alice");
|
||||
assert_eq!(mentions[1].1, "@bob-dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_capitalized_phrases() {
|
||||
let entities = extract_entities("I met John Smith at the World Trade Center yesterday.");
|
||||
let persons: Vec<_> = entities.iter().filter(|(l, _)| l == "Person").collect();
|
||||
assert!(persons.iter().any(|(_, n)| n == "John Smith"));
|
||||
assert!(persons.iter().any(|(_, n)| n == "World Trade Center"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_false_positives_on_sentence_start() {
|
||||
let entities = extract_entities("The cat sat on the mat.");
|
||||
let persons: Vec<_> = entities.iter().filter(|(l, _)| l == "Person").collect();
|
||||
// "The cat" should not appear as a person (single cap word + lowercase).
|
||||
assert!(persons.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplication() {
|
||||
let entities = extract_entities("Visit https://example.com and https://example.com again.");
|
||||
let urls: Vec<_> = entities.iter().filter(|(l, _)| l == "Url").collect();
|
||||
assert_eq!(urls.len(), 1);
|
||||
}
|
||||
}
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
//! Knowledge graph integration for OSpipe.
|
||||
//!
|
||||
//! Provides entity extraction from captured text and stores entity relationships
|
||||
//! in a [`ruvector_graph::GraphDB`] (native) or a lightweight in-memory stub (WASM).
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use ospipe::graph::KnowledgeGraph;
|
||||
//!
|
||||
//! let mut kg = KnowledgeGraph::new();
|
||||
//! let ids = kg.ingest_frame_entities("frame-001", "Meeting with John Smith at https://meet.example.com").unwrap();
|
||||
//! let people = kg.find_by_label("Person");
|
||||
//! ```
|
||||
|
||||
pub mod entity_extractor;
|
||||
|
||||
use crate::error::Result;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A lightweight entity representation returned by query methods.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entity {
|
||||
/// Unique identifier for this entity.
|
||||
pub id: String,
|
||||
/// Category label (e.g. "Person", "Url", "Mention", "Email", "Frame").
|
||||
pub label: String,
|
||||
/// Human-readable name or value.
|
||||
pub name: String,
|
||||
/// Additional key-value properties.
|
||||
pub properties: HashMap<String, String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native implementation (backed by ruvector-graph)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod inner {
|
||||
use super::*;
|
||||
use crate::error::OsPipeError;
|
||||
use ruvector_graph::{EdgeBuilder, GraphDB, NodeBuilder, PropertyValue};
|
||||
|
||||
/// A knowledge graph that stores entity relationships extracted from captured
|
||||
/// frames. On native targets this is backed by [`ruvector_graph::GraphDB`].
|
||||
pub struct KnowledgeGraph {
|
||||
db: GraphDB,
|
||||
}
|
||||
|
||||
impl KnowledgeGraph {
|
||||
/// Create a new, empty knowledge graph.
|
||||
pub fn new() -> Self {
|
||||
Self { db: GraphDB::new() }
|
||||
}
|
||||
|
||||
/// Add an entity node to the graph.
|
||||
///
|
||||
/// Returns the newly created node ID.
|
||||
pub fn add_entity(
|
||||
&self,
|
||||
label: &str,
|
||||
name: &str,
|
||||
properties: HashMap<String, String>,
|
||||
) -> Result<String> {
|
||||
let mut builder = NodeBuilder::new().label(label).property("name", name);
|
||||
|
||||
for (k, v) in &properties {
|
||||
builder = builder.property(k.as_str(), v.as_str());
|
||||
}
|
||||
|
||||
let node = builder.build();
|
||||
let id = self
|
||||
.db
|
||||
.create_node(node)
|
||||
.map_err(|e| OsPipeError::Storage(format!("graph: {}", e)))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Create a directed relationship (edge) between two entities.
|
||||
///
|
||||
/// Both `from_id` and `to_id` must refer to existing nodes.
|
||||
/// Returns the edge ID.
|
||||
pub fn add_relationship(
|
||||
&self,
|
||||
from_id: &str,
|
||||
to_id: &str,
|
||||
rel_type: &str,
|
||||
) -> Result<String> {
|
||||
let edge = EdgeBuilder::new(from_id.to_string(), to_id.to_string(), rel_type).build();
|
||||
let id = self
|
||||
.db
|
||||
.create_edge(edge)
|
||||
.map_err(|e| OsPipeError::Storage(format!("graph: {}", e)))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Find all entities that carry `label`.
|
||||
pub fn find_by_label(&self, label: &str) -> Vec<Entity> {
|
||||
self.db
|
||||
.get_nodes_by_label(label)
|
||||
.into_iter()
|
||||
.map(|n| node_to_entity(&n))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find all entities directly connected to `entity_id` (both outgoing and
|
||||
/// incoming edges).
|
||||
pub fn neighbors(&self, entity_id: &str) -> Vec<Entity> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
let node_id = entity_id.to_string();
|
||||
|
||||
// Outgoing neighbours.
|
||||
for edge in self.db.get_outgoing_edges(&node_id) {
|
||||
if seen.insert(edge.to.clone()) {
|
||||
if let Some(node) = self.db.get_node(&edge.to) {
|
||||
result.push(node_to_entity(&node));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming neighbours.
|
||||
for edge in self.db.get_incoming_edges(&node_id) {
|
||||
if seen.insert(edge.from.clone()) {
|
||||
if let Some(node) = self.db.get_node(&edge.from) {
|
||||
result.push(node_to_entity(&node));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Run heuristic NER on `text` and return extracted `(label, name)` pairs.
|
||||
pub fn extract_entities(text: &str) -> Vec<(String, String)> {
|
||||
entity_extractor::extract_entities(text)
|
||||
}
|
||||
|
||||
/// Extract entities from `text`, create nodes for each, link them to the
|
||||
/// given `frame_id` node (creating the frame node if it does not yet exist),
|
||||
/// and return the IDs of all newly created entity nodes.
|
||||
pub fn ingest_frame_entities(&self, frame_id: &str, text: &str) -> Result<Vec<String>> {
|
||||
// Ensure frame node exists.
|
||||
let frame_node_id = if self.db.get_node(frame_id).is_some() {
|
||||
frame_id.to_string()
|
||||
} else {
|
||||
let node = NodeBuilder::new()
|
||||
.id(frame_id)
|
||||
.label("Frame")
|
||||
.property("name", frame_id)
|
||||
.build();
|
||||
self.db
|
||||
.create_node(node)
|
||||
.map_err(|e| OsPipeError::Storage(format!("graph: {}", e)))?
|
||||
};
|
||||
|
||||
let extracted = entity_extractor::extract_entities(text);
|
||||
let mut entity_ids = Vec::with_capacity(extracted.len());
|
||||
|
||||
for (label, name) in &extracted {
|
||||
let entity_id = self.add_entity(label, name, HashMap::new())?;
|
||||
self.add_relationship(&frame_node_id, &entity_id, "CONTAINS")?;
|
||||
entity_ids.push(entity_id);
|
||||
}
|
||||
|
||||
Ok(entity_ids)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KnowledgeGraph {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `ruvector_graph::Node` into the crate-public `Entity` type.
|
||||
fn node_to_entity(node: &ruvector_graph::Node) -> Entity {
|
||||
let label = node
|
||||
.labels
|
||||
.first()
|
||||
.map_or_else(String::new, |l| l.name.clone());
|
||||
|
||||
let name = match node.get_property("name") {
|
||||
Some(PropertyValue::String(s)) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let mut properties = HashMap::new();
|
||||
for (k, v) in &node.properties {
|
||||
if k == "name" {
|
||||
continue;
|
||||
}
|
||||
let v_str = match v {
|
||||
PropertyValue::String(s) => s.clone(),
|
||||
PropertyValue::Integer(i) => i.to_string(),
|
||||
PropertyValue::Float(f) => f.to_string(),
|
||||
PropertyValue::Boolean(b) => b.to_string(),
|
||||
_ => format!("{:?}", v),
|
||||
};
|
||||
properties.insert(k.clone(), v_str);
|
||||
}
|
||||
|
||||
Entity {
|
||||
id: node.id.clone(),
|
||||
label,
|
||||
name,
|
||||
properties,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WASM fallback (lightweight in-memory stub)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod inner {
|
||||
use super::*;
|
||||
|
||||
struct StoredNode {
|
||||
id: String,
|
||||
label: String,
|
||||
name: String,
|
||||
properties: HashMap<String, String>,
|
||||
}
|
||||
|
||||
struct StoredEdge {
|
||||
_id: String,
|
||||
from: String,
|
||||
to: String,
|
||||
_rel_type: String,
|
||||
}
|
||||
|
||||
/// A knowledge graph backed by simple `Vec` storage for WASM targets.
|
||||
pub struct KnowledgeGraph {
|
||||
nodes: Vec<StoredNode>,
|
||||
edges: Vec<StoredEdge>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl KnowledgeGraph {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
next_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_entity(
|
||||
&mut self,
|
||||
label: &str,
|
||||
name: &str,
|
||||
properties: HashMap<String, String>,
|
||||
) -> Result<String> {
|
||||
let id = format!("wasm-{}", self.next_id);
|
||||
self.next_id += 1;
|
||||
self.nodes.push(StoredNode {
|
||||
id: id.clone(),
|
||||
label: label.to_string(),
|
||||
name: name.to_string(),
|
||||
properties,
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn add_relationship(
|
||||
&mut self,
|
||||
from_id: &str,
|
||||
to_id: &str,
|
||||
rel_type: &str,
|
||||
) -> Result<String> {
|
||||
let id = format!("wasm-e-{}", self.next_id);
|
||||
self.next_id += 1;
|
||||
self.edges.push(StoredEdge {
|
||||
_id: id.clone(),
|
||||
from: from_id.to_string(),
|
||||
to: to_id.to_string(),
|
||||
_rel_type: rel_type.to_string(),
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn find_by_label(&self, label: &str) -> Vec<Entity> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| n.label == label)
|
||||
.map(|n| Entity {
|
||||
id: n.id.clone(),
|
||||
label: n.label.clone(),
|
||||
name: n.name.clone(),
|
||||
properties: n.properties.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn neighbors(&self, entity_id: &str) -> Vec<Entity> {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for e in &self.edges {
|
||||
if e.from == entity_id {
|
||||
ids.insert(e.to.clone());
|
||||
}
|
||||
if e.to == entity_id {
|
||||
ids.insert(e.from.clone());
|
||||
}
|
||||
}
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| ids.contains(&n.id))
|
||||
.map(|n| Entity {
|
||||
id: n.id.clone(),
|
||||
label: n.label.clone(),
|
||||
name: n.name.clone(),
|
||||
properties: n.properties.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn extract_entities(text: &str) -> Vec<(String, String)> {
|
||||
entity_extractor::extract_entities(text)
|
||||
}
|
||||
|
||||
pub fn ingest_frame_entities(&mut self, frame_id: &str, text: &str) -> Result<Vec<String>> {
|
||||
// Ensure frame node.
|
||||
let frame_exists = self.nodes.iter().any(|n| n.id == frame_id);
|
||||
let frame_node_id = if frame_exists {
|
||||
frame_id.to_string()
|
||||
} else {
|
||||
let id = frame_id.to_string();
|
||||
self.nodes.push(StoredNode {
|
||||
id: id.clone(),
|
||||
label: "Frame".to_string(),
|
||||
name: frame_id.to_string(),
|
||||
properties: HashMap::new(),
|
||||
});
|
||||
id
|
||||
};
|
||||
|
||||
let extracted = entity_extractor::extract_entities(text);
|
||||
let mut entity_ids = Vec::with_capacity(extracted.len());
|
||||
for (label, name) in &extracted {
|
||||
let eid = self.add_entity(label, name, HashMap::new())?;
|
||||
self.add_relationship(&frame_node_id, &eid, "CONTAINS")?;
|
||||
entity_ids.push(eid);
|
||||
}
|
||||
Ok(entity_ids)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KnowledgeGraph {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the platform-appropriate implementation.
|
||||
pub use inner::KnowledgeGraph;
|
||||
Reference in New Issue
Block a user