Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions backend-v2/src/handlers/document.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
use std::sync::Arc;

use aide::NoApi;
use axum::{
Json,
extract::{Multipart, Path, Query, State},
http::StatusCode,
};
use schemars::JsonSchema;
use serde::Deserialize;
use speedwagon::FileType;
use uuid::Uuid;

use crate::{
error::{ApiResult, AppError},
model::{
BatchIngestResponse, BatchPurgeResponse, BulkPurgeRequest, DocumentResponse, FailedItem,
},
state::AppState,
};

#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListDocumentsQuery {
#[serde(default)]
pub page: Option<u32>,
#[serde(default)]
pub page_size: Option<u32>,
}

pub async fn list_documents(
State(state): State<Arc<AppState>>,
Query(query): Query<ListDocumentsQuery>,
) -> ApiResult<Json<Vec<DocumentResponse>>> {
let page = query.page.unwrap_or(0);
let page_size = query.page_size.unwrap_or(50);

let store = state.store.read().await;
let docs = store
.list(false, page, page_size)
.map_err(|e| AppError::internal(e.to_string()))?;

Ok(Json(docs.into_iter().map(DocumentResponse::from).collect()))
}

pub async fn get_document(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<DocumentResponse>> {
let store = state.store.read().await;
match store.get(id) {
Some(doc) => Ok(Json(DocumentResponse::from(doc))),
None => Err(AppError::not_found("document not found")),
}
}

fn parse_filetype(filename: &str) -> Result<FileType, String> {
let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"pdf" => Ok(FileType::PDF),
"md" | "markdown" | "txt" => Ok(FileType::MD),
_ => Err(format!(
"unsupported file type '.{ext}' — supported: pdf, md, txt"
)),
}
}

pub async fn ingest_document(
State(state): State<Arc<AppState>>,
NoApi(mut multipart): NoApi<Multipart>,
) -> ApiResult<(StatusCode, Json<BatchIngestResponse>)> {
let mut valid_items: Vec<(Vec<u8>, FileType)> = Vec::new();
let mut filenames: Vec<String> = Vec::new();
let mut failed: Vec<FailedItem> = Vec::new();

while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::bad_request(format!("multipart error: {e}")))?
{
if field.name() != Some("file") {
continue;
}
let filename = field.file_name().unwrap_or("upload").to_string();
let bytes = field
.bytes()
.await
.map_err(|e| AppError::bad_request(format!("multipart error: {e}")))?;

match parse_filetype(&filename) {
Ok(filetype) => {
valid_items.push((bytes.to_vec(), filetype));
filenames.push(filename);
}
Err(e) => {
failed.push(FailedItem {
name: filename,
error: e,
});
}
}
}

if valid_items.is_empty() && failed.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(AppError::new("missing 'file' field in multipart body")),
));
}

let mut store = state.store.write().await;
let result = store
.ingest_many(valid_items)
.await
.map_err(|e| AppError::internal(e.to_string()))?;

let docs = store
.get_many(&result.succeeded)
.map_err(|e| AppError::internal(e.to_string()))?;
drop(store);

for f in result.failed {
let name = filenames
.get(f.index)
.cloned()
.unwrap_or_else(|| format!("file[{}]", f.index));
failed.push(FailedItem {
name,
error: f.error,
});
}

for doc in &docs {
tracing::info!(id = %doc.id, title = %doc.title, "document ingested");
}

let succeeded: Vec<DocumentResponse> = docs.into_iter().map(DocumentResponse::from).collect();
let status = if failed.is_empty() {
StatusCode::CREATED
} else {
StatusCode::OK
};

Ok((status, Json(BatchIngestResponse { succeeded, failed })))
}

pub async fn purge_document(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
let mut store = state.store.write().await;
match store
.purge(id)
.map_err(|e| AppError::internal(e.to_string()))?
{
Some(doc) => {
tracing::info!(%id, title = %doc.title, "document purged");
Ok(StatusCode::NO_CONTENT)
}
None => Err(AppError::not_found("document not found")),
}
}

