use anyhow::Result;
use crate::entities::attachment::{AttachMode, Attachment};
use crate::entities::profile::ToolId;
use crate::shared::api::EmbedRole;
use super::{Tool, ToolContext, ToolOutcome};
pub const ATTACHMENT_READ_ID: &str = "attachment_read";
pub const ATTACHMENT_SEARCH_ID: &str = "attachment_search";
const DEFAULT_TOP_K: usize = 5;
const INDEX_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
pub(crate) fn search_route(ctx: &ToolContext) -> &'static str {
if ctx.embed_configured {
ctx.loc.t("tool.attachment.search_indexing")
} else {
ctx.loc.t("tool.attachment.search_none")
}
}
pub struct AttachmentRead;
#[async_trait::async_trait]
impl Tool for AttachmentRead {
fn id(&self) -> ToolId {
ATTACHMENT_READ_ID.into()
}
fn concurrent(&self) -> bool {
true
}
fn group(&self) -> super::meta::ToolGroup {
super::meta::ToolGroup::Files
}
fn ui_label(&self) -> &'static str {
"read an attached file"
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.attachment_read.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
super::paged_read_parameters(
loc,
"name",
"tool.attachment_read.param.name",
"tool.attachment_read.param.page",
)
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
if ctx.attachments.is_empty() {
return Ok(ToolOutcome::text(ctx.loc.t("tool.attachment_read.none")));
}
let name = args
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim();
let hits: Vec<&crate::entities::attachment::Attachment> =
ctx.attachments.iter().filter(|a| a.matches(name)).collect();
if hits.len() > 1 {
let sources: Vec<&str> = hits.iter().map(|a| a.source.as_str()).collect();
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.attachment_read.ambiguous",
&[("name", name), ("sources", &sources.join(", "))],
)));
}
let Some(att) = hits.into_iter().next() else {
let names: Vec<&str> = ctx.attachments.iter().map(|a| a.name.as_str()).collect();
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.attachment_read.unknown",
&[("name", name), ("names", &names.join(", "))],
)));
};
let page_tokens = ctx.attachment_cfg.page_tokens;
let total = att.page_count(page_tokens);
let page = args
.get("page")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(1);
let Some(text) = att.page(page_tokens, page) else {
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.attachment_read.bad_page",
&[
("page", &page.to_string()),
("name", &att.name),
("total", &total.to_string()),
],
)));
};
let header = ctx.loc.tf(
"tool.attachment_read.header",
&[
("name", &att.name),
("page", &page.to_string()),
("total", &total.to_string()),
],
);
Ok(ToolOutcome::text(format!("{header}\n{text}")))
}
}
pub struct AttachmentSearch;
#[async_trait::async_trait]
impl Tool for AttachmentSearch {
fn id(&self) -> ToolId {
ATTACHMENT_SEARCH_ID.into()
}
fn concurrent(&self) -> bool {
true
}
fn group(&self) -> super::meta::ToolGroup {
super::meta::ToolGroup::Files
}
fn ui_label(&self) -> &'static str {
"search attached files"
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.attachment_search.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
super::search_parameters(
loc,
"tool.attachment_search.param.query",
"tool.attachment_search.param.top_k",
)
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
if ctx.attachments.is_empty() {
return Ok(ToolOutcome::text(ctx.loc.t("tool.attachment_read.none")));
}
let (query, k) = super::search_args(
&args,
ctx.loc,
"tool.attachment_search.err.query_empty",
DEFAULT_TOP_K,
)?;
let by_ref: Vec<&Attachment> = ctx
.attachments
.iter()
.filter(|a| a.mode == AttachMode::ByReference)
.collect();
if by_ref.is_empty() {
return Ok(ToolOutcome::text(
ctx.loc.t("tool.attachment_search.all_inline"),
));
}
let building: Vec<uuid::Uuid> = by_ref
.iter()
.filter(|a| ctx.index_board.building(a.id).is_some())
.map(|a| a.id)
.collect();
if !building.is_empty() {
ctx.index_board
.wait(&building, INDEX_WAIT, &ctx.cancel)
.await;
}
let indexed = ctx.storage.db().attachment_indexed_ids(ctx.chat_id)?;
let partial: Vec<(&Attachment, (usize, usize))> = by_ref
.iter()
.filter_map(|a| ctx.index_board.building(a.id).map(|p| (*a, p)))
.collect();
let unindexed: Vec<&Attachment> = by_ref
.iter()
.copied()
.filter(|a| !indexed.contains(&a.id) && ctx.index_board.building(a.id).is_none())
.collect();
if !by_ref.iter().any(|a| indexed.contains(&a.id)) {
let mut out = ctx.loc.t("tool.attachment_search.not_indexed").to_string();
push_lines(&mut out, ctx, &unindexed, &partial);
return Ok(ToolOutcome::text(out));
}
let Ok(mut embeddings) = ctx
.embedder
.embed(vec![query.to_string()], EmbedRole::Query)
.await
else {
return Ok(ToolOutcome::text(
ctx.loc.t("tool.attachment_search.embedder_down"),
));
};
let Some(query_vec) = embeddings.pop() else {
return Ok(ToolOutcome::text(
ctx.loc.t("tool.attachment_search.embedder_down"),
));
};
let hits = ctx
.storage
.db()
.attachment_search(ctx.chat_id, &query_vec, k)?;
let hits: Vec<_> = hits
.into_iter()
.filter(|h| ctx.attachments.iter().any(|a| a.id == h.attachment_id))
.collect();
if hits.is_empty() {
let mut out = ctx.loc.t("tool.attachment_search.result.empty").to_string();
push_not_searched(&mut out, ctx, &unindexed, &partial);
return Ok(ToolOutcome::text(out));
}
let hits: Vec<crate::entities::rag::RagHit> = hits
.into_iter()
.map(|h| crate::entities::rag::RagHit {
id: h.attachment_id,
source: h.name,
chunk_text: h.text,
distance: h.distance,
})
.collect();
let passages = super::rag::dedup_passages(super::rag::stitch_hits(hits));
let mut out = ctx.loc.tf(
"tool.attachment_search.result.header",
&[("n", &passages.len().to_string())],
);
for (i, p) in passages.iter().enumerate() {
out.push_str(&format!("\n\n{}. [{}]\n{}", i + 1, p.source, p.text));
}
out.push_str("\n\n");
out.push_str(ctx.loc.t("tool.attachment_search.result.hint"));
push_not_searched(&mut out, ctx, &unindexed, &partial);
Ok(ToolOutcome::text(out))
}
}
fn push_not_searched(
out: &mut String,
ctx: &ToolContext,
unindexed: &[&Attachment],
partial: &[(&Attachment, (usize, usize))],
) {
if !partial.is_empty() {
out.push_str("\n\n");
out.push_str(ctx.loc.t("tool.attachment_search.partial"));
push_lines(out, ctx, &[], partial);
}
if !unindexed.is_empty() {
out.push_str("\n\n");
out.push_str(ctx.loc.t("tool.attachment_search.not_searched"));
push_lines(out, ctx, unindexed, &[]);
}
}
fn push_lines(
out: &mut String,
ctx: &ToolContext,
unindexed: &[&Attachment],
partial: &[(&Attachment, (usize, usize))],
) {
let pages = |a: &Attachment| a.page_count(ctx.attachment_cfg.page_tokens).to_string();
for (a, (done, total)) in partial {
out.push('\n');
out.push_str(&ctx.loc.tf(
"tool.attachment_search.unindexed.building",
&[
("name", &a.name),
("pages", &pages(a)),
("done", &done.to_string()),
("total", &total.to_string()),
],
));
}
for a in unindexed {
out.push('\n');
out.push_str(&ctx.loc.tf(
"tool.attachment_search.unindexed.none",
&[("name", &a.name), ("pages", &pages(a))],
));
}
out.push('\n');
out.push_str(ctx.loc.t("tool.attachment_search.unindexed.route"));
if !partial.is_empty() {
out.push(' ');
out.push_str(ctx.loc.t("tool.attachment_search.building_retry"));
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
const PAGE: usize = 10;
fn ctx_with(attachments: Vec<Attachment>) -> (tempfile::TempDir, ToolContext) {
let (dir, _storage, mut ctx) = super::super::testkit::ctx_with_storage(Uuid::new_v4());
ctx.attachments = attachments.into();
ctx.attachment_cfg.page_tokens = PAGE;
(dir, ctx)
}
fn att(name: &str, text: &str) -> Attachment {
Attachment::new(
name,
format!("/tmp/{name}"),
text.to_string(),
text.len(),
AttachMode::ByReference,
)
}
#[tokio::test]
async fn reads_pages_and_reports_the_range() {
let text = "первая строка\nвторая строка\nтретья строка\nчетвёртая строка\n";
let (_d, ctx) = ctx_with(vec![att("doc.txt", text)]);
let tool = AttachmentRead;
let first = tool
.invoke(&ctx, serde_json::json!({"name": "doc.txt"}))
.await
.unwrap()
.result;
assert!(first.contains("первая строка"), "{first}");
assert!(first.contains("1"), "{first}");
let total = ctx.attachments[0].page_count(PAGE);
assert!(total > 1, "the fixture must span several pages");
let mut joined = String::new();
for p in 1..=total {
let out = tool
.invoke(&ctx, serde_json::json!({"name": "doc.txt", "page": p}))
.await
.unwrap()
.result;
joined.push_str(out.split_once('\n').unwrap().1);
}
assert_eq!(joined, text, "walking 1..M reads the whole file");
}
#[tokio::test]
async fn page_defaults_to_the_first_one() {
let (_d, ctx) = ctx_with(vec![att("doc.txt", "начало файла и продолжение текста")]);
let with = AttachmentRead
.invoke(&ctx, serde_json::json!({"name": "doc.txt", "page": 1}))
.await
.unwrap()
.result;
let without = AttachmentRead
.invoke(&ctx, serde_json::json!({"name": "doc.txt"}))
.await
.unwrap()
.result;
assert_eq!(with, without);
}
#[tokio::test]
async fn out_of_range_and_unknown_name_explain_themselves() {
let (_d, ctx) = ctx_with(vec![att("doc.txt", "коротко")]);
let out = AttachmentRead
.invoke(&ctx, serde_json::json!({"name": "doc.txt", "page": 99}))
.await
.unwrap()
.result;
assert!(out.contains("99") && out.contains('1'), "{out}");
let out = AttachmentRead
.invoke(&ctx, serde_json::json!({"name": "other.txt"}))
.await
.unwrap()
.result;
assert!(out.contains("doc.txt"), "{out}");
}
#[tokio::test]
async fn an_ambiguous_name_is_reported_instead_of_guessing() {
let one = Attachment::new(
"V Documentation",
"https://docs.example.io/memory.html",
"первый файл".into(),
11,
AttachMode::ByReference,
);
let two = Attachment::new(
"V Documentation",
"https://docs.example.io/concurrency.html",
"второй файл".into(),
11,
AttachMode::ByReference,
);
let (_d, ctx) = ctx_with(vec![one, two]);
let out = AttachmentRead
.invoke(&ctx, serde_json::json!({"name": "V Documentation"}))
.await
.unwrap()
.result;
assert!(
out.contains("memory.html"),
"the sources must be shown: {out}"
);
assert!(out.contains("concurrency.html"), "{out}");
assert!(
!out.contains("первый файл"),
"a page was read anyway: {out}"
);
let out = AttachmentRead
.invoke(
&ctx,
serde_json::json!({"name": "https://docs.example.io/concurrency.html"}),
)
.await
.unwrap()
.result;
assert!(out.contains("второй файл"), "{out}");
}
#[tokio::test]
async fn reports_clearly_when_nothing_is_attached() {
let (_d, ctx) = ctx_with(Vec::new());
let out = AttachmentRead
.invoke(&ctx, serde_json::json!({"name": "any.txt"}))
.await
.unwrap()
.result;
assert!(!out.is_empty());
}
#[tokio::test]
async fn description_and_results_are_localized() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
for desc in [
AttachmentRead.description(loc),
AttachmentSearch.description(loc),
] {
assert!(
!desc.contains('{') && !desc.contains('}'),
"{lang:?}: {desc}"
);
if lang == crate::shared::i18n::Lang::En {
assert!(
!desc.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"Cyrillic leaked into the en description: {desc}"
);
}
}
}
}
async fn index(ctx: &ToolContext, att: &Attachment, fragments: &[&str]) {
let texts: Vec<String> = fragments.iter().map(|s| s.to_string()).collect();
let vectors = ctx
.embedder
.embed(texts.clone(), EmbedRole::Passage)
.await
.unwrap();
for (text, embedding) in texts.iter().zip(vectors) {
let chunk = crate::entities::attachment::AttachmentChunk::new(
ctx.chat_id,
att.id,
&att.name,
text,
embedding,
);
ctx.storage.db().attachment_insert(&chunk).unwrap();
}
}
#[tokio::test]
async fn search_finds_the_fragment_by_meaning_and_names_the_file() {
let doc = att("doc.txt", "неважно — поиск идёт по индексу");
let (_d, ctx) = ctx_with(vec![doc.clone()]);
index(
&ctx,
&doc,
&[
"рецепт борща со свёклой и капустой",
"инструкция по замене масла в двигателе",
],
)
.await;
let out = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "борщ со свёклой"}))
.await
.unwrap()
.result;
assert!(out.contains("рецепт борща"), "{out}");
assert!(out.contains("doc.txt"), "the file must be named: {out}");
assert!(out.contains("attachment_read"), "{out}");
}
#[tokio::test]
async fn search_numbers_fragments_and_separates_them() {
let doc = att("doc.txt", "неважно");
let (_d, ctx) = ctx_with(vec![doc.clone()]);
index(
&ctx,
&doc,
&[
"первая строка первого фрагмента\nвторая строка первого фрагмента",
"совсем другое содержимое\nв несколько строк",
],
)
.await;
let out = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "строка"}))
.await
.unwrap()
.result;
assert!(
out.contains("1. [doc.txt]") && out.contains("2. [doc.txt]"),
"{out}"
);
assert!(out.contains("[doc.txt]\nпервая строка"), "{out}");
assert!(
out.contains("\n\n2. ["),
"fragments must be separated by a blank line: {out}"
);
}
#[tokio::test]
async fn search_hides_files_that_are_no_longer_attached() {
let removed = att("removed.txt", "x");
let kept = att("kept.txt", "y");
let (_d, ctx) = ctx_with(vec![kept.clone()]);
index(&ctx, &removed, &["секретный текст удалённого файла"]).await;
index(&ctx, &kept, &["обычный текст оставшегося файла"]).await;
let out = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "секретный текст"}))
.await
.unwrap()
.result;
assert!(
!out.contains("секретный текст удалённого файла") && !out.contains("removed.txt"),
"a removed file must not surface: {out}"
);
}
#[tokio::test]
async fn search_reports_when_nothing_is_indexed() {
let (_d, ctx) = ctx_with(vec![att("doc.txt", "текст")]);
let out = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "что угодно"}))
.await
.unwrap()
.result;
assert!(out.contains("attachment_read"), "{out}");
}
#[tokio::test]
async fn search_names_a_file_without_an_index() {
let doc = att("spec.md", "первая строка\nвторая строка\nтретья строка\n");
let (_d, ctx) = ctx_with(vec![doc.clone()]);
let out = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "что угодно"}))
.await
.unwrap()
.result;
let none = ctx.loc.tf(
"tool.attachment_search.unindexed.none",
&[
("name", "spec.md"),
("pages", &doc.page_count(PAGE).to_string()),
],
);
assert!(
out.starts_with(ctx.loc.t("tool.attachment_search.not_indexed")),
"{out}"
);
assert!(out.contains(&none), "{out}");
assert!(
out.contains(ctx.loc.t("tool.attachment_search.unindexed.route")),
"{out}"
);
assert!(
!out.contains(ctx.loc.t("tool.attachment_search.building_retry")),
"nothing is being built, so nothing to come back for: {out}"
);
}
#[tokio::test]
async fn search_waits_for_an_index_being_built() {
let doc = att("spec.md", "неважно — поиск идёт по индексу");
let (_d, ctx) = ctx_with(vec![doc.clone()]);
assert!(ctx.index_board.begin(doc.id, false));
ctx.index_board.progress(doc.id, 0, 2);
let builder = {
let ctx = ctx.clone();
let doc = doc.clone();
tokio::spawn(async move {
tokio::task::yield_now().await;
index(
&ctx,
&doc,
&[
"раздел 17.5: prompt_cap по умолчанию 4000",
"раздел 3: запуск сервера",
],
)
.await;
ctx.index_board.progress(doc.id, 2, 2);
ctx.index_board.finish(doc.id, None);
})
};
let out = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "prompt_cap 4000"}))
.await
.unwrap()
.result;
builder.await.unwrap();
assert!(out.contains("prompt_cap по умолчанию 4000"), "{out}");
assert!(
!out.contains(ctx.loc.t("tool.attachment_search.partial")),
"the whole file was indexed before the search ran: {out}"
);
}
#[tokio::test(start_paused = true)]
async fn search_past_the_bound_says_what_it_covered() {
let doc = att("big.md", "неважно");
let (_d, ctx) = ctx_with(vec![doc.clone()]);
ctx.index_board.begin(doc.id, false);
index(&ctx, &doc, &["начало файла: введение и оглавление"]).await;
ctx.index_board.progress(doc.id, 1, 40);
let out = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "введение"}))
.await
.unwrap()
.result;
assert!(out.contains("введение и оглавление"), "{out}");
let building = ctx.loc.tf(
"tool.attachment_search.unindexed.building",
&[
("name", "big.md"),
("pages", &doc.page_count(PAGE).to_string()),
("done", "1"),
("total", "40"),
],
);
let tail = out
.split_once(ctx.loc.t("tool.attachment_search.partial"))
.expect("no partial section")
.1;
assert!(tail.contains(&building), "{out}");
assert!(
tail.contains(ctx.loc.t("tool.attachment_search.building_retry")),
"{out}"
);
}
#[tokio::test]
async fn a_cancelled_turn_does_not_wait_for_the_index() {
let doc = att("big.md", "неважно");
let (_d, ctx) = ctx_with(vec![doc.clone()]);
ctx.index_board.begin(doc.id, false);
ctx.index_board.progress(doc.id, 0, 40);
ctx.cancel.cancel();
let out = tokio::time::timeout(
std::time::Duration::from_secs(5),
AttachmentSearch.invoke(&ctx, serde_json::json!({"query": "что угодно"})),
)
.await
.expect("the wait ignored the cancellation")
.unwrap()
.result;
assert!(
out.starts_with(ctx.loc.t("tool.attachment_search.not_indexed")),
"{out}"
);
let pages = doc.page_count(PAGE).to_string();
let building = ctx.loc.tf(
"tool.attachment_search.unindexed.building",
&[
("name", "big.md"),
("pages", &pages),
("done", "0"),
("total", "40"),
],
);
assert!(out.contains(&building), "{out}");
let none = ctx.loc.tf(
"tool.attachment_search.unindexed.none",
&[("name", "big.md"), ("pages", &pages)],
);
assert!(
!out.contains(&none),
"a file being indexed is not a file without an index: {out}"
);
}
#[tokio::test]
async fn search_names_the_files_it_did_not_see() {
let old = att("old.txt", "x");
let fresh = att("fresh.md", "y");
let (_d, ctx) = ctx_with(vec![old.clone(), fresh.clone()]);
index(&ctx, &old, &["рецепт борща со свёклой и капустой"]).await;
let none = ctx.loc.tf(
"tool.attachment_search.unindexed.none",
&[
("name", "fresh.md"),
("pages", &fresh.page_count(PAGE).to_string()),
],
);
let header = ctx.loc.t("tool.attachment_search.not_searched");
let hits = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "борщ со свёклой"}))
.await
.unwrap()
.result;
assert!(hits.contains("рецепт борща"), "{hits}");
let tail = hits.split_once(header).expect("no not-searched section").1;
assert!(tail.contains(&none), "{hits}");
assert!(
!tail.contains("old.txt"),
"an indexed file listed as unseen: {hits}"
);
let removed = att("removed.txt", "z");
index(&ctx, &removed, &["строка удалённого файла"]).await;
let out = AttachmentSearch
.invoke(
&ctx,
serde_json::json!({"query": "строка удалённого файла", "top_k": 1}),
)
.await
.unwrap()
.result;
assert!(
out.starts_with(ctx.loc.t("tool.attachment_search.result.empty")),
"{out}"
);
assert!(out.contains(header) && out.contains(&none), "{out}");
}
#[test]
fn a_new_attachment_is_offered_search_only_with_an_embedder() {
let (_d, mut ctx) = ctx_with(Vec::new());
ctx.embed_configured = true;
let with = search_route(&ctx);
assert!(with.contains("attachment_search"), "{with}");
ctx.embed_configured = false;
let without = search_route(&ctx);
assert_eq!(without, ctx.loc.t("tool.attachment.search_none"));
assert_ne!(with, without);
}
#[tokio::test]
async fn search_over_inline_files_only_says_they_are_in_full() {
let inline = Attachment::new(
"note.txt",
"/tmp/note.txt",
"короткий файл".into(),
13,
AttachMode::Inline,
);
let (_d, ctx) = ctx_with(vec![inline]);
let out = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "файл"}))
.await
.unwrap()
.result;
assert_eq!(out, ctx.loc.t("tool.attachment_search.all_inline"));
}
#[tokio::test]
async fn search_degrades_when_the_embedder_is_gone() {
struct Dead;
#[async_trait::async_trait]
impl crate::shared::api::Embedder for Dead {
async fn embed(
&self,
_texts: Vec<String>,
_role: EmbedRole,
) -> anyhow::Result<Vec<Vec<f32>>> {
anyhow::bail!("embedder unavailable")
}
}
let doc = att("doc.txt", "текст");
let (_d, mut ctx) = ctx_with(vec![doc.clone()]);
index(&ctx, &doc, &["какое-то содержимое"]).await;
ctx.embedder = std::sync::Arc::new(Dead);
let out = AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": "содержимое"}))
.await
.unwrap()
.result;
assert!(out.contains("attachment_read"), "{out}");
assert_eq!(out, ctx.loc.t("tool.attachment_search.embedder_down"));
}
#[tokio::test]
async fn search_needs_a_query_and_reports_an_empty_chat() {
let doc = att("doc.txt", "текст");
let (_d, ctx) = ctx_with(vec![doc.clone()]);
index(&ctx, &doc, &["содержимое"]).await;
assert!(
AttachmentSearch
.invoke(&ctx, serde_json::json!({"query": " "}))
.await
.is_err(),
"an empty query is a usage error"
);
let (_d2, empty) = ctx_with(Vec::new());
let out = AttachmentSearch
.invoke(&empty, serde_json::json!({"query": "что-нибудь"}))
.await
.unwrap()
.result;
assert!(!out.is_empty());
}
}