feat(kb): POST /api/admin/kb/articles/ai-draft endpoint
All checks were successful
build-and-release / build (tutors) (push) Successful in 9s
build-and-release / build (jobs) (push) Successful in 14s
build-and-release / build (catering-services) (push) Successful in 14s
build-and-release / build (ugc-content-creators) (push) Successful in 7s
build-and-release / build (video-editors) (push) Successful in 8s
backend-integration-tests / ai-credits (push) Successful in 7s
build-and-release / build (customers) (push) Successful in 14s
build-and-release / build (payments) (push) Successful in 13s
build-and-release / build (companies) (push) Successful in 17s
build-and-release / build (developers) (push) Successful in 16s
build-and-release / build (cron) (push) Successful in 22s
build-and-release / build (employees) (push) Successful in 21s
build-and-release / build (gateway) (push) Successful in 8s
build-and-release / build (graphic-designers) (push) Successful in 8s
build-and-release / build (users) (push) Successful in 2m55s
build-and-release / build (fitness-trainers) (push) Successful in 7s
build-and-release / build (job-seekers) (push) Successful in 8s
build-and-release / build (makeup-artists) (push) Successful in 6s
build-and-release / build (social-media-managers) (push) Successful in 10s
build-and-release / build (photographers) (push) Successful in 10s

Generates a full KB article (content + summary) via LiteLLM given a
title, optional category name, and optional topic hints.

- Prompt instructs the LLM to produce markdown-formatted body (300-500
  words) followed by ===SUMMARY=== delimiter and a ≤200-char summary
- Falls back gracefully if LLM omits the delimiter: derives summary from
  the first non-heading sentence
- Charges kb_article_draft feature credits against the admin's account
- Route is /api/admin/kb/articles/ai-draft (POST) — must be registered
  BEFORE /api/admin/kb/articles/{id} so the literal segment wins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tracewebstudio Dev 2026-08-15 14:03:45 +02:00
parent ad4ab0477a
commit b039ed7342

View file

@ -3,7 +3,7 @@ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::{get, patch}, routing::{get, patch, post},
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
@ -31,6 +31,7 @@ pub fn admin_router() -> Router<AppState> {
patch(admin_update_category).delete(admin_delete_category), patch(admin_update_category).delete(admin_delete_category),
) )
// Articles // Articles
.route("/articles/ai-draft", post(admin_ai_draft_article))
.route("/articles", get(admin_list_articles).post(admin_create_article)) .route("/articles", get(admin_list_articles).post(admin_create_article))
.route( .route(
"/articles/{id}", "/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<String>,
topic_hints: Option<String>,
}
#[derive(Debug, Serialize)]
struct AiDraftArticleResponse {
content: String,
summary: String,
}
async fn admin_ai_draft_article(
auth: AuthUser,
State(state): State<AppState>,
Json(body): Json<AiDraftArticleBody>,
) -> 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\
- 300500 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 12 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 ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
/// Map target_roles array to a single frontend role label /// Map target_roles array to a single frontend role label