organism_runtime/
classifier.rs1use converge_pack::{
15 AgentEffect, Context, ContextFact, ContextKey, ProposedFact, Provenance, ProvenanceSource,
16 Suggestor, TextPayload,
17};
18use organism_intent::problem::{ProblemClassification, classify_text};
19
20use crate::provenance::ORGANISM_RUNTIME_PROVENANCE;
21
22fn proposed_text_fact(
23 key: ContextKey,
24 id: impl Into<converge_pack::ProposalId>,
25 content: impl Into<String>,
26) -> ProposedFact {
27 ORGANISM_RUNTIME_PROVENANCE.proposed_fact(key, id, TextPayload::new(content))
28}
29
30fn fact_text(fact: &ContextFact) -> &str {
31 fact.text().unwrap_or_default()
32}
33
34pub struct ProblemClassifierSuggestor;
44
45impl ProblemClassifierSuggestor {
46 #[must_use]
47 pub fn new() -> Self {
48 Self
49 }
50}
51
52impl Default for ProblemClassifierSuggestor {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58const FACT_PREFIX: &str = "problem-class";
59
60#[async_trait::async_trait]
61#[allow(clippy::unnecessary_literal_bound)]
62impl Suggestor for ProblemClassifierSuggestor {
63 fn name(&self) -> &'static str {
64 "problem-classifier"
65 }
66
67 fn dependencies(&self) -> &[ContextKey] {
68 &[ContextKey::Seeds]
69 }
70
71 fn provenance(&self) -> Provenance {
72 ORGANISM_RUNTIME_PROVENANCE.provenance()
73 }
74
75 fn accepts(&self, ctx: &dyn Context) -> bool {
76 ctx.has(ContextKey::Seeds)
78 && !ctx
79 .get(ContextKey::Hypotheses)
80 .iter()
81 .any(|f| f.id().starts_with(FACT_PREFIX))
82 }
83
84 async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
85 let mut haystack = String::new();
86 for fact in ctx.get(ContextKey::Seeds) {
87 haystack.push(' ');
88 haystack.push_str(fact_text(fact));
89 }
90 for fact in ctx.get(ContextKey::Signals) {
91 haystack.push(' ');
92 haystack.push_str(fact_text(fact));
93 }
94
95 let classification = classify_text(&haystack);
96 let payload = serde_json::json!({
97 "agent": "problem-classifier",
98 "class": classification.class.as_str(),
99 "matched_keywords": classification.matched_keywords,
100 "defaulted": classification.defaulted,
101 });
102
103 AgentEffect::with_proposal(proposed_text_fact(
104 ContextKey::Hypotheses,
105 format!("{FACT_PREFIX}:{}", classification.class.as_str()),
106 payload.to_string(),
107 ))
108 }
109}
110
111#[must_use]
118pub fn extract_classification(ctx: &dyn Context) -> Option<ProblemClassification> {
119 ctx.get(ContextKey::Hypotheses)
120 .iter()
121 .find(|f| f.id().starts_with(FACT_PREFIX))
122 .and_then(|f| serde_json::from_str(fact_text(f)).ok())
123 .and_then(|v: serde_json::Value| {
124 let class_str = v.get("class")?.as_str()?;
125 let class = match class_str {
126 "decision" => organism_intent::problem::ProblemClass::Decision,
127 "research" => organism_intent::problem::ProblemClass::Research,
128 "evaluation" => organism_intent::problem::ProblemClass::Evaluation,
129 "planning" => organism_intent::problem::ProblemClass::Planning,
130 "diligence" => organism_intent::problem::ProblemClass::Diligence,
131 "incident" => organism_intent::problem::ProblemClass::Incident,
132 "strategy" => organism_intent::problem::ProblemClass::Strategy,
133 _ => return None,
134 };
135 let matched_keywords = v
136 .get("matched_keywords")?
137 .as_array()?
138 .iter()
139 .filter_map(|w| w.as_str().map(str::to_owned))
140 .collect();
141 let defaulted = v.get("defaulted")?.as_bool()?;
142 let tiebroken = v
143 .get("tiebroken")
144 .and_then(serde_json::Value::as_bool)
145 .unwrap_or(false);
146 Some(ProblemClassification {
147 class,
148 matched_keywords,
149 defaulted,
150 tiebroken,
151 })
152 })
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::formation::Formation;
159 use organism_intent::problem::ProblemClass;
160
161 fn classified_payload_from(seed: &str) -> serde_json::Value {
162 let result = tokio::runtime::Runtime::new()
163 .expect("runtime")
164 .block_on(async {
165 Formation::new("classifier-test")
166 .agent(ProblemClassifierSuggestor::new())
167 .seed(ContextKey::Seeds, "seed-1", seed, "test")
168 .run()
169 .await
170 .expect("formation runs")
171 });
172
173 let hypotheses = result.converge_result.context.get(ContextKey::Hypotheses);
174 let fact = hypotheses
175 .iter()
176 .find(|f| f.id().starts_with(FACT_PREFIX))
177 .expect("classifier emitted a problem-class hypothesis");
178 serde_json::from_str(fact_text(fact)).expect("payload is JSON")
179 }
180
181 #[test]
182 fn classifier_emits_evaluation_for_evaluation_keywords() {
183 let payload = classified_payload_from("evaluate the vendor proposals carefully");
184 assert_eq!(payload["class"], "evaluation");
185 assert_eq!(payload["defaulted"], false);
186 }
187
188 #[test]
189 fn classifier_emits_diligence_for_vet_keyword() {
190 let payload = classified_payload_from("vet the acquisition target end-to-end");
191 assert_eq!(payload["class"], "diligence");
192 }
193
194 #[test]
195 fn classifier_emits_incident_for_outage_keyword() {
196 let payload = classified_payload_from("respond to the prod outage and stabilize");
197 assert_eq!(payload["class"], "incident");
198 }
199
200 #[test]
201 fn classifier_falls_back_to_decision_with_no_keywords() {
202 let payload = classified_payload_from("doing the thing today");
203 assert_eq!(payload["class"], "decision");
204 assert_eq!(payload["defaulted"], true);
205 }
206
207 #[test]
208 fn extract_classification_recovers_typed_value() {
209 let payload = classified_payload_from("research the competitive landscape");
210 assert_eq!(payload["class"], "research");
213 let class_str = payload["class"].as_str().unwrap();
214 let class = match class_str {
215 "research" => ProblemClass::Research,
216 _ => panic!("unexpected class {class_str}"),
217 };
218 assert_eq!(class, ProblemClass::Research);
219 }
220}