Add Maritime/Logistics support assistant with safety/eval layer

RAG + multi-agent (retrieval, policy-compliance, drafting) support
assistant for a fictional shipping company, built in Rust with Qdrant
for vector storage and a hand-rolled Anthropic tool-calling client
(no Rust pydantic-ai equivalent exists). A deterministic supervisor
orchestrates the agents and runs every response through a rule-based
guardrail pipeline with an append-only audit log.

Includes a 44-example golden dataset (normal/injection/policy-
violation/hallucination-bait/out-of-scope) and a regression harness
that scores retrieval, generation, and guardrail correctness
separately, plus a write-up of the failure modes and trade-offs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
msjid
2026-09-18 21:04:09 +02:00
co-authored by Claude Sonnet 5
parent 93e92a1210
commit 771280cde6
56 changed files with 7214 additions and 72 deletions
+60
View File
@@ -0,0 +1,60 @@
//! The `escalate_to_human` tool exposed to the drafting agent.
use std::sync::Arc;
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::llm::schema::schema_value;
use crate::llm::tool::Tool;
use crate::models::domain::EscalationPriority;
use crate::tools::escalation_desk::EscalationDesk;
#[derive(Debug, Deserialize, JsonSchema)]
struct EscalateInput {
reason: String,
/// The customer's original message, verbatim, for the human agent.
customer_message: String,
priority: EscalationPriority,
related_reference: Option<String>,
}
pub struct EscalateTool {
desk: Arc<EscalationDesk>,
}
impl EscalateTool {
pub fn new(desk: Arc<EscalationDesk>) -> Self {
Self { desk }
}
}
#[async_trait]
impl Tool for EscalateTool {
fn name(&self) -> &str {
"escalate_to_human"
}
fn description(&self) -> &str {
"Hand this request off to a human specialist. Use for fee disputes, legal/customs \
advice, angry or threatening customers, or anything that would otherwise require \
breaking a support policy rule."
}
fn input_schema(&self) -> Value {
schema_value::<EscalateInput>()
}
async fn execute(&self, input: Value) -> anyhow::Result<Value> {
let input: EscalateInput = serde_json::from_value(input)?;
let ticket = self.desk.create_ticket(
input.reason,
input.customer_message,
input.priority,
input.related_reference,
);
Ok(json!({ "ticket_id": ticket.ticket_id }))
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Records escalations to a human specialist. In-memory for this portfolio
//! build; a production version would push to a ticketing system instead.
use std::sync::Mutex;
use chrono::Utc;
use uuid::Uuid;
use crate::models::domain::{EscalationPriority, EscalationTicket};
pub struct EscalationDesk {
tickets: Mutex<Vec<EscalationTicket>>,
}
impl EscalationDesk {
pub fn new() -> Self {
Self {
tickets: Mutex::new(Vec::new()),
}
}
pub fn create_ticket(
&self,
reason: impl Into<String>,
customer_message: impl Into<String>,
priority: EscalationPriority,
related_reference: Option<String>,
) -> EscalationTicket {
let ticket = EscalationTicket {
ticket_id: format!("ESC-{}", Uuid::new_v4().simple()),
reason: reason.into(),
customer_message: customer_message.into(),
priority,
created_at: Utc::now(),
related_reference,
};
self.tickets
.lock()
.expect("escalation desk mutex poisoned")
.push(ticket.clone());
ticket
}
pub fn ticket_count(&self) -> usize {
self.tickets.lock().expect("escalation desk mutex poisoned").len()
}
}
impl Default for EscalationDesk {
fn default() -> Self {
Self::new()
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod escalate_tool;
pub mod escalation_desk;
pub mod shipment_lookup;
pub mod shipment_repository;
pub use escalate_tool::EscalateTool;
pub use escalation_desk::EscalationDesk;
pub use shipment_lookup::ShipmentLookupTool;
pub use shipment_repository::ShipmentRepository;
+63
View File
@@ -0,0 +1,63 @@
//! The `lookup_shipment` tool exposed to the drafting agent.
use std::sync::Arc;
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::llm::schema::schema_value;
use crate::llm::tool::Tool;
use crate::tools::shipment_repository::ShipmentRepository;
#[derive(Debug, Deserialize, JsonSchema)]
struct LookupInput {
/// A booking reference (e.g. "MSL-4B7K921") or container number (e.g.
/// "MSCU1234567").
reference: String,
}
#[derive(Debug, Serialize)]
struct NotFound<'a> {
found: bool,
reference: &'a str,
}
pub struct ShipmentLookupTool {
repository: Arc<ShipmentRepository>,
}
impl ShipmentLookupTool {
pub fn new(repository: Arc<ShipmentRepository>) -> Self {
Self { repository }
}
}
#[async_trait]
impl Tool for ShipmentLookupTool {
fn name(&self) -> &str {
"lookup_shipment"
}
fn description(&self) -> &str {
"Look up a shipment's current status by booking reference or container number. \
Returns found: false if no such shipment exists -- treat that as authoritative, \
never guess a status instead."
}
fn input_schema(&self) -> Value {
schema_value::<LookupInput>()
}
async fn execute(&self, input: Value) -> anyhow::Result<Value> {
let input: LookupInput = serde_json::from_value(input)?;
match self.repository.lookup(&input.reference) {
Some(shipment) => Ok(serde_json::to_value(shipment)?),
None => Ok(json!(NotFound {
found: false,
reference: &input.reference,
})),
}
}
}
+51
View File
@@ -0,0 +1,51 @@
//! In-memory shipment lookup, seeded once at startup.
use std::collections::HashMap;
use crate::models::domain::Shipment;
pub struct ShipmentRepository {
by_reference: HashMap<String, Shipment>,
}
impl ShipmentRepository {
pub fn new(shipments: Vec<Shipment>) -> Self {
let mut by_reference = HashMap::with_capacity(shipments.len() * 2);
for shipment in shipments {
by_reference.insert(
shipment.booking_reference.to_uppercase(),
shipment.clone(),
);
by_reference.insert(shipment.container_number.to_uppercase(), shipment);
}
Self { by_reference }
}
pub fn lookup(&self, reference: &str) -> Option<&Shipment> {
self.by_reference.get(&reference.to_uppercase())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::seed_shipments::seed_shipments;
#[test]
fn finds_shipment_by_booking_reference_case_insensitively() {
let repo = ShipmentRepository::new(seed_shipments());
assert!(repo.lookup("msl-4b7k921").is_some());
}
#[test]
fn finds_shipment_by_container_number() {
let repo = ShipmentRepository::new(seed_shipments());
assert!(repo.lookup("MSCU7654321").is_some());
}
#[test]
fn returns_none_for_unknown_reference() {
let repo = ShipmentRepository::new(seed_shipments());
assert!(repo.lookup("MSL-0000000").is_none());
}
}