reviewloop 0.2.1

Reproducible, guardrailed automation for academic review workflows on paperreview.ai
Documentation
use crate::model::Job;
use anyhow::{Context, Result};
use chrono::Utc;
use serde_json::Value;
use std::{fs, path::Path};

pub fn write_review_artifacts(
    state_dir: &Path,
    job: &Job,
    token: &str,
    raw_json: &Value,
) -> Result<(String, String, String)> {
    let artifact_dir = state_dir.join("artifacts").join(&job.id);
    fs::create_dir_all(&artifact_dir)
        .with_context(|| format!("failed to create artifact dir: {}", artifact_dir.display()))?;

    let review_json = serde_json::to_string_pretty(raw_json)?;
    let summary_md = render_summary_markdown(raw_json);
    let meta_json = serde_json::to_string_pretty(&serde_json::json!({
        "job_id": job.id,
        "paper_id": job.paper_id,
        "backend": job.backend,
        "token": token,
        "generated_at": Utc::now().to_rfc3339(),
        "pdf_path": job.pdf_path,
        "pdf_hash": job.pdf_hash,
    }))?;

    let review_json_path = artifact_dir.join("review.json");
    let summary_md_path = artifact_dir.join("review.md");
    let meta_json_path = artifact_dir.join("meta.json");

    fs::write(&review_json_path, &review_json)
        .with_context(|| format!("failed to write {}", review_json_path.display()))?;
    fs::write(&summary_md_path, &summary_md)
        .with_context(|| format!("failed to write {}", summary_md_path.display()))?;
    fs::write(&meta_json_path, &meta_json)
        .with_context(|| format!("failed to write {}", meta_json_path.display()))?;

    Ok((review_json, summary_md, meta_json))
}

pub fn render_summary_markdown(raw_json: &Value) -> String {
    let mut out = String::new();
    out.push_str("# Review Summary\n\n");

    if let Some(title) = raw_json.get("title").and_then(Value::as_str) {
        out.push_str(&format!("- **Title**: {title}\n"));
    }
    if let Some(venue) = raw_json.get("venue").and_then(Value::as_str) {
        out.push_str(&format!("- **Venue**: {venue}\n"));
    }
    if let Some(score) = raw_json.get("numerical_score") {
        out.push_str(&format!("- **Estimated Score**: {score}\n"));
    }
    out.push('\n');

    if let Some(sections) = raw_json.get("sections").and_then(Value::as_object) {
        for key in [
            "summary",
            "strengths",
            "weaknesses",
            "detailed_comments",
            "questions",
            "assessment",
            "full_review",
        ] {
            if let Some(value) = sections.get(key).and_then(Value::as_str) {
                let heading = key.replace('_', " ");
                out.push_str(&format!("## {}\n\n{}\n\n", title_case(&heading), value));
            }
        }
    } else if let Some(content) = raw_json.get("content").and_then(Value::as_str) {
        out.push_str("## Content\n\n");
        out.push_str(content);
        out.push('\n');
    } else {
        out.push_str("## Raw JSON\n\n```json\n");
        out.push_str(&serde_json::to_string_pretty(raw_json).unwrap_or_default());
        out.push_str("\n```\n");
    }

    out
}

fn title_case(input: &str) -> String {
    input
        .split_whitespace()
        .map(|w| {
            let mut chars = w.chars();
            match chars.next() {
                Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

#[cfg(test)]
mod tests {
    use super::render_summary_markdown;
    use serde_json::json;

    #[test]
    fn renders_sections_when_present() {
        let raw = json!({
            "title": "A Paper",
            "venue": "ICLR",
            "sections": {
                "summary": "Good summary",
                "strengths": "Strong experiments"
            }
        });

        let md = render_summary_markdown(&raw);
        assert!(md.contains("# Review Summary"));
        assert!(md.contains("## Summary"));
        assert!(md.contains("Good summary"));
        assert!(md.contains("## Strengths"));
    }

    #[test]
    fn renders_content_fallback() {
        let raw = json!({
            "title": "A Paper",
            "content": "Full review body"
        });
        let md = render_summary_markdown(&raw);
        assert!(md.contains("## Content"));
        assert!(md.contains("Full review body"));
    }

    #[test]
    fn renders_raw_json_fallback() {
        let raw = json!({
            "foo": "bar"
        });
        let md = render_summary_markdown(&raw);
        assert!(md.contains("## Raw JSON"));
        assert!(md.contains("\"foo\": \"bar\""));
    }
}