| Server IP : 3.147.158.171 / Your IP : 216.73.216.88 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/www/html/api.turmansolutions.ai/app/services/ |
Upload File : |
import logging
import asyncio
import os
import time
from datetime import datetime
from typing import Optional, Callable, Awaitable
from fastapi import HTTPException
from app.services.chat import ChatService
from app.services.wp import WpService
from app.services.image import ImageService
from app.services.site_config_service import SiteConfigService
from app.services import usage_history
from app.services import credits_guard
from app.utilities.fact_check import fact_check_author_name as build_fact_check_author_name
from app.clients import markdown_parser as md
from app.appTypes import (
Character,
SiteConfig,
ArticleResponse,
ArticleCreationResult,
ArticleProgressEvent,
)
from typing import Any, Dict, List
# Directory for individual article logs
ARTICLE_LOGS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs', 'articles')
class ArticleCreationService:
"""
Unified service for creating articles with AI.
This service consolidates article creation logic used by both the streaming
API route and the command-line script. It handles:
- Character data loading
- Current events research
- AI article generation
- WordPress post creation
- Image generation
- Inline fact-checking
Progress updates can be emitted via optional callback for streaming scenarios.
"""
def __init__(self):
self.wp_service = WpService()
self.chat_service = ChatService()
self.image_service = ImageService()
self.site_config_service = SiteConfigService()
self._article_logger: Optional[logging.Logger] = None
self._article_log_handler: Optional[logging.FileHandler] = None
self._article_md_path: Optional[str] = None
async def create_article(
self,
site: SiteConfig,
character: Character,
character_description_override: str = '',
generate_featured_image: bool = True,
generate_secondary_image: bool = True,
research_current_events: bool = False,
include_previous_articles: bool = True,
enable_fact_checking: bool = True,
enable_corrections: bool = False,
series_name: Optional[str] = None,
continue_series_id: Optional[int] = None,
seed_image_url: Optional[str] = None,
seed_image_instructions: Optional[str] = None,
post_status: str = 'publish',
progress_callback: Optional[Callable[[ArticleProgressEvent], Awaitable[None]]] = None
) -> ArticleCreationResult:
"""
Create a complete article with AI and post to WordPress.
Args:
site: Site configuration
character: AI character to use for article generation
character_description_override: Optional override for character description
generate_featured_image: Whether to generate featured image
generate_secondary_image: Whether to generate secondary image
research_current_events: Whether to research current events first
include_previous_articles: Whether to include character's article history
enable_fact_checking: Whether to post fact-check comment
enable_corrections: Whether to post a corrections follow-up after the
fact-check (only runs when the fact-check was actually posted)
series_name: Optional series name (creates new if doesn't exist)
continue_series_id: Optional WP taxonomy term ID to continue a series
seed_image_url: Optional reference image URL to anchor the featured image.
Only honored for a single article or the first part of a new series;
for Part 2+ the existing series_reference_images chain takes over.
seed_image_instructions: Optional free-text guidance for how the seed
image should be used (e.g. "use this person as the main character").
post_status: WordPress post status for the created article. Defaults to
'publish'; pass 'draft' to generate a preview article that never goes
live (used by the in-app Character Studio test-article tool).
progress_callback: Optional async callback for progress updates
Returns:
ArticleCreationResult with article data and metadata
Raises:
HTTPException: On WordPress authentication or post creation failures
Exception: On AI generation or image upload failures
"""
# Set up article-specific logging
log_path = self._setup_article_logger(character.name, site.slug)
self._log('info', f"Article log file created: {log_path}")
# Track overall timing (initialized before try block for error handling)
overall_start = time.time()
# Attribute all provider usage from this flow (article, fact-check,
# corrections, Tavily) to this site. Reset in the finally below.
_usage_token = usage_history.enter_usage_scope(site.slug)
try:
# Credit gate: pause paid article generation when a site is out of
# credits and can't auto-recharge. Fails open if credits aren't
# wired up / the store is unreachable (see credits_guard).
await credits_guard.ensure_allowed(site.slug, 'article')
# Step 1: Starting (0%)
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='starting',
progress=0,
message='Starting article creation...'
)
)
self._log('info', f"Article creation started for character: {character.name}")
# Step 2: Authenticate with WordPress (8%)
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='authenticating',
progress=8,
message='Authenticating with WordPress...'
)
)
wp_token = self.wp_service.get_jwt_token(site)
# Validate wp_token is a string
if not isinstance(wp_token, str):
error_msg = f"Invalid token: {wp_token}"
self._log('error', error_msg)
raise HTTPException(401, error_msg)
self._log('info', f"WordPress authentication successful ({time.time() - step_start:.2f}s)")
# Step 3: Load character data (15%)
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='loading_character',
progress=15,
message=f'Loading character data for {character.name}...'
)
)
wp_user_data = self.wp_service.get_users(site, wp_token)
self._log('info', f"Fetched WP users ({time.time() - step_start:.2f}s)")
# Ensure wp_user_data is not None
if wp_user_data is None:
wp_user_data = []
self._log('warning', "No WordPress users returned, using empty list")
self.wp_service.add_to_character(site, character, wp_user_data)
# Load recent articles filtered by author (if character has WordPress user)
articles_start = time.time()
from app.appTypes import WpUser
if character.wp_user and isinstance(character.wp_user, WpUser):
character.articles = self.wp_service.get_recent_articles(
site, wp_token, 100, author_id=character.wp_user.id
)
if character.articles is None:
character.articles = []
self._log('warning', f"No articles returned for author {character.wp_user.id}")
else:
character.articles = []
self._log('warning', f"Character '{character.name}' has no WordPress user, skipping article history")
self._log('info', f"Loaded {len(character.articles)} articles for character ({time.time() - articles_start:.2f}s)")
# Step 4: Get categories and tags (20%)
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='fetching_data',
progress=20,
message='Fetching WordPress categories and tags...'
)
)
categories = self.wp_service.get_categories(site)
tags = self.wp_service.get_tags(site, wp_token)
if categories is None:
categories = []
self._log('warning', "No categories returned, using empty list")
if tags is None:
tags = []
self._log('warning', "No tags returned, using empty list")
self._log('info', f"Got {len(categories)} categories and {len(tags)} tags ({time.time() - step_start:.2f}s)")
# Step 4.5: Build series context if this is a series article
series_context = None
series_term_id = None
series_part_number = None
series_images: List[str] = []
series_reference_images: List[str] = []
if continue_series_id or series_name:
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='loading_series',
progress=22,
message='Loading series context...'
)
)
# Resolve or create the series term
if continue_series_id:
series_term_id = continue_series_id
elif series_name:
# Check if series already exists
existing_terms = self.wp_service.get_series_terms(site, wp_token)
if existing_terms:
for term in existing_terms:
if term.get('name', '').lower() == series_name.lower():
series_term_id = term['id']
break
if not series_term_id:
# Create new series
series_term_id = self.wp_service.create_series_term(site, wp_token, series_name)
if series_term_id:
self._log('info', f"Created new series '{series_name}' (term ID: {series_term_id})")
else:
self._log('error', f"Failed to create series '{series_name}'")
if series_term_id:
# Fetch existing articles in the series
series_articles = self.wp_service.get_series_articles(site, wp_token, series_term_id)
if series_articles and len(series_articles) > 0:
series_part_number = len(series_articles) + 1
# Build previous parts data
previous_parts = []
for sa in series_articles:
part_meta = sa.get('meta', {})
previous_parts.append({
'title': sa.get('title', {}).get('rendered', 'Untitled'),
'summary': part_meta.get('_series_summary', ''),
'part_number': part_meta.get('_series_part_number', 0),
})
# Get full content of the most recent part
latest_article = series_articles[-1]
latest_part_content = latest_article.get('content', {}).get('rendered', '')
# Extract images from first and latest parts for visual continuity
# extract_images() returns inline <img> srcs followed by the featured image
# (featured is last). We track two lists:
# - series_images: everything, used to inform the article-writing LLM
# - series_reference_images: just the featured image from first + latest,
# capped at 2, passed to the image generator as input_images
first_featured: Optional[str] = None
latest_featured: Optional[str] = None
try:
first_images = self.wp_service.extract_images(series_articles[0])
series_images.extend(first_images)
if first_images:
first_featured = first_images[-1]
except Exception as img_err:
self._log('warning', f"Could not extract images from first series article: {img_err}")
if len(series_articles) > 1:
try:
latest_images = self.wp_service.extract_images(latest_article)
# Avoid duplicates
for img in latest_images:
if img not in series_images:
series_images.append(img)
if latest_images:
latest_featured = latest_images[-1]
except Exception as img_err:
self._log('warning', f"Could not extract images from latest series article: {img_err}")
if first_featured:
series_reference_images.append(first_featured)
if latest_featured and latest_featured != first_featured:
series_reference_images.append(latest_featured)
# Extract category and tags from first article for consistency
first_article = series_articles[0]
series_category_name = None
series_tag_names: List[str] = []
# Get category name from first article
first_cat_ids = first_article.get('categories', [])
if first_cat_ids and categories:
for cat in categories:
if cat.get('id') in first_cat_ids:
series_category_name = cat.get('name')
break
# Get tag names from first article (excluding model/provider tags)
first_tag_ids = first_article.get('tags', [])
model_provider_names = {'OpenAI', 'Claude',
'gpt-5.5', 'gpt-5.4', 'gpt-5',
'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano',
'gpt-4o', 'gpt-4o-mini',
'claude-sonnet-4-6', 'claude-sonnet-4-5-20250929',
'claude-haiku-4-5-20251001',
'claude-opus-4-7', 'claude-opus-4-5-20251101', 'claude-opus-4-6'}
if first_tag_ids and tags:
for tag in tags:
if tag.get('id') in first_tag_ids and tag.get('name') not in model_provider_names:
series_tag_names.append(tag['name'])
# Get series name
resolved_series_name = series_name or ''
if not resolved_series_name:
terms = self.wp_service.get_series_terms(site, wp_token)
if terms:
for t in terms:
if t.get('id') == series_term_id:
resolved_series_name = t.get('name', f'Series {series_term_id}')
break
series_context = {
'series_name': resolved_series_name,
'part_number': series_part_number,
'previous_parts': previous_parts,
'latest_part_content': latest_part_content,
'series_images': series_images,
'series_category': series_category_name,
'series_tags': series_tag_names,
}
self._log('info', f"Series context built: part {series_part_number}, {len(previous_parts)} previous parts, {len(series_images)} images")
else:
# First article in series
series_part_number = 1
resolved_series_name = series_name or f'Series {series_term_id}'
self._log('info', f"Starting new series '{resolved_series_name}' - this will be part 1")
self._log('info', f"Series context loaded ({time.time() - step_start:.2f}s)")
# Write series context to markdown file
if series_context:
series_md = f"**Series:** {series_context['series_name']}\n"
series_md += f"**Part Number:** {series_context['part_number']}\n"
series_md += f"**Previous Parts:** {len(series_context['previous_parts'])}\n"
series_md += f"**Series Images:** {len(series_context['series_images'])}\n"
if series_context.get('series_category'):
series_md += f"**Series Category:** {series_context['series_category']}\n"
if series_context.get('series_tags'):
series_md += f"**Series Tags:** {', '.join(series_context['series_tags'])}\n"
self._write_markdown_section("Series Context", series_md)
# Seed image is honored only for a single article or the first part
# of a new series. For Part 2+, the existing series_reference_images
# chain (first + latest featured) carries the visual style forward.
seed_honored = bool(seed_image_url) and (
series_part_number is None or series_part_number == 1
)
if seed_image_url and not seed_honored:
self._log(
'warning',
f"seed_image_url supplied but ignored — series_part_number={series_part_number}; "
"seed image only applies to single articles or Part 1 of a new series."
)
effective_seed_url = seed_image_url if seed_honored else None
effective_seed_instructions = (seed_image_instructions or '').strip() if seed_honored else ''
# Step 5: Research current events if requested (25%)
search_results = None
if research_current_events:
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='researching',
progress=25,
message='Researching current events...'
)
)
# Require character to have searchQuery when researching current events
if not hasattr(character, 'searchQuery') or not character.searchQuery:
raise ValueError(f"Character '{character.name}' has research_current_events enabled but no searchQuery defined")
search_query = character.searchQuery
topic = character.searchTopicType if character.searchTopicType else 'general'
self._log('info', f"Searching for '{search_query}' (topic: {topic})")
search_results = self.chat_service.search_current_events(
search_query,
max_results=character.maxSearchResults or 5,
include_domains=character.searchDomainsIncluded,
exclude_domains=character.searchDomainsExcluded,
topic=topic,
time_range=character.searchTimeRange,
search_depth=character.searchDepth or "basic",
chunks_per_source=character.chunksPerSource,
)
self._log('info', f"Found {len(search_results)} search results ({time.time() - step_start:.2f}s)")
# Write search results to separate markdown file
self._write_search_results_file(search_query, topic, search_results, character.name)
# Step 6: Prepare system message (30%)
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='preparing_prompt',
progress=30,
message='Preparing article prompt...'
)
)
sys_msg = self.wp_service.get_new_article_system_message(
character, categories, tags, character_description_override or "", include_previous_articles, series_context
)
self._log('info', f"System message prepared ({time.time() - step_start:.2f}s)")
# Write system message to markdown file
self._write_markdown_section("System Message", sys_msg)
# Step 7: Generate article with AI (35%)
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='generating_article',
progress=35,
message='Generating article with AI (this may take a few minutes)...'
)
)
human_msg = 'Please write an article.'
# Combine the seed image (if honored) with any series_images. In
# practice these are mutually exclusive — the seed is only honored
# when there are no prior series parts — but the union keeps the
# call shape simple either way.
article_images: List[str] = []
if effective_seed_url:
article_images.append(effective_seed_url)
if series_images:
for img in series_images:
if img not in article_images:
article_images.append(img)
article, actual_model, enhanced_human_msg = self.chat_service.createArticle(
sys_msg, human_msg, model=character.model, search_results=search_results,
images=article_images if article_images else None,
seed_image_instructions=effective_seed_instructions or None,
)
# Write human message (with search context and citation instructions if research was performed) to markdown file
self._write_markdown_section("Human Message", enhanced_human_msg)
if not isinstance(article, ArticleResponse):
error_msg = "Article generation returned invalid response"
self._log('error', error_msg)
raise Exception(error_msg)
if series_part_number:
prefix_name = series_context['series_name'] if series_context else series_name
series_prefix = f"{prefix_name}, Part {series_part_number}: "
if not article.title.startswith(series_prefix):
article.title = f"{series_prefix}{article.title}"
self._log('info', f"Generated article - {article.title} ({time.time() - step_start:.2f}s)")
# Write article details to markdown file
model_info = f"{actual_model.provider.value} {actual_model.name}" if actual_model else "unknown"
article_md = f"""**Title:** {article.title}
**Category:** {article.category}
**Tags:** {', '.join(article.tags)}
**Model:** {model_info}
**Featured Image Description:** {article.featured_image_description or 'N/A'}
**Secondary Image Description:** {article.secondary_image_description or 'N/A'}
### Article Body
{article.body}"""
self._write_markdown_section("Generated Article", article_md)
# Step 8: Creating WordPress post (50%)
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='creating_post',
progress=50,
message='Creating WordPress post...'
)
)
# Create the WordPress post first (without images)
# Find/create category
category = self.wp_service.find_category_by_name(categories, article.category)
if not category:
self._log('error', f'Category not found: {article.category}')
category = self.wp_service.find_category_by_name(categories, 'Uncategorized')
if not category:
self._log('error', 'Uncategorized category not found!')
raise HTTPException(500, 'Category not found.')
# Find/create tags
tag_ids = []
for article_tag in article.tags:
tag = self.wp_service.find_tag_by_name(tags, article_tag)
if tag:
tag_ids.append(tag['id'])
else:
tag_id = self.wp_service.post_tag(site, wp_token, article_tag)
tag_ids.append(tag_id)
# Post the article
model_for_post = actual_model if actual_model else character.model
resp = self.wp_service.post_article(site, wp_token, character, article, category['id'], tag_ids, search_results, model=model_for_post, status=post_status)
if not resp:
raise HTTPException(500, 'Failed to create WordPress post.')
wp_post_id = resp["id"]
wp_post_link = resp.get("link")
self._log('info', f"Created WP post {wp_post_id} ({time.time() - step_start:.2f}s)")
# Step 8.5: Set series metadata on the post if applicable
if series_term_id and series_part_number:
summary = article.series_summary or ''
success = self.wp_service.set_post_series(
site, wp_token, wp_post_id, series_term_id, series_part_number, summary
)
if success:
self._log('info', f"Post assigned to series {series_term_id} as part {series_part_number}")
else:
self._log('error', f"Failed to assign post to series")
# Step 9: Generating featured image (65%) - Optional
featured_image_url = None
featured_image_base64 = None
if generate_featured_image:
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='generating_featured_image',
progress=65,
message='Generating featured image...'
)
)
try:
# Create and upload featured image. When a seed image is in
# play (single article or series Part 1), prepend it to the
# reference list so the image model anchors on it directly.
featured_refs: List[str] = []
if effective_seed_url:
featured_refs.append(effective_seed_url)
if series_reference_images:
for ref in series_reference_images:
if ref not in featured_refs:
featured_refs.append(ref)
featured_image, featured_image_base64 = await self.wp_service.create_image(
site, article,
reference_images=featured_refs or None,
seed_image_instructions=effective_seed_instructions or None,
)
self._log('info', f"Featured image created: {featured_image}")
resp = self.image_service.compress_and_save(site, wp_token, featured_image, 50)
# Check if upload succeeded
if "error" in resp:
error_msg = f"WordPress media upload failed: {resp.get('error')} - {resp.get('message', 'Unknown error')}"
raise Exception(error_msg)
image_id = resp["id"]
featured_image_url = str(resp.get("source_url", featured_image))
# Post featured image
self.wp_service.post_featured_image(site, wp_token, wp_post_id, image_id)
# Store caption (post_excerpt) + alt on the attachment. The
# caption is rendered under the featured image by the
# tsai-plugin featured-caption filter.
self.wp_service.update_media_meta(
site, wp_token, image_id,
caption=article.featured_image_caption,
alt_text=article.featured_image_caption or article.title,
)
self._log('info', f"Featured image posted ({time.time() - step_start:.2f}s)")
except Exception as featured_image_error:
# Best-effort: a transient image failure must not abort the
# already-published article or skip downstream fact-checking.
self._log('error', f"Featured image failed - {featured_image_error}")
featured_image_url = None
featured_image_base64 = None
else:
self._log('info', "Featured image generation skipped")
# Step 10: Generating secondary image (80%) - Optional
secondary_image_url = None
if generate_secondary_image:
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='generating_secondary_image',
progress=80,
message='Generating secondary image...'
)
)
try:
# Create and upload secondary image
secondary_image = await self.wp_service.create_secondary_image(
site, article, featured_image_base64,
reference_images=series_reference_images or None,
)
self._log('info', f"Secondary image created: {secondary_image}")
resp = self.image_service.compress_and_save(site, wp_token, secondary_image, 50)
# Check if upload succeeded
if "error" in resp:
error_msg = f"WordPress media upload failed: {resp.get('error')} - {resp.get('message', 'Unknown error')}"
raise Exception(error_msg)
secondary_image_id = resp["id"]
secondary_image_url = str(resp.get("source_url", secondary_image))
self._log('info', f"Secondary image uploaded ({time.time() - step_start:.2f}s)")
# Store caption + alt on the attachment (SEO/accessibility);
# the visible caption is the inline <figcaption> below.
self.wp_service.update_media_meta(
site, wp_token, secondary_image_id,
caption=article.secondary_image_caption,
alt_text=article.secondary_image_caption or article.title,
)
# Insert secondary image into content
post_content = md.render(article.body)
updated_content = self.wp_service.insert_image_into_content(
post_content, secondary_image_id, secondary_image_url, article.title,
caption=article.secondary_image_caption or "",
)
self.wp_service.update_post_content(site, wp_token, wp_post_id, updated_content)
self._log('info', "Post content updated with secondary image")
except Exception as secondary_image_error:
# Best-effort: a transient image failure must not abort the
# already-published article or skip downstream fact-checking.
self._log('error', f"Secondary image failed - {secondary_image_error}")
secondary_image_url = None
else:
self._log('info', "Secondary image generation skipped")
# Step 11: Finalizing (92%)
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='finalizing',
progress=92,
message='Finalizing article...'
)
)
# Image URLs used as vision context by both fact-check and corrections.
images = []
if generate_featured_image and featured_image_url:
images.append(featured_image_url)
if generate_secondary_image and secondary_image_url:
images.append(secondary_image_url)
# Step 12: Fact-checking (95%) - Only if enabled
fact_check_posted = False
fact_check_comment = None
fact_check_comment_id = None
if enable_fact_checking:
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='fact_checking',
progress=95,
message='Generating fact-check analysis...'
)
)
# Perform fact-checking (don't fail article creation if this fails)
try:
# Generate fact-check comment using opposite AI provider
fact_check_comment, model_name, provider_name = self.chat_service.fact_check_article(
article_title=article.title,
article_body=article.body,
images=images,
original_model=actual_model,
search_results=search_results
)
# Format author name and email for fact-check
fact_check_author_name = build_fact_check_author_name(provider_name, model_name)
fact_check_author_email = "editor@turmansolutions.ai"
# Post fact-check as a comment on the article. Capture the
# response so corrections can thread under it (the just-posted
# comment may be held in moderation and not re-fetchable).
fact_check_resp = self.wp_service.post_comment(
site=site,
character=character,
post_id=wp_post_id,
comment=fact_check_comment,
token=wp_token,
author_name_override=fact_check_author_name,
author_email_override=fact_check_author_email
)
fact_check_comment_id = fact_check_resp.get("id") if fact_check_resp else None
fact_check_posted = True
self._log('info', f"Fact-check comment posted ({time.time() - step_start:.2f}s)")
except Exception as fact_check_error:
self._log('error', f"Fact-check failed - {str(fact_check_error)}")
# Continue with article completion even if fact-check fails
else:
self._log('info', "Fact-checking skipped (disabled)")
# Step 13: Corrections (97%) - only if a fact-check was actually posted
corrections_posted = False
if enable_corrections and fact_check_posted and fact_check_comment_id is not None:
step_start = time.time()
await self._emit_progress(
progress_callback,
ArticleProgressEvent(
status='correcting',
progress=97,
message='Generating corrections follow-up...'
)
)
# Reuse the shared corrections orchestration; never abort an
# already-published article if corrections fail.
try:
from app.services.corrections_service import run_corrections_core
# Corrections expect the body as HTML (the contract shared
# with improve_article.py, which passes content.rendered).
# The revised body they generate is written straight back to
# WordPress with no markdown rendering, so we must hand them
# the published HTML — not article.body (raw markdown). Passing
# markdown here let WordPress's wpautop wrap it verbatim,
# leaving literal "## headings" and "[[1]](url)" citations in
# the body. Re-fetch the published post so the LLM also sees
# the footer and any inserted secondary image; fall back to
# rendering the markdown if the fetch fails.
corrections_body_html = None
current_post = self.wp_service.get_article(site, wp_token, wp_post_id)
if current_post:
corrections_body_html = current_post.get('content', {}).get('rendered')
if not corrections_body_html:
corrections_body_html = md.render(article.body)
corrections_result = run_corrections_core(
wp_service=self.wp_service,
chat_service=self.chat_service,
site=site,
wp_token=wp_token,
post_id=wp_post_id,
article_title=article.title,
article_body=corrections_body_html,
fact_check_text=fact_check_comment, # in-memory; no WP re-fetch
parent_comment_id=fact_check_comment_id,
images=images,
search_results=search_results,
character=character,
)
corrections_posted = True
self._log(
'info',
f"Corrections posted (body_updated={corrections_result['body_updated']}) "
f"({time.time() - step_start:.2f}s)"
)
except Exception as corrections_error:
self._log('error', f"Corrections failed - {str(corrections_error)}")
# Continue with article completion even if corrections fail
elif enable_corrections:
self._log('info', "Corrections skipped (fact-check not posted)")
total_time = time.time() - overall_start
self._log('info', f"Article creation completed successfully (total: {total_time:.2f}s)")
# Write summary to markdown file
model_info = f"{actual_model.provider.value} {actual_model.name}" if actual_model else "unknown"
self._write_summary_section(
total_time=total_time,
success=True,
article_title=article.title,
model_info=model_info,
wp_post_id=wp_post_id,
generate_featured_image=generate_featured_image,
generate_secondary_image=generate_secondary_image,
research_current_events=research_current_events,
enable_fact_checking=enable_fact_checking,
fact_check_posted=fact_check_posted,
enable_corrections=enable_corrections,
corrections_posted=corrections_posted,
featured_image_url=featured_image_url,
secondary_image_url=secondary_image_url
)
# Return complete result
return ArticleCreationResult(
article=article,
post_id=wp_post_id,
actual_model=actual_model,
permalink=wp_post_link,
featured_image_url=featured_image_url,
secondary_image_url=secondary_image_url,
search_results=search_results,
fact_check_posted=fact_check_posted,
corrections_posted=corrections_posted,
series_term_id=series_term_id,
series_part_number=series_part_number
)
except Exception as e:
total_time = time.time() - overall_start
self._log('error', f"Article creation failed: {str(e)}")
# Write failure summary
self._write_summary_section(
total_time=total_time,
success=False,
article_title="N/A",
model_info="N/A",
wp_post_id=0,
generate_featured_image=generate_featured_image,
generate_secondary_image=generate_secondary_image,
research_current_events=research_current_events,
enable_fact_checking=enable_fact_checking,
fact_check_posted=False,
enable_corrections=enable_corrections,
corrections_posted=False
)
raise
finally:
usage_history.exit_usage_scope(_usage_token)
# Always clean up the article logger
self._cleanup_article_logger()
async def _emit_progress(
self,
callback: Optional[Callable[[ArticleProgressEvent], Awaitable[None]]],
event: ArticleProgressEvent
):
"""
Emit progress event via callback if provided.
Args:
callback: Optional async callback function
event: Progress event to emit
"""
if callback:
await callback(event)
await asyncio.sleep(0) # Force flush
def _setup_article_logger(self, character_name: str, site_slug: str) -> str:
"""
Set up a dedicated logger and markdown file for this article creation.
Creates two files in logs/articles/:
- .log file for timestamped progress/debug entries
- .md file for readable system message and article content
Args:
character_name: Name of the character creating the article
site_slug: Site slug identifier
Returns:
Path to the created log file
"""
# Ensure logs directory exists
os.makedirs(ARTICLE_LOGS_DIR, exist_ok=True)
# Create filename with timestamp and sanitized character name
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_name = ''.join(c if c.isalnum() or c in '-_' else '_' for c in character_name)
base_filename = f'{timestamp}_{site_slug}_{safe_name}'
# Set up .log file for progress/debug logging
log_path = os.path.join(ARTICLE_LOGS_DIR, f'{base_filename}.log')
# Create a dedicated logger for this article
self._article_logger = logging.getLogger(f'article_{timestamp}_{safe_name}')
self._article_logger.setLevel(logging.INFO)
# Remove any existing handlers
self._article_logger.handlers.clear()
# Create file handler for this article
self._article_log_handler = logging.FileHandler(log_path, encoding='utf-8')
self._article_log_handler.setLevel(logging.INFO)
# Use same format as main logger
formatter = logging.Formatter(
'%(levelname)s %(asctime)s %(filename)s ln %(lineno)s %(message)s',
datefmt='%m/%d/%Y %I:%M:%S %p'
)
self._article_log_handler.setFormatter(formatter)
self._article_logger.addHandler(self._article_log_handler)
# Prevent propagation to root logger to avoid duplicate entries
self._article_logger.propagate = False
# Set up .md file for readable content
self._article_md_path = os.path.join(ARTICLE_LOGS_DIR, f'{base_filename}.md')
# Write markdown header
with open(self._article_md_path, 'w', encoding='utf-8') as f:
f.write(f"# Article Creation: {character_name}\n\n")
f.write(f"**Site:** {site_slug} \n")
f.write(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
f.write("---\n\n")
return log_path
def _log(self, level: str, message: str) -> None:
"""
Log to both the main logger and the article-specific logger.
Args:
level: Log level ('info', 'warning', 'error')
message: Message to log
"""
# Log to main logger
log_func = getattr(logging, level)
log_func(message)
# Log to article-specific logger if set up
if self._article_logger:
article_log_func = getattr(self._article_logger, level)
article_log_func(message)
def _write_markdown_section(self, header: str, content: str) -> None:
"""
Append a markdown section to the article's .md file.
Args:
header: Section header (will be formatted as ## header)
content: Content to write (can contain markdown)
"""
if self._article_md_path:
with open(self._article_md_path, 'a', encoding='utf-8') as f:
f.write(f"## {header}\n\n{content}\n\n")
def _write_search_results_file(self, search_query: str, topic: str, search_results: list, character_name: str) -> None:
"""
Write Tavily search results to a separate markdown file.
Args:
search_query: The search query used
topic: The search topic type
search_results: List of search result dicts with title, url, content, score
character_name: Name of the character for the filename
"""
if not self._article_md_path or not search_results:
return
# Create search results file with same base name but _search suffix
search_md_path = self._article_md_path.replace('.md', '_search.md')
with open(search_md_path, 'w', encoding='utf-8') as f:
f.write(f"# Tavily Search Results: {character_name}\n\n")
f.write(f"**Query:** {search_query} \n")
f.write(f"**Topic:** {topic} \n")
f.write(f"**Results:** {len(search_results)} \n")
f.write(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
f.write("---\n\n")
for i, result in enumerate(search_results, 1):
title = result.get('title', 'No title')
url = result.get('url', '')
content = result.get('content', 'No content')
score = result.get('score', 0)
raw_content = result.get('raw_content', '')
f.write(f"## {i}. {title}\n\n")
f.write(f"**URL:** [{url}]({url}) \n")
f.write(f"**Score:** {score:.4f}\n\n")
f.write(f"### Summary\n\n{content}\n\n")
if raw_content:
# Truncate raw content if too long
if len(raw_content) > 5000:
raw_content = raw_content[:5000] + "\n\n*[Content truncated...]*"
f.write(f"### Full Content\n\n{raw_content}\n\n")
f.write("---\n\n")
self._log('info', f"Search results written to: {search_md_path}")
def _write_summary_section(
self,
total_time: float,
success: bool,
article_title: str,
model_info: str,
wp_post_id: int,
generate_featured_image: bool,
generate_secondary_image: bool,
research_current_events: bool,
enable_fact_checking: bool,
fact_check_posted: bool,
enable_corrections: bool = False,
corrections_posted: bool = False,
featured_image_url: Optional[str] = None,
secondary_image_url: Optional[str] = None
) -> None:
"""
Write a summary section at the end of the article's .md file.
Args:
total_time: Total elapsed time in seconds
success: Whether article creation succeeded
article_title: Title of the generated article
model_info: Model provider and name used
wp_post_id: WordPress post ID
generate_featured_image: Whether featured image was requested
generate_secondary_image: Whether secondary image was requested
research_current_events: Whether research was requested
enable_fact_checking: Whether fact-checking was requested
fact_check_posted: Whether fact-check was actually posted
enable_corrections: Whether corrections were requested
corrections_posted: Whether corrections were actually posted
featured_image_url: URL of featured image if generated
secondary_image_url: URL of secondary image if generated
"""
if not self._article_md_path:
return
# Format time as minutes:seconds
minutes = int(total_time // 60)
seconds = total_time % 60
time_str = f"{minutes}m {seconds:.1f}s" if minutes > 0 else f"{seconds:.1f}s"
# Build options summary
options_enabled = []
options_disabled = []
if generate_featured_image:
if featured_image_url:
options_enabled.append(f"Featured Image: ✓ ({featured_image_url})")
else:
options_enabled.append("Featured Image: ✓ (generation failed)")
else:
options_disabled.append("Featured Image: ✗")
if generate_secondary_image:
if secondary_image_url:
options_enabled.append(f"Secondary Image: ✓ ({secondary_image_url})")
else:
options_enabled.append("Secondary Image: ✓ (generation failed)")
else:
options_disabled.append("Secondary Image: ✗")
if research_current_events:
options_enabled.append("Current Events Research: ✓")
else:
options_disabled.append("Current Events Research: ✗")
if enable_fact_checking:
if fact_check_posted:
options_enabled.append("Fact-Checking: ✓ (posted)")
else:
options_enabled.append("Fact-Checking: ✓ (failed to post)")
else:
options_disabled.append("Fact-Checking: ✗")
if enable_corrections:
if corrections_posted:
options_enabled.append("Auto-Corrections: ✓ (posted)")
else:
options_enabled.append("Auto-Corrections: ✓ (skipped/failed)")
else:
options_disabled.append("Auto-Corrections: ✗")
status_emoji = "✅" if success else "❌"
status_text = "Success" if success else "Failed"
summary = f"""**Status:** {status_emoji} {status_text}
**Total Time:** {time_str}
**Article Title:** {article_title}
**Model:** {model_info}
**WordPress Post ID:** {wp_post_id}
### Options Enabled
{chr(10).join('- ' + opt for opt in options_enabled) if options_enabled else '- None'}
### Options Disabled
{chr(10).join('- ' + opt for opt in options_disabled) if options_disabled else '- None'}
"""
self._write_markdown_section("Summary", summary)
def _cleanup_article_logger(self) -> None:
"""Clean up the article-specific logger and its handlers."""
if self._article_log_handler:
self._article_log_handler.close()
self._article_log_handler = None
if self._article_logger:
self._article_logger.handlers.clear()
self._article_logger = None
self._article_md_path = None