use std::collections::HashMap;
use crate::core::agents::{AgentDiary, DiaryEntryType};
use crate::core::knowledge::ProjectKnowledge;
#[derive(Debug, Clone)]
pub struct SkillCandidate {
pub slug: String,
pub title: String,
pub body: String,
pub category: String,
pub recurrence: u32,
pub confidence: f32,
pub sources: Vec<String>,
pub derivation: Derivation,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Derivation {
pub store: String,
pub facts: Vec<(String, String, String)>,
pub unbound: bool,
}
impl Derivation {
fn of_fact(store: &str, category: &str, key: &str, value: &str) -> Self {
Self {
store: store.to_owned(),
facts: vec![(category.to_owned(), key.to_owned(), value.to_owned())],
unbound: false,
}
}
fn unbound() -> Self {
Self {
unbound: true,
..Self::default()
}
}
fn join(&mut self, other: Self) {
if self.store.is_empty() {
self.store = other.store;
}
self.unbound |= other.unbound;
for fact in other.facts {
if !self.facts.contains(&fact) {
self.facts.push(fact);
}
}
}
}
fn knowledge_category_is_skillable(cat: &str) -> bool {
matches!(
cat.to_ascii_lowercase().as_str(),
"decision" | "insight" | "gotcha" | "pattern" | "convention" | "preference"
)
}
pub fn mine_candidates(project_root: &str) -> Vec<SkillCandidate> {
let mut by_slug: HashMap<String, SkillCandidate> = HashMap::new();
let knowledge = ProjectKnowledge::load_or_create(project_root);
let deliverable = knowledge.delivery_filter();
for fact in &knowledge.facts {
if !knowledge_category_is_skillable(&fact.category)
|| !deliverable(fact)
|| fact.hive_origin().is_some()
{
continue;
}
let title = title_from(&fact.key, &fact.value);
let slug = slugify(&title);
if slug.is_empty() {
continue;
}
let sources: Vec<String> = if fact.source_session.is_empty() {
Vec::new()
} else {
vec![fact.source_session.clone()]
};
merge_candidate(
&mut by_slug,
SkillCandidate {
slug,
title,
body: fact.value.trim().to_string(),
category: fact.category.to_ascii_lowercase(),
recurrence: fact.confirmation_count.max(1),
confidence: fact.confidence,
sources,
derivation: Derivation::of_fact(
&knowledge.project_root,
&fact.category,
&fact.key,
&fact.value,
),
},
);
}
for diary in AgentDiary::load_all_for_project(project_root) {
for entry in &diary.entries {
let category = match entry.entry_type {
DiaryEntryType::Decision => "decision",
DiaryEntryType::Insight => "insight",
DiaryEntryType::Discovery => "discovery",
DiaryEntryType::Progress | DiaryEntryType::Blocker => continue,
};
let body = entry.content.trim().to_string();
if body.is_empty() {
continue;
}
let title = title_from("", &body);
let slug = slugify(&title);
if slug.is_empty() {
continue;
}
merge_candidate(
&mut by_slug,
SkillCandidate {
slug,
title,
body,
category: category.to_string(),
recurrence: 1,
confidence: 0.6,
sources: vec![diary.agent_id.clone()],
derivation: Derivation::unbound(),
},
);
}
}
let mut out: Vec<SkillCandidate> = by_slug.into_values().collect();
out.sort_by(|a, b| {
b.recurrence
.cmp(&a.recurrence)
.then(b.confidence.total_cmp(&a.confidence))
.then(a.slug.cmp(&b.slug))
});
out
}
fn merge_candidate(map: &mut HashMap<String, SkillCandidate>, cand: SkillCandidate) {
if let Some(existing) = map.get_mut(&cand.slug) {
existing.recurrence = existing.recurrence.saturating_add(cand.recurrence);
existing.derivation.join(cand.derivation);
if cand.confidence > existing.confidence {
existing.confidence = cand.confidence;
}
if cand.body.len() > existing.body.len() {
existing.body = cand.body;
}
for s in cand.sources {
if !s.is_empty() && !existing.sources.contains(&s) {
existing.sources.push(s);
}
}
} else {
map.insert(cand.slug.clone(), cand);
}
}
fn title_from(key: &str, value: &str) -> String {
let k = key.trim();
if !k.is_empty() && k.chars().count() <= 80 {
return k.to_string();
}
first_sentence(value)
}
fn first_sentence(text: &str) -> String {
let t = text.trim();
let end = t.find(['.', '\n', '!', '?']).unwrap_or(t.len());
let candidate = t[..end].trim();
let s = if candidate.is_empty() { t } else { candidate };
truncate_chars(s, 80)
}
pub(crate) fn slugify(text: &str) -> String {
let mut slug = String::new();
let mut prev_dash = false;
for ch in text.trim().to_ascii_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
prev_dash = false;
} else if !slug.is_empty() && !prev_dash {
slug.push('-');
prev_dash = true;
}
}
let trimmed = slug.trim_matches('-');
truncate_chars(trimmed, 50).trim_matches('-').to_string()
}
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
s.chars().take(max).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_normalizes() {
assert_eq!(slugify("Stop before Build!"), "stop-before-build");
assert_eq!(slugify(" multiple spaces "), "multiple-spaces");
assert_eq!(slugify("___weird@@@chars###"), "weird-chars");
assert_eq!(slugify(""), "");
}
#[test]
fn first_sentence_truncates() {
assert_eq!(
first_sentence("Use atomic writes. And more."),
"Use atomic writes"
);
assert!(first_sentence(&"x".repeat(200)).chars().count() <= 80);
}
#[test]
fn merge_sums_recurrence_and_keeps_strongest() {
let mut map = HashMap::new();
merge_candidate(
&mut map,
SkillCandidate {
slug: "s".into(),
title: "t".into(),
body: "short".into(),
category: "decision".into(),
recurrence: 1,
confidence: 0.5,
sources: vec!["a".into()],
derivation: Derivation::default(),
},
);
merge_candidate(
&mut map,
SkillCandidate {
slug: "s".into(),
title: "t".into(),
body: "a much longer body".into(),
category: "decision".into(),
recurrence: 2,
confidence: 0.9,
sources: vec!["b".into()],
derivation: Derivation::default(),
},
);
let c = &map["s"];
assert_eq!(c.recurrence, 3);
assert_eq!(c.confidence, 0.9);
assert_eq!(c.body, "a much longer body");
assert_eq!(c.sources, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn a_fact_that_is_not_served_is_not_mined() {
use crate::core::context_admission::stored::lineage_support::{
BOUND_FACT, TYPED_FACT, governed, project, scoped, two_facts, withdraw_source,
};
let _data = governed();
let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
let (_directory, root) = project();
two_facts(&root, "decision");
let bodies = || -> Vec<String> {
scoped(&root, || mine_candidates(&root))
.into_iter()
.map(|candidate| candidate.body)
.collect()
};
let before = bodies();
assert!(before.contains(&BOUND_FACT.to_owned()), "{before:?}");
assert!(before.contains(&TYPED_FACT.to_owned()), "{before:?}");
withdraw_source(&root);
assert_eq!(bodies(), [TYPED_FACT.to_owned()]);
}
}