oxirs_graphrag/generation/
context_builder.rs1use crate::{CommunitySummary, GraphRAGResult, Triple};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ContextConfig {
9 pub max_length: usize,
11 pub include_communities: bool,
13 pub include_triples: bool,
15 pub triple_format: TripleFormat,
17 pub score_weighted: bool,
19}
20
21impl Default for ContextConfig {
22 fn default() -> Self {
23 Self {
24 max_length: 8000,
25 include_communities: true,
26 include_triples: true,
27 triple_format: TripleFormat::NaturalLanguage,
28 score_weighted: true,
29 }
30 }
31}
32
33#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
35pub enum TripleFormat {
36 NaturalLanguage,
38 Structured,
40 Turtle,
42 JsonLd,
44}
45
46#[derive(Debug, Clone, PartialEq)]
56pub struct ScoredTriple {
57 pub triple: Triple,
58 pub score: f64,
59}
60
61impl ScoredTriple {
62 pub fn new(triple: Triple, score: f64) -> Self {
64 Self { triple, score }
65 }
66
67 pub fn unscored(triple: Triple) -> Self {
72 Self { triple, score: 0.0 }
73 }
74}
75
76impl From<Triple> for ScoredTriple {
77 fn from(triple: Triple) -> Self {
78 Self::unscored(triple)
79 }
80}
81
82pub struct ContextBuilder {
84 config: ContextConfig,
85}
86
87impl Default for ContextBuilder {
88 fn default() -> Self {
89 Self::new(ContextConfig::default())
90 }
91}
92
93impl ContextBuilder {
94 pub fn new(config: ContextConfig) -> Self {
95 Self { config }
96 }
97
98 pub fn build(
108 &self,
109 query: &str,
110 triples: &[ScoredTriple],
111 communities: &[CommunitySummary],
112 ) -> GraphRAGResult<String> {
113 let mut context = String::new();
114 let mut remaining_length = self.config.max_length;
115
116 let query_section = format!("## Query\n{}\n\n", query);
118 if query_section.len() < remaining_length {
119 context.push_str(&query_section);
120 remaining_length -= query_section.len();
121 }
122
123 if self.config.include_communities && !communities.is_empty() {
125 let community_section = self.format_communities(communities, remaining_length / 3);
126 if community_section.len() < remaining_length {
127 context.push_str(&community_section);
128 remaining_length -= community_section.len();
129 }
130 }
131
132 if self.config.include_triples && !triples.is_empty() {
134 let ordered: Vec<&ScoredTriple> = if self.config.score_weighted {
135 let mut refs: Vec<&ScoredTriple> = triples.iter().collect();
136 refs.sort_by(|a, b| {
137 b.score
138 .partial_cmp(&a.score)
139 .unwrap_or(std::cmp::Ordering::Equal)
140 });
141 refs
142 } else {
143 triples.iter().collect()
144 };
145 let triples_section = self.format_triples(&ordered, remaining_length);
146 context.push_str(&triples_section);
147 }
148
149 Ok(context)
150 }
151
152 pub fn build_unscored(
157 &self,
158 query: &str,
159 triples: &[Triple],
160 communities: &[CommunitySummary],
161 ) -> GraphRAGResult<String> {
162 let scored: Vec<ScoredTriple> = triples
163 .iter()
164 .cloned()
165 .map(ScoredTriple::unscored)
166 .collect();
167 self.build(query, &scored, communities)
168 }
169
170 fn format_communities(&self, communities: &[CommunitySummary], max_length: usize) -> String {
172 let mut result = String::from("## Knowledge Graph Communities\n\n");
173
174 for community in communities {
175 let entry = format!(
176 "### {}\n{}\n**Entities:** {}\n\n",
177 community.id,
178 community.summary,
179 community
180 .entities
181 .iter()
182 .take(5)
183 .cloned()
184 .collect::<Vec<_>>()
185 .join(", ")
186 );
187
188 if result.len() + entry.len() > max_length {
189 break;
190 }
191 result.push_str(&entry);
192 }
193
194 result
195 }
196
197 fn format_triples(&self, triples: &[&ScoredTriple], max_length: usize) -> String {
202 let mut result = String::from("## Knowledge Graph Facts\n\n");
203
204 for scored in triples {
205 let triple = &scored.triple;
206 let entry = match self.config.triple_format {
207 TripleFormat::NaturalLanguage => self.triple_to_natural_language(triple),
208 TripleFormat::Structured => self.triple_to_structured(triple),
209 TripleFormat::Turtle => self.triple_to_turtle(triple),
210 TripleFormat::JsonLd => self.triple_to_jsonld(triple),
211 };
212
213 if result.len() + entry.len() > max_length {
214 break;
215 }
216 result.push_str(&entry);
217 result.push('\n');
218 }
219
220 result
221 }
222
223 fn triple_to_natural_language(&self, triple: &Triple) -> String {
225 let subject = self.extract_local_name(&triple.subject);
226 let predicate = self.predicate_to_phrase(&triple.predicate);
227 let object = self.extract_local_name(&triple.object);
228
229 format!("- {} {} {}", subject, predicate, object)
230 }
231
232 fn triple_to_structured(&self, triple: &Triple) -> String {
234 let subject = self.extract_local_name(&triple.subject);
235 let predicate = self.extract_local_name(&triple.predicate);
236 let object = self.extract_local_name(&triple.object);
237
238 format!("- {} → {} → {}", subject, predicate, object)
239 }
240
241 fn triple_to_turtle(&self, triple: &Triple) -> String {
243 format!(
244 "<{}> <{}> <{}> .",
245 triple.subject, triple.predicate, triple.object
246 )
247 }
248
249 fn triple_to_jsonld(&self, triple: &Triple) -> String {
251 let subject = self.extract_local_name(&triple.subject);
252 let predicate = self.extract_local_name(&triple.predicate);
253 let object = self.extract_local_name(&triple.object);
254
255 format!(
256 "{{ \"@id\": \"{}\", \"{}\": \"{}\" }}",
257 subject, predicate, object
258 )
259 }
260
261 fn extract_local_name(&self, uri: &str) -> String {
263 uri.rsplit('#')
265 .next()
266 .filter(|s| s != &uri) .or_else(|| uri.rsplit('/').next())
268 .unwrap_or(uri)
269 .to_string()
270 }
271
272 fn predicate_to_phrase(&self, predicate: &str) -> String {
274 let local = self.extract_local_name(predicate);
275
276 match local.as_str() {
278 "type" | "rdf:type" => "is a".to_string(),
279 "label" | "rdfs:label" => "is labeled".to_string(),
280 "subClassOf" => "is a subclass of".to_string(),
281 "partOf" => "is part of".to_string(),
282 "hasPart" => "has part".to_string(),
283 "relatedTo" => "is related to".to_string(),
284 "sameAs" => "is the same as".to_string(),
285 "knows" => "knows".to_string(),
286 "worksFor" => "works for".to_string(),
287 "locatedIn" => "is located in".to_string(),
288 _ => {
289 let mut result = String::new();
291 for (i, c) in local.chars().enumerate() {
292 if i > 0 && c.is_uppercase() {
293 result.push(' ');
294 }
295 result.push(c.to_lowercase().next().unwrap_or(c));
296 }
297 result
298 }
299 }
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn test_context_building() {
309 let builder = ContextBuilder::default();
310
311 let triples = vec![
312 Triple::new(
313 "http://example.org/Battery1",
314 "http://example.org/hasStatus",
315 "http://example.org/Critical",
316 ),
317 Triple::new(
318 "http://example.org/Battery1",
319 "http://example.org/temperature",
320 "85",
321 ),
322 ];
323
324 let communities = vec![CommunitySummary {
325 id: "community_0".to_string(),
326 summary: "Battery monitoring entities".to_string(),
327 entities: vec!["Battery1".to_string(), "Sensor1".to_string()],
328 representative_triples: vec![],
329 level: 0,
330 modularity: 0.5,
331 }];
332
333 let context = builder
334 .build_unscored("What is the battery status?", &triples, &communities)
335 .expect("should succeed");
336
337 assert!(context.contains("Query"));
338 assert!(context.contains("Battery1"));
339 }
340
341 #[test]
342 fn test_predicate_to_phrase() {
343 let builder = ContextBuilder::default();
344
345 assert_eq!(
346 builder.predicate_to_phrase("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
347 "is a"
348 );
349 assert_eq!(
350 builder.predicate_to_phrase("http://example.org/partOf"),
351 "is part of"
352 );
353 assert_eq!(
354 builder.predicate_to_phrase("http://example.org/hasTemperature"),
355 "has temperature"
356 );
357 }
358
359 fn triple_n(n: u32) -> Triple {
362 Triple::new(
363 format!("http://example.org/s{n}"),
364 "http://example.org/rel",
365 format!("http://example.org/o{n}"),
366 )
367 }
368
369 #[test]
370 fn regression_score_weighted_true_sorts_descending_by_score() {
371 let builder = ContextBuilder::new(ContextConfig {
372 triple_format: TripleFormat::Turtle,
373 score_weighted: true,
374 ..ContextConfig::default()
375 });
376
377 let triples = vec![
380 ScoredTriple::new(triple_n(1), 0.1),
381 ScoredTriple::new(triple_n(2), 0.9),
382 ScoredTriple::new(triple_n(3), 0.5),
383 ];
384
385 let context = builder.build("q", &triples, &[]).expect("should succeed");
386
387 let pos2 = context.find("s2").expect("s2 present");
388 let pos3 = context.find("s3").expect("s3 present");
389 let pos1 = context.find("s1").expect("s1 present");
390 assert!(
391 pos2 < pos3 && pos3 < pos1,
392 "expected order by descending score (s2=0.9, s3=0.5, s1=0.1), got: {context}"
393 );
394 }
395
396 #[test]
397 fn regression_score_weighted_false_preserves_input_order() {
398 let builder = ContextBuilder::new(ContextConfig {
399 triple_format: TripleFormat::Turtle,
400 score_weighted: false,
401 ..ContextConfig::default()
402 });
403
404 let triples = vec![
407 ScoredTriple::new(triple_n(1), 0.1),
408 ScoredTriple::new(triple_n(2), 0.9),
409 ScoredTriple::new(triple_n(3), 0.5),
410 ];
411
412 let context = builder.build("q", &triples, &[]).expect("should succeed");
413
414 let pos1 = context.find("s1").expect("s1 present");
415 let pos2 = context.find("s2").expect("s2 present");
416 let pos3 = context.find("s3").expect("s3 present");
417 assert!(
418 pos1 < pos2 && pos2 < pos3,
419 "expected original input order preserved, got: {context}"
420 );
421 }
422
423 #[test]
424 fn regression_unscored_triples_are_stable_regardless_of_score_weighted() {
425 let builder = ContextBuilder::new(ContextConfig {
430 triple_format: TripleFormat::Turtle,
431 score_weighted: true,
432 ..ContextConfig::default()
433 });
434
435 let triples = vec![triple_n(1), triple_n(2), triple_n(3)];
436 let context = builder
437 .build_unscored("q", &triples, &[])
438 .expect("should succeed");
439
440 let pos1 = context.find("s1").expect("s1 present");
441 let pos2 = context.find("s2").expect("s2 present");
442 let pos3 = context.find("s3").expect("s3 present");
443 assert!(pos1 < pos2 && pos2 < pos3);
444 }
445}