use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::shared::tokens::estimate_text;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttachMode {
#[default]
Inline,
ByReference,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Attachment {
pub id: Uuid,
pub name: String,
pub source: String,
pub added_at: DateTime<Utc>,
pub text: String,
pub bytes: usize,
pub est_tokens: usize,
pub mode: AttachMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_id: Option<Uuid>,
}
impl Attachment {
pub fn new(
name: impl Into<String>,
source: impl Into<String>,
text: String,
bytes: usize,
mode: AttachMode,
) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
source: source.into(),
added_at: Utc::now(),
est_tokens: estimate_text(&text) as usize,
text,
bytes,
mode,
file_id: None,
}
}
pub fn with_file(mut self, file_id: Uuid) -> Self {
self.file_id = Some(file_id);
self
}
pub fn info(&self, excerpt_tokens: usize) -> AttachmentInfo {
AttachmentInfo {
name: self.name.clone(),
source: self.source.clone(),
bytes: self.bytes,
est_tokens: self.est_tokens,
prompt_tokens: self.prompt_tokens(excerpt_tokens),
mode: self.mode,
has_original: self.file_id.is_some(),
}
}
pub fn prompt_tokens(&self, excerpt_tokens: usize) -> usize {
match self.mode {
AttachMode::Inline => self.est_tokens,
AttachMode::ByReference => estimate_text(self.excerpt(excerpt_tokens)) as usize,
}
}
pub fn page_count(&self, page_tokens: usize) -> usize {
paginate(&self.text, page_tokens).len()
}
pub fn page(&self, page_tokens: usize, n: usize) -> Option<&str> {
let pages = paginate(&self.text, page_tokens);
n.checked_sub(1).and_then(|i| pages.get(i)).copied()
}
pub fn matches(&self, target: &str) -> bool {
let t = target.trim().trim_matches(|c| c == '"' || c == '\'');
super::chat_file::same_name(&self.name, t) || super::chat_file::same_name(&self.source, t)
}
pub fn excerpt(&self, max_tokens: usize) -> &str {
excerpt(&self.text, max_tokens)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AttachmentChunk {
pub id: Uuid,
pub chat_id: Uuid,
pub attachment_id: Uuid,
pub name: String,
pub text: String,
pub embedding: Vec<f32>,
pub created_at: DateTime<Utc>,
}
impl AttachmentChunk {
pub fn new(
chat_id: Uuid,
attachment_id: Uuid,
name: impl Into<String>,
text: impl Into<String>,
embedding: Vec<f32>,
) -> Self {
Self {
id: Uuid::new_v4(),
chat_id,
attachment_id,
name: name.into(),
text: text.into(),
embedding,
created_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AttachmentHit {
pub attachment_id: Uuid,
pub name: String,
pub text: String,
pub distance: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AttachmentInfo {
pub name: String,
pub source: String,
pub bytes: usize,
pub est_tokens: usize,
pub prompt_tokens: usize,
pub mode: AttachMode,
pub has_original: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolved {
One(usize),
Shared(Vec<usize>),
Nothing,
}
pub fn resolve_handle<T>(
items: &[T],
target: &str,
matches: impl Fn(&T, &str) -> bool,
) -> Resolved {
resolve_handle_by(items, target, matches, |n| {
n.checked_sub(1).filter(|&i| i < items.len())
})
}
pub fn resolve_handle_by<T>(
items: &[T],
target: &str,
matches: impl Fn(&T, &str) -> bool,
numbered: impl Fn(usize) -> Option<usize>,
) -> Resolved {
let target = unquote(target);
let by_number = || match handle_number(target).and_then(&numbered) {
Some(at) => Resolved::One(at),
None => Resolved::Nothing,
};
if target.starts_with('#') && handle_number(target).is_some() {
return by_number();
}
let hits: Vec<usize> = items
.iter()
.enumerate()
.filter(|(_, item)| matches(*item, target))
.map(|(i, _)| i)
.collect();
match hits.len() {
0 => by_number(),
1 => Resolved::One(hits[0]),
_ => Resolved::Shared(hits),
}
}
pub fn handle_number(target: &str) -> Option<usize> {
let target = unquote(target);
match target.strip_prefix('#') {
Some(digits) => digits.trim().parse().ok(),
None if !target.is_empty() && target.bytes().all(|b| b.is_ascii_digit()) => {
target.parse().ok()
}
None => None,
}
}
pub fn handle_range(count: usize) -> String {
match count {
0 | 1 => "#1".to_string(),
n => format!("#1–#{n}"),
}
}
fn unquote(target: &str) -> &str {
target.trim().trim_matches(|c| c == '"' || c == '\'').trim()
}
pub fn name_is_shared<T>(items: &[T], i: usize, name: impl Fn(&T) -> &str) -> bool {
let Some(own) = items.get(i).map(&name) else {
return false;
};
items
.iter()
.enumerate()
.any(|(j, item)| j != i && super::chat_file::same_name(name(item), own))
}
pub fn paginate(text: &str, page_tokens: usize) -> Vec<&str> {
let budget = byte_budget(page_tokens);
let mut pages = Vec::new();
let mut rest = text;
while rest.len() > budget {
let cut = cut_point(rest, budget);
pages.push(&rest[..cut]);
rest = &rest[cut..];
}
pages.push(rest);
pages
}
pub fn excerpt(text: &str, max_tokens: usize) -> &str {
&text[..cut_point(text, byte_budget(max_tokens))]
}
fn byte_budget(tokens: usize) -> usize {
tokens.max(1).saturating_mul(4)
}
fn cut_point(text: &str, budget: usize) -> usize {
if text.len() <= budget {
return text.len();
}
let mut end = budget;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
let head = &text[..end];
let floor = end / 4;
let after = |i: usize, c: char| i + c.len_utf8();
head.rfind('\n')
.map(|i| after(i, '\n'))
.filter(|&c| c > floor)
.or_else(|| {
head.char_indices()
.rev()
.find(|(_, c)| c.is_whitespace())
.map(|(i, c)| after(i, c))
.filter(|&c| c > floor)
})
.unwrap_or(end)
}
pub fn format_bytes(bytes: usize) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
let b = bytes as f64;
if b >= MB {
format!("{:.1} MB", b / MB)
} else if b >= KB {
format!("{:.1} KB", b / KB)
} else {
format!("{bytes} B")
}
}
pub fn decide_mode(
est: usize,
used: usize,
cfg: &crate::shared::config::AttachmentSettings,
) -> AttachMode {
if est <= cfg.max_file_tokens && used + est <= cfg.max_total_tokens {
AttachMode::Inline
} else {
AttachMode::ByReference
}
}
pub fn inline_tokens_excluding(attachments: &[Attachment], source: &str) -> usize {
attachments
.iter()
.filter(|a| a.source != source && a.mode == AttachMode::Inline)
.map(|a| a.est_tokens)
.sum()
}
pub fn prompt_tokens(attachments: &[Attachment], excerpt_tokens: usize) -> usize {
attachments
.iter()
.map(|a| a.prompt_tokens(excerpt_tokens))
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn att(name: &str, text: &str, mode: AttachMode) -> Attachment {
Attachment::new(
name,
format!("/tmp/{name}"),
text.to_string(),
text.len(),
mode,
)
}
#[test]
fn est_tokens_follows_the_shared_heuristic() {
let a = att("a.txt", "abcdefghijklmnop", AttachMode::Inline); assert_eq!(a.est_tokens, 4);
let b = att("b.txt", "текс", AttachMode::Inline);
assert_eq!(b.est_tokens, 2);
}
#[test]
fn matches_by_name_or_path_case_insensitively() {
let a = att("Notes.md", "x", AttachMode::Inline);
assert!(a.matches("notes.md"));
assert!(a.matches("NOTES.MD"));
assert!(a.matches("/tmp/Notes.md"));
assert!(a.matches(" \"notes.md\" "));
assert!(!a.matches("other.md"));
}
#[test]
fn a_handle_resolves_to_one_item_to_every_holder_of_a_shared_name_or_to_nothing() {
let file = |dir: &str, name: &str| {
Attachment::new(
name,
format!("/tmp/{dir}/{name}"),
"x".into(),
1,
AttachMode::Inline,
)
};
let items = vec![
file("a", "notes.md"),
file("b", "Notes.md"),
file("a", "todo.txt"),
];
let resolve = |target: &str| resolve_handle(&items, target, Attachment::matches);
assert_eq!(resolve("#2"), Resolved::One(1), "#N is never ambiguous");
assert_eq!(
resolve("\"#2\""),
Resolved::One(1),
"quoted, it is still #N"
);
assert_eq!(
resolve("/tmp/b/Notes.md"),
Resolved::One(1),
"nor is a path"
);
assert_eq!(resolve("todo.txt"), Resolved::One(2));
assert_eq!(resolve("NOTES.MD"), Resolved::Shared(vec![0, 1]));
assert_eq!(resolve(" \"notes.md\" "), Resolved::Shared(vec![0, 1]));
for nothing in ["#0", "#4", "#x", "other.md", ""] {
assert_eq!(resolve(nothing), Resolved::Nothing, "{nothing:?}");
}
let shared: Vec<bool> = (0..items.len())
.map(|i| name_is_shared(&items, i, |a| a.name.as_str()))
.collect();
assert_eq!(shared, [true, true, false]);
assert!(!name_is_shared(&items, 9, |a| a.name.as_str()));
}
#[test]
fn a_bare_number_is_its_handle_unless_an_item_is_called_that() {
let items = vec![
att("3", "x", AttachMode::Inline),
att("chart.png", "x", AttachMode::Inline),
att("notes.md", "x", AttachMode::Inline),
];
let resolve = |target: &str| resolve_handle(&items, target, Attachment::matches);
assert_eq!(resolve("1"), Resolved::One(0), "`1` is `#1`");
assert_eq!(
resolve(" \"2\" "),
Resolved::One(1),
"quoted, still a number"
);
assert_eq!(resolve("3"), Resolved::One(0), "the file called `3` wins");
assert_eq!(
resolve("#3"),
Resolved::One(2),
"and `#3` is still the third"
);
for nothing in ["0", "4", "+1", "1.5", "99999999999999999999999"] {
assert_eq!(resolve(nothing), Resolved::Nothing, "{nothing:?}");
}
assert_eq!(handle_number("#2"), Some(2));
assert_eq!(handle_number("2"), Some(2));
assert_eq!(handle_number(" '7' "), Some(7));
for name in ["chart.png", "#x", "+1", "12a", ""] {
assert_eq!(handle_number(name), None, "{name:?}");
}
assert_eq!(handle_range(1), "#1");
assert_eq!(handle_range(3), "#1–#3");
}
#[test]
fn the_listing_and_the_removal_share_one_fold() {
let file = |dir: &str, name: &str| {
Attachment::new(
name,
format!("/tmp/{dir}/{name}"),
"x".into(),
1,
AttachMode::Inline,
)
};
let items = vec![
file("a", "Отчёт.csv"),
file("b", "отчёт.csv"),
file("a", "sales.csv"),
];
let shared: Vec<bool> = (0..items.len())
.map(|i| name_is_shared(&items, i, |a| a.name.as_str()))
.collect();
assert_eq!(shared, [true, true, false], "one name, two holders");
assert_eq!(
resolve_handle(&items, "ОТЧЁТ.CSV", Attachment::matches),
Resolved::Shared(vec![0, 1]),
"and the removal reads it the same way"
);
}
#[test]
fn excerpt_cuts_on_word_boundary_and_keeps_short_text_whole() {
let short = "small";
assert_eq!(excerpt(short, 10), short);
let long = "aaaa bbbb cccc dddd";
let cut = excerpt(long, 2);
assert!(long.starts_with(cut), "the excerpt is a prefix: {cut:?}");
assert!(cut.len() <= 8, "the excerpt fits the byte budget: {cut:?}");
assert!(
cut.ends_with(char::is_whitespace),
"cut on a word boundary: {cut:?}"
);
assert_eq!(cut, paginate(long, 2)[0]);
}
#[test]
fn excerpt_never_splits_a_character() {
let text = "абвгдеёжзийклмн";
let cut = excerpt(text, 1); assert!(text.starts_with(cut));
assert!(cut.chars().count() <= 2);
}
#[test]
fn inline_tokens_ignores_by_reference_and_the_source_being_replaced() {
let list = vec![
att("a.txt", "abcdefgh", AttachMode::Inline), att("b.txt", "abcdefgh", AttachMode::ByReference),
att("c.txt", "abcd", AttachMode::Inline), ];
assert_eq!(inline_tokens_excluding(&list, ""), 3);
assert_eq!(inline_tokens_excluding(&list, "/tmp/a.txt"), 1);
}
#[test]
fn mode_is_by_reference_past_either_budget() {
let cfg = crate::shared::config::AttachmentSettings {
max_file_tokens: 100,
max_total_tokens: 150,
..Default::default()
};
assert_eq!(decide_mode(100, 0, &cfg), AttachMode::Inline);
assert_eq!(decide_mode(101, 0, &cfg), AttachMode::ByReference);
assert_eq!(decide_mode(60, 100, &cfg), AttachMode::ByReference);
assert_eq!(decide_mode(50, 100, &cfg), AttachMode::Inline);
}
#[test]
fn pagination_covers_the_text_exactly_and_prefers_line_breaks() {
let text = "строка один\nстрока два\nстрока три\nстрока четыре\nстрока пять\n";
let pages = paginate(text, 5); assert!(pages.len() > 1, "the text must split: {pages:?}");
assert_eq!(pages.concat(), text);
assert!(
pages[..pages.len() - 1]
.iter()
.all(|p| p.ends_with('\n') || p.ends_with(' ')),
"pages should end on a line/word boundary: {pages:?}"
);
}
#[test]
fn short_and_empty_text_is_a_single_page() {
assert_eq!(paginate("short", 100), vec!["short"]);
assert_eq!(paginate("", 100), vec![""]);
let a = att("a.txt", "", AttachMode::ByReference);
assert_eq!(a.page_count(100), 1, "page 1 always exists");
assert_eq!(a.page(100, 1), Some(""));
assert_eq!(a.page(100, 0), None, "pages are 1-based");
assert_eq!(a.page(100, 2), None);
}
#[test]
fn pagination_never_splits_a_character() {
let text = "абвгдеёжзийклмнопрстуфхцч".repeat(4);
let pages = paginate(&text, 3); assert_eq!(pages.concat(), text);
assert!(pages.iter().all(|p| p.chars().count() * 2 == p.len()));
}
#[test]
fn prompt_cost_counts_the_excerpt_for_by_reference_not_the_whole_file() {
let big = "слово ".repeat(2000); let inline = att("a.txt", &big, AttachMode::Inline);
let by_ref = att("b.txt", &big, AttachMode::ByReference);
assert_eq!(inline.prompt_tokens(50), inline.est_tokens);
let cost = by_ref.prompt_tokens(50);
assert!(cost <= 50, "a by-reference file costs its excerpt: {cost}");
assert!(cost > 0, "and it is NOT free: {cost}");
}
#[test]
fn format_bytes_switches_units() {
assert_eq!(format_bytes(840), "840 B");
assert_eq!(format_bytes(12_595), "12.3 KB");
assert_eq!(format_bytes(1_468_006), "1.4 MB");
}
#[test]
fn serde_roundtrip() {
let a = att("n.md", "содержимое", AttachMode::ByReference);
let json = serde_json::to_string(&a).unwrap();
let back: Attachment = serde_json::from_str(&json).unwrap();
assert_eq!(a, back);
assert!(json.contains("by_reference"), "{json}");
}
}