pdf-quiz-generator/backend/tests/test_article_ai.py
Daniel dbfdff90a1
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Failing after 25s
Tests / e2e (push) Failing after 30s
fix: Reading is published, Editorial has its own address, and repeat works
Reading shows nobody drafts, not even an admin. An educator's unfinished
work sat among the published shelf with a tag on it, which made Reading
and Editorial two views of one list and left you unsure which you were
looking at. The list is published-only for everybody now, and the tag
and its style are gone with it — the badge stays on an article's own
page, where a draft can still be opened.

And Editorial has its own URL. /editorial/articles/:id renders the same
page, but the crumb reads "Editorial" and goes back to the queue.
Opening an article from the queue used to land on Reading's address, so
the only way out was the top of the published library — you lost your
place in the queue to look at one draft. Drafting from the reading page
lands there too, because a new draft is editorial work from the moment
it exists.

References from PubMed are fields, not a sentence. Every other
reference on an article is {title, author, pages} and the reader reads
those keys, so the flat line the PubMed path wrote drew as six blank
rows under a References heading: the DKA draft cited six real papers
and appeared to cite none. A paper now fills journal, year and PMID
instead of pages, and the PMID is a link to the record. Rows written
before this pull themselves apart on the way out rather than being
rewritten in the database, so the drafts that already exist heal
themselves.

Repeat session has never worked. The dialog asked the bank for mode
"study" — the name of the route it lands on — and the bank has "timed"
and "learning", so every repeat came back 422 and the dialog reported
its own house message, "Could not build that session", because the
detail was a list rather than a string. Both fixed: the right mode, and
a server that says something is quoted rather than swallowed.

And the objective named in "your performance analysis for Pediatrics
Boards" opens the objective picker. It was a link to the account page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 04:16:46 +02:00

174 lines
9.3 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()