File Upload and Analysis API
File Upload and Analysis API
Folder src
25 printable files
src\api\[Link]
src\api\file_upload.rs
src\api\[Link]
src\api\[Link]
src\core\[Link]
src\core\[Link]
src\core\[Link]
src\core\[Link]
src\core\sentence_pipeline.rs
src\core\[Link]
src\core\[Link]
src\core\[Link]
src\core\[Link]
src\extraction\[Link]
src\extraction\[Link]
src\extraction\[Link]
src\extraction\[Link]
src\[Link]
src\[Link]
src\models\[Link]
src\models\[Link]
src\models\[Link]
src\models\[Link]
src\models\sentence_analysis.rs
src\sentence\[Link]
src\api\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 1/39
12/8/25, 8:45 AM src
28
29 #[error("Internal server error: {0}")]
30 Internal(#[from] anyhow::Error),
31 }
32
33 /// Error response body
34 #[derive(Debug, Serialize)]
35 struct ErrorResponse {
36 error: String,
37 code: String,
38 }
39
40 impl IntoResponse for AppError {
41 fn into_response(self) -> Response {
42 let (status, code) = match &self {
43 AppError::TooManyDocuments(_) => (StatusCode::BAD_REQUEST,
"TOO_MANY_DOCUMENTS"),
44 AppError::EmptyDocument(_) => (StatusCode::BAD_REQUEST, "EMPTY_DOCUMENT"),
45 AppError::NoDocuments => (StatusCode::BAD_REQUEST, "NO_DOCUMENTS"),
46 AppError::NotEnoughDocuments(_) => (StatusCode::BAD_REQUEST,
"NOT_ENOUGH_DOCUMENTS
"),
47 AppError::DocumentTooLong(_, _) => (StatusCode::BAD_REQUEST,
"DOCUMENT_TOO_LONG"),
48 AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERRO
R, "INTERNAL_ERROR"),
49 };
50
51 let body = ErrorResponse {
52 error: self.to_string(),
53 code: code.to_string(),
54 };
55
56 (status, Json(body)).into_response()
57 }
58 }
59
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 2/39
12/8/25, 8:45 AM src
src\api\file_upload.rs
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 3/39
12/8/25, 8:45 AM src
52 if sentences.is_empty() {
53 return Err(FileUploadError::EmptyDocument(filename));
54 }
55
56 Ok(SentenceDocument::new(filename, sentences))
57 })
58 .collect();
59
60 let documents = documents?;
61
62 // Count total sentences
63 let total_sentences: usize = [Link]().map(|d| [Link]()).sum();
64
65 // Analyze similarity
66 let (matches, global_similarity) = analyze_sentence_sim
ilarity(&documents, threshold);
67
68 // Compute processing time
69 let processing_time_ms = start_time.elapsed().as_millis() as u64;
70
71 // Build metadata
72 let metadata = AnalysisMetadata::new(
73 [Link](),
74 total_sentences,
75 processing_time_ms,
76 threshold,
77 );
78
79 // Build response
80 let response = SentenceAnalysisResp
onse::new(metadata, matches, global_similarity);
81
82 Ok(Json(response))
83 }
84
85 /// Health check endpoint
86 pub async fn health_handler() -> &'static str {
87 "OK"
88 }
89
90 /// Errors that can occur during file upload and processing
91 #[derive(Debug)]
92 pub enum FileUploadError {
93 InvalidMultipart(String),
94 MissingFilename,
95 ReadError(String),
96 FileTooLarge(String, usize),
97 TotalSizeTooLarge(usize),
98 TooManyFiles(usize),
99 NotEnoughFiles(usize),
100 UnsupportedFileType(String),
101 ExtractionError(String, String),
102 EmptyDocument(String),
103 InvalidThreshold(String),
104 InvalidThresholdRang
e(f32),
105 }
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 4/39
12/8/25, 8:45 AM src
106
107 impl IntoResponse for FileUploadError {
108 fn into_response(self) -> Response {
109 let (status, message) = match self {
110 FileUploadError::InvalidMultipart(e) => {
111 (StatusCode::BAD_REQUEST, format!("Invalid multipart data: {}", e))
112 }
113 FileUploadError::MissingFilename => {
114 (StatusCode::BAD_REQUEST, "File is missing filename".to_string())
115 }
116 FileUploadError::ReadError(e) => {
117 (StatusCode::BAD_REQUEST, format!("Error reading file: {}", e))
118 }
119 FileUploadError::FileTooLarge(filename, max) => {
120 (
121 StatusCode::PAYLOAD_TOO_LARGE,
122 format!("File '{}' exceeds maximum size of {} bytes", filename, max),
123 )
124 }
125 FileUploadError::TotalSizeTooLarge(max) => {
126 (
127 StatusCode::PAYLOAD_TOO_LARGE,
128 format!("Total upload size exceeds maximum of {} bytes", max),
129 )
130 }
131 FileUploadError::TooManyFiles(max) => {
132 (
133 StatusCode::BAD_REQUEST,
134 format!("Too many files. Maximum allowed: {}", max),
135 )
136 }
137 FileUploadError::NotEnoughFiles(min) => {
138 (
139 StatusCode::BAD_REQUEST,
140 format!("Not enough files. Minimum required: {}", min),
141 )
142 }
143 FileUploadError::UnsupportedFileType(filename) => {
144 (
145 StatusCode::BAD_REQUEST,
146 format!("Unsupported file type: {}. Allowed: PDF, DOCX, TXT",
filename),
147 )
148 }
149 FileUploadError::ExtractionError(filename, error) => {
150 (
151 StatusCode::UNPROCESSABLE_ENTITY
,
152 format!("Failed to extract text from '{}': {}", filename, error),
153 )
154 }
155 FileUploadError::EmptyDocument(filename) => {
156 (
157 StatusCode::BAD_REQUEST,
158 format!("Document '{}' contains no text or sentences", filename),
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 5/39
12/8/25, 8:45 AM src
159 )
160 }
161 FileUploadError::InvalidThreshold(value) => {
162 (
163 StatusCode::BAD_REQUEST,
164 format!("Invalid threshold value: '{}'. Must be a number between 0.0
and 1.0", value),
165 )
166 }
167 FileUploadError::InvalidThresholdRang
e(value) => {
168 (
169 StatusCode::BAD_REQUEST,
170 format!("Threshold {} out of range. Must be between 0.0 and 1.0",
value),
171 )
172 }
173 };
174
175 (status, message).into_response()
176 }
177 }
178
179 /// Extract files and threshold from multipart form data
180 async fn extract_files_and_th
reshold(
181 multipart: &mut Multipart,
182 ) -> Result<(Vec<(String, Vec<u8>)>, f32), FileUploadError> {
183 let mut files: Vec<(String, Vec<u8>)> = Vec::new();
184 let mut threshold_value: Option<f32> = None;
185 let mut total_size = 0usize;
186
187 while let Some(field) = multipart.next_field().await
188 .map_err(|e| FileUploadError::InvalidMultipart(e.to_string()))? {
189
190 let field_name = [Link]().unwrap_or("").to_string();
191
192 // Check if this is the threshold field
193 if field_name == "threshold" {
194 let threshold_str = [Link]().await
195 .map_err(|e| FileUploadError::ReadError(e.to_string()))?;
196
197 threshold_value = Some(
198 threshold_str.trim().parse::<f32>()
199 .map_err(|_| FileUploadError::InvalidThreshold(threshold_str))?
200 );
201 continue;
202 }
203
204 // Otherwise, it's a file field
205 let filename = field.file_name()
206 .ok_or(FileUploadError::MissingFilename)?
207 .to_string();
208
209 let data = [Link]().await
210 .map_err(|e| FileUploadError::ReadError(e.to_string()))?
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 6/39
12/8/25, 8:45 AM src
211 .to_vec();
212
213 // Check individual file size
214 if [Link]() > MAX_FILE_SIZE {
215 return Err(FileUploadError::FileTooLarge(filename, MAX_FILE_SIZE));
216 }
217
218 total_size += [Link]();
219
220 // Check total size
221 if total_size > MAX_TOTAL_SIZE {
222 return Err(FileUploadError::TotalSizeTooLarge(MAX_TOTAL_SIZE));
223 }
224
225 [Link]((filename, data));
226
227 // Check max files
228 if [Link]() > MAX_FILES {
229 return Err(FileUploadError::TooManyFiles(MAX_FILES));
230 }
231 }
232
233 // Use provided threshold or default
234 let threshold = threshold_value.unwrap_or(DEFAULT_THRESHOLD);
235
236 // Validate threshold range
237 if threshold < 0.0 || threshold > 1.0 {
238 return Err(FileUploadError::InvalidThresholdRang
e(threshold));
239 }
240
241 Ok((files, threshold))
242 }
243
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 7/39
12/8/25, 8:45 AM src
src\api\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 8/39
12/8/25, 8:45 AM src
src\api\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 9/39
12/8/25, 8:45 AM src
src\core\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 10/39
12/8/25, 8:45 AM src
src\core\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 11/39
12/8/25, 8:45 AM src
src\core\[Link]
1 //! Core processing functions - all pure functions with no side effects
2
3 mod normalize;
4 mod tokenize;
5 mod tf;
6 mod idf;
7 mod vectorize;
8 mod similarity;
9 mod matrix;
10 mod sentence_pipeline;
11
12 pub use normalize::normalize_text;
13 pub use tokenize::tokenize;
14 pub use tf::compute_tf;
15 pub use idf::compute_idf;
16 pub use vectorize::{vectorize, compute_tfidf_vector
};
17 pub use similarity::{cosine_similarity, compute_cosine_simil
arity};
18 pub use matrix::compute_similarity_m
atrix;
19 pub use sentence_pipeline::{analyze_sentence_sim
ilarity, SentenceDocument};
20
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 12/39
12/8/25, 8:45 AM src
src\core\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 13/39
12/8/25, 8:45 AM src
src\core\sentence_pipeline.rs
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 15/39
12/8/25, 8:45 AM src
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 16/39
12/8/25, 8:45 AM src
157 .or_insert_with(Vec::new)
158 .push(vector);
159 acc
160 });
161
162 let empty_vec: Vec<&SentenceVector> = Vec::new();
163
164 // Collect all document pairs first, then map to compute similarities
165 let doc_pairs: Vec<(usize, usize)> = (0..[Link]())
166 .flat_map(|doc_a_idx| {
167 ((doc_a_idx + 1)..[Link]()).map(move |doc_b_idx| (doc_a_idx,
doc_b_idx))
168 })
169 .collect();
170
171 // Compute similarity for each pair
172 let mut global_sims: Vec<GlobalSimilarity> = doc_pairs
173 .iter()
174 .filter_map(|(doc_a_idx, doc_b_idx)| {
175 let vecs_a = doc_vectors.get(doc_a_idx).unwrap_or(&empty_vec);
176 let vecs_b = doc_vectors.get(doc_b_idx).unwrap_or(&empty_vec);
177
178 if vecs_a.is_empty() || vecs_b.is_empty() {
179 return None;
180 }
181
182 // Compute all cross-document sentence similarities using flat_map
183 let similarities: Vec<f32> = vecs_a
184 .iter()
185 .flat_map(|vec_a| {
186 vecs_b.iter().map(|vec_b| {
187 compute_cosine_simil
arity(&vec_a.vector, &vec_b.vector)
188 })
189 })
190 .collect();
191
192 // Average similarity
193 let avg_similarity = [Link]().sum::<f32>() / [Link]() as
f32;
194
195 Some(GlobalSimilarity::new(
196 documents[*doc_a_idx].[Link](),
197 documents[*doc_b_idx].[Link](),
198 avg_similarity,
199 ))
200 })
201 .collect();
202
203 // Sort by score descending (must use mut here as sort_by requires &mut self)
204 global_sims.sort_by(|a, b| [Link].partial_cmp(&[Link]).unwrap());
205
206 global_sims
207 }
208
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 17/39
12/8/25, 8:45 AM src
src\core\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 18/39
12/8/25, 8:45 AM src
52
53 // Compute magnitudes
54 let magnitude_a: f32 = vec_a.values().map(|x| x * x).sum::<f32>().sqrt();
55 let magnitude_b: f32 = vec_b.values().map(|x| x * x).sum::<f32>().sqrt();
56
57 if magnitude_a == 0.0 || magnitude_b == 0.0 {
58 return 0.0;
59 }
60
61 dot_product / (magnitude_a * magnitude_b)
62 }
63
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 19/39
12/8/25, 8:45 AM src
src\core\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 20/39
12/8/25, 8:45 AM src
src\core\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 21/39
12/8/25, 8:45 AM src
src\core\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 22/39
12/8/25, 8:45 AM src
src\extraction\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 23/39
12/8/25, 8:45 AM src
52 } else {
53 Some(para_text)
54 }
55 }
56 _ => None,
57 }
58 })
59 .collect();
60
61 text_parts.join("\n")
62 })
63 }
64
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 24/39
12/8/25, 8:45 AM src
src\extraction\[Link]
1 //! File extraction modules for PDF, DOCX, and TXT files
2
3 pub mod pdf;
4 pub mod docx;
5 pub mod txt;
6
7 pub use self::pdf::extract_pdf;
8 pub use self::docx::extract_docx;
9 pub use self::txt::extract_txt;
10
11 use std::path::Path;
12
13 /// Supported file types for extraction
14 #[derive(Debug, Clone, Copy, PartialEq)]
15 pub enum FileType {
16 Pdf,
17 Docx,
18 Txt,
19 }
20
21 impl FileType {
22 /// Detect file type from extension
23 pub fn from_extension(extension: &str) -> Option<Self> {
24 match extension.to_lowercase().as_str() {
25 "pdf" => Some(FileType::Pdf),
26 "docx" => Some(FileType::Docx),
27 "txt" => Some(FileType::Txt),
28 _ => None,
29 }
30 }
31
32 /// Detect file type from filename
33 pub fn from_filename(filename: &str) -> Option<Self> {
34 Path::new(filename)
35 .extension()
36 .and_then(|ext| ext.to_str())
37 .and_then(Self::from_extension)
38 }
39 }
40
41 /// Extract text from file bytes based on file type
42 pub fn extract_text(file_bytes: &[u8], file_type: FileType) -> Result<String, String> {
43 match file_type {
44 FileType::Pdf => extract_pdf(file_bytes),
45 FileType::Docx => extract_docx(file_bytes),
46 FileType::Txt => extract_txt(file_bytes),
47 }
48 }
49
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 25/39
12/8/25, 8:45 AM src
src\extraction\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 26/39
12/8/25, 8:45 AM src
src\extraction\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 27/39
12/8/25, 8:45 AM src
src\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 28/39
12/8/25, 8:45 AM src
src\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 29/39
12/8/25, 8:45 AM src
src\models\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 30/39
12/8/25, 8:45 AM src
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 31/39
12/8/25, 8:45 AM src
src\models\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 32/39
12/8/25, 8:45 AM src
src\models\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 33/39
12/8/25, 8:45 AM src
src\models\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 34/39
12/8/25, 8:45 AM src
src\models\sentence_analysis.rs
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 35/39
12/8/25, 8:45 AM src
52
53 impl SentenceMatch {
54 pub fn new(
55 source_doc: String,
56 source_sentence_inde
x: usize,
57 source_sentence: String,
58 target_doc: String,
59 target_sentence_inde
x: usize,
60 target_sentence: String,
61 similarity: f32,
62 ) -> Self {
63 Self {
64 source_doc,
65 source_sentence_inde
x,
66 source_sentence,
67 target_doc,
68 target_sentence_inde
x,
69 target_sentence,
70 similarity,
71 }
72 }
73 }
74
75 /// Global similarity between two documents
76 #[derive(Debug, Clone, Serialize, Deserialize)]
77 pub struct GlobalSimilarity {
78 /// First document filename
79 #[serde(rename = "docA")]
80 pub doc_a: String,
81 /// Second document filename
82 #[serde(rename = "docB")]
83 pub doc_b: String,
84 /// Overall similarity score (0.0 to 1.0)
85 pub score: f32,
86 }
87
88 impl GlobalSimilarity {
89 pub fn new(doc_a: String, doc_b: String, score: f32) -> Self {
90 Self { doc_a, doc_b, score }
91 }
92 }
93
94 /// Response payload for sentence-level document analysis
95 #[derive(Debug, Clone, Serialize, Deserialize)]
96 pub struct SentenceAnalysisResp
onse {
97 /// Analysis metadata
98 pub metadata: AnalysisMetadata,
99 /// List of sentence matches above threshold
100 pub matches: Vec<SentenceMatch>,
101 /// Global similarity scores between document pairs
102 pub global_similarity: Vec<GlobalSimilarity>,
103 }
104
105 impl SentenceAnalysisResp
onse {
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 36/39
12/8/25, 8:45 AM src
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 37/39
12/8/25, 8:45 AM src
src\sentence\[Link]
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 38/39
12/8/25, 8:45 AM src
52
53 // Combine sentences with optional remaining
54 sentences_from_range
s.into_iter()
55 .chain(remaining_sentence)
56 .collect()
57 }
58
localhost:33055/06e81d38-77a8-40c6-bafe-ec10aec40970/ 39/39