qualia_core_db/inference/
post_turn_verify.rs1use crate::quant_graph_grounding::{ground_generation, GroundingResult};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct VerifyCheck {
26 pub id: String,
27 pub ok: bool,
28 pub detail: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct VerifiedTurn {
34 pub final_text: String,
36 pub display_html: String,
38 pub cml_turtle: String,
40 pub repaired: bool,
42 pub checks: Vec<VerifyCheck>,
44 pub grounding_reason: Option<String>,
45}
46
47#[inline]
49pub fn return_html_as_text() -> bool {
50 matches!(
51 std::env::var("QUALIA_RETURN_VERIFY_HTML").ok().as_deref(),
52 Some("1") | Some("true") | Some("on")
53 ) || crate::inference_modes::fast_verify_html_default()
54}
55
56pub fn verify_and_heal_turn(prompt: &str, draft: &str) -> VerifiedTurn {
58 let g: GroundingResult = ground_generation(prompt, draft);
59 let mut checks = Vec::new();
60
61 checks.push(VerifyCheck {
63 id: "nonempty".into(),
64 ok: !draft.trim().is_empty(),
65 detail: if draft.trim().is_empty() {
66 "draft empty".into()
67 } else {
68 format!("{} chars", draft.len())
69 },
70 });
71
72 if let Some(ref reason) = g.reason {
74 checks.push(VerifyCheck {
75 id: format!("graph:{reason}"),
76 ok: !g.repaired,
77 detail: if g.repaired {
78 format!("repaired → {}", truncate(&g.text, 80))
79 } else {
80 "grounded (answer_ok present)".into()
81 },
82 });
83 } else {
84 checks.push(VerifyCheck {
85 id: "graph:no_match".into(),
86 ok: true,
87 detail: "no high-stakes fact needles matched prompt".into(),
88 });
89 }
90
91 if g.repaired {
94 checks.push(VerifyCheck {
95 id: "heal_applied".into(),
96 ok: true,
97 detail: "quant-graph replaced ungrounded draft".into(),
98 });
99 }
100
101 let tags = extract_simple_cml_tags(prompt);
103 if !tags.is_empty() {
104 checks.push(VerifyCheck {
105 id: "cml_tags".into(),
106 ok: true,
107 detail: format!("{} context tag(s) from prompt", tags.len()),
108 });
109 }
110
111 let final_text = g.text.clone();
112 let display_html = render_turn_html(prompt, draft, &final_text, g.repaired, &checks, &tags);
113 let cml_turtle = render_cml_turtle(prompt, &final_text, g.repaired, &checks, &tags);
114
115 log::info!(
116 "post_turn_verify|repaired={}|checks={}|reason={:?}",
117 g.repaired,
118 checks.len(),
119 g.reason
120 );
121
122 VerifiedTurn {
123 final_text,
124 display_html,
125 cml_turtle,
126 repaired: g.repaired,
127 checks,
128 grounding_reason: g.reason,
129 }
130}
131
132pub fn maybe_verify_turn(prompt: &str, draft: &str) -> VerifiedTurn {
134 if crate::inference_modes::post_turn_verify_enabled() {
135 verify_and_heal_turn(prompt, draft)
136 } else {
137 VerifiedTurn {
139 final_text: draft.to_string(),
140 display_html: format!(
141 "<article class=\"q-turn\"><p>{}</p></article>",
142 escape_html(draft)
143 ),
144 cml_turtle: String::new(),
145 repaired: false,
146 checks: vec![],
147 grounding_reason: None,
148 }
149 }
150}
151
152fn truncate(s: &str, n: usize) -> String {
153 if s.len() <= n {
154 s.to_string()
155 } else {
156 format!("{}…", &s[..n])
157 }
158}
159
160fn escape_html(s: &str) -> String {
161 s.chars()
162 .map(|c| match c {
163 '&' => "&".into(),
164 '<' => "<".into(),
165 '>' => ">".into(),
166 '"' => """.into(),
167 _ => c.to_string(),
168 })
169 .collect()
170}
171
172fn extract_simple_cml_tags(text: &str) -> Vec<(String, String)> {
174 let mut out = Vec::new();
175 let mut rest = text;
177 while let Some(start) = rest.find("[[") {
178 let after = &rest[start + 2..];
179 if let Some(end) = after.find("]]") {
180 let label = after[..end].trim();
181 if !label.is_empty() {
182 out.push(("general".into(), label.to_string()));
183 }
184 rest = &after[end + 2..];
185 } else {
186 break;
187 }
188 }
189 for raw in text.split_whitespace() {
190 let tok = raw.trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?' | ';' | ')' | '('));
191 if let Some(body) = tok.strip_prefix('#') {
192 if let Some((k, v)) = body.split_once(':') {
193 let tier = k.to_ascii_lowercase();
194 if matches!(
195 tier.as_str(),
196 "topic" | "project" | "task" | "pursuit" | "general"
197 ) {
198 let label = v.replace('_', " ");
199 if !label.is_empty() {
200 out.push((tier, label));
201 }
202 }
203 } else if !body.is_empty() {
204 out.push(("topic".into(), body.replace('_', " ")));
205 }
206 }
207 }
208 out
209}
210
211fn render_turn_html(
212 prompt: &str,
213 draft: &str,
214 final_text: &str,
215 repaired: bool,
216 checks: &[VerifyCheck],
217 tags: &[(String, String)],
218) -> String {
219 let status = if repaired {
220 "<span class=\"q-badge q-repaired\">self-healed</span>"
221 } else {
222 "<span class=\"q-badge q-ok\">verified</span>"
223 };
224 let mut checks_html = String::from("<ul class=\"q-checks\">");
225 for c in checks {
226 let mark = if c.ok { "✓" } else { "✗" };
227 let cls = if c.ok { "pass" } else { "fail" };
228 checks_html.push_str(&format!(
229 "<li class=\"{cls}\"><code>{mark} {}</code> — {}</li>",
230 escape_html(&c.id),
231 escape_html(&c.detail)
232 ));
233 }
234 checks_html.push_str("</ul>");
235
236 let mut tags_html = String::new();
237 if !tags.is_empty() {
238 tags_html.push_str("<p class=\"q-cml-tags\">");
239 for (t, l) in tags {
240 tags_html.push_str(&format!(
241 "<span class=\"q-tag\">#{}:{}</span> ",
242 escape_html(t),
243 escape_html(l)
244 ));
245 }
246 tags_html.push_str("</p>");
247 }
248
249 let draft_block = if repaired {
250 format!(
251 "<details class=\"q-draft\"><summary>Original draft (pre-heal)</summary><pre>{}</pre></details>",
252 escape_html(draft)
253 )
254 } else {
255 String::new()
256 };
257
258 format!(
259 r#"<!DOCTYPE html>
260<html lang="en"><head><meta charset="utf-8"/><title>Qualia turn</title>
261<style>
262body{{font-family:system-ui,sans-serif;background:#0c0f14;color:#e2e8f0;margin:1.5rem}}
263.q-turn{{max-width:42rem;margin:auto}}
264.q-badge{{font-size:.7rem;padding:.15rem .5rem;border-radius:999px;font-weight:700}}
265.q-ok{{background:rgba(16,185,129,.2);color:#6ee7b7}}
266.q-repaired{{background:rgba(245,158,11,.2);color:#fcd34d}}
267.q-answer{{font-size:1.1rem;line-height:1.55;padding:1rem;border:1px solid rgba(148,163,184,.2);border-radius:12px;background:rgba(255,255,255,.04)}}
268.q-checks{{font-size:.85rem;line-height:1.6}}
269.q-checks .pass{{color:#86efac}}
270.q-checks .fail{{color:#fca5a5}}
271.q-meta{{font-size:.75rem;color:#94a3b8;margin-top:1.5rem}}
272.q-tag{{display:inline-block;margin:.15rem;padding:.1rem .4rem;border-radius:6px;background:rgba(59,130,246,.15);color:#93c5fd;font-size:.75rem}}
273pre{{white-space:pre-wrap;font-size:.8rem;opacity:.85}}
274</style></head><body>
275<article class="q-turn" data-qualia-verify="1">
276 <header><h1>Response {status}</h1>
277 <p class="q-meta">Prompt: {prompt}</p>{tags}
278 </header>
279 <section class="q-answer"><p>{answer}</p></section>
280 {draft}
281 <section><h2>Verification</h2>{checks}
282 <p class="q-meta">Post-turn path: generate → graph/CML verify → finalise. Mid-decode Sentinel skipped in FastVerify mode.</p>
283 </section>
284</article></body></html>"#,
285 status = status,
286 prompt = escape_html(&truncate(prompt, 200)),
287 tags = tags_html,
288 answer = escape_html(final_text).replace('\n', "<br/>"),
289 draft = draft_block,
290 checks = checks_html,
291 )
292}
293
294fn render_cml_turtle(
295 prompt: &str,
296 final_text: &str,
297 repaired: bool,
298 checks: &[VerifyCheck],
299 tags: &[(String, String)],
300) -> String {
301 let mut out = String::from(
302 "@prefix cml: <https://webizen.org/cml#> .\n@prefix q42: <https://ns.webizen.org/q42/> .\n\n",
303 );
304 out.push_str("<urn:qualia:turn:current> a cml:Turn ;\n");
305 out.push_str(&format!(" cml:prompt {} ;\n", ttl_str(prompt)));
306 out.push_str(&format!(" cml:finalText {} ;\n", ttl_str(final_text)));
307 out.push_str(&format!(
308 " cml:selfHealed {} ;\n",
309 if repaired { "true" } else { "false" }
310 ));
311 out.push_str(" cml:verifyPath \"post-turn\" .\n\n");
312 for (i, c) in checks.iter().enumerate() {
313 out.push_str(&format!(
314 "<urn:qualia:check:{i}> a cml:VerifyCheck, cml:Proposed ;\n"
315 ));
316 out.push_str(&format!(" cml:id {} ;\n", ttl_str(&c.id)));
317 out.push_str(&format!(
318 " cml:ok {} ;\n",
319 if c.ok { "true" } else { "false" }
320 ));
321 out.push_str(&format!(" cml:detail {} .\n\n", ttl_str(&c.detail)));
322 }
323 for (t, l) in tags {
324 out.push_str(&format!(
325 "<urn:qualia:tag:{}:{}> a cml:Proposed ;\n cml:tier {} ;\n cml:label {} .\n\n",
326 t,
327 l.chars()
328 .filter(|c| c.is_alphanumeric())
329 .collect::<String>(),
330 ttl_str(t),
331 ttl_str(l)
332 ));
333 }
334 out
335}
336
337fn ttl_str(s: &str) -> String {
338 format!(
339 "\"{}\"",
340 s.replace('\\', "\\\\")
341 .replace('"', "\\\"")
342 .replace('\n', "\\n")
343 )
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use crate::inference_modes::{set_inference_mode, InferenceMode};
350 use crate::quant_graph_grounding::reset_fact_store_to_defaults;
351
352 #[test]
353 fn heals_wrong_capital() {
354 if std::env::var("QUALIA_INFERENCE_MODE").is_ok() {
355 return;
356 }
357 reset_fact_store_to_defaults();
358 set_inference_mode(InferenceMode::FastVerify);
359 let v = verify_and_heal_turn("What is the capital of France?", "I think it is Lyon.");
360 assert!(v.repaired);
361 assert!(v.final_text.to_ascii_lowercase().contains("paris"));
362 assert!(v.display_html.contains("self-healed") || v.display_html.contains("q-repaired"));
363 assert!(v.cml_turtle.contains("cml:Turn"));
364 set_inference_mode(InferenceMode::Portable);
365 }
366
367 #[test]
368 fn leaves_good_answer() {
369 reset_fact_store_to_defaults();
370 let v = verify_and_heal_turn(
371 "What is the capital of France?",
372 "The capital of France is Paris.",
373 );
374 assert!(!v.repaired);
375 assert!(v.final_text.contains("Paris"));
376 }
377}