use serde::Deserialize;
use tracing::{debug, warn};
use crate::models::{Effort, Finding, FindingCategory, Verdict};
#[derive(Debug, Deserialize)]
struct LlmOutputBlock {
verdict: String,
#[serde(default)]
grade: String,
#[serde(default)]
#[allow(dead_code)] grade_justification: String,
#[serde(default)]
summary: String,
#[serde(default)]
findings: Vec<LlmFinding>,
}
#[derive(Debug, Deserialize)]
struct LlmFinding {
title: String,
body: String,
#[serde(default)]
severity: String,
#[serde(default)]
confidence: f32,
#[serde(default)]
file: String,
#[serde(default)]
line: Option<u32>,
#[serde(default)]
category: FindingCategory,
#[serde(default)]
consequence: String,
#[serde(default)]
suggested_replacement: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ParsedReview {
pub verdict: Verdict,
pub grade: Option<String>,
pub summary: String,
pub findings: Vec<Finding>,
pub is_fail_safe: bool,
pub fail_safe_reason: Option<String>,
}
impl ParsedReview {
pub fn fail_safe(reason: impl Into<String>) -> Self {
Self {
verdict: Verdict::Unknown,
grade: None,
summary: String::new(),
findings: Vec::new(),
is_fail_safe: true,
fail_safe_reason: Some(reason.into()),
}
}
}
pub fn parse_review_response(body: &str) -> ParsedReview {
if body.trim().is_empty() {
warn!("LLM returned empty response — applying fail-safe UNKNOWN (fail-closed, #1241)");
return ParsedReview::fail_safe("empty LLM response");
}
if let Some(parsed) = try_parse_direct_json(body) {
debug!(verdict = ?parsed.verdict, findings = parsed.findings.len(), "parsed via direct JSON (structured output)");
return parsed;
}
if let Some(parsed) = try_parse_json_block(body) {
debug!(verdict = ?parsed.verdict, findings = parsed.findings.len(), "parsed via JSON block");
return parsed;
}
if let Some(verdict) = scan_verdict_keyword(body) {
warn!(
?verdict,
"JSON parse failed — fell back to verdict keyword scan (spec REV-112)"
);
return ParsedReview {
verdict,
grade: None,
summary: String::new(),
findings: Vec::new(),
is_fail_safe: false,
fail_safe_reason: None,
};
}
warn!(
body_len = body.len(),
"failed to parse verdict from LLM response — applying fail-safe UNKNOWN \
(fail-closed; #1241 supersedes spec REV-130)"
);
ParsedReview::fail_safe("no parseable verdict in LLM response")
}
fn try_parse_direct_json(body: &str) -> Option<ParsedReview> {
let trimmed = body.trim();
if !trimmed.starts_with('{') {
return None;
}
let block: LlmOutputBlock = serde_json::from_str(trimmed).ok()?;
let verdict = parse_verdict_string(&block.verdict).unwrap_or(Verdict::Unknown);
let grade = extract_grade_field(&block.grade);
let findings = block
.findings
.into_iter()
.map(convert_llm_finding)
.collect();
Some(ParsedReview {
verdict,
grade,
summary: block.summary,
findings,
is_fail_safe: false,
fail_safe_reason: None,
})
}
fn try_parse_json_block(body: &str) -> Option<ParsedReview> {
let fence_start = body.rfind("```json")?;
let after_fence = &body[fence_start + 7..];
let fence_end = after_fence.find("```")?;
let json_text = after_fence[..fence_end].trim();
let block: LlmOutputBlock = match serde_json::from_str(json_text) {
Ok(b) => b,
Err(e) => {
debug!("JSON block parse error: {e}");
return None;
}
};
let verdict = parse_verdict_string(&block.verdict).unwrap_or(Verdict::Unknown);
let grade = extract_grade_field(&block.grade);
let findings = block
.findings
.into_iter()
.map(convert_llm_finding)
.collect();
Some(ParsedReview {
verdict,
grade,
summary: block.summary,
findings,
is_fail_safe: false,
fail_safe_reason: None,
})
}
fn convert_llm_finding(f: LlmFinding) -> Finding {
let effort = match f.severity.to_lowercase().as_str() {
"high" | "critical" => Effort::High,
"medium" => Effort::Medium,
_ => Effort::Low,
};
let file = if f.file.is_empty() {
"unknown".to_string()
} else {
f.file
};
let category = f.category;
let line = f.line;
let mut finding = Finding::new(file, f.title, f.body, String::new(), f.confidence, effort)
.with_category(category);
finding.line = line;
finding.consequence = f.consequence;
finding.suggested_replacement = f.suggested_replacement.filter(|s| !s.trim().is_empty());
finding
}
fn scan_verdict_keyword(body: &str) -> Option<Verdict> {
let scan_start = body.len().saturating_sub((body.len() / 5).max(200));
let tail = &body[scan_start..];
if tail.contains("BLOCK") {
return Some(Verdict::Block);
}
if tail.contains("REQUEST_CHANGES") {
return Some(Verdict::RequestChanges);
}
if tail.contains("APPROVE*") {
return Some(Verdict::ApproveWithReservations);
}
if tail.contains("APPROVE") {
return Some(Verdict::Approve);
}
if tail.contains("UNKNOWN") {
return Some(Verdict::Unknown);
}
None
}
fn extract_grade_field(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
const VALID_GRADES: &[&str] = &[
"A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "D-", "F",
];
if VALID_GRADES.contains(&trimmed) {
Some(trimmed.to_string())
} else {
warn!(
grade = trimmed,
"LLM returned unrecognised grade — ignoring (will use default)"
);
None
}
}
fn parse_verdict_string(s: &str) -> Option<Verdict> {
match s.trim().to_uppercase().as_str() {
"APPROVE" => Some(Verdict::Approve),
"APPROVE*" => Some(Verdict::ApproveWithReservations),
"REQUEST_CHANGES" | "REQUEST CHANGES" => Some(Verdict::RequestChanges),
"BLOCK" => Some(Verdict::Block),
"UNKNOWN" => Some(Verdict::Unknown),
_ => None,
}
}
#[cfg(test)]
#[path = "parser_tests.rs"]
mod tests;