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];