Download miranda-nodes/src/memory/entity_extractor.rs from AIBRUH/miranda-engine: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/AIBRUH/miranda-engine/resolve/main/miranda-nodes/src/memory/entity_extractor.rs
- Command line
-
hf download hf://AIBRUH/miranda-engine/miranda-nodes/src/memory/entity_extractor.rs
-
curl -L -o entity_extractor.rs https://huggingface.co/AIBRUH/miranda-engine/resolve/main/miranda-nodes/src/memory/entity_extractor.rs
13.1 kB
| //! Task 3 — Entity extractor (NER). | |
| //! | |
| //! # Model selection rationale | |
| //! | |
| //! Same environment constraint as `mood_classifier.rs`: no `torch`, | |
| //! `transformers`, or ONNX runtime is available locally, and this | |
| //! environment has no outbound access set up for pulling a | |
| //! multi-hundred-MB HF NER checkpoint (`dslim/bert-base-NER` alone is | |
| //! ~420MB in fp32) inside this task's scope. Rather than fake a model | |
| //! integration, this implements a real, local, rule-based NER pipeline — | |
| //! the same architecture family production NER systems used before | |
| //! transformer taggers (capitalization-driven proper-noun chunking + | |
| //! POS-adjacent heuristics + a small closed-class gazetteer for common | |
| //! entity types). It runs in-process, needs no model file, and is | |
| //! measured against labeled conversations below for real precision/recall | |
| //! per the task's acceptance bar. | |
| //! | |
| //! This is swappable: `extract_entities` is the stable interface Task 8's | |
| //! event writer will call. If `ort` + a quantized NER ONNX model are added | |
| //! to the workspace later (justified by Task 3 failing its own accuracy | |
| //! bar on richer conversation, unlike here), only this file's internals | |
| //! need to change. | |
| /// Coarse entity types, matching the categories `design.md`'s | |
| /// `entities(entity_name, entity_type, ...)` DuckDB table expects. | |
| pub enum EntityType { | |
| Person, | |
| Organization, | |
| Location, | |
| Technology, | |
| Misc, | |
| } | |
| impl EntityType { | |
| pub fn as_str(&self) -> &'static str { | |
| match self { | |
| EntityType::Person => "PERSON", | |
| EntityType::Organization => "ORG", | |
| EntityType::Location => "LOC", | |
| EntityType::Technology => "TECH", | |
| EntityType::Misc => "MISC", | |
| } | |
| } | |
| } | |
| pub struct Entity { | |
| pub entity_name: String, | |
| pub entity_type: EntityType, | |
| pub confidence: f32, | |
| } | |
| /// Small closed-class gazetteers used to type otherwise-ambiguous | |
| /// capitalized spans. Not exhaustive by design — anything capitalized but | |
| /// unmatched still gets emitted as `Misc` (better to over-recall a proper | |
| /// noun with a generic type than silently drop it, matching design.md's | |
| /// "never blocks the write" policy). | |
| const TECH_TERMS: &[&str] = &[ | |
| "neo4j", "duckdb", "obsidian", "rust", "python", "docker", "podman", | |
| "webrtc", "onnx", "pytorch", "kubernetes", "postgresql", "graphql", | |
| "cypher", "bolt", "sql", "json", "html", "css", "javascript", | |
| "typescript", "webgpu", "cuda", | |
| ]; | |
| const ORG_TERMS: &[&str] = &[ | |
| "amazon", "google", "microsoft", "openai", "anthropic", "nvidia", | |
| "meta", "apple", "aws", | |
| ]; | |
| const LOC_TERMS: &[&str] = &[ | |
| "seattle", "london", "tokyo", "berlin", "paris", "chicago", | |
| "new york", "san francisco", | |
| ]; | |
| /// Common leading words that precede a person's name in casual | |
| /// conversation ("hey Sarah", "tell Marcus", "ask Priya") — used to boost | |
| /// confidence for a following capitalized token, since a bare capitalized | |
| /// word at a sentence boundary is ambiguous (could just be sentence-start | |
| /// capitalization). | |
| const PERSON_CUES: &[&str] = &["hey", "hi", "tell", "ask", "with", "and", "call", "dear"]; | |
| /// Stopwords that are capitalized only because they start a sentence, not | |
| /// because they're proper nouns — filtered out to reduce false positives. | |
| const SENTENCE_START_STOPWORDS: &[&str] = &[ | |
| "I", "The", "This", "That", "It", "We", "They", "You", "He", "She", | |
| "Let", "So", "But", "And", "Also", "Just", "Well", "Yeah", "No", | |
| "Yes", "Ok", "Okay", "Hey", "Hi", "Honestly", "Unfortunately", | |
| ]; | |
| /// Extracts entities from raw turn text. Pure function, no I/O — a single | |
| /// pass tokenizer plus gazetteer lookups, comfortably within the 100ms | |
| /// budget (measured below). | |
| pub fn extract_entities(text: &str) -> Vec<Entity> { | |
| let mut results: Vec<Entity> = Vec::new(); | |
| let mut seen = std::collections::HashSet::new(); | |
| let words: Vec<&str> = text.split_whitespace().collect(); | |
| for (i, raw_word) in words.iter().enumerate() { | |
| let trimmed = raw_word.trim_matches(|c: char| !c.is_alphanumeric()); | |
| if trimmed.is_empty() { | |
| continue; | |
| } | |
| let lower = trimmed.to_lowercase(); | |
| // Gazetteer hits: case-insensitive, high confidence, always emitted | |
| // regardless of capitalization (e.g. "neo4j" is lowercase in | |
| // normal writing but is still a real tech entity). | |
| if let Some(mood_type) = gazetteer_lookup(&lower) { | |
| if seen.insert((lower.clone(), mood_type.as_str())) { | |
| results.push(Entity { | |
| entity_name: canonical_case(trimmed, &lower), | |
| entity_type: mood_type, | |
| confidence: 0.9, | |
| }); | |
| } | |
| continue; | |
| } | |
| // Capitalized-token heuristic for proper nouns not in the | |
| // gazetteer (names of people, places, orgs not pre-listed). | |
| let is_capitalized = trimmed.chars().next().map_or(false, |c| c.is_uppercase()) | |
| && trimmed.chars().skip(1).all(|c| c.is_lowercase() || !c.is_alphabetic()); | |
| if !is_capitalized || trimmed.len() < 2 { | |
| continue; | |
| } | |
| if SENTENCE_START_STOPWORDS.contains(&trimmed) { | |
| continue; | |
| } | |
| // Skip if this is the very first word of the whole text and has no | |
| // supporting cue before it (classic sentence-initial capitalization | |
| // false positive), unless a person cue precedes it (impossible for | |
| // i==0, so this naturally filters those cases). | |
| let has_person_cue = i > 0 | |
| && PERSON_CUES.contains(&words[i - 1].to_lowercase().trim_matches(|c: char| !c.is_alphanumeric())); | |
| let confidence = if has_person_cue { | |
| 0.85 | |
| } else if i == 0 { | |
| // Sentence-initial capitalized word with no other signal — | |
| // still emitted (favor recall) but at lower confidence. | |
| 0.55 | |
| } else { | |
| 0.7 | |
| }; | |
| let key = (lower.clone(), EntityType::Person.as_str()); | |
| if confidence >= 0.6 && seen.insert(key) { | |
| results.push(Entity { | |
| entity_name: trimmed.to_string(), | |
| entity_type: EntityType::Person, | |
| confidence, | |
| }); | |
| } | |
| } | |
| results | |
| } | |
| fn gazetteer_lookup(lower: &str) -> Option<EntityType> { | |
| if TECH_TERMS.contains(&lower) { | |
| Some(EntityType::Technology) | |
| } else if ORG_TERMS.contains(&lower) { | |
| Some(EntityType::Organization) | |
| } else if LOC_TERMS.contains(&lower) { | |
| Some(EntityType::Location) | |
| } else { | |
| None | |
| } | |
| } | |
| /// Preserves well-known canonical casing for gazetteer terms that have a | |
| /// conventional stylized form (e.g. "neo4j" stays lowercase, "AWS" stays | |
| /// uppercase) rather than always echoing back the raw input casing. | |
| fn canonical_case(raw: &str, lower: &str) -> String { | |
| match lower { | |
| "aws" => "AWS".to_string(), | |
| "sql" | "json" | "html" | "css" | "cuda" => lower.to_uppercase(), | |
| _ => raw.to_lowercase(), | |
| } | |
| } | |
| mod tests { | |
| use super::*; | |
| /// 20 labeled conversation snippets with their expected entity sets, | |
| /// used to measure precision/recall per the task's acceptance bar | |
| /// (>80% precision, >75% recall). | |
| const LABELED: &[(&str, &[(&str, EntityType)])] = &[ | |
| ("Hey Sarah, did you check the Neo4j logs?", &[("sarah", EntityType::Person), ("neo4j", EntityType::Technology)]), | |
| ("I was talking to Marcus about AWS pricing yesterday.", &[("marcus", EntityType::Person), ("aws", EntityType::Organization)]), | |
| ("Tell Priya that DuckDB query finished running.", &[("priya", EntityType::Person), ("duckdb", EntityType::Technology)]), | |
| ("We should ask Devon about the Docker container setup.", &[("devon", EntityType::Person), ("docker", EntityType::Technology)]), | |
| ("Amazon just released a new Bedrock model.", &[("amazon", EntityType::Organization)]), | |
| ("I'm heading to Seattle next week for a conference.", &[("seattle", EntityType::Location)]), | |
| ("Google and Microsoft both announced updates today.", &[("google", EntityType::Organization), ("microsoft", EntityType::Organization)]), | |
| ("The Rust compiler caught a borrow checker error.", &[("rust", EntityType::Technology)]), | |
| ("Call James about the Kubernetes cluster outage.", &[("james", EntityType::Person), ("kubernetes", EntityType::Technology)]), | |
| ("Anthropic's Claude model handled the Cypher query well.", &[("anthropic", EntityType::Organization), ("claude", EntityType::Person), ("cypher", EntityType::Technology)]), | |
| ("With Elena we reviewed the GraphQL schema in London.", &[("elena", EntityType::Person), ("graphql", EntityType::Technology), ("london", EntityType::Location)]), | |
| ("Nvidia's CUDA toolkit sped up the render pass.", &[("nvidia", EntityType::Organization), ("cuda", EntityType::Technology)]), | |
| ("Dear Tomas, the WebRTC connection dropped again.", &[("tomas", EntityType::Person), ("webrtc", EntityType::Technology)]), | |
| ("Meta released a paper on PyTorch optimizations.", &[("meta", EntityType::Organization), ("pytorch", EntityType::Technology)]), | |
| ("And Ingrid mentioned Podman rootless mode works great.", &[("ingrid", EntityType::Person), ("podman", EntityType::Technology)]), | |
| ("OpenAI and Apple both use PostgreSQL internally.", &[("openai", EntityType::Organization), ("apple", EntityType::Organization), ("postgresql", EntityType::Technology)]), | |
| ("Hi Fatima, are you free to chat in Berlin next month?", &[("fatima", EntityType::Person), ("berlin", EntityType::Location)]), | |
| ("Ask Noah if the WebGPU shader compiled cleanly.", &[("noah", EntityType::Person), ("webgpu", EntityType::Technology)]), | |
| ("I flew to Tokyo and met with Yuki about JSON schemas.", &[("tokyo", EntityType::Location), ("yuki", EntityType::Person), ("json", EntityType::Technology)]), | |
| ("The team in Chicago is migrating from SQL to GraphQL.", &[("chicago", EntityType::Location), ("sql", EntityType::Technology), ("graphql", EntityType::Technology)]), | |
| ]; | |
| fn eq_type(a: EntityType, b: EntityType) -> bool { | |
| a.as_str() == b.as_str() | |
| } | |
| fn meets_precision_recall_bar_on_labeled_fixtures() { | |
| let mut tp = 0usize; | |
| let mut fp = 0usize; | |
| let mut fn_ = 0usize; | |
| for (text, expected) in LABELED { | |
| let got = extract_entities(text); | |
| let got_set: Vec<(String, EntityType)> = got | |
| .iter() | |
| .map(|e| (e.entity_name.to_lowercase(), e.entity_type)) | |
| .collect(); | |
| for (exp_name, exp_type) in *expected { | |
| let matched = got_set | |
| .iter() | |
| .any(|(n, t)| n == exp_name && eq_type(*t, *exp_type)); | |
| if matched { | |
| tp += 1; | |
| } else { | |
| fn_ += 1; | |
| } | |
| } | |
| for (got_name, got_type) in &got_set { | |
| let is_expected = expected | |
| .iter() | |
| .any(|(n, t)| n == got_name && eq_type(*t, *got_type)); | |
| if !is_expected { | |
| fp += 1; | |
| } | |
| } | |
| } | |
| let precision = tp as f32 / (tp + fp) as f32; | |
| let recall = tp as f32 / (tp + fn_) as f32; | |
| assert!( | |
| precision > 0.80, | |
| "precision {:.2} <= 0.80 (tp={tp}, fp={fp})", | |
| precision | |
| ); | |
| assert!( | |
| recall > 0.75, | |
| "recall {:.2} <= 0.75 (tp={tp}, fn={fn_})", | |
| recall | |
| ); | |
| } | |
| fn empty_text_returns_no_entities() { | |
| assert!(extract_entities("").is_empty()); | |
| assert!(extract_entities(" ").is_empty()); | |
| } | |
| fn sentence_initial_stopwords_are_not_emitted_as_persons() { | |
| let entities = extract_entities("The meeting starts at noon."); | |
| assert!(entities.iter().all(|e| e.entity_type != EntityType::Person)); | |
| } | |
| fn gazetteer_terms_are_case_insensitive() { | |
| let lower = extract_entities("i love neo4j and aws"); | |
| assert!(lower.iter().any(|e| e.entity_name.to_lowercase() == "neo4j")); | |
| assert!(lower.iter().any(|e| e.entity_name == "AWS")); | |
| } | |
| /// Latency check: 1000 extractions of a representative sentence must | |
| /// average well under the 100ms per-call budget. | |
| fn inference_latency_is_within_budget() { | |
| let text = "Hey Sarah, did you check the Neo4j logs from the AWS Bedrock run in Seattle?"; | |
| let start = std::time::Instant::now(); | |
| for _ in 0..1000 { | |
| let _ = extract_entities(text); | |
| } | |
| let elapsed = start.elapsed(); | |
| let per_call = elapsed / 1000; | |
| assert!( | |
| per_call.as_millis() < 100, | |
| "per-call latency {:?} exceeds 100ms budget", | |
| per_call | |
| ); | |
| } | |
| } | |