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