Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ agent-memory help
- 关系重新激活会复用三元组记录,不是完整事件日志或任意历史时刻回放。
- 两个后端的图算法都在 Rust 侧加载边后计算;暂无大图性能保证、分页或原生 Cypher 遍历。
- 不自动迁移旧 SQLite 图谱,CLI 暂不选择 Neo4j;没有现成 MCP 服务或 Python/TypeScript SDK。
- 默认特征哈希 / 规则抽取不等于模型理解;启用外部模型时数据会按配置发送。
- 默认特征哈希 / 规则抽取不等于模型理解(详见[规则抽取行为与示例矩阵](docs/guide.md#rule-extraction-behavior-matrix));启用外部模型时数据会按配置发送。

## 开发与参与

Expand Down
21 changes: 20 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,26 @@ until you attach one with `with_graph`.
`AgentMemory::open` does not enable an extractor. Opt in using
`with_extractor(Arc::new(RuleExtractor::new()))`, or supply `LlmExtractor` with your `ChatClient`.
The built-in rules cover a small set of explicit Chinese/English sentence patterns; English
rule extraction lowercases names. This is not general named-entity recognition.
rule extraction lowercases names. This is intentionally limited pattern matching, not general named-entity
recognition (NER) or model-level semantic understanding.

### Rule extraction behavior matrix

| Category | Input Example | Extracted Triple `(s, p, o)` | Notes |
| --- | --- | --- | --- |
| Whitespace | ` Alice lives in Beijing ` | `("alice", "lives_in", "beijing")` | Leading & trailing whitespace trimmed |
| Final punctuation | `Alice lives in Beijing.` | `("alice", "lives_in", "beijing")` | Sentence delimiters stripped |
| Repeated punctuation | `Alice likes Rust!!!???` | `("alice", "likes", "rust")` | Non-alphanumeric trail trimmed |
| Chinese punctuation | ` 张三喜欢喝乌龙茶!!! ` | `("张三", "喜欢", "乌龙茶")` | Chinese lead word `喝` stripped |
| Mixed English case | `ALICE PREFERS VIM` | `("alice", "prefers", "vim")` | English input lowercased |
| Multi-word predicate | `Bob Works At Microsoft.` | `("bob", "works_at", "microsoft")` | Matched case-insensitively |
| Unicode / Umlaut | `Jürgen likes München` | `("jürgen", "likes", "münchen")` | Unicode letters preserved & lowercased |
| Accented Latin | `Renée lives in Paris` | `("renée", "lives_in", "paris")` | Accent preserved & lowercased |
| Mixed CJK & Latin | `Bob 住在 上海` | `("Bob", "住在", "上海")` | Treated as CJK; preserves original casing |
| **Unsupported**: Unmodeled verb | `Alice visited Paris yesterday` | None | Only predefined triggers matched |
| **Unsupported**: Passive voice | `Rust is liked by Alice` | None | Passive construction unmodeled |
| **Unsupported**: Self-referential | `Alice likes Alice` | None | `subject == object` filtered out |
| **Unsupported**: Stop word only | `Alice likes to and` | None | Object reduces to empty |

Extracted triples carry `source_memory_id` and are added with `GraphStore::add_triple`, allowing
multiple objects. Neither the extractor nor `remember_fact` chooses which previous fact to
Expand Down
110 changes: 110 additions & 0 deletions src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,4 +473,114 @@ mod tests {
let ex = LlmExtractor::new(|_: &str| Err("network down".to_string()));
assert!(ex.extract("文本").is_empty());
}

#[test]
fn rule_extractor_behavior_matrix() {
struct TestCase {
category: &'static str,
input: &'static str,
expected: Option<(&'static str, &'static str, &'static str)>,
}

let cases = [
// Whitespace & sentence-final punctuation
TestCase {
category: "leading and trailing whitespace",
input: " Alice lives in Beijing ",
expected: Some(("alice", "lives_in", "beijing")),
},
TestCase {
category: "sentence-final period",
input: "Alice lives in Beijing.",
expected: Some(("alice", "lives_in", "beijing")),
},
TestCase {
category: "repeated exclamation and question marks",
input: "Alice likes Rust!!!???",
expected: Some(("alice", "likes", "rust")),
},
TestCase {
category: "Chinese full-width punctuation",
input: " 张三喜欢喝乌龙茶!!! ",
expected: Some(("张三", "喜欢", "乌龙茶")),
},
// Case normalization (English)
TestCase {
category: "mixed case English patterns",
input: "ALICE PREFERS VIM",
expected: Some(("alice", "prefers", "vim")),
},
TestCase {
category: "capitalized multi-word predicate",
input: "Bob Works At Microsoft.",
expected: Some(("bob", "works_at", "microsoft")),
},
// Unicode / Non-ASCII names
TestCase {
category: "non-ASCII German umlaut in subject and object",
input: "Jürgen likes München",
expected: Some(("jürgen", "likes", "münchen")),
},
TestCase {
category: "non-ASCII accented Latin name in English pattern",
input: "Renée lives in Paris",
expected: Some(("renée", "lives_in", "paris")),
},
TestCase {
category: "CJK mixed with English subject/object",
input: "Bob 住在 上海",
expected: Some(("Bob", "住在", "上海")),
},
// Unsupported patterns (negative examples)
TestCase {
category: "unsupported predicate",
input: "Alice visited Paris yesterday",
expected: None,
},
TestCase {
category: "passive voice (unsupported by rule patterns)",
input: "Rust is liked by Alice",
expected: None,
},
TestCase {
category: "identical subject and object (filtered out)",
input: "Alice likes Alice",
expected: None,
},
TestCase {
category: "empty object after trailing stop words",
input: "Alice likes to and",
expected: None,
},
];

let extractor = RuleExtractor::new();
for tc in cases {
let triples = extractor.extract(tc.input);
match tc.expected {
Some((s, p, o)) => {
assert_eq!(
triples.len(),
1,
"Expected 1 triple for [{}]: input='{}', got {:?}",
tc.category,
tc.input,
triples
);
assert_eq!(triples[0].subject, s, "subject mismatch for [{}]", tc.category);
assert_eq!(triples[0].predicate, p, "predicate mismatch for [{}]", tc.category);
assert_eq!(triples[0].object, o, "object mismatch for [{}]", tc.category);
}
None => {
assert!(
triples.is_empty(),
"Expected empty triples for [{}]: input='{}', got {:?}",
tc.category,
tc.input,
triples
);
}
}
}
}
}