1use crate::agent::types::ToolSpec;
6use crate::db::Corpus;
7use serde_json::{json, Value};
8
9fn facet_prop(desc: &str, facets: &[String]) -> serde_json::Value {
13 let mut o = json!({ "type": "string", "description": desc });
14 if !facets.is_empty() {
15 o["enum"] = json!(facets);
16 }
17 o
18}
19
20pub fn engine_tools(facets: &[String]) -> Vec<ToolSpec> {
23 vec![
24 ToolSpec {
25 name: "list_facets".into(),
26 description: "List the corpus facets (token prefixes) and how many distinct tokens each has. Call this first to learn the schema.".into(),
27 schema: json!({ "type": "object", "properties": {}, "additionalProperties": false }),
28 },
29 ToolSpec {
30 name: "run_workflow".into(),
31 description: "Answer in ONE call. Your MAIN job is `constraints`: the facet/value tokens or predicates that narrow the rows to what the question is about, AND-ed together (e.g. [\"powertrain/electric\", \"(num range_km gt 500)\"]). Leave `program` OUT to auto-profile the narrowed rows (the engine picks the informative breakdowns/crosstab/clusters for you). Only set `program` when the question clearly needs a specific one: `crosstab` (facet_a × facet_b, 'for each A which B'), `breakdown` (partition by `facet`), `rank` (salient values of `facet`), `cooccurs` (neighbours of `token`), `s_path` (how tokens `a`,`b` connect), `s_clusters` (concept groupings). Use exact facet NAMES from list_facets.".into(),
32 schema: json!({
33 "type": "object",
34 "properties": {
35 "constraints": { "type": "array", "items": { "type": "string" }, "description": "PRIMARY: filters to AND, as facet/value tokens or predicates, e.g. [\"powertrain/electric\", \"(num range_km gt 500)\"]" },
36 "program": { "type": "string", "enum": ["crosstab", "breakdown", "rank", "cooccurs", "s_path", "s_clusters", "narrow"], "description": "OPTIONAL specific analysis; omit to auto-profile" },
37 "facet_a": facet_prop("crosstab only: grouping facet NAME (e.g. country)", facets),
38 "facet_b": facet_prop("crosstab only: facet NAME counted within each group (e.g. powertrain)", facets),
39 "facet": facet_prop("breakdown/rank only: the facet NAME (e.g. country)", facets),
40 "token": { "type": "string", "description": "cooccurs only: the focus facet/value token (e.g. aws-service/lambda)" },
41 "a": { "type": "string", "description": "s_path only: first facet/value token" },
42 "b": { "type": "string", "description": "s_path only: second facet/value token" }
43 },
44 "additionalProperties": false
45 }),
46 },
47 ToolSpec {
48 name: "facet_tokens".into(),
49 description: "List the most common tokens under a facet (with support counts), so you know the exact tokens you can query. Tokens look like `facet/value`.".into(),
50 schema: json!({
51 "type": "object",
52 "properties": {
53 "facet": { "type": "string", "description": "the facet prefix, e.g. `geo` or `country`" },
54 "limit": { "type": "integer", "description": "max tokens to return (default 40)" }
55 },
56 "required": ["facet"],
57 "additionalProperties": false
58 }),
59 },
60 ToolSpec {
61 name: "ikl_query".into(),
62 description: "Run an IKL s-expression over the corpus and return matching situations. IKL is set-algebra over tokens: `(and A B)`, `(or A B)`, `(not A)`, a bare token, a glob like `geo/*`, or a numeric range `(num <field> <op> <value>)` with op ge|gt|le|lt|eq|ne over a numeric_field from list_facets. Example: `(and powertrain/electric (num range_km ge 500))`.".into(),
63 schema: json!({
64 "type": "object",
65 "properties": {
66 "ikl": { "type": "string", "description": "the IKL s-expression" },
67 "limit": { "type": "integer", "description": "max situations to return (default 20)" }
68 },
69 "required": ["ikl"],
70 "additionalProperties": false
71 }),
72 },
73 ToolSpec {
74 name: "search".into(),
75 description: "Free-text search across the corpus documents/rows: give any keywords or a natural-language phrase, get back matching situations (with their text). Use this when the user asks WHAT the documents say about something, WHICH files talk about X, or wants to READ evidence — as opposed to counting/comparing (use breakdown/crosstab for those).".into(),
76 schema: json!({
77 "type": "object",
78 "properties": {
79 "query": { "type": "string", "description": "keywords or natural-language phrase" },
80 "limit": { "type": "integer", "description": "max hits (default 8)" }
81 },
82 "required": ["query"],
83 "additionalProperties": false
84 }),
85 },
86 ToolSpec {
87 name: "entity_link".into(),
88 description: "Resolve a question's mentions (acronyms, entities, phrases) to the corpus's PRECISE existing tokens and retrieve on them. Use for entity-specific questions: the exact tokens docs are indexed under are often more specific than what a plain query guesses.".into(),
89 schema: json!({
90 "type": "object",
91 "properties": { "question": { "type": "string", "description": "the question or sub-question to link" } },
92 "required": ["question"],
93 "additionalProperties": false
94 }),
95 },
96 ]
97}
98
99fn str_arg<'a>(input: &'a Value, key: &str) -> &'a str {
100 input.get(key).and_then(|v| v.as_str()).unwrap_or("")
101}
102fn usize_arg(input: &Value, key: &str, default: usize) -> usize {
103 input.get(key).and_then(|v| v.as_u64()).map(|n| n as usize).unwrap_or(default)
104}
105fn str_list(input: &Value, key: &str) -> Vec<String> {
106 input
107 .get(key)
108 .and_then(|v| v.as_array())
109 .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
110 .unwrap_or_default()
111}
112
113pub fn exec_tool(corpus: &Corpus, name: &str, input: &Value) -> (String, bool) {
115 match name {
116 "list_facets" => {
117 let s = corpus.stats();
118 let ambient = ["rel", "src"];
122 let facets: Vec<Value> = s
123 .facets
124 .iter()
125 .filter(|(f, _)| !ambient.contains(&f.as_str()))
126 .map(|(f, n)| json!({ "facet": f, "distinct_tokens": n }))
127 .collect();
128 (json!({ "situations": s.situations, "facets": facets, "numeric_fields": s.numeric_fields }).to_string(), false)
129 }
130 "facet_tokens" => {
131 let facet = input.get("facet").and_then(|v| v.as_str()).unwrap_or("");
132 if facet.is_empty() {
133 return (json!({ "error": "missing `facet`" }).to_string(), true);
134 }
135 let limit = (input.get("limit").and_then(|v| v.as_u64()).unwrap_or(40) as usize).min(60);
138 let min_support = input.get("min_support").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
139 let all = corpus.facet_tokens(facet, 1000);
140 let total_distinct = all.len();
141 let filtered: Vec<(String, usize)> = all.into_iter().filter(|(_, n)| *n >= min_support).collect();
142 let shown = filtered.iter().take(limit).cloned().collect::<Vec<_>>();
143 let truncated = filtered.len() > shown.len();
144 let toks: Vec<Value> = shown.iter().map(|(t, n)| json!({ "token": t, "support": n })).collect();
145 let mut out = json!({ "facet": facet, "tokens": toks, "distinct_tokens": total_distinct });
146 if truncated {
147 out["truncated"] = Value::Bool(true);
148 out["note"] = Value::String(format!("showing top {} by support; {} more not shown — raise min_support or query specific tokens with ikl_query", shown.len(), filtered.len() - shown.len()));
149 }
150 (out.to_string(), false)
151 }
152 "ikl_query" => {
153 let ikl = input.get("ikl").and_then(|v| v.as_str()).unwrap_or("");
154 if ikl.is_empty() {
155 return (json!({ "error": "missing `ikl`" }).to_string(), true);
156 }
157 let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
158 let out = corpus.query(ikl, limit);
159 let hits: Vec<Value> = out
160 .hits
161 .iter()
162 .map(|h| {
163 let cells: Vec<String> = h
165 .cells
166 .iter()
167 .map(|c| if c.len() > 300 { format!("{}…", &c[..300]) } else { c.clone() })
168 .collect();
169 json!({ "sid": h.sid, "cells": cells })
170 })
171 .collect();
172 (
173 json!({ "ikl": ikl, "count": out.count, "micros": out.micros, "returned": hits.len(), "hits": hits }).to_string(),
174 false,
175 )
176 }
177 "search" => {
178 let q = str_arg(input, "query");
179 if q.is_empty() {
180 return (json!({ "error": "missing `query`" }).to_string(), true);
181 }
182 let limit = usize_arg(input, "limit", 8);
183 let linked = corpus.entity_link(q);
184 if linked.is_empty() {
185 return (json!({ "query": q, "count": 0, "note": "no matching tokens found in the corpus vocabulary" }).to_string(), false);
186 }
187 let ranked = corpus.search_ranked(&linked, limit);
189 let hits: Vec<Value> = ranked.iter().map(|(sid, cov, cells)| {
190 let cells: Vec<String> = cells.iter().map(|c| if c.chars().count() > 400 { format!("{}…", c.chars().take(400).collect::<String>()) } else { c.clone() }).collect();
191 json!({ "sid": sid, "match_score": cov, "cells": cells })
192 }).collect();
193 (json!({ "query": q, "matched_tokens": linked, "returned": hits.len(), "hits": hits }).to_string(), false)
194 }
195 "entity_link" => {
196 let q = str_arg(input, "question");
197 if q.is_empty() {
198 return (json!({ "error": "missing `question`" }).to_string(), true);
199 }
200 let linked = corpus.entity_link(q);
201 if linked.is_empty() {
202 return (json!({ "linked": [], "note": "no precise tokens linked from the question" }).to_string(), false);
203 }
204 let ikl = if linked.len() > 1 { format!("(or {})", linked.join(" ")) } else { linked[0].clone() };
205 let out = corpus.query(&ikl, 12);
206 let hits: Vec<Value> = out
207 .hits
208 .iter()
209 .map(|h| {
210 let cells: Vec<String> = h.cells.iter().map(|c| if c.len() > 300 { format!("{}…", &c[..300]) } else { c.clone() }).collect();
211 json!({ "sid": h.sid, "cells": cells })
212 })
213 .collect();
214 (json!({ "linked": linked, "ikl": ikl, "count": out.count, "returned": hits.len(), "hits": hits }).to_string(), false)
215 }
216 "breakdown" => {
217 let anchor = str_arg(input, "anchor");
218 let anchor = if anchor.is_empty() || anchor == "all" { "*" } else { anchor }; let facet = str_arg(input, "facet");
220 if facet.is_empty() {
221 return (json!({ "error": "need `facet`" }).to_string(), true);
222 }
223 (corpus.breakdown(anchor, facet, usize_arg(input, "k", 15)).to_string(), false)
224 }
225 "crosstab" => {
226 let anchor = str_arg(input, "anchor");
227 let anchor = if anchor.is_empty() || anchor == "all" { "*" } else { anchor }; let (fa, fb) = (str_arg(input, "facet_a"), str_arg(input, "facet_b"));
229 if fa.is_empty() || fb.is_empty() {
230 return (json!({ "error": "need `facet_a`, `facet_b`" }).to_string(), true);
231 }
232 (corpus.crosstab(anchor, fa, fb, usize_arg(input, "k", 6)).to_string(), false)
233 }
234 "rank" => {
235 let facet = str_arg(input, "facet");
236 if facet.is_empty() {
237 return (json!({ "error": "missing `facet`" }).to_string(), true);
238 }
239 (corpus.rank(facet, usize_arg(input, "k", 10)).to_string(), false)
240 }
241 "cooccurs" => {
242 let token = str_arg(input, "token");
243 if token.is_empty() {
244 return (json!({ "error": "missing `token`" }).to_string(), true);
245 }
246 (corpus.cooccurs(token, usize_arg(input, "k", 12)).to_string(), false)
247 }
248 "s_path" => {
249 let (a, b) = (str_arg(input, "a"), str_arg(input, "b"));
250 if a.is_empty() || b.is_empty() {
251 return (json!({ "error": "need `a` and `b`" }).to_string(), true);
252 }
253 (corpus.s_path(a, b, usize_arg(input, "s", 1)).to_string(), false)
254 }
255 "s_clusters" => (corpus.s_clusters(usize_arg(input, "s", 2), usize_arg(input, "k", 8)).to_string(), false),
256 "narrow" => (corpus.narrow(&str_list(input, "scope"), &str_list(input, "filters")).to_string(), false),
257 other => (json!({ "error": format!("unknown tool {other}") }).to_string(), true),
258 }
259}
260
261pub const SYSTEM_PROMPT: &str = "\
262You are SteelDB, a retrieval-as-reasoning agent over a corpus of documents and structured rows. Answers \
263come ONLY from the corpus, reached through the tools. Pick the right tool for the question:
264
265DOCUMENT / EVIDENCE questions (what do the docs say about X, which files mention Y, quote the passage \
266about Z, summarise the sections on W): call `search` with plain-language keywords — it returns matching \
267situations with their text. Quote from the hit `cells` in your answer.
268
269ANALYTIC questions on structured data (compare, enumerate, rank, relate, break down, distribute): first \
270`list_facets` to see the schema, `facet_tokens` for exact token vocabulary, then use one of the bitmap \
271programs — `breakdown` (partition), `crosstab` (relate two facets), `rank` (most salient values), \
272`cooccurs` / `s_path` / `s_clusters` (structural graph), or `narrow` (find which constraint the corpus \
273cannot satisfy). Do NOT dump facet_tokens on a facet with hundreds of long-tail values — use `search` \
274or `ikl_query` instead.
275
276IKL for precise set queries: `ikl_query` runs `(and A B)`, `(or A B)`, `(not A)`, globs like `geo/*`, or \
277NUMERIC ranges `(num <field> <op> <value>)` (op ge|gt|le|lt|eq|ne) over the numeric_fields shown by \
278list_facets — e.g. `(and powertrain/electric (num range_km ge 500))` for 'electric vehicles with range \
279over 500'. `entity_link` has already been run for you up front — reuse those linked tokens as scope. \
280Answer concisely, grounded in what the tools returned, and say plainly if the corpus does not support \
281an answer. Never invent tokens; only query tokens you have seen.";