use converge_pack::{
AgentEffect, Context, ContextFact, ContextKey, ProposedFact, Provenance, ProvenanceSource,
Suggestor, TextPayload,
};
use organism_intent::problem::{ProblemClassification, classify_text};
use crate::provenance::ORGANISM_RUNTIME_PROVENANCE;
fn proposed_text_fact(
key: ContextKey,
id: impl Into<converge_pack::ProposalId>,
content: impl Into<String>,
) -> ProposedFact {
ORGANISM_RUNTIME_PROVENANCE.proposed_fact(key, id, TextPayload::new(content))
}
fn fact_text(fact: &ContextFact) -> &str {
fact.text().unwrap_or_default()
}
pub struct ProblemClassifierSuggestor;
impl ProblemClassifierSuggestor {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for ProblemClassifierSuggestor {
fn default() -> Self {
Self::new()
}
}
const FACT_PREFIX: &str = "problem-class";
#[async_trait::async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Suggestor for ProblemClassifierSuggestor {
fn name(&self) -> &'static str {
"problem-classifier"
}
fn dependencies(&self) -> &[ContextKey] {
&[ContextKey::Seeds]
}
fn provenance(&self) -> Provenance {
ORGANISM_RUNTIME_PROVENANCE.provenance()
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(ContextKey::Seeds)
&& !ctx
.get(ContextKey::Hypotheses)
.iter()
.any(|f| f.id().starts_with(FACT_PREFIX))
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let mut haystack = String::new();
for fact in ctx.get(ContextKey::Seeds) {
haystack.push(' ');
haystack.push_str(fact_text(fact));
}
for fact in ctx.get(ContextKey::Signals) {
haystack.push(' ');
haystack.push_str(fact_text(fact));
}
let classification = classify_text(&haystack);
let payload = serde_json::json!({
"agent": "problem-classifier",
"class": classification.class.as_str(),
"matched_keywords": classification.matched_keywords,
"defaulted": classification.defaulted,
});
AgentEffect::with_proposal(proposed_text_fact(
ContextKey::Hypotheses,
format!("{FACT_PREFIX}:{}", classification.class.as_str()),
payload.to_string(),
))
}
}
#[must_use]
pub fn extract_classification(ctx: &dyn Context) -> Option<ProblemClassification> {
ctx.get(ContextKey::Hypotheses)
.iter()
.find(|f| f.id().starts_with(FACT_PREFIX))
.and_then(|f| serde_json::from_str(fact_text(f)).ok())
.and_then(|v: serde_json::Value| {
let class_str = v.get("class")?.as_str()?;
let class = match class_str {
"decision" => organism_intent::problem::ProblemClass::Decision,
"research" => organism_intent::problem::ProblemClass::Research,
"evaluation" => organism_intent::problem::ProblemClass::Evaluation,
"planning" => organism_intent::problem::ProblemClass::Planning,
"diligence" => organism_intent::problem::ProblemClass::Diligence,
"incident" => organism_intent::problem::ProblemClass::Incident,
"strategy" => organism_intent::problem::ProblemClass::Strategy,
_ => return None,
};
let matched_keywords = v
.get("matched_keywords")?
.as_array()?
.iter()
.filter_map(|w| w.as_str().map(str::to_owned))
.collect();
let defaulted = v.get("defaulted")?.as_bool()?;
let tiebroken = v
.get("tiebroken")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
Some(ProblemClassification {
class,
matched_keywords,
defaulted,
tiebroken,
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formation::Formation;
use organism_intent::problem::ProblemClass;
fn classified_payload_from(seed: &str) -> serde_json::Value {
let result = tokio::runtime::Runtime::new()
.expect("runtime")
.block_on(async {
Formation::new("classifier-test")
.agent(ProblemClassifierSuggestor::new())
.seed(ContextKey::Seeds, "seed-1", seed, "test")
.run()
.await
.expect("formation runs")
});
let hypotheses = result.converge_result.context.get(ContextKey::Hypotheses);
let fact = hypotheses
.iter()
.find(|f| f.id().starts_with(FACT_PREFIX))
.expect("classifier emitted a problem-class hypothesis");
serde_json::from_str(fact_text(fact)).expect("payload is JSON")
}
#[test]
fn classifier_emits_evaluation_for_evaluation_keywords() {
let payload = classified_payload_from("evaluate the vendor proposals carefully");
assert_eq!(payload["class"], "evaluation");
assert_eq!(payload["defaulted"], false);
}
#[test]
fn classifier_emits_diligence_for_vet_keyword() {
let payload = classified_payload_from("vet the acquisition target end-to-end");
assert_eq!(payload["class"], "diligence");
}
#[test]
fn classifier_emits_incident_for_outage_keyword() {
let payload = classified_payload_from("respond to the prod outage and stabilize");
assert_eq!(payload["class"], "incident");
}
#[test]
fn classifier_falls_back_to_decision_with_no_keywords() {
let payload = classified_payload_from("doing the thing today");
assert_eq!(payload["class"], "decision");
assert_eq!(payload["defaulted"], true);
}
#[test]
fn extract_classification_recovers_typed_value() {
let payload = classified_payload_from("research the competitive landscape");
assert_eq!(payload["class"], "research");
let class_str = payload["class"].as_str().unwrap();
let class = match class_str {
"research" => ProblemClass::Research,
_ => panic!("unexpected class {class_str}"),
};
assert_eq!(class, ProblemClass::Research);
}
}