1use bon::Builder;
2use chrono::{DateTime, Utc};
3use nutype::nutype;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::collections::BTreeMap;
6use uuid::Uuid;
7
8use crate::agents;
9use crate::agents::WorkflowAgent;
10use domain::{agent, audit, customer, entities, message, pet, policy, workflow};
11
12#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
13pub enum Error {
15 #[error("daily update preview requires a DailyNoteCreated or DailyUpdateNeeded workflow event")]
16 UnsupportedWorkflowEvent,
18 #[error("daily update preview requires at least one staff note")]
19 MissingStaffNotes,
21 #[error("daily update preview requires at least one policy-allowed draft/summarize action")]
22 MissingAllowedAction,
24 #[error("daily update preview could not build a validated domain value: {0}")]
25 InvalidDomainValue(String),
27}
28
29pub type Result<T> = core::result::Result<T, Error>;
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
33pub struct MvpPreviewRequest {
35 pub event: workflow::Event,
37 pub pet_name: pet::Name,
39 pub owner_display_name: customer::Name,
41 pub policy_snapshot_id: policy::Id,
43 pub notes: Vec<entities::CareNote>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct MvpPreview {
50 pub agent_packet: agents::AgentPromptPacket<daily_care_update::Input>,
52 pub output: daily_care_update::Output,
54 pub owner_message_draft: CustomerMessageDraft,
56 pub approval: entities::approval::Record,
58 pub send_stub: SendStub,
60 pub audit_log: Vec<entities::audit::Event>,
62}
63
64pub mod daily_care_update {
66 use serde::{Deserialize, Serialize};
67
68 use super::{
69 CustomerMessageDraft, IncludedFact, InternalFlag, OmittedFact, ReviewDisposition, customer,
70 entities, pet, policy,
71 };
72
73 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74 pub struct Input {
76 pub pet_name: pet::Name,
78 pub owner_display_name: customer::Name,
80 pub policy_snapshot_id: policy::Id,
82 pub notes: Vec<entities::CareNote>,
84 }
85
86 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87 pub struct Output {
89 pub customer_message: CustomerMessageDraft,
91 pub internal_flags: Vec<InternalFlag>,
93 #[serde(flatten)]
94 pub disposition: ReviewDisposition,
96 pub included_facts: Vec<IncludedFact>,
98 pub omitted_facts: Vec<OmittedFact>,
100 }
101
102 #[derive(Debug, Clone, Copy)]
103 pub struct Agent;
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum ReviewDisposition {
110 DraftOnlyRequiresReview {
112 reason: ReviewReason,
114 },
115}
116
117impl ReviewDisposition {
118 pub const fn allows_live_send(&self) -> bool {
120 false
121 }
122
123 pub const fn requires_human_review(&self) -> bool {
125 true
126 }
127
128 pub const fn review_reason(&self) -> &ReviewReason {
130 match self {
131 Self::DraftOnlyRequiresReview { reason } => reason,
132 }
133 }
134}
135
136impl Serialize for ReviewDisposition {
137 fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
138 where
139 S: Serializer,
140 {
141 #[derive(Serialize)]
142 struct Wire<'a> {
143 should_send: bool,
144 requires_review: bool,
145 review_reason: &'a ReviewReason,
146 }
147
148 Wire {
149 should_send: self.allows_live_send(),
150 requires_review: self.requires_human_review(),
151 review_reason: self.review_reason(),
152 }
153 .serialize(serializer)
154 }
155}
156
157impl<'de> Deserialize<'de> for ReviewDisposition {
158 fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
159 where
160 D: Deserializer<'de>,
161 {
162 #[derive(Deserialize)]
163 struct Wire {
164 should_send: bool,
165 requires_review: bool,
166 review_reason: Option<ReviewReason>,
167 }
168
169 let wire = Wire::deserialize(deserializer)?;
170 match (wire.should_send, wire.requires_review, wire.review_reason) {
171 (false, true, Some(reason)) => Ok(Self::DraftOnlyRequiresReview { reason }),
172 _ => Err(serde::de::Error::custom(
173 "daily care update v1 output must remain a draft-only review-required disposition",
174 )),
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct CustomerMessageDraft {
182 pub body_ref: message::BodyRef,
184 pub channel_hint: message::Channel,
186 pub language: LanguageTag,
188 pub tone: ToneLabel,
190 pub audience: Audience,
192 pub redaction_profile: RedactionProfile,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub enum Audience {
199 Customer,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204pub enum InternalFlagCode {
206 CustomerMessageApprovalNotConfigured,
208 RawInternalNoteNotCustomerSafe,
210 BehaviorReviewRequired,
212 MedicalOrMedicationReviewRequired,
214 PolicyGapRequiresReview,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub enum InternalFlagSeverity {
221 Info,
223 NeedsStaffReview,
225 NeedsManagerReview,
227 DoNotSend,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232pub enum RecommendedFlagAction {
234 StaffReview,
236 ManagerReview,
238 SuppressUpdate,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct InternalFlag {
245 pub code: InternalFlagCode,
247 pub severity: InternalFlagSeverity,
249 pub message: FlagMessage,
251 pub source_note_ids: Vec<entities::care_note::Id>,
253 pub recommended_action: RecommendedFlagAction,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct IncludedFact {
260 pub source_note_id: entities::care_note::Id,
262 pub summary: FactSummary,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct OmittedFact {
269 pub source_note_id: entities::care_note::Id,
271 pub reason: OmissionReason,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
276pub enum OmissionReason {
278 InternalOnly,
280 SensitiveRequiresReview,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285pub struct SendStub {
287 pub mode: SendMode,
289 pub blocked_by: Vec<policy::ReviewGate>,
291 pub audit_action: entities::audit::Action,
293}
294
295impl SendStub {
296 pub fn is_blocked_until_human_approval(&self) -> bool {
298 matches!(self.mode, SendMode::ApprovalRequiredStub) && !self.blocked_by.is_empty()
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303pub enum SendMode {
305 ApprovalRequiredStub,
307}
308
309#[nutype(
310 sanitize(trim),
311 validate(not_empty, len_char_max = 120),
312 derive(
313 Debug,
314 Clone,
315 PartialEq,
316 Eq,
317 PartialOrd,
318 Ord,
319 Hash,
320 Serialize,
321 Deserialize
322 )
323)]
324pub struct LanguageTag(String);
325
326#[nutype(
327 sanitize(trim),
328 validate(not_empty, len_char_max = 120),
329 derive(
330 Debug,
331 Clone,
332 PartialEq,
333 Eq,
334 PartialOrd,
335 Ord,
336 Hash,
337 Serialize,
338 Deserialize
339 )
340)]
341pub struct ToneLabel(String);
342
343#[nutype(
344 sanitize(trim),
345 validate(not_empty, len_char_max = 160),
346 derive(
347 Debug,
348 Clone,
349 PartialEq,
350 Eq,
351 PartialOrd,
352 Ord,
353 Hash,
354 Serialize,
355 Deserialize
356 )
357)]
358pub struct RedactionProfile(String);
359
360#[nutype(
361 sanitize(trim),
362 validate(not_empty, len_char_max = 160),
363 derive(
364 Debug,
365 Clone,
366 PartialEq,
367 Eq,
368 PartialOrd,
369 Ord,
370 Hash,
371 Serialize,
372 Deserialize
373 )
374)]
375pub struct ReviewReason(String);
376
377#[nutype(
378 sanitize(trim),
379 validate(not_empty, len_char_max = 400),
380 derive(
381 Debug,
382 Clone,
383 PartialEq,
384 Eq,
385 PartialOrd,
386 Ord,
387 Hash,
388 Serialize,
389 Deserialize
390 )
391)]
392pub struct FlagMessage(String);
393
394#[nutype(
395 sanitize(trim),
396 validate(not_empty, len_char_max = 500),
397 derive(
398 Debug,
399 Clone,
400 PartialEq,
401 Eq,
402 PartialOrd,
403 Ord,
404 Hash,
405 Serialize,
406 Deserialize
407 )
408)]
409pub struct FactSummary(String);
410
411pub fn build_mvp_preview(request: MvpPreviewRequest) -> Result<MvpPreview> {
413 validate_request(&request)?;
414
415 let input = daily_care_update::Input {
416 pet_name: request.pet_name,
417 owner_display_name: request.owner_display_name,
418 policy_snapshot_id: request.policy_snapshot_id,
419 notes: request.notes,
420 };
421
422 let agent = daily_care_update::Agent;
423 let agent_packet = agent.build_prompt_packet(&request.event, input.clone());
424 let output = generate_output(&input)?;
425 let review_gate = review_gate_for(&output);
426
427 let message_id =
428 entities::MessageId(Uuid::from_u128(0xDA17_0000_0000_0000_0000_0000_0000_0001));
429 let approval_id =
430 entities::approval::Id(Uuid::from_u128(0xDA17_0000_0000_0000_0000_0000_0000_0002));
431 let _owner_message_record = entities::Message::builder()
432 .id(message_id)
433 .subject(entities::MessageSubject::Reservation(
434 subject_reservation_id(&request.event),
435 ))
436 .direction(message::Direction::OutboundDraft)
437 .channel(message::Channel::Portal)
438 .status(message::Status::ApprovalRequested)
439 .body_ref(output.customer_message.body_ref.clone())
440 .approval_gate(review_gate.clone())
441 .audit_refs(vec![audit::EventId(Uuid::from_u128(
442 0xDA17_0000_0000_0000_0000_0000_0000_0003,
443 ))])
444 .build();
445
446 let approval = entities::approval::Record::builder()
447 .id(approval_id)
448 .target(entities::approval::Target::Message(message_id))
449 .gate(review_gate.clone())
450 .lifecycle(entities::approval::Lifecycle::ApprovalRequested)
451 .requested_by(entities::ActorRef::Agent {
452 workflow: agent_name()?,
453 })
454 .requested_at(request.event.occurred_at)
455 .audit_refs(vec![audit::EventId(Uuid::from_u128(
456 0xDA17_0000_0000_0000_0000_0000_0000_0004,
457 ))])
458 .build();
459
460 let send_stub = SendStub {
461 mode: SendMode::ApprovalRequiredStub,
462 blocked_by: vec![review_gate],
463 audit_action: entities::audit::Action::Extension(audit_action_label(
464 "message.send.blocked_stub",
465 )?),
466 };
467
468 let audit_log = audit_log(&request.event, message_id, approval_id)?;
469 let owner_message_draft = output.customer_message.clone();
470
471 Ok(MvpPreview {
472 agent_packet,
473 owner_message_draft,
474 output,
475 approval,
476 send_stub,
477 audit_log,
478 })
479}
480
481impl agents::WorkflowAgent<daily_care_update::Input, daily_care_update::Output>
482 for daily_care_update::Agent
483{
484 fn spec(&self) -> agents::AgentSpec {
485 agents::baseline_agent_specs()
486 .into_iter()
487 .find(|spec| spec.name.clone().into_inner() == "daily-care-update")
488 .expect("baseline daily-care-update agent spec exists")
489 }
490
491 fn build_prompt_packet(
492 &self,
493 event: &workflow::Event,
494 input: daily_care_update::Input,
495 ) -> agents::AgentPromptPacket<daily_care_update::Input> {
496 agents::AgentPromptPacket::builder()
497 .workflow_name(agent_name().expect("static daily-update agent name is valid"))
498 .goal(agent::Purpose::try_new(
499 "Transform source-backed staff care notes into a customer-safe draft preview while preserving approval gates and audit lineage.",
500 ).expect("static daily-update purpose is valid"))
501 .event(event.clone())
502 .input(input)
503 .policies(vec![agent::PolicyInstruction::try_new(
504 "Draft only: live customer sends and health/behavior concern wording require human approval.",
505 ).expect("static daily-update policy instruction is valid")])
506 .output_schema_name(agent::OutputSchemaName::try_new("DailyCareUpdateOutput.v1").expect("static daily-update schema name is valid"))
507 .build()
508 }
509
510 fn validate_output(
511 &self,
512 output: workflow::Result<daily_care_update::Output>,
513 ) -> workflow::Result<daily_care_update::Output> {
514 output
515 }
516}
517
518fn validate_request(request: &MvpPreviewRequest) -> Result<()> {
519 if !matches!(
520 request.event.event_type,
521 workflow::EventType::DailyNoteCreated | workflow::EventType::DailyUpdateNeeded
522 ) {
523 return Err(Error::UnsupportedWorkflowEvent);
524 }
525 if request.notes.is_empty() {
526 return Err(Error::MissingStaffNotes);
527 }
528 if !request
529 .event
530 .policy_context
531 .allowed_actions
532 .iter()
533 .any(|action| {
534 matches!(
535 action,
536 workflow::AllowedAction::SummarizeCareNotes
537 | workflow::AllowedAction::DraftCustomerMessage
538 )
539 })
540 {
541 return Err(Error::MissingAllowedAction);
542 }
543 Ok(())
544}
545
546fn generate_output(input: &daily_care_update::Input) -> Result<daily_care_update::Output> {
547 let mut included_facts = Vec::new();
548 let mut omitted_facts = Vec::new();
549 let mut internal_flags = vec![InternalFlag {
550 code: InternalFlagCode::CustomerMessageApprovalNotConfigured,
551 severity: InternalFlagSeverity::NeedsStaffReview,
552 message: flag_message(
553 "Daily care updates are draft-only until a location/channel/template send policy is approved.",
554 )?,
555 source_note_ids: input.notes.iter().map(|note| note.id).collect(),
556 recommended_action: RecommendedFlagAction::StaffReview,
557 }];
558
559 let mut safe_note_bodies = Vec::new();
560 let mut sensitive_note_ids = Vec::new();
561
562 for note in &input.notes {
563 if matches!(
564 note.visibility,
565 entities::care_note::Visibility::InternalOnly
566 ) {
567 omitted_facts.push(OmittedFact {
568 source_note_id: note.id,
569 reason: OmissionReason::InternalOnly,
570 });
571 internal_flags.push(InternalFlag {
572 code: InternalFlagCode::RawInternalNoteNotCustomerSafe,
573 severity: InternalFlagSeverity::DoNotSend,
574 message: flag_message("Raw internal staff notes are omitted from customer copy.")?,
575 source_note_ids: vec![note.id],
576 recommended_action: RecommendedFlagAction::SuppressUpdate,
577 });
578 continue;
579 }
580
581 if sensitive_kind(note.kind) {
582 omitted_facts.push(OmittedFact {
583 source_note_id: note.id,
584 reason: OmissionReason::SensitiveRequiresReview,
585 });
586 sensitive_note_ids.push(note.id);
587 continue;
588 }
589
590 let summary = normalize_sentence(note.body.clone().into_inner());
591 included_facts.push(IncludedFact {
592 source_note_id: note.id,
593 summary: FactSummary::try_new(summary.clone()).map_err(invalid_domain_value)?,
594 });
595 safe_note_bodies.push(summary);
596 }
597
598 let sensitive_review = if !sensitive_note_ids.is_empty() {
599 let code = if input
600 .notes
601 .iter()
602 .any(|note| matches!(note.kind, entities::care_note::Kind::Behavior))
603 {
604 InternalFlagCode::BehaviorReviewRequired
605 } else {
606 InternalFlagCode::MedicalOrMedicationReviewRequired
607 };
608 internal_flags.push(InternalFlag {
609 code,
610 severity: InternalFlagSeverity::NeedsManagerReview,
611 message: flag_message("Sensitive care-note content was suppressed until manager review approves customer wording.")?,
612 source_note_ids: sensitive_note_ids,
613 recommended_action: RecommendedFlagAction::ManagerReview,
614 });
615 Some(code)
616 } else {
617 None
618 };
619
620 let review_reason = match sensitive_review {
621 Some(InternalFlagCode::BehaviorReviewRequired) => "behavior_review_required",
622 Some(_) => "medical_or_medication_review_required",
623 None => "customer_message_approval_not_configured",
624 };
625
626 let body = if safe_note_bodies.is_empty() {
627 format!(
628 "Hi {} — {}'s daily update is being reviewed by our care team before we share customer-facing wording.",
629 input.owner_display_name.clone().into_inner(),
630 input.pet_name.clone().into_inner()
631 )
632 } else {
633 format!(
634 "Hi {} — {} {}",
635 input.owner_display_name.clone().into_inner(),
636 input.pet_name.clone().into_inner(),
637 safe_note_bodies.join(" ")
638 )
639 };
640
641 Ok(daily_care_update::Output {
642 customer_message: CustomerMessageDraft {
643 body_ref: message::BodyRef::try_new(body).map_err(invalid_domain_value)?,
644 channel_hint: message::Channel::Portal,
645 language: LanguageTag::try_new("en-US").map_err(invalid_domain_value)?,
646 tone: ToneLabel::try_new("warm_concise_factual").map_err(invalid_domain_value)?,
647 audience: Audience::Customer,
648 redaction_profile: RedactionProfile::try_new("customer_safe_daily_update_v1")
649 .map_err(invalid_domain_value)?,
650 },
651 internal_flags,
652 disposition: ReviewDisposition::DraftOnlyRequiresReview {
653 reason: ReviewReason::try_new(review_reason).map_err(invalid_domain_value)?,
654 },
655 included_facts,
656 omitted_facts,
657 })
658}
659
660fn review_gate_for(output: &daily_care_update::Output) -> policy::ReviewGate {
661 if output.internal_flags.iter().any(|flag| {
662 matches!(
663 flag.code,
664 InternalFlagCode::BehaviorReviewRequired
665 | InternalFlagCode::MedicalOrMedicationReviewRequired
666 )
667 }) {
668 policy::ReviewGate::ManagerApproval
669 } else {
670 policy::ReviewGate::CustomerMessageApproval
671 }
672}
673
674fn audit_log(
675 event: &workflow::Event,
676 message_id: entities::MessageId,
677 approval_id: entities::approval::Id,
678) -> Result<Vec<entities::audit::Event>> {
679 Ok(vec![
680 audit_event(
681 event.occurred_at,
682 event.actor.clone(),
683 entities::audit::Subject::WorkflowEvent(event.event_id),
684 entities::audit::Action::WorkflowEventRecorded,
685 "daily-care-update workflow event recorded for MVP preview",
686 )?,
687 audit_event(
688 event.occurred_at,
689 entities::ActorRef::Agent {
690 workflow: agent_name()?,
691 },
692 entities::audit::Subject::Message(message_id),
693 entities::audit::Action::MessageApprovalRequested,
694 "daily care update owner-message draft created; no live send attempted",
695 )?,
696 audit_event(
697 event.occurred_at,
698 entities::ActorRef::Agent {
699 workflow: agent_name()?,
700 },
701 entities::audit::Subject::Approval(approval_id),
702 entities::audit::Action::ApprovalDecisionRecorded,
703 "approval record opened for staff/manager review stub",
704 )?,
705 ])
706}
707
708fn audit_event(
709 at: DateTime<Utc>,
710 actor: entities::ActorRef,
711 subject: entities::audit::Subject,
712 action: entities::audit::Action,
713 summary: &str,
714) -> Result<entities::audit::Event> {
715 let mut metadata = BTreeMap::new();
716 metadata.insert(
717 entities::audit::MetadataKey::try_new("summary").map_err(invalid_domain_value)?,
718 entities::audit::MetadataValue::try_new(summary).map_err(invalid_domain_value)?,
719 );
720 Ok(entities::audit::Event {
721 at,
722 actor,
723 subject,
724 action,
725 metadata,
726 })
727}
728
729fn subject_reservation_id(event: &workflow::Event) -> entities::reservation::Id {
730 match event.subject {
731 workflow::Subject::Reservation(id) => id,
732 _ => entities::reservation::Id(Uuid::nil()),
733 }
734}
735
736fn sensitive_kind(kind: entities::care_note::Kind) -> bool {
737 matches!(
738 kind,
739 entities::care_note::Kind::Medication
740 | entities::care_note::Kind::Medical
741 | entities::care_note::Kind::Behavior
742 )
743}
744
745fn normalize_sentence(body: String) -> String {
746 let mut normalized = body.trim().to_owned();
747 if !normalized.ends_with(['.', '!', '?']) {
748 normalized.push('.');
749 }
750 normalized
751}
752
753fn agent_name() -> Result<agent::Name> {
754 agent::Name::try_new("daily-care-update").map_err(invalid_domain_value)
755}
756
757fn audit_action_label(label: &str) -> Result<entities::audit::ActionLabel> {
758 entities::audit::ActionLabel::try_new(label).map_err(invalid_domain_value)
759}
760
761fn flag_message(message: &str) -> Result<FlagMessage> {
762 FlagMessage::try_new(message).map_err(invalid_domain_value)
763}
764
765fn invalid_domain_value(error: impl std::fmt::Display) -> Error {
766 Error::InvalidDomainValue(error.to_string())
767}