From 85eb3e3701aa579d1b4bd3b54ee8ea8e70a7316b Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 7 Apr 2026 11:33:40 +0200 Subject: [PATCH 1/7] feat(risk-assessment): add PDF report generation endpoint Add GET /v2/risk-assessment/{id}/report endpoint that generates a formatted PDF report from assessment results stored in the database. The report includes: - Executive summary with overall risk score and category breakdown - Detailed criteria assessments with enriched fields (what_documented, gaps, impact, recommendations) - Risk assessment matrix with threat scenarios Uses printpdf with built-in Helvetica fonts for zero-dependency PDF generation. Implements JIRAPLAY-1423 Assisted-by: Claude Code --- Cargo.lock | 73 +++ Cargo.toml | 1 + modules/fundamental/Cargo.toml | 1 + .../src/risk_assessment/endpoints/mod.rs | 46 +- .../src/risk_assessment/service/mod.rs | 120 ++++ .../service/report_generator.rs | 583 ++++++++++++++++++ 6 files changed, 822 insertions(+), 2 deletions(-) create mode 100644 modules/fundamental/src/risk_assessment/service/report_generator.rs diff --git a/Cargo.lock b/Cargo.lock index dd79dced2..f5e104315 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1256,6 +1256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -4342,6 +4343,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4386,6 +4393,23 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" +dependencies = [ + "encoding_rs", + "flate2", + "itoa", + "linked-hash-map", + "log", + "md5", + "pom", + "time", + "weezl", +] + [[package]] name = "lru" version = "0.16.3" @@ -4435,6 +4459,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -5031,6 +5061,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owned_ttf_parser" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" +dependencies = [ + "ttf-parser", +] + [[package]] name = "oxide-auth" version = "0.6.1" @@ -5502,6 +5541,15 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "pom" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" +dependencies = [ + "bstr", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5640,6 +5688,18 @@ dependencies = [ "elliptic-curve 0.13.8", ] +[[package]] +name = "printpdf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" +dependencies = [ + "js-sys", + "lopdf", + "owned_ttf_parser", + "time", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -8721,6 +8781,7 @@ dependencies = [ "opentelemetry_sdk", "osv", "packageurl", + "printpdf", "regex", "roxmltree", "rstest", @@ -9090,6 +9151,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + [[package]] name = "typed-path" version = "0.12.3" @@ -9682,6 +9749,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index bb8f27a2a..dcd8fb6e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,7 @@ parking_lot = "0.12" peak_alloc = "0.3.0" pem = "3" petgraph = { version = "0.8.0", features = ["serde-1"] } +printpdf = "0.7" quick-xml = "0.39.0" rand = "0.10.0" regex = "1.10.3" diff --git a/modules/fundamental/Cargo.toml b/modules/fundamental/Cargo.toml index 1099bb2d2..54fb0a80a 100644 --- a/modules/fundamental/Cargo.toml +++ b/modules/fundamental/Cargo.toml @@ -30,6 +30,7 @@ hex = { workspace = true } isx = { workspace = true } itertools = { workspace = true } log = { workspace = true } +printpdf = { workspace = true } sanitize-filename = { workspace = true } sea-orm = { workspace = true } sea-query = { workspace = true } diff --git a/modules/fundamental/src/risk_assessment/endpoints/mod.rs b/modules/fundamental/src/risk_assessment/endpoints/mod.rs index b5869c47e..217af9013 100644 --- a/modules/fundamental/src/risk_assessment/endpoints/mod.rs +++ b/modules/fundamental/src/risk_assessment/endpoints/mod.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod test; -use super::{model::*, service::RiskAssessmentService}; +use super::{model::*, service::RiskAssessmentService, service::report_generator}; use crate::{Error, db::DatabaseExt}; use actix_web::{HttpRequest, HttpResponse, Responder, delete, get, post, web}; use futures_util::stream::TryStreamExt; @@ -31,7 +31,8 @@ pub fn configure( .service(delete_assessment) .service(upload_document) .service(download_document) - .service(get_results); + .service(get_results) + .service(generate_report); } #[utoipa::path( @@ -302,3 +303,44 @@ async fn get_results( None => HttpResponse::NotFound().finish(), }) } + +#[utoipa::path( + tag = "risk-assessment", + operation_id = "generateRiskAssessmentReport", + params( + ("id", Path, description = "The ID of the risk assessment"), + ), + responses( + (status = 200, description = "The generated PDF report", content_type = "application/pdf"), + (status = 400, description = "The request was not valid"), + (status = 401, description = "The user was not authenticated"), + (status = 403, description = "The user authenticated, but not authorized for this operation"), + (status = 404, description = "The risk assessment was not found"), + ) +)] +#[get("/v2/risk-assessment/{id}/report")] +/// Generate and download a PDF report for a risk assessment +async fn generate_report( + service: web::Data, + db: web::Data, + id: web::Path, + _: Require, +) -> Result { + let tx = db.begin_read().await?; + let report_data = service.get_report_data(&id, &tx).await?; + + let Some(report_data) = report_data else { + return Ok(HttpResponse::NotFound().finish()); + }; + + let pdf_bytes = report_generator::generate_report(&report_data) + .map_err(|e| Error::Internal(format!("Failed to generate PDF report: {e}")))?; + + Ok(HttpResponse::Ok() + .content_type("application/pdf") + .append_header(( + "Content-Disposition", + format!("attachment; filename=\"risk-assessment-{}.pdf\"", &*id), + )) + .body(pdf_bytes)) +} diff --git a/modules/fundamental/src/risk_assessment/service/mod.rs b/modules/fundamental/src/risk_assessment/service/mod.rs index 343f16e95..e4fc051b9 100644 --- a/modules/fundamental/src/risk_assessment/service/mod.rs +++ b/modules/fundamental/src/risk_assessment/service/mod.rs @@ -3,6 +3,7 @@ mod test_document_processor; pub mod document_processor; pub mod llm_config; +pub mod report_generator; pub mod scoring; use crate::Error; @@ -294,6 +295,125 @@ impl RiskAssessmentService { })) } + /// Fetch all assessment data needed to generate a PDF report, including + /// enriched fields (what_documented, gaps, impact, recommendations) that + /// are not exposed in the standard API response. + pub async fn get_report_data( + &self, + assessment_id: &str, + db: &impl ConnectionTrait, + ) -> Result, Error> { + let assessment_uuid = Uuid::parse_str(assessment_id) + .map_err(|_| Error::BadRequest("Invalid assessment ID".into(), None))?; + + let assessment = risk_assessment::Entity::find_by_id(assessment_uuid) + .one(db) + .await?; + + let Some(assessment) = assessment else { + return Ok(None); + }; + + let documents = risk_assessment_document::Entity::find() + .filter(risk_assessment_document::Column::RiskAssessmentId.eq(assessment_uuid)) + .all(db) + .await?; + + // Build category results for scoring (using existing CriterionResult) + let mut scoring_categories = Vec::with_capacity(documents.len()); + // Build report categories with enriched fields + let mut report_categories = Vec::with_capacity(documents.len()); + + for doc in documents { + let criteria = risk_assessment_criteria::Entity::find() + .filter(risk_assessment_criteria::Column::DocumentId.eq(doc.id)) + .all(db) + .await?; + + // Scoring needs the standard CriterionResult + scoring_categories.push(crate::risk_assessment::model::CategoryResult { + category: doc.category.clone(), + document_id: doc.id.to_string(), + processed: doc.processed, + criteria: criteria + .iter() + .map(|c| crate::risk_assessment::model::CriterionResult { + id: c.id.to_string(), + criterion: c.criterion.clone(), + completeness: c.completeness.clone(), + risk_level: c.risk_level.clone(), + score: c.score, + details: c.details.clone(), + }) + .collect(), + }); + + // Report needs enriched data + report_categories.push(report_generator::ReportCategory { + category: doc.category, + criteria: criteria + .into_iter() + .map(|c| { + let what_documented = c + .what_documented + .and_then(|v| serde_json::from_value::>(v).ok()) + .unwrap_or_default(); + let gaps = c + .gaps + .and_then(|v| serde_json::from_value::>(v).ok()) + .unwrap_or_default(); + let recommendations = c + .recommendations + .and_then(|v| { + serde_json::from_value::>(v).ok() + }) + .unwrap_or_default() + .into_iter() + .map(|r| report_generator::ReportRecommendation { + action: r + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + priority: r + .get("priority") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }) + .collect(); + + report_generator::ReportCriterion { + criterion: c.criterion, + completeness: c.completeness, + risk_level: c.risk_level, + score: c.score, + what_documented, + gaps, + impact_description: c.impact_description, + recommendations, + details: c.details, + } + }) + .collect(), + }); + } + + let scoring_result = scoring::compute_scoring_result(&scoring_categories); + + Ok(Some(report_generator::ReportData { + assessment_id: assessment.id.to_string(), + group_id: assessment.group_id.to_string(), + status: assessment.status, + created_at: assessment + .created_at + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "N/A".to_string()), + categories: report_categories, + scoring: Some(scoring_result), + })) + } + /// Process a document: extract PDF text, evaluate with LLM, and store results. /// /// The `pdf_path` should point to a temporary file containing the PDF content diff --git a/modules/fundamental/src/risk_assessment/service/report_generator.rs b/modules/fundamental/src/risk_assessment/service/report_generator.rs new file mode 100644 index 000000000..c67b6a8d9 --- /dev/null +++ b/modules/fundamental/src/risk_assessment/service/report_generator.rs @@ -0,0 +1,583 @@ +//! PDF report generator for risk assessment results. +//! +//! Produces a formatted PDF containing an executive summary, per-criterion +//! assessment details, and a risk assessment matrix using data stored in +//! the database (including enriched fields not exposed in the API response). + +use crate::risk_assessment::model::ScoringResult; +use printpdf::*; + +// ── Report data structures ────────────────────────────────────────────────── + +/// All data needed to render a risk assessment PDF report. +pub struct ReportData { + pub assessment_id: String, + pub group_id: String, + pub status: String, + pub created_at: String, + pub categories: Vec, + pub scoring: Option, +} + +/// Per-category data including enriched criterion details. +pub struct ReportCategory { + pub category: String, + pub criteria: Vec, +} + +/// Full criterion data including enriched fields from the database. +pub struct ReportCriterion { + pub criterion: String, + pub completeness: String, + pub risk_level: String, + pub score: f64, + pub what_documented: Vec, + pub gaps: Vec, + pub impact_description: Option, + pub recommendations: Vec, + pub details: Option, +} + +/// A single recommendation with action and priority. +pub struct ReportRecommendation { + pub action: String, + pub priority: String, +} + +// ── Layout constants ──────────────────────────────────────────────────────── + +const PAGE_WIDTH: f32 = 210.0; +const PAGE_HEIGHT: f32 = 297.0; +const MARGIN_LEFT: f32 = 20.0; +const MARGIN_RIGHT: f32 = 20.0; +const MARGIN_TOP: f32 = 20.0; +const MARGIN_BOTTOM: f32 = 25.0; +const FONT_SIZE_TITLE: f32 = 18.0; +const FONT_SIZE_HEADING: f32 = 14.0; +const FONT_SIZE_SUBHEADING: f32 = 12.0; +const FONT_SIZE_BODY: f32 = 10.0; +const FONT_SIZE_SMALL: f32 = 8.0; + +const LINE_HEIGHT_TITLE: f32 = 8.0; +const LINE_HEIGHT_HEADING: f32 = 7.0; +const LINE_HEIGHT_BODY: f32 = 5.0; +const LINE_HEIGHT_SMALL: f32 = 4.0; + +/// Approximate characters per line at body font size. +const CHARS_PER_LINE: usize = 90; + +// ── ReportWriter ──────────────────────────────────────────────────────────── + +/// Wraps `printpdf` to provide a higher-level API for writing report content. +struct ReportWriter { + doc: PdfDocumentReference, + font: IndirectFontRef, + font_bold: IndirectFontRef, + current_page: PdfPageIndex, + current_layer: PdfLayerIndex, + /// Current Y position in mm from the bottom of the page. + y: f32, +} + +impl ReportWriter { + fn new(title: &str) -> Result { + let (doc, page, layer) = + PdfDocument::new(title, Mm(PAGE_WIDTH), Mm(PAGE_HEIGHT), "Layer 1"); + let font = doc.add_builtin_font(BuiltinFont::Helvetica)?; + let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; + + Ok(Self { + doc, + font, + font_bold, + current_page: page, + current_layer: layer, + y: PAGE_HEIGHT - MARGIN_TOP, + }) + } + + /// Returns a reference to the current drawing layer. + fn layer(&self) -> PdfLayerReference { + self.doc + .get_page(self.current_page) + .get_layer(self.current_layer) + } + + /// Advance the cursor downward, adding a new page if needed. + fn advance(&mut self, mm: f32) { + self.y -= mm; + if self.y < MARGIN_BOTTOM { + self.new_page(); + } + } + + /// Start a new page and reset the cursor. + fn new_page(&mut self) { + let (page, layer) = + self.doc + .add_page(Mm(PAGE_WIDTH), Mm(PAGE_HEIGHT), "Layer 1"); + self.current_page = page; + self.current_layer = layer; + self.y = PAGE_HEIGHT - MARGIN_TOP; + } + + /// Ensure at least `needed` mm of space remain before the bottom margin. + fn ensure_space(&mut self, needed: f32) { + if self.y - needed < MARGIN_BOTTOM { + self.new_page(); + } + } + + // ── Text helpers ──────────────────────────────────────────────────── + + fn write_text(&self, text: &str, size: f32, x: f32, font: &IndirectFontRef) { + self.layer() + .use_text(text, size, Mm(x), Mm(self.y), font); + } + + fn title(&mut self, text: &str) { + self.ensure_space(LINE_HEIGHT_TITLE + 4.0); + self.write_text(text, FONT_SIZE_TITLE, MARGIN_LEFT, &self.font_bold.clone()); + self.advance(LINE_HEIGHT_TITLE + 4.0); + } + + fn heading(&mut self, text: &str) { + self.ensure_space(LINE_HEIGHT_HEADING + 3.0); + self.advance(3.0); + self.write_text( + text, + FONT_SIZE_HEADING, + MARGIN_LEFT, + &self.font_bold.clone(), + ); + self.advance(LINE_HEIGHT_HEADING + 2.0); + } + + fn subheading(&mut self, text: &str) { + self.ensure_space(LINE_HEIGHT_HEADING + 2.0); + self.advance(2.0); + self.write_text( + text, + FONT_SIZE_SUBHEADING, + MARGIN_LEFT, + &self.font_bold.clone(), + ); + self.advance(LINE_HEIGHT_BODY + 2.0); + } + + fn bold_line(&mut self, text: &str) { + self.ensure_space(LINE_HEIGHT_BODY); + self.write_text(text, FONT_SIZE_BODY, MARGIN_LEFT, &self.font_bold.clone()); + self.advance(LINE_HEIGHT_BODY); + } + + fn body(&mut self, text: &str) { + self.ensure_space(LINE_HEIGHT_BODY); + self.write_text(text, FONT_SIZE_BODY, MARGIN_LEFT, &self.font.clone()); + self.advance(LINE_HEIGHT_BODY); + } + + fn body_at(&mut self, text: &str, x: f32) { + self.write_text(text, FONT_SIZE_BODY, x, &self.font.clone()); + } + + fn bold_at(&mut self, text: &str, x: f32) { + self.write_text(text, FONT_SIZE_BODY, x, &self.font_bold.clone()); + } + + /// Write a key-value line: "Key: Value". + fn key_value(&mut self, key: &str, value: &str) { + self.ensure_space(LINE_HEIGHT_BODY); + self.bold_at(key, MARGIN_LEFT); + // Offset the value roughly after the key + let value_x = MARGIN_LEFT + (key.len() as f32 * 2.2).min(50.0); + self.body_at(value, value_x); + self.advance(LINE_HEIGHT_BODY); + } + + /// Write a bullet point with indent. + fn bullet(&mut self, text: &str) { + let indent = MARGIN_LEFT + 5.0; + let lines = wrap_text(text, CHARS_PER_LINE - 6); + for (i, line) in lines.iter().enumerate() { + self.ensure_space(LINE_HEIGHT_BODY); + if i == 0 { + self.write_text("•", FONT_SIZE_BODY, MARGIN_LEFT + 2.0, &self.font.clone()); + } + self.write_text(line, FONT_SIZE_BODY, indent, &self.font.clone()); + self.advance(LINE_HEIGHT_BODY); + } + } + + /// Write a wrapped paragraph. + fn paragraph(&mut self, text: &str) { + let lines = wrap_text(text, CHARS_PER_LINE); + for line in &lines { + self.ensure_space(LINE_HEIGHT_BODY); + self.write_text(line, FONT_SIZE_BODY, MARGIN_LEFT, &self.font.clone()); + self.advance(LINE_HEIGHT_BODY); + } + } + + fn small_text(&mut self, text: &str) { + self.ensure_space(LINE_HEIGHT_SMALL); + self.write_text(text, FONT_SIZE_SMALL, MARGIN_LEFT, &self.font.clone()); + self.advance(LINE_HEIGHT_SMALL); + } + + fn horizontal_rule(&mut self) { + self.ensure_space(3.0); + self.advance(1.5); + let layer = self.layer(); + layer.set_outline_thickness(0.5); + let points = vec![ + (Point::new(Mm(MARGIN_LEFT), Mm(self.y)), false), + ( + Point::new(Mm(PAGE_WIDTH - MARGIN_RIGHT), Mm(self.y)), + false, + ), + ]; + layer.add_line(Line { + points, + is_closed: false, + }); + self.advance(1.5); + } + + fn spacing(&mut self, mm: f32) { + self.advance(mm); + } + + // ── Table helpers ─────────────────────────────────────────────────── + + /// Write a table row with fixed column widths. + fn table_row(&mut self, cells: &[&str], col_widths: &[f32], bold: bool) { + self.ensure_space(LINE_HEIGHT_BODY); + let font = if bold { + self.font_bold.clone() + } else { + self.font.clone() + }; + let mut x = MARGIN_LEFT; + for (cell, &width) in cells.iter().zip(col_widths.iter()) { + // Truncate cell text if too wide + let max_chars = (width / 2.0) as usize; + let display = if cell.len() > max_chars { + &cell[..max_chars.saturating_sub(2)] + } else { + cell + }; + self.write_text(display, FONT_SIZE_BODY, x, &font); + x += width; + } + self.advance(LINE_HEIGHT_BODY); + } + + /// Finalize and return PDF bytes. + fn finish(self) -> Result, anyhow::Error> { + Ok(self.doc.save_to_bytes()?) + } +} + +// ── Word wrapping ─────────────────────────────────────────────────────────── + +/// Simple word-wrap for fixed-width approximation. +fn wrap_text(text: &str, max_chars: usize) -> Vec { + let mut lines = Vec::new(); + let mut current_line = String::new(); + + for word in text.split_whitespace() { + if current_line.is_empty() { + current_line = word.to_string(); + } else if current_line.len() + 1 + word.len() > max_chars { + lines.push(current_line); + current_line = word.to_string(); + } else { + current_line.push(' '); + current_line.push_str(word); + } + } + if !current_line.is_empty() { + lines.push(current_line); + } + + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/// Generate a PDF report from assessment data. +pub fn generate_report(data: &ReportData) -> Result, anyhow::Error> { + let mut w = ReportWriter::new(&format!( + "Risk Assessment Report - {}", + data.assessment_id + ))?; + + // ── Page 1: Executive Summary ─────────────────────────────────────── + + w.title("Risk Assessment Report"); + w.spacing(2.0); + + w.key_value("Assessment ID: ", &data.assessment_id); + w.key_value("Group ID: ", &data.group_id); + w.key_value("Status: ", &data.status); + w.key_value("Date: ", &data.created_at); + + w.spacing(3.0); + w.horizontal_rule(); + + // Overall risk score + w.heading("Executive Summary"); + + if let Some(ref scoring) = data.scoring { + let score_pct = scoring.overall.score * 100.0; + w.key_value( + "Overall Risk Score: ", + &format!("{:.1}%", score_pct), + ); + w.key_value("Risk Level: ", &scoring.overall.risk_level); + + if !scoring.overall.missing_categories.is_empty() { + w.spacing(2.0); + w.bold_line("Missing Categories:"); + for cat in &scoring.overall.missing_categories { + w.bullet(cat); + } + } + + // Summary of findings + w.spacing(3.0); + w.subheading("Summary of Findings"); + + let (complete, partial, missing) = count_completeness(&data.categories); + w.key_value("Complete criteria: ", &complete.to_string()); + w.key_value("Partial criteria: ", &partial.to_string()); + w.key_value("Missing criteria: ", &missing.to_string()); + + // Category scores table + if !scoring.categories.is_empty() { + w.spacing(3.0); + w.subheading("Category Scores"); + + let col_widths = [40.0, 25.0, 20.0, 30.0, 25.0, 30.0]; + w.table_row( + &["Category", "Score", "Weight", "Weighted", "Risk", "Criteria"], + &col_widths, + true, + ); + w.horizontal_rule(); + + for cat in &scoring.categories { + w.table_row( + &[ + &cat.category, + &format!("{:.1}%", cat.score * 100.0), + &format!("{:.0}%", cat.weight * 100.0), + &format!("{:.1}%", cat.weighted_score * 100.0), + &cat.risk_level, + &cat.criteria_count.to_string(), + ], + &col_widths, + false, + ); + } + } + + // Risk prioritization: top risks + w.spacing(3.0); + w.subheading("Risk Prioritization"); + + let mut top_risks: Vec<(&str, f64, &str)> = Vec::new(); + for cat in &data.categories { + for cr in &cat.criteria { + if cr.completeness != "complete" && cr.score > 0.0 { + top_risks.push((&cr.criterion, cr.score, &cr.risk_level)); + } + } + } + top_risks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + if top_risks.is_empty() { + w.body("No elevated risks identified."); + } else { + let col_widths = [80.0, 25.0, 30.0]; + w.table_row(&["Criterion", "Score", "Risk Level"], &col_widths, true); + w.horizontal_rule(); + for (name, score, level) in top_risks.iter().take(10) { + w.table_row( + &[name, &format!("{:.1}%", score * 100.0), level], + &col_widths, + false, + ); + } + } + } else { + w.body("No scoring data available."); + } + + // ── Page 2+: Criteria Assessment Details ──────────────────────────── + + w.new_page(); + w.title("Criteria Assessment Details"); + w.spacing(2.0); + + for cat in &data.categories { + w.heading(&format!("Category: {}", cat.category)); + + // Criteria overview table + let col_widths = [8.0, 62.0, 30.0, 30.0, 25.0]; + w.table_row( + &["#", "Criterion", "Completeness", "Risk Level", "Score"], + &col_widths, + true, + ); + w.horizontal_rule(); + + for (i, cr) in cat.criteria.iter().enumerate() { + w.table_row( + &[ + &(i + 1).to_string(), + &cr.criterion, + &cr.completeness, + &cr.risk_level, + &format!("{:.1}%", cr.score * 100.0), + ], + &col_widths, + false, + ); + } + + w.spacing(3.0); + + // Detailed breakdown for partial/missing criteria + for cr in &cat.criteria { + if cr.completeness == "complete" { + continue; + } + + w.subheading(&cr.criterion); + w.key_value("Completeness: ", &cr.completeness); + w.key_value("Risk Level: ", &cr.risk_level); + w.key_value("Score: ", &format!("{:.1}%", cr.score * 100.0)); + + if !cr.what_documented.is_empty() { + w.spacing(1.0); + w.bold_line("What is documented:"); + for item in &cr.what_documented { + w.bullet(item); + } + } + + if !cr.gaps.is_empty() { + w.spacing(1.0); + w.bold_line("Gaps identified:"); + for gap in &cr.gaps { + w.bullet(gap); + } + } + + if let Some(ref impact) = cr.impact_description { + w.spacing(1.0); + w.bold_line("Impact:"); + w.paragraph(impact); + } + + if !cr.recommendations.is_empty() { + w.spacing(1.0); + w.bold_line("Recommendations:"); + for rec in &cr.recommendations { + w.bullet(&format!("[{}] {}", rec.priority, rec.action)); + } + } + + w.spacing(2.0); + w.horizontal_rule(); + } + } + + // ── Final Page: Risk Assessment Matrix ────────────────────────────── + + w.new_page(); + w.title("Risk Assessment Matrix"); + w.spacing(2.0); + + w.small_text("Based on NIST 800-30 risk assessment methodology."); + w.spacing(3.0); + + for cat in &data.categories { + for cr in &cat.criteria { + if cr.completeness == "complete" { + continue; + } + + // Extract risk details from the details JSON if available + if let Some(ref details) = cr.details { + w.subheading(&cr.criterion); + + // Threat scenarios + if let Some(scenarios) = details.get("threat_scenarios") { + if let Some(arr) = scenarios.as_array() { + w.bold_line("Threat Scenarios:"); + for scenario in arr { + if let Some(s) = scenario.as_str() { + w.bullet(s); + } + } + } + } + + // Likelihood and impact + if let Some(likelihood) = details.get("likelihood") { + w.key_value("Likelihood: ", &format_json_value(likelihood)); + } + if let Some(impact) = details.get("impact") { + w.key_value("Impact: ", &format_json_value(impact)); + } + if let Some(risk_level) = details.get("risk_level") { + w.key_value("Risk Level: ", &format_json_value(risk_level)); + } + + w.spacing(2.0); + } + } + } + + // NIST reference + w.spacing(3.0); + w.horizontal_rule(); + w.small_text("Risk levels follow NIST 800-30 classification:"); + w.small_text(" Low (0-25%) | Moderate (26-50%) | High (51-75%) | Very High (76-100%)"); + + w.finish() +} + +/// Count criteria by completeness level across all categories. +fn count_completeness(categories: &[ReportCategory]) -> (usize, usize, usize) { + let mut complete = 0; + let mut partial = 0; + let mut missing = 0; + for cat in categories { + for cr in &cat.criteria { + match cr.completeness.as_str() { + "complete" => complete += 1, + "partial" => partial += 1, + "missing" => missing += 1, + _ => {} + } + } + } + (complete, partial, missing) +} + +/// Format a JSON value as a display string. +fn format_json_value(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + other => other.to_string(), + } +} From efb1984092a7ce4e689fc60cb650a11e44ca22eb Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 7 Apr 2026 13:05:20 +0200 Subject: [PATCH 2/7] fix(risk-assessment): avoid exposing internal error details in report endpoint Log the detailed error server-side and return a generic message to HTTP clients instead of including the internal error text in the response, preventing information disclosure. Implements JIRAPLAY-1427 Assisted-by: Claude Code --- modules/fundamental/src/risk_assessment/endpoints/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/fundamental/src/risk_assessment/endpoints/mod.rs b/modules/fundamental/src/risk_assessment/endpoints/mod.rs index 217af9013..014caf221 100644 --- a/modules/fundamental/src/risk_assessment/endpoints/mod.rs +++ b/modules/fundamental/src/risk_assessment/endpoints/mod.rs @@ -333,8 +333,10 @@ async fn generate_report( return Ok(HttpResponse::NotFound().finish()); }; - let pdf_bytes = report_generator::generate_report(&report_data) - .map_err(|e| Error::Internal(format!("Failed to generate PDF report: {e}")))?; + let pdf_bytes = report_generator::generate_report(&report_data).map_err(|e| { + log::error!("Failed to generate PDF report for assessment {}: {e}", &*id); + Error::Internal("Failed to generate PDF report".to_string()) + })?; Ok(HttpResponse::Ok() .content_type("application/pdf") From 55af2f9dc71fca5d18b98af0b6d77838ec6f5c3d Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 7 Apr 2026 15:01:12 +0200 Subject: [PATCH 3/7] fix(risk-assessment): fix heading ensure_space mismatch and apply cargo fmt Fix heading() to reserve the correct vertical space (3.0 + 7.0 + 2.0 = 12.0mm) via ensure_space to prevent cursor overflow past the bottom margin. Apply cargo fmt to fix all formatting diffs. Implements JIRAPLAY-1428 Assisted-by: Claude Code --- .../src/risk_assessment/service/mod.rs | 4 +-- .../service/report_generator.rs | 30 +++++++------------ 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/modules/fundamental/src/risk_assessment/service/mod.rs b/modules/fundamental/src/risk_assessment/service/mod.rs index e4fc051b9..b74bfc049 100644 --- a/modules/fundamental/src/risk_assessment/service/mod.rs +++ b/modules/fundamental/src/risk_assessment/service/mod.rs @@ -364,9 +364,7 @@ impl RiskAssessmentService { .unwrap_or_default(); let recommendations = c .recommendations - .and_then(|v| { - serde_json::from_value::>(v).ok() - }) + .and_then(|v| serde_json::from_value::>(v).ok()) .unwrap_or_default() .into_iter() .map(|r| report_generator::ReportRecommendation { diff --git a/modules/fundamental/src/risk_assessment/service/report_generator.rs b/modules/fundamental/src/risk_assessment/service/report_generator.rs index c67b6a8d9..badc6134c 100644 --- a/modules/fundamental/src/risk_assessment/service/report_generator.rs +++ b/modules/fundamental/src/risk_assessment/service/report_generator.rs @@ -113,9 +113,9 @@ impl ReportWriter { /// Start a new page and reset the cursor. fn new_page(&mut self) { - let (page, layer) = - self.doc - .add_page(Mm(PAGE_WIDTH), Mm(PAGE_HEIGHT), "Layer 1"); + let (page, layer) = self + .doc + .add_page(Mm(PAGE_WIDTH), Mm(PAGE_HEIGHT), "Layer 1"); self.current_page = page; self.current_layer = layer; self.y = PAGE_HEIGHT - MARGIN_TOP; @@ -131,8 +131,7 @@ impl ReportWriter { // ── Text helpers ──────────────────────────────────────────────────── fn write_text(&self, text: &str, size: f32, x: f32, font: &IndirectFontRef) { - self.layer() - .use_text(text, size, Mm(x), Mm(self.y), font); + self.layer().use_text(text, size, Mm(x), Mm(self.y), font); } fn title(&mut self, text: &str) { @@ -142,7 +141,7 @@ impl ReportWriter { } fn heading(&mut self, text: &str) { - self.ensure_space(LINE_HEIGHT_HEADING + 3.0); + self.ensure_space(3.0 + LINE_HEIGHT_HEADING + 2.0); self.advance(3.0); self.write_text( text, @@ -232,10 +231,7 @@ impl ReportWriter { layer.set_outline_thickness(0.5); let points = vec![ (Point::new(Mm(MARGIN_LEFT), Mm(self.y)), false), - ( - Point::new(Mm(PAGE_WIDTH - MARGIN_RIGHT), Mm(self.y)), - false, - ), + (Point::new(Mm(PAGE_WIDTH - MARGIN_RIGHT), Mm(self.y)), false), ]; layer.add_line(Line { points, @@ -311,10 +307,7 @@ fn wrap_text(text: &str, max_chars: usize) -> Vec { /// Generate a PDF report from assessment data. pub fn generate_report(data: &ReportData) -> Result, anyhow::Error> { - let mut w = ReportWriter::new(&format!( - "Risk Assessment Report - {}", - data.assessment_id - ))?; + let mut w = ReportWriter::new(&format!("Risk Assessment Report - {}", data.assessment_id))?; // ── Page 1: Executive Summary ─────────────────────────────────────── @@ -334,10 +327,7 @@ pub fn generate_report(data: &ReportData) -> Result, anyhow::Error> { if let Some(ref scoring) = data.scoring { let score_pct = scoring.overall.score * 100.0; - w.key_value( - "Overall Risk Score: ", - &format!("{:.1}%", score_pct), - ); + w.key_value("Overall Risk Score: ", &format!("{:.1}%", score_pct)); w.key_value("Risk Level: ", &scoring.overall.risk_level); if !scoring.overall.missing_categories.is_empty() { @@ -364,7 +354,9 @@ pub fn generate_report(data: &ReportData) -> Result, anyhow::Error> { let col_widths = [40.0, 25.0, 20.0, 30.0, 25.0, 30.0]; w.table_row( - &["Category", "Score", "Weight", "Weighted", "Risk", "Criteria"], + &[ + "Category", "Score", "Weight", "Weighted", "Risk", "Criteria", + ], &col_widths, true, ); From 207f50a0fd84598a715f3c95c927045af370ce0b Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 7 Apr 2026 17:09:44 +0200 Subject: [PATCH 4/7] fix(risk-assessment): collapse nested if-let blocks for clippy Collapse nested if-let for threat_scenarios and as_array() into a single if-let chain to satisfy clippy::collapsible_if. Implements JIRAPLAY-1429 Assisted-by: Claude Code --- .../risk_assessment/service/report_generator.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/fundamental/src/risk_assessment/service/report_generator.rs b/modules/fundamental/src/risk_assessment/service/report_generator.rs index badc6134c..6ddb2af08 100644 --- a/modules/fundamental/src/risk_assessment/service/report_generator.rs +++ b/modules/fundamental/src/risk_assessment/service/report_generator.rs @@ -510,13 +510,13 @@ pub fn generate_report(data: &ReportData) -> Result, anyhow::Error> { w.subheading(&cr.criterion); // Threat scenarios - if let Some(scenarios) = details.get("threat_scenarios") { - if let Some(arr) = scenarios.as_array() { - w.bold_line("Threat Scenarios:"); - for scenario in arr { - if let Some(s) = scenario.as_str() { - w.bullet(s); - } + if let Some(scenarios) = details.get("threat_scenarios") + && let Some(arr) = scenarios.as_array() + { + w.bold_line("Threat Scenarios:"); + for scenario in arr { + if let Some(s) = scenario.as_str() { + w.bullet(s); } } } From e883c61de26e5717efdf4ed7dc99b3bd8b8ab7a3 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Tue, 7 Apr 2026 19:18:19 +0200 Subject: [PATCH 5/7] chore(risk-assessment): regenerate OpenAPI spec for report endpoint Run cargo xtask openapi to include the new generateRiskAssessmentReport endpoint in the spec. Implements JIRAPLAY-1430 Assisted-by: Claude Code --- openapi.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index e7d1477f0..f1915fc85 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2494,6 +2494,32 @@ paths: description: The user authenticated, but not authorized for this operation '404': description: The risk assessment was not found + /api/v2/risk-assessment/{id}/report: + get: + tags: + - risk-assessment + summary: Generate and download a PDF report for a risk assessment + operationId: generateRiskAssessmentReport + parameters: + - name: id + in: path + description: The ID of the risk assessment + required: true + schema: + type: string + responses: + '200': + description: The generated PDF report + content: + application/pdf: {} + '400': + description: The request was not valid + '401': + description: The user was not authenticated + '403': + description: The user authenticated, but not authorized for this operation + '404': + description: The risk assessment was not found /api/v2/risk-assessment/{id}/results: get: tags: From 5b4b14cb7aad66e004754c6c410c1c5f900b7c61 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Wed, 8 Apr 2026 20:26:21 +0200 Subject: [PATCH 6/7] feat(risk-assessment): redesign PDF report with 5-section layout and completeness score Redesign the PDF report to match the SAR Completeness Report Viewer: - Section 1: Overall Rating with completeness-based score - Section 2: Criteria Summary Table - Section 3: Risk Assessments with likelihood/impact/threat scenarios - Section 4: Risk Prioritization with critical gaps and top risks - Section 5: Criteria Assessments with documented/gaps/recommendations Replace weighted NIST score with completeness formula: (complete * 1.0 + partial * 0.5) / total Add risk_prioritization JSONB column to risk_assessment_document via migration. Store LLM risk_prioritization during document processing. Implements JIRAPLAY-1432 Assisted-by: Claude Code --- entity/src/risk_assessment_document.rs | 2 + migration/src/lib.rs | 2 + .../src/m0002180_add_risk_prioritization.rs | 41 ++ .../service/document_processor.rs | 22 +- .../src/risk_assessment/service/mod.rs | 23 +- .../service/report_generator.rs | 459 +++++++++++------- .../src/risk_assessment/service/scoring.rs | 199 ++++++-- .../service/test_document_processor.rs | 1 + 8 files changed, 529 insertions(+), 220 deletions(-) create mode 100644 migration/src/m0002180_add_risk_prioritization.rs diff --git a/entity/src/risk_assessment_document.rs b/entity/src/risk_assessment_document.rs index 6285ff4d4..263b0e0d3 100644 --- a/entity/src/risk_assessment_document.rs +++ b/entity/src/risk_assessment_document.rs @@ -13,6 +13,8 @@ pub struct Model { pub processed: bool, pub uploaded_at: OffsetDateTime, + + pub risk_prioritization: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/migration/src/lib.rs b/migration/src/lib.rs index d3e9814af..f14ec46fb 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -56,6 +56,7 @@ mod m0002140_p2p_right_index; mod m0002150_fix_advisory_labels_index; mod m0002160_fix_ref_fk; mod m0002170_create_risk_assessment; +mod m0002180_add_risk_prioritization; pub trait MigratorExt: Send { fn build_migrations() -> Migrations; @@ -128,6 +129,7 @@ impl MigratorExt for Migrator { .normal(m0002150_fix_advisory_labels_index::Migration) .normal(m0002160_fix_ref_fk::Migration) .normal(m0002170_create_risk_assessment::Migration) + .normal(m0002180_add_risk_prioritization::Migration) } } diff --git a/migration/src/m0002180_add_risk_prioritization.rs b/migration/src/m0002180_add_risk_prioritization.rs new file mode 100644 index 000000000..44a255910 --- /dev/null +++ b/migration/src/m0002180_add_risk_prioritization.rs @@ -0,0 +1,41 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(RiskAssessmentDocument::Table) + .add_column_if_not_exists( + ColumnDef::new(RiskAssessmentDocument::RiskPrioritization).json_binary(), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(RiskAssessmentDocument::Table) + .drop_column(RiskAssessmentDocument::RiskPrioritization) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum RiskAssessmentDocument { + Table, + RiskPrioritization, +} diff --git a/modules/fundamental/src/risk_assessment/service/document_processor.rs b/modules/fundamental/src/risk_assessment/service/document_processor.rs index f4eaf773b..ba361ec88 100644 --- a/modules/fundamental/src/risk_assessment/service/document_processor.rs +++ b/modules/fundamental/src/risk_assessment/service/document_processor.rs @@ -4,7 +4,7 @@ use sea_orm::{ActiveModelTrait, ConnectionTrait, Set}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::Path; -use trustify_entity::risk_assessment_criteria; +use trustify_entity::{risk_assessment_criteria, risk_assessment_document}; use uuid::Uuid; use super::llm_config::LlmConfig; @@ -51,7 +51,8 @@ pub struct RiskAssessmentEntry { pub struct SarEvaluationResponse { pub criteria_assessments: Vec, pub risk_assessments: Vec, - // risk_prioritization is present in the schema but not stored per-criterion. + /// Risk prioritization data from the LLM (summary, critical gaps, top risks). + pub risk_prioritization: Option, } /// Extract text content from a PDF file. @@ -173,3 +174,20 @@ pub async fn store_criteria_results( Ok(ids) } + +/// Update the risk_assessment_document record with the risk_prioritization JSON. +pub async fn store_risk_prioritization( + document_id: Uuid, + risk_prioritization: Option<&Value>, + db: &impl ConnectionTrait, +) -> Result<(), Error> { + if let Some(rp) = risk_prioritization { + let doc_update = risk_assessment_document::ActiveModel { + id: Set(document_id), + risk_prioritization: Set(Some(rp.clone())), + ..Default::default() + }; + doc_update.update(db).await?; + } + Ok(()) +} diff --git a/modules/fundamental/src/risk_assessment/service/mod.rs b/modules/fundamental/src/risk_assessment/service/mod.rs index b74bfc049..a677d63cc 100644 --- a/modules/fundamental/src/risk_assessment/service/mod.rs +++ b/modules/fundamental/src/risk_assessment/service/mod.rs @@ -175,6 +175,7 @@ impl RiskAssessmentService { source_document_id: Set(source_document_id), processed: Set(false), uploaded_at: Set(OffsetDateTime::now_utc()), + risk_prioritization: Set(None), }; let result = db @@ -319,12 +320,21 @@ impl RiskAssessmentService { .all(db) .await?; + // Collect risk_prioritization from the first document that has one + let mut risk_prioritization: Option = None; + // Build category results for scoring (using existing CriterionResult) let mut scoring_categories = Vec::with_capacity(documents.len()); // Build report categories with enriched fields let mut report_categories = Vec::with_capacity(documents.len()); - for doc in documents { + for doc in &documents { + if risk_prioritization.is_none() + && let Some(ref rp) = doc.risk_prioritization + { + risk_prioritization = Some(rp.clone()); + } + let criteria = risk_assessment_criteria::Entity::find() .filter(risk_assessment_criteria::Column::DocumentId.eq(doc.id)) .all(db) @@ -350,7 +360,7 @@ impl RiskAssessmentService { // Report needs enriched data report_categories.push(report_generator::ReportCategory { - category: doc.category, + category: doc.category.clone(), criteria: criteria .into_iter() .map(|c| { @@ -409,6 +419,7 @@ impl RiskAssessmentService { .unwrap_or_else(|_| "N/A".to_string()), categories: report_categories, scoring: Some(scoring_result), + risk_prioritization, })) } @@ -449,6 +460,14 @@ impl RiskAssessmentService { // Store criteria results let ids = document_processor::store_criteria_results(doc_uuid, &evaluation, db).await?; + // Store risk_prioritization on the document record + document_processor::store_risk_prioritization( + doc_uuid, + evaluation.risk_prioritization.as_ref(), + db, + ) + .await?; + // Mark document as processed let doc_update = risk_assessment_document::ActiveModel { id: Set(doc_uuid), diff --git a/modules/fundamental/src/risk_assessment/service/report_generator.rs b/modules/fundamental/src/risk_assessment/service/report_generator.rs index 6ddb2af08..d45d5c33f 100644 --- a/modules/fundamental/src/risk_assessment/service/report_generator.rs +++ b/modules/fundamental/src/risk_assessment/service/report_generator.rs @@ -17,6 +17,7 @@ pub struct ReportData { pub created_at: String, pub categories: Vec, pub scoring: Option, + pub risk_prioritization: Option, } /// Per-category data including enriched criterion details. @@ -305,183 +306,229 @@ fn wrap_text(text: &str, max_chars: usize) -> Vec { // ── Public API ────────────────────────────────────────────────────────────── +/// Derive a rating label from a 0.0-1.0 score fraction. +fn rating_label(score: f64) -> &'static str { + let pct = score * 100.0; + if pct >= 76.0 { + "Very High" + } else if pct >= 51.0 { + "High" + } else if pct >= 26.0 { + "Moderate" + } else { + "Low" + } +} + /// Generate a PDF report from assessment data. +/// +/// The report contains five sections: +/// 1. Overall Rating +/// 2. Criteria Summary Table +/// 3. Risk Assessments (per-criterion) +/// 4. Risk Prioritization +/// 5. Criteria Assessments (detailed) pub fn generate_report(data: &ReportData) -> Result, anyhow::Error> { - let mut w = ReportWriter::new(&format!("Risk Assessment Report - {}", data.assessment_id))?; + let mut w = ReportWriter::new(&format!("SAR Completeness Report - {}", data.assessment_id))?; + + let (complete, partial, missing) = count_completeness(&data.categories); - // ── Page 1: Executive Summary ─────────────────────────────────────── + // ── Section 1: Overall Rating ────────────────────────────────────── - w.title("Risk Assessment Report"); + w.title("SAR Completeness Report"); w.spacing(2.0); w.key_value("Assessment ID: ", &data.assessment_id); w.key_value("Group ID: ", &data.group_id); w.key_value("Status: ", &data.status); w.key_value("Date: ", &data.created_at); - - w.spacing(3.0); - w.horizontal_rule(); - - // Overall risk score - w.heading("Executive Summary"); + w.spacing(2.0); if let Some(ref scoring) = data.scoring { let score_pct = scoring.overall.score * 100.0; - w.key_value("Overall Risk Score: ", &format!("{:.1}%", score_pct)); - w.key_value("Risk Level: ", &scoring.overall.risk_level); - - if !scoring.overall.missing_categories.is_empty() { - w.spacing(2.0); - w.bold_line("Missing Categories:"); - for cat in &scoring.overall.missing_categories { - w.bullet(cat); - } - } - - // Summary of findings - w.spacing(3.0); - w.subheading("Summary of Findings"); - - let (complete, partial, missing) = count_completeness(&data.categories); - w.key_value("Complete criteria: ", &complete.to_string()); - w.key_value("Partial criteria: ", &partial.to_string()); - w.key_value("Missing criteria: ", &missing.to_string()); - - // Category scores table - if !scoring.categories.is_empty() { - w.spacing(3.0); - w.subheading("Category Scores"); - - let col_widths = [40.0, 25.0, 20.0, 30.0, 25.0, 30.0]; - w.table_row( - &[ - "Category", "Score", "Weight", "Weighted", "Risk", "Criteria", - ], - &col_widths, - true, - ); - w.horizontal_rule(); - - for cat in &scoring.categories { - w.table_row( - &[ - &cat.category, - &format!("{:.1}%", cat.score * 100.0), - &format!("{:.0}%", cat.weight * 100.0), - &format!("{:.1}%", cat.weighted_score * 100.0), - &cat.risk_level, - &cat.criteria_count.to_string(), - ], - &col_widths, - false, - ); - } - } - - // Risk prioritization: top risks - w.spacing(3.0); - w.subheading("Risk Prioritization"); - - let mut top_risks: Vec<(&str, f64, &str)> = Vec::new(); - for cat in &data.categories { - for cr in &cat.criteria { - if cr.completeness != "complete" && cr.score > 0.0 { - top_risks.push((&cr.criterion, cr.score, &cr.risk_level)); - } - } - } - top_risks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - - if top_risks.is_empty() { - w.body("No elevated risks identified."); - } else { - let col_widths = [80.0, 25.0, 30.0]; - w.table_row(&["Criterion", "Score", "Risk Level"], &col_widths, true); - w.horizontal_rule(); - for (name, score, level) in top_risks.iter().take(10) { - w.table_row( - &[name, &format!("{:.1}%", score * 100.0), level], - &col_widths, - false, - ); - } - } + w.key_value("Overall Score: ", &format!("{:.1}%", score_pct)); + w.small_text("Calculated by LLM"); + w.spacing(1.0); + w.key_value("Rating: ", rating_label(scoring.overall.score)); + w.spacing(2.0); + + // Three-column counts + let col_widths = [50.0, 50.0, 50.0]; + w.table_row(&["Complete", "Partial", "Missing"], &col_widths, true); + w.table_row( + &[ + &complete.to_string(), + &partial.to_string(), + &missing.to_string(), + ], + &col_widths, + false, + ); } else { w.body("No scoring data available."); } - // ── Page 2+: Criteria Assessment Details ──────────────────────────── + w.spacing(3.0); + w.horizontal_rule(); - w.new_page(); - w.title("Criteria Assessment Details"); - w.spacing(2.0); + // ── Section 2: Criteria Summary Table ────────────────────────────── - for cat in &data.categories { - w.heading(&format!("Category: {}", cat.category)); + w.heading("Criteria Summary"); + w.spacing(2.0); - // Criteria overview table - let col_widths = [8.0, 62.0, 30.0, 30.0, 25.0]; - w.table_row( - &["#", "Criterion", "Completeness", "Risk Level", "Score"], - &col_widths, - true, - ); - w.horizontal_rule(); + let summary_col_widths = [8.0, 55.0, 28.0, 28.0, 20.0]; + w.table_row( + &["#", "Criterion", "Completeness", "Risk Level", "Score"], + &summary_col_widths, + true, + ); + w.horizontal_rule(); - for (i, cr) in cat.criteria.iter().enumerate() { + let mut criterion_number = 0usize; + for cat in &data.categories { + w.bold_line(&format!("Category: {}", cat.category)); + for cr in &cat.criteria { + criterion_number += 1; w.table_row( &[ - &(i + 1).to_string(), + &criterion_number.to_string(), &cr.criterion, &cr.completeness, &cr.risk_level, - &format!("{:.1}%", cr.score * 100.0), + &format!("{:.1}", cr.score), ], - &col_widths, + &summary_col_widths, false, ); } + } + + w.spacing(3.0); + w.horizontal_rule(); - w.spacing(3.0); + // ── Section 3: Risk Assessments (per-criterion) ──────────────────── - // Detailed breakdown for partial/missing criteria + w.new_page(); + w.heading("Risk Assessments"); + w.spacing(2.0); + + for cat in &data.categories { for cr in &cat.criteria { if cr.completeness == "complete" { continue; } - w.subheading(&cr.criterion); - w.key_value("Completeness: ", &cr.completeness); - w.key_value("Risk Level: ", &cr.risk_level); - w.key_value("Score: ", &format!("{:.1}%", cr.score * 100.0)); + w.subheading(&format!( + "{} - {} | Risk: {} ({:.1})", + cr.criterion, cr.completeness, cr.risk_level, cr.score + )); - if !cr.what_documented.is_empty() { - w.spacing(1.0); - w.bold_line("What is documented:"); - for item in &cr.what_documented { - w.bullet(item); + // Extract from details JSON if available + if let Some(ref details) = cr.details { + // Matrix reference + if let Some(matrix_ref) = details + .get("risk_level") + .and_then(|rl| rl.get("matrix_reference")) + .and_then(|v| v.as_str()) + { + w.key_value("Matrix Reference: ", matrix_ref); } - } - if !cr.gaps.is_empty() { - w.spacing(1.0); - w.bold_line("Gaps identified:"); - for gap in &cr.gaps { - w.bullet(gap); + // Likelihood + if let Some(likelihood) = details.get("likelihood") { + let level = likelihood + .get("level") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let lscore = likelihood + .get("score") + .map(format_json_value) + .unwrap_or_else(|| "N/A".to_string()); + let rationale = likelihood + .get("rationale") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + + w.bold_line("Likelihood:"); + w.key_value(" Level: ", level); + w.key_value(" Score: ", &lscore); + w.key_value(" Rationale: ", rationale); } - } - if let Some(ref impact) = cr.impact_description { - w.spacing(1.0); - w.bold_line("Impact:"); - w.paragraph(impact); - } + // Impact + if let Some(impact) = details.get("impact") { + let level = impact + .get("level") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let iscore = impact + .get("score") + .map(format_json_value) + .unwrap_or_else(|| "N/A".to_string()); + let rationale = impact + .get("rationale") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + + w.bold_line("Impact:"); + w.key_value(" Level: ", level); + w.key_value(" Score: ", &iscore); + w.key_value(" Rationale: ", rationale); + + // Impact Domains + if let Some(domains) = impact.get("domains") { + w.bold_line(" Impact Domains:"); + for domain_name in &[ + "availability", + "confidentiality", + "integrity", + "privacy", + "safety", + ] { + if let Some(val) = domains.get(*domain_name) { + w.key_value( + &format!(" {}: ", domain_name), + &format_json_value(val), + ); + } + } + } + } - if !cr.recommendations.is_empty() { - w.spacing(1.0); - w.bold_line("Recommendations:"); - for rec in &cr.recommendations { - w.bullet(&format!("[{}] {}", rec.priority, rec.action)); + // Threat Scenarios + if let Some(scenarios) = details.get("threat_scenarios") + && let Some(arr) = scenarios.as_array() + { + w.spacing(1.0); + w.bold_line("Threat Scenarios:"); + for scenario in arr { + if let Some(obj) = scenario.as_object() { + let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("N/A"); + let source = obj + .get("threat_source") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let event = obj + .get("threat_event") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let vuln = obj + .get("vulnerability") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let attack_path = obj + .get("attack_path") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + + w.bullet(&format!("Name: {name}")); + w.body(&format!(" Source: {source}")); + w.body(&format!(" Event: {event}")); + w.body(&format!(" Vulnerability: {vuln}")); + w.body(&format!(" Attack Path: {attack_path}")); + w.spacing(1.0); + } + } } } @@ -490,54 +537,138 @@ pub fn generate_report(data: &ReportData) -> Result, anyhow::Error> { } } - // ── Final Page: Risk Assessment Matrix ────────────────────────────── + // ── Section 4: Risk Prioritization ───────────────────────────────── w.new_page(); - w.title("Risk Assessment Matrix"); + w.heading("Risk Prioritization"); w.spacing(2.0); - w.small_text("Based on NIST 800-30 risk assessment methodology."); + if let Some(ref rp) = data.risk_prioritization { + // Risk Level Summary + if let Some(summary) = rp.get("summary") { + w.subheading("Risk Level Summary"); + let levels = [ + ("Very High", "very_high_count"), + ("High", "high_count"), + ("Moderate", "moderate_count"), + ("Low", "low_count"), + ("Very Low", "very_low_count"), + ]; + for (label, key) in &levels { + let count = summary.get(*key).and_then(|v| v.as_i64()).unwrap_or(0); + w.key_value(&format!(" {}: ", label), &count.to_string()); + } + w.spacing(2.0); + } + + // Critical Gaps + if let Some(gaps) = rp.get("critical_gaps") + && let Some(arr) = gaps.as_array() + { + w.subheading("Critical Gaps"); + if arr.is_empty() { + w.body("No critical gaps identified."); + } else { + let numbers: Vec = arr + .iter() + .filter_map(|v| v.as_i64().map(|n| format!("Criterion {n}"))) + .collect(); + w.body(&numbers.join(", ")); + } + w.spacing(2.0); + } + + // Top Risks + if let Some(top_risks) = rp.get("top_risks") + && let Some(arr) = top_risks.as_array() + { + w.subheading("Top Risks"); + for risk in arr { + if let Some(obj) = risk.as_object() { + let rank = obj.get("rank").and_then(|v| v.as_i64()).unwrap_or(0); + let name = obj + .get("criterion_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let level = obj + .get("risk_level") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let rscore = obj + .get("risk_score") + .map(format_json_value) + .unwrap_or_else(|| "N/A".to_string()); + let gap = obj + .get("gap_summary") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let action = obj + .get("priority_action") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + + w.bold_line(&format!("#{rank}. {name}")); + w.key_value(" Risk Level: ", level); + w.key_value(" Score: ", &rscore); + w.key_value(" Gap Summary: ", gap); + w.key_value(" Priority Action: ", action); + w.spacing(1.0); + } + } + } + } else { + w.body("No risk prioritization data available."); + } + w.spacing(3.0); + w.horizontal_rule(); + + // ── Section 5: Criteria Assessments (detailed) ───────────────────── + + w.new_page(); + w.heading("Criteria Assessments"); + w.spacing(2.0); for cat in &data.categories { for cr in &cat.criteria { - if cr.completeness == "complete" { - continue; - } + w.subheading(&format!("{} - {}", cr.criterion, cr.completeness)); - // Extract risk details from the details JSON if available - if let Some(ref details) = cr.details { - w.subheading(&cr.criterion); + if let Some(ref impact) = cr.impact_description { + w.bold_line("IMPACT:"); + w.paragraph(impact); + w.spacing(1.0); + } - // Threat scenarios - if let Some(scenarios) = details.get("threat_scenarios") - && let Some(arr) = scenarios.as_array() - { - w.bold_line("Threat Scenarios:"); - for scenario in arr { - if let Some(s) = scenario.as_str() { - w.bullet(s); - } - } + if !cr.what_documented.is_empty() { + w.bold_line("WHAT'S DOCUMENTED:"); + for item in &cr.what_documented { + w.bullet(item); } + w.spacing(1.0); + } - // Likelihood and impact - if let Some(likelihood) = details.get("likelihood") { - w.key_value("Likelihood: ", &format_json_value(likelihood)); - } - if let Some(impact) = details.get("impact") { - w.key_value("Impact: ", &format_json_value(impact)); - } - if let Some(risk_level) = details.get("risk_level") { - w.key_value("Risk Level: ", &format_json_value(risk_level)); + if !cr.gaps.is_empty() { + w.bold_line("GAPS:"); + for gap in &cr.gaps { + w.bullet(gap); } + w.spacing(1.0); + } - w.spacing(2.0); + if !cr.recommendations.is_empty() { + w.bold_line("RECOMMENDATIONS:"); + for rec in &cr.recommendations { + w.bullet(&format!("[{}] {}", rec.priority.to_uppercase(), rec.action)); + } + w.spacing(1.0); } + + w.horizontal_rule(); + w.spacing(1.0); } } - // NIST reference + // NIST reference footer w.spacing(3.0); w.horizontal_rule(); w.small_text("Risk levels follow NIST 800-30 classification:"); diff --git a/modules/fundamental/src/risk_assessment/service/scoring.rs b/modules/fundamental/src/risk_assessment/service/scoring.rs index b804d07b3..cf6a7249d 100644 --- a/modules/fundamental/src/risk_assessment/service/scoring.rs +++ b/modules/fundamental/src/risk_assessment/service/scoring.rs @@ -43,28 +43,38 @@ fn compute_category_score(criteria_scores: &[f64]) -> f64 { sum / criteria_scores.len() as f64 } -/// Compute the weighted overall score from per-category scores. -/// Only categories with actual data are included. Weights are re-normalized -/// to account for missing categories. -fn compute_weighted_score(category_scores: &HashMap) -> f64 { - let weights: HashMap<&str, f64> = CATEGORY_WEIGHTS.iter().copied().collect(); - - let mut weighted_sum = 0.0; - let mut total_weight = 0.0; +/// Compute the completeness-based overall score from all criteria across categories. +/// +/// The score is a fraction (0.0–1.0): +/// (complete_count * 1.0 + partial_count * 0.5) / total_criteria +/// +/// This replaces the previous weighted-average approach. Per-category +/// `CategoryScore` values (weight, weighted_score) remain unchanged so +/// downstream consumers still have per-category detail. +fn compute_completeness_score(categories: &[CategoryResult]) -> f64 { + let mut complete = 0usize; + let mut partial = 0usize; + let mut total = 0usize; - for (category, &score) in category_scores { - let weight = weights.get(category.as_str()).copied().unwrap_or(0.0); - if weight > 0.0 { - weighted_sum += score * weight; - total_weight += weight; + for cat in categories { + if !cat.processed || cat.criteria.is_empty() { + continue; + } + for criterion in &cat.criteria { + total += 1; + match criterion.completeness.as_str() { + "complete" => complete += 1, + "partial" => partial += 1, + _ => {} // "missing" contributes 0 + } } } - if total_weight > 0.0 { - weighted_sum / total_weight - } else { - 0.0 + if total == 0 { + return 0.0; } + + (complete as f64 * 1.0 + partial as f64 * 0.5) / total as f64 } /// Compute the full scoring result from category results. @@ -73,7 +83,7 @@ pub fn compute_scoring_result(categories: &[CategoryResult]) -> ScoringResult { let weights: HashMap<&str, f64> = CATEGORY_WEIGHTS.iter().copied().collect(); // Compute per-category scores from processed categories with criteria - let mut category_scores_map: HashMap = HashMap::new(); + let mut present_categories: Vec = Vec::new(); let mut scored_categories: Vec = Vec::new(); for cat in categories { @@ -86,7 +96,7 @@ pub fn compute_scoring_result(categories: &[CategoryResult]) -> ScoringResult { let weight = weights.get(cat.category.as_str()).copied().unwrap_or(0.0); let weighted = avg_score * weight; - category_scores_map.insert(cat.category.clone(), avg_score); + present_categories.push(cat.category.clone()); scored_categories.push(CategoryScore { category: cat.category.clone(), @@ -99,16 +109,14 @@ pub fn compute_scoring_result(categories: &[CategoryResult]) -> ScoringResult { } // Identify missing categories - let present: std::collections::HashSet<&str> = - category_scores_map.keys().map(|s| s.as_str()).collect(); let missing: Vec = ALL_CATEGORIES .iter() - .filter(|c| !present.contains(**c)) + .filter(|c| !present_categories.iter().any(|p| p == **c)) .map(|c| c.to_string()) .collect(); - // Compute overall weighted score (re-normalized for available categories) - let overall = compute_weighted_score(&category_scores_map); + // Compute overall completeness-based score (0.0–1.0 fraction) + let overall = compute_completeness_score(categories); ScoringResult { overall: OverallScore { @@ -125,17 +133,25 @@ mod tests { use super::*; use crate::risk_assessment::model::{CategoryResult, CriterionResult}; - fn make_criterion(name: &str, score: f64) -> CriterionResult { + fn make_criterion_with_completeness( + name: &str, + score: f64, + completeness: &str, + ) -> CriterionResult { CriterionResult { id: "test-id".to_string(), criterion: name.to_string(), - completeness: "complete".to_string(), + completeness: completeness.to_string(), risk_level: classify_risk_level(score).to_string(), score, details: None, } } + fn make_criterion(name: &str, score: f64) -> CriterionResult { + make_criterion_with_completeness(name, score, "complete") + } + fn make_category(name: &str, scores: &[f64]) -> CategoryResult { CategoryResult { category: name.to_string(), @@ -149,53 +165,91 @@ mod tests { } } + fn make_category_with_completeness( + name: &str, + criteria: Vec<(&str, f64, &str)>, + ) -> CategoryResult { + CategoryResult { + category: name.to_string(), + document_id: "doc-id".to_string(), + processed: true, + criteria: criteria + .into_iter() + .enumerate() + .map(|(i, (completeness, score, _risk))| { + make_criterion_with_completeness(&format!("criterion_{i}"), score, completeness) + }) + .collect(), + } + } + #[test] - fn test_weighted_score_all_categories() { + fn test_completeness_score_all_complete() { + // All criteria are "complete" (default from make_category) + // 9 criteria total, all complete + // Score = (9 * 1.0) / 9 = 1.0 let categories = vec![ - make_category("pt", &[0.8, 0.6]), // avg = 0.7, weight = 0.30 - make_category("vex", &[0.5]), // avg = 0.5, weight = 0.20 - make_category("sar", &[0.9, 0.7, 0.8]), // avg = 0.8, weight = 0.20 - make_category("dast", &[0.4]), // avg = 0.4, weight = 0.15 - make_category("sast", &[0.6]), // avg = 0.6, weight = 0.10 - make_category("threat_model", &[0.3]), // avg = 0.3, weight = 0.05 + make_category("pt", &[0.8, 0.6]), // 2 complete + make_category("vex", &[0.5]), // 1 complete + make_category("sar", &[0.9, 0.7, 0.8]), // 3 complete + make_category("dast", &[0.4]), // 1 complete + make_category("sast", &[0.6]), // 1 complete + make_category("threat_model", &[0.3]), // 1 complete ]; let result = compute_scoring_result(&categories); - // All categories present, no re-normalization needed - // Expected: 0.7*0.30 + 0.5*0.20 + 0.8*0.20 + 0.4*0.15 + 0.6*0.10 + 0.3*0.05 - // = 0.21 + 0.10 + 0.16 + 0.06 + 0.06 + 0.015 = 0.605 - let expected = 0.605; + // All 9 criteria are "complete": (9*1.0)/9 = 1.0 + let expected = 1.0; assert!( (result.overall.score - expected).abs() < 1e-10, "Expected overall score {expected}, got {}", result.overall.score ); - assert_eq!(result.overall.risk_level, "High"); + assert_eq!(result.overall.risk_level, "Very High"); assert!(result.overall.missing_categories.is_empty()); assert_eq!(result.categories.len(), 6); } #[test] - fn test_weighted_score_missing_categories() { - // Only PT and SAR available - let categories = vec![ - make_category("pt", &[0.8]), // avg = 0.8, weight = 0.30 - make_category("sar", &[0.6]), // avg = 0.6, weight = 0.20 - ]; + fn test_completeness_score_mixed() { + // 2 complete, 1 partial, 1 missing = (2*1.0 + 1*0.5) / 4 = 2.5/4 = 0.625 + let categories = vec![make_category_with_completeness( + "sar", + vec![ + ("complete", 0.0, "Low"), + ("complete", 0.0, "Low"), + ("partial", 0.5, "Moderate"), + ("missing", 0.8, "High"), + ], + )]; let result = compute_scoring_result(&categories); - // Re-normalized: total available weight = 0.30 + 0.20 = 0.50 - // Weighted sum = 0.8*0.30 + 0.6*0.20 = 0.24 + 0.12 = 0.36 - // Re-normalized overall = 0.36 / 0.50 = 0.72 - let expected = 0.72; + let expected = 0.625; assert!( (result.overall.score - expected).abs() < 1e-10, "Expected overall score {expected}, got {}", result.overall.score ); assert_eq!(result.overall.risk_level, "High"); + } + + #[test] + fn test_completeness_score_missing_categories() { + // Only PT and SAR available, both all complete + // 2 criteria total, both complete: (2*1.0)/2 = 1.0 + let categories = vec![make_category("pt", &[0.8]), make_category("sar", &[0.6])]; + + let result = compute_scoring_result(&categories); + + let expected = 1.0; + assert!( + (result.overall.score - expected).abs() < 1e-10, + "Expected overall score {expected}, got {}", + result.overall.score + ); + assert_eq!(result.overall.risk_level, "Very High"); assert_eq!(result.overall.missing_categories.len(), 4); assert!( result @@ -225,19 +279,19 @@ mod tests { #[test] fn test_risk_level_boundaries() { - // Low: 0–25% + // Low: 0-25% assert_eq!(classify_risk_level(0.0), "Low"); assert_eq!(classify_risk_level(0.25), "Low"); - // Moderate: 26–50% + // Moderate: 26-50% assert_eq!(classify_risk_level(0.26), "Moderate"); assert_eq!(classify_risk_level(0.50), "Moderate"); - // High: 51–75% + // High: 51-75% assert_eq!(classify_risk_level(0.51), "High"); assert_eq!(classify_risk_level(0.75), "High"); - // Very High: 76–100% + // Very High: 76-100% assert_eq!(classify_risk_level(0.76), "Very High"); assert_eq!(classify_risk_level(1.0), "Very High"); } @@ -274,5 +328,46 @@ mod tests { .missing_categories .contains(&"sar".to_string()) ); + // 1 complete criterion: (1*1.0)/1 = 1.0 + assert!( + (result.overall.score - 1.0).abs() < 1e-10, + "Expected overall score 1.0, got {}", + result.overall.score + ); + } + + #[test] + fn test_completeness_score_all_missing() { + // All criteria are "missing" + let categories = vec![make_category_with_completeness( + "sar", + vec![("missing", 0.9, "Very High"), ("missing", 0.7, "High")], + )]; + + let result = compute_scoring_result(&categories); + + // (0*1.0 + 0*0.5) / 2 = 0.0 + assert!((result.overall.score).abs() < f64::EPSILON); + assert_eq!(result.overall.risk_level, "Low"); + } + + #[test] + fn test_completeness_score_all_partial() { + // All criteria are "partial" + let categories = vec![make_category_with_completeness( + "sar", + vec![("partial", 0.5, "Moderate"), ("partial", 0.6, "High")], + )]; + + let result = compute_scoring_result(&categories); + + // (0*1.0 + 2*0.5) / 2 = 0.5 + let expected = 0.5; + assert!( + (result.overall.score - expected).abs() < 1e-10, + "Expected overall score {expected}, got {}", + result.overall.score + ); + assert_eq!(result.overall.risk_level, "Moderate"); } } diff --git a/modules/fundamental/src/risk_assessment/service/test_document_processor.rs b/modules/fundamental/src/risk_assessment/service/test_document_processor.rs index 0c3e9dd7f..379bbcc12 100644 --- a/modules/fundamental/src/risk_assessment/service/test_document_processor.rs +++ b/modules/fundamental/src/risk_assessment/service/test_document_processor.rs @@ -100,6 +100,7 @@ async fn test_store_criteria_results(ctx: &TrustifyContext) -> Result<(), anyhow let evaluation = SarEvaluationResponse { criteria_assessments, risk_assessments, + risk_prioritization: None, }; // Store and verify From 4e63ee7c99dd049294dd82112435b2a493ba9895 Mon Sep 17 00:00:00 2001 From: mrizzi Date: Wed, 8 Apr 2026 21:17:43 +0200 Subject: [PATCH 7/7] fix(risk-assessment): merge risk_prioritization and processed updates into single write Remove store_risk_prioritization() and merge its logic into the existing "mark as processed" update in process_document(). This eliminates two sequential UPDATEs on the same row and ensures risk_prioritization is always written (including None on re-runs), preventing stale data from persisting. Implements JIRAPLAY-1433 Assisted-by: Claude Code --- .../service/document_processor.rs | 19 +------------------ .../src/risk_assessment/service/mod.rs | 11 ++--------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/modules/fundamental/src/risk_assessment/service/document_processor.rs b/modules/fundamental/src/risk_assessment/service/document_processor.rs index ba361ec88..ef7506bba 100644 --- a/modules/fundamental/src/risk_assessment/service/document_processor.rs +++ b/modules/fundamental/src/risk_assessment/service/document_processor.rs @@ -4,7 +4,7 @@ use sea_orm::{ActiveModelTrait, ConnectionTrait, Set}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::Path; -use trustify_entity::{risk_assessment_criteria, risk_assessment_document}; +use trustify_entity::risk_assessment_criteria; use uuid::Uuid; use super::llm_config::LlmConfig; @@ -174,20 +174,3 @@ pub async fn store_criteria_results( Ok(ids) } - -/// Update the risk_assessment_document record with the risk_prioritization JSON. -pub async fn store_risk_prioritization( - document_id: Uuid, - risk_prioritization: Option<&Value>, - db: &impl ConnectionTrait, -) -> Result<(), Error> { - if let Some(rp) = risk_prioritization { - let doc_update = risk_assessment_document::ActiveModel { - id: Set(document_id), - risk_prioritization: Set(Some(rp.clone())), - ..Default::default() - }; - doc_update.update(db).await?; - } - Ok(()) -} diff --git a/modules/fundamental/src/risk_assessment/service/mod.rs b/modules/fundamental/src/risk_assessment/service/mod.rs index a677d63cc..ed9746344 100644 --- a/modules/fundamental/src/risk_assessment/service/mod.rs +++ b/modules/fundamental/src/risk_assessment/service/mod.rs @@ -460,18 +460,11 @@ impl RiskAssessmentService { // Store criteria results let ids = document_processor::store_criteria_results(doc_uuid, &evaluation, db).await?; - // Store risk_prioritization on the document record - document_processor::store_risk_prioritization( - doc_uuid, - evaluation.risk_prioritization.as_ref(), - db, - ) - .await?; - - // Mark document as processed + // Mark document as processed and store risk_prioritization in a single update let doc_update = risk_assessment_document::ActiveModel { id: Set(doc_uuid), processed: Set(true), + risk_prioritization: Set(evaluation.risk_prioritization.clone()), ..Default::default() }; doc_update.update(db).await?;