use std::collections::BTreeSet;
use crate::api::{RedDBError, RedDBResult};
use super::ask_rql_planner::{validate_candidate, CandidateDisposition, ValidatedCandidate};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AskIntent {
Factual,
Synthesis,
HowTo,
}
impl AskIntent {
pub fn as_str(&self) -> &'static str {
match self {
AskIntent::Factual => "factual",
AskIntent::Synthesis => "synthesis",
AskIntent::HowTo => "how_to",
}
}
fn parse(raw: &str) -> Option<AskIntent> {
match raw.trim().to_ascii_lowercase().as_str() {
"factual" => Some(AskIntent::Factual),
"synthesis" => Some(AskIntent::Synthesis),
"how_to" | "howto" | "how-to" => Some(AskIntent::HowTo),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScoredCollection {
pub collection: String,
pub score: f32,
pub columns: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NarrowedSlice {
pub collections: Vec<ScoredCollection>,
}
impl NarrowedSlice {
pub fn collection_names(&self) -> Vec<&str> {
self.collections
.iter()
.map(|c| c.collection.as_str())
.collect()
}
pub fn is_empty(&self) -> bool {
self.collections.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawSuggestion {
pub rql: String,
pub rationale: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SuggestedStatement {
pub rql: String,
pub mutating: bool,
pub statement_type: &'static str,
pub rationale: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AskPlan {
pub intent: AskIntent,
pub query: Option<String>,
pub answer: String,
pub suggestion: Vec<RawSuggestion>,
pub rationale: String,
}
impl AskPlan {
pub fn summary(&self) -> String {
let mut out = format!("intent={}", self.intent.as_str());
if let Some(query) = &self.query {
out.push_str("; query=");
out.push_str(query.trim());
}
out
}
}
pub fn validate_suggestions(raw: &[RawSuggestion]) -> Vec<SuggestedStatement> {
let mut out = Vec::new();
for item in raw {
if let Ok(candidate) = validate_candidate(&item.rql) {
out.push(SuggestedStatement {
mutating: !candidate.is_read_only(),
statement_type: candidate.statement_type,
rql: candidate.rql,
rationale: item.rationale.clone(),
});
}
}
out
}
pub trait PlannerModel {
fn plan(&self, prompt: &str) -> RedDBResult<String>;
}
impl<F> PlannerModel for F
where
F: Fn(&str) -> RedDBResult<String>,
{
fn plan(&self, prompt: &str) -> RedDBResult<String> {
self(prompt)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanRouting {
Execute { candidate: ValidatedCandidate },
RefuseMutating {
statement_type: &'static str,
rql: String,
},
Suggest {
answer: String,
suggestion: Vec<SuggestedStatement>,
},
Unsupported { intent: AskIntent },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedRoute {
pub prompt: String,
pub plan: AskPlan,
pub routing: PlanRouting,
}
pub fn build_planner_prompt(question: &str, slice: &NarrowedSlice) -> String {
let mut prompt = String::new();
prompt.push_str(
"You are the RedDB ASK planner. Classify the user's question intent and, for a \
factual question, generate a single read-only RQL SELECT candidate over the \
collections below.\n\
Respond with ONLY a JSON object, no code fences or commentary:\n\
{\"intent\": \"factual\"|\"synthesis\"|\"how_to\", \"query\": \"<read-only RQL or null>\", \
\"rationale\": \"<one sentence>\"}\n\
Rules: use only the collections and columns listed; never invent a collection; \
a factual question MUST carry a read-only SELECT (joins and the global `any` source \
are allowed); never emit INSERT/UPDATE/DELETE/DDL.\n\n",
);
if slice.collections.is_empty() {
prompt.push_str("Candidate collections: (none)\n");
} else {
prompt.push_str("Candidate collections (name, score, columns):\n");
for c in &slice.collections {
prompt.push_str("- ");
prompt.push_str(&c.collection);
prompt.push_str(&format!(" (score {:.4})", c.score));
if !c.columns.is_empty() {
let mut cols: BTreeSet<&str> = BTreeSet::new();
for col in &c.columns {
cols.insert(col.as_str());
}
prompt.push_str(": ");
prompt.push_str(&cols.into_iter().collect::<Vec<_>>().join(", "));
}
prompt.push('\n');
}
}
prompt.push_str("\nQuestion: ");
prompt.push_str(question);
prompt
}
pub fn parse_plan(raw: &str) -> RedDBResult<AskPlan> {
let json = extract_json_object(raw)
.ok_or_else(|| RedDBError::Query("ASK planner returned no JSON plan object".to_string()))?;
let parsed: crate::json::Value = crate::json::from_str(json).map_err(|err| {
RedDBError::Query(format!("ASK planner returned invalid JSON plan: {err}"))
})?;
let obj = parsed
.as_object()
.ok_or_else(|| RedDBError::Query("ASK planner plan must be a JSON object".to_string()))?;
let intent_raw = obj
.get("intent")
.and_then(|v| v.as_str())
.ok_or_else(|| RedDBError::Query("ASK planner plan is missing `intent`".to_string()))?;
let intent = AskIntent::parse(intent_raw).ok_or_else(|| {
RedDBError::Query(format!(
"ASK planner returned an unknown intent `{intent_raw}`"
))
})?;
let query = obj
.get("query")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let answer = obj
.get("answer")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let suggestion = obj
.get("suggestion")
.and_then(|v| v.as_array())
.map(parse_suggestions)
.unwrap_or_default();
let rationale = obj
.get("rationale")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
Ok(AskPlan {
intent,
query,
answer,
suggestion,
rationale,
})
}
fn parse_suggestions(items: &[crate::json::Value]) -> Vec<RawSuggestion> {
let mut out = Vec::new();
for item in items {
if let Some(rql) = item.as_str() {
let rql = rql.trim();
if !rql.is_empty() {
out.push(RawSuggestion {
rql: rql.to_string(),
rationale: String::new(),
});
}
} else if let Some(obj) = item.as_object() {
let rql = obj
.get("rql")
.or_else(|| obj.get("statement"))
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if rql.is_empty() {
continue;
}
let rationale = obj
.get("rationale")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
out.push(RawSuggestion { rql, rationale });
}
}
out
}
pub fn route_plan(plan: &AskPlan) -> RedDBResult<PlanRouting> {
match plan.intent {
AskIntent::Factual => {
let rql = plan.query.as_deref().ok_or_else(|| {
RedDBError::Query(
"ASK planner classified the question as factual but produced no query \
candidate"
.to_string(),
)
})?;
let candidate = validate_candidate(rql)?;
match candidate.disposition {
CandidateDisposition::ReadOnly => Ok(PlanRouting::Execute { candidate }),
CandidateDisposition::Mutating => Ok(PlanRouting::RefuseMutating {
statement_type: candidate.statement_type,
rql: candidate.rql,
}),
}
}
AskIntent::HowTo => {
let suggestion = validate_suggestions(&plan.suggestion);
Ok(PlanRouting::Suggest {
answer: plan.answer.clone(),
suggestion,
})
}
other => Ok(PlanRouting::Unsupported { intent: other }),
}
}
pub fn plan_and_route<M: PlannerModel>(
question: &str,
slice: &NarrowedSlice,
model: &M,
) -> RedDBResult<PlannedRoute> {
let prompt = build_planner_prompt(question, slice);
let raw = model.plan(&prompt)?;
let plan = parse_plan(&raw)?;
let routing = route_plan(&plan)?;
Ok(PlannedRoute {
prompt,
plan,
routing,
})
}
pub const DEFAULT_MAX_PLAN_STEPS: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanStep {
RefineRetrieval,
Query,
}
impl PlanStep {
pub fn as_str(&self) -> &'static str {
match self {
PlanStep::RefineRetrieval => "refine_retrieval",
PlanStep::Query => "query",
}
}
}
pub fn clamp_plan_steps(requested: Option<usize>, cap: usize) -> usize {
let cap = cap.max(1);
match requested {
Some(n) => n.clamp(1, cap),
None => cap,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BudgetExhausted {
pub attempted: PlanStep,
pub executed_steps: usize,
pub max_steps: usize,
}
#[derive(Debug, Clone)]
pub struct PlanBudget {
max_steps: usize,
executed: Vec<PlanStep>,
}
impl PlanBudget {
pub fn new(requested_steps: Option<usize>, cap: usize) -> Self {
Self {
max_steps: clamp_plan_steps(requested_steps, cap),
executed: Vec::new(),
}
}
pub fn max_steps(&self) -> usize {
self.max_steps
}
pub fn executed_count(&self) -> usize {
self.executed.len()
}
pub fn remaining(&self) -> usize {
self.max_steps.saturating_sub(self.executed.len())
}
pub fn executed_steps(&self) -> &[PlanStep] {
&self.executed
}
pub fn charge(&mut self, step: PlanStep) -> Result<(), BudgetExhausted> {
if self.executed.len() >= self.max_steps {
return Err(BudgetExhausted {
attempted: step,
executed_steps: self.executed.len(),
max_steps: self.max_steps,
});
}
self.executed.push(step);
Ok(())
}
}
pub trait RetrievalFunnel {
fn funnel(&self, expanded: bool) -> RedDBResult<NarrowedSlice>;
}
impl<F> RetrievalFunnel for F
where
F: Fn(bool) -> RedDBResult<NarrowedSlice>,
{
fn funnel(&self, expanded: bool) -> RedDBResult<NarrowedSlice> {
self(expanded)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum GroundingOutcome {
Grounded { slice: NarrowedSlice, refined: bool },
NoMatchingSources,
}
pub fn ground_with_refine<F: RetrievalFunnel>(funnel: &F) -> RedDBResult<GroundingOutcome> {
let first = funnel.funnel(false)?;
if !first.is_empty() {
return Ok(GroundingOutcome::Grounded {
slice: first,
refined: false,
});
}
let refined = funnel.funnel(true)?;
if !refined.is_empty() {
return Ok(GroundingOutcome::Grounded {
slice: refined,
refined: true,
});
}
Ok(GroundingOutcome::NoMatchingSources)
}
fn extract_json_object(raw: &str) -> Option<&str> {
let start = raw.find('{')?;
let end = raw.rfind('}')?;
if end <= start {
return None;
}
Some(&raw[start..=end])
}
#[cfg(test)]
mod tests {
use super::*;
fn slice() -> NarrowedSlice {
NarrowedSlice {
collections: vec![
ScoredCollection {
collection: "travelers".to_string(),
score: 0.91,
columns: vec!["passport".to_string(), "name".to_string()],
},
ScoredCollection {
collection: "trips".to_string(),
score: 0.44,
columns: vec!["passport".to_string(), "city".to_string()],
},
],
}
}
fn mock_model(plan_json: &'static str) -> impl PlannerModel {
move |_prompt: &str| Ok(plan_json.to_string())
}
#[test]
fn prompt_contains_only_narrowed_slice() {
let prompt = build_planner_prompt("who owns passport FDD-1?", &slice());
assert!(prompt.contains("travelers"));
assert!(prompt.contains("trips"));
assert!(prompt.contains("passport"));
assert!(!prompt.contains("secret_ledger"));
}
#[test]
fn parse_plan_reads_intent_and_query() {
let plan = parse_plan(
"{\"intent\":\"factual\",\"query\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"lookup\"}",
)
.unwrap();
assert_eq!(plan.intent, AskIntent::Factual);
assert_eq!(
plan.query.as_deref(),
Some("SELECT * FROM travelers WHERE passport = 'FDD-1'")
);
assert_eq!(plan.rationale, "lookup");
}
#[test]
fn parse_plan_tolerates_code_fences() {
let plan = parse_plan(
"```json\n{\"intent\":\"synthesis\",\"query\":null,\"rationale\":\"summary\"}\n```",
)
.unwrap();
assert_eq!(plan.intent, AskIntent::Synthesis);
assert!(plan.query.is_none());
}
#[test]
fn parse_plan_rejects_non_json() {
let err = parse_plan("the answer is 42").unwrap_err();
assert!(
err.to_string().contains("no JSON plan object"),
"got: {err}"
);
}
#[test]
fn parse_plan_rejects_unknown_intent() {
let err = parse_plan("{\"intent\":\"chitchat\",\"query\":null}").unwrap_err();
assert!(err.to_string().contains("unknown intent"), "got: {err}");
}
#[test]
fn factual_plan_routes_to_executable_read_only_candidate() {
let route = plan_and_route(
"who owns passport FDD-1?",
&slice(),
&mock_model(
"{\"intent\":\"factual\",\"query\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"lookup\"}",
),
)
.unwrap();
match route.routing {
PlanRouting::Execute { candidate } => {
assert!(candidate.is_read_only());
assert_eq!(candidate.statement_type, "select");
}
other => panic!("expected Execute, got {other:?}"),
}
}
#[test]
fn factual_join_candidate_is_executable() {
let route = plan_and_route(
"which cities did the owner of passport FDD-1 visit?",
&slice(),
&mock_model(
"{\"intent\":\"factual\",\"query\":\"SELECT * FROM travelers JOIN trips ON travelers.passport = trips.passport WHERE travelers.passport = 'FDD-1'\",\"rationale\":\"join\"}",
),
)
.unwrap();
assert!(matches!(route.routing, PlanRouting::Execute { .. }));
}
#[test]
fn factual_plan_with_mutating_candidate_is_refused_not_executed() {
let route = plan_and_route(
"delete traveler FDD-1",
&slice(),
&mock_model(
"{\"intent\":\"factual\",\"query\":\"DELETE FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"oops\"}",
),
)
.unwrap();
match route.routing {
PlanRouting::RefuseMutating { statement_type, .. } => {
assert_eq!(statement_type, "delete")
}
other => panic!("expected RefuseMutating, got {other:?}"),
}
}
#[test]
fn factual_plan_with_malformed_candidate_is_rejected() {
let err = plan_and_route(
"who owns passport FDD-1?",
&slice(),
&mock_model(
"{\"intent\":\"factual\",\"query\":\"this is not rql\",\"rationale\":\"x\"}",
),
)
.unwrap_err();
assert!(
err.to_string().contains("invalid RQL candidate"),
"got: {err}"
);
}
#[test]
fn factual_plan_without_query_is_rejected() {
let err = plan_and_route(
"who owns passport FDD-1?",
&slice(),
&mock_model("{\"intent\":\"factual\",\"query\":null,\"rationale\":\"x\"}"),
)
.unwrap_err();
assert!(err.to_string().contains("no query candidate"), "got: {err}");
}
#[test]
fn synthesis_intent_routes_unsupported_in_this_slice() {
let route = plan_and_route(
"summarise yesterday's incidents",
&slice(),
&mock_model("{\"intent\":\"synthesis\",\"query\":null,\"rationale\":\"rag\"}"),
)
.unwrap();
assert_eq!(
route.routing,
PlanRouting::Unsupported {
intent: AskIntent::Synthesis
}
);
}
#[test]
fn synthesis_intent_carries_the_routed_intent_for_the_rag_fallthrough() {
let route = plan_and_route(
"summarise the incidents from yesterday",
&slice(),
&mock_model("{\"intent\":\"synthesis\",\"query\":null,\"rationale\":\"rag\"}"),
)
.unwrap();
match route.routing {
PlanRouting::Unsupported { intent } => {
assert_eq!(intent, AskIntent::Synthesis);
assert_eq!(intent.as_str(), "synthesis");
}
other => panic!("expected Unsupported{{synthesis}}, got {other:?}"),
}
}
#[test]
fn calculation_question_routes_factual_not_synthesis() {
let route = plan_and_route(
"how many trips went to Lisbon?",
&slice(),
&mock_model(
"{\"intent\":\"factual\",\"query\":\"SELECT COUNT(*) FROM trips WHERE city = 'Lisbon'\",\"rationale\":\"aggregate count\"}",
),
)
.unwrap();
assert_ne!(
route.plan.intent,
AskIntent::Synthesis,
"a calculation must never classify as synthesis (the LLM must not invent numbers)"
);
match route.routing {
PlanRouting::Execute { candidate } => {
assert!(candidate.is_read_only());
assert_eq!(candidate.statement_type, "select");
}
other => {
panic!("a calculation must route to an executable factual candidate, got {other:?}")
}
}
}
#[test]
fn how_to_intent_routes_to_a_validated_suggestion_envelope() {
let route = plan_and_route(
"how would I capture events from orders into a queue?",
&slice(),
&mock_model(
"{\"intent\":\"how_to\",\"answer\":\"Create a queue and backfill events into it.\",\
\"suggestion\":[\
{\"rql\":\"CREATE QUEUE events_q WORK\",\"rationale\":\"the sink queue\"},\
{\"rql\":\"EVENTS BACKFILL orders TO events_q\",\"rationale\":\"seed history\"},\
{\"rql\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"inspect\"}\
],\"rationale\":\"guide\"}",
),
)
.unwrap();
match route.routing {
PlanRouting::Suggest { answer, suggestion } => {
assert!(answer.contains("Create a queue"));
assert_eq!(suggestion.len(), 3);
assert!(suggestion[0].mutating, "CREATE QUEUE is mutating DDL");
assert!(suggestion[1].mutating, "EVENTS BACKFILL is mutating");
assert!(!suggestion[2].mutating, "the SELECT is read-only");
assert_eq!(suggestion[2].statement_type, "select");
assert_eq!(suggestion[0].rationale, "the sink queue");
}
other => panic!("expected Suggest, got {other:?}"),
}
}
#[test]
fn how_to_suggestion_drops_unparseable_statements_never_returns_them_raw() {
let route = plan_and_route(
"how do I list travelers?",
&slice(),
&mock_model(
"{\"intent\":\"how_to\",\"answer\":\"Select from the collection.\",\
\"suggestion\":[\
{\"rql\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\"},\
{\"rql\":\"this is not rql at all\"},\
\"DELETE FROM travelers WHERE passport = 'FDD-1'\"\
]}",
),
)
.unwrap();
match route.routing {
PlanRouting::Suggest { suggestion, .. } => {
assert_eq!(suggestion.len(), 2, "the unparseable statement is dropped");
assert_eq!(suggestion[0].statement_type, "select");
assert_eq!(suggestion[1].statement_type, "delete");
assert!(suggestion[1].mutating);
for s in &suggestion {
assert!(
!s.rql.contains("not rql"),
"raw unparseable text must never survive"
);
}
}
other => panic!("expected Suggest, got {other:?}"),
}
}
#[test]
fn routing_distinguishes_how_to_from_factual_at_the_closure_model_seam() {
let factual = plan_and_route(
"how would I capture events into a queue?",
&slice(),
&mock_model(
"{\"intent\":\"factual\",\"query\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"x\"}",
),
)
.unwrap();
assert!(matches!(factual.routing, PlanRouting::Execute { .. }));
let how_to = plan_and_route(
"how would I capture events into a queue?",
&slice(),
&mock_model(
"{\"intent\":\"how_to\",\"answer\":\"Use a queue.\",\"suggestion\":[\"CREATE QUEUE events_q WORK\"],\"rationale\":\"x\"}",
),
)
.unwrap();
assert!(matches!(how_to.routing, PlanRouting::Suggest { .. }));
}
#[test]
fn steps_clause_is_clamped_to_the_config_cap_never_exceeding_it() {
assert_eq!(clamp_plan_steps(Some(9), 3), 3);
assert_eq!(clamp_plan_steps(Some(2), 3), 2);
assert_eq!(clamp_plan_steps(Some(3), 3), 3);
assert_eq!(clamp_plan_steps(None, 3), 3);
assert_eq!(clamp_plan_steps(Some(0), 3), 1);
assert_eq!(clamp_plan_steps(None, 0), 1);
}
#[test]
fn plan_budget_charges_until_exhausted_then_refuses_more() {
let mut budget = PlanBudget::new(Some(2), 3);
assert_eq!(budget.max_steps(), 2);
assert_eq!(budget.remaining(), 2);
assert!(budget.charge(PlanStep::RefineRetrieval).is_ok());
assert_eq!(budget.remaining(), 1);
assert!(budget.charge(PlanStep::Query).is_ok());
assert_eq!(budget.remaining(), 0);
assert_eq!(
budget.executed_steps(),
&[PlanStep::RefineRetrieval, PlanStep::Query]
);
let err = budget.charge(PlanStep::Query).unwrap_err();
assert_eq!(err.max_steps, 2);
assert_eq!(err.executed_steps, 2);
assert_eq!(err.attempted, PlanStep::Query);
assert_eq!(budget.executed_count(), 2);
}
#[test]
fn plan_budget_request_above_cap_cannot_buy_extra_steps() {
let mut budget = PlanBudget::new(Some(5), 1);
assert_eq!(budget.max_steps(), 1);
assert!(budget.charge(PlanStep::Query).is_ok());
assert!(budget.charge(PlanStep::RefineRetrieval).is_err());
}
fn scripted_funnel(
script: Vec<bool>,
) -> (impl RetrievalFunnel, std::rc::Rc<std::cell::Cell<usize>>) {
let calls = std::rc::Rc::new(std::cell::Cell::new(0usize));
let calls_inner = calls.clone();
let funnel = move |_expanded: bool| -> RedDBResult<NarrowedSlice> {
let n = calls_inner.get();
calls_inner.set(n + 1);
let non_empty = script.get(n).copied().unwrap_or(false);
Ok(if non_empty {
slice()
} else {
NarrowedSlice::default()
})
};
(funnel, calls)
}
#[test]
fn grounding_succeeds_on_first_pass_without_refining() {
let (funnel, calls) = scripted_funnel(vec![true]);
let outcome = ground_with_refine(&funnel).unwrap();
assert_eq!(calls.get(), 1, "no refine when the first pass grounds");
match outcome {
GroundingOutcome::Grounded { refined, .. } => assert!(!refined),
other => panic!("expected Grounded, got {other:?}"),
}
}
#[test]
fn empty_first_pass_triggers_exactly_one_refine_retrieval() {
let (funnel, calls) = scripted_funnel(vec![false, true]);
let outcome = ground_with_refine(&funnel).unwrap();
assert_eq!(calls.get(), 2, "exactly one refine re-funnel");
match outcome {
GroundingOutcome::Grounded { refined, .. } => assert!(refined),
other => panic!("expected Grounded after refine, got {other:?}"),
}
}
#[test]
fn second_grounding_failure_returns_no_matching_sources_not_an_invented_answer() {
let (funnel, calls) = scripted_funnel(vec![false, false]);
let outcome = ground_with_refine(&funnel).unwrap();
assert_eq!(calls.get(), 2, "exactly one refine retry, then give up");
assert_eq!(outcome, GroundingOutcome::NoMatchingSources);
}
#[test]
fn plan_summary_includes_intent_and_query() {
let plan = AskPlan {
intent: AskIntent::Factual,
query: Some("SELECT * FROM travelers WHERE passport = 'FDD-1'".to_string()),
answer: String::new(),
suggestion: Vec::new(),
rationale: "lookup".to_string(),
};
let summary = plan.summary();
assert!(summary.contains("intent=factual"));
assert!(summary.contains("SELECT * FROM travelers"));
}
}