pub async fn purge_documents(
State(state): State<Arc<AppState>>,
Json(payload): Json<BulkPurgeRequest>,
) -> ApiResult<(StatusCode, Json<BatchPurgeResponse>)> {
if payload.ids.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(AppError::new("ids must not be empty")),
));
}

let mut ids = Vec::with_capacity(payload.ids.len());
let mut failed = Vec::new();
for raw_id in payload.ids {
match Uuid::parse_str(&raw_id) {
Ok(id) => ids.push(id),
Err(_) => failed.push(FailedItem {
name: raw_id,
error: "invalid document id".into(),
}),
}
}

let mut store = state.store.write().await;
let result = store.purge_many(ids);
drop(store);

let purged: Vec<String> = result.purged.iter().map(|id| id.to_string()).collect();
failed.extend(result.failed.into_iter().map(|f| FailedItem {
name: f.id.to_string(),
error: f.error,
}));

for id in &purged {
tracing::info!(%id, "document purged");
}

Ok((StatusCode::OK, Json(BatchPurgeResponse { purged, failed })))
}
2 changes: 2 additions & 0 deletions backend-v2/src/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
mod auth;
mod document;
mod session;
mod user;

pub use auth::*;
pub use document::*;
pub use session::*;
pub use user::*;
2 changes: 1 addition & 1 deletion backend-v2/src/handlers/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use crate::{
state::AppState,
};

const DEFAULT_MODEL: &str = "openai/gpt-4o-mini";
const DEFAULT_MODEL: &str = "openai/gpt-5.4-mini";

fn sandbox_name_for(id: &Uuid) -> String {
let s = id.simple().to_string();
Expand Down
2 changes: 1 addition & 1 deletion backend-v2/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ async fn run_server() -> std::io::Result<()> {

let store_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".speedwagon");
let store = Arc::new(RwLock::new(
Store::new(store_path).expect("speedwagon store init"),
Store::new(&store_path).expect("speedwagon store init"),
));

{
Expand Down
58 changes: 58 additions & 0 deletions backend-v2/src/model/document.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use speedwagon::Document;

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct DocumentResponse {
pub id: String,
pub title: String,
pub len: usize,
}

impl From<&Document> for DocumentResponse {
fn from(doc: &Document) -> Self {
Self {
id: doc.id.clone(),
title: doc.title.clone(),
len: doc.len,
}
}
}

impl From<Document> for DocumentResponse {
fn from(doc: Document) -> Self {
Self {
id: doc.id,
title: doc.title,
len: doc.len,
}
}
}

// --- Response DTOs ---

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct FailedItem {
pub name: String,
pub error: String,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct BatchIngestResponse {
pub succeeded: Vec<DocumentResponse>,
pub failed: Vec<FailedItem>,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct BatchPurgeResponse {
pub purged: Vec<String>,
pub failed: Vec<FailedItem>,
}

// --- Request DTOs ---

#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BulkPurgeRequest {
pub ids: Vec<String>,
}
2 changes: 2 additions & 0 deletions backend-v2/src/model/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
mod document;
mod message;
mod session;
mod user;

pub use document::*;
pub use message::*;
pub use session::*;
pub use user::*;
13 changes: 13 additions & 0 deletions backend-v2/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,23 @@ pub fn get_router(state: Arc<AppState>) -> ApiRouter {
post(handlers::send_message_stream),
);

let document_routes = ApiRouter::new()
.api_route(
"/documents",
get(handlers::list_documents)
.post(handlers::ingest_document)
.delete(handlers::purge_documents),
)
.api_route(
"/documents/{id}",
get(handlers::get_document).delete(handlers::purge_document),
);

ApiRouter::new()
.merge(auth_routes)
.merge(me_routes)
.merge(admin_routes)
.merge(session_routes)
.merge(document_routes)
.with_state(state)
}
Loading