fix: refine uses existing article content and validate AI section IDs

AI refine now sends the current body and sections to the model; invalid model section IDs are replaced with valid hex IDs. Job polling list raised to 200. 50 backend tests pass.
This commit is contained in:
Daniel 2026-09-07 17:44:15 +02:00
parent 0fa8d0a689
commit d3663fd5fc
3 changed files with 32 additions and 5 deletions

View file

@ -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"

View file

@ -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", "")),

View file

@ -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()