use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use khive_brain_core::{FeedbackSignal, SectionType};
use khive_runtime::{KhiveRuntime, NamespaceToken, RuntimeError, VerbRegistry};
use khive_storage::EdgeRelation;
use crate::knowledge::section_feedback::on_section_feedback;
use crate::KnowledgePack;
fn deser<T: serde::de::DeserializeOwned>(params: Value) -> Result<T, RuntimeError> {
serde_json::from_value(params)
.map_err(|e| RuntimeError::InvalidInput(format!("bad params: {e}")))
}
fn short_id(uuid: Uuid) -> String {
uuid.as_hyphenated().to_string().chars().take(8).collect()
}
pub(crate) async fn resolve_uuid(
s: &str,
runtime: &KhiveRuntime,
token: &NamespaceToken,
) -> Result<Uuid, RuntimeError> {
if let Ok(uuid) = s.parse::<Uuid>() {
return Ok(uuid);
}
if s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit()) {
return match runtime.resolve_prefix(token, s).await? {
Some(uuid) => Ok(uuid),
None => Err(RuntimeError::InvalidInput(format!(
"no record matches prefix: {s:?}"
))),
};
}
Err(RuntimeError::InvalidInput(format!(
"invalid UUID (expected full UUID or 8+ hex prefix): {s:?}"
)))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LearnParams {
#[serde(default)]
name: Option<String>,
#[serde(default, alias = "content")]
description: Option<String>,
#[serde(default)]
domain: Option<String>,
#[serde(default)]
tags: Option<Vec<String>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CiteParams {
concept_id: String,
source_id: String,
#[serde(default)]
weight: Option<f64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TopicParams {
#[serde(default)]
domain: Option<String>,
#[serde(default)]
query: Option<String>,
#[serde(default)]
limit: Option<u32>,
}
impl KnowledgePack {
pub(crate) async fn handle_learn(
&self,
token: &NamespaceToken,
params: Value,
) -> Result<Value, RuntimeError> {
let p: LearnParams = deser(params)?;
let name = match p.name.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(n) => n.to_string(),
None => {
let src = p.description.as_deref().unwrap_or("").trim().to_string();
if src.is_empty() {
return Err(RuntimeError::InvalidInput(
"name must not be empty (provide 'name' or 'content')".to_string(),
));
}
if src.chars().count() <= 60 {
src.clone()
} else {
let byte_limit = src
.char_indices()
.nth(60)
.map(|(i, _)| i)
.unwrap_or(src.len());
let boundary = src[..byte_limit]
.rfind(char::is_whitespace)
.unwrap_or(byte_limit);
src[..boundary].trim_end().to_string()
}
}
};
let domain_norm: Option<String> = p
.domain
.as_ref()
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty());
let properties = domain_norm.as_ref().map(|d| json!({ "domain": d }));
let mut tags = p.tags.unwrap_or_default();
if let Some(d) = &domain_norm {
if !tags.contains(d) {
tags.push(d.clone());
}
}
let entity = self
.runtime
.create_entity(
token,
"concept",
None,
&name,
p.description.as_deref(),
properties,
tags.clone(),
)
.await?;
Ok(json!({
"id": short_id(entity.id),
"full_id": entity.id.as_hyphenated().to_string(),
"kind": "concept",
"name": entity.name,
"description": entity.description,
"domain": domain_norm,
"tags": entity.tags,
"namespace": entity.namespace,
}))
}
pub(crate) async fn handle_cite(
&self,
token: &NamespaceToken,
params: Value,
) -> Result<Value, RuntimeError> {
let p: CiteParams = deser(params)?;
let concept_id = resolve_uuid(&p.concept_id, &self.runtime, token).await?;
let source_id = resolve_uuid(&p.source_id, &self.runtime, token).await?;
let weight = p.weight.unwrap_or(1.0).clamp(0.0, 1.0);
let edge = self
.runtime
.link(
token,
concept_id,
source_id,
EdgeRelation::IntroducedBy,
weight,
None,
)
.await?;
Ok(json!({
"id": short_id(edge.id.0),
"full_id": edge.id.0.as_hyphenated().to_string(),
"relation": "introduced_by",
"concept_id": concept_id.as_hyphenated().to_string(),
"source_id": source_id.as_hyphenated().to_string(),
"weight": weight,
}))
}
pub(crate) async fn handle_topic(
&self,
token: &NamespaceToken,
params: Value,
) -> Result<Value, RuntimeError> {
let p: TopicParams = deser(params)?;
let limit = p.limit.unwrap_or(20).min(100);
let domain_filter = p
.domain
.as_deref()
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty());
if let Some(ref query) = p.query {
let hits = self
.runtime
.hybrid_search(token, query, None, limit * 4, Some("concept"), None)
.await?;
let hit_ids: Vec<Uuid> = hits.iter().map(|h| h.entity_id).collect();
let entity_map: std::collections::HashMap<Uuid, _> = if !hit_ids.is_empty() {
self.runtime
.get_entities_by_ids(token, &hit_ids)
.await?
.into_iter()
.map(|e| (e.id, e))
.collect()
} else {
std::collections::HashMap::new()
};
let filtered: Vec<_> = hits
.into_iter()
.filter(|h| {
let Some(entity) = entity_map.get(&h.entity_id) else {
return false;
};
if let Some(ref d) = domain_filter {
entity.tags.iter().any(|t| t.eq_ignore_ascii_case(d))
} else {
true
}
})
.collect();
let total = filtered.len();
let results: Vec<Value> = filtered
.into_iter()
.take(limit as usize)
.map(|h| {
let entity = entity_map.get(&h.entity_id).unwrap();
let mut item = json!({
"id": short_id(entity.id),
"full_id": entity.id.as_hyphenated().to_string(),
"name": entity.name,
"description": entity.description,
"tags": entity.tags,
"score": h.score.to_f64(),
});
if let Some(snippet) = h.snippet {
item["snippet"] = serde_json::Value::String(snippet);
}
item
})
.collect();
Ok(json!({ "results": results, "total": total }))
} else {
let total = self
.runtime
.count_entities_tagged(token, Some("concept"), domain_filter.as_deref())
.await?;
let entities = self
.runtime
.list_entities_tagged(token, Some("concept"), domain_filter.as_deref(), limit, 0)
.await?;
let results: Vec<Value> = entities
.into_iter()
.map(|e| {
json!({
"id": short_id(e.id),
"full_id": e.id.as_hyphenated().to_string(),
"name": e.name,
"description": e.description,
"tags": e.tags,
})
})
.collect();
Ok(json!({ "results": results, "total": total }))
}
}
pub(crate) async fn handle_feedback(
&self,
token: &NamespaceToken,
params: Value,
registry: &VerbRegistry,
) -> Result<Value, RuntimeError> {
let target_id_str = params
.get("target_id")
.and_then(|v| v.as_str())
.map(str::to_owned);
let raw = params
.get("section_signals")
.and_then(|v| v.as_object())
.ok_or_else(|| {
RuntimeError::InvalidInput(
"section_signals is required and must be an object".to_string(),
)
})?;
let mut signals: Vec<(SectionType, FeedbackSignal)> = Vec::with_capacity(raw.len());
for (key, val) in raw {
let section_type = SectionType::from_str_loose(key).ok_or_else(|| {
RuntimeError::InvalidInput(format!("unknown section_type: {key:?}"))
})?;
let signal_str = val.as_str().ok_or_else(|| {
RuntimeError::InvalidInput(format!("section signal for {key:?} must be a string"))
})?;
let signal = match signal_str {
"useful" => FeedbackSignal::Useful,
"not_useful" => FeedbackSignal::NotUseful,
"wrong" => FeedbackSignal::Wrong,
other => {
return Err(RuntimeError::InvalidInput(format!(
"unknown feedback signal {other:?}; expected useful | not_useful | wrong"
)))
}
};
signals.push((section_type, signal));
}
let ns = token.namespace().as_str().to_string();
let section_signals_val = params.get("section_signals").cloned().unwrap_or_default();
if let Some(ref profile_id) = self.brain_profile {
if let Some(ref tid) = target_id_str {
let brain_params = json!({
"namespace": ns,
"target_id": tid,
"signal": "useful",
"served_by_profile_id": profile_id,
"section_signals": section_signals_val,
});
let result = registry.dispatch("brain.feedback", brain_params).await?;
return Ok(json!({
"ok": true,
"brain_profile": profile_id,
"signals_applied": signals.len(),
"emitted": result.get("emitted").and_then(|v| v.as_bool()).unwrap_or(false),
}));
}
}
if let Some(ref tid) = target_id_str {
if let Some(profile_id) =
knowledge_resolve_namespace_profile(registry, &ns, "recall").await
{
let brain_params = json!({
"namespace": ns,
"target_id": tid,
"signal": "useful",
"served_by_profile_id": profile_id,
"section_signals": section_signals_val,
});
let result = registry.dispatch("brain.feedback", brain_params).await?;
return Ok(json!({
"ok": true,
"brain_profile": profile_id,
"signals_applied": signals.len(),
"emitted": result.get("emitted").and_then(|v| v.as_bool()).unwrap_or(false),
}));
}
}
let total_events = {
let mut state = self.section_posteriors.lock().map_err(|_| {
RuntimeError::Internal("section_posteriors lock poisoned".to_string())
})?;
on_section_feedback(&mut state, &signals);
state.total_events
};
Ok(json!({
"ok": true,
"total_events": total_events,
"signals_applied": signals.len(),
}))
}
}
async fn knowledge_resolve_namespace_profile(
registry: &VerbRegistry,
namespace: &str,
consumer_kind: &str,
) -> Option<String> {
let resolve_params = json!({
"namespace": namespace,
"consumer_kind": consumer_kind,
});
match registry.dispatch("brain.resolve", resolve_params).await {
Ok(v) => {
let matched_binding = v
.get("matched_binding")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if matched_binding {
v.get("resolved_profile_id")
.and_then(|id| id.as_str())
.map(str::to_owned)
} else {
None
}
}
Err(_) => None,
}
}