feat: a workbench — PDFs in, drafts read, questions out

Uploading, extracting and reviewing were three places and the middle one
wrote straight into the bank. They are one job, so they are one page:
the documents, the batches a run produced, and the drafts themselves,
with the counts of what is still to be read said before anything else.

The review is an editor, because that is what the staging table is for.
A draft that is not ready says why on its own row rather than on a
failed attempt to accept it, and cannot be selected until it is fixed.
Fixing happens in place, with nothing in the bank. Only the chosen,
ready ones cross over, and that is the single moment a question id is
taken — an accepted draft then says which question it became.

Nextcloud is the administrator's. It was in every learner's settings as
though each of them had a cloud to connect; it is an import path for
whoever loads the corpus.

The image work is restated in the TODO now that the ped-ai design has
actually been read rather than remembered: allow-listed widths only,
EXIF rotate, resize without enlarging, WebP q82, derivatives in the same
bucket under a thumbs/ prefix, generated best-effort. Pillow rather than
sharp — sharp is Node and this backend is Python — and no Caddy caching,
because these are behind auth and ped-ai serves its own private,
no-store for that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 00:02:02 +02:00
parent fc1aaf5dca
commit 202979f7c0
8 changed files with 635 additions and 16 deletions

View file

@ -236,9 +236,7 @@ Captured so nothing is lost while the article writing runs.
- [x] **Boxed quiz player** — done 2026-09-11. A box the height of the window:
the rail and the bottom bar stay put, the question scrolls in its own
column, and the footer is out of the way while a session is being sat.
- [ ] **Share dialog** — a proper one: session title, first question as a
preview, the link with Copy, and a few share targets. Currently a copy
button in the more-menu.
- [x] **Share dialog** — done 2026-09-11.
- [x] **Sharing off site-wide** — done 2026-09-11. Settings → Site policy.
Stops new links; one already handed to somebody keeps working.
- [ ] **Remove per-question share/unshare**`Question.is_shared` and
@ -251,17 +249,19 @@ Captured so nothing is lost while the article writing runs.
codes an administrator issues, with a note of who each is for and who it
let in. The form asks for one only when the site needs it, and never says
whether a code is valid before the account is made.
- [ ] **Settings, properly** — the section list was a restructure, not the
revamp asked for. Wants: what belongs there decided first, then the
sign-up policy and invite codes, the site-wide sharing switch, and the
admin dashboard's contents folded in rather than linked out to.
- [ ] **Comments backend still mounted** — the UI is gone and nothing calls
`/api/comments`. The router, model and table are still there; removing
them destroys whatever was written, so it is a deliberate decision.
## Loose ends
- [ ] **Image thumbnails and caching** — you mentioned a tool from the ped-ai
- [x] **Settings, properly** — done 2026-09-11: People and AI models rebuilt natively, AdminPage deleted.
- [x] **Comments backend removed** — done 2026-09-11: router, model and table dropped.
- [ ] **Image thumbnails.** The ped-ai design, read 2026-09-11: allow-listed
widths only (256 and 640 — any other `?w=` is refused, so the endpoint
cannot be turned into a resize-on-demand CPU sink), EXIF rotate, resize
without enlarging, WebP q82, derivatives stored in the same bucket under
a `thumbs/{id}/{width}` prefix so credentials, lifecycle and backup are
unchanged, generated best-effort so a failed preview never fails the
upload. **Pillow, not sharp** — sharp is Node and this backend is Python;
Pillow 12.3 is already installed and does the same three things. **No
Caddy caching**: these are behind auth, ped-ai serves its own
`private, no-store` for that reason, and the win is the 256px WebP.
- [ ] ~~Image thumbnails and caching~~ — you mentioned a tool from the ped-ai
work that generates thumbnails and caches through Caddy so images load
fast, click to open full size, same bucket, and no straightforward
download of the original. Not started: I need the name of that tool or a
@ -356,6 +356,24 @@ Analysis**, which has three tabs.
clinical tool is app.pedshub.com. AI Mode answers about the corpus a
learner is studying, and nothing here is for use at a bedside.
## Tools, as asked 2026-09-11 (late)
The whole pipeline from a PDF to a question in the bank lives on one page,
and nothing reaches the bank until an administrator has read it.
- [ ] **Move the PDFs into Tools.** Upload, the document list and extraction
all move off their own pages and onto the Tools page, because they are
one job and were three places.
- [ ] **Nextcloud is the administrator's alone.** Nobody else connects an
account; it is an import path for whoever loads the corpus, not a
per-learner integration. It moves out of everyone's Settings.
- [ ] **Review and promote, on the same page.** A run lands as a batch of
drafts — already built, `draft_batches` / `draft_questions`, with their
own sequence so nothing takes a question id early. What is missing is
the screen: read them, fix them, reject the rubbish, then select the
ones worth keeping and move them into the bank. Acceptance is the only
moment a `Question` is created.
## Collections, as shown 2026-09-11 (evening)
- [ ] **A collections page.** Favorites and the question libraries in one

