Skip to main content

steeldb/agent/
synth.rs

1//! Deterministic answer synthesis — render a bitmap-program result (the structured JSON the analytics
2//! programs return) into a grounded natural-language answer via a fixed template, with NO model in the
3//! loop. This is the default path for small tool-caller drivers (Needle): the model selects the program
4//! and arguments, the engine computes the numbers, and we phrase them here — so the answer can never
5//! hallucinate figures the corpus doesn't support. When a synthesis-capable provider (Bedrock / a large
6//! OpenAI-compatible model) is configured, the loop uses the model's prose instead.
7
8use serde_json::Value;
9
10/// Render a program result into a template answer, or `None` if it isn't a recognised program payload.
11pub fn render_program(v: &Value) -> Option<String> {
12    match v.get("program").and_then(|p| p.as_str()) {
13        Some("breakdown") => Some(render_breakdown(v)),
14        Some("crosstab") => Some(render_crosstab(v)),
15        Some("rank") => Some(render_rank(v)),
16        Some("structure") => render_structure(v),
17        Some("profile") => Some(render_profile(v)),
18        _ => None,
19    }
20}
21
22/// Compose the auto-profile: render each routed section with its own program template.
23fn render_profile(v: &Value) -> String {
24    let sections = v.get("sections").and_then(|s| s.as_array()).cloned().unwrap_or_default();
25    if sections.is_empty() {
26        return "No informative structure found in the matching rows.".to_string();
27    }
28    let scope = v.get("scope_size").and_then(|x| x.as_u64()).unwrap_or(0);
29    let mut out = vec![format!("Profile of {scope} matching situations:")];
30    for s in &sections {
31        if let Some(r) = s.get("result").and_then(render_program) {
32            out.push(format!("\n{r}"));
33        }
34    }
35    out.join("\n")
36}
37
38fn render_breakdown(v: &Value) -> String {
39    let facet = v.get("facet").and_then(|x| x.as_str()).unwrap_or("value");
40    let total = v.get("total").and_then(|x| x.as_u64()).unwrap_or(0);
41    let parts = v.get("partition").and_then(|x| x.as_array()).cloned().unwrap_or_default();
42    if parts.is_empty() {
43        return format!("No {facet} values matched ({total} situations in scope).");
44    }
45    let items: Vec<String> = parts
46        .iter()
47        .map(|p| {
48            let val = p.get("value").and_then(|x| x.as_str()).unwrap_or("?");
49            let n = p.get("count").and_then(|x| x.as_u64()).unwrap_or(0);
50            format!("{val} ({n})")
51        })
52        .collect();
53    let top = parts[0].get("value").and_then(|x| x.as_str()).unwrap_or("?");
54    format!("Breakdown by {facet} across {total} situations: {}. Most common: {top}.", items.join(", "))
55}
56
57fn render_crosstab(v: &Value) -> String {
58    let row_facet = v.get("row_facet").and_then(|x| x.as_str()).unwrap_or("row");
59    let col_facet = v.get("col_facet").and_then(|x| x.as_str()).unwrap_or("col");
60    let total = v.get("total").and_then(|x| x.as_u64()).unwrap_or(0);
61    let matrix = v.get("matrix").and_then(|x| x.as_array()).cloned().unwrap_or_default();
62    if matrix.is_empty() {
63        return format!("No {row_facet} × {col_facet} combinations matched ({total} situations).");
64    }
65    let mut lines = Vec::new();
66    for row in &matrix {
67        let name = row.get("row").and_then(|x| x.as_str()).unwrap_or("?");
68        let cells = row.get("cells").and_then(|x| x.as_array()).cloned().unwrap_or_default();
69        // argmax cell = the most common col value for this row (answers "which X is most common per Y")
70        let mut best: Option<(&str, u64)> = None;
71        let parts: Vec<String> = cells
72            .iter()
73            .map(|c| {
74                let col = c.get("col").and_then(|x| x.as_str()).unwrap_or("?");
75                let n = c.get("count").and_then(|x| x.as_u64()).unwrap_or(0);
76                if best.map(|(_, bn)| n > bn).unwrap_or(true) {
77                    best = Some((col, n));
78                }
79                format!("{col} {n}")
80            })
81            .collect();
82        let most = best.filter(|(_, n)| *n > 0).map(|(c, _)| format!(" → most common: {c}")).unwrap_or_default();
83        lines.push(format!("- {name}: {}{most}", parts.join(", ")));
84    }
85    format!("{row_facet} × {col_facet} ({total} situations):\n{}", lines.join("\n"))
86}
87
88fn render_rank(v: &Value) -> String {
89    let facet = v.get("facet").and_then(|x| x.as_str()).unwrap_or("value");
90    let ranked = v.get("ranked").and_then(|x| x.as_array()).cloned().unwrap_or_default();
91    if ranked.is_empty() {
92        return format!("No {facet} values to rank.");
93    }
94    let items: Vec<String> = ranked
95        .iter()
96        .take(8)
97        .map(|r| {
98            let tok = r.get("token").and_then(|x| x.as_str()).unwrap_or("?");
99            let leaf = tok.rsplit('/').next().unwrap_or(tok);
100            match r.get("mdus").and_then(|x| x.as_f64()) {
101                Some(m) => format!("{leaf} ({m:.2})"),
102                None => leaf.to_string(),
103            }
104        })
105        .collect();
106    format!("Top {facet} by salience: {}.", items.join(", "))
107}
108
109fn render_structure(v: &Value) -> Option<String> {
110    match v.get("op").and_then(|x| x.as_str()) {
111        Some("cooccurs") => {
112            let focus = v.get("focus").and_then(|x| x.as_str()).unwrap_or("?");
113            let co = v.get("cooccurs").and_then(|x| x.as_array()).cloned().unwrap_or_default();
114            if co.is_empty() {
115                return Some(format!("{focus} has no notable co-occurring tokens."));
116            }
117            let items: Vec<String> = co
118                .iter()
119                .take(8)
120                .map(|c| {
121                    let tok = c.get("token").and_then(|x| x.as_str()).unwrap_or("?");
122                    let leaf = tok.rsplit('/').next().unwrap_or(tok);
123                    let n = c.get("shared").and_then(|x| x.as_u64()).unwrap_or(0);
124                    format!("{leaf} ({n})")
125                })
126                .collect();
127            Some(format!("{focus} most co-occurs with: {}.", items.join(", ")))
128        }
129        Some("s_path") => {
130            let (a, b) = (v.get("a").and_then(|x| x.as_str()).unwrap_or("?"), v.get("b").and_then(|x| x.as_str()).unwrap_or("?"));
131            match v.get("path").and_then(|p| p.as_array()) {
132                Some(p) if !p.is_empty() => {
133                    let hops: Vec<&str> = p.iter().filter_map(|x| x.as_str()).map(|t| t.rsplit('/').next().unwrap_or(t)).collect();
134                    Some(format!("Path {a} → {b}: {}.", hops.join(" → ")))
135                }
136                _ => Some(format!("No connecting path between {a} and {b} at this overlap threshold.")),
137            }
138        }
139        Some("s_clusters") => {
140            let cl = v.get("clusters").and_then(|x| x.as_array()).cloned().unwrap_or_default();
141            if cl.is_empty() {
142                return Some("No multi-token clusters at this overlap threshold.".into());
143            }
144            let lines: Vec<String> = cl
145                .iter()
146                .take(6)
147                .map(|c| {
148                    let size = c.get("size").and_then(|x| x.as_u64()).unwrap_or(0);
149                    let toks: Vec<&str> = c
150                        .get("tokens")
151                        .and_then(|t| t.as_array())
152                        .map(|a| a.iter().filter_map(|x| x.as_str()).map(|t| t.rsplit('/').next().unwrap_or(t)).take(8).collect())
153                        .unwrap_or_default();
154                    format!("- [{size}] {}", toks.join(", "))
155                })
156                .collect();
157            Some(format!("{} concept clusters:\n{}", cl.len(), lines.join("\n")))
158        }
159        _ => None,
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use serde_json::json;
167
168    #[test]
169    fn crosstab_reports_argmax_per_row() {
170        let v = json!({
171            "program": "crosstab", "row_facet": "country", "col_facet": "powertrain", "total": 20,
172            "matrix": [
173                {"row": "japan", "cells": [{"col":"electric","count":2},{"col":"diesel","count":1},{"col":"hybrid","count":2}]},
174                {"row": "germany", "cells": [{"col":"electric","count":3},{"col":"diesel","count":2},{"col":"hybrid","count":0}]}
175            ]
176        });
177        let out = render_program(&v).unwrap();
178        assert!(out.contains("country × powertrain"));
179        assert!(out.contains("germany: electric 3, diesel 2, hybrid 0 → most common: electric"));
180        // japan ties electric/hybrid at 2 → argmax keeps the first seen (electric)
181        assert!(out.contains("japan:"));
182    }
183
184    #[test]
185    fn breakdown_and_rank_render() {
186        let b = json!({"program":"breakdown","facet":"country","total":20,"partition":[{"value":"usa","count":6},{"value":"japan","count":5}]});
187        assert!(render_program(&b).unwrap().contains("Most common: usa"));
188        let r = json!({"program":"rank","facet":"ent","ranked":[{"token":"ent/connect","mdus":1.0},{"token":"ent/agent","mdus":0.76}]});
189        assert!(render_program(&r).unwrap().contains("connect (1.00)"));
190    }
191
192    #[test]
193    fn non_program_returns_none() {
194        assert!(render_program(&json!({"foo": "bar"})).is_none());
195    }
196}