pdf-quiz-generator/backend/tests/test_article_ai.py
Daniel deb8ee7830
Some checks failed
Tests / backend (push) Failing after 6s
Tests / frontend (push) Failing after 41s
Tests / e2e (push) Failing after 40s
fix: the high-yield view was the longest prose in the article
Measured across the 323 AI drafts in the bank before touching anything.
The prompt describes three readings of a topic and the second of them
was not happening:

  short     326 sections   1 per article   1,808 chars   4 highlighted
  long    2,112 sections   6.5 per article   721 chars
  clinical 1,060 sections                     531 chars

So the revision view — the thing meant to be tight lists of what a
candidate must know — came out as a single section two and a half times
longer than a full one, written as bullets that were paragraphs, and 4
of 326 carried the ==highlight== the reader has a renderer for. "Tight
lists, not prose" and "sparingly" are adjectives, and the model read
them as suggestions.

They are numbers now: 2 to 4 separate sections, each under 600
characters, bullets of at most 20 words, and one to three highlighted
facts per section with an example of what that looks like.

Two drafts written against the live model afterwards:

  Kawasaki disease        3 short sections, 292 chars avg, 3/3 highlighted
  Neonatal hypoglycaemia  3 short sections, 227 chars avg, 3/3 highlighted

and the reader draws nine key-point marks on the first of them.

Also, refining no longer loses a section's variant. The existing draft
went to the model as a flat list of `## Heading`, so it had to guess all
over again which sections were the bedside and which were the revision
view — a refine could quietly move one into the other. The variant
travels in the heading now and the prompt says to keep it.

Left alone deliberately: the 16,000-token ceiling (no truncation
failure in the logs to justify moving it) and temperature 0.4.

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

246 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']
self.assertIn('## [clinical] At the bedside', prompt)
self.assertIn('## [short] High yield', prompt)
self.assertIn("must be kept", prompt)
finally:
patch.stopall()
self.bank.tearDown()