use serde::{Deserialize, Serialize};
use crate::case::CaseRef;
use crate::event::{ArtifactRef, OperationalReceipt, ReceiptSeverity};
use crate::ids::{
AttachmentId, BlockId, ConversationId, EventId, InteractionId, OperationKey, OptionId,
QuestionId, ReceiptId, TurnId, WorkflowKey,
};
use crate::interaction::{FieldValue, InteractionKind, InteractionStatus, InteractionView};
use crate::knowledge::Citation;
use crate::locale::{Locale, LocalizedText};
use crate::plan::AnswerBasis;
use crate::reduce::AnswerTask;
crate::ids::string_id! {
ReplayToken
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NoticeSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnswerStatus {
Answered,
ClarificationRequested,
Unsupported,
SourceUnavailable,
Withheld,
NotWritten,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeneratedAnswer {
pub block_id: BlockId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub question_id: Option<QuestionId>,
pub text: String,
pub basis: AnswerBasis,
pub status: AnswerStatus,
#[serde(default)]
pub facts_used: Vec<NarratableFact>,
#[serde(default)]
pub citations: Vec<Citation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enumerations: Vec<AnsweredEnumeration>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnsweredEnumeration {
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preamble: Option<String>,
pub values: Vec<AnsweredValue>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnsweredValue {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeneratedTransition {
pub block_id: BlockId,
pub text: String,
#[serde(default)]
pub facts_used: Vec<NarratableFact>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReceiptBlock {
pub block_id: BlockId,
pub receipt: OperationalReceipt,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerNotice {
pub block_id: BlockId,
pub code: String,
pub severity: NoticeSeverity,
pub text: LocalizedText,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractionBlock {
pub block_id: BlockId,
pub view: InteractionView,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactView {
pub block_id: BlockId,
pub artifact: ArtifactRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResponseBlock {
Answer(GeneratedAnswer),
Transition(GeneratedTransition),
Receipt(ReceiptBlock),
Notice(ServerNotice),
Interaction(InteractionBlock),
Artifact(ArtifactView),
}
impl ResponseBlock {
#[must_use]
pub fn block_id(&self) -> &BlockId {
match self {
Self::Answer(b) => &b.block_id,
Self::Transition(b) => &b.block_id,
Self::Receipt(b) => &b.block_id,
Self::Notice(b) => &b.block_id,
Self::Interaction(b) => &b.block_id,
Self::Artifact(b) => &b.block_id,
}
}
#[must_use]
pub fn is_model_authored(&self) -> bool {
matches!(self, Self::Answer(_) | Self::Transition(_))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssistantTurn {
pub turn_id: TurnId,
pub conversation_id: ConversationId,
pub blocks: Vec<ResponseBlock>,
pub replay_token: ReplayToken,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subjects: Vec<crate::case::CaseRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expectations: Vec<Expectation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub done: Vec<DoneAct>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub offers: Vec<Offer>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Offer {
pub case_ref: CaseRef,
pub operation: crate::ids::OperationKey,
pub words: String,
pub arguments: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DoneAct {
pub act: Box<crate::understanding::UnderstoodAct>,
pub case_ref: CaseRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Expectation {
AwaitingValue {
act: Box<crate::understanding::UnderstoodAct>,
#[serde(default, skip_serializing_if = "Option::is_none")]
case_ref: Option<CaseRef>,
missing: Vec<String>,
},
AwaitingObligation {
case_ref: CaseRef,
obligation: String,
},
AwaitingOperation {
case_ref: CaseRef,
obligation: String,
act: crate::flow::ObligationAct,
},
StillWaiting {
act: Box<crate::understanding::UnderstoodAct>,
#[serde(default, skip_serializing_if = "Option::is_none")]
case_ref: Option<CaseRef>,
missing: Vec<String>,
},
}
impl AssistantTurn {
#[must_use]
pub fn block_ids(&self) -> Vec<BlockId> {
self.blocks.iter().map(|b| b.block_id().clone()).collect()
}
pub fn receipts(&self) -> impl Iterator<Item = &OperationalReceipt> {
self.blocks.iter().filter_map(|b| match b {
ResponseBlock::Receipt(r) => Some(&r.receipt),
_ => None,
})
}
pub fn interactions(&self) -> impl Iterator<Item = &InteractionView> {
self.blocks.iter().filter_map(|b| match b {
ResponseBlock::Interaction(i) => Some(&i.view),
_ => None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToneProfile {
#[default]
Neutral,
Warm,
Formal,
Concise,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactRelevance {
#[default]
ThisTurn,
Background,
}
impl FactRelevance {
#[must_use]
pub const fn is_this_turn(self) -> bool {
matches!(self, Self::ThisTurn)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AnswerProgress {
Running,
Done,
Failed,
Closed,
}
impl AnswerProgress {
#[must_use]
pub const fn of(status: InteractionStatus) -> Self {
match status {
InteractionStatus::Resolving => Self::Running,
InteractionStatus::Resolved => Self::Done,
InteractionStatus::Failed => Self::Failed,
InteractionStatus::Active
| InteractionStatus::Declined
| InteractionStatus::Dismissed
| InteractionStatus::Invalidated
| InteractionStatus::Expired => Self::Closed,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum NarratableFact {
OperationAvailable {
workflow: WorkflowKey,
operation: OperationKey,
summary: String,
},
Record {
case_ref: CaseRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
phase: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
outcome: Option<serde_json::Value>,
},
StateValue {
case_ref: CaseRef,
field: String,
value: serde_json::Value,
},
ProposedChange {
case_ref: CaseRef,
field: String,
#[serde(default, skip_serializing_if = "FieldValue::is_absent")]
before: FieldValue,
#[serde(default, skip_serializing_if = "FieldValue::is_absent")]
after: FieldValue,
},
OperationalOutcome {
receipt_id: ReceiptId,
event_ids: Vec<EventId>,
status_code: String,
},
InteractionAvailable {
interaction_id: InteractionId,
interaction_kind: InteractionKind,
},
InstructionDeclined {
case_ref: CaseRef,
interaction_id: InteractionId,
option_id: OptionId,
#[serde(default)]
question: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
option_label: String,
},
ActAwaitingConfirmation {
case_ref: CaseRef,
operation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
subject: Option<String>,
},
ActRefused {
#[serde(default, skip_serializing_if = "Option::is_none")]
case_ref: Option<CaseRef>,
code: String,
explanation: String,
},
ActChangedNothing {
case_ref: CaseRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
operation: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
explanation: String,
},
ValueNeeded {
#[serde(default, skip_serializing_if = "Option::is_none")]
case_ref: Option<CaseRef>,
operation: String,
arguments: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
NotUnderstood {
words: String,
},
ActHeld {
operation: String,
because: String,
},
WorkflowUnavailable {
workflow: WorkflowKey,
reason: String,
},
InteractionAlreadyAnswered {
interaction_id: InteractionId,
option_id: Option<OptionId>,
progress: AnswerProgress,
},
AttachmentNotShown {
attachment_id: AttachmentId,
filename: Option<String>,
reason: String,
},
ObligationOpen {
case_ref: CaseRef,
obligation: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
sentence: Option<String>,
#[serde(default)]
relevance: FactRelevance,
},
Knowledge {
chunk_id: String,
source_id: String,
text: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaimClass {
Creation,
Update,
Deletion,
Submission,
Acceptance,
Delivery,
Completion,
InteractionVisibility,
FutureNotification,
}
impl ClaimClass {
pub const ALL: [Self; 9] = [
Self::Creation,
Self::Update,
Self::Deletion,
Self::Submission,
Self::Acceptance,
Self::Delivery,
Self::Completion,
Self::InteractionVisibility,
Self::FutureNotification,
];
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReceiptSummary {
pub receipt_id: ReceiptId,
pub status_code: String,
pub severity: ReceiptSeverity,
pub event_ids: Vec<EventId>,
pub title: String,
pub body: String,
}
impl ReceiptSummary {
#[must_use]
pub fn of(receipt: &OperationalReceipt, locale: &crate::locale::Locale) -> Self {
Self {
receipt_id: receipt.receipt_id,
status_code: receipt.status_code.clone(),
severity: receipt.severity,
event_ids: receipt.event_ids.clone(),
title: receipt.title.resolve(locale).to_owned(),
body: receipt.body.resolve(locale).to_owned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OptionSummary {
pub id: OptionId,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractionSummary {
pub interaction_id: InteractionId,
pub kind: InteractionKind,
pub blocking: bool,
#[serde(default)]
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub body: String,
pub options: Vec<OptionSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NarrationRequest {
pub tone: ToneProfile,
pub locale: Locale,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_text: Option<String>,
pub questions: Vec<AnswerTask>,
#[serde(default)]
pub pending_questions: usize,
pub allowed_facts: Vec<NarratableFact>,
pub forbidden_claim_classes: Vec<ClaimClass>,
pub surrounding_receipts: Vec<ReceiptSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_interaction_summary: Option<InteractionSummary>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guidance: Vec<CaseGuidance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preceding_reply: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub case_labels: Vec<CaseLabel>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CaseLabel {
pub case_ref: crate::case::CaseRef,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CaseGuidance {
pub case_ref: crate::case::CaseRef,
pub briefing: String,
}
pub mod claim_guard {
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::{AssistantTurn, NarratableFact, ResponseBlock};
use crate::event::ReceiptSeverity;
use crate::ids::{BlockId, InteractionId, ReceiptId};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ClaimViolation {
#[error(
"block {block_id} claims outcome of receipt {receipt_id} without a backing receipt block"
)]
UnbackedOperationalOutcome {
block_id: BlockId,
receipt_id: ReceiptId,
},
#[error("receipt block {block_id} ({receipt_id}) claims success without events")]
ReceiptWithoutEvents {
block_id: BlockId,
receipt_id: ReceiptId,
},
#[error("block {block_id} refers to interaction {interaction_id} that is not in the turn")]
InteractionNotInTurn {
block_id: BlockId,
interaction_id: InteractionId,
},
#[error("block {block_id} repeats receipt {receipt_id}")]
DuplicateReceiptId {
block_id: BlockId,
receipt_id: ReceiptId,
},
}
pub fn verify(turn: &AssistantTurn) -> Result<(), ClaimViolation> {
let mut receipts: BTreeMap<ReceiptId, BTreeSet<_>> = BTreeMap::new();
let mut interactions = BTreeSet::new();
for block in &turn.blocks {
match block {
ResponseBlock::Receipt(r) => {
if r.receipt.severity == ReceiptSeverity::Success
&& r.receipt.event_ids.is_empty()
{
return Err(ClaimViolation::ReceiptWithoutEvents {
block_id: r.block_id.clone(),
receipt_id: r.receipt.receipt_id,
});
}
if receipts
.insert(
r.receipt.receipt_id,
r.receipt.event_ids.iter().copied().collect(),
)
.is_some()
{
return Err(ClaimViolation::DuplicateReceiptId {
block_id: r.block_id.clone(),
receipt_id: r.receipt.receipt_id,
});
}
}
ResponseBlock::Interaction(i) => {
interactions.insert(i.view.id);
}
_ => {}
}
}
for block in &turn.blocks {
let (block_id, facts) = match block {
ResponseBlock::Answer(a) => (&a.block_id, &a.facts_used),
ResponseBlock::Transition(t) => (&t.block_id, &t.facts_used),
_ => continue,
};
for fact in facts {
match fact {
NarratableFact::OperationalOutcome {
receipt_id,
event_ids,
..
} => {
let backed = receipts.get(receipt_id).is_some_and(|events| {
!events.is_empty() && event_ids.iter().all(|e| events.contains(e))
});
if !backed {
return Err(ClaimViolation::UnbackedOperationalOutcome {
block_id: block_id.clone(),
receipt_id: *receipt_id,
});
}
}
NarratableFact::InteractionAvailable { interaction_id, .. }
if !interactions.contains(interaction_id) =>
{
return Err(ClaimViolation::InteractionNotInTurn {
block_id: block_id.clone(),
interaction_id: *interaction_id,
});
}
_ => {}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::ReceiptSeverity;
fn receipt_with_block(block: &str, id: ReceiptId, events: Vec<EventId>) -> ResponseBlock {
ResponseBlock::Receipt(ReceiptBlock {
block_id: BlockId::from(block),
receipt: OperationalReceipt {
receipt_id: id,
event_ids: events,
severity: ReceiptSeverity::Success,
title: "Sent".into(),
body: "Rebooking sent".into(),
status_code: "trip.rebooking_sent".into(),
artifact_refs: vec![],
},
})
}
fn receipt(id: ReceiptId, events: Vec<EventId>) -> ResponseBlock {
receipt_with_block("r1", id, events)
}
fn transition(fact: NarratableFact) -> ResponseBlock {
ResponseBlock::Transition(GeneratedTransition {
block_id: BlockId::from("t1"),
text: "Done".into(),
facts_used: vec![fact],
})
}
fn turn(blocks: Vec<ResponseBlock>) -> AssistantTurn {
AssistantTurn {
turn_id: TurnId::nil(),
conversation_id: ConversationId::nil(),
blocks,
subjects: Vec::new(),
expectations: Vec::new(),
replay_token: ReplayToken::from("rt"),
done: Vec::new(),
offers: Vec::new(),
}
}
#[test]
fn outcome_claims_need_receipt_blocks() {
let rid = ReceiptId::nil();
let eid = EventId::nil();
let fact = NarratableFact::OperationalOutcome {
receipt_id: rid,
event_ids: vec![eid],
status_code: "trip.rebooking_sent".into(),
};
assert!(
claim_guard::verify(&turn(vec![
transition(fact.clone()),
receipt(rid, vec![eid])
]))
.is_ok()
);
assert!(matches!(
claim_guard::verify(&turn(vec![transition(fact.clone())])),
Err(claim_guard::ClaimViolation::UnbackedOperationalOutcome { .. })
));
assert!(matches!(
claim_guard::verify(&turn(vec![receipt(rid, vec![])])),
Err(claim_guard::ClaimViolation::ReceiptWithoutEvents { .. })
));
let other_event = EventId::new();
assert!(matches!(
claim_guard::verify(&turn(vec![
transition(fact),
receipt(rid, vec![other_event])
])),
Err(claim_guard::ClaimViolation::UnbackedOperationalOutcome { .. })
));
}
#[test]
fn two_receipts_may_not_share_an_id() {
let rid = ReceiptId::nil();
let mine = EventId::nil();
let other = EventId::new();
let fact = NarratableFact::OperationalOutcome {
receipt_id: rid,
event_ids: vec![other],
status_code: "trip.rebooking_sent".into(),
};
let violation = claim_guard::verify(&turn(vec![
transition(fact),
receipt_with_block("r1", rid, vec![mine]),
receipt_with_block("r2", rid, vec![other]),
]))
.unwrap_err();
assert_eq!(
violation,
claim_guard::ClaimViolation::DuplicateReceiptId {
block_id: BlockId::from("r2"),
receipt_id: rid,
}
);
}
#[test]
fn interaction_claims_need_interaction_blocks() {
let fact = NarratableFact::InteractionAvailable {
interaction_id: InteractionId::nil(),
interaction_kind: InteractionKind::ConfirmCommand,
};
assert!(matches!(
claim_guard::verify(&turn(vec![transition(fact)])),
Err(claim_guard::ClaimViolation::InteractionNotInTurn { .. })
));
}
#[test]
fn blocks_carry_ids_and_tag() {
let t = turn(vec![transition(NarratableFact::Knowledge {
chunk_id: "c".into(),
source_id: "s".into(),
text: "t".into(),
})]);
assert_eq!(t.block_ids(), vec![BlockId::from("t1")]);
let json = serde_json::to_value(&t.blocks[0]).unwrap();
assert_eq!(json["kind"], "transition");
}
}