1use 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
22pub 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#[non_exhaustive]
35pub struct VerbalizationPipeline {
36 llm: Arc<dyn LlmClient>,
37 max_iterations: u8,
38}
39
40impl VerbalizationPipeline {
41 pub fn new(llm: Arc<dyn LlmClient>) -> Self {
47 Self {
48 llm,
49 max_iterations: DEFAULT_MAX_ITERATIONS,
50 }
51 }
52
53 pub fn with_max_iterations(mut self, n: u8) -> Self {
57 self.max_iterations = n;
58 self
59 }
60
61 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
102pub struct VerbalizationCritic {
108 required_fact_ids: Vec<String>,
109}
110
111impl VerbalizationCritic {
112 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
144pub 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 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}