Skip to main content

klieo_memory_graph_rag/
path_rag.rs

1//! [`VerbalizationPipeline`] — LLM-driven prose summary of a
2//! [`klieo_memory_graph::RetrievalPath`] under a
3//! [`klieo_spec::QualityLoop`] refinement budget.
4//!
5//! Given a recall path produced by `ProvenanceKnowledgeGraph::recall_paths`,
6//! the pipeline asks the LLM to compose a causal narrative — "X
7//! co-occurs with Y, which is mentioned in fact F" — then iterates
8//! through the [`VerbalizationCritic`] + [`LlmRefiner`] pair until
9//! the critic accepts or the iteration budget is exhausted.
10//!
11//! The default critic passes when every `PathHop.fact_id` is
12//! mentioned verbatim in the candidate prose; downstream extensions
13//! can layer additional checks (e.g. regulatory-citation matching)
14//! by composing their own [`klieo_spec::Critic`].
15
16use async_trait::async_trait;
17use klieo_core::llm::{ChatRequest, LlmClient, Message, Role};
18use klieo_memory_graph::RetrievalPath;
19use klieo_spec::{Critic, Critique, QualityLoop, QualityMetadata, Refiner, SpecError};
20use std::sync::Arc;
21
22/// Default iteration budget for the verbalisation quality loop.
23/// Two passes — initial draft + one refinement — caps LLM spend per
24/// verbalisation while leaving room for the critic to reject the
25/// first attempt.
26pub const DEFAULT_MAX_ITERATIONS: u8 = 2;
27
28const SYSTEM_PROMPT: &str =
29    "You are a retrieval-path verbaliser. Compose a concise causal narrative \
30     that mentions every fact id supplied. Keep the prose plain and accurate; \
31     never invent facts beyond the supplied list.";
32
33/// LLM-driven verbaliser over a [`RetrievalPath`].
34#[non_exhaustive]
35pub struct VerbalizationPipeline {
36    llm: Arc<dyn LlmClient>,
37    max_iterations: u8,
38}
39
40impl VerbalizationPipeline {
41    /// Construct the pipeline with the default
42    /// [`DEFAULT_MAX_ITERATIONS`] budget. `llm` is shared across the
43    /// initial draft and every refinement; the pipeline does not
44    /// configure model selection — the supplied client handles
45    /// model+temperature internally.
46    pub fn new(llm: Arc<dyn LlmClient>) -> Self {
47        Self {
48            llm,
49            max_iterations: DEFAULT_MAX_ITERATIONS,
50        }
51    }
52
53    /// Override the per-verbalisation iteration cap. `0` is rejected
54    /// at `verbalize` time with [`SpecError::Config`] (via the
55    /// underlying [`QualityLoop`]).
56    pub fn with_max_iterations(mut self, n: u8) -> Self {
57        self.max_iterations = n;
58        self
59    }
60
61    /// Verbalise `path` into a causal narrative. Returns the final
62    /// text and the loop's run metadata (iteration count + whether
63    /// the critic ultimately passed). Inspect
64    /// [`QualityMetadata::passed`] before relying on the prose for
65    /// downstream display.
66    pub async fn verbalize(
67        &self,
68        path: &RetrievalPath,
69    ) -> Result<(String, QualityMetadata), SpecError> {
70        let hops_summary = render_hops(path);
71        let initial = self.draft_initial(&hops_summary).await?;
72        let critic = VerbalizationCritic::new(path);
73        let refiner = LlmRefiner {
74            llm: Arc::clone(&self.llm),
75            hops_summary,
76        };
77        QualityLoop::new(critic, refiner)
78            .with_max_iterations(self.max_iterations)
79            .run(initial)
80            .await
81    }
82
83    async fn draft_initial(&self, hops_summary: &str) -> Result<String, SpecError> {
84        let req = ChatRequest::new(vec![
85            Message {
86                role: Role::System,
87                content: SYSTEM_PROMPT.to_string(),
88                tool_calls: Vec::new(),
89                tool_call_id: None,
90            },
91            Message {
92                role: Role::User,
93                content: format!("Hops:\n{hops_summary}\n\nWrite the narrative."),
94                tool_calls: Vec::new(),
95                tool_call_id: None,
96            },
97        ]);
98        complete(self.llm.as_ref(), req).await
99    }
100}
101
102/// Critic that passes when the candidate verbalisation mentions
103/// every `fact_id` from the supplied path. The check is verbatim
104/// substring matching: the LLM must echo each `fact_id` once so the
105/// downstream evidence-bundle linker can splice the prose back to
106/// the chain entries it cites.
107pub struct VerbalizationCritic {
108    required_fact_ids: Vec<String>,
109}
110
111impl VerbalizationCritic {
112    /// Snapshot the required fact ids from the supplied path so the
113    /// critic owns its evaluation contract independent of the path's
114    /// lifetime.
115    pub fn new(path: &RetrievalPath) -> Self {
116        let required_fact_ids = path.hops.iter().map(|h| h.fact_id.to_string()).collect();
117        Self { required_fact_ids }
118    }
119}
120
121#[async_trait]
122impl Critic<String> for VerbalizationCritic {
123    async fn evaluate(&self, candidate: &String, _iter: u8) -> Result<Critique, SpecError> {
124        if self.required_fact_ids.is_empty() {
125            return Ok(Critique::pass("no facts to verbalise"));
126        }
127        let missing: Vec<&str> = self
128            .required_fact_ids
129            .iter()
130            .filter(|f| !candidate.contains(f.as_str()))
131            .map(String::as_str)
132            .collect();
133        if missing.is_empty() {
134            Ok(Critique::pass("every fact id is mentioned"))
135        } else {
136            Ok(Critique::fail(format!(
137                "missing fact ids: {}",
138                missing.join(", ")
139            )))
140        }
141    }
142}
143
144/// Refiner that re-prompts the LLM with the critique feedback so
145/// the next draft addresses the missing facts.
146pub struct LlmRefiner {
147    llm: Arc<dyn LlmClient>,
148    hops_summary: String,
149}
150
151#[async_trait]
152impl Refiner<String> for LlmRefiner {
153    async fn refine(
154        &self,
155        current: String,
156        critique: &Critique,
157        _iter: u8,
158    ) -> Result<String, SpecError> {
159        let req = ChatRequest::new(vec![
160            Message {
161                role: Role::System,
162                content: SYSTEM_PROMPT.to_string(),
163                tool_calls: Vec::new(),
164                tool_call_id: None,
165            },
166            Message {
167                role: Role::User,
168                content: format!(
169                    "Hops:\n{}\n\nPrevious draft:\n{current}\n\nCritique:\n{}\n\nRevise the draft so the critique passes.",
170                    self.hops_summary, critique.feedback,
171                ),
172                tool_calls: Vec::new(),
173                tool_call_id: None,
174            },
175        ]);
176        complete(self.llm.as_ref(), req).await
177    }
178}
179
180fn render_hops(path: &RetrievalPath) -> String {
181    let mut out = String::new();
182    for (idx, hop) in path.hops.iter().enumerate() {
183        out.push_str(&format!(
184            "{}. entity={} fact_id={}",
185            idx + 1,
186            hop.entity.name,
187            hop.fact_id
188        ));
189        if hop.chain_entry.is_some() {
190            out.push_str(" provenance=signed");
191        }
192        out.push('\n');
193    }
194    out
195}
196
197async fn complete(llm: &dyn LlmClient, req: ChatRequest) -> Result<String, SpecError> {
198    let resp = llm.complete(req).await.map_err(|e| SpecError::Downstream {
199        message: "verbalisation LLM call failed".into(),
200        source: Some(Box::new(e)),
201    })?;
202    Ok(resp.message.content)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use klieo_core::ids::FactId;
209    use klieo_core::test_utils::{FakeLlmClient, FakeLlmStep};
210    use klieo_memory_graph::{EntityRef, EntityType, PathHop};
211
212    fn path_with_two_hops() -> RetrievalPath {
213        RetrievalPath::with_hops(vec![
214            PathHop::new(
215                EntityRef::new(EntityType::Ticket, "T-1"),
216                FactId::new("fact-alpha"),
217            ),
218            PathHop::new(
219                EntityRef::new(EntityType::Policy, "P-1"),
220                FactId::new("fact-beta"),
221            ),
222        ])
223    }
224
225    fn scripted(steps: Vec<&str>) -> Arc<FakeLlmClient> {
226        Arc::new(
227            FakeLlmClient::new("verbalisation-test").with_steps(
228                steps
229                    .into_iter()
230                    .map(|s| FakeLlmStep::Text(s.into()))
231                    .collect(),
232            ),
233        )
234    }
235
236    #[tokio::test]
237    async fn verbalization_produces_readable_chain() {
238        let llm = scripted(vec![
239            "Ticket T-1 surfaces fact-alpha; Policy P-1 surfaces fact-beta.",
240        ]);
241        let pipeline = VerbalizationPipeline::new(llm);
242        let (text, meta) = pipeline.verbalize(&path_with_two_hops()).await.unwrap();
243        assert!(text.contains("fact-alpha"));
244        assert!(text.contains("fact-beta"));
245        assert!(meta.passed, "critic must accept on first draft");
246        assert_eq!(meta.iterations_used, 0);
247    }
248
249    #[tokio::test]
250    async fn refiner_revisits_when_first_draft_misses_facts() {
251        let llm = scripted(vec![
252            "Ticket T-1 surfaces fact-alpha but the second fact is unmentioned.",
253            "Ticket T-1 surfaces fact-alpha and Policy P-1 surfaces fact-beta.",
254        ]);
255        let pipeline = VerbalizationPipeline::new(llm);
256        let (text, meta) = pipeline.verbalize(&path_with_two_hops()).await.unwrap();
257        assert!(text.contains("fact-alpha"));
258        assert!(text.contains("fact-beta"));
259        assert!(meta.passed);
260        assert_eq!(meta.iterations_used, 1, "one refinement round needed");
261    }
262
263    #[tokio::test]
264    async fn quality_loop_terminates_within_bounds() {
265        // Two replies + cap iterations at 2 → initial draft + one
266        // refine, then the second critique exhausts the budget
267        // without another refine call. Script length matches the
268        // exact call count.
269        let llm = scripted(vec!["Mentions only fact-alpha.", "Still only fact-alpha."]);
270        let pipeline = VerbalizationPipeline::new(llm).with_max_iterations(2);
271        let (_text, meta) = pipeline.verbalize(&path_with_two_hops()).await.unwrap();
272        assert!(!meta.passed);
273        assert_eq!(meta.iterations_used, 2);
274        assert_eq!(meta.max_iterations, 2);
275    }
276
277    #[tokio::test]
278    async fn empty_path_passes_immediately() {
279        let llm = scripted(vec!["(no facts)"]);
280        let pipeline = VerbalizationPipeline::new(llm);
281        let (_text, meta) = pipeline
282            .verbalize(&RetrievalPath::with_hops(Vec::new()))
283            .await
284            .unwrap();
285        assert!(meta.passed);
286        assert_eq!(meta.iterations_used, 0);
287    }
288
289    #[tokio::test]
290    async fn zero_max_iterations_rejected() {
291        let llm = scripted(vec!["draft"]);
292        let pipeline = VerbalizationPipeline::new(llm).with_max_iterations(0);
293        let err = pipeline.verbalize(&path_with_two_hops()).await.unwrap_err();
294        assert!(matches!(err, SpecError::Config(_)));
295    }
296
297    #[test]
298    fn render_hops_lists_each_fact_id() {
299        let path = path_with_two_hops();
300        let rendered = render_hops(&path);
301        assert!(rendered.contains("fact-alpha"));
302        assert!(rendered.contains("fact-beta"));
303        assert!(
304            !rendered.contains("provenance=signed"),
305            "unsigned hops must not carry the provenance marker"
306        );
307    }
308
309    #[tokio::test]
310    async fn critic_accepts_when_all_facts_present() {
311        let path = path_with_two_hops();
312        let critic = VerbalizationCritic::new(&path);
313        let crit = critic
314            .evaluate(&"fact-alpha and fact-beta are both here".to_string(), 0)
315            .await
316            .unwrap();
317        assert!(crit.pass);
318    }
319
320    #[tokio::test]
321    async fn critic_lists_missing_facts_in_feedback() {
322        let path = path_with_two_hops();
323        let critic = VerbalizationCritic::new(&path);
324        let crit = critic
325            .evaluate(&"only fact-alpha is mentioned".to_string(), 0)
326            .await
327            .unwrap();
328        assert!(!crit.pass);
329        assert!(
330            crit.feedback.contains("fact-beta"),
331            "feedback must name the missing fact: {}",
332            crit.feedback
333        );
334    }
335}