Skip to main content

oxirs_graphrag/generation/
context_builder.rs

1//! Context building for LLM generation
2
3use crate::{CommunitySummary, GraphRAGResult, Triple};
4use serde::{Deserialize, Serialize};
5
6/// Context builder configuration
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ContextConfig {
9    /// Maximum context length in characters
10    pub max_length: usize,
11    /// Include community summaries
12    pub include_communities: bool,
13    /// Include raw triples
14    pub include_triples: bool,
15    /// Triple format
16    pub triple_format: TripleFormat,
17    /// Prioritize triples by score
18    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/// Triple formatting options
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
35pub enum TripleFormat {
36    /// Natural language: "Entity A is related to Entity B"
37    NaturalLanguage,
38    /// Structured: "subject → predicate → object"
39    Structured,
40    /// Turtle-like: `<subject> <predicate> <object> .`
41    Turtle,
42    /// JSON-LD style
43    JsonLd,
44}
45
46/// A knowledge-graph [`Triple`] paired with a relevance score.
47///
48/// [`ContextBuilder::build`] honors [`ContextConfig::score_weighted`] by
49/// sorting these descending by `score` before truncating to the character
50/// budget. `Triple` itself carries no score (it is a plain RDF fact), so
51/// callers that want prioritized ordering must supply one explicitly — use
52/// [`ScoredTriple::unscored`] for callers with no real relevance signal,
53/// which makes `score_weighted` a stable (input-order-preserving) no-op
54/// rather than silently claiming to weight triples it has no data to weight.
55#[derive(Debug, Clone, PartialEq)]
56pub struct ScoredTriple {
57    pub triple: Triple,
58    pub score: f64,
59}
60
61impl ScoredTriple {
62    /// Wrap a triple with an explicit relevance score.
63    pub fn new(triple: Triple, score: f64) -> Self {
64        Self { triple, score }
65    }
66
67    /// Wrap a triple with a neutral score, for callers with no relevance
68    /// signal available. `Vec::sort_by` is stable, so a slice of
69    /// all-neutral-score triples is left in its original order even when
70    /// `score_weighted` is enabled.
71    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
82/// Context builder for LLM input
83pub 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    /// Build context string from subgraph and communities.
99    ///
100    /// When [`ContextConfig::score_weighted`] is set, `triples` are sorted
101    /// by descending [`ScoredTriple::score`] before truncation, so the most
102    /// relevant facts survive the character budget first. Pass
103    /// [`ScoredTriple::unscored`] triples (or use [`Self::build_unscored`])
104    /// if no real relevance score is available — `score_weighted` then has
105    /// no effect (stable sort preserves input order), rather than silently
106    /// pretending to prioritize triples it has no signal to prioritize by.
107    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        // Add query context
117        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        // Add community summaries
124        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        // Add triples, honoring `score_weighted` if requested.
133        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    /// Convenience wrapper for callers with no relevance score available:
153    /// wraps every triple as [`ScoredTriple::unscored`] and delegates to
154    /// [`Self::build`]. `score_weighted` becomes a no-op in this case, since
155    /// there is no real score to weight by.
156    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    /// Format community summaries
171    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    /// Format triples according to configured format. `triples` is assumed
198    /// to already be in the order they should be considered for inclusion
199    /// (score-weighted or input order, decided by the caller in
200    /// [`Self::build`]).
201    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    /// Convert triple to natural language
224    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    /// Convert triple to structured format
233    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    /// Convert triple to Turtle format
242    fn triple_to_turtle(&self, triple: &Triple) -> String {
243        format!(
244            "<{}> <{}> <{}> .",
245            triple.subject, triple.predicate, triple.object
246        )
247    }
248
249    /// Convert triple to JSON-LD style
250    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    /// Extract local name from URI
262    fn extract_local_name(&self, uri: &str) -> String {
263        // Try '#' first (for RDF namespace URIs), then '/'
264        uri.rsplit('#')
265            .next()
266            .filter(|s| s != &uri) // Only use if '#' was found
267            .or_else(|| uri.rsplit('/').next())
268            .unwrap_or(uri)
269            .to_string()
270    }
271
272    /// Convert predicate URI to natural language phrase
273    fn predicate_to_phrase(&self, predicate: &str) -> String {
274        let local = self.extract_local_name(predicate);
275
276        // Common predicate mappings
277        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                // Convert camelCase to spaces
290                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    // ── Regression: score_weighted actually reorders triples (P2) ──────────
360
361    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        // Deliberately supplied in ascending score order — a correct
378        // implementation must reorder to descending (low score last).
379        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        // Same triples as the sort test, but score_weighted is off: input
405        // order (ascending score here) must be preserved verbatim.
406        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        // Callers with no real relevance signal (build_unscored) must get
426        // input-order-preserving output even with score_weighted enabled —
427        // `score_weighted` should never fabricate an ordering it has no
428        // data to justify.
429        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}