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.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. */ +{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.{' '} + +
: