use crate::provider_api::LlmResponse;
use converge_core::prompt::{
AgentPrompt, AgentRole, Constraint, OutputContract, PromptContext, PromptFormat,
};
use converge_core::{ContextKey, ProposedFact};
pub struct ProviderPromptBuilder {
base: AgentPrompt,
output_format_hint: Option<String>,
}
impl ProviderPromptBuilder {
#[must_use]
pub fn new(base: AgentPrompt) -> Self {
Self {
base,
output_format_hint: None,
}
}
#[must_use]
pub fn with_output_format(mut self, format: impl Into<String>) -> Self {
self.output_format_hint = Some(format.into());
self
}
#[must_use]
pub fn build_for_claude(&self) -> String {
let edn_prompt = self.base.serialize(PromptFormat::Edn);
let mut prompt = String::from("<prompt>\n");
prompt.push_str(&edn_prompt);
prompt.push_str("\n</prompt>\n\n");
if let Some(ref format) = self.output_format_hint {
if format.as_str() == "xml" {
prompt.push_str("<instructions>\n");
prompt.push_str("Respond in XML format with the following structure:\n");
prompt.push_str("<response>\n");
prompt.push_str(" <proposals>\n");
prompt.push_str(
" <proposal id=\"...\" confidence=\"0.0-1.0\">content</proposal>\n",
);
prompt.push_str(" </proposals>\n");
prompt.push_str("</response>\n");
prompt.push_str("</instructions>");
} else {
prompt.push_str("<instructions>Respond in ");
prompt.push_str(format);
prompt.push_str(" format.</instructions>");
}
} else {
prompt.push_str("<instructions>\n");
prompt.push_str("Respond with proposed facts in a structured format.\n");
prompt.push_str("Each proposal should include: id, content, confidence (0.0-1.0).\n");
prompt.push_str("</instructions>");
}
prompt
}
#[must_use]
pub fn build_edn_only(&self) -> String {
self.base.serialize(PromptFormat::Edn)
}
#[must_use]
pub fn build_for_openai(&self) -> String {
let edn_prompt = self.base.serialize(PromptFormat::Edn);
let mut prompt = String::from("Prompt (EDN format):\n");
prompt.push_str(&edn_prompt);
prompt.push_str("\n\n");
prompt.push_str("Respond with a JSON object containing an array of proposals:\n");
prompt.push_str("{\n");
prompt.push_str(" \"proposals\": [\n");
prompt.push_str(" {\"id\": \"...\", \"content\": \"...\", \"confidence\": 0.0-1.0}\n");
prompt.push_str(" ]\n");
prompt.push_str("}\n");
prompt
}
#[must_use]
pub fn build_generic(&self) -> String {
self.base.serialize(PromptFormat::Edn)
}
}
pub struct StructuredResponseParser;
impl StructuredResponseParser {
#[must_use]
pub fn parse_claude_xml(
response: &LlmResponse,
target_key: ContextKey,
model: &str,
) -> Vec<ProposedFact> {
let content = &response.content;
let mut proposals = Vec::new();
let mut in_proposal = false;
let mut current_id = String::new();
let mut current_confidence = 0.7; let mut current_content = String::new();
let lines: Vec<&str> = content.lines().collect();
for line in lines {
let line = line.trim();
if line.starts_with("<proposal") {
in_proposal = true;
if let Some(id_start) = line.find("id=\"") {
let id_end = line[id_start + 4..].find('"').unwrap_or(0);
current_id = line[id_start + 4..id_start + 4 + id_end].to_string();
}
if let Some(conf_start) = line.find("confidence=\"") {
let conf_end = line[conf_start + 12..].find('"').unwrap_or(0);
if let Ok(conf) =
line[conf_start + 12..conf_start + 12 + conf_end].parse::<f64>()
{
current_confidence = conf;
}
}
if let Some(content_start) = line.find('>')
&& let Some(content_end) = line.find("</proposal>")
{
current_content = line[content_start + 1..content_end].trim().to_string();
}
} else if in_proposal
&& !line.starts_with("</proposal>")
&& !line.starts_with("<proposal")
{
if !current_content.is_empty() {
current_content.push(' ');
}
current_content.push_str(line);
}
if line.contains("</proposal>") {
if !current_id.is_empty() && !current_content.is_empty() {
proposals.push(ProposedFact {
key: target_key,
id: current_id.clone(),
content: current_content.clone(),
confidence: current_confidence,
provenance: format!("{}:{}", model, response.model),
});
}
in_proposal = false;
current_id.clear();
current_content.clear();
current_confidence = 0.7;
}
}
proposals
}
pub fn parse_openai_json(
response: &LlmResponse,
target_key: ContextKey,
model: &str,
) -> Result<Vec<ProposedFact>, String> {
use serde_json::Value;
let json: Value = serde_json::from_str(&response.content)
.map_err(|e| format!("Failed to parse JSON: {e}"))?;
let mut proposals = Vec::new();
if let Some(proposals_array) = json.get("proposals").and_then(|v| v.as_array()) {
for proposal in proposals_array {
let id = proposal
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing or invalid 'id' field".to_string())?
.to_string();
let content = proposal
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing or invalid 'content' field".to_string())?
.to_string();
let confidence = proposal
.get("confidence")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.7);
proposals.push(ProposedFact {
key: target_key,
id,
content,
confidence,
provenance: format!("{}:{}", model, response.model),
});
}
} else {
if let (Some(id), Some(content)) = (
json.get("id").and_then(|v| v.as_str()),
json.get("content").and_then(|v| v.as_str()),
) {
let confidence = json
.get("confidence")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.7);
proposals.push(ProposedFact {
key: target_key,
id: id.to_string(),
content: content.to_string(),
confidence,
provenance: format!("{}:{}", model, response.model),
});
} else {
return Err("No proposals found in JSON response".to_string());
}
}
Ok(proposals)
}
#[must_use]
pub fn parse_generic(
response: &LlmResponse,
target_key: ContextKey,
model: &str,
) -> Vec<ProposedFact> {
use std::time::{SystemTime, UNIX_EPOCH};
let id = SystemTime::now().duration_since(UNIX_EPOCH).map_or_else(
|_| "proposal-0".to_string(),
|d| format!("proposal-{:x}", d.as_nanos() % 0xFFFF_FFFF),
);
vec![ProposedFact {
key: target_key,
id,
content: response.content.clone(),
confidence: 0.7,
provenance: format!("{}:{}", model, response.model),
}]
}
}
pub fn build_claude_prompt(
role: AgentRole,
objective: impl Into<String>,
context: PromptContext,
output_contract: OutputContract,
constraints: impl IntoIterator<Item = Constraint>,
) -> String {
let base =
AgentPrompt::new(role, objective, context, output_contract).with_constraints(constraints);
ProviderPromptBuilder::new(base)
.with_output_format("xml")
.build_for_claude()
}
pub fn build_openai_prompt(
role: AgentRole,
objective: impl Into<String>,
context: PromptContext,
output_contract: OutputContract,
constraints: impl IntoIterator<Item = Constraint>,
) -> String {
let base =
AgentPrompt::new(role, objective, context, output_contract).with_constraints(constraints);
ProviderPromptBuilder::new(base).build_for_openai()
}
#[cfg(test)]
#[allow(clippy::float_cmp)] mod tests {
use super::*;
use converge_core::Fact;
#[test]
fn test_claude_prompt_building() {
let mut ctx = PromptContext::new();
ctx.add_facts(
ContextKey::Signals,
vec![Fact {
key: ContextKey::Signals,
id: "s1".to_string(),
content: "Test signal".to_string(),
}],
);
let prompt = build_claude_prompt(
AgentRole::Proposer,
"test-objective",
ctx,
OutputContract::new("proposed-fact", ContextKey::Competitors),
vec![Constraint::NoInvent, Constraint::NoHallucinate],
);
assert!(prompt.contains("<prompt>"));
assert!(prompt.contains(":r :proposer"));
assert!(prompt.contains("<instructions>"));
assert!(prompt.contains("XML format"));
}
#[test]
fn test_openai_prompt_building() {
let ctx = PromptContext::new();
let prompt = build_openai_prompt(
AgentRole::Proposer,
"test-objective",
ctx,
OutputContract::new("proposed-fact", ContextKey::Strategies),
vec![Constraint::NoInvent],
);
assert!(prompt.contains("EDN format"));
assert!(prompt.contains("JSON"));
assert!(prompt.contains("proposals"));
}
#[test]
fn test_claude_xml_parsing() {
let xml_response = r#"
<response>
<proposals>
<proposal id="p1" confidence="0.85">Test content 1</proposal>
<proposal id="p2" confidence="0.90">Test content 2</proposal>
</proposals>
</response>
"#;
let response = LlmResponse {
content: xml_response.to_string(),
model: "claude-sonnet-4-6".to_string(),
usage: crate::provider_api::TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
},
finish_reason: crate::provider_api::FinishReason::Stop,
};
let proposals = StructuredResponseParser::parse_claude_xml(
&response,
ContextKey::Competitors,
"anthropic",
);
assert_eq!(proposals.len(), 2);
assert_eq!(proposals[0].id, "p1");
assert_eq!(proposals[0].confidence, 0.85);
assert_eq!(proposals[1].id, "p2");
assert_eq!(proposals[1].confidence, 0.90);
}
#[test]
fn test_openai_json_parsing() {
let json_response = r#"
{
"proposals": [
{"id": "p1", "content": "Test content 1", "confidence": 0.85},
{"id": "p2", "content": "Test content 2", "confidence": 0.90}
]
}
"#;
let response = LlmResponse {
content: json_response.to_string(),
model: "gpt-4".to_string(),
usage: crate::provider_api::TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
},
finish_reason: crate::provider_api::FinishReason::Stop,
};
let proposals = StructuredResponseParser::parse_openai_json(
&response,
ContextKey::Strategies,
"openai",
)
.unwrap();
assert_eq!(proposals.len(), 2);
assert_eq!(proposals[0].id, "p1");
assert_eq!(proposals[0].confidence, 0.85);
assert_eq!(proposals[1].id, "p2");
assert_eq!(proposals[1].confidence, 0.90);
}
}