use async_trait::async_trait;
use klieo_core::error::MemoryError;
use klieo_core::llm::{ChatRequest, LlmClient, Message, ResponseFormat, Role};
use klieo_memory_graph::{EntityExtractor, EntityRef, EntityType};
use serde::Deserialize;
use std::collections::HashSet;
use std::sync::Arc;
const SYSTEM_PROMPT: &str = "You are an entity-extraction service. Given a text body, \
identify typed entities and return them as a JSON array. Each element MUST be an object with \
exactly two string fields: `type` (one of: Ticket, Policy, Agent, Member, Concept; or a free-form \
domain label for novel categories) and `name` (the entity identifier as it appears in the text, \
preserving case). Return ONLY the JSON array — no prose, no preamble. An empty array (`[]`) means \
no entities were found.";
pub struct LlmEntityExtractor {
client: Arc<dyn LlmClient>,
}
impl LlmEntityExtractor {
pub fn new(client: Arc<dyn LlmClient>) -> Self {
Self { client }
}
fn build_request(text: &str) -> ChatRequest {
let messages = vec![
Message {
role: Role::System,
content: SYSTEM_PROMPT.to_string(),
tool_calls: Vec::new(),
tool_call_id: None,
},
Message {
role: Role::User,
content: text.to_string(),
tool_calls: Vec::new(),
tool_call_id: None,
},
];
ChatRequest {
response_format: ResponseFormat::Json {
schema: schema_for_entity_array(),
},
..ChatRequest::new(messages)
}
}
}
#[derive(Debug, Deserialize)]
struct WireEntity {
#[serde(rename = "type")]
entity_type: String,
name: String,
}
fn schema_for_entity_array() -> serde_json::Value {
serde_json::json!({
"type": "array",
"items": {
"type": "object",
"required": ["type", "name"],
"properties": {
"type": { "type": "string" },
"name": { "type": "string" }
}
}
})
}
fn parse_response(body: &str) -> Option<Vec<EntityRef>> {
let parsed: Vec<WireEntity> = serde_json::from_str(body.trim()).ok()?;
let mut out = Vec::with_capacity(parsed.len());
for w in parsed {
let entity_type = EntityType::from_label(&w.entity_type);
match EntityRef::try_new(entity_type, w.name.clone()) {
Ok(entity_ref) => out.push(entity_ref),
Err(err) => {
tracing::warn!(
extractor = "LlmEntityExtractor",
label = %w.entity_type,
name = %w.name,
error = %err,
"LLM-emitted entity rejected (reserved-label collision); dropping"
);
}
}
}
Some(out)
}
#[async_trait]
impl EntityExtractor for LlmEntityExtractor {
async fn extract(
&self,
text: &str,
hints: &[EntityRef],
) -> Result<Vec<EntityRef>, MemoryError> {
let request = Self::build_request(text);
let response = match self.client.complete(request).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(
extractor = "LlmEntityExtractor",
error = %e,
"LLM call failed; falling back to caller hints"
);
return Ok(hints.to_vec());
}
};
let llm_entities = match parse_response(&response.message.content) {
Some(es) => es,
None => {
tracing::warn!(
extractor = "LlmEntityExtractor",
body = %response.message.content,
"LLM response was not a parseable JSON entity array; falling back to caller hints"
);
return Ok(hints.to_vec());
}
};
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut out = Vec::with_capacity(hints.len() + llm_entities.len());
for h in hints {
let key = (h.entity_type.as_str().to_owned(), h.name.clone());
if seen.insert(key) {
out.push(h.clone());
}
}
for h in llm_entities {
let key = (h.entity_type.as_str().to_owned(), h.name.clone());
if seen.insert(key) {
out.push(h);
}
}
Ok(out)
}
}