ruvector_scipix/api/
handlers.rs

1use axum::{
2    extract::{Path, Query, State},
3    http::StatusCode,
4    response::{sse::Event, IntoResponse, Sse},
5    Json,
6};
7use futures::stream::{self, Stream};
8use serde::{Deserialize, Serialize};
9use std::{convert::Infallible, time::Duration};
10use tracing::{error, info, warn};
11use validator::Validate;
12
13use super::{
14    jobs::{JobStatus, PdfJob},
15    requests::{LatexRequest, PdfRequest, StrokesRequest, TextRequest},
16    responses::{ErrorResponse, PdfResponse, TextResponse},
17    state::AppState,
18};
19
20/// Health check handler
21pub async fn get_health() -> impl IntoResponse {
22    #[derive(Serialize)]
23    struct Health {
24        status: &'static str,
25        version: &'static str,
26    }
27
28    Json(Health {
29        status: "ok",
30        version: env!("CARGO_PKG_VERSION"),
31    })
32}
33
34/// Process text/image OCR request
35/// Supports multipart/form-data, base64, and URL inputs
36///
37/// # Important
38/// This endpoint requires OCR models to be configured. If models are not available,
39/// returns a 503 Service Unavailable error with instructions.
40pub async fn process_text(
41    State(_state): State<AppState>,
42    Json(request): Json<TextRequest>,
43) -> Result<Json<TextResponse>, ErrorResponse> {
44    info!("Processing text OCR request");
45
46    // Validate request
47    request.validate().map_err(|e| {
48        warn!("Invalid request: {:?}", e);
49        ErrorResponse::validation_error(format!("Validation failed: {}", e))
50    })?;
51
52    // Download or decode image
53    let image_data = match request.get_image_data().await {
54        Ok(data) => data,
55        Err(e) => {
56            error!("Failed to get image data: {:?}", e);
57            return Err(ErrorResponse::internal_error("Failed to process image"));
58        }
59    };
60
61    // Validate image data is not empty
62    if image_data.is_empty() {
63        return Err(ErrorResponse::validation_error("Image data is empty"));
64    }
65
66    // OCR processing requires models to be configured
67    // Return informative error explaining how to set up the service
68    Err(ErrorResponse::service_unavailable(
69        "OCR service not fully configured. ONNX models are required for OCR processing. \
70         Please download compatible models (PaddleOCR, TrOCR) and configure the model directory. \
71         See documentation at /docs/MODEL_SETUP.md for setup instructions."
72    ))
73}
74
75/// Process digital ink strokes
76///
77/// # Important
78/// This endpoint requires OCR models to be configured.
79pub async fn process_strokes(
80    State(_state): State<AppState>,
81    Json(request): Json<StrokesRequest>,
82) -> Result<Json<TextResponse>, ErrorResponse> {
83    info!("Processing strokes request with {} strokes", request.strokes.len());
84
85    request.validate().map_err(|e| {
86        ErrorResponse::validation_error(format!("Validation failed: {}", e))
87    })?;
88
89    // Validate we have stroke data
90    if request.strokes.is_empty() {
91        return Err(ErrorResponse::validation_error("No strokes provided"));
92    }
93
94    // Stroke recognition requires models to be configured
95    Err(ErrorResponse::service_unavailable(
96        "Stroke recognition service not configured. ONNX models required for ink recognition."
97    ))
98}
99
100/// Process legacy LaTeX equation request
101///
102/// # Important
103/// This endpoint requires OCR models to be configured.
104pub async fn process_latex(
105    State(_state): State<AppState>,
106    Json(request): Json<LatexRequest>,
107) -> Result<Json<TextResponse>, ErrorResponse> {
108    info!("Processing legacy LaTeX request");
109
110    request.validate().map_err(|e| {
111        ErrorResponse::validation_error(format!("Validation failed: {}", e))
112    })?;
113
114    // LaTeX recognition requires models to be configured
115    Err(ErrorResponse::service_unavailable(
116        "LaTeX recognition service not configured. ONNX models required."
117    ))
118}
119
120/// Create async PDF processing job
121pub async fn process_pdf(
122    State(state): State<AppState>,
123    Json(request): Json<PdfRequest>,
124) -> Result<Json<PdfResponse>, ErrorResponse> {
125    info!("Creating PDF processing job");
126
127    request.validate().map_err(|e| {
128        ErrorResponse::validation_error(format!("Validation failed: {}", e))
129    })?;
130
131    // Create job
132    let job = PdfJob::new(request);
133    let job_id = job.id.clone();
134
135    // Queue job
136    state
137        .job_queue
138        .enqueue(job)
139        .await
140        .map_err(|e| {
141            error!("Failed to enqueue job: {:?}", e);
142            ErrorResponse::internal_error("Failed to create PDF job")
143        })?;
144
145    let response = PdfResponse {
146        pdf_id: job_id,
147        status: JobStatus::Processing,
148        message: Some("PDF processing started".to_string()),
149        result: None,
150        error: None,
151    };
152
153    Ok(Json(response))
154}
155
156/// Get PDF job status
157pub async fn get_pdf_status(
158    State(state): State<AppState>,
159    Path(id): Path<String>,
160) -> Result<Json<PdfResponse>, ErrorResponse> {
161    info!("Getting PDF job status: {}", id);
162
163    let status = state
164        .job_queue
165        .get_status(&id)
166        .await
167        .ok_or_else(|| ErrorResponse::not_found("Job not found"))?;
168
169    let response = PdfResponse {
170        pdf_id: id.clone(),
171        status: status.clone(),
172        message: Some(format!("Job status: {:?}", status)),
173        result: state.job_queue.get_result(&id).await,
174        error: state.job_queue.get_error(&id).await,
175    };
176
177    Ok(Json(response))
178}
179
180/// Delete PDF job
181pub async fn delete_pdf_job(
182    State(state): State<AppState>,
183    Path(id): Path<String>,
184) -> Result<StatusCode, ErrorResponse> {
185    info!("Deleting PDF job: {}", id);
186
187    state
188        .job_queue
189        .cancel(&id)
190        .await
191        .map_err(|_| ErrorResponse::not_found("Job not found"))?;
192
193    Ok(StatusCode::NO_CONTENT)
194}
195
196/// Stream PDF processing results via SSE
197pub async fn stream_pdf_results(
198    State(_state): State<AppState>,
199    Path(_id): Path<String>,
200) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
201    info!("Streaming PDF results for job: {}", _id);
202
203    let stream = stream::unfold(0, move |page| {
204
205        async move {
206            if page > 10 {
207                // Example: stop after 10 pages
208                return None;
209            }
210
211            tokio::time::sleep(Duration::from_millis(500)).await;
212
213            let event = Event::default()
214                .json_data(serde_json::json!({
215                    "page": page,
216                    "text": format!("Content from page {}", page),
217                    "progress": (page as f32 / 10.0) * 100.0
218                }))
219                .ok()?;
220
221            Some((Ok(event), page + 1))
222        }
223    });
224
225    Sse::new(stream)
226}
227
228/// Convert document to different format (MMD/DOCX/etc)
229///
230/// # Note
231/// Document conversion requires additional backend services to be configured.
232pub async fn convert_document(
233    State(_state): State<AppState>,
234    Json(_request): Json<serde_json::Value>,
235) -> Result<Json<serde_json::Value>, ErrorResponse> {
236    info!("Converting document");
237
238    // Document conversion is not yet implemented
239    Err(ErrorResponse::not_implemented(
240        "Document conversion is not yet implemented. This feature requires additional backend services."
241    ))
242}
243
244/// Get OCR processing history
245#[derive(Deserialize)]
246pub struct HistoryQuery {
247    #[serde(default)]
248    page: u32,
249    #[serde(default = "default_limit")]
250    limit: u32,
251}
252
253fn default_limit() -> u32 {
254    50
255}
256
257/// Get OCR processing history
258///
259/// # Note
260/// History storage requires a database backend to be configured.
261/// Returns empty results if no database is available.
262pub async fn get_ocr_results(
263    State(_state): State<AppState>,
264    Query(params): Query<HistoryQuery>,
265) -> Result<Json<serde_json::Value>, ErrorResponse> {
266    info!("Getting OCR results history: page={}, limit={}", params.page, params.limit);
267
268    // History storage not configured - return empty results with notice
269    Ok(Json(serde_json::json!({
270        "results": [],
271        "total": 0,
272        "page": params.page,
273        "limit": params.limit,
274        "notice": "History storage not configured. Results are not persisted."
275    })))
276}
277
278/// Get OCR usage statistics
279///
280/// # Note
281/// Usage tracking requires a database backend to be configured.
282/// Returns zeros if no database is available.
283pub async fn get_ocr_usage(
284    State(_state): State<AppState>,
285) -> Result<Json<serde_json::Value>, ErrorResponse> {
286    info!("Getting OCR usage statistics");
287
288    // Usage tracking not configured - return zeros with notice
289    Ok(Json(serde_json::json!({
290        "requests_today": 0,
291        "requests_month": 0,
292        "quota_limit": null,
293        "quota_remaining": null,
294        "notice": "Usage tracking not configured. Statistics are not recorded."
295    })))
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[tokio::test]
303    async fn test_health_check() {
304        let response = get_health().await.into_response();
305        assert_eq!(response.status(), StatusCode::OK);
306    }
307}