use serde::de::DeserializeOwned;
use crate::config::CONFIG;
use crate::providers::chat;
use crate::util::parse_fenced_json;
use crate::{ChatMessage, ChatRequest, ToolSpec};
pub(crate) struct ExtractionConfig<'a> {
pub model: &'a str,
pub tool_specs: Option<&'a [ToolSpec]>,
pub temperature: f32,
pub reasoning_effort: Option<String>,
pub max_attempts: usize,
}
pub(crate) async fn retry_extract_structured<T: DeserializeOwned>(
history: &[ChatMessage],
extraction_prompt: &str,
retry_prompt: &str,
config: ExtractionConfig<'_>,
) -> anyhow::Result<T> {
let mut extraction_history = history.to_vec();
if !extraction_prompt.is_empty() {
extraction_history.push(ChatMessage::user(extraction_prompt));
}
let mut last_raw = String::new();
let routing = CONFIG.model_routing(config.model);
for _attempt in 1..=config.max_attempts {
let response = chat(ChatRequest {
messages: extraction_history.clone(),
tools: config.tool_specs.map(<[ToolSpec]>::to_vec),
model: config.model.to_string(),
allow_image_parts: false, temperature: config.temperature,
reasoning_effort: config.reasoning_effort.clone(),
provider_order: routing.provider_order.clone(),
provider_allow_fallbacks: routing.allow_fallbacks,
})
.await?;
last_raw = response.text_or_empty().to_string();
if response.tool_calls.is_empty()
&& let Ok(result) = parse_fenced_json::<T>(&last_raw)
{
return Ok(result);
}
extraction_history.push(ChatMessage::assistant(last_raw.clone()));
extraction_history.push(ChatMessage::user(retry_prompt));
}
let snippet: String = last_raw.chars().take(300).collect();
anyhow::bail!(
"Failed to extract structured response after {max_attempts} attempts. Last raw: {snippet}",
max_attempts = config.max_attempts,
)
}
pub(crate) const CALLBACK_PREFIX: &str = "__opt__";
#[must_use]
pub fn decode_callback(content: &str) -> Option<(Option<String>, String)> {
let rest = content.strip_prefix(CALLBACK_PREFIX)?;
Some(match rest.split_once('|') {
Some((tid, lbl)) if !tid.is_empty() => (Some(tid.to_string()), lbl.to_string()),
Some((_, lbl)) => (None, lbl.to_string()),
None => (None, rest.to_string()),
})
}
pub(crate) const ACTION_PREFIX: &str = "__act__";
#[must_use]
pub fn decode_action(content: &str) -> Option<(String, String)> {
let rest = content.strip_prefix(ACTION_PREFIX)?;
match rest.split_once('|') {
Some((action, payload)) => Some((action.to_string(), payload.to_string())),
None => Some((rest.to_string(), String::new())),
}
}
#[cfg(test)]
mod callback_tests {
use super::{decode_action, decode_callback};
#[test]
fn test_decode_callback() {
struct Case {
name: &'static str,
input: &'static str,
expected: Option<(Option<&'static str>, &'static str)>,
}
let cases = [
Case {
name: "with ticket id",
input: "__opt__mahbot-123|Option A",
expected: Some((Some("mahbot-123"), "Option A")),
},
Case {
name: "empty ticket id",
input: "__opt__|Label",
expected: Some((None, "Label")),
},
Case {
name: "no delimiter",
input: "__opt__BareLabel",
expected: Some((None, "BareLabel")),
},
Case {
name: "rejects non prefix",
input: "random_text",
expected: None,
},
Case {
name: "rejects empty",
input: "",
expected: None,
},
Case {
name: "label with extra pipes",
input: "__opt__ticket|A|B|C",
expected: Some((Some("ticket"), "A|B|C")),
},
Case {
name: "only prefix and pipe",
input: "__opt__|",
expected: Some((None, "")),
},
];
for case in &cases {
let result = decode_callback(case.input);
let expected = case
.expected
.map(|(tid, lbl)| (tid.map(String::from), lbl.to_string()));
assert_eq!(result, expected, "case: {}", case.name);
}
}
#[test]
fn test_decode_action() {
struct Case {
name: &'static str,
input: &'static str,
expected: Option<(&'static str, &'static str)>,
}
let cases = [
Case {
name: "with payload",
input: "__act__set_image_model|google/gemini-3.1-flash-image-preview",
expected: Some(("set_image_model", "google/gemini-3.1-flash-image-preview")),
},
Case {
name: "empty payload pipe",
input: "__act__clear_session|",
expected: Some(("clear_session", "")),
},
Case {
name: "no pipe",
input: "__act__clear_session",
expected: Some(("clear_session", "")),
},
Case {
name: "rejects non prefix",
input: "random_text",
expected: None,
},
Case {
name: "rejects empty",
input: "",
expected: None,
},
];
for case in &cases {
let result = decode_action(case.input);
let expected = case
.expected
.map(|(action, payload)| (action.to_string(), payload.to_string()));
assert_eq!(result, expected, "case: {}", case.name);
}
}
}