use ragrig::{AttachStrategy, AttachedDocument};
const RESERVED_PROMPT_TOKENS: usize = 1024;
const CHARS_PER_TOKEN: usize = 3;
const ATTACHMENT_PERCENT: usize = 70;
#[derive(Clone, Debug, Default)]
pub struct SciscraperAttach {
context_tokens: Option<usize>,
}
impl SciscraperAttach {
pub fn new() -> Self {
Self::default()
}
pub fn with_context_tokens(tokens: usize) -> Self {
Self {
context_tokens: Some(tokens),
}
}
pub fn budget_chars(&self) -> Option<usize> {
self.context_tokens.map(|tokens| {
tokens
.saturating_sub(RESERVED_PROMPT_TOKENS)
.saturating_mul(CHARS_PER_TOKEN)
})
}
fn attachment_budget(&self) -> usize {
self.budget_chars()
.map_or(usize::MAX, |budget| budget * ATTACHMENT_PERCENT / 100)
}
fn retrieval_budget(&self, attachments: &[AttachedDocument]) -> usize {
match self.budget_chars() {
None => usize::MAX,
Some(budget) if attachments.is_empty() => budget,
Some(budget) => budget - budget * ATTACHMENT_PERCENT / 100,
}
}
}
impl AttachStrategy for SciscraperAttach {
fn merge(&self, retrieved: &str, attachments: &[AttachedDocument]) -> String {
let mut context = String::new();
if !attachments.is_empty() {
context.push_str("<sciscraper:v1:context source=\"creator\">\n");
let mut remaining = self.attachment_budget();
let share = remaining / attachments.len();
for (index, attachment) in attachments.iter().enumerate() {
let limit = if index + 1 == attachments.len() {
remaining
} else {
share.min(remaining)
};
let total = attachment.content.chars().count();
let (content, truncated) = truncate(&attachment.content, limit);
remaining = remaining.saturating_sub(content.chars().count());
context.push_str(&format!(
"<sciscraper:v1:document name=\"{}\">{content}",
attribute(&attachment.name)
));
if truncated {
context.push_str(&format!(
"<sciscraper:v1:truncated shown=\"{}\" total=\"{total}\"/>",
content.chars().count()
));
}
context.push_str("</sciscraper:v1:document>\n");
}
context.push_str("</sciscraper:v1:context>");
}
let retrieved = retrieved.trim_end_matches('\n');
let (retrieved, _) = truncate(retrieved, self.retrieval_budget(attachments));
if !retrieved.trim().is_empty() {
if !context.is_empty() {
context.push_str("\n\n");
}
context.push_str("<sciscraper:v1:context source=\"corpus\">\n");
context.push_str(&retrieved);
context.push_str("\n</sciscraper:v1:context>");
}
context
}
fn name(&self) -> &'static str {
"sciscraper"
}
}
fn truncate(text: &str, max: usize) -> (String, bool) {
let count = text.chars().count();
if count <= max {
return (text.to_owned(), false);
}
(text.chars().take(max).collect(), true)
}
fn attribute(value: &str) -> String {
value
.replace('&', "&")
.replace('"', """)
.replace('<', "<")
}
#[cfg(test)]
mod tests {
use super::*;
fn document(name: &str, content: &str) -> AttachedDocument {
AttachedDocument {
name: name.to_owned(),
content: content.to_owned(),
}
}
#[test]
fn attachments_and_retrieval_use_the_documented_containers() {
let merged = SciscraperAttach::new().merge(
"[doc: Kano2025]\nretrieved passage",
&[
document("@Kano2025", "PAPER BODY"),
document("@Kobayashi2011", "OTHER BODY"),
],
);
assert_eq!(
merged,
"<sciscraper:v1:context source=\"creator\">\n\
<sciscraper:v1:document name=\"@Kano2025\">PAPER BODY</sciscraper:v1:document>\n\
<sciscraper:v1:document name=\"@Kobayashi2011\">OTHER BODY</sciscraper:v1:document>\n\
</sciscraper:v1:context>\n\n\
<sciscraper:v1:context source=\"corpus\">\n\
[doc: Kano2025]\nretrieved passage\n\
</sciscraper:v1:context>"
);
}
#[test]
fn empty_parts_do_not_leave_empty_containers() {
assert_eq!(SciscraperAttach::new().merge("", &[]), "");
assert_eq!(
SciscraperAttach::new().merge("chunks", &[]),
"<sciscraper:v1:context source=\"corpus\">\nchunks\n</sciscraper:v1:context>"
);
assert_eq!(
SciscraperAttach::new().merge("", &[document("@Key", "BODY")]),
"<sciscraper:v1:context source=\"creator\">\n\
<sciscraper:v1:document name=\"@Key\">BODY</sciscraper:v1:document>\n\
</sciscraper:v1:context>"
);
}
#[test]
fn attribute_values_are_escaped() {
let merged = SciscraperAttach::new().merge("", &[document("a\"<&", "BODY")]);
assert!(merged.contains("name=\"a"<&\""));
}
#[test]
fn a_token_budget_keeps_the_whole_context_within_the_window() {
let merged = SciscraperAttach::with_context_tokens(1124)
.merge(&"r".repeat(1_000), &[document("@Key", &"a".repeat(1_000))]);
assert!(merged.contains(
"<sciscraper:v1:truncated shown=\"210\" total=\"1000\"/></sciscraper:v1:document>"
));
assert!(merged.contains(&format!("source=\"corpus\">\n{}\n", "r".repeat(90))));
let body = merged
.split_once("name=\"@Key\">")
.expect("document body")
.1
.split_once("<sciscraper:v1:truncated")
.expect("truncation marker")
.0;
assert_eq!(body.chars().count(), 210);
}
#[test]
fn short_papers_do_not_get_a_marker_and_leave_room_for_retrieval() {
let merged = SciscraperAttach::with_context_tokens(1124)
.merge(&"r".repeat(1_000), &[document("@Key", "SHORT")]);
assert!(!merged.contains("<sciscraper:v1:truncated"));
assert!(merged.contains(">SHORT</sciscraper:v1:document>"));
assert!(merged.contains(&"r".repeat(90)));
}
}