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
@@ -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);
+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",
@@ -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>
);
}
+3 -1
View File
@@ -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" },
+80
View File
@@ -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;