use std::collections::HashSet;
use crate::config::BusinessProfile;
use crate::content::angles::{
assign_angle_confidence, AngleMiningOutput, AngleType, EvidenceItem, MinedAngle,
MIN_EVIDENCE_COUNT, MIN_EVIDENCE_QUALITY,
};
use crate::content::evidence::{
extract_evidence, pre_filter_data_points, validate_evidence, NeighborContent,
};
use crate::error::LlmError;
use crate::llm::{GenerationParams, LlmProvider, TokenUsage};
#[cfg(test)]
mod tests;
pub async fn generate_mined_angles(
provider: &dyn LlmProvider,
business: &BusinessProfile,
topic: &str,
neighbors: &[NeighborContent],
selection_context: Option<&str>,
) -> Result<AngleMiningOutput, LlmError> {
let provider_name = provider.name().to_string();
let mut usage = TokenUsage::default();
if neighbors.is_empty() {
return Ok(AngleMiningOutput {
angles: vec![],
fallback_reason: Some("no_neighbors_accepted".to_string()),
evidence_quality_score: 0.0,
usage,
model: String::new(),
provider: provider_name,
});
}
let candidates = pre_filter_data_points(neighbors);
tracing::debug!(
candidate_count = candidates.len(),
"Data point candidates from regex pre-filter"
);
let raw_evidence = extract_evidence(provider, topic, neighbors, &candidates).await?;
usage.accumulate(&TokenUsage {
input_tokens: 0,
output_tokens: 0,
});
let accepted_ids: HashSet<i64> = neighbors.iter().map(|n| n.node_id).collect();
let evidence = validate_evidence(raw_evidence, &accepted_ids);
tracing::debug!(evidence_count = evidence.len(), "Validated evidence items");
if evidence.len() < MIN_EVIDENCE_COUNT {
return Ok(AngleMiningOutput {
angles: vec![],
fallback_reason: Some("insufficient_evidence".to_string()),
evidence_quality_score: if evidence.is_empty() {
0.0
} else {
evidence.iter().map(|e| e.confidence).sum::<f64>() / evidence.len() as f64
},
usage,
model: String::new(),
provider: provider_name,
});
}
let evidence_quality_score =
evidence.iter().map(|e| e.confidence).sum::<f64>() / evidence.len() as f64;
if evidence_quality_score < MIN_EVIDENCE_QUALITY {
return Ok(AngleMiningOutput {
angles: vec![],
fallback_reason: Some("low_evidence_quality".to_string()),
evidence_quality_score,
usage,
model: String::new(),
provider: provider_name,
});
}
let system = build_angle_generation_prompt(business, topic, &evidence, selection_context);
let user_message = "Generate content angles from the evidence above.".to_string();
let params = GenerationParams {
max_tokens: 800,
temperature: 0.8,
..Default::default()
};
let resp = provider.complete(&system, &user_message, ¶ms).await?;
usage.accumulate(&resp.usage);
let model = resp.model.clone();
tracing::debug!(
raw_response = %resp.text,
"Raw LLM response for angle generation"
);
let mut angles = parse_angles_response(&resp.text, &evidence);
angles.retain(|a| !a.evidence.is_empty());
if angles.is_empty() {
return Ok(AngleMiningOutput {
angles: vec![],
fallback_reason: Some("all_angles_filtered".to_string()),
evidence_quality_score,
usage,
model,
provider: provider_name,
});
}
angles.truncate(3);
Ok(AngleMiningOutput {
angles,
fallback_reason: None,
evidence_quality_score,
usage,
model,
provider: provider_name,
})
}
fn build_angle_generation_prompt(
business: &BusinessProfile,
topic: &str,
evidence: &[EvidenceItem],
selection_context: Option<&str>,
) -> String {
let audience_section = if business.target_audience.is_empty() {
String::new()
} else {
format!("\nYour audience: {}.", business.target_audience)
};
let voice_section = match &business.brand_voice {
Some(v) if !v.is_empty() => format!("\nVoice & personality: {v}"),
_ => String::new(),
};
let mut persona_parts = Vec::new();
if !business.persona_opinions.is_empty() {
persona_parts.push(format!(
"Opinions you hold: {}",
business.persona_opinions.join("; ")
));
}
if !business.persona_experiences.is_empty() {
persona_parts.push(format!(
"Experiences you can reference: {}",
business.persona_experiences.join("; ")
));
}
let persona_section = if persona_parts.is_empty() {
String::new()
} else {
format!("\n{}", persona_parts.join("\n"))
};
let selection_section = match selection_context {
Some(ctx) if !ctx.is_empty() => format!("\n\nSelected vault context:\n{ctx}"),
_ => String::new(),
};
let mut evidence_list = String::new();
for (i, e) in evidence.iter().enumerate() {
evidence_list.push_str(&format!(
"[{}] ({}) \"{}\" from \"{}\" [confidence: {:.1}]\n",
i + 1,
e.evidence_type,
e.citation_text,
e.source_note_title,
e.confidence,
));
}
format!(
"You are {}'s social media voice. {}.\
{audience_section}\
{voice_section}\
{persona_section}\
{selection_section}\n\n\
Task: Generate up to 3 content angles from the evidence below.\n\
Each angle must be one of: story, listicle, hot_take.\n\
Generate exactly one angle per type IF the evidence supports it.\n\
Do not pad with unsupported angles.\n\n\
Topic: {topic}\n\n\
Evidence items:\n{evidence_list}\n\
Output format (strictly follow this, no extra text):\n\
ANGLE_TYPE: story\n\
SEED_TEXT: <opening tweet, max 280 chars>\n\
RATIONALE: <1 sentence why this angle works>\n\
EVIDENCE_IDS: 1, 3\n\
---\n\
(repeat for each angle)",
business.product_name, business.product_description,
)
}
pub fn parse_angles_response(text: &str, evidence: &[EvidenceItem]) -> Vec<MinedAngle> {
let mut angles = Vec::new();
let mut current_type: Option<AngleType> = None;
let mut current_seed = String::new();
let mut current_rationale = String::new();
let mut current_evidence_ids: Vec<usize> = Vec::new();
let flush = |angle_type: Option<AngleType>,
seed: &str,
rationale: &str,
evidence_ids: &[usize],
evidence: &[EvidenceItem],
angles: &mut Vec<MinedAngle>| {
if let Some(at) = angle_type {
let seed = seed.trim().to_string();
if !seed.is_empty() {
let matched_evidence: Vec<EvidenceItem> = evidence_ids
.iter()
.filter_map(|&id| {
if id >= 1 && id <= evidence.len() {
Some(evidence[id - 1].clone())
} else {
None
}
})
.collect();
let confidence = assign_angle_confidence(&matched_evidence);
let char_count = seed.len();
angles.push(MinedAngle {
angle_type: at,
seed_text: seed,
char_count,
evidence: matched_evidence,
confidence,
rationale: rationale.trim().to_string(),
});
}
}
};
for line in text.lines() {
let trimmed = line.trim();
if trimmed == "---" || trimmed == "- - -" {
flush(
current_type,
¤t_seed,
¤t_rationale,
¤t_evidence_ids,
evidence,
&mut angles,
);
current_type = None;
current_seed.clear();
current_rationale.clear();
current_evidence_ids.clear();
continue;
}
let cleaned = strip_formatting(trimmed);
if let Some(val) = strip_prefix_ci(&cleaned, "angle_type:") {
current_type = parse_angle_type(val.trim());
} else if let Some(val) = strip_prefix_ci(&cleaned, "seed_text:") {
current_seed = strip_quotes(val.trim());
} else if let Some(val) = strip_prefix_ci(&cleaned, "rationale:") {
current_rationale = val.trim().to_string();
} else if let Some(val) = strip_prefix_ci(&cleaned, "evidence_ids:") {
current_evidence_ids = val
.split([',', ' '])
.filter_map(|s| s.trim().parse::<usize>().ok())
.collect();
}
}
flush(
current_type,
¤t_seed,
¤t_rationale,
¤t_evidence_ids,
evidence,
&mut angles,
);
angles
}
fn parse_angle_type(s: &str) -> Option<AngleType> {
match s.to_lowercase().as_str() {
"story" => Some(AngleType::Story),
"listicle" => Some(AngleType::Listicle),
"hot_take" | "hottake" | "hot take" => Some(AngleType::HotTake),
_ => None,
}
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
let lower = text.to_ascii_lowercase();
if lower.starts_with(prefix) {
Some(&text[prefix.len()..])
} else {
None
}
}
fn strip_formatting(line: &str) -> String {
let mut s = line.replace("**", "");
if let Some(first) = s.chars().next() {
if first.is_ascii_digit() {
if let Some(pos) = s.find(|c: char| !c.is_ascii_digit()) {
let after = &s[pos..];
if after.starts_with(". ") || after.starts_with(") ") || after.starts_with(": ") {
s = after[2..].to_string();
}
}
}
}
if let Some(rest) = s.strip_prefix("- ").or_else(|| s.strip_prefix("\u{2022} ")) {
s = rest.to_string();
}
s
}
fn strip_quotes(text: &str) -> String {
let t = text.trim();
if (t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\'')) {
t[1..t.len() - 1].to_string()
} else {
t.to_string()
}
}