feat(faq): implement FAQ moderation workflow with import, review, and publish functionalities
This commit is contained in:
@@ -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);
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<QueueStatus | ''>('pending');
|
||||
const [items, setItems] = useState<FaqReviewItem[]>([]);
|
||||
const [faqEntries, setFaqEntries] = useState<FaqEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [workingId, setWorkingId] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [formState, setFormState] = useState<Record<string, { answer: string; note: string; tags: string }>>({});
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<header className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-indigo-600 p-2 text-white">
|
||||
<MessageSquare size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight text-slate-900 dark:text-white">Moderación FAQ desde IA</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Revisa consultas de alumnos sin buen contexto RAG, responde manualmente y publícalas al FAQ.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => loadData()}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 dark:border-white/20 dark:text-slate-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<RefreshCw size={16} /> Refrescar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportCandidates}
|
||||
disabled={importing}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{importing ? 'Importando...' : 'Importar desde chats'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Clock3 size={14} /> Pendientes: {pendingCount}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
<CheckCircle2 size={14} /> FAQ publicadas: {faqEntries.length}
|
||||
</span>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as QueueStatus | '';
|
||||
setStatus(next);
|
||||
loadData(next);
|
||||
}}
|
||||
className="ml-auto rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-white/20 dark:bg-slate-800"
|
||||
>
|
||||
{statusOptions.map((opt) => (
|
||||
<option key={opt.value || 'all'} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600 dark:border-white/10 dark:bg-slate-900 dark:text-slate-300">
|
||||
Cargando cola de revisión...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-10 text-center text-slate-600 dark:border-white/20 dark:bg-slate-900 dark:text-slate-300">
|
||||
No hay consultas para revisar con el filtro actual.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => {
|
||||
const local = formState[item.id] ?? {
|
||||
answer: item.human_answer ?? '',
|
||||
note: item.reviewer_note ?? '',
|
||||
tags: '',
|
||||
};
|
||||
|
||||
const isWorking = workingId === item.id;
|
||||
|
||||
return (
|
||||
<article key={item.id} className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-slate-100 px-2 py-1 font-bold uppercase tracking-wide text-slate-600 dark:bg-slate-800 dark:text-slate-300">
|
||||
Estado: {item.status}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-1 font-bold uppercase tracking-wide ${
|
||||
item.rag_context_found
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
}`}>
|
||||
{item.rag_context_found ? 'con RAG' : 'sin RAG útil'}
|
||||
</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">
|
||||
Alumno: {item.student_name || 'N/D'} ({item.student_email || 'sin email'})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-bold text-slate-700 dark:text-slate-200">Pregunta del alumno</h3>
|
||||
<p className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-800 dark:border-white/10 dark:bg-slate-800 dark:text-slate-100">
|
||||
{item.question_text}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-bold text-slate-700 dark:text-slate-200">Respuesta actual IA</h3>
|
||||
<p className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700 dark:border-white/10 dark:bg-slate-800 dark:text-slate-200">
|
||||
{item.ai_response || 'Sin respuesta registrada'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3">
|
||||
<label className="text-sm font-semibold text-slate-700 dark:text-slate-200">Respuesta humana</label>
|
||||
<textarea
|
||||
value={local.answer}
|
||||
onChange={(e) => setLocalForm(item.id, 'answer', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-white/20 dark:bg-slate-800"
|
||||
placeholder="Escribe la respuesta validada por un humano..."
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
value={local.tags}
|
||||
onChange={(e) => setLocalForm(item.id, 'tags', e.target.value)}
|
||||
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-white/20 dark:bg-slate-800"
|
||||
placeholder="Tags para FAQ (coma separada): grammar, pronunciation"
|
||||
/>
|
||||
<input
|
||||
value={local.note}
|
||||
onChange={(e) => setLocalForm(item.id, 'note', e.target.value)}
|
||||
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-white/20 dark:bg-slate-800"
|
||||
placeholder="Nota interna del revisor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitAnswer(item, false)}
|
||||
disabled={isWorking}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 disabled:opacity-50 dark:border-white/20 dark:text-slate-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<Send size={15} /> Guardar respuesta
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitAnswer(item, true)}
|
||||
disabled={isWorking}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle2 size={15} /> Publicar en FAQ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => dismissItem(item)}
|
||||
disabled={isWorking}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-rose-600 px-3 py-2 text-sm font-semibold text-white hover:bg-rose-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={15} /> Descartar
|
||||
</button>
|
||||
{item.faq_entry_id && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-100 px-2 py-1 text-xs font-bold text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
FAQ ID: {item.faq_entry_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||
<h2 className="mb-3 text-lg font-black text-slate-900 dark:text-white">Ultimas FAQ publicadas</h2>
|
||||
{faqEntries.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Todavia no hay entradas publicadas.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{faqEntries.map((entry) => (
|
||||
<li key={entry.id} className="rounded-lg border border-slate-200 p-3 dark:border-white/10">
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">{entry.question}</p>
|
||||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">{entry.answer}</p>
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">Tags: {entry.tags.join(', ')}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-4 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||
Recomendacion: antes de publicar, valida que la respuesta aplique transversalmente y no dependa de una sesion individual.
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
TrendingUp,
|
||||
Mic,
|
||||
FileArchive,
|
||||
Gauge
|
||||
Gauge,
|
||||
MessageSquareQuestion
|
||||
} from "lucide-react";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -25,6 +26,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{ icon: Building2, label: "Organizations", href: "/admin" },
|
||||
{ icon: Users, label: "Users", href: "/admin/users" },
|
||||
{ icon: Gauge, label: "Tokens IA", href: "/admin/token-usage" },
|
||||
{ icon: MessageSquareQuestion, label: "FAQ Moderation", href: "/admin/faq-review" },
|
||||
{ icon: FileArchive, label: "Material Compartido", href: "/admin/materials" },
|
||||
{ icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" },
|
||||
{ icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" },
|
||||
|
||||
@@ -1584,6 +1584,45 @@ export const lmsApi = {
|
||||
}, true),
|
||||
getCourseAudioResponseStats: (courseId: string): Promise<AudioResponseStats> =>
|
||||
apiFetch(`/courses/${courseId}/audio-responses/stats`, { method: 'GET' }, true),
|
||||
|
||||
// FAQ moderation from student AI chats
|
||||
importFaqCandidates: (limit = 50): Promise<{ imported: number; skipped: number }> =>
|
||||
apiFetch('/faq/review/import-candidates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ limit })
|
||||
}, true),
|
||||
getFaqReviewQueue: (
|
||||
status?: 'pending' | 'answered' | 'published' | 'dismissed',
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<FaqReviewQueueResponse> =>
|
||||
apiFetch('/faq/review-queue', {
|
||||
method: 'GET',
|
||||
query: { status, limit, offset }
|
||||
}, true),
|
||||
answerFaqReviewItem: (
|
||||
itemId: string,
|
||||
payload: {
|
||||
human_answer: string;
|
||||
reviewer_note?: string;
|
||||
publish_to_faq?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
): Promise<void> =>
|
||||
apiFetch(`/faq/review-queue/${itemId}/answer`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, true),
|
||||
dismissFaqReviewItem: (itemId: string, reviewer_note?: string): Promise<void> =>
|
||||
apiFetch(`/faq/review-queue/${itemId}/dismiss`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reviewer_note })
|
||||
}, true),
|
||||
listFaqEntries: (search?: string, limit = 100, offset = 0): Promise<FaqEntry[]> =>
|
||||
apiFetch('/faq/entries', {
|
||||
method: 'GET',
|
||||
query: { search, limit, offset }
|
||||
}, true),
|
||||
};
|
||||
|
||||
export interface Meeting {
|
||||
@@ -1619,6 +1658,47 @@ export interface PublicProfile {
|
||||
completed_courses_count: number;
|
||||
}
|
||||
|
||||
export interface FaqReviewItem {
|
||||
id: string;
|
||||
source_ai_usage_log_id?: string;
|
||||
user_id: string;
|
||||
student_name?: string;
|
||||
student_email?: string;
|
||||
lesson_id?: string;
|
||||
session_id?: string;
|
||||
question_text: string;
|
||||
ai_response?: string;
|
||||
rag_context_found: boolean;
|
||||
status: 'pending' | 'answered' | 'published' | 'dismissed';
|
||||
reviewer_id?: string;
|
||||
reviewer_name?: string;
|
||||
reviewer_note?: string;
|
||||
human_answer?: string;
|
||||
faq_entry_id?: string;
|
||||
created_at: string;
|
||||
reviewed_at?: string;
|
||||
}
|
||||
|
||||
export interface FaqReviewQueueResponse {
|
||||
items: FaqReviewItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface FaqEntry {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
tags?: string[];
|
||||
source: string;
|
||||
created_by?: string;
|
||||
is_published: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LtiDeepLinkingContentItem {
|
||||
type: 'ltiResourceLink';
|
||||
title?: string;
|
||||
|
||||
Reference in New Issue
Block a user