use crate::core::language_models::{BaseChatModel, LLMResult};
use crate::schema::Message;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct ExtractedEntity {
pub name: String,
#[serde(rename = "type")]
pub entity_type: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ExtractedRelation {
pub source: String,
pub target: String,
#[serde(rename = "type")]
pub relation_type: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct ExtractionResult {
#[serde(default)]
pub entities: Vec<ExtractedEntity>,
#[serde(default)]
pub relations: Vec<ExtractedRelation>,
}
const EXTRACTION_PROMPT: &str = r#"You are a knowledge graph extraction assistant. Given the following text, extract entities and their relations.
Return a JSON object with exactly two keys:
- "entities": an array of objects, each with keys "name", "type", "description"
- "relations": an array of objects, each with keys "source", "target", "type", "description"
Rules:
- "source" and "target" in relations must match entity "name" values exactly.
- Keep entity types simple: Person, Organization, Location, Technology, Concept, Event, etc.
- Keep relation types simple: works_at, located_in, uses, created, part_of, related_to, etc.
- Extract at most {max_entities} entities and {max_relations} relations.
- Return ONLY the JSON object, no other text.
Example:
Text: "Alice works at Google as a software engineer. She uses Python and TensorFlow."
Output:
{
"entities": [
{"name": "Alice", "type": "Person", "description": "A software engineer at Google"},
{"name": "Google", "type": "Organization", "description": "A technology company"},
{"name": "Python", "type": "Technology", "description": "A programming language"},
{"name": "TensorFlow", "type": "Technology", "description": "A machine learning framework"}
],
"relations": [
{"source": "Alice", "target": "Google", "type": "works_at", "description": "Alice is employed at Google"},
{"source": "Alice", "target": "Python", "type": "uses", "description": "Alice uses Python"},
{"source": "Alice", "target": "TensorFlow", "type": "uses", "description": "Alice uses TensorFlow"}
]
}
Text:
{text}"#;
pub async fn extract<M: BaseChatModel>(
llm: &M,
text: &str,
max_entities: usize,
max_relations: usize,
) -> Result<ExtractionResult, super::GraphRAGError> {
let prompt = {
use crate::PromptTemplate;
let template = PromptTemplate::new(EXTRACTION_PROMPT);
let max_entities_str = max_entities.to_string();
let max_relations_str = max_relations.to_string();
let mut vars: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
vars.insert("max_entities", &max_entities_str);
vars.insert("max_relations", &max_relations_str);
vars.insert("text", text);
template.format(&vars).unwrap_or_else(|_| EXTRACTION_PROMPT.to_string())
};
let messages = vec![Message::human(prompt)];
let response: LLMResult = llm
.chat(messages, None)
.await
.map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
parse_extraction(&response.content)
}
pub fn parse_extraction(raw: &str) -> Result<ExtractionResult, super::GraphRAGError> {
crate::core::json_parse::parse_llm_json::<ExtractionResult>(raw).map_err(|e| {
super::GraphRAGError::ExtractionError(format!(
"Failed to parse extraction JSON: {}",
e
))
})
}
#[cfg(test)]
fn extract_json(text: &str) -> String {
let trimmed = text.trim();
if let Some(rest) = trimmed.strip_prefix("```json") {
if let Some(end) = rest.find("```") {
return rest[..end].trim().to_string();
}
}
if let Some(rest) = trimmed.strip_prefix("```") {
if let Some(end) = rest.find("```") {
return rest[..end].trim().to_string();
}
}
if let Some(start) = trimmed.find('{') {
if let Some(end) = trimmed.rfind('}') {
if end > start {
return trimmed[start..=end].to_string();
}
}
}
trimmed.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_extraction_valid() {
let raw = r#"{"entities":[{"name":"Alice","type":"Person","description":"A developer"}],"relations":[{"source":"Alice","target":"Rust","type":"uses","description":"Alice uses Rust"}]}"#;
let result = parse_extraction(raw).unwrap();
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].name, "Alice");
assert_eq!(result.relations.len(), 1);
assert_eq!(result.relations[0].source, "Alice");
}
#[test]
fn test_parse_extraction_markdown_wrapped() {
let raw = r#"```json
{"entities":[{"name":"Bob","type":"Person","description":"A manager"}],"relations":[]}
```"#;
let result = parse_extraction(raw).unwrap();
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].name, "Bob");
}
#[test]
fn test_parse_extraction_empty_arrays() {
let raw = r#"{"entities":[],"relations":[]}"#;
let result = parse_extraction(raw).unwrap();
assert!(result.entities.is_empty());
assert!(result.relations.is_empty());
}
#[test]
fn test_parse_extraction_invalid() {
let raw = "not json at all";
assert!(parse_extraction(raw).is_err());
}
#[test]
fn test_extract_json_plain() {
let input = r#"{"key": "value"}"#;
assert_eq!(extract_json(input), input);
}
#[test]
fn test_extract_json_code_fence() {
let input = "```json\n{\"key\": \"value\"}\n```";
assert_eq!(extract_json(input), "{\"key\": \"value\"}");
}
#[test]
fn test_extract_json_with_surrounding_text() {
let input = "Here is the result:\n{\"key\": \"value\"}\nDone.";
assert_eq!(extract_json(input), "{\"key\": \"value\"}");
}
#[test]
fn test_extraction_prompt_contains_few_shot_example() {
assert!(
EXTRACTION_PROMPT.contains("Example:"),
"extraction prompt should contain few-shot example"
);
assert!(
EXTRACTION_PROMPT.contains("Alice"),
"extraction prompt example should contain entity 'Alice'"
);
assert!(
EXTRACTION_PROMPT.contains("works_at"),
"extraction prompt example should contain relation type 'works_at'"
);
}
}