feat(faq): implement FAQ moderation workflow with import, review, and publish functionalities

This commit is contained in:
2026-04-10 15:46:04 -04:00
parent 0c039ebfbc
commit 7f3e1ce9b1
6 changed files with 944 additions and 1 deletions
+481
View File
@@ -0,0 +1,481 @@
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use common::auth::Claims;
use common::middleware::Org;
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use uuid::Uuid;
fn ensure_reviewer_role(claims: &Claims) -> Result<(), (StatusCode, String)> {
if claims.role != "admin" && claims.role != "instructor" {
return Err((StatusCode::FORBIDDEN, "No autorizado".to_string()));
}
Ok(())
}
#[derive(Debug, Deserialize)]
pub struct ImportCandidatesPayload {
pub limit: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct ImportCandidatesResponse {
pub imported: i64,
pub skipped: i64,
}
#[derive(Debug, Deserialize)]
pub struct ReviewQueueFilters {
pub status: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct FaqReviewItem {
pub id: Uuid,
pub source_ai_usage_log_id: Option<Uuid>,
pub user_id: Uuid,
pub student_name: Option<String>,
pub student_email: Option<String>,
pub lesson_id: Option<Uuid>,
pub session_id: Option<Uuid>,
pub question_text: String,
pub ai_response: Option<String>,
pub rag_context_found: bool,
pub status: String,
pub reviewer_id: Option<Uuid>,
pub reviewer_name: Option<String>,
pub reviewer_note: Option<String>,
pub human_answer: Option<String>,
pub faq_entry_id: Option<Uuid>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub reviewed_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize)]
pub struct FaqReviewQueueResponse {
pub items: Vec<FaqReviewItem>,
pub total: i64,
pub limit: i64,
pub offset: i64,
}
#[derive(Debug, Deserialize)]
pub struct AnswerReviewPayload {
pub human_answer: String,
pub reviewer_note: Option<String>,
pub publish_to_faq: Option<bool>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct DismissReviewPayload {
pub reviewer_note: Option<String>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct FaqEntry {
pub id: Uuid,
pub organization_id: Uuid,
pub question: String,
pub answer: String,
pub tags: Option<Vec<String>>,
pub source: String,
pub created_by: Option<Uuid>,
pub is_published: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct FaqEntriesFilters {
pub search: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// POST /faq/review/import-candidates
/// Importa preguntas de chats de alumnos donde RAG no encontró contexto suficiente.
pub async fn import_faq_candidates(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<ImportCandidatesPayload>,
) -> Result<Json<ImportCandidatesResponse>, (StatusCode, String)> {
ensure_reviewer_role(&claims)?;
let limit = payload.limit.unwrap_or(50).clamp(1, 200);
let row = sqlx::query(
r#"
WITH candidates AS (
SELECT
a.id AS source_ai_usage_log_id,
a.organization_id,
a.user_id,
CASE
WHEN COALESCE(a.request_metadata->>'lesson_id', '') ~* '^[0-9a-fA-F-]{36}$'
THEN (a.request_metadata->>'lesson_id')::uuid
ELSE NULL
END AS lesson_id,
CASE
WHEN COALESCE(a.request_metadata->>'session_id', '') ~* '^[0-9a-fA-F-]{36}$'
THEN (a.request_metadata->>'session_id')::uuid
ELSE NULL
END AS session_id,
COALESCE(
(
SELECT cm.content
FROM chat_messages cm
WHERE cm.session_id = (
CASE
WHEN COALESCE(a.request_metadata->>'session_id', '') ~* '^[0-9a-fA-F-]{36}$'
THEN (a.request_metadata->>'session_id')::uuid
ELSE NULL
END
)
AND cm.role = 'user'
ORDER BY cm.created_at DESC
LIMIT 1
),
''
) AS question_text,
a.response AS ai_response,
COALESCE((a.request_metadata->>'has_rag')::boolean, FALSE) AS rag_context_found
FROM ai_usage_logs a
WHERE a.organization_id = $1
AND a.request_type = 'chat'
AND COALESCE((a.request_metadata->>'has_rag')::boolean, FALSE) = FALSE
AND a.request_metadata ? 'session_id'
AND NOT EXISTS (
SELECT 1
FROM faq_review_queue q
WHERE q.source_ai_usage_log_id = a.id
)
ORDER BY a.created_at DESC
LIMIT $2
),
inserted AS (
INSERT INTO faq_review_queue (
organization_id,
source_ai_usage_log_id,
user_id,
lesson_id,
session_id,
question_text,
ai_response,
rag_context_found,
status
)
SELECT
c.organization_id,
c.source_ai_usage_log_id,
c.user_id,
c.lesson_id,
c.session_id,
c.question_text,
c.ai_response,
c.rag_context_found,
'pending'
FROM candidates c
WHERE c.question_text <> ''
RETURNING 1
)
SELECT
(SELECT COUNT(*)::bigint FROM inserted) AS imported,
((SELECT COUNT(*)::bigint FROM candidates) - (SELECT COUNT(*)::bigint FROM inserted)) AS skipped
"#,
)
.bind(org_ctx.id)
.bind(limit)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al importar candidatos FAQ: {}", e)))?;
let imported: i64 = row.get("imported");
let skipped: i64 = row.get("skipped");
Ok(Json(ImportCandidatesResponse { imported, skipped }))
}
/// GET /faq/review-queue
pub async fn list_faq_review_queue(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Query(filters): Query<ReviewQueueFilters>,
) -> Result<Json<FaqReviewQueueResponse>, (StatusCode, String)> {
ensure_reviewer_role(&claims)?;
let limit = filters.limit.unwrap_or(50).clamp(1, 200);
let offset = filters.offset.unwrap_or(0).max(0);
let items = sqlx::query_as::<_, FaqReviewItem>(
r#"
SELECT
q.id,
q.source_ai_usage_log_id,
q.user_id,
u.full_name AS student_name,
u.email AS student_email,
q.lesson_id,
q.session_id,
q.question_text,
q.ai_response,
q.rag_context_found,
q.status,
q.reviewer_id,
r.full_name AS reviewer_name,
q.reviewer_note,
q.human_answer,
q.faq_entry_id,
q.created_at,
q.reviewed_at
FROM faq_review_queue q
LEFT JOIN users u ON u.id = q.user_id
LEFT JOIN users r ON r.id = q.reviewer_id
WHERE q.organization_id = $1
AND ($2::text IS NULL OR q.status = $2::text)
ORDER BY q.created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(org_ctx.id)
.bind(filters.status.clone())
.bind(limit)
.bind(offset)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al listar la cola FAQ: {}", e)))?;
let total: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)::bigint
FROM faq_review_queue
WHERE organization_id = $1
AND ($2::text IS NULL OR status = $2::text)
"#,
)
.bind(org_ctx.id)
.bind(filters.status.clone())
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al contar cola FAQ: {}", e)))?;
Ok(Json(FaqReviewQueueResponse {
items,
total,
limit,
offset,
}))
}
/// POST /faq/review-queue/:id/answer
pub async fn answer_faq_review_item(
Org(org_ctx): Org,
claims: Claims,
Path(item_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<AnswerReviewPayload>,
) -> Result<StatusCode, (StatusCode, String)> {
ensure_reviewer_role(&claims)?;
let answer = payload.human_answer.trim();
if answer.is_empty() {
return Err((StatusCode::BAD_REQUEST, "La respuesta humana no puede estar vacía".to_string()));
}
let mut tx = pool
.begin()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("No se pudo iniciar transacción FAQ: {}", e)))?;
let queue_item: (String, String) = sqlx::query_as(
"SELECT status, question_text FROM faq_review_queue WHERE id = $1 AND organization_id = $2"
)
.bind(item_id)
.bind(org_ctx.id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al buscar item FAQ: {}", e)))?
.ok_or((StatusCode::NOT_FOUND, "Item de revisión no encontrado".to_string()))?;
if queue_item.0 == "dismissed" {
return Err((StatusCode::BAD_REQUEST, "El item está descartado y no puede responderse".to_string()));
}
let publish = payload.publish_to_faq.unwrap_or(false);
if publish {
let tags = payload.tags.unwrap_or_default();
let faq_entry_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO faq_entries (
organization_id,
question,
answer,
tags,
source,
created_by,
is_published
)
VALUES ($1, $2, $3, $4, 'human-reviewed', $5, TRUE)
RETURNING id
"#,
)
.bind(org_ctx.id)
.bind(queue_item.1)
.bind(answer)
.bind(tags)
.bind(claims.sub)
.fetch_one(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al crear FAQ: {}", e)))?;
sqlx::query(
r#"
UPDATE faq_review_queue
SET
status = 'published',
reviewer_id = $1,
reviewer_note = $2,
human_answer = $3,
faq_entry_id = $4,
reviewed_at = NOW()
WHERE id = $5 AND organization_id = $6
"#,
)
.bind(claims.sub)
.bind(payload.reviewer_note)
.bind(answer)
.bind(faq_entry_id)
.bind(item_id)
.bind(org_ctx.id)
.execute(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al actualizar cola FAQ: {}", e)))?;
} else {
sqlx::query(
r#"
UPDATE faq_review_queue
SET
status = 'answered',
reviewer_id = $1,
reviewer_note = $2,
human_answer = $3,
reviewed_at = NOW()
WHERE id = $4 AND organization_id = $5
"#,
)
.bind(claims.sub)
.bind(payload.reviewer_note)
.bind(answer)
.bind(item_id)
.bind(org_ctx.id)
.execute(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al responder cola FAQ: {}", e)))?;
}
tx.commit()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("No se pudo confirmar transacción FAQ: {}", e)))?;
Ok(StatusCode::OK)
}
/// POST /faq/review-queue/:id/dismiss
pub async fn dismiss_faq_review_item(
Org(org_ctx): Org,
claims: Claims,
Path(item_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<DismissReviewPayload>,
) -> Result<StatusCode, (StatusCode, String)> {
ensure_reviewer_role(&claims)?;
let result = sqlx::query(
r#"
UPDATE faq_review_queue
SET
status = 'dismissed',
reviewer_id = $1,
reviewer_note = $2,
reviewed_at = NOW()
WHERE id = $3 AND organization_id = $4
"#,
)
.bind(claims.sub)
.bind(payload.reviewer_note)
.bind(item_id)
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al descartar item FAQ: {}", e)))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Item de revisión no encontrado".to_string()));
}
Ok(StatusCode::OK)
}
/// GET /faq/entries
pub async fn list_faq_entries(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Query(filters): Query<FaqEntriesFilters>,
) -> Result<Json<Vec<FaqEntry>>, (StatusCode, String)> {
ensure_reviewer_role(&claims)?;
let limit = filters.limit.unwrap_or(100).clamp(1, 300);
let offset = filters.offset.unwrap_or(0).max(0);
let query_term = filters.search.clone().unwrap_or_default();
let maybe_search = if query_term.trim().is_empty() {
None
} else {
Some(query_term)
};
let rows = sqlx::query_as::<_, FaqEntry>(
r#"
SELECT
id,
organization_id,
question,
answer,
tags,
source,
created_by,
is_published,
created_at,
updated_at
FROM faq_entries
WHERE organization_id = $1
AND is_published = TRUE
AND (
$2::text IS NULL
OR question ILIKE ('%' || $2 || '%')
OR answer ILIKE ('%' || $2 || '%')
)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(org_ctx.id)
.bind(maybe_search)
.bind(limit)
.bind(offset)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al listar FAQ: {}", e)))?;
Ok(Json(rows))
}
+22
View File
@@ -7,6 +7,7 @@ mod handlers_notes;
mod handlers_payments;
mod handlers_peer_review;
mod handlers_embeddings;
mod handlers_faq;
mod lti;
mod jwks;
mod predictive;
@@ -217,6 +218,27 @@ async fn main() {
"/knowledge-base/{id}/embedding/regenerate",
post(handlers_embeddings::regenerate_knowledge_embedding),
)
// Moderación humana para FAQ basada en chats de alumnos
.route(
"/faq/review/import-candidates",
post(handlers_faq::import_faq_candidates),
)
.route(
"/faq/review-queue",
get(handlers_faq::list_faq_review_queue),
)
.route(
"/faq/review-queue/{id}/answer",
post(handlers_faq::answer_faq_review_item),
)
.route(
"/faq/review-queue/{id}/dismiss",
post(handlers_faq::dismiss_faq_review_item),
)
.route(
"/faq/entries",
get(handlers_faq::list_faq_entries),
)
// Rutas de Foros de Discusión
.route(
"/courses/{id}/discussions",