use crate::{error::ApiError, state::AppState}; use axum::{ Json, extract::{Path, State}, }; use serde::Deserialize; use serde_json::{Value, json}; use uuid::Uuid; pub async fn capabilities() -> Json { Json(json!({ "api_version": "v1", "entities": { "problem": "A task with a prompt, owner-authored solution and tags.", "shelf_item": "A link or resource collected for learning.", "learning_space": "A course or a short learning session.", "lesson": "A structured lesson that can reference problems and shelf items." }, "reader": { "materials": "/api/reader/materials", "import": "/api/reader/import", "authoring_rules": "/api/reader/authoring-rules", "content_format": "Markdown with math, Mermaid, source citations and stable exercise IDs", "guide": "AGENT_GUIDE.md" } })) } #[derive(Debug, Deserialize)] pub struct AgentProblemRequest { pub title: String, pub prompt: Option, pub tags: Option>, pub source_context: Option, } pub async fn create_problem( State(state): State, Json(input): Json, ) -> Result, ApiError> { let row: Value = sqlx::query_scalar( "insert into problems (title, prompt, tags, source_context, created_by) values ($1, $2, $3, $4, 'agent') returning to_jsonb(problems.*)", ) .bind(required_title(&input.title)?) .bind(input.prompt.unwrap_or_else(empty_document)) .bind(normalize_tags(input.tags.unwrap_or_default())) .bind(input.source_context) .fetch_one(&state.platform_db) .await?; Ok(Json(row)) } #[derive(Debug, Deserialize)] pub struct AgentShelfItemRequest { pub collection_id: Option, pub kind: String, pub title: String, pub url: Option, pub asset_id: Option, pub tags: Option>, pub metadata: Option, } pub async fn create_shelf_item( State(state): State, Json(input): Json, ) -> Result, ApiError> { if !matches!( input.kind.as_str(), "article" | "book" | "file" | "link" | "image" | "video" ) { return Err(ApiError::BadRequest("unsupported shelf item kind".into())); } let row: Value = sqlx::query_scalar( "insert into shelf_items (collection_id, kind, title, url, asset_id, tags, metadata, created_by) values ($1, $2, $3, $4, $5, $6, $7, 'agent') returning to_jsonb(shelf_items.*)", ) .bind(input.collection_id) .bind(input.kind) .bind(required_title(&input.title)?) .bind(input.url) .bind(input.asset_id) .bind(normalize_tags(input.tags.unwrap_or_default())) .bind(input.metadata.unwrap_or_else(|| json!({}))) .fetch_one(&state.platform_db) .await?; Ok(Json(row)) } #[derive(Debug, Deserialize)] pub struct AgentLearningSpaceRequest { pub kind: String, pub title: String, pub description: Option, pub metadata: Option, } pub async fn create_learning_space( State(state): State, Json(input): Json, ) -> Result, ApiError> { if input.kind != "course" && input.kind != "session" { return Err(ApiError::BadRequest( "kind must be course or session".into(), )); } let row: Value = sqlx::query_scalar( "insert into learning_spaces (kind, title, description, metadata) values ($1, $2, $3, $4) returning to_jsonb(learning_spaces.*)", ) .bind(input.kind) .bind(required_title(&input.title)?) .bind(input.description.unwrap_or_default()) .bind(input.metadata.unwrap_or_else(|| json!({}))) .fetch_one(&state.platform_db) .await?; Ok(Json(row)) } #[derive(Debug, Deserialize)] pub struct AgentLessonRequest { pub title: String, pub position: Option, pub document: Option, pub resource_ids: Option>, pub problem_ids: Option>, } pub async fn create_lesson( State(state): State, Path(space_id): Path, Json(input): Json, ) -> Result, ApiError> { let row: Value = sqlx::query_scalar( "insert into learning_lessons (space_id, position, title, document, resource_ids, problem_ids, created_by) values ($1, $2, $3, $4, $5, $6, 'agent') returning to_jsonb(learning_lessons.*)", ) .bind(space_id) .bind(input.position.unwrap_or(0)) .bind(required_title(&input.title)?) .bind(input.document.unwrap_or_else(empty_document)) .bind(input.resource_ids.unwrap_or_default()) .bind(input.problem_ids.unwrap_or_default()) .fetch_one(&state.platform_db) .await?; Ok(Json(row)) } fn required_title(value: &str) -> Result<&str, ApiError> { let title = value.trim(); if title.is_empty() { Err(ApiError::BadRequest("title is required".into())) } else { Ok(title) } } fn normalize_tags(tags: Vec) -> Vec { let mut tags: Vec<_> = tags .into_iter() .map(|tag| tag.trim().to_owned()) .filter(|tag| !tag.is_empty()) .collect(); tags.sort_by_key(|tag| tag.to_lowercase()); tags.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); tags } fn empty_document() -> Value { json!({"schema_version": 1, "content": {"type": "doc", "content": []}}) }