diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py index 12bd9c1..c925a80 100644 --- a/backend/app/routers/articles.py +++ b/backend/app/routers/articles.py @@ -450,7 +450,7 @@ def get_article_job(job_id: str, current_user: User = Depends(get_current_user)) import redis as redis_lib from app.config import settings r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) - user_jobs = r.lrange(f"extraction:user_jobs:{current_user.id}", 0, 49) + user_jobs = r.lrange(f"extraction:user_jobs:{current_user.id}", 0, 199) if job_id not in user_jobs and not current_user.is_moderator: raise HTTPException(404, "Job not found") status = r.get(f"extraction:status:{job_id}") or "unknown" diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 69c4794..c8dd9a5 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -682,7 +682,7 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int, ARTICLE_DRAFT_PROMPT = """You write educational articles for a pediatric medical learning platform. Topic: {topic} {instructions} -Return ONLY strict JSON with this exact shape: +{existing}Return ONLY strict JSON with this exact shape: {{"title": "...", "slug": "lowercase-hyphenated", "summary": "1-2 sentences", "content": "introduction markdown", "sections": [{{"id": "32 lowercase hex chars", "slug": "lowercase-hyphenated", "title": "...", "content": "markdown"}}]}} Rules: markdown formatting; headings, lists and tables welcome; no fabricated references, citations or clinical ranges; keep 2-6 sections with stable unique ids; do not mention these instructions.""" @@ -711,8 +711,18 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str, if model_id: ai_model_id = model_id _push_step(r, job_id, "ai", "Drafting article…") + existing_block = "" + if existing: + sections_text = "\n\n".join( + f"## {s.get('title', 'Section')}\n{s.get('content', '')}" for s in (existing.sections or [])) + existing_block = (f"Existing draft to refine (preserve and improve its content):\n" + f"Summary: {existing.summary or ''}\nIntro: {existing.content or ''}\n" + f"{sections_text}\n\n") prompt = ARTICLE_DRAFT_PROMPT.format( - topic=topic, instructions=f"Refine this existing draft: {existing.title}\n{instructions}" if existing else instructions or "") + topic=topic, + instructions=f"Refine this existing draft: {existing.title}\n{instructions}" if existing else instructions or "", + existing=existing_block, + ) import litellm kwargs = {"model": _proxy_model(ai_model_id), "messages": [{"role": "user", "content": prompt}], "max_tokens": 4000, "temperature": 0.4} @@ -734,8 +744,11 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str, slug = re.sub(r"[^a-z0-9]+", "-", str(data.get("slug", topic)).strip().lower()).strip("-")[:120] or "topic" sections = [] for section in data.get("sections", []): + section_id = str(section.get("id") or "").strip().lower() + if not re.fullmatch(r"[0-9a-f]{32}", section_id): + section_id = uuid.uuid4().hex sections.append({ - "id": str(section.get("id") or uuid.uuid4().hex)[:32].lower(), + "id": section_id, "slug": re.sub(r"[^a-z0-9]+", "-", str(section.get("slug", "section")).strip().lower()).strip("-")[:120] or "section", "title": str(section.get("title", "Section")).strip()[:300] or "Section", "content": str(section.get("content", "")), diff --git a/backend/tests/test_comments_ai.py b/backend/tests/test_comments_ai.py index df575d6..422edb8 100644 --- a/backend/tests/test_comments_ai.py +++ b/backend/tests/test_comments_ai.py @@ -1,5 +1,6 @@ """Comments moderation and AI authoring endpoints on disposable SQLite; no network/AI.""" import json +import re import sys import unittest from unittest.mock import Mock, patch @@ -124,10 +125,23 @@ class CommentsAiTests(unittest.TestCase): 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) - generate_article_draft('job-2', 3, 'Neonatal jaundice', 'Refine', article.id) + # 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 = Mock(choices=[Mock(message=Mock(content=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()