use std::sync::Arc;
use rusqlite::params;
use serde_json::json;
use tokio::sync::Mutex as AsyncMutex;
use tracing::{debug, info, instrument};
use crate::db;
use crate::db::mediation_events::MediationEventKind;
use crate::error::{Error, Result};
use crate::models::mediation::ClassificationLabel;
use crate::models::reasoning::{SummaryRequest, TranscriptEntry};
use crate::prompts::PromptBundle;
use crate::reasoning::ReasoningProvider;
const AUTHORITY_BOUNDARY_PHRASES: &[&str] = &[
"admin-settle",
"admin_settle",
"admin-cancel",
"admin_cancel",
"release funds",
"release the funds",
"settle the trade",
"settle funds",
"cancel trade",
"cancel the trade",
"force close",
"force-close",
"move funds",
"transfer funds",
"disburse funds",
"close the dispute",
];
#[derive(Debug, Clone)]
pub struct MediationSummary {
pub session_id: String,
pub dispute_id: String,
pub classification: ClassificationLabel,
pub confidence: f64,
pub suggested_next_step: String,
pub summary_text: String,
pub rationale_id: String,
pub prompt_bundle_id: String,
pub policy_hash: String,
pub generated_at: i64,
}
pub struct SummarizeParams<'a> {
pub conn: &'a Arc<AsyncMutex<rusqlite::Connection>>,
pub session_id: &'a str,
pub dispute_id: &'a str,
pub classification: ClassificationLabel,
pub confidence: f64,
pub transcript: Vec<TranscriptEntry>,
pub prompt_bundle: &'a Arc<PromptBundle>,
pub reasoning: &'a dyn ReasoningProvider,
pub provider_name: &'a str,
pub model_name: &'a str,
}
#[instrument(
skip_all,
fields(session_id = %params.session_id, dispute_id = %params.dispute_id)
)]
pub async fn summarize(params: SummarizeParams<'_>) -> Result<MediationSummary> {
let request = SummaryRequest {
session_id: params.session_id.to_string(),
dispute_id: params.dispute_id.to_string(),
prompt_bundle: Arc::clone(params.prompt_bundle),
transcript: params.transcript,
classification: params.classification,
confidence: params.confidence,
};
let response = params
.reasoning
.summarize(request)
.await
.map_err(|e| Error::ReasoningUnavailable(e.to_string()))?;
if response.summary_text.trim().is_empty() || response.suggested_next_step.trim().is_empty() {
return Err(Error::PolicyViolation(
"empty summary or suggested next step from reasoning provider".into(),
));
}
if contains_authority_boundary_phrase(&response.summary_text)
|| contains_authority_boundary_phrase(&response.suggested_next_step)
{
return Err(Error::PolicyViolation(
"authority boundary attempt in summary".into(),
));
}
let now = current_ts_secs()?;
let rationale_text = response.rationale.0;
let prompt_bundle_id = params.prompt_bundle.id.clone();
let policy_hash = params.prompt_bundle.policy_hash.clone();
let classification = params.classification;
let confidence = params.confidence;
let suggested_next_step = response.suggested_next_step;
let summary_text = response.summary_text;
let session_id_owned = params.session_id.to_string();
let dispute_id_owned = params.dispute_id.to_string();
let rationale_id = {
let mut guard = params.conn.lock().await;
let tx = guard.transaction()?;
let rationale_id = db::rationales::insert_rationale(
&tx,
Some(&session_id_owned),
params.provider_name,
params.model_name,
&prompt_bundle_id,
&policy_hash,
&rationale_text,
now,
)?;
debug!(
session_id = %session_id_owned,
rationale_id = %rationale_id,
"rationale persisted"
);
tx.execute(
"INSERT INTO mediation_summaries (
session_id, dispute_id, classification, confidence,
suggested_next_step, summary_text,
prompt_bundle_id, policy_hash, rationale_id, generated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
session_id_owned,
dispute_id_owned,
classification.to_string(),
confidence,
suggested_next_step,
summary_text,
prompt_bundle_id,
policy_hash,
rationale_id,
now,
],
)?;
let payload = json!({
"rationale_id": rationale_id,
"classification": classification.to_string(),
"confidence": confidence,
})
.to_string();
db::mediation_events::record_event(
&tx,
MediationEventKind::SummaryGenerated,
Some(&session_id_owned),
&payload,
Some(&rationale_id),
Some(&prompt_bundle_id),
Some(&policy_hash),
now,
)?;
tx.commit()?;
rationale_id
};
info!(
session_id = %session_id_owned,
rationale_id = %rationale_id,
classification = %classification,
confidence = confidence,
"summary_generated"
);
Ok(MediationSummary {
session_id: session_id_owned,
dispute_id: dispute_id_owned,
classification,
confidence,
suggested_next_step,
summary_text,
rationale_id,
prompt_bundle_id,
policy_hash,
generated_at: now,
})
}
fn contains_authority_boundary_phrase(text: &str) -> bool {
let lower = text.to_lowercase();
AUTHORITY_BOUNDARY_PHRASES
.iter()
.any(|phrase| lower.contains(phrase))
}
use super::current_ts_secs;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authority_boundary_phrases_match_case_insensitively() {
assert!(contains_authority_boundary_phrase(
"Please use admin-settle to release the escrow."
));
assert!(contains_authority_boundary_phrase(
"Recommend ADMIN-CANCEL on this order."
));
assert!(contains_authority_boundary_phrase(
"The solver should release funds immediately."
));
assert!(contains_authority_boundary_phrase(
"Force-close the dispute right away."
));
}
#[test]
fn benign_summary_text_passes() {
assert!(!contains_authority_boundary_phrase(
"Buyer confirms receipt; seller acknowledges the transfer landed."
));
assert!(!contains_authority_boundary_phrase(
"Both parties agree the fiat payment completed at 14:05."
));
assert!(!contains_authority_boundary_phrase(""));
}
}