Skip to main content

turnframe_runtime/
attachments.rs

1//! The turn's files, on their way to a model.
2//!
3//! # The half that was missing
4//!
5//! Attachments already travel end to end at the *plan* level: a turn carries
6//! them, the interpretation schema offers `attachment_extraction` as evidence
7//! pinned to the identifiers the turn actually has, and an act can name a file
8//! so an application's own extractor fetches the bytes and rewrites the command.
9//! That is how a document's numbers reach a record without passing through a
10//! model at all, and it needs nothing from here.
11//!
12//! What was missing is the model **seeing** the file. Nothing built a
13//! [`ContentPart`] from an attachment, so a turn carrying a photograph reached
14//! the model as a sentence mentioning one. An adopter whose extraction is a
15//! vision call behind a domain prompt has no fallback when it returns nothing —
16//! a receipt photographed at an angle, a scan of a scan, a document that is not
17//! what the prompt asks for — except "I cannot read this", on the turn where the
18//! user has just done the work of taking the picture.
19//!
20//! # What is not decided here
21//!
22//! Which providers can accept what. A part carrying an image makes the request
23//! require vision and one carrying a document makes it require documents, both
24//! derived from the part itself, so the router picks a candidate that has them
25//! and falls back through the chain like any other requirement. A file one
26//! vendor refuses and another accepts is therefore already handled, and handled
27//! in the one place that knows the vendors.
28
29use std::sync::Arc;
30
31use turnframe_core::ids::{AttachmentId, TurnId};
32use turnframe_core::locale::{Locale, LocalizedText};
33use turnframe_core::turn::{AttachmentRef, AttachmentSource};
34use turnframe_provider::request::ContentPart;
35
36use crate::config::AttachmentConfig;
37
38/// Why a file the turn carried was not put in front of the model.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum NotShown {
42    /// The application could not hand over the bytes.
43    Unavailable,
44    /// The request's declared budget would not take it.
45    OverBudget,
46    /// No model this deployment can route to accepts its kind.
47    ///
48    /// Asked before the request is sent rather than discovered by sending it: a
49    /// part carrying an image makes a request require vision, and a router with
50    /// nothing to serve it fails the call. Attaching a photograph to a
51    /// deployment whose models have no vision would then turn "I could not look
52    /// at your file" into "the turn failed", on the turn where the user has just
53    /// taken the picture.
54    Unsupported,
55}
56
57/// One file the turn carried and the model was not shown.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct OmittedAttachment {
60    /// The file.
61    pub attachment_id: AttachmentId,
62    /// What the user called it, when the upload carried a name.
63    pub filename: Option<String>,
64    /// Why.
65    pub reason: NotShown,
66}
67
68/// The turn's files as a model can be given them, and the ones it cannot.
69#[derive(Debug, Clone, Default)]
70pub struct TurnAttachments {
71    /// The parts to append to the request, in the order the turn carried them.
72    pub parts: Vec<ContentPart>,
73    /// The files that did not make it, in the same order.
74    ///
75    /// Never silently empty on a turn that dropped something: the whole point of
76    /// carrying this beside the parts is that an answer about the wrong document
77    /// is worse than an answer about none.
78    pub omitted: Vec<OmittedAttachment>,
79}
80
81impl TurnAttachments {
82    /// Whether the turn carried nothing, or nothing survived.
83    #[must_use]
84    pub fn is_empty(&self) -> bool {
85        self.parts.is_empty() && self.omitted.is_empty()
86    }
87
88    /// Fetches every attachment of the turn, in order, within the budget.
89    ///
90    /// # Order, and why it is the turn's
91    ///
92    /// The files are taken in the order the user attached them and the budget is
93    /// spent in that order, so a turn that overruns loses its last file rather
94    /// than an arbitrary one. A file too large for the whole budget is skipped
95    /// and the smaller ones behind it are still taken, because dropping the rest
96    /// of somebody's upload over one oversized photograph helps nobody.
97    ///
98    /// # With no source configured
99    ///
100    /// Nothing is fetched and nothing is reported. A deployment that never
101    /// registered an [`AttachmentSource`] has not lost a file it was showing —
102    /// it is the behaviour this runtime had before the port existed, and saying
103    /// "I could not look at your file" to every such turn would be noise about a
104    /// feature nobody asked for.
105    pub async fn gather(
106        source: Option<&Arc<dyn AttachmentSource>>,
107        config: AttachmentConfig,
108        turn_id: &TurnId,
109        attachments: &[AttachmentRef],
110        carries: impl Fn(&ContentPart) -> bool,
111    ) -> Self {
112        let Some(source) = source else {
113            return Self::default();
114        };
115        let mut gathered = Self::default();
116        let mut spent = 0usize;
117        for attachment in attachments {
118            if config
119                .max_files
120                .is_some_and(|max| gathered.parts.len() >= max)
121            {
122                gathered.omit(attachment, NotShown::OverBudget);
123                continue;
124            }
125            let content = match source.fetch(turn_id, attachment).await {
126                Ok(content) => content,
127                Err(error) => {
128                    // A file the application cannot hand over is ordinary: it
129                    // may keep the bytes for one turn and never write them down.
130                    tracing::info!(
131                        target: "turnframe.attachments",
132                        attachment = %attachment.attachment_id,
133                        error = %error,
134                        "an attachment could not be fetched; the turn stands without it"
135                    );
136                    gathered.omit(attachment, NotShown::Unavailable);
137                    continue;
138                }
139            };
140            if config
141                .max_total_bytes
142                .is_some_and(|max| spent.saturating_add(content.bytes.len()) > max)
143            {
144                gathered.omit(attachment, NotShown::OverBudget);
145                continue;
146            }
147            let part = ContentPart::inline_bytes(content.media_type, &content.bytes);
148            if !carries(&part) {
149                gathered.omit(attachment, NotShown::Unsupported);
150                continue;
151            }
152            spent = spent.saturating_add(content.bytes.len());
153            gathered.parts.push(part);
154        }
155        gathered
156    }
157
158    fn omit(&mut self, attachment: &AttachmentRef, reason: NotShown) {
159        self.omitted.push(OmittedAttachment {
160            attachment_id: attachment.attachment_id.clone(),
161            filename: attachment.filename.clone(),
162            reason,
163        });
164    }
165}
166
167/// What a user is told about a file the model was not shown.
168///
169/// Deterministic, and reaches them whether or not a model runs — which is the
170/// property that matters, because the alternative is prose about a document
171/// nobody looked at.
172#[derive(Debug, Clone)]
173#[non_exhaustive]
174pub struct AttachmentCopy {
175    /// The application could not hand the file over.
176    pub unavailable: LocalizedText,
177    /// The file did not fit the budget for one request.
178    pub over_budget: LocalizedText,
179    /// No model this deployment runs accepts its kind.
180    pub unsupported: LocalizedText,
181}
182
183impl AttachmentCopy {
184    /// The built-in copy: English, with Italian.
185    #[must_use]
186    pub fn standard() -> Self {
187        crate::copy::ServerCopy::translated(Self::english(), "it", ITALIAN)
188    }
189
190    /// English alone.
191    #[must_use]
192    pub fn english() -> Self {
193        Self {
194            unavailable: LocalizedText::new(
195                "I could not open one of the files you sent, so I have not looked at it.",
196            ),
197            over_budget: LocalizedText::new(
198                "One of the files you sent was too large for me to look at.",
199            ),
200            unsupported: LocalizedText::new("I cannot open files of that kind."),
201        }
202    }
203
204    /// The sentence for one reason.
205    #[must_use]
206    pub fn for_reason(&self, reason: NotShown) -> &LocalizedText {
207        match reason {
208            NotShown::Unavailable => &self.unavailable,
209            NotShown::OverBudget => &self.over_budget,
210            NotShown::Unsupported => &self.unsupported,
211        }
212    }
213
214    /// The sentence for one reason, resolved.
215    #[must_use]
216    pub fn resolved(&self, reason: NotShown, locale: &Locale) -> String {
217        self.for_reason(reason).resolve(locale).to_owned()
218    }
219}
220
221impl Default for AttachmentCopy {
222    fn default() -> Self {
223        Self::standard()
224    }
225}
226
227crate::copy::server_copy!(AttachmentCopy, [unavailable, over_budget, unsupported]);
228
229/// The built-in Italian of [`AttachmentCopy`], by field.
230const ITALIAN: &[(&str, &str)] = &[
231    (
232        "unavailable",
233        "Non ho potuto aprire uno dei file che hai inviato, quindi non l'ho guardato.",
234    ),
235    (
236        "over_budget",
237        "Uno dei file che hai inviato era troppo grande per poterlo guardare.",
238    ),
239    ("unsupported", "Non posso aprire file di quel tipo."),
240];