From 7f3e1ce9b1e727e7b388d22e0f8569a2dcce7470 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Fri, 10 Apr 2026 15:46:04 -0400 Subject: [PATCH] feat(faq): implement FAQ moderation workflow with import, review, and publish functionalities --- .../20260410000001_faq_review_queue.sql | 44 ++ services/lms-service/src/handlers_faq.rs | 481 ++++++++++++++++++ services/lms-service/src/main.rs | 22 + web/studio/src/app/admin/faq-review/page.tsx | 314 ++++++++++++ web/studio/src/app/admin/layout.tsx | 4 +- web/studio/src/lib/api.ts | 80 +++ 6 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 services/lms-service/migrations/20260410000001_faq_review_queue.sql create mode 100644 services/lms-service/src/handlers_faq.rs create mode 100644 web/studio/src/app/admin/faq-review/page.tsx diff --git a/services/lms-service/migrations/20260410000001_faq_review_queue.sql b/services/lms-service/migrations/20260410000001_faq_review_queue.sql new file mode 100644 index 0000000..ab0302d --- /dev/null +++ b/services/lms-service/migrations/20260410000001_faq_review_queue.sql @@ -0,0 +1,44 @@ +-- FAQ moderation workflow based on student AI chats + +CREATE TABLE IF NOT EXISTS faq_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + question TEXT NOT NULL, + answer TEXT NOT NULL, + tags TEXT[] DEFAULT '{}', + source VARCHAR(50) NOT NULL DEFAULT 'human-reviewed', + created_by UUID, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_faq_entries_org_published + ON faq_entries (organization_id, is_published, created_at DESC); + +CREATE TABLE IF NOT EXISTS faq_review_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + source_ai_usage_log_id UUID UNIQUE, + user_id UUID NOT NULL, + lesson_id UUID, + session_id UUID, + question_text TEXT NOT NULL, + ai_response TEXT, + rag_context_found BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + reviewer_id UUID, + reviewer_note TEXT, + human_answer TEXT, + faq_entry_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + CONSTRAINT chk_faq_review_status CHECK (status IN ('pending', 'answered', 'published', 'dismissed')), + CONSTRAINT fk_faq_review_faq_entry FOREIGN KEY (faq_entry_id) REFERENCES faq_entries(id) +); + +CREATE INDEX IF NOT EXISTS idx_faq_review_org_status + ON faq_review_queue (organization_id, status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_faq_review_source_log + ON faq_review_queue (source_ai_usage_log_id); diff --git a/services/lms-service/src/handlers_faq.rs b/services/lms-service/src/handlers_faq.rs new file mode 100644 index 0000000..9f4ba9a --- /dev/null +++ b/services/lms-service/src/handlers_faq.rs @@ -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, +} + +#[derive(Debug, Serialize)] +pub struct ImportCandidatesResponse { + pub imported: i64, + pub skipped: i64, +} + +#[derive(Debug, Deserialize)] +pub struct ReviewQueueFilters { + pub status: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct FaqReviewItem { + pub id: Uuid, + pub source_ai_usage_log_id: Option, + pub user_id: Uuid, + pub student_name: Option, + pub student_email: Option, + pub lesson_id: Option, + pub session_id: Option, + pub question_text: String, + pub ai_response: Option, + pub rag_context_found: bool, + pub status: String, + pub reviewer_id: Option, + pub reviewer_name: Option, + pub reviewer_note: Option, + pub human_answer: Option, + pub faq_entry_id: Option, + pub created_at: chrono::DateTime, + pub reviewed_at: Option>, +} + +#[derive(Debug, Serialize)] +pub struct FaqReviewQueueResponse { + pub items: Vec, + pub total: i64, + pub limit: i64, + pub offset: i64, +} + +#[derive(Debug, Deserialize)] +pub struct AnswerReviewPayload { + pub human_answer: String, + pub reviewer_note: Option, + pub publish_to_faq: Option, + pub tags: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct DismissReviewPayload { + pub reviewer_note: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct FaqEntry { + pub id: Uuid, + pub organization_id: Uuid, + pub question: String, + pub answer: String, + pub tags: Option>, + pub source: String, + pub created_by: Option, + pub is_published: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct FaqEntriesFilters { + pub search: Option, + pub limit: Option, + pub offset: Option, +} + +/// 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, + Json(payload): Json, +) -> Result, (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, + Query(filters): Query, +) -> Result, (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, + State(pool): State, + Json(payload): Json, +) -> Result { + 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, + State(pool): State, + Json(payload): Json, +) -> Result { + 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, + Query(filters): Query, +) -> Result>, (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)) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index b6f33a6..b6e8827 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -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", diff --git a/web/studio/src/app/admin/faq-review/page.tsx b/web/studio/src/app/admin/faq-review/page.tsx new file mode 100644 index 0000000..0adabcb --- /dev/null +++ b/web/studio/src/app/admin/faq-review/page.tsx @@ -0,0 +1,314 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { AlertCircle, CheckCircle2, Clock3, MessageSquare, RefreshCw, Send, Trash2 } from 'lucide-react'; +import { FaqEntry, FaqReviewItem, lmsApi } from '@/lib/api'; + +type QueueStatus = 'pending' | 'answered' | 'published' | 'dismissed'; + +const statusOptions: Array<{ value: QueueStatus | ''; label: string }> = [ + { value: '', label: 'Todos' }, + { value: 'pending', label: 'Pendientes' }, + { value: 'answered', label: 'Respondidas' }, + { value: 'published', label: 'Publicadas en FAQ' }, + { value: 'dismissed', label: 'Descartadas' }, +]; + +export default function AdminFaqReviewPage() { + const [status, setStatus] = useState('pending'); + const [items, setItems] = useState([]); + const [faqEntries, setFaqEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [workingId, setWorkingId] = useState(null); + const [importing, setImporting] = useState(false); + const [formState, setFormState] = useState>({}); + + const pendingCount = useMemo(() => items.filter((i) => i.status === 'pending').length, [items]); + + const loadData = async (selectedStatus: QueueStatus | '' = status) => { + setLoading(true); + try { + const [queue, entries] = await Promise.all([ + lmsApi.getFaqReviewQueue(selectedStatus || undefined, 100, 0), + lmsApi.listFaqEntries(undefined, 20, 0), + ]); + setItems(queue.items); + setFaqEntries(entries); + } catch (err) { + console.error('Error loading FAQ moderation data', err); + alert('No se pudo cargar la cola de FAQ.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleImportCandidates = async () => { + setImporting(true); + try { + const result = await lmsApi.importFaqCandidates(100); + alert(`Importadas: ${result.imported}. Omitidas: ${result.skipped}.`); + await loadData(); + } catch (err) { + console.error('Error importing FAQ candidates', err); + alert('No se pudieron importar candidatos desde chats.'); + } finally { + setImporting(false); + } + }; + + const setLocalForm = (itemId: string, field: 'answer' | 'note' | 'tags', value: string) => { + setFormState((prev) => ({ + ...prev, + [itemId]: { + answer: prev[itemId]?.answer ?? '', + note: prev[itemId]?.note ?? '', + tags: prev[itemId]?.tags ?? '', + [field]: value, + }, + })); + }; + + const submitAnswer = async (item: FaqReviewItem, publishToFaq: boolean) => { + const local = formState[item.id] ?? { answer: '', note: '', tags: '' }; + const answer = local.answer.trim(); + if (!answer) { + alert('Debes escribir una respuesta humana.'); + return; + } + + const tags = local.tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + + setWorkingId(item.id); + try { + await lmsApi.answerFaqReviewItem(item.id, { + human_answer: answer, + reviewer_note: local.note.trim() || undefined, + publish_to_faq: publishToFaq, + tags: tags.length ? tags : undefined, + }); + await loadData(); + } catch (err) { + console.error('Error answering FAQ review item', err); + alert('No se pudo guardar la respuesta.'); + } finally { + setWorkingId(null); + } + }; + + const dismissItem = async (item: FaqReviewItem) => { + const local = formState[item.id] ?? { answer: '', note: '', tags: '' }; + setWorkingId(item.id); + try { + await lmsApi.dismissFaqReviewItem(item.id, local.note.trim() || undefined); + await loadData(); + } catch (err) { + console.error('Error dismissing FAQ review item', err); + alert('No se pudo descartar la consulta.'); + } finally { + setWorkingId(null); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Moderación FAQ desde IA

+

+ Revisa consultas de alumnos sin buen contexto RAG, responde manualmente y publícalas al FAQ. +

+
+
+ +
+ + +
+
+ +
+ + Pendientes: {pendingCount} + + + FAQ publicadas: {faqEntries.length} + + +
+
+ + {loading ? ( +
+ Cargando cola de revisión... +
+ ) : items.length === 0 ? ( +
+ No hay consultas para revisar con el filtro actual. +
+ ) : ( +
+ {items.map((item) => { + const local = formState[item.id] ?? { + answer: item.human_answer ?? '', + note: item.reviewer_note ?? '', + tags: '', + }; + + const isWorking = workingId === item.id; + + return ( +
+
+ + Estado: {item.status} + + + {item.rag_context_found ? 'con RAG' : 'sin RAG útil'} + + + Alumno: {item.student_name || 'N/D'} ({item.student_email || 'sin email'}) + +
+ +
+
+

Pregunta del alumno

+

+ {item.question_text} +

+
+
+

Respuesta actual IA

+

+ {item.ai_response || 'Sin respuesta registrada'} +

+
+
+ +
+ +