use rigg_core::resources::ResourceKind;
use rigg_diff::Change;
const NARRATIVE_SYSTEM_PROMPT: &str = "\
You are summarizing configuration changes for Azure AI Search and Microsoft Foundry resources. \
The user wants to know exactly what changed — nothing more.
Rules:
- State what was added, removed, or changed. Be factual and specific.
- When the intent of a change is obvious from the content, state it plainly \
(e.g., \"added Norwegian support\" when nb-NO is added to a languages list).
- NEVER speculate beyond what the content clearly shows. Do not invent purpose or impact \
(no \"this enhances...\", \"this improves...\", \"this ensures...\").
- Scale your response to the size of the change. A one-line change gets 1-2 sentences. \
A large restructuring gets a longer explanation.
- Do NOT describe unchanged parts of a resource. Focus exclusively on the diff.
- Quote specific values when they clarify (e.g., \"added 'nb-NO' to the languages list\").
- For text changes (like agent instructions), summarize what text was added, removed, or \
reworded — quote short phrases. Do not describe what the instructions \"do\".
- Use bullet points for multiple changes. Use prose only if there is a single simple change.
- Frame the direction (push = local → server, pull = server → local, diff = comparison).";
const PER_RESOURCE_SYSTEM_PROMPT: &str = "\
You are summarizing configuration changes for a single Azure AI Search or Microsoft Foundry \
resource. The user has already seen the per-change breakdown. Your job is to give a concise \
factual summary (2-4 sentences). State what changed. When intent is obvious from the content, \
state it plainly. Do NOT speculate beyond what the content shows — no \"enhances\", \
\"improves\", \"ensures\". If you see a potential issue (e.g., a breaking change or missing \
dependency), mention it as a factual observation.";
pub enum ChangeStatus {
New,
Modified,
Deleted,
}
pub struct ResourceContext {
pub kind: ResourceKind,
pub name: String,
pub status: ChangeStatus,
pub local_content: Option<String>,
pub remote_content: Option<String>,
pub descriptions: Vec<String>,
}
pub async fn explain_all_changes(
resources: &[ResourceContext],
command_context: &str,
total_unchanged: usize,
) -> anyhow::Result<String> {
let user_prompt = build_narrative_prompt(resources, command_context, total_unchanged);
rigg_client::ai::generate_text_with_limit(NARRATIVE_SYSTEM_PROMPT, &user_prompt, 4000).await
}
pub async fn explain_resource_changes(
resource_type: &str,
resource_name: &str,
changes: &[Change],
descriptions: &[String],
command_context: &str,
) -> anyhow::Result<String> {
let user_prompt = build_per_resource_prompt(
resource_type,
resource_name,
changes,
descriptions,
command_context,
);
rigg_client::ai::generate_text(PER_RESOURCE_SYSTEM_PROMPT, &user_prompt).await
}
fn build_narrative_prompt(
resources: &[ResourceContext],
command_context: &str,
total_unchanged: usize,
) -> String {
let direction = match command_context {
"push" => {
"Pushing local configuration to Azure. These changes will be applied to the server."
}
"pull" => {
"Pulling configuration from Azure. These changes will be applied to your local files."
}
_ => "Comparing local configuration files against what is currently on Azure.",
};
let mut prompt = format!("Operation: {}\n{}\n\n", command_context, direction);
let changed_count = resources.len();
prompt.push_str(&format!("{} resource(s) with changes", changed_count));
if total_unchanged > 0 {
prompt.push_str(&format!(", {} unchanged", total_unchanged));
}
prompt.push_str(".\n\n");
for resource in resources {
let status_label = match (&resource.status, command_context) {
(ChangeStatus::New, "push") => "new locally — will be created on the server",
(ChangeStatus::New, "pull") => "new on the server — will be created locally",
(ChangeStatus::New, _) => "exists on one side only",
(ChangeStatus::Modified, _) => "modified — differs between local and server",
(ChangeStatus::Deleted, "push") => "deleted locally",
(ChangeStatus::Deleted, "pull") => "deleted on the server — will be removed locally",
(ChangeStatus::Deleted, _) => "exists on one side only",
};
prompt.push_str(&format!(
"=== {} '{}' ({}) ===\n",
resource.kind.display_name(),
resource.name,
status_label
));
if let Some(local) = &resource.local_content {
prompt.push_str("--- Local version ---\n");
push_content_with_limit(&mut prompt, local, 15000);
prompt.push('\n');
}
if let Some(remote) = &resource.remote_content {
prompt.push_str("--- Server version ---\n");
push_content_with_limit(&mut prompt, remote, 15000);
prompt.push('\n');
}
if !resource.descriptions.is_empty() {
prompt.push_str("Change summary:\n");
for desc in &resource.descriptions {
prompt.push_str(&format!("- {}\n", desc));
}
}
prompt.push('\n');
}
prompt
}
fn push_content_with_limit(prompt: &mut String, content: &str, max_chars: usize) {
if content.len() <= max_chars {
prompt.push_str(content);
} else {
prompt.push_str(&content[..max_chars]);
prompt.push_str(&format!(
"\n... (truncated, {} chars total)\n",
content.len()
));
}
}
pub fn format_for_ai(kind: ResourceKind, value: &serde_json::Value) -> String {
if kind == ResourceKind::Agent {
rigg_core::resources::agent::agent_to_yaml(value)
} else {
rigg_core::normalize::format_json(value)
}
}
fn build_per_resource_prompt(
resource_type: &str,
resource_name: &str,
changes: &[Change],
descriptions: &[String],
command_context: &str,
) -> String {
let context_explanation = match command_context {
"push" => "These changes will be applied from local files to Azure.",
"pull" => "These changes will be pulled from Azure to overwrite local files.",
_ => "Showing differences between local files and Azure.",
};
let mut prompt = format!(
"Resource: {} '{}'\nOperation: {} — {}\n",
resource_type, resource_name, command_context, context_explanation
);
if !descriptions.is_empty() {
prompt.push_str("\nChange descriptions:\n");
for (i, desc) in descriptions.iter().enumerate() {
prompt.push_str(&format!("{}. {}\n", i + 1, desc));
}
}
let mut raw_details = Vec::new();
for change in changes {
if let Some(d) = build_value_detail(change) {
raw_details.push(d);
}
}
if !raw_details.is_empty() {
prompt.push_str("\nValue details:\n");
for detail in &raw_details {
prompt.push_str(&format!("- {}\n", detail));
}
}
prompt
}
fn build_value_detail(change: &Change) -> Option<String> {
match change.kind {
rigg_diff::ChangeKind::Modified => {
let old = change.old_value.as_ref()?;
let new = change.new_value.as_ref()?;
if is_simple_scalar(old) && is_simple_scalar(new) {
return None;
}
let old_summary = summarize_value_rich(old);
let new_summary = summarize_value_rich(new);
Some(format!(
"{}: {} → {}",
change.path, old_summary, new_summary
))
}
rigg_diff::ChangeKind::Added => {
let new = change.new_value.as_ref()?;
if is_simple_scalar(new) {
return None;
}
Some(format!(
"{}: (added) {}",
change.path,
summarize_value_rich(new)
))
}
rigg_diff::ChangeKind::Removed => {
let old = change.old_value.as_ref()?;
if is_simple_scalar(old) {
return None;
}
Some(format!(
"{}: (removed) {}",
change.path,
summarize_value_rich(old)
))
}
}
}
fn is_simple_scalar(value: &serde_json::Value) -> bool {
matches!(
value,
serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null
) || matches!(value, serde_json::Value::String(s) if s.len() <= 200)
}
fn summarize_value_rich(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => {
if s.len() > 2000 {
format!("\"{}...\" ({} chars total)", &s[..2000], s.len())
} else {
format!("\"{}\"", s)
}
}
serde_json::Value::Array(arr) => {
let names: Vec<&str> = arr
.iter()
.filter_map(|item| item.get("name").and_then(|n| n.as_str()))
.take(10)
.collect();
if names.is_empty() {
format!("[{} items]", arr.len())
} else if names.len() < arr.len() {
format!("[{} items: {}, ...]", arr.len(), names.join(", "))
} else {
format!("[{} items: {}]", arr.len(), names.join(", "))
}
}
serde_json::Value::Object(obj) => {
let keys: Vec<&str> = obj.keys().take(10).map(|k| k.as_str()).collect();
if keys.len() < obj.len() {
format!("{{keys: {}, ...}}", keys.join(", "))
} else {
format!("{{keys: {}}}", keys.join(", "))
}
}
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_summarize_value_rich_string() {
let v = serde_json::Value::String("hello".to_string());
assert_eq!(summarize_value_rich(&v), "\"hello\"");
}
#[test]
fn test_summarize_value_rich_long_string() {
let s = "a".repeat(3000);
let v = serde_json::Value::String(s);
let result = summarize_value_rich(&v);
assert!(result.contains("3000 chars total"));
assert!(result.contains(&"a".repeat(2000)));
}
#[test]
fn test_summarize_value_rich_array_with_names() {
let v = serde_json::json!([
{"name": "field1", "type": "string"},
{"name": "field2", "type": "int"}
]);
assert_eq!(summarize_value_rich(&v), "[2 items: field1, field2]");
}
#[test]
fn test_summarize_value_rich_array_without_names() {
let v = serde_json::json!([1, 2, 3]);
assert_eq!(summarize_value_rich(&v), "[3 items]");
}
#[test]
fn test_summarize_value_rich_object() {
let v = serde_json::json!({"model": "gpt-4", "temperature": 0.7});
let result = summarize_value_rich(&v);
assert!(result.starts_with("{keys: "));
assert!(result.contains("model"));
assert!(result.contains("temperature"));
}
#[test]
fn test_narrative_prompt_structure() {
let resources = vec![ResourceContext {
kind: ResourceKind::Agent,
name: "bot".to_string(),
status: ChangeStatus::Modified,
local_content: Some("instructions: Be friendly\nmodel: gpt-4o\n".to_string()),
remote_content: Some("instructions: Be formal\nmodel: gpt-4o\n".to_string()),
descriptions: vec!["Instructions differ between local and server".to_string()],
}];
let prompt = build_narrative_prompt(&resources, "diff", 3);
assert!(prompt.contains("Operation: diff"));
assert!(prompt.contains("Comparing local"));
assert!(prompt.contains("Agent 'bot'"));
assert!(prompt.contains("--- Local version ---"));
assert!(prompt.contains("--- Server version ---"));
assert!(prompt.contains("Be friendly"));
assert!(prompt.contains("Be formal"));
assert!(prompt.contains("3 unchanged"));
}
#[test]
fn test_narrative_prompt_push_framing() {
let resources = vec![ResourceContext {
kind: ResourceKind::Index,
name: "products".to_string(),
status: ChangeStatus::New,
local_content: Some("{\"name\": \"products\"}".to_string()),
remote_content: None,
descriptions: vec![],
}];
let prompt = build_narrative_prompt(&resources, "push", 0);
assert!(prompt.contains("Pushing local configuration to Azure"));
assert!(prompt.contains("will be created on the server"));
}
#[test]
fn test_narrative_prompt_pull_framing() {
let resources = vec![ResourceContext {
kind: ResourceKind::Agent,
name: "helper".to_string(),
status: ChangeStatus::Deleted,
local_content: Some("instructions: old\n".to_string()),
remote_content: None,
descriptions: vec![],
}];
let prompt = build_narrative_prompt(&resources, "pull", 0);
assert!(prompt.contains("Pulling configuration from Azure"));
assert!(prompt.contains("will be removed locally"));
}
#[test]
fn test_content_truncation() {
let mut prompt = String::new();
let long_content = "x".repeat(20000);
push_content_with_limit(&mut prompt, &long_content, 15000);
assert!(prompt.contains("truncated"));
assert!(prompt.contains("20000 chars total"));
}
#[test]
fn test_per_resource_prompt() {
let changes = vec![Change {
path: "model".to_string(),
kind: rigg_diff::ChangeKind::Modified,
old_value: Some(serde_json::json!("gpt-4")),
new_value: Some(serde_json::json!("gpt-4o")),
description: None,
}];
let descriptions =
vec!["Uses model 'gpt-4o' locally but 'gpt-4' on the server".to_string()];
let prompt =
build_per_resource_prompt("Agent", "my-agent", &changes, &descriptions, "push");
assert!(prompt.contains("Agent 'my-agent'"));
assert!(prompt.contains("push"));
assert!(prompt.contains("Uses model"));
}
#[test]
fn test_build_value_detail_skips_simple_scalars() {
let change = Change {
path: "model".to_string(),
kind: rigg_diff::ChangeKind::Modified,
old_value: Some(serde_json::json!("gpt-4")),
new_value: Some(serde_json::json!("gpt-4o")),
description: None,
};
assert!(build_value_detail(&change).is_none());
}
#[test]
fn test_build_value_detail_includes_long_strings() {
let change = Change {
path: "instructions".to_string(),
kind: rigg_diff::ChangeKind::Modified,
old_value: Some(serde_json::Value::String("a".repeat(500))),
new_value: Some(serde_json::Value::String("b".repeat(500))),
description: None,
};
let detail = build_value_detail(&change);
assert!(detail.is_some());
assert!(detail.unwrap().contains("instructions:"));
}
}