diff --git a/backend/app/routers/ai_mode.py b/backend/app/routers/ai_mode.py index 708b7a9..ef37457 100644 --- a/backend/app/routers/ai_mode.py +++ b/backend/app/routers/ai_mode.py @@ -203,9 +203,19 @@ async def ask(conversation_id: int, data: AskIn, db: Session = Depends(get_db), log.error("AI Mode failed for user %s", current_user.id, exc_info=True) raise HTTPException(502, "AI Mode is temporarily unavailable. Try again in a moment.") + # An empty answer is a failure, not an answer. It used to be stored and + # drawn as a blank card the learner could neither read nor retry — a silent + # failure is the worst kind, because it looks like the product working. + if not raw.strip(): + raise HTTPException(502, "The model returned nothing. Ask again.") + # The safety step: anything the model cited that retrieval did not find is # removed here, before it is stored or shown. reply, citations = ai_mode_service.enforce_citations(raw, sources) + # Citations can be the whole of a short reply — "See [[article:7]]." with an + # invented marker leaves an empty string once the marker is deleted. + if not reply.strip(): + raise HTTPException(502, "The model's answer did not survive checking. Ask again.") db.add(ConversationMessage(conversation_id=conversation.id, role="user", content=question, citations=[])) @@ -217,7 +227,7 @@ async def ask(conversation_id: int, data: AskIn, db: Session = Depends(get_db), # the conversation turned out to be about, so the name waits for the turn # that is — which is usually the very next one. if conversation.title == "New chat" and mode != "chat": - conversation.title = question[:80] + ("…" if len(question) > 80 else "") + conversation.title = ai_mode_service.thread_title(question) conversation.updated_at = datetime.utcnow() db.commit() db.refresh(answer) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f266aa6..e2cae45 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -23,6 +23,13 @@ class UserResponse(BaseModel): # Whether there is one at all, never anything about it. Settings has to say # "Set a password" or "Change password", and it cannot tell from the outside. has_password: bool = False + # What the role *means*, computed once here rather than in every page that + # asks. The interface had been checking `user.is_moderator` for months on a + # payload that has never carried it, so every moderator-only control was + # hidden from moderators — including the AI draft panel, which is why + # "Draft with AI" appeared to do nothing. + is_moderator: bool = False + is_admin: bool = False class Config: from_attributes = True @@ -33,6 +40,7 @@ class UserResponse(BaseModel): "id": user.id, "email": user.email, "name": user.name, "role": user.role, "is_unthrottled": user.is_unthrottled or 0, "created_at": user.created_at, "has_password": bool(user.hashed_password), + "is_moderator": bool(user.is_moderator), "is_admin": bool(user.is_admin), }) diff --git a/backend/app/services/ai_mode_service.py b/backend/app/services/ai_mode_service.py index c6560f6..1581b9f 100644 --- a/backend/app/services/ai_mode_service.py +++ b/backend/app/services/ai_mode_service.py @@ -287,6 +287,14 @@ CITE = ( "write a URL and never cite a marker that is not listed here.\n\n" "Never reveal the answer to a practice question. You may say what a question " "is about so the learner can go and attempt it.\n\n" + # Asked for five questions and able to see one, it explained at length that + # it had "only actually looked at one cervicitis item so far" and offered to + # go and gather the rest. None of that is the learner's problem: what they + # can practise is under the answer, as a control that builds the session. + "Never describe your own retrieval — how many sources you were given, that " + "you have not looked at more, or what you could go and fetch. If the learner " + "asks for more questions than you can cite, name what there is in one " + "sentence and stop. Do not write practice questions of your own.\n\n" "Be brief: a few sentences or a short list.\n\n" ) @@ -515,3 +523,40 @@ def practice_ids(db: Session, user: User, citations, question: str) -> list[int] allowed = allowed.filter(scope) visible = {row.id for row in allowed.all()} return [qid for qid in ordered if qid in visible][:PRACTICE_MAX] + + +#: Openers a learner types before the real question. Dropped from the thread +#: name, never from the question itself. +_FILLER = re.compile( + r"^(?:hi|hey|hello|ok|okay|so|please|pls|can you|could you|tell me|" + r"i want to know|i'd like to know|explain to me)\b[\s,:-]*", re.I) +_TITLE_MAX = 60 + + +def thread_title(question: str) -> str: + """A name for a conversation, taken from its first question. + + It used to be the raw question truncated at eighty characters, which is how + a sidebar ends up reading "how do i treat cervicitis in a teenager and wh…" + — the learner's typing, warts and all. This trims the throat-clearing, + starts with a capital, and cuts at a word rather than mid-syllable. + """ + original = " ".join((question or "").split()) + text = original + # "ok so bronchiolitis" is two openers, not one. + for _ in range(3): + stripped = _FILLER.sub("", text).strip() + if stripped == text: + break + text = stripped + text = text or original + if not text: + return "New chat" + text = re.sub(r"\bi\b", "I", text) + if len(text) > _TITLE_MAX: + cut = text[:_TITLE_MAX].rsplit(" ", 1)[0] or text[:_TITLE_MAX] + text = cut.rstrip(" ,;:-") + "…" + else: + # A full stop adds nothing to a label; a question mark says what it is. + text = text.rstrip(" .,;:") + return text[0].upper() + text[1:] diff --git a/backend/tests/test_ai_mode.py b/backend/tests/test_ai_mode.py index 5671b22..f0b8198 100644 --- a/backend/tests/test_ai_mode.py +++ b/backend/tests/test_ai_mode.py @@ -236,6 +236,32 @@ class AiModeRouteTests(_AiModeBase): # A half-written exchange is worse than none: the question is not kept. self.assertEqual(self.db.query(ConversationMessage).count(), 0) + def test_an_empty_answer_is_refused_rather_than_drawn_as_a_blank_card(self): + conversation_id = self.client.post('/ai/conversations').json()['id'] + with self.reply_with(" "): + response = self.client.post(f'/ai/conversations/{conversation_id}/messages', + json={'message': 'febrile seizure'}) + self.assertEqual(response.status_code, 502) + self.assertEqual(self.db.query(ConversationMessage).count(), 0) + + def test_an_answer_that_was_only_an_invented_citation_is_refused(self): + conversation_id = self.client.post('/ai/conversations').json()['id'] + # Every word of it goes when the marker nobody can vouch for goes. + with self.reply_with("[[article:9999]]"): + response = self.client.post(f'/ai/conversations/{conversation_id}/messages', + json={'message': 'febrile seizure'}) + self.assertEqual(response.status_code, 502) + self.assertEqual(self.db.query(ConversationMessage).count(), 0) + + def test_a_thread_is_named_tidily_from_its_first_question(self): + conversation_id = self.client.post('/ai/conversations').json()['id'] + with self.reply_with("An answer."): + response = self.client.post( + f'/ai/conversations/{conversation_id}/messages', + json={'message': 'hi, how do i treat a febrile seizure?'}) + # Not the learner's typing verbatim: no opener, a capital, and "I". + self.assertEqual(response.json()['title'], 'How do I treat a febrile seizure?') + def test_deleting_a_thread_takes_its_messages(self): with self.reply_with("An answer."): conversation_id, _ = self.ask('febrile seizure') diff --git a/frontend/src/components/ArticleLink.jsx b/frontend/src/components/ArticleLink.jsx index c8b9cfa..c6a5750 100644 --- a/frontend/src/components/ArticleLink.jsx +++ b/frontend/src/components/ArticleLink.jsx @@ -38,7 +38,7 @@ export function fetchPreview(slug) { /** The numeric id behind a slug, reusing whatever the hover card already fetched. */ export const resolveArticleId = (slug) => fetchPreview(slug).then(data => data?.id ?? null) -export default function ArticleLink({ slug, children, className = '' }) { +export default function ArticleLink({ slug, sectionId = null, children, className = '' }) { const [preview, setPreview] = useState(null) const [open, setOpen] = useState(false) const [above, setAbove] = useState(false) @@ -48,7 +48,10 @@ export default function ArticleLink({ slug, children, className = '' }) { // A cross-reference written by id addresses the article directly; one // written by slug goes through the slug route, which also resolves the // names an article used to have. - const href = /^\d+$/.test(slug) ? `/articles/${slug}` : `/articles/s/${slug}` + // A citation can point at one section rather than the whole article; the + // reader opens on it. Nothing in prose writes one, so it is normally absent. + const where = sectionId ? `?section=${encodeURIComponent(sectionId)}` : '' + const href = (/^\d+$/.test(slug) ? `/articles/${slug}` : `/articles/s/${slug}`) + where useEffect(() => () => clearTimeout(timer.current), []) diff --git a/frontend/src/pages/AiModePage.css b/frontend/src/pages/AiModePage.css index 972efcc..5bb999b 100644 --- a/frontend/src/pages/AiModePage.css +++ b/frontend/src/pages/AiModePage.css @@ -93,6 +93,12 @@ } .ai-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 0; } +.ai-empty { color: var(--text-muted); font-size: 0.9rem; margin: 0; } +.ai-again { + background: none; border: 0; padding: 0; cursor: pointer; + color: var(--primary); font: inherit; text-decoration: underline; +} +.ai-again:disabled { opacity: 0.5; cursor: default; } /* The field and its controls are one object with one outline, so the microphone reads as part of the question rather than as a button parked next to it. */ @@ -136,8 +142,23 @@ } .ai-starters-more:hover { color: var(--primary); } +/* A source read beside the answer. The page is capped narrow for reading a + conversation; the pane needs the rest of the monitor, so the cap lifts only + while one is open and drops again when it closes. */ +.ai-page.has-split { max-width: 1560px; grid-template-columns: 250px minmax(0, 1fr) minmax(360px, 42%); } +.ai-page.has-split.is-folded { grid-template-columns: 52px minmax(0, 1fr) minmax(360px, 42%); } +.ai-page.has-split .article-split-pane { position: sticky; top: 12px; max-height: calc(100dvh - 24px); } + +@media (max-width: 1180px) { + /* Not enough width for three columns: the pane takes the conversation's + place rather than squeezing both into ribbons. */ + .ai-page.has-split, .ai-page.has-split.is-folded { grid-template-columns: 52px minmax(0, 1fr); } + .ai-page.has-split .ai-main { display: none; } +} + @media (max-width: 820px) { - .ai-page, .ai-page.is-folded { grid-template-columns: 1fr; } + .ai-page, .ai-page.is-folded, + .ai-page.has-split, .ai-page.has-split.is-folded { grid-template-columns: 1fr; } /* A drawer from the left, like the session's questions and an article's contents — not a panel that pushes the conversation down the page. */ .ai-rail { diff --git a/frontend/src/pages/AiModePage.jsx b/frontend/src/pages/AiModePage.jsx index 36285cc..619ce72 100644 --- a/frontend/src/pages/AiModePage.jsx +++ b/frontend/src/pages/AiModePage.jsx @@ -1,8 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import api from '../api/client' +import ArticleLink from '../components/ArticleLink' +// The whole article reader, loaded only if a source is actually opened beside +// the conversation — most chats never open one. +const ArticleSplitPane = lazy(() => import('../components/ArticleSplitPane')) +import { SplitViewProvider } from '../context/SplitViewContext' import useMediaQuery from '../hooks/useMediaQuery' import { useSessionDrawer } from '../context/SessionDrawer' import useDictation, { canDictate } from '../hooks/useDictation' @@ -167,6 +172,14 @@ function Answer({ content, citations, onPractise, practising, built, onStart, on {citation.title} {citation.text &&

{citation.text}

} + ) : citation.kind === 'article' || citation.kind === 'section' ? ( + /* The same hover card a cross-reference in prose gets: what + the source says, and the choice of a tab or the pane beside + the answer. A source you have to leave the conversation to + read is a source most people will not read. */ + + {citation.title} + ) : ( {citation.title} )} @@ -190,7 +203,7 @@ function Answer({ content, citations, onPractise, practising, built, onStart, on {built && (
- {built.count} question{built.count === 1 ? '' : 's'} ready + {built.count} question{built.count === 1 ? '' : 's'} ready to sit Sit it now, or leave it in your sessions and carry on here.
@@ -222,6 +235,7 @@ function Answer({ content, citations, onPractise, practising, built, onStart, on */ export default function AiModePage() { const [threads, setThreads] = useState([]) + const [splitTrail, setSplitTrail] = useState([]) const [activeId, setActiveId] = useState(null) const [messages, setMessages] = useState([]) const [draft, setDraft] = useState('') @@ -260,9 +274,12 @@ export default function AiModePage() { // thread of its own for the answer, and the thread list must not quietly open // the most recent chat underneath it. const fromUrl = useRef(Boolean(incoming)) - // The thread whose first answer is still in flight. Its transcript lives only - // in this tab so far, so fetching it would wipe the question sitting in it. - const inFlight = useRef(null) + // Which thread the messages on screen belong to. It used to be "the thread + // whose first answer is in flight", cleared the moment that request finished + // — which lost the race whenever React ran the effect below after that: it + // fetched an empty transcript over the exchange that had just happened, and + // left the learner looking at the starting prompts again. + const shown = useRef(null) // Speaking instead of typing. The browser's own recogniser where there is // one, our transcriber where there is not. const dictation = useDictation({ @@ -282,8 +299,10 @@ export default function AiModePage() { }, [loadThreads]) useEffect(() => { - if (activeId == null) { setMessages([]); return } - if (inFlight.current === activeId) return + if (activeId == null) { shown.current = null; setMessages([]); return } + // Already on screen — either just fetched, or written here as it happened. + if (shown.current === activeId) return + shown.current = activeId api.get(`/ai/conversations/${activeId}`) .then(res => setMessages(res.data.messages || [])) .catch(() => setError('Could not open that conversation')) @@ -328,7 +347,9 @@ export default function AiModePage() { // sit twenty questions is a decision, and it was being made for the // learner by a button that read "Practise these 3 topics" — the session // is now made, kept, and waiting either way. - setBuilt({ messageId, quizId: res.data.quiz_id, count: res.data.questions_count }) + // `count`, which is what the endpoint returns. Reading a field that does + // not exist rendered the offer as a headless "questions ready". + setBuilt({ messageId, quizId: res.data.quiz_id, count: res.data.count }) } catch (err) { setError(apiError(err, 'Could not build a session from this answer')) } finally { @@ -358,7 +379,7 @@ export default function AiModePage() { if (threadId == null) { const created = await api.post('/ai/conversations') threadId = created.data.id - inFlight.current = threadId + shown.current = threadId setActiveId(threadId) setThreads(prev => [{ id: threadId, title: 'New chat', message_count: 0, updated_at: null }, ...prev]) } @@ -370,7 +391,6 @@ export default function AiModePage() { setMessages(prev => prev.filter(m => !String(m.id).startsWith('pending-'))) setDraft(question) // Handing the question back rather than losing it. } finally { - inFlight.current = null setSending(false) } } @@ -410,8 +430,18 @@ export default function AiModePage() { const blank = messages.length === 0 && !sending const starters = moreStarters ? STARTERS : STARTERS.slice(0, STARTERS_SHOWN) + // A source opened beside the answer rather than instead of it — the same + // trail the reading page keeps, so following a reference out of a source + // replaces the pane and offers the way back. + const pushSplit = useCallback((slug) => setSplitTrail( + trail => (trail.at(-1) === slug ? trail : [...trail, slug])), []) + const splitView = useMemo(() => ({ open: pushSplit, inPane: false }), [pushSplit]) + const paneView = useMemo(() => ({ open: pushSplit, inPane: true }), [pushSplit]) + const splitSlug = splitTrail.at(-1) || null + return ( -
+ +
{/* No in-page Chats button. On a phone the menu in the header opens this rail, the way it opens a session's questions and an article's contents — one control, in the same place, on every page. */} @@ -457,10 +487,19 @@ export default function AiModePage() {

How can PedsHub help you today?

) : (
- {messages.map(message => ( + {messages.map((message, i) => (
{message.role === 'user' ?

{message.content}

+ : !String(message.content || '').trim() + // An answer that came back empty used to be drawn as an empty + // card — nothing to read and nothing to do. New ones are + // refused before they are stored; these are the old ones. + ?

+ No answer came back.{' '} + +

: )} + + {splitSlug && ( + + }> + setSplitTrail(trail => trail.slice(0, -1))} + onClose={() => setSplitTrail([])} /> + + + )}
+ ) } diff --git a/frontend/src/pages/AiModePage.test.jsx b/frontend/src/pages/AiModePage.test.jsx index ca7f5c5..1faf076 100644 --- a/frontend/src/pages/AiModePage.test.jsx +++ b/frontend/src/pages/AiModePage.test.jsx @@ -70,6 +70,37 @@ describe('AI Mode', () => { expect(within(curated).getByText('linked')).toBeInTheDocument() }) + it('gives a source the same hover card as a cross-reference, pane and all', async () => { + api.get.mockImplementation(url => { + if (url === '/ai/conversations') return Promise.resolve({ data: threads }) + if (url === '/articles/preview/7') return Promise.resolve({ data: { + id: 7, slug: 'febrile-seizures', title: 'Febrile seizures', + excerpt: 'A seizure with a fever and nothing else to blame.', + section_count: 4, status: 'published' } }) + if (url === '/articles/7') return Promise.resolve({ data: { + id: 7, title: 'Febrile seizures', sections: [], status: 'published' } }) + if (url.startsWith('/ai/conversations/')) return Promise.resolve({ + data: { id: 1, title: 'Febrile seizures', messages: [answer] } }) + return Promise.resolve({ data: [] }) + }) + mount() + await screen.findByText(/Fever first/) + + // Clicking the source shows what it says rather than leaving the + // conversation to find out — the same card the prose links have. + const sources = document.querySelector('.ai-sources') + await userEvent.click(within(sources).getByText('Febrile seizures')) + const card = await screen.findByRole('tooltip') + expect(card).toHaveTextContent('A seizure with a fever') + expect(within(card).getByRole('link', { name: /new tab/i })) + .toHaveAttribute('href', '/articles/7') + + // And the pane opens beside the answer, not instead of it. + await userEvent.click(within(card).getByRole('button', { name: /split view/i })) + await screen.findByRole('region', { name: /Split view/ }) + expect(document.querySelector('.ai-page')).toHaveClass('has-split') + }) + it('shows the question straight away rather than after a round trip', async () => { mount() await screen.findByRole('button', { name: 'Febrile seizures' }) @@ -143,7 +174,7 @@ describe('AI Mode', () => { it('turns an answer into a session, and says so when there is nothing to sit', async () => { mockApi(threads, [answer]) - api.post.mockResolvedValue({ data: { quiz_id: 91, questions_count: 6 } }) + api.post.mockResolvedValue({ data: { quiz_id: 91, count: 6 } }) mount() await userEvent.click(await screen.findByRole('button', { name: 'Febrile seizures' })) // Named after what it will build, and named before it is pressed. @@ -155,7 +186,7 @@ describe('AI Mode', () => { // Built, and offered rather than entered: leaving a conversation to sit // twenty questions is a decision, and "Later" leaves the session in the // list rather than throwing it away. - expect(await screen.findByText(/6 questions ready/)).toBeInTheDocument() + expect(await screen.findByText(/6 questions ready to sit/)).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Start now' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Later' })) expect(screen.queryByText(/questions ready/)).not.toBeInTheDocument()