diff --git a/apps/users/src/handlers/kb.rs b/apps/users/src/handlers/kb.rs index 7bc8da7..a65191c 100644 --- a/apps/users/src/handlers/kb.rs +++ b/apps/users/src/handlers/kb.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - routing::{get, patch}, + routing::{get, patch, post}, Json, Router, }; use contracts::auth_middleware::AuthUser; @@ -31,6 +31,7 @@ pub fn admin_router() -> Router { patch(admin_update_category).delete(admin_delete_category), ) // Articles + .route("/articles/ai-draft", post(admin_ai_draft_article)) .route("/articles", get(admin_list_articles).post(admin_create_article)) .route( "/articles/{id}", @@ -955,6 +956,98 @@ async fn admin_delete_article( } } +// ── POST /api/admin/kb/articles/ai-draft ───────────────────────────────────── +// Generates a full KB article draft (body + summary) from a title and optional +// topic hints. Admin reviews and saves with the normal create/update endpoints. + +#[derive(Debug, Deserialize)] +struct AiDraftArticleBody { + title: String, + category: Option, + topic_hints: Option, +} + +#[derive(Debug, Serialize)] +struct AiDraftArticleResponse { + content: String, + summary: String, +} + +async fn admin_ai_draft_article( + auth: AuthUser, + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let category = body.category.as_deref().unwrap_or("General"); + let hints = body.topic_hints.as_deref().unwrap_or(""); + + let prompt = format!( + "You are a technical writer for Nxtgauge, a multi-vertical marketplace platform \ + connecting job seekers, professionals, companies, and customers.\n\n\ + Write a clear and helpful help center article.\n\ + Title: {title}\n\ + Category: {category}\n\ + {hints_section}\ + Requirements:\n\ + - Write in plain, friendly language aimed at non-technical users\n\ + - Use markdown: ## for section headings, **bold** for key terms, numbered or bullet lists where helpful\n\ + - 300–500 words total\n\ + - End with a short 'Need more help?' paragraph directing users to contact support\n\n\ + Return ONLY the article body in markdown. Do NOT include the article title at the top.\n\n\ + After the article body, add this exact delimiter on its own line:\n\ + ===SUMMARY===\n\ + Then write a 1–2 sentence summary (under 200 characters) suitable for search results and previews.", + title = body.title, + category = category, + hints_section = if hints.is_empty() { + String::new() + } else { + format!("Additional context: {hints}\n") + }, + ); + + let outcome = crate::ai_credits::call_litellm_and_charge( + &state.pool, + auth.user_id, + "kb_article_draft", + None, + None, + &prompt, + ) + .await; + + match outcome { + Ok(outcome) => { + let raw = outcome.result.trim().to_string(); + // Split on ===SUMMARY=== delimiter; fall back gracefully if LLM omits it + let (content, summary) = if let Some(idx) = raw.find("===SUMMARY===") { + let body_part = raw[..idx].trim().to_string(); + let summary_part = raw[idx + "===SUMMARY===".len()..].trim().to_string(); + (body_part, summary_part) + } else { + // No delimiter — derive a short summary from the first non-heading line + let first_sentence = raw + .lines() + .find(|l| !l.trim().is_empty() && !l.trim().starts_with('#')) + .unwrap_or("") + .trim() + .trim_end_matches('.') + .to_string(); + (raw, format!("{first_sentence}.")) + }; + (StatusCode::OK, Json(AiDraftArticleResponse { content, summary })).into_response() + } + Err(e) => { + tracing::error!("admin_ai_draft_article failed: {}", e); + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": "AI draft generation failed", "detail": e.to_string() })), + ) + .into_response() + } + } +} + // ── Helpers ─────────────────────────────────────────────────────────────────── /// Map target_roles array to a single frontend role label