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();
for _attempt in 1..=config.max_attempts {
let routing = CONFIG.model_routing(config.model);
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,
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 is_callback(content: &str) -> bool {
content.starts_with(CALLBACK_PREFIX)
}
#[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 is_action(content: &str) -> bool {
content.starts_with(ACTION_PREFIX)
}
#[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, is_action, is_callback};
#[test]
fn is_callback_matches_prefix() {
assert!(is_callback("__opt__ticket123|Label"));
assert!(is_callback("__opt__|Label"));
assert!(is_callback("__opt__bare"));
}
#[test]
fn is_callback_rejects_non_prefix() {
assert!(!is_callback("not_opt_something"));
assert!(!is_callback(""));
assert!(!is_callback("__op__ticket|Label"));
}
#[test]
fn decode_callback_with_ticket_id() {
let (ticket_id, label) = decode_callback("__opt__mahbot-123|Option A").unwrap();
assert_eq!(ticket_id.as_deref(), Some("mahbot-123"));
assert_eq!(label, "Option A");
}
#[test]
fn decode_callback_empty_ticket_id() {
let (ticket_id, label) = decode_callback("__opt__|Label").unwrap();
assert_eq!(ticket_id.as_deref(), None);
assert_eq!(label, "Label");
}
#[test]
fn decode_callback_no_delimiter() {
let (ticket_id, label) = decode_callback("__opt__BareLabel").unwrap();
assert_eq!(ticket_id.as_deref(), None);
assert_eq!(label, "BareLabel");
}
#[test]
fn decode_callback_rejects_non_prefix() {
assert!(decode_callback("random_text").is_none());
assert!(decode_callback("").is_none());
}
#[test]
fn decode_callback_label_with_extra_pipes() {
let (ticket_id, label) = decode_callback("__opt__ticket|A|B|C").unwrap();
assert_eq!(ticket_id.as_deref(), Some("ticket"));
assert_eq!(label, "A|B|C");
}
#[test]
fn decode_callback_only_prefix_and_pipe() {
let (ticket_id, label) = decode_callback("__opt__|").unwrap();
assert_eq!(ticket_id.as_deref(), None);
assert_eq!(label, "");
}
#[test]
fn is_action_matches_prefix() {
assert!(is_action("__act__set_image_model|model-name"));
assert!(is_action("__act__clear_session|"));
assert!(is_action("__act__clear_session"));
}
#[test]
fn is_action_rejects_non_prefix() {
assert!(!is_action("not_act_something"));
assert!(!is_action(""));
assert!(!is_action("__ac__something"));
}
#[test]
fn decode_action_with_payload() {
let (action, payload) =
decode_action("__act__set_image_model|google/gemini-3.1-flash-image-preview").unwrap();
assert_eq!(action, "set_image_model");
assert_eq!(payload, "google/gemini-3.1-flash-image-preview");
}
#[test]
fn decode_action_empty_payload_with_pipe() {
let (action, payload) = decode_action("__act__clear_session|").unwrap();
assert_eq!(action, "clear_session");
assert_eq!(payload, "");
}
#[test]
fn decode_action_no_pipe() {
let (action, payload) = decode_action("__act__clear_session").unwrap();
assert_eq!(action, "clear_session");
assert_eq!(payload, "");
}
#[test]
fn decode_action_rejects_non_prefix() {
assert!(decode_action("random_text").is_none());
assert!(decode_action("").is_none());
}
}