feat: full feature buildout — streaming, i18n, mastery map, admin, jobs
Progressive lesson streaming via onSegment callback (fixes SSE for non-English users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers, Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema, mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions), image queries, source citations, view transitions, reading animations, i18n (next-intl), PDF export, surprise endpoint, and 402 passing unit tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { NovelQueueItem } from '@/lib/db/queries';
|
||||
|
||||
interface Props {
|
||||
queue: NovelQueueItem[];
|
||||
}
|
||||
|
||||
interface LabelFormState {
|
||||
tag: string;
|
||||
signature: string;
|
||||
description: string;
|
||||
correction: string;
|
||||
addGolden: boolean;
|
||||
}
|
||||
|
||||
function EvidenceHighlight({ text, span }: { text: string; span: string }) {
|
||||
if (!span || !text.includes(span)) {
|
||||
return <span style={s.responseText}>{text}</span>;
|
||||
}
|
||||
const idx = text.indexOf(span);
|
||||
return (
|
||||
<span style={s.responseText}>
|
||||
{text.slice(0, idx)}
|
||||
<mark style={s.evidenceMark}>{span}</mark>
|
||||
{text.slice(idx + span.length)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueCard({ item, onProcessed }: { item: NovelQueueItem; onProcessed: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [form, setForm] = useState<LabelFormState>({
|
||||
tag: '',
|
||||
signature: item.evidenceSpan,
|
||||
description: '',
|
||||
correction: '',
|
||||
addGolden: false,
|
||||
});
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const label = async () => {
|
||||
if (!form.tag.trim() || !form.signature.trim()) return;
|
||||
if (!item.conceptId) { setMsg('No concept linked — cannot label.'); return; }
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
const res = await fetch('/api/admin/misconceptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'label',
|
||||
queueId: item.id,
|
||||
conceptId: item.conceptId,
|
||||
tag: form.tag.trim(),
|
||||
signature: form.signature.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
correction: form.correction.trim() || undefined,
|
||||
addGolden: form.addGolden,
|
||||
goldenData: form.addGolden ? {
|
||||
responseText: item.responseText,
|
||||
expectedTag: form.tag.trim(),
|
||||
conceptId: item.conceptId,
|
||||
} : undefined,
|
||||
}),
|
||||
});
|
||||
setBusy(false);
|
||||
if (res.ok) { onProcessed(); } else { setMsg('Label failed — check server logs.'); }
|
||||
};
|
||||
|
||||
const dismiss = async () => {
|
||||
setBusy(true);
|
||||
await fetch('/api/admin/misconceptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'dismiss', queueId: item.id }),
|
||||
});
|
||||
setBusy(false);
|
||||
onProcessed();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.card}>
|
||||
<div style={s.cardHead}>
|
||||
<div>
|
||||
<span style={s.conceptTag}>{item.conceptName ?? 'Unknown concept'}</span>
|
||||
<span style={s.timestamp}>{new Date(item.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div style={s.actionRow}>
|
||||
<button style={s.labelBtn} disabled={busy} onClick={() => setExpanded((e) => !e)}>
|
||||
{expanded ? 'Collapse' : 'Label'}
|
||||
</button>
|
||||
<button style={s.dismissBtn} disabled={busy} onClick={() => void dismiss()}>
|
||||
{busy ? '…' : 'Dismiss'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={s.checkpointPrompt}>{item.checkpointPrompt}</p>
|
||||
|
||||
<div style={s.responsePre}>
|
||||
<EvidenceHighlight text={item.responseText} span={item.evidenceSpan} />
|
||||
</div>
|
||||
|
||||
{item.existingMisconceptions.length > 0 && (
|
||||
<details style={s.details}>
|
||||
<summary style={s.summary}>Existing misconceptions for this concept ({item.existingMisconceptions.length})</summary>
|
||||
<ul style={s.miscList}>
|
||||
{item.existingMisconceptions.map((m) => (
|
||||
<li key={m.id} style={s.miscItem}>
|
||||
<code>{m.tag}</code> — {m.signature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div style={s.labelForm}>
|
||||
<label style={s.fieldLabel}>
|
||||
Tag (short slug, e.g. <code>scope-confusion</code>)
|
||||
<input
|
||||
style={s.input}
|
||||
value={form.tag}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tag: e.target.value }))}
|
||||
placeholder="e.g. scope-confusion"
|
||||
/>
|
||||
</label>
|
||||
<label style={s.fieldLabel}>
|
||||
Signature (text pattern that reveals this)
|
||||
<input
|
||||
style={s.input}
|
||||
value={form.signature}
|
||||
onChange={(e) => setForm((f) => ({ ...f, signature: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label style={s.fieldLabel}>
|
||||
Description (what the learner wrongly believes)
|
||||
<input
|
||||
style={s.input}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</label>
|
||||
<label style={s.fieldLabel}>
|
||||
Correction (brief guidance for the tutor)
|
||||
<input
|
||||
style={s.input}
|
||||
value={form.correction}
|
||||
onChange={(e) => setForm((f) => ({ ...f, correction: e.target.value }))}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ ...s.fieldLabel, flexDirection: 'row', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.addGolden}
|
||||
onChange={(e) => setForm((f) => ({ ...f, addGolden: e.target.checked }))}
|
||||
/>
|
||||
Also add as golden eval case
|
||||
</label>
|
||||
{msg && <p style={s.error}>{msg}</p>}
|
||||
<button
|
||||
style={s.saveBtn}
|
||||
disabled={busy || !form.tag.trim() || !form.signature.trim()}
|
||||
onClick={() => void label()}
|
||||
>
|
||||
{busy ? 'Saving…' : 'Save misconception'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MisconceptionsClient({ queue }: Props) {
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState(queue);
|
||||
|
||||
const markProcessed = (id: string) => {
|
||||
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={s.heading}>Novel Misconceptions Queue</h1>
|
||||
<p style={s.sub}>
|
||||
{items.length === 0
|
||||
? 'Queue is empty — all novel responses have been reviewed.'
|
||||
: `${items.length} unprocessed response${items.length !== 1 ? 's' : ''} — label or dismiss each.`}
|
||||
</p>
|
||||
|
||||
{items.map((item) => (
|
||||
<QueueCard key={item.id} item={item} onProcessed={() => markProcessed(item.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = {
|
||||
heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-2)' },
|
||||
sub: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', color: 'var(--color-ink-muted)', marginBottom: 'var(--space-8)' },
|
||||
card: { border: '1px solid var(--color-border)', borderRadius: '6px', padding: 'var(--space-5)', marginBottom: 'var(--space-4)' },
|
||||
cardHead: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 'var(--space-3)', marginBottom: 'var(--space-3)', flexWrap: 'wrap' as const },
|
||||
conceptTag: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-accent)', textTransform: 'uppercase' as const, letterSpacing: '0.06em' },
|
||||
timestamp: { fontFamily: 'var(--font-display)', fontSize: '0.75rem', color: 'var(--color-ink-faint)', marginLeft: 'var(--space-3)' },
|
||||
actionRow: { display: 'flex', gap: 'var(--space-2)' },
|
||||
checkpointPrompt: { fontFamily: 'var(--font-body)', fontSize: '0.9375rem', color: 'var(--color-ink-muted)', fontStyle: 'italic', marginBottom: 'var(--space-3)' },
|
||||
responsePre: { fontFamily: 'var(--font-body)', fontSize: '0.9375rem', lineHeight: 1.65, color: 'var(--color-ink)', background: 'var(--color-surface)', border: '1px solid var(--color-border-subtle)', borderRadius: '4px', padding: 'var(--space-3)', marginBottom: 'var(--space-3)' },
|
||||
responseText: { whiteSpace: 'pre-wrap' as const },
|
||||
evidenceMark: { background: 'var(--color-misconception-subtle)', color: 'var(--color-misconception)', borderBottom: '2px solid var(--color-misconception)', padding: '0 2px', borderRadius: '2px' },
|
||||
details: { marginBottom: 'var(--space-3)' },
|
||||
summary: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-muted)', cursor: 'pointer' },
|
||||
miscList: { listStyle: 'none', padding: 0, margin: 'var(--space-2) 0 0' },
|
||||
miscItem: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-muted)', marginBottom: 'var(--space-1)' },
|
||||
labelForm: { borderTop: '1px solid var(--color-border-subtle)', paddingTop: 'var(--space-4)', marginTop: 'var(--space-3)', display: 'flex', flexDirection: 'column' as const, gap: 'var(--space-3)' },
|
||||
fieldLabel: { display: 'flex', flexDirection: 'column' as const, gap: 'var(--space-1)', fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-muted)' },
|
||||
input: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', color: 'var(--color-ink)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '4px', padding: 'var(--space-2) var(--space-3)', width: '100%' },
|
||||
error: { color: 'var(--color-misconception)', fontSize: '0.875rem' },
|
||||
labelBtn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-accent-subtle)', color: 'var(--color-accent)', border: '1px solid var(--color-accent)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' },
|
||||
dismissBtn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-surface)', color: 'var(--color-ink-muted)', border: '1px solid var(--color-border)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' },
|
||||
saveBtn: { alignSelf: 'flex-start', padding: 'var(--space-2) var(--space-5)', background: 'var(--color-accent)', color: 'var(--color-on-accent)', border: 'none', borderRadius: '4px', cursor: 'pointer', fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500 },
|
||||
} as const;
|
||||
Reference in New Issue
Block a user