Skip to main content

datasynth_banking/labels/
narrative_generator.rs

1//! Case narrative generator for SAR reports.
2
3use chrono::NaiveDate;
4use datasynth_core::models::banking::{AmlTypology, LaunderingStage, Sophistication};
5use rand::prelude::*;
6use rand_chacha::ChaCha8Rng;
7use serde::{Deserialize, Serialize};
8
9use crate::models::{
10    AmlScenario, CaseNarrative, CaseRecommendation, RedFlag, RedFlagCategory, ViolatedExpectation,
11};
12
13/// Narrative generator for AML cases.
14pub struct NarrativeGenerator {
15    rng: ChaCha8Rng,
16}
17
18impl NarrativeGenerator {
19    /// Create a new narrative generator.
20    pub fn new(seed: u64) -> Self {
21        Self {
22            rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(7000)),
23        }
24    }
25
26    /// Generate narrative for an AML scenario.
27    pub fn generate(&mut self, scenario: &AmlScenario) -> CaseNarrative {
28        let storyline = self.generate_storyline(scenario);
29        let mut narrative = CaseNarrative::new(&storyline);
30
31        // Add evidence points
32        for evidence in self.generate_evidence_points(scenario) {
33            narrative.add_evidence(&evidence);
34        }
35
36        // Add violated expectations
37        for ve in self.generate_violated_expectations(scenario) {
38            narrative.add_violated_expectation(ve);
39        }
40
41        // Add red flags
42        let today = chrono::Utc::now().date_naive();
43        for rf in self.generate_red_flags(scenario, today) {
44            narrative.add_red_flag(rf);
45        }
46
47        // Set recommendation
48        let recommendation = self.recommend_action(scenario);
49        narrative.with_recommendation(recommendation)
50    }
51
52    /// Generate main storyline.
53    fn generate_storyline(&mut self, scenario: &AmlScenario) -> String {
54        let typology_desc = self.typology_description(scenario.typology);
55        let sophistication_desc = self.sophistication_description(scenario.sophistication);
56        let stage_desc = self.stages_description(&scenario.stages);
57
58        format!(
59            "Investigation identified {} activity pattern involving {} sophistication level. \
60             The activity appears consistent with the {} stage(s) of money laundering. \
61             Analysis period: {} to {}. \
62             Total {} accounts involved in the suspicious activity cluster.",
63            typology_desc,
64            sophistication_desc,
65            stage_desc,
66            scenario.start_date.format("%Y-%m-%d"),
67            scenario.end_date.format("%Y-%m-%d"),
68            scenario.involved_accounts.len()
69        )
70    }
71
72    /// Get typology description.
73    fn typology_description(&self, typology: AmlTypology) -> &'static str {
74        match typology {
75            AmlTypology::Structuring => "cash deposit structuring",
76            AmlTypology::Smurfing => "smurfing/structuring",
77            AmlTypology::CuckooSmurfing => "cuckoo smurfing",
78            AmlTypology::FunnelAccount => "funnel account aggregation",
79            AmlTypology::ConcentrationAccount => "concentration account abuse",
80            AmlTypology::PouchActivity => "pouch activity",
81            AmlTypology::Layering => "complex layering chain",
82            AmlTypology::RapidMovement => "rapid fund movement",
83            AmlTypology::ShellCompany => "shell company network",
84            AmlTypology::RoundTripping => "round-tripping fund movement",
85            AmlTypology::TradeBasedML => "trade-based money laundering",
86            AmlTypology::InvoiceManipulation => "invoice manipulation",
87            AmlTypology::MoneyMule => "money mule operation",
88            AmlTypology::RomanceScam => "romance scam activity",
89            AmlTypology::AdvanceFeeFraud => "advance fee fraud",
90            AmlTypology::RealEstateIntegration => "real estate-based integration",
91            AmlTypology::LuxuryGoods => "luxury goods integration",
92            AmlTypology::CasinoIntegration => "casino-based integration",
93            AmlTypology::CryptoIntegration => "cryptocurrency integration",
94            AmlTypology::AccountTakeover => "account takeover fraud",
95            AmlTypology::SyntheticIdentity => "synthetic identity fraud",
96            AmlTypology::FirstPartyFraud => "first-party fraud",
97            AmlTypology::AuthorizedPushPayment => "authorized push payment fraud",
98            AmlTypology::BusinessEmailCompromise => "business email compromise",
99            AmlTypology::FakeVendor => "fake vendor fraud",
100            AmlTypology::TerroristFinancing => "potential terrorist financing",
101            AmlTypology::SanctionsEvasion => "potential sanctions evasion",
102            AmlTypology::TaxEvasion => "tax evasion",
103            AmlTypology::HumanTrafficking => "human trafficking related",
104            AmlTypology::DrugTrafficking => "drug trafficking related",
105            AmlTypology::Corruption => "corruption/PEP-related activity",
106            AmlTypology::Custom(_) => "custom suspicious pattern",
107        }
108    }
109
110    /// Get sophistication description.
111    fn sophistication_description(&self, sophistication: Sophistication) -> &'static str {
112        match sophistication {
113            Sophistication::Basic => "basic/amateur",
114            Sophistication::Standard => "standard/organized",
115            Sophistication::Professional => "professional/systematic",
116            Sophistication::Advanced => "advanced/coordinated network",
117            Sophistication::StateLevel => "state-level/highly sophisticated",
118        }
119    }
120
121    /// Get stages description.
122    fn stages_description(&self, stages: &[LaunderingStage]) -> String {
123        if stages.is_empty() {
124            return "unclassified".to_string();
125        }
126
127        stages
128            .iter()
129            .map(|s| match s {
130                LaunderingStage::Placement => "placement",
131                LaunderingStage::Layering => "layering",
132                LaunderingStage::Integration => "integration",
133                LaunderingStage::NotApplicable => "N/A",
134            })
135            .collect::<Vec<_>>()
136            .join("/")
137    }
138
139    /// Generate evidence points.
140    fn generate_evidence_points(&mut self, scenario: &AmlScenario) -> Vec<String> {
141        let mut points = Vec::new();
142
143        // Add typology-specific evidence
144        match scenario.typology {
145            AmlTypology::Structuring | AmlTypology::Smurfing => {
146                let deposit_count = self.rng.gen_range(5..20);
147                let threshold = 10_000;
148                points.push(format!(
149                    "{} cash deposits below ${} reporting threshold within {} days",
150                    deposit_count,
151                    threshold,
152                    (scenario.end_date - scenario.start_date).num_days()
153                ));
154                points.push("Deposits made at multiple branch locations".to_string());
155                points.push("Immediate consolidation transfer following deposits".to_string());
156            }
157            AmlTypology::FunnelAccount => {
158                let source_count = self.rng.gen_range(8..25);
159                points.push(format!(
160                    "{} unrelated inbound transfers from different sources",
161                    source_count
162                ));
163                points.push("Rapid outward transfers within 24-48 hours of receipt".to_string());
164                points.push("No business relationship with senders documented".to_string());
165            }
166            AmlTypology::Layering => {
167                let hop_count = self.rng.gen_range(3..8);
168                points.push(format!(
169                    "Funds traced through {} intermediary accounts",
170                    hop_count
171                ));
172                points.push("Systematic splitting and recombination of amounts".to_string());
173                points.push("Time delays inserted between hops to avoid detection".to_string());
174            }
175            AmlTypology::MoneyMule => {
176                points.push("New account with limited prior transaction history".to_string());
177                points.push("Pattern of receive-and-forward within short timeframe".to_string());
178                points.push(
179                    "Cash withdrawals/wire transfers immediately following deposits".to_string(),
180                );
181                points.push("Small retention amount consistent with mule compensation".to_string());
182            }
183            _ => {
184                points.push("Unusual transaction pattern identified".to_string());
185                points.push("Activity inconsistent with stated account purpose".to_string());
186            }
187        }
188
189        // Add sophistication-based evidence
190        if matches!(
191            scenario.sophistication,
192            Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
193        ) {
194            points.push("Use of intermediary entities to obscure beneficial ownership".to_string());
195        }
196        if matches!(
197            scenario.sophistication,
198            Sophistication::Advanced | Sophistication::StateLevel
199        ) {
200            points.push(
201                "Coordinated activity across multiple accounts and jurisdictions".to_string(),
202            );
203        }
204
205        points
206    }
207
208    /// Generate violated expectations.
209    fn generate_violated_expectations(
210        &mut self,
211        scenario: &AmlScenario,
212    ) -> Vec<ViolatedExpectation> {
213        let mut violations = Vec::new();
214
215        // Transaction frequency violation
216        let expected_freq = self.rng.gen_range(5..15);
217        let actual_freq = self.rng.gen_range(25..100);
218        violations.push(ViolatedExpectation::new(
219            "Monthly transaction count",
220            &format!("{}", expected_freq),
221            &format!("{}", actual_freq),
222            (actual_freq as f64 - expected_freq as f64) / expected_freq as f64 * 100.0,
223        ));
224
225        // Cash activity violation
226        if matches!(
227            scenario.typology,
228            AmlTypology::Structuring | AmlTypology::Smurfing | AmlTypology::MoneyMule
229        ) {
230            let expected_cash = self.rng.gen_range(5..15);
231            let actual_cash = self.rng.gen_range(40..80);
232            violations.push(ViolatedExpectation::new(
233                "Cash activity percentage",
234                &format!("{}%", expected_cash),
235                &format!("{}%", actual_cash),
236                (actual_cash - expected_cash) as f64,
237            ));
238        }
239
240        // Volume violation
241        let expected_vol = self.rng.gen_range(5000..15000);
242        let actual_vol = self.rng.gen_range(50000..250000);
243        violations.push(ViolatedExpectation::new(
244            "Monthly transaction volume",
245            &format!("${}", expected_vol),
246            &format!("${}", actual_vol),
247            (actual_vol as f64 - expected_vol as f64) / expected_vol as f64 * 100.0,
248        ));
249
250        violations
251    }
252
253    /// Generate red flags.
254    fn generate_red_flags(&mut self, scenario: &AmlScenario, date: NaiveDate) -> Vec<RedFlag> {
255        let mut flags = Vec::new();
256
257        // Common red flags
258        flags.push(RedFlag::new(
259            RedFlagCategory::ActivityPattern,
260            "Funds moved rapidly through account with minimal dwell time",
261            8,
262            date,
263        ));
264
265        // Typology-specific red flags
266        match scenario.typology {
267            AmlTypology::Structuring | AmlTypology::Smurfing => {
268                flags.push(RedFlag::new(
269                    RedFlagCategory::TransactionCharacteristic,
270                    "Multiple transactions just below $10,000 reporting threshold",
271                    9,
272                    date,
273                ));
274            }
275            AmlTypology::FunnelAccount => {
276                flags.push(RedFlag::new(
277                    RedFlagCategory::ThirdParty,
278                    "Multiple unrelated senders with no apparent business connection",
279                    7,
280                    date,
281                ));
282            }
283            AmlTypology::MoneyMule => {
284                flags.push(RedFlag::new(
285                    RedFlagCategory::AccountCharacteristic,
286                    "New account with unusually high activity",
287                    7,
288                    date,
289                ));
290                flags.push(RedFlag::new(
291                    RedFlagCategory::ActivityPattern,
292                    "Immediate cash withdrawals following electronic deposits",
293                    9,
294                    date,
295                ));
296            }
297            _ => {}
298        }
299
300        // Sophistication-based flags
301        if matches!(
302            scenario.sophistication,
303            Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
304        ) {
305            flags.push(RedFlag::new(
306                RedFlagCategory::CustomerBehavior,
307                "Complex ownership structure obscures beneficial owner",
308                6,
309                date,
310            ));
311        }
312
313        flags
314    }
315
316    /// Recommend action based on scenario.
317    fn recommend_action(&self, scenario: &AmlScenario) -> CaseRecommendation {
318        // High severity typologies
319        if matches!(
320            scenario.typology,
321            AmlTypology::SanctionsEvasion
322                | AmlTypology::TerroristFinancing
323                | AmlTypology::Corruption
324        ) {
325            return CaseRecommendation::ReportLawEnforcement;
326        }
327
328        // Sophisticated activity warrants SAR
329        if matches!(
330            scenario.sophistication,
331            Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
332        ) {
333            return CaseRecommendation::FileSar;
334        }
335
336        // Mule accounts should be closed
337        if scenario.typology == AmlTypology::MoneyMule {
338            return CaseRecommendation::CloseAccount;
339        }
340
341        // Standard suspicious activity
342        if matches!(scenario.sophistication, Sophistication::Standard) {
343            return CaseRecommendation::FileSar;
344        }
345
346        // Basic activity - enhanced monitoring
347        CaseRecommendation::EnhancedMonitoring
348    }
349}
350
351/// Exported case narrative with full details.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ExportedNarrative {
354    /// Case ID
355    pub case_id: String,
356    /// Main storyline
357    pub storyline: String,
358    /// Evidence points
359    pub evidence_points: Vec<String>,
360    /// Violated expectations
361    pub violated_expectations: Vec<ExportedViolation>,
362    /// Red flags
363    pub red_flags: Vec<ExportedRedFlag>,
364    /// Recommendation
365    pub recommendation: String,
366    /// Scenario metadata
367    pub metadata: NarrativeMetadata,
368}
369
370/// Exported violated expectation.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct ExportedViolation {
373    pub expectation_type: String,
374    pub expected: String,
375    pub actual: String,
376    pub deviation_percent: f64,
377}
378
379/// Exported red flag.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct ExportedRedFlag {
382    pub category: String,
383    pub description: String,
384    pub severity: u8,
385}
386
387/// Narrative metadata.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct NarrativeMetadata {
390    /// Typology
391    pub typology: String,
392    /// Sophistication level
393    pub sophistication: String,
394    /// Laundering stages
395    pub stages: Vec<String>,
396    /// Start date
397    pub start_date: String,
398    /// End date
399    pub end_date: String,
400    /// Account count
401    pub account_count: usize,
402    /// Detectability score
403    pub detectability: f64,
404}
405
406impl ExportedNarrative {
407    /// Create from scenario and narrative.
408    pub fn from_scenario(scenario: &AmlScenario, narrative: &CaseNarrative) -> Self {
409        Self {
410            case_id: scenario.scenario_id.clone(),
411            storyline: narrative.storyline.clone(),
412            evidence_points: narrative.evidence_points.clone(),
413            violated_expectations: narrative
414                .violated_expectations
415                .iter()
416                .map(|ve| ExportedViolation {
417                    expectation_type: ve.expectation_type.clone(),
418                    expected: ve.expected_value.clone(),
419                    actual: ve.actual_value.clone(),
420                    deviation_percent: ve.deviation_percentage,
421                })
422                .collect(),
423            red_flags: narrative
424                .red_flags
425                .iter()
426                .map(|rf| ExportedRedFlag {
427                    category: format!("{:?}", rf.category),
428                    description: rf.description.clone(),
429                    severity: rf.severity,
430                })
431                .collect(),
432            recommendation: format!("{:?}", narrative.recommendation),
433            metadata: NarrativeMetadata {
434                typology: format!("{:?}", scenario.typology),
435                sophistication: format!("{:?}", scenario.sophistication),
436                stages: scenario.stages.iter().map(|s| format!("{:?}", s)).collect(),
437                start_date: scenario.start_date.format("%Y-%m-%d").to_string(),
438                end_date: scenario.end_date.format("%Y-%m-%d").to_string(),
439                account_count: scenario.involved_accounts.len(),
440                detectability: scenario.detectability,
441            },
442        }
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn test_narrative_generation() {
452        let mut generator = NarrativeGenerator::new(12345);
453
454        let scenario = AmlScenario::new(
455            "TEST-001",
456            AmlTypology::Structuring,
457            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
458            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
459        )
460        .with_sophistication(Sophistication::Standard);
461
462        let narrative = generator.generate(&scenario);
463
464        assert!(!narrative.storyline.is_empty());
465        assert!(!narrative.evidence_points.is_empty());
466        assert!(!narrative.violated_expectations.is_empty());
467        assert!(!narrative.red_flags.is_empty());
468    }
469
470    #[test]
471    fn test_recommendation() {
472        let generator = NarrativeGenerator::new(12345);
473
474        // Professional sophistication -> SAR
475        let scenario = AmlScenario::new(
476            "TEST-001",
477            AmlTypology::Structuring,
478            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
479            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
480        )
481        .with_sophistication(Sophistication::Professional);
482
483        let rec = generator.recommend_action(&scenario);
484        assert_eq!(rec, CaseRecommendation::FileSar);
485
486        // Money mule -> Close account
487        let scenario = AmlScenario::new(
488            "TEST-002",
489            AmlTypology::MoneyMule,
490            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
491            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
492        )
493        .with_sophistication(Sophistication::Basic);
494
495        let rec = generator.recommend_action(&scenario);
496        assert_eq!(rec, CaseRecommendation::CloseAccount);
497    }
498}