View file

@ -38,6 +38,7 @@ const MediaPage = lazyPage(() => import('./pages/MediaPage'))
const EditorialPage = lazyPage(() => import('./pages/EditorialPage'))
const AccessPage = lazyPage(() => import('./pages/AccessPage'))
const HandbookPage = lazyPage(() => import('./pages/HandbookPage'))
const ToolsPage = lazyPage(() => import('./pages/ToolsPage'))
const StudyPlansPage = lazyPage(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazyPage(() => import('./pages/StudyPlanPage'))
const StudyPlanBlockPage = lazyPage(() => import('./pages/StudyPlanBlockPage'))
@ -172,6 +173,9 @@ function AppRoutes() {
{/* Moderator-only */}
<Route element={<RequireAuth moderator />}>
<Route element={<AppLayout />}>
{/* The whole pipeline in one place: a PDF in, drafts read, the
ones worth keeping moved into the bank. */}
<Route path="/tools" element={<ToolsPage />} />
<Route path="/upload" element={<UploadPage />} />
<Route path="/study/:id/edit" element={<QuizEditPage />} />
<Route path="/jobs" element={<JobsPage />} />

View file

@ -0,0 +1,59 @@
.db { border-top: 1px solid var(--border); padding: 12px 14px 16px; }
.db-error { margin: 0 0 10px; font-size: 0.84rem; color: var(--wrong-fg); }
.db-bar {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
}
.db-all { display: inline-flex; align-items: center; gap: 9px; font-size: 0.85rem; }
.db-bar-actions { display: inline-flex; gap: 8px; flex-wrap: wrap; }
.db-list { list-style: none; margin: 0; padding: 0; max-height: 60vh; overflow-y: auto; }
.db-list > li { border-top: 1px solid var(--border); padding: 12px 0; }
.db-list > li.is-accepted { opacity: 0.6; }
.db-list > li.is-rejected { opacity: 0.45; }
.db-row { display: flex; align-items: flex-start; gap: 11px; }
.db-row > input[type="checkbox"] { margin-top: 3px; flex: none; }
.db-mark { flex: none; width: 18px; text-align: center; font-weight: 700; }
.db-mark.is-accepted { color: var(--correct-fg); }
.db-mark.is-rejected { color: var(--wrong-fg); }
.db-body { flex: 1; min-width: 0; }
.db-stem { margin: 0; font-size: 0.88rem; line-height: 1.55; }
.db-num { color: var(--text-subtle); margin-right: 6px; font-variant-numeric: tabular-nums; }
.db-options { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.db-options li {
font-size: 0.82rem; padding: 4px 9px; border-radius: 6px;
background: var(--bg); color: var(--text-muted);
}
.db-options li.is-right {
background: var(--correct-bg); color: var(--correct-fg); font-weight: 600;
}
.db-problems { margin: 8px 0 0; font-size: 0.8rem; color: var(--wrong-fg); }
.db-became { margin: 8px 0 0; font-size: 0.8rem; color: var(--text-subtle); }
.db-row-actions { display: inline-flex; gap: 6px; flex: none; }
.db-row-actions button {
padding: 4px 10px; font: inherit; font-size: 0.76rem; font-weight: 600;
cursor: pointer; border-radius: 999px;
border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted);
}
.db-row-actions button:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
/* Fixing one in place is the whole point of a staging table. */
.db-edit { display: flex; flex-direction: column; gap: 10px; }
.db-edit label { display: flex; flex-direction: column; gap: 4px; }
.db-edit label span {
font-size: 0.66rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.db-edit textarea, .db-edit input {
width: 100%; padding: 8px 10px;
/* 16px on touch: iOS zooms in on anything smaller and never zooms back. */
font-size: 16px; font-family: inherit; line-height: 1.5;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text); resize: vertical;
}
@media (min-width: 700px) { .db-edit textarea, .db-edit input { font-size: 0.86rem; } }
.db-edit-actions { display: flex; gap: 8px; }

View file

@ -0,0 +1,204 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import api from '../api/client'
import './DraftBatch.css'
const detail = (err, fallback) => {
const value = err?.response?.data?.detail
return typeof value === 'string' ? value : fallback
}
/**
* Reading a run of extracted questions, and deciding about them.
*
* The point of the staging table is that a draft can be wrong and be corrected
* without anything being in the bank. So this is an editor as much as a list:
* what is not ready says why, it can be fixed in place, and only then does
* anything cross over which is the single moment a question id is taken.
*/
export default function DraftBatch({ batchId, onChanged }) {
const [batch, setBatch] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [picked, setPicked] = useState(() => new Set())
const [editing, setEditing] = useState(null)
const [draftEdit, setDraftEdit] = useState(null)
const load = useCallback(() => {
api.get(`/drafts/batches/${batchId}`)
.then(res => setBatch(res.data))
.catch(() => setError('Could not load that batch'))
}, [batchId])
useEffect(() => { load() }, [load])
const pending = useMemo(
() => (batch?.drafts || []).filter(d => d.status === 'pending'), [batch])
const ready = useMemo(() => pending.filter(d => d.problems.length === 0), [pending])
const act = async (run, failure, message) => {
setBusy(true); setError('')
try {
await run()
setPicked(new Set())
load()
onChanged?.()
if (message) setError('')
} catch (err) { setError(detail(err, failure)) }
finally { setBusy(false) }
}
const accept = (ids) => act(
() => api.post('/drafts/accept', { ids }),
'Could not move those across')
const reject = (ids) => act(
() => api.post('/drafts/reject', { ids }),
'Could not drop those')
const saveEdit = () => act(
async () => {
await api.patch(`/drafts/${editing}`, draftEdit)
setEditing(null)
setDraftEdit(null)
},
'Could not save that')
const startEdit = (draft) => {
setEditing(draft.id)
setDraftEdit({
question_text: draft.question_text || '',
options: draft.options || [],
correct_answer: draft.correct_answer || '',
explanation: draft.explanation || '',
})
}
const toggle = (id) => setPicked(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
if (!batch) return <div className="loading"><div className="spinner" /></div>
const chosen = [...picked]
const chosenReady = chosen.filter(id => {
const draft = batch.drafts.find(d => d.id === id)
return draft && draft.problems.length === 0 && draft.status === 'pending'
})
return (
<div className="db">
{error && <p className="db-error" role="alert">{error}</p>}
<div className="db-bar">
<label className="db-all">
<input type="checkbox"
checked={ready.length > 0 && chosenReady.length === ready.length}
disabled={ready.length === 0}
onChange={e => setPicked(e.target.checked ? new Set(ready.map(d => d.id)) : new Set())} />
<span>
{ready.length} ready{pending.length > ready.length &&
`, ${pending.length - ready.length} need work`}
</span>
</label>
<span className="db-bar-actions">
<button type="button" className="btn btn-secondary btn-sm"
disabled={busy || chosen.length === 0} onClick={() => reject(chosen)}>
Drop {chosen.length || ''}
</button>
{/* The only moment a question id is taken. */}
<button type="button" className="btn btn-primary btn-sm"
disabled={busy || chosenReady.length === 0} onClick={() => accept(chosenReady)}>
Move {chosenReady.length || ''} into the bank
</button>
</span>
</div>
<ul className="db-list">
{batch.drafts.map(draft => (
<li key={draft.id} className={`is-${draft.status}`}>
<div className="db-row">
{draft.status === 'pending' ? (
<input type="checkbox" checked={picked.has(draft.id)}
disabled={draft.problems.length > 0}
aria-label={`Select draft ${draft.position + 1}`}
onChange={() => toggle(draft.id)} />
) : (
<span className={`db-mark is-${draft.status}`} aria-label={draft.status}>
{draft.status === 'accepted' ? '✓' : '✕'}
</span>
)}
<div className="db-body">
{editing === draft.id ? (
<div className="db-edit">
<label>
<span>Question</span>
<textarea rows={4} value={draftEdit.question_text}
onChange={e => setDraftEdit(d => ({ ...d, question_text: e.target.value }))} />
</label>
<label>
<span>Options, one a line</span>
<textarea rows={4} value={draftEdit.options.join('\n')}
onChange={e => setDraftEdit(d => ({
...d, options: e.target.value.split('\n').filter(Boolean) }))} />
</label>
<label>
<span>Correct answer it has to be one of the options</span>
<input value={draftEdit.correct_answer}
onChange={e => setDraftEdit(d => ({ ...d, correct_answer: e.target.value }))} />
</label>
<label>
<span>Explanation</span>
<textarea rows={3} value={draftEdit.explanation}
onChange={e => setDraftEdit(d => ({ ...d, explanation: e.target.value }))} />
</label>
<div className="db-edit-actions">
<button type="button" className="btn btn-primary btn-sm"
disabled={busy} onClick={saveEdit}>Save</button>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => { setEditing(null); setDraftEdit(null) }}>Cancel</button>
</div>
</div>
) : (
<>
<p className="db-stem">
<span className="db-num">{draft.position + 1}.</span>
{draft.question_text}
</p>
{draft.options?.length > 0 && (
<ul className="db-options">
{draft.options.map((option, i) => (
<li key={i} className={option === draft.correct_answer ? 'is-right' : undefined}>
{option}
</li>
))}
</ul>
)}
{draft.problems.length > 0 && (
// Said before anyone opens it, so what needs work is
// visible from the list rather than on a failed attempt.
<p className="db-problems">Not ready: {draft.problems.join(', ')}</p>
)}
{draft.status === 'accepted' && (
<p className="db-became">Now question #{draft.question_id}.</p>
)}
</>
)}
</div>
{draft.status === 'pending' && editing !== draft.id && (
<span className="db-row-actions">
<button type="button" onClick={() => startEdit(draft)}>Edit</button>
<button type="button" disabled={busy} onClick={() => reject([draft.id])}>Drop</button>
</span>
)}
</div>
</li>
))}
</ul>
</div>
)
}

View file

@ -227,7 +227,7 @@ function ToolsSection() {
<div className="set-cards">
{[
{ to: '/handbook', icon: '📖', label: 'Handbook', desc: 'How the parts that are not obvious work' },
{ to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' },
{ to: '/tools', icon: '🧪', label: 'Question workbench', desc: 'PDFs in, drafts read, questions out' },
{ to: '/categories', icon: '🗂️', label: 'Taxonomy', desc: 'Topics, systems, symptoms, diseases' },
{ to: '/editorial', icon: '✍️', label: 'Editorial', desc: 'Article drafts and references' },
{ to: '/access', icon: '🔑', label: 'Access', desc: 'Who may edit what' },
@ -416,7 +416,14 @@ export default function SettingsPage() {
render: () => <DataSection /> },
...(isModerator ? [
{ key: 'library', group: 'Content', icon: '📚', label: 'Documents',
render: () => <><DocumentsSection /><NextcloudSection /></> },
render: () => (
<>
<DocumentsSection />
{/* One person loads the corpus. This was offered to every learner
as though each had a cloud to connect. */}
{isAdmin && <NextcloudSection />}
</>
) },
{ key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools',
render: () => <ToolsSection /> },
] : []),

View file

@ -0,0 +1,54 @@
.tools { max-width: 900px; margin: 0 auto; padding-bottom: 64px; }
.tools-head { margin-bottom: 24px; }
.tools-head h1 { margin: 0 0 8px; font-size: 1.5rem; font-weight: 700; }
.tools-head p { margin: 0; font-size: 0.92rem; line-height: 1.65; color: var(--text-muted); max-width: 66ch; }
.tools-error { margin: 0 0 14px; font-size: 0.85rem; color: var(--wrong-fg); }
/* What is waiting, said before anything else on the page. */
.tools-waiting {
margin: 0 0 20px; padding: 11px 14px;
font-size: 0.88rem; border-radius: 10px;
background: var(--option-sel-bg); color: var(--primary);
}
.tools-section { margin-bottom: 28px; }
.tools-section-head {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
}
.tools-section-head h2 { margin: 0; font-size: 1.05rem; font-weight: 650; }
.tools-empty, .tools-note, .tools-more {
margin: 0; padding: 14px; font-size: 0.86rem; line-height: 1.6;
color: var(--text-muted); background: var(--bg); border-radius: 10px;
}
.tools-more { padding: 8px 14px 0; background: none; font-size: 0.8rem; }
.tools-docs { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: 10px; }
.tools-docs > li {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 10px 14px; border-bottom: 1px solid var(--border);
}
.tools-docs > li:last-child { border-bottom: 0; }
.tools-docs a { text-decoration: none; color: var(--text); min-width: 0; }
.tools-docs strong { display: block; font-size: 0.88rem; font-weight: 650; overflow-wrap: anywhere; }
.tools-docs small { display: block; margin-top: 2px; font-size: 0.78rem; color: var(--text-muted); }
.tools-batches { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.tools-batches > li { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.tools-batches > li.is-open { border-color: var(--primary); }
.tools-batch {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
width: 100%; padding: 12px 14px;
font: inherit; text-align: left; cursor: pointer;
background: var(--card-bg); border: 0; color: var(--text);
}
.tools-batch:hover { background: var(--bg); }
.tools-batch strong { display: block; font-size: 0.92rem; font-weight: 650; }
.tools-batch small { display: block; margin-top: 2px; font-size: 0.78rem; color: var(--text-muted); }
.tools-counts { display: inline-flex; align-items: center; gap: 8px; flex: none; font-size: 0.76rem; }
.tools-counts em {
font-style: normal; font-weight: 600; padding: 2px 8px; border-radius: 999px;
}
.tools-counts .is-pending { background: var(--option-sel-bg); color: var(--primary); }
.tools-counts .is-accepted { background: var(--correct-bg); color: var(--correct-fg); }
.tools-counts .is-rejected { background: var(--bg); color: var(--text-subtle); }

View file

@ -0,0 +1,166 @@
import { lazy, Suspense, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import './ToolsPage.css'
const DraftBatch = lazy(() => import('../components/DraftBatch'))
const when = value => (value
? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
: '')
/**
* From a PDF to a question in the bank, on one page.
*
* Uploading, extracting and reviewing were three places, and the middle one
* wrote straight into the bank. They are one job: a document goes in, a model
* proposes questions, somebody reads them, and the ones worth keeping are
* moved across. Nothing reaches the bank until that last step.
*/
export default function ToolsPage() {
const { user } = useAuth()
const [batches, setBatches] = useState([])
const [documents, setDocuments] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [open, setOpen] = useState(null)
const [showDone, setShowDone] = useState(false)
const load = useCallback(() => {
Promise.all([
api.get('/drafts/batches').catch(() => ({ data: [] })),
api.get('/documents/').catch(() => ({ data: [] })),
])
.then(([b, d]) => { setBatches(b.data || []); setDocuments(d.data || []) })
.catch(() => setError('Could not load the workbench'))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
if (loading) return <div className="loading"><div className="spinner" /></div>
const shown = showDone ? batches : batches.filter(b => b.status !== 'closed')
const waiting = batches.filter(b => (b.counts?.pending || 0) > 0)
const pending = waiting.reduce((n, b) => n + (b.counts.pending || 0), 0)
return (
<div className="tools">
<div className="tools-head">
<h1>Tools</h1>
<p>
A document goes in, a model proposes questions, you read them, and the
ones worth keeping move into the bank. Nothing is in the bank until
you put it there.
</p>
</div>
{error && <p className="tools-error" role="alert">{error}</p>}
{pending > 0 && (
<p className="tools-waiting" role="status">
<strong>{pending}</strong> draft{pending === 1 ? '' : 's'} waiting to be read,
across {waiting.length} batch{waiting.length === 1 ? '' : 'es'}.
</p>
)}
<section className="tools-section">
<div className="tools-section-head">
<h2>Documents</h2>
<Link to="/upload" className="btn btn-primary btn-sm">Upload a PDF</Link>
</div>
{documents.length === 0 ? (
<p className="tools-empty">
Nothing uploaded yet. A PDF is where a batch of questions comes from.
</p>
) : (
<ul className="tools-docs">
{documents.slice(0, 8).map(doc => (
<li key={doc.id}>
<Link to={`/documents/${doc.id}`}>
<strong>{doc.original_filename}</strong>
<small>
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing…'}
{' · '}{when(doc.uploaded_at)}
</small>
</Link>
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
</li>
))}
</ul>
)}
{documents.length > 8 && (
<p className="tools-more">{documents.length - 8} more open one to extract from it.</p>
)}
</section>
<section className="tools-section">
<div className="tools-section-head">
<h2>Extracted questions</h2>
{batches.some(b => b.status === 'closed') && (
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setShowDone(v => !v)}>
{showDone ? 'Hide finished' : 'Show finished'}
</button>
)}
</div>
{shown.length === 0 ? (
<p className="tools-empty">
No batches waiting. Extract from a document above and the proposals
land here to be read.
</p>
) : (
<ul className="tools-batches">
{shown.map(batch => {
const counts = batch.counts || {}
const isOpen = open === batch.id
return (
<li key={batch.id} className={isOpen ? 'is-open' : undefined}>
<button type="button" className="tools-batch" aria-expanded={isOpen}
onClick={() => setOpen(id => (id === batch.id ? null : batch.id))}>
<span>
<strong>{batch.title}</strong>
<small>
{when(batch.created_at)}
{batch.model_id && ` · ${batch.model_id}`}
{batch.extraction_mode && ` · ${batch.extraction_mode}`}
</small>
</span>
<span className="tools-counts">
{counts.pending > 0 && <em className="is-pending">{counts.pending} to read</em>}
{counts.accepted > 0 && <em className="is-accepted">{counts.accepted} kept</em>}
{counts.rejected > 0 && <em className="is-rejected">{counts.rejected} dropped</em>}
<span aria-hidden="true">{isOpen ? '⌃' : '⌄'}</span>
</span>
</button>
{isOpen && (
<Suspense fallback={<div className="loading"><div className="spinner" /></div>}>
<DraftBatch batchId={batch.id} onChanged={load} />
</Suspense>
)}
</li>
)
})}
</ul>
)}
</section>
{user?.role === 'admin' && (
<section className="tools-section">
<div className="tools-section-head">
<h2>Import</h2>
</div>
<p className="tools-note">
{/* One person loads the corpus. It was in everyone's settings as
though each learner had a cloud to connect, which none of them
has and none of them needs. */}
Nextcloud is an import path for whoever loads the corpus, not a
per-learner integration. <Link to="/settings?s=library">Connect it</Link>.
</p>
</section>
)}
</div>
)
}

View file

@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import ToolsPage from './ToolsPage'
import api from '../api/client'
vi.mock('../api/client', () => ({
default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
}))
vi.mock('../context/AuthContext', () => ({
useAuth: () => ({ user: { id: 1, role: 'admin' } }),
}))
const BATCHES = [
{ id: 1, title: 'Nelson ch. 12', status: 'open', created_at: '2026-09-11',
model_id: 'claude-opus-5', extraction_mode: 'standard',
counts: { pending: 2, accepted: 1 } },
{ id: 2, title: 'Old run', status: 'closed', created_at: '2026-09-01', counts: { accepted: 5 } },
]
const BATCH = {
id: 1, title: 'Nelson ch. 12', status: 'open', counts: { pending: 2, accepted: 1 },
drafts: [
{ id: 11, position: 0, question_text: 'A neonate with cyanosis…', options: ['TGA', 'ASD'],
correct_answer: 'TGA', explanation: 'Because.', status: 'pending', problems: [], question_id: null },
{ id: 12, position: 1, question_text: 'Broken one', options: ['only'],
correct_answer: null, status: 'pending', problems: ['fewer than two options'], question_id: null },
{ id: 13, position: 2, question_text: 'Already kept', options: ['a', 'b'],
correct_answer: 'a', status: 'accepted', problems: [], question_id: 900 },
],
}
beforeEach(() => {
vi.clearAllMocks()
api.get.mockImplementation(url => {
if (url === '/drafts/batches') return Promise.resolve({ data: BATCHES })
if (url === '/drafts/batches/1') return Promise.resolve({ data: BATCH })
if (url === '/documents/') return Promise.resolve({ data: [
{ id: 3, original_filename: 'nelson.pdf', total_pages: 400, status: 'ready', uploaded_at: '2026-09-10' },
] })
return Promise.resolve({ data: [] })
})
api.post.mockResolvedValue({ data: { accepted: 1, questions: [] } })
api.patch.mockResolvedValue({ data: {} })
})
const mount = () => render(<MemoryRouter><ToolsPage /></MemoryRouter>)
describe('the question workbench', () => {
it('says what is waiting to be read before anything else', async () => {
mount()
// The count is its own element, so ask about the sentence around it.
const waiting = await screen.findByRole('status')
expect(waiting).toHaveTextContent('2 drafts waiting to be read')
expect(waiting).toHaveTextContent('across 1 batch')
})
it('keeps documents and extracted batches on the one page', async () => {
mount()
expect(await screen.findByText('nelson.pdf')).toBeInTheDocument()
expect(screen.getByText('Nelson ch. 12')).toBeInTheDocument()
})
it('hides finished batches until asked', async () => {
mount()
await screen.findByText('Nelson ch. 12')
expect(screen.queryByText('Old run')).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Show finished' }))
expect(screen.getByText('Old run')).toBeInTheDocument()
})
it('will not let a draft that is not ready be moved across', async () => {
mount()
await userEvent.click(await screen.findByRole('button', { name: /Nelson ch\. 12/ }))
// Said on the row rather than on a failed attempt to accept it.
expect(await screen.findByText(/fewer than two options/)).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Select draft 2' })).toBeDisabled()
})
it('moves only the chosen, ready drafts into the bank', async () => {
mount()
await userEvent.click(await screen.findByRole('button', { name: /Nelson ch\. 12/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: 'Select draft 1' }))
await userEvent.click(screen.getByRole('button', { name: /Move 1 into the bank/ }))
// The only moment a question id is taken.
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/drafts/accept', { ids: [11] }))
})
it('says what an accepted draft became', async () => {
mount()
await userEvent.click(await screen.findByRole('button', { name: /Nelson ch\. 12/ }))
expect(await screen.findByText(/Now question #900/)).toBeInTheDocument()
})
it('fixes a draft in place rather than in the bank', async () => {
mount()
await userEvent.click(await screen.findByRole('button', { name: /Nelson ch\. 12/ }))
const rows = await screen.findAllByRole('button', { name: 'Edit' })
await userEvent.click(rows[1])
const answer = screen.getByLabelText(/Correct answer/)
await userEvent.type(answer, 'two')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/drafts/12',
expect.objectContaining({ correct_answer: 'two' })))
})
})