The account settings offered "Change password", and there is no password to change: accounts live at PedsHub SSO, which is also where a passkey, an address or a second factor is set. A form that writes a credential nothing checks is worse than no form — it invites somebody to secure their account by a route that does not secure it. The panel now keeps the display name and points at the provider for the rest. Retired with it: GET /auth/verify-email, POST /auth/resend-verification, POST /auth/forgot-password, POST /auth/reset-password, the new_password branch of PUT /auth/me, the two schemas behind them, and the three pages — VerifyEmailPage, ForgotPasswordPage, ResetPasswordPage — with their routes and the links into them. An account with no password cannot forget one. POST /auth/login stays, still refused while sso_only is set. It is the way back in if the provider is ever unreachable, together with the DEFAULT_ADMIN_EMAIL seed at startup, and removing it would leave no door at all on a bad day. The test that walked five password doors now walks that one and asserts the other four answer 404 rather than 403 — gone, not guarded. Verified live: all four endpoints 404, and the account panel shows a name field and "Manage your account ↗" to sso.pedshub.com/if/user/#/settings, with no password field anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
250 lines
13 KiB
Python
250 lines
13 KiB
Python
"""AI authoring endpoints on disposable SQLite; no network, no model calls."""
|
|
import json
|
|
import re
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
import test_quiz_builder as fixtures
|
|
from app.models.article import Article
|
|
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
|
|
from app.models.question import Question
|
|
from app.routers import articles
|
|
from app.tasks.quiz_tasks import generate_article_draft, generate_article_cards
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
DRAFT_RESPONSE = {
|
|
"title": "AI draft topic", "slug": "ai-draft-topic", "summary": "Draft summary",
|
|
"content": "Draft intro", "sections": [
|
|
{"id": "a" * 32, "slug": "first", "title": "First", "content": "Body"},
|
|
{"id": "b" * 32, "slug": "second", "title": "Second", "content": "More"},
|
|
],
|
|
}
|
|
|
|
|
|
class ArticleAiTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.bank = fixtures.BuilderTests()
|
|
self.bank.setUp()
|
|
self.client = self.bank.client
|
|
self.client.app.include_router(articles.router, prefix='/articles')
|
|
self.redis = Mock()
|
|
self.redis.from_url.return_value = self.redis
|
|
self.redis.get.return_value = None
|
|
self.redis.lrange.return_value = []
|
|
self.redis.lpush.return_value = 1
|
|
self.redis.expire.return_value = True
|
|
self.redis.incr.side_effect = [1, 21]
|
|
self.delay = patch.dict(sys.modules, {'redis': self.redis})
|
|
self.delay.start()
|
|
self.queue = patch('app.tasks.quiz_tasks.generate_article_draft.delay').start()
|
|
self.card_queue = patch('app.tasks.quiz_tasks.generate_article_cards.delay').start()
|
|
|
|
def tearDown(self):
|
|
patch.stopall()
|
|
self.bank.tearDown()
|
|
|
|
def publish(self, article_id):
|
|
return self.client.post(f'/articles/{article_id}/publish', json={'published': True})
|
|
|
|
def test_ai_endpoints_queue_and_poll(self):
|
|
self.bank.user = self.bank.mod
|
|
response = self.client.post('/articles/ai-draft', json={'topic': 'Neonatal jaundice', 'instructions': 'Two sections'})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
job_id = response.json()['job_id']
|
|
self.queue.assert_called_once()
|
|
self.redis.get.return_value = 'completed'
|
|
poll = self.client.get(f'/articles/job/{job_id}').json()
|
|
self.assertEqual(poll['status'], 'completed')
|
|
self.bank.user = self.bank.owner
|
|
self.assertEqual(self.client.get(f'/articles/job/{job_id}').status_code, 404)
|
|
self.bank.user = self.bank.mod
|
|
article = self.client.post('/articles/', json={
|
|
"title": "Refine me", "slug": "refine-me", "content": "Intro", "sections": []}).json()
|
|
self.assertEqual(self.client.post(f"/articles/{article['id']}/ai-refine", json={'instructions': 'Shorten'}).status_code, 200)
|
|
self.assertEqual(self.client.post(f"/articles/{article['id']}/ai-cards").status_code, 200)
|
|
self.card_queue.assert_called_once()
|
|
self.bank.user = self.bank.owner
|
|
self.assertEqual(self.client.post('/articles/ai-draft', json={'topic': 'Learner'}).status_code, 403)
|
|
self.assertEqual(self.client.post(f"/articles/{article['id']}/ai-refine", json={'instructions': 'x'}).status_code, 403)
|
|
|
|
def test_draft_task_creates_and_refines_without_publishing(self):
|
|
self.redis.set.return_value = True
|
|
ai = patch('app.services.ai_service.chat').start()
|
|
ai.return_value = json.dumps(DRAFT_RESPONSE)
|
|
with patch('app.tasks.quiz_tasks.SessionLocal', sessionmaker(bind=self.bank.engine)), \
|
|
patch('app.services.ai_service.get_model_for_task', return_value=('synthetic', None)):
|
|
generate_article_draft('job-1', 3, 'Neonatal jaundice', '')
|
|
article = self.bank.db.query(Article).filter_by(slug='ai-draft-topic').one()
|
|
self.assertEqual(article.status, 'draft')
|
|
self.assertEqual([s['id'] for s in article.sections], ['a' * 32, 'b' * 32])
|
|
self.redis.set.assert_any_call('extraction:status:job-1', 'completed', ex=3600)
|
|
# Refine must send the existing body to the model, not just the title.
|
|
article.content = 'Unique draft body to preserve'
|
|
article.sections = [{'id': 'c' * 32, 'slug': 'kept', 'title': 'Kept', 'content': 'Kept body'}]
|
|
self.bank.db.commit()
|
|
generate_article_draft('job-2', 3, 'Neonatal jaundice', 'Shorten', article.id)
|
|
prompt = ai.call_args.kwargs['messages'][0]['content']
|
|
self.assertIn('Unique draft body to preserve', prompt)
|
|
self.assertIn('Kept body', prompt)
|
|
refreshed = self.bank.db.query(Article).filter_by(id=article.id).one()
|
|
self.assertEqual(refreshed.status, 'draft')
|
|
self.assertEqual(refreshed.slug, 'ai-draft-topic')
|
|
# Invalid model section ids are replaced with valid hex ids.
|
|
bad = dict(DRAFT_RESPONSE, sections=[{'id': 'bad-id', 'slug': 'bad', 'title': 'Bad', 'content': ''}])
|
|
ai.return_value = json.dumps(bad)
|
|
generate_article_draft('job-3', 3, 'Neonatal jaundice', 'Again', article.id)
|
|
fixed = self.bank.db.query(Article).filter_by(id=article.id).one()
|
|
self.assertTrue(all(re.fullmatch(r'[0-9a-f]{32}', s['id']) for s in fixed.sections), fixed.sections)
|
|
patch.stopall()
|
|
self.redis.reset_mock()
|
|
|
|
def test_cards_task_builds_private_linked_deck(self):
|
|
self.redis.set.return_value = True
|
|
self.bank.db.add(Article(slug='cards-source', title='Cards source', content='Body',
|
|
sections=[{'id': 'c' * 32, 'slug': 's', 'title': 'S', 'content': 'More'}],
|
|
user_id=3, status='published'))
|
|
self.bank.db.commit()
|
|
article = self.bank.db.query(Article).filter_by(slug='cards-source').one()
|
|
with patch('app.tasks.quiz_tasks.SessionLocal', sessionmaker(bind=self.bank.engine)), \
|
|
patch('app.services.extraction_modes.generate_flashcards',
|
|
return_value=[{'front': 'F1', 'back': 'B1'}, {'front': 'F2', 'back': 'B2', 'page_reference': 3}]), \
|
|
patch('app.services.ai_service.get_model_for_task', return_value=('synthetic', None)):
|
|
generate_article_cards('job-3', 3, article.id)
|
|
deck = self.bank.db.query(FlashcardDeck).filter_by(title='Cards: Cards source').one()
|
|
self.assertEqual(deck.is_shared, 0)
|
|
cards = self.bank.db.query(Flashcard).filter_by(deck_id=deck.id).all()
|
|
self.assertEqual(len(cards), 2)
|
|
self.assertEqual(deck.card_count, 2)
|
|
linked = self.bank.db.query(FlashcardArticleLink).filter_by(article_id=article.id).count()
|
|
self.assertEqual(linked, 2)
|
|
self.redis.set.assert_any_call('extraction:status:job-3', 'completed', ex=3600)
|
|
patch.stopall()
|
|
self.redis.reset_mock()
|
|
|
|
def test_pubmed_references_are_fields_the_reader_draws(self):
|
|
"""A paper is stored like a book: fields, not one flat sentence.
|
|
|
|
Everything else on an article is {title, author, pages}, and the reader
|
|
reads those keys — so the line PubMed used to write appeared as a blank
|
|
row under a References heading. A draft with six real papers looked
|
|
like a draft with none.
|
|
"""
|
|
from app.services import pubmed
|
|
records = [{
|
|
'pmid': '37419787', 'title': 'Management of diabetic ketoacidosis.',
|
|
'journal': 'Eur J Intern Med', 'year': '2023',
|
|
'authors': 'Barski L, Golbets E, et al',
|
|
'url': 'https://pubmed.ncbi.nlm.nih.gov/37419787/', 'abstract': '',
|
|
}]
|
|
reference, = pubmed.as_references(records)
|
|
self.assertEqual(reference['title'], 'Management of diabetic ketoacidosis')
|
|
self.assertEqual(reference['pmid'], '37419787')
|
|
self.assertEqual(reference['journal'], 'Eur J Intern Med')
|
|
self.assertEqual(reference['pages'], [])
|
|
|
|
# And a row written before this pulls itself apart on the way out,
|
|
# rather than being rewritten in the database.
|
|
healed = articles._reference_json(
|
|
'Barski L, et al. Management of diabetic ketoacidosis.. '
|
|
'Eur J Intern Med. 2023. PMID: 37419787.')
|
|
self.assertEqual(healed['pmid'], '37419787')
|
|
self.assertNotIn('PMID', healed['title'])
|
|
self.assertEqual(articles._reference_json({'title': 'A book'}), {'title': 'A book'})
|
|
|
|
def test_reading_shows_nobody_drafts(self):
|
|
"""Reading is the published library, Editorial is the unfinished work.
|
|
|
|
An educator saw their own drafts filed among the published articles
|
|
with a tag on them, which made Reading and Editorial two views of one
|
|
list. Not even an admin sees a draft here.
|
|
"""
|
|
self.bank.db.add(Article(slug='half-written', title='Half written',
|
|
content='Body', sections=[], user_id=3, status='draft'))
|
|
self.bank.db.add(Article(slug='finished', title='Finished',
|
|
content='Body', sections=[], user_id=3, status='published'))
|
|
self.bank.db.commit()
|
|
listed = self.client.get('/articles/').json()
|
|
slugs = [a['slug'] for a in listed]
|
|
self.assertIn('finished', slugs)
|
|
self.assertNotIn('half-written', slugs)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|
|
|
|
|
|
class DraftPromptTests(unittest.TestCase):
|
|
"""What the prompt asks for, measured against what 323 drafts produced.
|
|
|
|
The three readings of a topic — the full article, the high-yield revision
|
|
view, the bedside — were described and then largely ignored for the second
|
|
of them: across every AI draft in the bank, the "short" view came out as
|
|
one section of 1,808 characters (against 721 for a long one), and 4 of 326
|
|
short sections carried a highlighted fact. The revision view was the
|
|
longest prose in the article and none of it was marked.
|
|
|
|
So the instruction is a measurement rather than an adjective.
|
|
"""
|
|
|
|
def test_the_short_view_is_asked_for_in_numbers(self):
|
|
from app.tasks.quiz_tasks import ARTICLE_DRAFT_PROMPT
|
|
self.assertIn("2 to 4 SEPARATE sections", ARTICLE_DRAFT_PROMPT)
|
|
self.assertIn("under 600 characters", ARTICLE_DRAFT_PROMPT)
|
|
self.assertIn("at most 20 words", ARTICLE_DRAFT_PROMPT)
|
|
|
|
def test_highlights_are_required_and_shown(self):
|
|
from app.tasks.quiz_tasks import ARTICLE_DRAFT_PROMPT
|
|
self.assertIn("at least one and at most three highlighted facts",
|
|
ARTICLE_DRAFT_PROMPT)
|
|
# An example, because "wrap it in double equals" produced 1.2% uptake.
|
|
self.assertIn("==most common==", ARTICLE_DRAFT_PROMPT)
|
|
|
|
def test_refining_is_told_which_reading_each_section_is(self):
|
|
"""Otherwise a refine re-guesses every variant.
|
|
|
|
The existing draft was handed over as a flat list of `## Heading`, so
|
|
the model could not know which sections were the bedside and which were
|
|
the revision view — and a refine could quietly move one into the other.
|
|
"""
|
|
self.bank = fixtures.BuilderTests()
|
|
self.bank.setUp()
|
|
try:
|
|
from unittest.mock import patch
|
|
from sqlalchemy.orm import sessionmaker
|
|
from app.models.article import Article
|
|
|
|
self.bank.db.add(Article(
|
|
slug='refine-me', title='Refine me', content='Intro', user_id=None,
|
|
status='draft', sections=[
|
|
{'id': 'a' * 32, 'slug': 'bedside', 'title': 'At the bedside',
|
|
'variant': 'clinical', 'content': 'Do this'},
|
|
{'id': 'b' * 32, 'slug': 'high-yield', 'title': 'High yield',
|
|
'variant': 'short', 'content': '- A fact'},
|
|
]))
|
|
self.bank.db.commit()
|
|
article = self.bank.db.query(Article).filter_by(slug='refine-me').one()
|
|
|
|
redis = Mock()
|
|
redis.from_url.return_value = redis
|
|
redis.get.return_value = None
|
|
redis.lrange.return_value = []
|
|
with patch.dict(sys.modules, {'redis': redis}), \
|
|
patch('app.tasks.quiz_tasks.SessionLocal',
|
|
sessionmaker(bind=self.bank.engine)), \
|
|
patch('app.services.ai_service.get_model_for_task',
|
|
return_value=('synthetic', None)), \
|
|
patch('app.services.ai_service.chat') as ai:
|
|
ai.return_value = json.dumps(DRAFT_RESPONSE)
|
|
generate_article_draft('job-refine', 3, 'Refine me', '', article.id)
|
|
prompt = ai.call_args.kwargs['messages'][0]['content']
|
|
# Variant and id both travel: the variant so a bedside section is
|
|
# not rewritten as part of the long read, the id so the links
|
|
# pointing at it survive the refine.
|
|
self.assertIn(f"## [clinical] [id:{'a' * 32}] At the bedside", prompt)
|
|
self.assertIn(f"## [short] [id:{'b' * 32}] High yield", prompt)
|
|
self.assertIn("must be kept", prompt)
|
|
self.assertIn("returned unchanged", prompt)
|
|
finally:
|
|
patch.stopall()
|
|
self.bank.tearDown()
|