use crate::agent::AgentId;
use crate::backend::http::HttpConfig;
use crate::error::ProserpinaError;
use crate::message::MessageKind;
use crate::report::{Finding, Severity};
use crate::subject::Subject;
use crate::transcript::Transcript;
pub(crate) const FINDING_BLOCK_TAG: &str = "proserpina-finding";
pub fn parse_findings(body: &str) -> Vec<Finding> {
let mut findings = Vec::new();
for block in extract_finding_blocks(body) {
if let Some(f) = parse_block(&block) {
findings.push(f);
}
}
findings
}
fn extract_finding_blocks(body: &str) -> Vec<String> {
let open = format!("```{FINDING_BLOCK_TAG}");
let mut blocks = Vec::new();
let mut rest = body;
while let Some(start) = rest.find(&open) {
let after_open = &rest[start + open.len()..];
let line_end = after_open.find('\n').unwrap_or(after_open.len());
let after_open_line = &after_open[line_end..];
let Some(close_offset) = after_open_line.find("```") else {
break; };
let block_inner = &after_open_line[..close_offset];
blocks.push(block_inner.trim().to_owned());
rest = &after_open_line[close_offset + 3..];
}
blocks
}
fn parse_block(block: &str) -> Option<Finding> {
let mut severity: Option<Severity> = None;
let mut category: Option<String> = None;
let mut summary: Option<String> = None;
let mut location: Option<String> = None;
let mut quote: Option<String> = None;
let mut suggested_change: Option<String> = None;
let mut supporting: Vec<AgentId> = Vec::new();
for line in block.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim();
match key {
"severity" => severity = Some(parse_severity(value)),
"category" => category = Some(value.to_owned()),
"summary" => summary = Some(value.to_owned()),
"location" => location = Some(value.to_owned()),
"quote" => quote = Some(value.to_owned()),
"suggested_change" => suggested_change = Some(value.to_owned()),
"supporting_critics" => {
supporting = value
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(AgentId::new)
.collect();
}
_ => {}
}
}
let summary = summary?;
Some(
Finding::new(severity.unwrap_or(Severity::Major), summary)
.with_category_opt(category)
.with_location_opt(location)
.with_quote_opt(quote)
.with_suggested_change_opt(suggested_change)
.with_supporting_critics(supporting),
)
}
fn parse_severity(value: &str) -> Severity {
match value.to_lowercase().as_str() {
"info" => Severity::Info,
"minor" => Severity::Minor,
"major" => Severity::Major,
"blocker" => Severity::Blocker,
_ => Severity::Major, }
}
pub fn render_summary_prompt(
subject: &Subject,
transcript: &Transcript,
language: Option<&str>,
) -> Vec<SummaryMessage> {
let mut system = String::from(
"You are summarizing a multi-critic peer review. Group the \
critiques into distinct issues. Emit ONE fenced code block per issue, tagged \
```proserpina-finding```, with these fields (one per line, `key: value`):\n\
severity: (info|minor|major|blocker)\n\
category: (a short label)\n\
summary: (the issue, one line)\n\
location: (where in the document, e.g. §2 or line 47)\n\
quote: (the excerpt being critiqued)\n\
suggested_change: (an actionable recommended change)\n\
supporting_critics: (comma-separated critic names)\n\n\
All fields except `severity` and `summary` are optional. Cluster issues that \
multiple critics raised; list them in supporting_critics. Do not emit prose \
outside the blocks.",
);
if let Some(lang) = language {
system.push_str(&format!(" Respond in {lang}."));
}
let system = system.as_str();
let mut user = String::new();
user.push_str("# Document under critique\n\n");
user.push_str(subject.text());
user.push_str("\n\n# Critique transcript\n\n");
for msg in transcript.iter() {
user.push_str(&format!(
"[{}] ({}) {}: {}\n",
msg.sender(),
kind_label(msg.kind()),
msg.kind().label(),
msg.text(),
));
}
vec![
SummaryMessage {
role: "system".to_owned(),
content: system.to_owned(),
},
SummaryMessage {
role: "user".to_owned(),
content: user,
},
]
}
fn kind_label(_kind: MessageKind) -> &'static str {
"critic"
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SummaryMessage {
pub role: String,
pub content: String,
}
pub fn summarize(
subject: &Subject,
transcript: &Transcript,
config: &HttpConfig,
policy: &crate::backend::http::RetryPolicy,
language: Option<&str>,
) -> Result<Vec<Finding>, ProserpinaError> {
use crate::backend::http::send_chat_completion;
let messages = render_summary_prompt(subject, transcript, language);
let body = serde_json::json!({
"model": config.model,
"messages": messages,
});
let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| ProserpinaError::summary_failed(format!("runtime build: {e}")))?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(policy.timeout_secs))
.build()
.map_err(|e| ProserpinaError::summary_failed(format!("client build: {e}")))?;
let body_text = runtime
.block_on(send_chat_completion(
&client,
&url,
&config.api_key,
&body,
policy,
&format!("summarizer ({})", config.model),
))
.map_err(|e| ProserpinaError::summary_failed(e.to_string()))?;
let parsed: serde_json::Value = serde_json::from_str(&body_text)
.map_err(|e| ProserpinaError::summary_failed(format!("invalid JSON: {e}")))?;
let content = parsed
.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.ok_or_else(|| {
ProserpinaError::summary_failed("response had no choices[0].message.content")
})?;
Ok(parse_findings(content))
}