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:
@@ -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 }))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user