feat: AI Mode — a chat that cannot cite what it did not find
The design settled earlier, built as described: retrieval decides what may be cited, and the server enforces it. The model is handed a shortlist of at most fourteen sources from the learner's own library and told to cite them by marker. Afterwards every citation it wrote is checked against that shortlist and anything else is deleted before it is stored or shown. A hallucinated citation is not unlikely here, it is impossible — surviving is not a decision the model gets to make. A URL it invents is not a citation either: only the marker form counts, so a plausible-looking link stays in the prose citing nothing. Retrieval reuses the hybrid search already in place, and each corpus keeps its own visibility rules — the bank predicate and exam scope for questions, the draft rule for articles, deck ownership for cards. A question source carries the stem only: a chat that printed the answer would hand away the practice it exists to prepare you for. Curated links do the job they were built for. A retrieved row an educator tied to another retrieved row is boosted, because two things somebody already linked surfacing for one query is evidence rather than coincidence. Nothing is stored for this; the boost lives only in that ordering, and the answer marks those sources so the reader knows which claim rests on an educator's judgement rather than on a ranking. Citations are stored with the answer as filtered, so reopening a thread shows the links it showed at the time rather than a fresh retrieval that may now rank differently. In the page the markers become numbers and each number opens its source; a section citation deep-links into that section. Two smaller decisions worth naming: a question appears in the thread the moment you send it and is handed back to the input if the answer fails, because typed words are not something to lose on a 502; and someone else's thread returns 404 rather than 403, since whether it exists is not your business either. 182 backend, 206 frontend green — 16 of the backend tests are the citation contract and the retrieval boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
2d2845fa0a
commit
9beafff0bf
12 changed files with 1129 additions and 5 deletions
41
backend/alembic/versions/a5b6c7d8e9f0_conversations.py
Normal file
41
backend/alembic/versions/a5b6c7d8e9f0_conversations.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""AI Mode threads and their turns.
|
||||||
|
|
||||||
|
Revision ID: a5b6c7d8e9f0
|
||||||
|
Revises: f4a5b6c7d8e9
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "a5b6c7d8e9f0"
|
||||||
|
down_revision = "f4a5b6c7d8e9"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"conversations",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer,
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("title", sa.String(200), nullable=False, server_default="New chat"),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"conversation_messages",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column("conversation_id", sa.Integer,
|
||||||
|
sa.ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("role", sa.String(16), nullable=False),
|
||||||
|
sa.Column("content", sa.Text, nullable=False),
|
||||||
|
# What the answer was allowed to cite after filtering, so reopening a
|
||||||
|
# thread shows the links it showed at the time.
|
||||||
|
sa.Column("citations", sa.JSON, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("conversation_messages")
|
||||||
|
op.drop_table("conversations")
|
||||||
|
|
@ -11,7 +11,7 @@ from app.logging_config import setup_logging
|
||||||
setup_logging(settings.LOG_LEVEL)
|
setup_logging(settings.LOG_LEVEL)
|
||||||
from app.database import engine, Base, SessionLocal
|
from app.database import engine, Base, SessionLocal
|
||||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
|
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
|
||||||
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search
|
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode
|
||||||
from app.utils.auth import get_password_hash
|
from app.utils.auth import get_password_hash
|
||||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||||
|
|
||||||
|
|
@ -168,7 +168,7 @@ def setup_pgvector():
|
||||||
# Import new models so create_all picks them up
|
# Import new models so create_all picks them up
|
||||||
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
|
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
|
||||||
from app.models import flashcard, course # noqa
|
from app.models import flashcard, course # noqa
|
||||||
from app.models import category_grant, exam, media, study_plan # noqa
|
from app.models import category_grant, conversation, exam, media, study_plan # noqa
|
||||||
|
|
||||||
# Kill stale idle-in-transaction connections from previous killed startups.
|
# Kill stale idle-in-transaction connections from previous killed startups.
|
||||||
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
|
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
|
||||||
|
|
@ -643,6 +643,7 @@ app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
|
||||||
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
|
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
|
||||||
app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"])
|
app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"])
|
||||||
app.include_router(search.router, prefix="/api/search", tags=["search"])
|
app.include_router(search.router, prefix="/api/search", tags=["search"])
|
||||||
|
app.include_router(ai_mode.router, prefix="/api/ai", tags=["ai-mode"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
|
||||||
43
backend/app/models/conversation.py
Normal file
43
backend/app/models/conversation.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(Base):
|
||||||
|
"""One AI Mode thread, belonging to one learner."""
|
||||||
|
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
title = Column(String(200), nullable=False, default="New chat")
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
messages = relationship("ConversationMessage", back_populates="conversation",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ConversationMessage.id")
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationMessage(Base):
|
||||||
|
"""A turn in a thread.
|
||||||
|
|
||||||
|
`citations` holds what the answer was allowed to cite *after* the server
|
||||||
|
filtered it, so reopening a thread shows the same links it showed at the
|
||||||
|
time — not a fresh retrieval that may now rank differently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "conversation_messages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
conversation_id = Column(Integer, ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
role = Column(String(16), nullable=False) # user | assistant
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
citations = Column(JSON, nullable=False, default=list)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
conversation = relationship("Conversation", back_populates="messages")
|
||||||
156
backend/app/routers/ai_mode.py
Normal file
156
backend/app/routers/ai_mode.py
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
"""AI Mode — a chat that can only answer from the learner's own library.
|
||||||
|
|
||||||
|
The router is thin on purpose. Everything that matters is in
|
||||||
|
`ai_mode_service`: retrieval builds the shortlist, the shortlist is the whole
|
||||||
|
prompt, and every citation the model writes is checked against that shortlist
|
||||||
|
before anyone sees it.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.conversation import Conversation, ConversationMessage
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import ai_mode_service
|
||||||
|
from app.services.ai_service import get_model_for_task
|
||||||
|
from app.utils.auth import check_rate_limit, get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DAILY_LIMIT = 60
|
||||||
|
# How much of the thread goes back to the model. Long enough to follow a
|
||||||
|
# conversation, short enough that the sources stay the bulk of the prompt.
|
||||||
|
HISTORY_TURNS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _own(db: Session, conversation_id: int, user: User) -> Conversation:
|
||||||
|
conversation = db.get(Conversation, conversation_id)
|
||||||
|
if not conversation or conversation.user_id != user.id:
|
||||||
|
# Not "forbidden": whether somebody else's thread exists is not this
|
||||||
|
# user's business either.
|
||||||
|
raise HTTPException(404, "Conversation not found")
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
|
def _message_json(message: ConversationMessage) -> dict:
|
||||||
|
return {
|
||||||
|
"id": message.id, "role": message.role, "content": message.content,
|
||||||
|
"citations": message.citations or [],
|
||||||
|
"created_at": message.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations")
|
||||||
|
def list_conversations(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
rows = db.query(Conversation).filter(Conversation.user_id == current_user.id).order_by(
|
||||||
|
Conversation.updated_at.desc(), Conversation.id.desc()).limit(50).all()
|
||||||
|
return [{"id": c.id, "title": c.title, "updated_at": c.updated_at,
|
||||||
|
"message_count": len(c.messages)} for c in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversations", status_code=201)
|
||||||
|
def create_conversation(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
conversation = Conversation(user_id=current_user.id, title="New chat")
|
||||||
|
db.add(conversation)
|
||||||
|
db.commit()
|
||||||
|
return {"id": conversation.id, "title": conversation.title, "messages": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations/{conversation_id}")
|
||||||
|
def get_conversation(conversation_id: int, db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)):
|
||||||
|
conversation = _own(db, conversation_id, current_user)
|
||||||
|
return {"id": conversation.id, "title": conversation.title,
|
||||||
|
"messages": [_message_json(m) for m in conversation.messages]}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationRename(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/conversations/{conversation_id}")
|
||||||
|
def rename_conversation(conversation_id: int, data: ConversationRename,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)):
|
||||||
|
conversation = _own(db, conversation_id, current_user)
|
||||||
|
conversation.title = data.title.strip()
|
||||||
|
db.commit()
|
||||||
|
return {"id": conversation.id, "title": conversation.title}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/conversations/{conversation_id}", status_code=204)
|
||||||
|
def delete_conversation(conversation_id: int, db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)):
|
||||||
|
db.delete(_own(db, conversation_id, current_user))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class AskIn(BaseModel):
|
||||||
|
message: str = Field(min_length=1, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversations/{conversation_id}/messages")
|
||||||
|
async def ask(conversation_id: int, data: AskIn, db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)):
|
||||||
|
"""Answer from the learner's library, citing only what retrieval found."""
|
||||||
|
conversation = _own(db, conversation_id, current_user)
|
||||||
|
question = data.message.strip()
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
check_rate_limit(
|
||||||
|
key=f"ai_mode_daily:{current_user.id}:{today}",
|
||||||
|
max_calls=DAILY_LIMIT, window_seconds=86400,
|
||||||
|
detail=f"You've reached today's AI Mode limit of {DAILY_LIMIT} messages.",
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
model_id, api_key = get_model_for_task(db, "teach")
|
||||||
|
if not model_id:
|
||||||
|
raise HTTPException(503, "No AI model is configured. Ask an admin to set one up.")
|
||||||
|
|
||||||
|
sources = ai_mode_service.retrieve(db, current_user, question)
|
||||||
|
history = [{"role": m.role, "content": m.content}
|
||||||
|
for m in conversation.messages[-HISTORY_TURNS:]]
|
||||||
|
messages = [{"role": "system", "content": ai_mode_service.build_prompt(sources)},
|
||||||
|
*history, {"role": "user", "content": question}]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.ai_service import _proxy_model
|
||||||
|
|
||||||
|
kwargs = {"model": _proxy_model(model_id), "messages": messages,
|
||||||
|
"max_tokens": 700, "temperature": 0.3}
|
||||||
|
if api_key or settings.LITELLM_API_KEY:
|
||||||
|
kwargs["api_key"] = api_key or settings.LITELLM_API_KEY
|
||||||
|
if settings.LITELLM_API_BASE:
|
||||||
|
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||||
|
response = await litellm.acompletion(**kwargs)
|
||||||
|
raw = (response.choices[0].message.content or "").strip()
|
||||||
|
except Exception:
|
||||||
|
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.")
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
db.add(ConversationMessage(conversation_id=conversation.id, role="user",
|
||||||
|
content=question, citations=[]))
|
||||||
|
answer = ConversationMessage(conversation_id=conversation.id, role="assistant",
|
||||||
|
content=reply, citations=citations)
|
||||||
|
db.add(answer)
|
||||||
|
# The first question names the thread; "New chat" ages badly in a rail of them.
|
||||||
|
if conversation.title == "New chat":
|
||||||
|
conversation.title = question[:80] + ("…" if len(question) > 80 else "")
|
||||||
|
conversation.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(answer)
|
||||||
|
return {"message": _message_json(answer), "title": conversation.title,
|
||||||
|
"source_count": len(sources)}
|
||||||
246
backend/app/services/ai_mode_service.py
Normal file
246
backend/app/services/ai_mode_service.py
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"""Retrieval and citation handling for AI Mode.
|
||||||
|
|
||||||
|
The safety property, stated plainly: **retrieval decides what may be cited, and
|
||||||
|
the server enforces it.** The model is handed a numbered shortlist and told to
|
||||||
|
cite from it by id. Afterwards every citation it wrote is checked against that
|
||||||
|
shortlist and anything else is deleted. A citation the model invented cannot
|
||||||
|
survive, because surviving is not a thing the model gets to decide.
|
||||||
|
|
||||||
|
This is the same discipline as the article page not printing answers: a property
|
||||||
|
the system holds, not one the model is trusted to respect.
|
||||||
|
|
||||||
|
Two kinds of link exist and are treated differently. *Curated* links are rows an
|
||||||
|
educator created — a question tied to an article section, a card to a question.
|
||||||
|
They are assertions, so they are trusted, and a retrieved row that carries one to
|
||||||
|
another retrieved row is boosted: two things an educator already tied together
|
||||||
|
answering the same query is evidence, not coincidence. *Retrieved* links are
|
||||||
|
ranked guesses computed per query and stored nowhere.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.article import Article, ArticleSectionIndex, QuestionArticleLink
|
||||||
|
from app.models.flashcard import Flashcard, FlashcardDeck
|
||||||
|
from app.models.question import Question
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.quiz_builder import bank_query, exam_scope_predicate
|
||||||
|
from app.services.search_service import hybrid_ids
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# How many of each kind retrieval offers the model. Small on purpose: a
|
||||||
|
# shortlist the model can hold is worth more than a corpus it skims.
|
||||||
|
PER_KIND = 6
|
||||||
|
MAX_SOURCES = 14
|
||||||
|
# An excerpt long enough to answer from, short enough that fourteen of them fit.
|
||||||
|
EXCERPT_CHARS = 700
|
||||||
|
# A curated tie between two retrieved rows is evidence an educator left behind.
|
||||||
|
CURATED_BOOST = 0.5
|
||||||
|
|
||||||
|
CITATION_RE = re.compile(r"\[\[(article|section|question|card):([A-Za-z0-9#_-]+)\]\]")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: str | None, limit: int = EXCERPT_CHARS) -> str:
|
||||||
|
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", value or "")
|
||||||
|
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||||
|
text = re.sub(r"<[^>]+>", " ", text)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
return text[:limit] + ("…" if len(text) > limit else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _articles(db: Session, user: User, query: str) -> list[dict]:
|
||||||
|
ranked, _ = hybrid_ids(db, query, "article", limit=PER_KIND * 3)
|
||||||
|
if not ranked:
|
||||||
|
return []
|
||||||
|
rows = db.query(Article).filter(Article.id.in_(ranked)).all()
|
||||||
|
if not user.is_moderator:
|
||||||
|
rows = [a for a in rows if a.status == "published" or a.user_id == user.id]
|
||||||
|
order = {rid: i for i, rid in enumerate(ranked)}
|
||||||
|
rows.sort(key=lambda a: order.get(a.id, len(order)))
|
||||||
|
return [{
|
||||||
|
"kind": "article", "ref": str(a.id), "id": a.id,
|
||||||
|
"title": a.title,
|
||||||
|
"text": _clean(a.summary or a.content),
|
||||||
|
"score": 1.0 / (1 + order.get(a.id, 0)),
|
||||||
|
} for a in rows[:PER_KIND]]
|
||||||
|
|
||||||
|
|
||||||
|
def _sections(db: Session, user: User, query: str) -> list[dict]:
|
||||||
|
ranked, _ = hybrid_ids(db, query, "article_section", limit=PER_KIND * 3)
|
||||||
|
if not ranked:
|
||||||
|
return []
|
||||||
|
rows = db.query(ArticleSectionIndex).filter(ArticleSectionIndex.id.in_(ranked)).all()
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
articles = {a.id: a for a in db.query(Article).filter(
|
||||||
|
Article.id.in_({r.article_id for r in rows})).all()}
|
||||||
|
order = {rid: i for i, rid in enumerate(ranked)}
|
||||||
|
rows.sort(key=lambda r: order.get(r.id, len(order)))
|
||||||
|
out = []
|
||||||
|
for row in rows[:PER_KIND]:
|
||||||
|
article = articles.get(row.article_id)
|
||||||
|
if not article:
|
||||||
|
continue
|
||||||
|
if article.status != "published" and not user.is_moderator and article.user_id != user.id:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"kind": "section", "ref": f"{article.id}#{row.section_id}",
|
||||||
|
"id": article.id, "section_id": row.section_id,
|
||||||
|
"title": f"{article.title} › {row.title or 'section'}",
|
||||||
|
"text": _clean(row.content),
|
||||||
|
"score": 1.0 / (1 + order.get(row.id, 0)),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _questions(db: Session, user: User, query: str) -> list[dict]:
|
||||||
|
ranked, _ = hybrid_ids(db, query, "question", limit=PER_KIND * 3)
|
||||||
|
if not ranked:
|
||||||
|
return []
|
||||||
|
q = bank_query(db, user).filter(Question.id.in_(ranked))
|
||||||
|
scope = exam_scope_predicate(db, user)
|
||||||
|
if scope is not None:
|
||||||
|
q = q.filter(scope)
|
||||||
|
order = {rid: i for i, rid in enumerate(ranked)}
|
||||||
|
rows = sorted(q.all(), key=lambda r: order.get(r.id, len(order)))
|
||||||
|
return [{
|
||||||
|
"kind": "question", "ref": str(row.id), "id": row.id,
|
||||||
|
"title": f"Question #{row.id}",
|
||||||
|
# The stem only. An answer belongs to the quiz runner, and a chat that
|
||||||
|
# printed it would hand away the practice it is meant to prepare for.
|
||||||
|
"text": _clean(row.question_text, 320),
|
||||||
|
"score": 1.0 / (1 + order.get(row.id, 0)),
|
||||||
|
} for row in rows[:PER_KIND]]
|
||||||
|
|
||||||
|
|
||||||
|
def _cards(db: Session, user: User, query: str) -> list[dict]:
|
||||||
|
ranked, _ = hybrid_ids(db, query, "flashcard", limit=PER_KIND * 3)
|
||||||
|
if not ranked:
|
||||||
|
return []
|
||||||
|
own = [d.id for d in db.query(FlashcardDeck.id).filter(
|
||||||
|
FlashcardDeck.user_id == user.id, FlashcardDeck.deleted_at.is_(None)).all()]
|
||||||
|
if not own:
|
||||||
|
return []
|
||||||
|
rows = db.query(Flashcard).filter(
|
||||||
|
Flashcard.id.in_(ranked), Flashcard.deck_id.in_(own)).all()
|
||||||
|
order = {rid: i for i, rid in enumerate(ranked)}
|
||||||
|
rows.sort(key=lambda r: order.get(r.id, len(order)))
|
||||||
|
return [{
|
||||||
|
"kind": "card", "ref": str(row.id), "id": row.id,
|
||||||
|
"title": _clean(row.front, 90),
|
||||||
|
"text": _clean(row.back, 260),
|
||||||
|
"score": 1.0 / (1 + order.get(row.id, 0)),
|
||||||
|
} for row in rows[:PER_KIND]]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_curated_boost(db: Session, sources: list[dict]) -> None:
|
||||||
|
"""Lift a source that an educator tied to another source in this shortlist.
|
||||||
|
|
||||||
|
Two rows an educator already linked, both surfacing for the same question, is
|
||||||
|
a stronger signal than either ranking alone — that is what a curated link is
|
||||||
|
for. Nothing is stored; the boost lives only in this ordering.
|
||||||
|
"""
|
||||||
|
article_ids = {s["id"] for s in sources if s["kind"] in ("article", "section")}
|
||||||
|
question_ids = {s["id"] for s in sources if s["kind"] == "question"}
|
||||||
|
if not article_ids or not question_ids:
|
||||||
|
return
|
||||||
|
pairs = db.query(QuestionArticleLink).filter(
|
||||||
|
QuestionArticleLink.article_id.in_(article_ids),
|
||||||
|
QuestionArticleLink.question_id.in_(question_ids)).all()
|
||||||
|
if not pairs:
|
||||||
|
return
|
||||||
|
linked_articles = {p.article_id for p in pairs}
|
||||||
|
linked_questions = {p.question_id for p in pairs}
|
||||||
|
for source in sources:
|
||||||
|
if source["kind"] in ("article", "section") and source["id"] in linked_articles:
|
||||||
|
source["score"] += CURATED_BOOST
|
||||||
|
source["curated"] = True
|
||||||
|
elif source["kind"] == "question" and source["id"] in linked_questions:
|
||||||
|
source["score"] += CURATED_BOOST
|
||||||
|
source["curated"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve(db: Session, user: User, query: str) -> list[dict]:
|
||||||
|
"""The only things the model will be allowed to cite for this message."""
|
||||||
|
query = (query or "").strip()
|
||||||
|
if len(query) < 2:
|
||||||
|
return []
|
||||||
|
sources: list[dict] = []
|
||||||
|
for finder in (_sections, _articles, _questions, _cards):
|
||||||
|
try:
|
||||||
|
sources.extend(finder(db, user, query))
|
||||||
|
except Exception:
|
||||||
|
# One corpus failing narrows the answer; it does not end it.
|
||||||
|
logger.warning("AI Mode retrieval failed for %s", finder.__name__, exc_info=True)
|
||||||
|
_apply_curated_boost(db, sources)
|
||||||
|
sources.sort(key=lambda s: -s["score"])
|
||||||
|
return sources[:MAX_SOURCES]
|
||||||
|
|
||||||
|
|
||||||
|
def sources_block(sources: list[dict]) -> str:
|
||||||
|
"""The shortlist, as the model sees it."""
|
||||||
|
lines = []
|
||||||
|
for source in sources:
|
||||||
|
marker = f"[[{source['kind']}:{source['ref']}]]"
|
||||||
|
curated = " (an educator linked this to another source here)" if source.get("curated") else ""
|
||||||
|
lines.append(f"{marker} {source['title']}{curated}\n{source['text']}")
|
||||||
|
return "\n\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(sources: list[dict]) -> str:
|
||||||
|
if not sources:
|
||||||
|
return (
|
||||||
|
"You are a study assistant for a pediatrics learning platform.\n"
|
||||||
|
"Nothing in this learner's library matches their question. Say so plainly "
|
||||||
|
"in one or two sentences and suggest what they might search for instead. "
|
||||||
|
"Do not answer from your own knowledge, and do not cite anything."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"You are a study assistant for a pediatrics learning platform.\n\n"
|
||||||
|
"Answer only from the sources below. They are the learner's own library — "
|
||||||
|
"if they do not contain the answer, say so rather than filling the gap from "
|
||||||
|
"your own knowledge, which the learner cannot check against anything.\n\n"
|
||||||
|
"Cite with the exact marker shown, for example [[article:7]] or "
|
||||||
|
"[[section:7#abc123]], placed at the end of the sentence it supports. Never "
|
||||||
|
"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"
|
||||||
|
"Be brief: a few sentences or a short list.\n\n"
|
||||||
|
f"SOURCES\n\n{sources_block(sources)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_citations(reply: str, sources: list[dict]) -> tuple[str, list[dict]]:
|
||||||
|
"""Drop every citation that was not retrieved, and report the ones that stand.
|
||||||
|
|
||||||
|
This is the step that makes a hallucinated citation impossible rather than
|
||||||
|
unlikely. The model can write whatever marker it likes; only markers in the
|
||||||
|
shortlist survive contact with this function.
|
||||||
|
"""
|
||||||
|
allowed = {f"{s['kind']}:{s['ref']}": s for s in sources}
|
||||||
|
used: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def replace(match: re.Match) -> str:
|
||||||
|
key = f"{match.group(1)}:{match.group(2)}"
|
||||||
|
source = allowed.get(key)
|
||||||
|
if source is None:
|
||||||
|
return "" # Invented, or pointing at something this learner may not see.
|
||||||
|
used[key] = source
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
cleaned = CITATION_RE.sub(replace, reply)
|
||||||
|
# Deleting a marker can leave a double space or a space before a full stop.
|
||||||
|
cleaned = re.sub(r"[ \t]{2,}", " ", cleaned)
|
||||||
|
cleaned = re.sub(r"\s+([.,;:!?])", r"\1", cleaned).strip()
|
||||||
|
|
||||||
|
citations = [{
|
||||||
|
"marker": f"[[{s['kind']}:{s['ref']}]]",
|
||||||
|
"kind": s["kind"], "id": s["id"],
|
||||||
|
"section_id": s.get("section_id"),
|
||||||
|
"title": s["title"],
|
||||||
|
"curated": bool(s.get("curated")),
|
||||||
|
} for s in used.values()]
|
||||||
|
return cleaned, citations
|
||||||
201
backend/tests/test_ai_mode.py
Normal file
201
backend/tests/test_ai_mode.py
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
"""AI Mode: retrieval, the citation contract, and thread ownership.
|
||||||
|
|
||||||
|
Disposable SQLite; the model itself is stubbed, because what is worth testing
|
||||||
|
here is not what a model says but what the server does with it. The safety
|
||||||
|
property — an invented citation cannot survive — has to hold whatever comes back.
|
||||||
|
"""
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import test_quiz_builder as fixtures
|
||||||
|
from app.models.article import Article, ArticleSectionIndex, QuestionArticleLink
|
||||||
|
from app.models.conversation import Conversation, ConversationMessage
|
||||||
|
from app.models.flashcard import Flashcard, FlashcardDeck
|
||||||
|
from app.models.question import Question
|
||||||
|
from app.routers import ai_mode
|
||||||
|
from app.services import ai_mode_service
|
||||||
|
|
||||||
|
|
||||||
|
class CitationContractTests(unittest.TestCase):
|
||||||
|
"""The step that makes a hallucinated citation impossible rather than rare."""
|
||||||
|
|
||||||
|
def sources(self):
|
||||||
|
return [
|
||||||
|
{"kind": "article", "ref": "7", "id": 7, "title": "Febrile seizures"},
|
||||||
|
{"kind": "section", "ref": "7#abc", "id": 7, "section_id": "abc", "title": "Workup"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_a_citation_that_was_not_retrieved_is_deleted(self):
|
||||||
|
reply, citations = ai_mode_service.enforce_citations(
|
||||||
|
"Fever first [[article:7]]. Then lumbar puncture [[article:99]].", self.sources())
|
||||||
|
self.assertIn("[[article:7]]", reply)
|
||||||
|
self.assertNotIn("99", reply)
|
||||||
|
self.assertEqual([c["id"] for c in citations], [7])
|
||||||
|
|
||||||
|
def test_deleting_a_marker_does_not_leave_broken_punctuation(self):
|
||||||
|
reply, _ = ai_mode_service.enforce_citations(
|
||||||
|
"This is true [[article:404]].", self.sources())
|
||||||
|
self.assertEqual(reply, "This is true.")
|
||||||
|
|
||||||
|
def test_a_url_the_model_invents_is_not_a_citation(self):
|
||||||
|
# Only the marker form counts, so a plausible-looking link cannot smuggle
|
||||||
|
# itself into the citation list.
|
||||||
|
reply, citations = ai_mode_service.enforce_citations(
|
||||||
|
"See https://uptodate.com/febrile-seizures for more.", self.sources())
|
||||||
|
self.assertEqual(citations, [])
|
||||||
|
self.assertIn("uptodate.com", reply) # left in the prose, cited by nothing
|
||||||
|
|
||||||
|
def test_the_same_source_cited_twice_is_listed_once(self):
|
||||||
|
_, citations = ai_mode_service.enforce_citations(
|
||||||
|
"One [[article:7]]. Two [[article:7]].", self.sources())
|
||||||
|
self.assertEqual(len(citations), 1)
|
||||||
|
|
||||||
|
def test_a_section_citation_keeps_the_section_it_points_at(self):
|
||||||
|
_, citations = ai_mode_service.enforce_citations("Here [[section:7#abc]].", self.sources())
|
||||||
|
self.assertEqual(citations[0]["section_id"], "abc")
|
||||||
|
|
||||||
|
def test_with_no_sources_the_model_is_told_to_say_so(self):
|
||||||
|
prompt = ai_mode_service.build_prompt([])
|
||||||
|
self.assertIn("Do not answer from your own knowledge", prompt)
|
||||||
|
# And nothing it writes can be cited anyway.
|
||||||
|
reply, citations = ai_mode_service.enforce_citations("Anything [[article:1]].", [])
|
||||||
|
self.assertEqual(citations, [])
|
||||||
|
self.assertEqual(reply, "Anything.")
|
||||||
|
|
||||||
|
|
||||||
|
class _AiModeBase(unittest.TestCase):
|
||||||
|
"""Fixtures shared by the route and retrieval cases; holds no tests itself."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.bank = fixtures.BuilderTests()
|
||||||
|
self.bank.setUp()
|
||||||
|
self.client = self.bank.client
|
||||||
|
self.client.app.include_router(ai_mode.router, prefix='/ai')
|
||||||
|
self.db = self.bank.db
|
||||||
|
|
||||||
|
self.db.add(Article(id=7, slug='febrile-seizures', title='Febrile seizures',
|
||||||
|
summary='A seizure with fever in a young child',
|
||||||
|
sections=[{"id": "a" * 32, "slug": "workup", "title": "Workup", "content": "LP"}],
|
||||||
|
status='published', user_id=3))
|
||||||
|
self.db.add(ArticleSectionIndex(id=1, article_id=7, section_id='a' * 32,
|
||||||
|
title='Workup', content='Lumbar puncture in a febrile infant'))
|
||||||
|
self.db.add(FlashcardDeck(id=1, title='Mine', user_id=1))
|
||||||
|
self.db.flush()
|
||||||
|
self.db.add(Flashcard(id=1, deck_id=1, front='Febrile seizure duration', back='Under 15 minutes'))
|
||||||
|
self.db.query(Question).filter(Question.id == 1).update(
|
||||||
|
{"question_text": "A child with a febrile seizure lasting two minutes"})
|
||||||
|
self.db.commit()
|
||||||
|
self.bank.user = self.bank.owner
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.bank.tearDown()
|
||||||
|
|
||||||
|
def reply_with(self, text):
|
||||||
|
"""Stub the model, so the test is about the server's half of the contract."""
|
||||||
|
response = type("R", (), {"choices": [type("C", (), {
|
||||||
|
"message": type("M", (), {"content": text})()})()]})()
|
||||||
|
return patch("litellm.acompletion", new=AsyncMock(return_value=response))
|
||||||
|
|
||||||
|
def ask(self, message, conversation_id=None):
|
||||||
|
if conversation_id is None:
|
||||||
|
conversation_id = self.client.post('/ai/conversations').json()['id']
|
||||||
|
return conversation_id, self.client.post(
|
||||||
|
f'/ai/conversations/{conversation_id}/messages', json={'message': message})
|
||||||
|
|
||||||
|
|
||||||
|
class AiModeRouteTests(_AiModeBase):
|
||||||
|
def test_an_answer_keeps_only_the_citations_retrieval_found(self):
|
||||||
|
with self.reply_with("Yes [[article:7]], and also [[article:12345]]."):
|
||||||
|
_, response = self.ask('febrile seizure')
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
body = response.json()['message']
|
||||||
|
self.assertNotIn('12345', body['content'])
|
||||||
|
self.assertEqual([c['id'] for c in body['citations']], [7])
|
||||||
|
|
||||||
|
def test_the_first_question_names_the_thread(self):
|
||||||
|
with self.reply_with("An answer."):
|
||||||
|
conversation_id, response = self.ask('What causes febrile seizures?')
|
||||||
|
self.assertEqual(response.json()['title'], 'What causes febrile seizures?')
|
||||||
|
# And a later message does not rename it out from under the learner.
|
||||||
|
with self.reply_with("Another answer."):
|
||||||
|
_, second = self.ask('And the workup?', conversation_id)
|
||||||
|
self.assertEqual(second.json()['title'], 'What causes febrile seizures?')
|
||||||
|
|
||||||
|
def test_both_turns_are_stored_so_a_thread_can_be_reopened(self):
|
||||||
|
with self.reply_with("Because of fever [[article:7]]."):
|
||||||
|
conversation_id, _ = self.ask('why do febrile seizures happen?')
|
||||||
|
messages = self.client.get(f'/ai/conversations/{conversation_id}').json()['messages']
|
||||||
|
self.assertEqual([m['role'] for m in messages], ['user', 'assistant'])
|
||||||
|
# The citations are stored as they were filtered, not recomputed later.
|
||||||
|
self.assertEqual(messages[1]['citations'][0]['id'], 7)
|
||||||
|
self.assertEqual(messages[0]['citations'], [])
|
||||||
|
|
||||||
|
def test_a_thread_belongs_to_one_learner(self):
|
||||||
|
with self.reply_with("An answer."):
|
||||||
|
conversation_id, _ = self.ask('febrile seizure')
|
||||||
|
self.bank.user = self.bank.peer
|
||||||
|
# Not 403: whether somebody else's thread exists is not this user's business.
|
||||||
|
self.assertEqual(self.client.get(f'/ai/conversations/{conversation_id}').status_code, 404)
|
||||||
|
self.assertEqual(self.client.delete(f'/ai/conversations/{conversation_id}').status_code, 404)
|
||||||
|
self.assertEqual(self.client.post(
|
||||||
|
f'/ai/conversations/{conversation_id}/messages', json={'message': 'hello'}).status_code, 404)
|
||||||
|
self.assertEqual(self.client.get('/ai/conversations').json(), [])
|
||||||
|
|
||||||
|
def test_a_model_failure_is_reported_not_stored(self):
|
||||||
|
conversation_id = self.client.post('/ai/conversations').json()['id']
|
||||||
|
with patch("litellm.acompletion", new=AsyncMock(side_effect=RuntimeError("down"))):
|
||||||
|
response = self.client.post(f'/ai/conversations/{conversation_id}/messages',
|
||||||
|
json={'message': 'febrile seizure'})
|
||||||
|
self.assertEqual(response.status_code, 502)
|
||||||
|
# A half-written exchange is worse than none: the question is not kept.
|
||||||
|
self.assertEqual(self.db.query(ConversationMessage).count(), 0)
|
||||||
|
|
||||||
|
def test_deleting_a_thread_takes_its_messages(self):
|
||||||
|
with self.reply_with("An answer."):
|
||||||
|
conversation_id, _ = self.ask('febrile seizure')
|
||||||
|
self.assertEqual(self.client.delete(f'/ai/conversations/{conversation_id}').status_code, 204)
|
||||||
|
self.assertEqual(self.db.query(Conversation).count(), 0)
|
||||||
|
self.assertEqual(self.db.query(ConversationMessage).count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class RetrievalTests(_AiModeBase):
|
||||||
|
def test_retrieval_offers_only_what_this_learner_may_see(self):
|
||||||
|
self.db.add(Article(id=8, slug='draft-febrile', title='Febrile draft',
|
||||||
|
summary='Unpublished febrile notes', sections=[],
|
||||||
|
status='draft', user_id=3))
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
self.bank.user = self.bank.owner
|
||||||
|
found = ai_mode_service.retrieve(self.db, self.bank.owner, 'febrile')
|
||||||
|
self.assertNotIn(8, [s['id'] for s in found if s['kind'] == 'article'])
|
||||||
|
# Question 3 is another user's private question.
|
||||||
|
self.assertNotIn(3, [s['id'] for s in found if s['kind'] == 'question'])
|
||||||
|
|
||||||
|
self.bank.user = self.bank.mod
|
||||||
|
found = ai_mode_service.retrieve(self.db, self.bank.mod, 'febrile')
|
||||||
|
self.assertIn(8, [s['id'] for s in found if s['kind'] == 'article'])
|
||||||
|
|
||||||
|
def test_a_question_source_carries_the_stem_and_not_the_answer(self):
|
||||||
|
found = ai_mode_service.retrieve(self.db, self.bank.owner, 'febrile seizure')
|
||||||
|
questions = [s for s in found if s['kind'] == 'question']
|
||||||
|
self.assertTrue(questions)
|
||||||
|
for source in questions:
|
||||||
|
self.assertNotIn('Full explanation', source['text'])
|
||||||
|
self.assertNotIn('yes', source['text'].split())
|
||||||
|
|
||||||
|
def test_a_curated_link_between_two_hits_lifts_both(self):
|
||||||
|
self.db.add(QuestionArticleLink(question_id=1, article_id=7, section_id=None))
|
||||||
|
self.db.commit()
|
||||||
|
found = ai_mode_service.retrieve(self.db, self.bank.owner, 'febrile seizure')
|
||||||
|
curated = [s for s in found if s.get('curated')]
|
||||||
|
# An educator tied these two together; both surfacing for one query is
|
||||||
|
# evidence rather than coincidence.
|
||||||
|
self.assertIn(('article', 7), [(s['kind'], s['id']) for s in curated])
|
||||||
|
self.assertIn(('question', 1), [(s['kind'], s['id']) for s in curated])
|
||||||
|
|
||||||
|
def test_the_prompt_carries_the_shortlist_and_the_rules(self):
|
||||||
|
found = ai_mode_service.retrieve(self.db, self.bank.owner, 'febrile')
|
||||||
|
prompt = ai_mode_service.build_prompt(found)
|
||||||
|
self.assertIn('[[article:7]]', prompt)
|
||||||
|
self.assertIn('never cite a marker that is not listed here', prompt)
|
||||||
|
self.assertIn('Never reveal the answer to a practice question', prompt)
|
||||||
10
docs/TODO.md
10
docs/TODO.md
|
|
@ -9,9 +9,13 @@ Updated 2026-09-10.
|
||||||
|
|
||||||
## Design agreed, not built
|
## Design agreed, not built
|
||||||
|
|
||||||
- [ ] **AI Mode (RAG chat)** — see "AI Mode design" below. Needs: conversation +
|
- [x] **AI Mode (RAG chat)** — done 2026-09-10. `conversations` and
|
||||||
message tables, the retrieval step, the ID-citation contract, and the
|
`conversation_messages` (migration `a5b6c7d8e9f0`), retrieval across
|
||||||
chat UI with a thread rail.
|
sections, articles, questions and cards with the curated-link boost, the
|
||||||
|
ID-citation contract enforced server-side, and `/ai` with a thread rail.
|
||||||
|
Answers cite by number and every source opens; a section citation
|
||||||
|
deep-links into its section. Question sources carry the stem only, so a
|
||||||
|
chat cannot hand away the practice it is meant to prepare for.
|
||||||
- [x] **Global search page** — done 2026-09-10. `GET /search` runs every corpus
|
- [x] **Global search page** — done 2026-09-10. `GET /search` runs every corpus
|
||||||
at once and `/search` groups the answer by kind, with section hits listed
|
at once and `/search` groups the answer by kind, with section hits listed
|
||||||
under the article they belong to and linked to that section. A header box
|
under the article they belong to and linked to that section. A header box
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const LandingPage = lazy(() => import('./pages/LandingPage'))
|
||||||
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
|
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
|
||||||
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
|
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
|
||||||
const SearchPage = lazy(() => import('./pages/SearchPage'))
|
const SearchPage = lazy(() => import('./pages/SearchPage'))
|
||||||
|
const AiModePage = lazy(() => import('./pages/AiModePage'))
|
||||||
const MediaPage = lazy(() => import('./pages/MediaPage'))
|
const MediaPage = lazy(() => import('./pages/MediaPage'))
|
||||||
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
|
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
|
||||||
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
|
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
|
||||||
|
|
@ -104,6 +105,7 @@ function AppRoutes() {
|
||||||
<Route path="/questions/manage" element={<QuestionManagerPage />} />
|
<Route path="/questions/manage" element={<QuestionManagerPage />} />
|
||||||
<Route path="/flashcards" element={<FlashcardsPage />} />
|
<Route path="/flashcards" element={<FlashcardsPage />} />
|
||||||
<Route path="/search" element={<SearchPage />} />
|
<Route path="/search" element={<SearchPage />} />
|
||||||
|
<Route path="/ai" element={<AiModePage />} />
|
||||||
<Route path="/media" element={<MediaPage />} />
|
<Route path="/media" element={<MediaPage />} />
|
||||||
<Route path="/study-plans" element={<StudyPlansPage />} />
|
<Route path="/study-plans" element={<StudyPlansPage />} />
|
||||||
<Route path="/study-plans/:id" element={<StudyPlanPage />} />
|
<Route path="/study-plans/:id" element={<StudyPlanPage />} />
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ export default function Navbar({ onSignIn, onRegister }) {
|
||||||
const navLinks = user ? [
|
const navLinks = user ? [
|
||||||
{ to: '/home', label: 'Home' },
|
{ to: '/home', label: 'Home' },
|
||||||
{ to: '/', label: 'Dashboard' },
|
{ to: '/', label: 'Dashboard' },
|
||||||
|
{ to: '/ai', label: 'AI Mode' },
|
||||||
{ to: '/quizzes', label: 'Quizzes' },
|
{ to: '/quizzes', label: 'Quizzes' },
|
||||||
{ to: '/analysis', label: 'Analysis' },
|
{ to: '/analysis', label: 'Analysis' },
|
||||||
{ to: '/question-bank', label: 'Question Bank' },
|
{ to: '/question-bank', label: 'Question Bank' },
|
||||||
|
|
|
||||||
90
frontend/src/pages/AiModePage.css
Normal file
90
frontend/src/pages/AiModePage.css
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/* AI Mode: a rail of threads beside the conversation. */
|
||||||
|
|
||||||
|
.ai-page { display: grid; grid-template-columns: 230px 1fr; gap: 18px; align-items: start; max-width: 1060px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.ai-rail {
|
||||||
|
position: sticky; top: 76px; max-height: calc(100vh - 100px); overflow-y: auto;
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 12px;
|
||||||
|
}
|
||||||
|
.ai-new { width: 100%; margin-bottom: 10px; }
|
||||||
|
.ai-rail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.ai-rail li { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.ai-thread {
|
||||||
|
flex: 1; min-width: 0; min-height: 38px; padding: 8px 10px;
|
||||||
|
background: none; border: 0; border-radius: 7px; cursor: pointer;
|
||||||
|
font: inherit; font-size: 0.84rem; color: var(--text); text-align: left;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ai-thread:hover { background: var(--bg); }
|
||||||
|
.ai-thread.is-active { background: var(--option-sel-bg); color: var(--primary); font-weight: 650; }
|
||||||
|
.ai-thread-delete {
|
||||||
|
flex-shrink: 0; background: none; border: 0; cursor: pointer; padding: 6px;
|
||||||
|
color: var(--text-subtle); font-size: 0.8rem; opacity: 0;
|
||||||
|
}
|
||||||
|
.ai-rail li:hover .ai-thread-delete, .ai-thread-delete:focus-visible { opacity: 1; }
|
||||||
|
.ai-rail-empty { font-size: 0.83rem; color: var(--text-muted); margin: 8px 2px; }
|
||||||
|
.ai-rail-toggle { display: none; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.ai-main { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.ai-intro { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 28px; }
|
||||||
|
.ai-intro h1 { margin: 0 0 8px; font-size: 1.3rem; }
|
||||||
|
.ai-intro p { margin: 0 0 10px; color: var(--text-muted); font-size: 0.92rem; line-height: 1.65; max-width: 60ch; }
|
||||||
|
.ai-intro-note { font-size: 0.86rem !important; color: var(--text-subtle) !important; }
|
||||||
|
|
||||||
|
.ai-thread-view { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.ai-msg { max-width: 100%; }
|
||||||
|
.ai-msg.is-user {
|
||||||
|
align-self: flex-end; max-width: 78%;
|
||||||
|
background: var(--option-sel-bg); border-radius: 12px 12px 2px 12px; padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.ai-msg.is-user p { margin: 0; font-size: 0.92rem; line-height: 1.55; overflow-wrap: anywhere; }
|
||||||
|
.ai-msg.is-assistant {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px 12px 12px 2px; padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.ai-answer { font-size: 0.93rem; line-height: 1.68; }
|
||||||
|
.ai-answer > :first-child { margin-top: 0; }
|
||||||
|
.ai-answer > :last-child { margin-bottom: 0; }
|
||||||
|
.ai-thinking { margin: 0; color: var(--text-muted); font-size: 0.88rem; }
|
||||||
|
|
||||||
|
/* Sources are the point, not a footnote: a claim you can open is different from
|
||||||
|
one you have to believe. */
|
||||||
|
.ai-sources {
|
||||||
|
list-style: none; margin: 14px 0 0; padding: 12px 0 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.ai-sources li { display: flex; align-items: baseline; gap: 8px; font-size: 0.84rem; }
|
||||||
|
.ai-source-num {
|
||||||
|
flex-shrink: 0; min-width: 20px; height: 20px; border-radius: 10px;
|
||||||
|
background: var(--option-sel-bg); color: var(--primary);
|
||||||
|
font-size: 0.7rem; font-weight: 700; display: inline-flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.ai-sources a { color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
|
||||||
|
.ai-sources a:hover { color: var(--primary); text-decoration: underline; }
|
||||||
|
.ai-curated {
|
||||||
|
font-size: 0.63rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
|
||||||
|
padding: 1px 7px; border-radius: 10px; background: var(--correct-bg); color: var(--correct-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 0; }
|
||||||
|
|
||||||
|
.ai-composer { display: flex; gap: 8px; align-items: flex-end; position: sticky; bottom: 0; padding-bottom: 12px; background: var(--bg); }
|
||||||
|
.ai-composer textarea {
|
||||||
|
flex: 1; min-width: 0; resize: vertical; padding: 11px 14px;
|
||||||
|
border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
background: var(--input-bg); color: var(--text); font: inherit; font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.ai-composer textarea:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: var(--primary); }
|
||||||
|
.ai-composer .btn { min-height: 44px; }
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.ai-page { grid-template-columns: 1fr; }
|
||||||
|
.ai-rail { position: static; display: none; max-height: none; }
|
||||||
|
.ai-rail.is-open { display: block; }
|
||||||
|
.ai-rail-toggle { display: inline-block; }
|
||||||
|
.ai-msg.is-user { max-width: 88%; }
|
||||||
|
}
|
||||||
219
frontend/src/pages/AiModePage.jsx
Normal file
219
frontend/src/pages/AiModePage.jsx
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import api from '../api/client'
|
||||||
|
import './AiModePage.css'
|
||||||
|
|
||||||
|
const apiError = (err, fallback) => {
|
||||||
|
const detail = err?.response?.data?.detail
|
||||||
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const CITATION = /[ \t]*\[\[(article|section|question|card):([A-Za-z0-9#_-]+)\]\]/g
|
||||||
|
|
||||||
|
/** Where a citation points. Sections deep-link into the article they belong to. */
|
||||||
|
export const citationHref = (citation) => {
|
||||||
|
if (citation.kind === 'section') return `/articles/${citation.id}?section=${citation.section_id}`
|
||||||
|
if (citation.kind === 'article') return `/articles/${citation.id}`
|
||||||
|
if (citation.kind === 'question') return `/questions/${citation.id}`
|
||||||
|
return '/flashcards'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the markers left in the prose into numbered links.
|
||||||
|
*
|
||||||
|
* The server has already deleted anything the model invented, so every marker
|
||||||
|
* that reaches here has a source behind it. Numbering rather than inlining the
|
||||||
|
* title keeps a sentence readable when it rests on three sources.
|
||||||
|
*/
|
||||||
|
function Answer({ content, citations }) {
|
||||||
|
const index = new Map(citations.map((c, i) => [c.marker, i + 1]))
|
||||||
|
// The match swallows the space before the marker, so the number replaces it
|
||||||
|
// rather than following it and leaving a double gap.
|
||||||
|
const numbered = content.replace(CITATION, (_match, kind, ref) => {
|
||||||
|
const number = index.get(`[[${kind}:${ref}]]`)
|
||||||
|
return number ? ` [${number}]` : ''
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div className="ai-answer">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{numbered}</ReactMarkdown>
|
||||||
|
{citations.length > 0 && (
|
||||||
|
<ol className="ai-sources">
|
||||||
|
{citations.map((citation, i) => (
|
||||||
|
<li key={citation.marker}>
|
||||||
|
<span className="ai-source-num">{i + 1}</span>
|
||||||
|
<Link to={citationHref(citation)}>{citation.title}</Link>
|
||||||
|
{/* An educator tied this source to another one in the answer;
|
||||||
|
that is a stronger claim than retrieval ranking it highly. */}
|
||||||
|
{citation.curated && <span className="ai-curated" title="Linked by an educator">linked</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chat that can only answer from this learner's own library.
|
||||||
|
*
|
||||||
|
* The rail of threads is the same shape as every other chat because that is
|
||||||
|
* what people already know; what is different is underneath. Retrieval decides
|
||||||
|
* what the model may cite, the server deletes anything else, and the answer
|
||||||
|
* carries its sources so a claim can be checked rather than believed.
|
||||||
|
*/
|
||||||
|
export default function AiModePage() {
|
||||||
|
const [threads, setThreads] = useState([])
|
||||||
|
const [activeId, setActiveId] = useState(null)
|
||||||
|
const [messages, setMessages] = useState([])
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [railOpen, setRailOpen] = useState(false)
|
||||||
|
const endRef = useRef(null)
|
||||||
|
|
||||||
|
const loadThreads = useCallback(() => api.get('/ai/conversations')
|
||||||
|
.then(res => { setThreads(res.data || []); return res.data || [] })
|
||||||
|
.catch(() => []), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadThreads().then(rows => {
|
||||||
|
if (rows.length) setActiveId(rows[0].id)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [loadThreads])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeId == null) { setMessages([]); return }
|
||||||
|
api.get(`/ai/conversations/${activeId}`)
|
||||||
|
.then(res => setMessages(res.data.messages || []))
|
||||||
|
.catch(() => setError('Could not open that conversation'))
|
||||||
|
}, [activeId])
|
||||||
|
|
||||||
|
useEffect(() => { endRef.current?.scrollIntoView?.({ behavior: 'smooth' }) }, [messages, sending])
|
||||||
|
|
||||||
|
const startThread = async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.post('/ai/conversations')
|
||||||
|
setThreads(prev => [{ id: res.data.id, title: res.data.title, message_count: 0 }, ...prev])
|
||||||
|
setActiveId(res.data.id)
|
||||||
|
setMessages([])
|
||||||
|
setRailOpen(false)
|
||||||
|
} catch (err) { setError(apiError(err, 'Could not start a conversation')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeThread = async (id) => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.delete(`/ai/conversations/${id}`)
|
||||||
|
const rest = threads.filter(t => t.id !== id)
|
||||||
|
setThreads(rest)
|
||||||
|
if (activeId === id) setActiveId(rest[0]?.id ?? null)
|
||||||
|
} catch (err) { setError(apiError(err, 'Could not delete that conversation')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async (event) => {
|
||||||
|
event?.preventDefault?.()
|
||||||
|
const text = draft.trim()
|
||||||
|
if (!text || sending) return
|
||||||
|
|
||||||
|
let threadId = activeId
|
||||||
|
setError('')
|
||||||
|
setSending(true)
|
||||||
|
// The question appears immediately; waiting on a round trip to see your own
|
||||||
|
// words makes the whole thing feel broken.
|
||||||
|
setMessages(prev => [...prev, { id: `pending-${Date.now()}`, role: 'user', content: text, citations: [] }])
|
||||||
|
setDraft('')
|
||||||
|
try {
|
||||||
|
if (threadId == null) {
|
||||||
|
const created = await api.post('/ai/conversations')
|
||||||
|
threadId = created.data.id
|
||||||
|
setActiveId(threadId)
|
||||||
|
setThreads(prev => [{ id: threadId, title: 'New chat', message_count: 0 }, ...prev])
|
||||||
|
}
|
||||||
|
const res = await api.post(`/ai/conversations/${threadId}/messages`, { message: text })
|
||||||
|
setMessages(prev => [...prev, res.data.message])
|
||||||
|
setThreads(prev => prev.map(t => t.id === threadId ? { ...t, title: res.data.title } : t))
|
||||||
|
} catch (err) {
|
||||||
|
setError(apiError(err, 'AI Mode is unavailable right now'))
|
||||||
|
setMessages(prev => prev.filter(m => !String(m.id).startsWith('pending-')))
|
||||||
|
setDraft(text) // Handing the question back rather than losing it.
|
||||||
|
} finally { setSending(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ai-page">
|
||||||
|
<button className="ai-rail-toggle" aria-expanded={railOpen}
|
||||||
|
onClick={() => setRailOpen(v => !v)}>
|
||||||
|
{railOpen ? '✕ Close chats' : '☰ Chats'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<aside className={`ai-rail${railOpen ? ' is-open' : ''}`}>
|
||||||
|
<button className="btn btn-primary btn-sm ai-new" onClick={startThread}>New chat</button>
|
||||||
|
<ul>
|
||||||
|
{threads.map(thread => (
|
||||||
|
<li key={thread.id}>
|
||||||
|
<button className={`ai-thread${thread.id === activeId ? ' is-active' : ''}`}
|
||||||
|
onClick={() => { setActiveId(thread.id); setRailOpen(false) }}>
|
||||||
|
{thread.title}
|
||||||
|
</button>
|
||||||
|
<button className="ai-thread-delete" aria-label={`Delete ${thread.title}`}
|
||||||
|
onClick={() => removeThread(thread.id)}>✕</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{!loading && threads.length === 0 && <p className="ai-rail-empty">No chats yet.</p>}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="ai-main">
|
||||||
|
{messages.length === 0 && !sending ? (
|
||||||
|
<div className="ai-intro">
|
||||||
|
<h1>AI Mode</h1>
|
||||||
|
<p>
|
||||||
|
Ask about anything in your library. Answers come from your own reading,
|
||||||
|
questions and cards — and every claim carries the source it came from,
|
||||||
|
so you can check it rather than take its word.
|
||||||
|
</p>
|
||||||
|
<p className="ai-intro-note">
|
||||||
|
If your library does not cover something, it says so instead of
|
||||||
|
filling the gap with something you cannot verify.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ai-thread-view">
|
||||||
|
{messages.map(message => (
|
||||||
|
<div key={message.id} className={`ai-msg is-${message.role}`}>
|
||||||
|
{message.role === 'user'
|
||||||
|
? <p>{message.content}</p>
|
||||||
|
: <Answer content={message.content} citations={message.citations || []} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sending && (
|
||||||
|
<div className="ai-msg is-assistant">
|
||||||
|
<p className="ai-thinking" role="status">Searching your library…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="ai-error" role="alert">{error}</p>}
|
||||||
|
|
||||||
|
<form className="ai-composer" onSubmit={send}>
|
||||||
|
<textarea value={draft} rows={2} aria-label="Ask AI Mode"
|
||||||
|
placeholder="Ask about anything in your library…"
|
||||||
|
onChange={e => setDraft(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) send(e) }} />
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={sending || !draft.trim()}>
|
||||||
|
{sending ? 'Asking…' : 'Ask'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
frontend/src/pages/AiModePage.test.jsx
Normal file
120
frontend/src/pages/AiModePage.test.jsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import AiModePage, { citationHref } from './AiModePage'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
|
||||||
|
|
||||||
|
const threads = [{ id: 1, title: 'Febrile seizures', message_count: 2, updated_at: null }]
|
||||||
|
const answer = {
|
||||||
|
id: 22, role: 'assistant',
|
||||||
|
content: 'Fever first [[article:7]]. Then a workup [[section:7#abc]].',
|
||||||
|
citations: [
|
||||||
|
{ marker: '[[article:7]]', kind: 'article', id: 7, section_id: null, title: 'Febrile seizures', curated: true },
|
||||||
|
{ marker: '[[section:7#abc]]', kind: 'section', id: 7, section_id: 'abc', title: 'Febrile seizures › Workup', curated: false },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockApi = (rows = threads, messages = []) => api.get.mockImplementation(url => {
|
||||||
|
if (url === '/ai/conversations') return Promise.resolve({ data: rows })
|
||||||
|
if (url.startsWith('/ai/conversations/')) return Promise.resolve({ data: { id: 1, title: 'Febrile seizures', messages } })
|
||||||
|
return Promise.resolve({ data: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const mount = () => render(<MemoryRouter><AiModePage /></MemoryRouter>)
|
||||||
|
|
||||||
|
describe('AI Mode', () => {
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); mockApi() })
|
||||||
|
|
||||||
|
it('turns markers into numbered links you can open', async () => {
|
||||||
|
mockApi(threads, [answer])
|
||||||
|
mount()
|
||||||
|
const message = (await screen.findByText(/Fever first/)).closest('.ai-msg')
|
||||||
|
|
||||||
|
// The marker itself never reaches the reader.
|
||||||
|
expect(message.textContent).not.toContain('[[article:7]]')
|
||||||
|
expect(message.textContent).toContain('Fever first [1]')
|
||||||
|
|
||||||
|
const sources = within(message).getAllByRole('link')
|
||||||
|
expect(sources[0]).toHaveAttribute('href', '/articles/7')
|
||||||
|
// A section citation deep-links into the section it came from.
|
||||||
|
expect(sources[1]).toHaveAttribute('href', '/articles/7?section=abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says which sources an educator linked, since that is a stronger claim', async () => {
|
||||||
|
mockApi(threads, [answer])
|
||||||
|
mount()
|
||||||
|
await screen.findByText(/Fever first/)
|
||||||
|
const sources = document.querySelector('.ai-sources')
|
||||||
|
const curated = within(sources).getByText('Febrile seizures').closest('li')
|
||||||
|
expect(within(curated).getByText('linked')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the question straight away rather than after a round trip', async () => {
|
||||||
|
mount()
|
||||||
|
await screen.findByRole('button', { name: 'Febrile seizures' })
|
||||||
|
let resolve
|
||||||
|
api.post.mockReturnValue(new Promise(r => { resolve = r }))
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Ask AI Mode'), 'what is the workup?')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Ask' }))
|
||||||
|
expect(screen.getByText('what is the workup?')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Searching your library')
|
||||||
|
|
||||||
|
resolve({ data: { message: answer, title: 'Febrile seizures' } })
|
||||||
|
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hands the question back when the answer fails, rather than losing it', async () => {
|
||||||
|
mount()
|
||||||
|
await screen.findByRole('button', { name: 'Febrile seizures' })
|
||||||
|
api.post.mockRejectedValue({ response: { data: { detail: "You've reached today's AI Mode limit of 60 messages." } } })
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Ask AI Mode'), 'one more question')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Ask' }))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent("today's AI Mode limit")
|
||||||
|
// Typed words are not something to throw away on a failure.
|
||||||
|
expect(screen.getByLabelText('Ask AI Mode')).toHaveValue('one more question')
|
||||||
|
expect(screen.queryByText('one more question', { selector: 'p' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a thread on the first question when none is open', async () => {
|
||||||
|
mockApi([])
|
||||||
|
mount()
|
||||||
|
await screen.findByRole('heading', { name: 'AI Mode' })
|
||||||
|
api.post.mockImplementation(url => url === '/ai/conversations'
|
||||||
|
? Promise.resolve({ data: { id: 5, title: 'New chat' } })
|
||||||
|
: Promise.resolve({ data: { message: answer, title: 'What is jaundice?' } }))
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Ask AI Mode'), 'What is jaundice?')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Ask' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/ai/conversations/5/messages',
|
||||||
|
{ message: 'What is jaundice?' }))
|
||||||
|
// The thread takes its name from the question, not from "New chat".
|
||||||
|
expect(await screen.findByRole('button', { name: 'What is jaundice?' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is honest about what it will not do before you ask', async () => {
|
||||||
|
mockApi([])
|
||||||
|
mount()
|
||||||
|
expect(await screen.findByText(/says so instead of filling the gap/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a thread and moves off it', async () => {
|
||||||
|
mount()
|
||||||
|
await screen.findByRole('button', { name: 'Febrile seizures' })
|
||||||
|
api.delete.mockResolvedValue({})
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Delete Febrile seizures' }))
|
||||||
|
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/ai/conversations/1'))
|
||||||
|
expect(screen.getByText('No chats yet.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends a card citation to the deck list, which is where a card lives', () => {
|
||||||
|
expect(citationHref({ kind: 'card', id: 3 })).toBe('/flashcards')
|
||||||
|
expect(citationHref({ kind: 'question', id: 9 })).toBe('/questions/9')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue