1use crate::db::Corpus;
16use serde_json::{json, Value};
17
18pub fn compile_anchor(scope: &[String], constraints: &[String]) -> String {
21 let mut parts: Vec<String> = Vec::new();
22 let scope: Vec<&String> = scope.iter().filter(|s| !s.trim().is_empty()).collect();
23 match scope.len() {
24 0 => {}
25 1 => parts.push(scope[0].clone()),
26 _ => parts.push(format!("(or {})", scope.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" "))),
27 }
28 for c in constraints {
29 let c = c.trim();
30 if !c.is_empty() {
31 parts.push(c.to_string());
32 }
33 }
34 match parts.len() {
35 0 => "*".to_string(),
36 1 => parts.remove(0),
37 _ => format!("(and {})", parts.join(" ")),
38 }
39}
40
41fn str_field<'a>(v: &'a Value, k: &str) -> &'a str {
42 v.get(k).and_then(|x| x.as_str()).unwrap_or("")
43}
44
45fn norm(s: &str) -> String {
47 s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
48}
49
50fn resolve_facet(facets: &[String], name: &str) -> Option<String> {
53 if name.is_empty() {
54 return None;
55 }
56 if facets.iter().any(|f| f == name) {
57 return Some(name.to_string());
58 }
59 let n = norm(name);
60 facets.iter().find(|f| norm(f) == n).cloned()
61}
62
63fn resolve_constraint(corpus: &Corpus, facets: &[String], c: &str) -> Result<String, String> {
67 let c = c.trim();
68 if c.is_empty() {
69 return Err("empty constraint".into());
70 }
71 if c.starts_with('(') {
72 return Ok(c.to_string()); }
74 if corpus.has_token(c) {
75 return Ok(c.to_string());
76 }
77 if let Some((f, v)) = c.split_once('/') {
78 let rf = resolve_facet(facets, f).ok_or_else(|| format!("constraint facet '{f}' is not in the corpus"))?;
79 let nv = norm(v);
80 for (tok, _) in corpus.facet_tokens(&rf, 100_000) {
81 let leaf = tok.split_once('/').map(|x| x.1).unwrap_or(&tok);
82 if norm(leaf) == nv {
83 return Ok(tok);
84 }
85 }
86 Err(format!("no value '{v}' under facet '{rf}'"))
87 } else {
88 Err(format!("constraint '{c}' must be a facet/value token"))
89 }
90}
91
92fn bad_facet(field: &str, got: &str, facets: &[String]) -> (String, bool) {
94 (json!({ "error": format!("{field} '{got}' is not a corpus facet"), "valid_facets": facets }).to_string(), true)
95}
96
97fn usize_field(v: &Value, k: &str, default: usize) -> usize {
98 v.get(k).and_then(|x| x.as_u64()).map(|n| n as usize).unwrap_or(default)
99}
100
101pub fn execute(corpus: &Corpus, dsl: &Value, scope: &[String]) -> (String, bool) {
105 let raw_constraints: Vec<String> = dsl
106 .get("constraints")
107 .and_then(|c| c.as_array())
108 .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
109 .unwrap_or_default();
110 let program = str_field(dsl, "program");
111 let k = usize_field(dsl, "k", 8);
112
113 let facets = corpus.facet_names();
117 let constraints: Vec<String> = {
118 let mut out = Vec::with_capacity(raw_constraints.len());
119 for c in &raw_constraints {
120 match resolve_constraint(corpus, &facets, c) {
121 Ok(t) => out.push(t),
122 Err(e) => return (json!({ "error": format!("constraint {e}"), "valid_facets": facets }).to_string(), true),
123 }
124 }
125 out
126 };
127 let anchor = compile_anchor(scope, &constraints);
128
129 match program {
130 "crosstab" => {
131 let (fa, fb) = (str_field(dsl, "facet_a"), str_field(dsl, "facet_b"));
132 let Some(fa) = resolve_facet(&facets, fa) else { return bad_facet("facet_a", fa, &facets) };
133 let Some(fb) = resolve_facet(&facets, fb) else { return bad_facet("facet_b", fb, &facets) };
134 (with_dsl(corpus.crosstab(&anchor, &fa, &fb, k), &anchor, &constraints), false)
135 }
136 "breakdown" => {
137 let facet = str_field(dsl, "facet");
138 let Some(facet) = resolve_facet(&facets, facet) else { return bad_facet("facet", facet, &facets) };
139 (with_dsl(corpus.breakdown(&anchor, &facet, k.max(15)), &anchor, &constraints), false)
140 }
141 "rank" => {
142 let facet = str_field(dsl, "facet");
143 let Some(facet) = resolve_facet(&facets, facet) else { return bad_facet("facet", facet, &facets) };
144 (with_dsl(corpus.rank(&facet, k), &anchor, &constraints), false)
146 }
147 "cooccurs" => {
148 let token = str_field(dsl, "token");
149 if !corpus.has_token(token) {
150 return (json!({ "error": format!("cooccurs token '{token}' is not in the corpus"), "valid_facets": facets }).to_string(), true);
151 }
152 (with_dsl(corpus.cooccurs(token, k), &anchor, &constraints), false)
153 }
154 "s_path" => {
155 let a = match resolve_constraint(corpus, &facets, str_field(dsl, "a")) {
156 Ok(t) => t,
157 Err(e) => return (json!({ "error": format!("s_path a: {e}") }).to_string(), true),
158 };
159 let b = match resolve_constraint(corpus, &facets, str_field(dsl, "b")) {
160 Ok(t) => t,
161 Err(e) => return (json!({ "error": format!("s_path b: {e}") }).to_string(), true),
162 };
163 let s = usize_field(dsl, "s", 1);
164 (with_dsl(corpus.s_path(&a, &b, s), &anchor, &constraints), false)
165 }
166 "s_clusters" => {
167 let s = usize_field(dsl, "s", 2);
168 (with_dsl(corpus.s_clusters(s, k.max(6)), &anchor, &constraints), false)
169 }
170 "narrow" => {
171 (with_dsl(corpus.narrow(scope, &constraints), &anchor, &constraints), false)
173 }
174 "" | "profile" | "auto" => (with_dsl(crate::agent::profile::auto_profile(corpus, &anchor), &anchor, &constraints), false),
176 other => (
177 json!({ "error": format!("unknown program `{other}`"), "valid_programs": ["crosstab", "breakdown", "rank", "cooccurs", "s_path", "s_clusters", "narrow", "profile"] }).to_string(),
178 true,
179 ),
180 }
181}
182
183fn with_dsl(mut result: Value, anchor: &str, constraints: &[String]) -> String {
186 if let Value::Object(m) = &mut result {
187 m.insert("compiled_anchor".into(), json!(anchor));
188 m.insert("constraints".into(), json!(constraints));
189 }
190 result.to_string()
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn anchor_compiles_scope_and_constraints() {
199 assert_eq!(compile_anchor(&[], &[]), "*");
200 assert_eq!(compile_anchor(&[], &["powertrain/electric".into()]), "powertrain/electric");
201 assert_eq!(
202 compile_anchor(&["ent/x".into(), "ent/y".into()], &["(num range_km gt 500)".into()]),
203 "(and (or ent/x ent/y) (num range_km gt 500))"
204 );
205 assert_eq!(
206 compile_anchor(&[], &["powertrain/electric".into(), "body/suv".into()]),
207 "(and powertrain/electric body/suv)"
208 );
209 }
210}