Skip to main content

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}