1use lc_core::language_models::BaseChatModel;
8use lc_core::tools::ToolDefinition;
9use lc_schema::Message;
10use serde::Deserialize;
11use serde_json::json;
12
13use crate::structured::{chat_structured, StructuredChatResult};
14
15#[derive(Debug, Clone, Deserialize)]
17pub struct ExtractedEntity {
18 pub name: String,
20 #[serde(rename = "type")]
22 pub entity_type: String,
23 #[serde(default)]
25 pub description: String,
26}
27
28#[derive(Debug, Clone, Deserialize)]
30pub struct ExtractedRelation {
31 pub source: String,
33 pub target: String,
35 #[serde(rename = "type")]
37 pub relation_type: String,
38 #[serde(default)]
40 pub description: String,
41}
42
43#[derive(Debug, Deserialize)]
45pub struct ExtractionResult {
46 #[serde(default)]
48 pub entities: Vec<ExtractedEntity>,
49 #[serde(default)]
51 pub relations: Vec<ExtractedRelation>,
52}
53
54const EXTRACTION_PROMPT: &str = r#"You are a knowledge graph extraction assistant. Given the following text, extract entities and their relations.
55
56Return a JSON object with exactly two keys:
57- "entities": an array of objects, each with keys "name", "type", "description"
58- "relations": an array of objects, each with keys "source", "target", "type", "description"
59
60Rules:
61- "source" and "target" in relations must match entity "name" values exactly.
62- Keep entity types simple: Person, Organization, Location, Technology, Concept, Event, etc.
63- Keep relation types simple: works_at, located_in, uses, created, part_of, related_to, etc.
64- Extract at most {max_entities} entities and {max_relations} relations.
65- Return ONLY the JSON object, no other text.
66
67Example:
68Text: "Alice works at Google as a software engineer. She uses Python and TensorFlow."
69Output:
70{
71 "entities": [
72 {"name": "Alice", "type": "Person", "description": "A software engineer at Google"},
73 {"name": "Google", "type": "Organization", "description": "A technology company"},
74 {"name": "Python", "type": "Technology", "description": "A programming language"},
75 {"name": "TensorFlow", "type": "Technology", "description": "A machine learning framework"}
76 ],
77 "relations": [
78 {"source": "Alice", "target": "Google", "type": "works_at", "description": "Alice is employed at Google"},
79 {"source": "Alice", "target": "Python", "type": "uses", "description": "Alice uses Python"},
80 {"source": "Alice", "target": "TensorFlow", "type": "uses", "description": "Alice uses TensorFlow"}
81 ]
82}
83
84Text:
85{text}"#;
86
87fn extraction_tool() -> ToolDefinition {
89 ToolDefinition::new(
90 "extract_entities_relations",
91 "从文本中提取知识图谱实体与关系,返回 entities 与 relations 两个数组",
92 )
93 .with_parameters(json!({
94 "type": "object",
95 "properties": {
96 "entities": {
97 "type": "array",
98 "items": {
99 "type": "object",
100 "properties": {
101 "name": { "type": "string" },
102 "type": { "type": "string" },
103 "description": { "type": "string" }
104 },
105 "required": ["name", "type", "description"]
106 }
107 },
108 "relations": {
109 "type": "array",
110 "items": {
111 "type": "object",
112 "properties": {
113 "source": { "type": "string" },
114 "target": { "type": "string" },
115 "type": { "type": "string" },
116 "description": { "type": "string" }
117 },
118 "required": ["source", "target", "type", "description"]
119 }
120 }
121 },
122 "required": ["entities", "relations"]
123 }))
124}
125
126fn parse_structured(result: &StructuredChatResult) -> Option<ExtractionResult> {
128 if let Some(args) = &result.tool_args {
129 if let Ok(parsed) = serde_json::from_value::<ExtractionResult>(args.clone()) {
130 return Some(parsed);
131 }
132 }
133 parse_extraction(&result.content).ok()
134}
135
136pub async fn extract<M: BaseChatModel>(
138 llm: &M,
139 text: &str,
140 max_entities: usize,
141 max_relations: usize,
142) -> Result<ExtractionResult, super::GraphRAGError> {
143 let prompt = {
144 use lc_prompts::PromptTemplate;
145 let template = PromptTemplate::new(EXTRACTION_PROMPT);
146 let max_entities_str = max_entities.to_string();
147 let max_relations_str = max_relations.to_string();
148 let mut vars: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
149 vars.insert("max_entities", &max_entities_str);
150 vars.insert("max_relations", &max_relations_str);
151 vars.insert("text", text);
152 template
153 .format(&vars)
154 .unwrap_or_else(|_| EXTRACTION_PROMPT.to_string())
155 };
156
157 extract_with_retry(llm, text, prompt).await
158}
159
160async fn extract_with_retry<M: BaseChatModel>(
163 llm: &M,
164 original_text: &str,
165 prompt: String,
166) -> Result<ExtractionResult, super::GraphRAGError> {
167 const MAX_RETRIES: usize = 2;
168 let mut current_prompt = prompt;
169
170 for attempt in 0..=MAX_RETRIES {
171 let result = chat_structured(
172 llm,
173 Some(extraction_tool()),
174 vec![Message::human(¤t_prompt)],
175 )
176 .await
177 .map_err(|e| super::GraphRAGError::LLMError(e.to_string()))?;
178
179 if let Some(parsed) = parse_structured(&result) {
180 return Ok(parsed);
181 }
182
183 if attempt < MAX_RETRIES {
184 current_prompt = format!(
185 "上次的输出不是合法 JSON,无法解析。请重新从下面文本提取实体与关系,\
186 只返回一个 JSON 对象(键为 entities 与 relations),不要包含任何解释、\
187 编号、引号或代码块。\n\n文本:\n{}\n\n上次输出(无效):\n{}\n\n只输出 JSON 对象:",
188 original_text, result.content
189 );
190 }
191 }
192
193 Err(super::GraphRAGError::ExtractionError(
194 "LLM repeatedly returned invalid JSON; entity/relation extraction failed".to_string(),
195 ))
196}
197
198pub fn parse_extraction(raw: &str) -> Result<ExtractionResult, super::GraphRAGError> {
200 lc_core::json_parse::parse_llm_json::<ExtractionResult>(raw).map_err(|e| {
201 super::GraphRAGError::ExtractionError(format!("Failed to parse extraction JSON: {}", e))
202 })
203}
204
205#[cfg(test)]
207fn extract_json(text: &str) -> String {
208 let trimmed = text.trim();
209
210 if let Some(rest) = trimmed.strip_prefix("```json") {
212 if let Some(end) = rest.find("```") {
213 return rest[..end].trim().to_string();
214 }
215 }
216
217 if let Some(rest) = trimmed.strip_prefix("```") {
219 if let Some(end) = rest.find("```") {
220 return rest[..end].trim().to_string();
221 }
222 }
223
224 if let Some(start) = trimmed.find('{') {
226 if let Some(end) = trimmed.rfind('}') {
227 if end > start {
228 return trimmed[start..=end].to_string();
229 }
230 }
231 }
232
233 trimmed.to_string()
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn test_parse_extraction_valid() {
242 let raw = r#"{"entities":[{"name":"Alice","type":"Person","description":"A developer"}],"relations":[{"source":"Alice","target":"Rust","type":"uses","description":"Alice uses Rust"}]}"#;
243 let result = parse_extraction(raw).unwrap();
244 assert_eq!(result.entities.len(), 1);
245 assert_eq!(result.entities[0].name, "Alice");
246 assert_eq!(result.relations.len(), 1);
247 assert_eq!(result.relations[0].source, "Alice");
248 }
249
250 #[test]
251 fn test_parse_extraction_markdown_wrapped() {
252 let raw = r#"```json
253{"entities":[{"name":"Bob","type":"Person","description":"A manager"}],"relations":[]}
254```"#;
255 let result = parse_extraction(raw).unwrap();
256 assert_eq!(result.entities.len(), 1);
257 assert_eq!(result.entities[0].name, "Bob");
258 }
259
260 #[test]
261 fn test_parse_extraction_empty_arrays() {
262 let raw = r#"{"entities":[],"relations":[]}"#;
263 let result = parse_extraction(raw).unwrap();
264 assert!(result.entities.is_empty());
265 assert!(result.relations.is_empty());
266 }
267
268 #[test]
269 fn test_parse_extraction_invalid() {
270 let raw = "not json at all";
271 assert!(parse_extraction(raw).is_err());
272 }
273
274 #[test]
275 fn test_extract_json_plain() {
276 let input = r#"{"key": "value"}"#;
277 assert_eq!(extract_json(input), input);
278 }
279
280 #[test]
281 fn test_extract_json_code_fence() {
282 let input = "```json\n{\"key\": \"value\"}\n```";
283 assert_eq!(extract_json(input), "{\"key\": \"value\"}");
284 }
285
286 #[test]
287 fn test_extract_json_with_surrounding_text() {
288 let input = "Here is the result:\n{\"key\": \"value\"}\nDone.";
289 assert_eq!(extract_json(input), "{\"key\": \"value\"}");
290 }
291
292 #[test]
294 fn test_extraction_prompt_contains_few_shot_example() {
295 assert!(
296 EXTRACTION_PROMPT.contains("Example:"),
297 "extraction prompt should contain few-shot example"
298 );
299 assert!(
300 EXTRACTION_PROMPT.contains("Alice"),
301 "extraction prompt example should contain entity 'Alice'"
302 );
303 assert!(
304 EXTRACTION_PROMPT.contains("works_at"),
305 "extraction prompt example should contain relation type 'works_at'"
306 );
307 }
308
309 #[test]
311 fn test_extraction_tool_schema() {
312 let tool = extraction_tool();
313 assert_eq!(tool.function.name, "extract_entities_relations");
314 let params = tool.function.parameters.expect("parameters should exist");
315 assert!(params["properties"]["entities"].is_object());
316 assert!(params["properties"]["relations"].is_object());
317 }
318
319 #[test]
321 fn test_parse_structured_tool_args() {
322 let result = StructuredChatResult {
323 content: "".to_string(),
324 tool_args: Some(json!({
325 "entities": [{"name": "Alice", "type": "Person", "description": "dev"}],
326 "relations": []
327 })),
328 };
329 let parsed = parse_structured(&result).expect("tool_args should parse successfully");
330 assert_eq!(parsed.entities.len(), 1);
331 assert_eq!(parsed.entities[0].name, "Alice");
332 assert!(parsed.relations.is_empty());
333 }
334
335 #[test]
337 fn test_parse_structured_text_fallback() {
338 let result = StructuredChatResult {
339 content: r#"{"entities": [{"name": "Bob", "type": "Person", "description": "mgr"}], "relations": []}"#.to_string(),
340 tool_args: None,
341 };
342 let parsed = parse_structured(&result).expect("text JSON should parse successfully");
343 assert_eq!(parsed.entities[0].name, "Bob");
344 }
345
346 #[test]
348 fn test_parse_structured_none() {
349 let result = StructuredChatResult {
350 content: "not json".to_string(),
351 tool_args: Some(json!("not an object")),
352 };
353 assert!(parse_structured(&result).is_none());
354 }
355}