ainl_graph_extractor/turn_extract.rs
1//! Turn-scoped semantic extraction for **graph-memory fact candidates** (no SQLite store).
2//!
3//! This complements [`crate::GraphExtractorTask::run_pass`], which walks a persisted
4//! [`ainl_memory::SqliteGraphStore`] and returns [`crate::ExtractionReport`] (recurrence updates,
5//! persona evolution, merged `RawSignal`s). That pass cannot run inside the agent loop without
6//! store access and would duplicate persona side-effects.
7//!
8//! Here we reuse the same deterministic signal source used inside `extract_pass` — namely
9//! [`ainl_semantic_tagger::tag_turn`] over user text, optional assistant text, and tool names —
10//! and expose **non-tool, non-tone** [`SemanticTag`]s so callers can map them to lightweight
11//! semantic rows (e.g. `ExtractedFact` in `openfang-runtime`).
12//!
13//! **Rationale for filtering:** episode nodes already list tools; tone tags are high-churn and
14//! poor fits for durable "fact" rows compared to topic / preference / correction / behavior.
15//!
16//! Vitals: when [`TurnVitals`] are available they are surfaced as additional
17//! [`TagNamespace::Behavior`] tags (e.g. `"vitals:reasoning:pass"`) so downstream consumers
18//! can index or route on cognitive state without depending on `openfang-types` directly.
19
20use ainl_semantic_tagger::{tag_turn, SemanticTag, TagNamespace};
21
22/// Minimal vitals snapshot accepted by this crate (avoids a direct `openfang-types` dep).
23///
24/// Callers in `openfang-runtime` construct this from `openfang_types::vitals::CognitiveVitals`.
25#[derive(Debug, Clone)]
26pub struct TurnVitals {
27 /// Gate string: "pass" / "warn" / "fail".
28 pub gate: String,
29 /// Phase string, e.g. "reasoning:0.69".
30 pub phase: String,
31 /// Trust score in [0, 1].
32 pub trust: f32,
33}
34
35/// Semantic tags from one completed turn suitable for downstream fact extraction.
36///
37/// Excludes [`TagNamespace::Tool`] (tool list lives on the episode) and [`TagNamespace::Tone`]
38/// (keeps graph fact rows focused on preferences, topics, and correction/behavior signals).
39///
40/// When `vitals` is `Some`, additional [`TagNamespace::Behavior`] tags are appended:
41/// - `"vitals:<phase_kind>:<gate>"` — primary routing tag, e.g. `"vitals:reasoning:pass"`
42/// - `"vitals:elevated"` — present only when gate is `"warn"` or `"fail"`
43pub fn extract_turn_semantic_tags_for_memory(
44 user_message: &str,
45 assistant_response: Option<&str>,
46 tools: &[String],
47 vitals: Option<&TurnVitals>,
48) -> Vec<SemanticTag> {
49 let mut tags: Vec<SemanticTag> = tag_turn(user_message, assistant_response, tools)
50 .into_iter()
51 .filter(|t| !matches!(t.namespace, TagNamespace::Tool | TagNamespace::Tone))
52 .collect();
53
54 if let Some(v) = vitals {
55 let phase_kind = v.phase.split(':').next().unwrap_or("unknown");
56 tags.push(SemanticTag {
57 namespace: TagNamespace::Behavior,
58 value: format!("vitals:{}:{}", phase_kind, v.gate),
59 confidence: v.trust,
60 });
61 if v.gate == "warn" || v.gate == "fail" {
62 tags.push(SemanticTag {
63 namespace: TagNamespace::Behavior,
64 value: "vitals:elevated".to_string(),
65 confidence: 1.0 - v.trust,
66 });
67 }
68 }
69
70 tags
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[test]
78 fn turn_extract_includes_topic_rust() {
79 let tags = extract_turn_semantic_tags_for_memory(
80 "I need help with cargo, serde, and async fn in my rust project",
81 Some("We can use tokio for that."),
82 &[],
83 None,
84 );
85 assert!(
86 tags.iter()
87 .any(|t| t.namespace == TagNamespace::Topic && t.value == "rust"),
88 "expected rust topic, got {tags:?}"
89 );
90 }
91
92 #[test]
93 fn turn_extract_filters_tools_and_tone() {
94 let tags = extract_turn_semantic_tags_for_memory(
95 "Hello",
96 Some("Hi there"),
97 &["bash".into()],
98 None,
99 );
100 assert!(!tags.iter().any(|t| t.namespace == TagNamespace::Tool));
101 assert!(!tags.iter().any(|t| t.namespace == TagNamespace::Tone));
102 }
103
104 #[test]
105 fn turn_extract_vitals_pass_tag() {
106 let v = TurnVitals {
107 gate: "pass".to_string(),
108 phase: "reasoning:0.72".to_string(),
109 trust: 0.72,
110 };
111 let tags = extract_turn_semantic_tags_for_memory("Hello", None, &[], Some(&v));
112 assert!(
113 tags.iter()
114 .any(|t| t.namespace == TagNamespace::Behavior
115 && t.value == "vitals:reasoning:pass"),
116 "expected vitals:reasoning:pass tag"
117 );
118 assert!(
119 !tags.iter().any(|t| t.value == "vitals:elevated"),
120 "should not have elevated tag on pass"
121 );
122 }
123
124 #[test]
125 fn turn_extract_vitals_warn_elevated() {
126 let v = TurnVitals {
127 gate: "warn".to_string(),
128 phase: "hallucination:0.40".to_string(),
129 trust: 0.40,
130 };
131 let tags = extract_turn_semantic_tags_for_memory("tell me facts", None, &[], Some(&v));
132 assert!(
133 tags.iter().any(|t| t.value == "vitals:elevated"),
134 "expected vitals:elevated tag on warn"
135 );
136 }
137}