use std::collections::BTreeSet;
use crate::error::ModelResult;
use crate::lang::active_locales;
use crate::traits::{Extractor, ImmediateExtraction, StrongExtraction};
use hippmem_core::hash::JIEBA;
use hippmem_core::model::understanding::{
CausalClaim, CausalKind, DecisionFrame, EmotionFrame, EntityMention, EntityType, GoalFrame,
GoalStatus, Polarity, PreferenceFrame, TopicTag,
};
use hippmem_core::model::unit::{ContentType, MemoryContent};
use hippmem_core::score::UnitScore;
#[derive(Default)]
pub struct DeterministicExtractor;
impl DeterministicExtractor {
pub fn extract_sync_immediate(
&self,
content: &MemoryContent,
) -> ModelResult<ImmediateExtraction> {
Ok(self.immediate_impl(content))
}
pub fn extract_sync_strong(&self, content: &MemoryContent) -> ModelResult<StrongExtraction> {
Ok(self.strong_impl(content))
}
fn immediate_impl(&self, content: &MemoryContent) -> ImmediateExtraction {
let text = &content.raw;
ImmediateExtraction {
entities: extract_entities(text),
topics: extract_topics(text),
explicit_causals: extract_explicit_causals(text),
language: content.language,
content_type: Some(content.content_type),
importance: heuristic_importance(text, &content.content_type),
}
}
fn strong_impl(&self, content: &MemoryContent) -> StrongExtraction {
let text = &content.raw;
StrongExtraction {
goals: extract_goals(text),
preferences: extract_preferences(text),
emotions: extract_emotions(text),
decisions: extract_decisions(text),
implicit_causals: vec![],
contradictions: vec![],
confidence: UnitScore::new(0.35),
}
}
}
#[async_trait::async_trait]
impl Extractor for DeterministicExtractor {
async fn extract_immediate(&self, c: &MemoryContent) -> ModelResult<ImmediateExtraction> {
Ok(self.immediate_impl(c))
}
async fn extract_strong(&self, c: &MemoryContent) -> ModelResult<StrongExtraction> {
Ok(self.strong_impl(c))
}
fn backend_id(&self) -> &str {
"deterministic-rules"
}
}
fn extract_entities(text: &str) -> Vec<EntityMention> {
let mut out = Vec::new();
let mut seen_canonical: BTreeSet<String> = BTreeSet::new();
for word in text.split_whitespace() {
let t = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
if t.len() >= 2 && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
let canonical = t.to_lowercase();
if seen_canonical.insert(canonical.clone()) {
out.push(EntityMention {
text: t.to_string(),
canonical,
entity_type: EntityType::Other,
span: None,
confidence: UnitScore::new(0.6),
});
}
}
}
let chinese_entities = extract_chinese_entities(text);
for e in chinese_entities {
if seen_canonical.insert(e.canonical.clone()) {
out.push(e);
}
}
out
}
fn extract_chinese_entities(text: &str) -> Vec<EntityMention> {
if !text.contains(|c: char| c as u32 >= 0x4E00 && c as u32 <= 0x9FFF) {
return vec![];
}
let tags = JIEBA.tag(text, true);
tags.into_iter()
.filter_map(|t| {
let (entity_type, confidence) = match t.tag {
"nr" => (EntityType::Person, UnitScore::new(0.55)),
"ns" => (EntityType::Other, UnitScore::new(0.55)), "nt" => (EntityType::Org, UnitScore::new(0.55)),
"nz" => (EntityType::Other, UnitScore::new(0.55)),
"nrt" => (EntityType::Other, UnitScore::new(0.55)),
"eng" => (EntityType::Other, UnitScore::new(0.50)),
"x" if is_potential_cjk_name(t.word) => (EntityType::Other, UnitScore::new(0.40)),
_ => return None,
};
Some(EntityMention {
text: t.word.to_string(),
canonical: t.word.to_lowercase(),
entity_type,
span: None,
confidence,
})
})
.collect()
}
fn is_potential_cjk_name(word: &str) -> bool {
if word.len() < 2 {
return false;
}
word.chars()
.all(|c| matches!(c as u32, 0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0x20000..=0x2A6DF))
}
fn extract_topics(text: &str) -> Vec<TopicTag> {
hippmem_core::hash::tokenize(text, "zh")
.into_iter()
.filter(|t| t.len() >= 2)
.take(5)
.map(|label| TopicTag {
label,
confidence: UnitScore::new(0.5),
})
.collect()
}
fn extract_explicit_causals(text: &str) -> Vec<CausalClaim> {
for lang in active_locales() {
for (cm, em) in lang.causal_pairs {
if let (Some(cp), Some(ep)) = (text.find(cm), text.find(em)) {
let cause = text[cp + cm.len()..ep].trim();
let effect = text[ep + em.len()..].trim();
if !cause.is_empty() && !effect.is_empty() {
return vec![CausalClaim {
cause: cause.chars().take(80).collect(),
effect: effect.chars().take(80).collect(),
kind: CausalKind::Explicit,
evidence_span: None,
confidence: UnitScore::new(0.7),
}];
}
}
}
}
vec![]
}
fn extract_preferences(text: &str) -> Vec<PreferenceFrame> {
for lang in active_locales() {
for w in lang.preference_pos {
if text.contains(w) {
return vec![PreferenceFrame {
object: format!("detected marker: {w}"),
polarity: Polarity::Like,
strength: UnitScore::new(0.5),
still_valid: true,
confidence: UnitScore::new(0.5),
}];
}
}
}
for lang in active_locales() {
for w in lang.preference_neg {
if text.contains(w) {
return vec![PreferenceFrame {
object: format!("detected marker: {w}"),
polarity: Polarity::Dislike,
strength: UnitScore::new(0.5),
still_valid: true,
confidence: UnitScore::new(0.5),
}];
}
}
}
vec![]
}
fn extract_emotions(text: &str) -> Vec<EmotionFrame> {
for lang in active_locales() {
for (w, ek) in lang.emotion_keywords {
if text.contains(w) {
return vec![EmotionFrame {
emotion: *ek,
intensity: UnitScore::new(0.6),
trigger: Some(format!("detected word: {w}")),
confidence: UnitScore::new(0.5),
}];
}
}
}
vec![]
}
fn extract_goals(text: &str) -> Vec<GoalFrame> {
for lang in active_locales() {
for m in lang.goal_markers {
if text.contains(m) {
return vec![GoalFrame {
description: format!("detected marker: {m}"),
status: GoalStatus::Active,
constraints: vec![],
confidence: UnitScore::new(0.4),
}];
}
}
}
vec![]
}
fn extract_decisions(text: &str) -> Vec<DecisionFrame> {
for lang in active_locales() {
for m in lang.decision_markers {
if text.contains(m) {
return vec![DecisionFrame {
decision: format!("detected marker: {m}"),
rationale: Some(text.chars().take(100).collect()),
decided_at: None,
reverted: false,
confidence: UnitScore::new(0.45),
}];
}
}
}
vec![]
}
fn heuristic_importance(text: &str, ct: &ContentType) -> UnitScore {
let base = match text.chars().count() {
n if n > 200 => 0.7,
n if n > 50 => 0.5,
_ => 0.3,
};
let bonus = match ct {
ContentType::Decision | ContentType::Preference => 0.2,
_ => 0.0,
};
UnitScore::new(f32::min(base + bonus, 1.0))
}
#[cfg(test)]
mod tests {
use super::*;
use hippmem_core::model::unit::Language;
#[test]
fn detects_causal() {
let content = MemoryContent {
raw: "because memory was low, so the program crashed".into(),
summary: None,
normalized: None,
language: Language::En,
content_type: ContentType::UserStatement,
};
let r = DeterministicExtractor.immediate_impl(&content);
assert!(!r.explicit_causals.is_empty());
}
}