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:
parent
0fa8d0a689
commit
d3663fd5fc
3 changed files with 32 additions and 5 deletions
|
|
@ -450,7 +450,7 @@ def get_article_job(job_id: str, current_user: User = Depends(get_current_user))
|
||||||
import redis as redis_lib
|
import redis as redis_lib
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
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:
|
if job_id not in user_jobs and not current_user.is_moderator:
|
||||||
raise HTTPException(404, "Job not found")
|
raise HTTPException(404, "Job not found")
|
||||||
status = r.get(f"extraction:status:{job_id}") or "unknown"
|
status = r.get(f"extraction:status:{job_id}") or "unknown"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
ARTICLE_DRAFT_PROMPT = """You write educational articles for a pediatric medical learning platform.
|
||||||
Topic: {topic}
|
Topic: {topic}
|
||||||
{instructions}
|
{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"}}]}}
|
{{"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."""
|
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:
|
if model_id:
|
||||||
ai_model_id = model_id
|
ai_model_id = model_id
|
||||||
_push_step(r, job_id, "ai", "Drafting article…")
|
_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(
|
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
|
import litellm
|
||||||
kwargs = {"model": _proxy_model(ai_model_id), "messages": [{"role": "user", "content": prompt}],
|
kwargs = {"model": _proxy_model(ai_model_id), "messages": [{"role": "user", "content": prompt}],
|
||||||
"max_tokens": 4000, "temperature": 0.4}
|
"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"
|
slug = re.sub(r"[^a-z0-9]+", "-", str(data.get("slug", topic)).strip().lower()).strip("-")[:120] or "topic"
|
||||||
sections = []
|
sections = []
|
||||||
for section in data.get("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({
|
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",
|
"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",
|
"title": str(section.get("title", "Section")).strip()[:300] or "Section",
|
||||||
"content": str(section.get("content", "")),
|
"content": str(section.get("content", "")),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Comments moderation and AI authoring endpoints on disposable SQLite; no network/AI."""
|
"""Comments moderation and AI authoring endpoints on disposable SQLite; no network/AI."""
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
@ -124,10 +125,23 @@ class CommentsAiTests(unittest.TestCase):
|
||||||
self.assertEqual(article.status, 'draft')
|
self.assertEqual(article.status, 'draft')
|
||||||
self.assertEqual([s['id'] for s in article.sections], ['a' * 32, 'b' * 32])
|
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)
|
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()
|
refreshed = self.bank.db.query(Article).filter_by(id=article.id).one()
|
||||||
self.assertEqual(refreshed.status, 'draft')
|
self.assertEqual(refreshed.status, 'draft')
|
||||||
self.assertEqual(refreshed.slug, 'ai-draft-topic')
|
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()
|
patch.stopall()
|
||||||
self.redis.reset_mock()
|
self.redis.reset_mock()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue