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/wp.py
import html
import json
import logging
import os
import secrets
import string
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from fastapi import HTTPException
import requests
from dotenv import load_dotenv

from app.services.chat import ChatService
from app.services.image import ImageService
from app.services import image_v2 as image_v2_service
from app.clients import markdown_parser as md
from app.utilities.citations import normalize_citations
from app.utilities.math_tex import convert_math
from ..appTypes import ArticleResponse, Character, LLModel, SiteConfig, WpUser
from bs4 import BeautifulSoup

chat_service = ChatService()
image_service = ImageService()


def _use_image_v2() -> bool:
    """Article images use gpt-image-2 by default. Set ARTICLE_IMAGE_API=v1 to roll back."""
    return os.environ.get('ARTICLE_IMAGE_API', 'v2').lower() != 'v1'


def _series_style_lock_preamble(n_refs: int) -> str:
    """Style-lock instructions prepended to image prompts when reference images are supplied."""
    noun = 'reference image' if n_refs == 1 else f'{n_refs} reference images'
    return (
        f'This image is part of a continuing visual series. The {noun} provided are from '
        'earlier installments. Match their artistic style, medium, color palette, lighting, '
        'composition mood, and any recurring character or setting details. Maintain visual '
        'continuity with the prior installment(s). Then render the following scene:\n\n'
    )

load_dotenv()

WP_SERVICE_USER = os.getenv('WP_USER')
WP_SERVICE_PASSWORD = os.getenv('WP_PASSWORD')


V2_PATH = '/wp-json/wp/v2'

ARTICLE_SERIES_URL = f'{V2_PATH}/article_series'
CATEGORIES_URL = f'{V2_PATH}/categories'
COMMENTS_URL = f'{V2_PATH}/comments'
MEDIA_URL = f'{V2_PATH}/media'
POSTS_URL = f'{V2_PATH}/posts'
TAGS_URL = f'{V2_PATH}/tags'
TOKEN_URL = f'/wp-json/jwt-auth/v1/token'
TOKEN_VALIDATE_URL = f'{TOKEN_URL}/validate'
USERS_URL = f'{V2_PATH}/users'


class WpService:
    def validate_token(self, site: SiteConfig, token: Optional[str]) -> Optional[Dict[str, Any]]:
        """Validate a WordPress JWT against THIS site's WordPress install.

        Returns the validation payload when the token is good, None otherwise.
        A token minted by a different site's WordPress is signed with that
        install's secret, so it fails here — this is what scopes an in-app agent
        to the site the user actually logged in to.

        Two jwt-auth plugin response shapes are in the wild and our sites run both:
          - older: HTTP 200 {"code": "jwt_auth_valid_token", "data": {"status": 200}}
          - newer: HTTP 200 {"success": true, "code": "jwt_auth_valid_token", ...}
        An invalid token is a 403 either way. This is an auth gate, so an
        unrecognized 200 body is treated as a failure rather than a pass.
        """
        if not token:
            return None
        start_time = time.time()
        try:
            response = requests.post(
                site.wp_host + TOKEN_VALIDATE_URL,
                headers={'Authorization': f'Bearer {token}'},
                timeout=15,
            )
        except requests.RequestException as e:
            logging.warning(f"WP token validation error for {site.slug}: {e}")
            return None
        elapsed = time.time() - start_time
        if response.status_code != 200:
            logging.info(
                f"WP token validation rejected for {site.slug}: "
                f"{response.status_code} ({elapsed:.2f}s)")
            return None
        try:
            payload = response.json()
        except ValueError:
            logging.warning(f"WP token validation returned non-JSON for {site.slug}")
            return None
        if payload.get('success') is True or payload.get('code') == 'jwt_auth_valid_token':
            logging.debug(f"WP API: validate_token ok for {site.slug} ({elapsed:.2f}s)")
            return payload
        logging.warning(
            f"WP token validation returned 200 with an unrecognized body for "
            f"{site.slug}: {str(payload)[:200]}")
        return None

    def get_jwt_token(self, site: SiteConfig) -> Optional[str | Dict[str, str]]:
        start_time = time.time()
        data = {
            'username': WP_SERVICE_USER,
            'password': WP_SERVICE_PASSWORD
        }
        response = requests.post(site.wp_host + TOKEN_URL, data=data)
        elapsed = time.time() - start_time
        if response.status_code == 200:
            logging.debug(f"WP API: get_jwt_token ({elapsed:.2f}s)")
            return response.json().get('token')
        else:
            logging.error(
                f"Failed to get token: {response.status_code} {response.text} ({elapsed:.2f}s)")
        return {'error': f'status code: {response.status_code}'}

    def get_article(self, site: SiteConfig, token: str, post_id: int) -> Optional[Dict[str, Any]]:
        headers = {'Authorization': f'Bearer {token}'}
        params = {}

        POSTS_URL = f'/wp-json/wp/v2/posts/{post_id}?_embed'
        response = requests.get(site.wp_host + POSTS_URL,
                                headers=headers, params=params)
        if response.status_code == 200:
            return response.json()
        else:
            logging.info(
                f"Failed to get post: {response.status_code} {response.text}")
            return None

    def get_comment_counts(
        self,
        site: SiteConfig,
        post_ids: List[int],
        exclude_author_prefixes: Tuple[str, ...] = (),
    ) -> Dict[int, int]:
        """Return {post_id: approved_comment_count} for the given posts.

        Comments whose author_name (case-insensitive) starts with any string
        in exclude_author_prefixes are not counted. Posts with no countable
        comments are still present in the map with count 0.
        """
        if not post_ids:
            return {}
        prefixes = tuple(p.lower() for p in exclude_author_prefixes)
        counts: Dict[int, int] = {pid: 0 for pid in post_ids}
        page = 1
        while True:
            params = {
                'post': post_ids,
                'per_page': 100,
                'page': page,
                '_fields': 'post,author_name',
            }
            resp = requests.get(site.wp_host + COMMENTS_URL, params=params)
            if resp.status_code != 200:
                logging.warning(
                    f'get_comment_counts failed (page {page}): '
                    f'{resp.status_code} {resp.text[:200]}'
                )
                return counts
            for c in resp.json():
                pid = c.get('post')
                if pid not in counts:
                    continue
                if prefixes:
                    name = (c.get('author_name') or '').lower()
                    if name.startswith(prefixes):
                        continue
                counts[pid] += 1
            total_pages = int(resp.headers.get('X-WP-TotalPages', '1') or '1')
            if page >= total_pages:
                break
            page += 1
        return counts

    def get_recent_articles(self, site: SiteConfig, token: str, count: int = 25, author_id: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
        start_time = time.time()
        headers = {
            'Authorization': f'Bearer {token}'
        }
        # 'comments_open': 'true'
        # '_embed': 'true'
        params = {
            'per_page': count,
            'orderby': 'date',
            'order': 'desc',
            'status': 'publish',
        }

        # Add author filter if provided
        if author_id:
            params['author'] = author_id
            logging.debug(f"Filtering articles by author ID: {author_id}")

        response = requests.get(site.wp_host + POSTS_URL,
                                headers=headers, params=params)
        elapsed = time.time() - start_time
        if response.status_code == 200:
            articles = response.json()
            logging.debug(f"WP API: get_recent_articles returned {len(articles)} articles ({elapsed:.2f}s)")
            return articles
        else:
            logging.info(
                f"Failed to get posts: {response.status_code} {response.text} ({elapsed:.2f}s)")
            return None

    def has_author_posted_today(self, site: SiteConfig, token: str, author_id: str) -> bool:
        today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
        headers = {'Authorization': f'Bearer {token}'}
        params = {
            'per_page': 1,
            'author': author_id,
            'after': today_start,
            'status': 'publish,future,draft,pending,private,trash',
        }
        try:
            response = requests.get(site.wp_host + POSTS_URL, headers=headers, params=params)
            if response.status_code == 200:
                posts = response.json()
                return len(posts) > 0
            logging.warning(
                f"has_author_posted_today: unexpected status {response.status_code} "
                f"for author {author_id}: {response.text[:200]}"
            )
        except Exception as e:
            logging.warning(f"Error checking today's posts for author {author_id}: {e}")
        return False

    def has_author_commented_today(self, site: SiteConfig, token: str, author_id: str) -> bool:
        today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
        headers = {'Authorization': f'Bearer {token}'}
        # 'all' matches both 'hold' (pending) and 'approve'. The comments REST
        # endpoint sanitizes status with sanitize_key, which strips commas, so
        # a comma-separated list silently matches nothing — see commit history.
        params = {
            'per_page': 1,
            'author': author_id,
            'after': today_start,
            'status': 'all',
        }
        try:
            response = requests.get(site.wp_host + COMMENTS_URL, headers=headers, params=params)
            if response.status_code == 200:
                comments = response.json()
                return len(comments) > 0
            logging.warning(
                f"has_author_commented_today: unexpected status {response.status_code} "
                f"for author {author_id}: {response.text[:200]}"
            )
        except Exception as e:
            logging.warning(f"Error checking today's comments for author {author_id}: {e}")
        return False

    def post_article(self, site: SiteConfig, token: str, character: Character, article: ArticleResponse, category_id: int, tag_ids: List[int], search_results: Optional[List[Dict]] = None, model=None, status: str = 'publish') -> Optional[Dict[str, Any]]:
        start_time = time.time()
        headers = {
            'Authorization': f'Bearer {token}'
        }

        if not category_id:
            category_id = 1
            logging.info(f'Category not found: {article.category}')

        logging.debug(f'NEW TITLE: {article.title}')
        logging.debug(f'BODY:  {article.body[:50]}...')
        logging.debug(f'CATEGORY:  {article.category}')
        logging.debug(f'TAGS:  {article.tags}')

        # Render markdown to HTML, then normalize inline citations to the
        # canonical superscript-anchor form (covers markdown citations that leak
        # when the model emits an HTML body).
        post_content = normalize_citations(convert_math(md.render(article.body)))

        model_to_note = model if model else character.model
        if model_to_note and model_to_note.name:
            footer_html = (
                '<p class="tsai-article-meta">'
                f'<em>Generated by AI &middot; {model_to_note.provider.value} &middot; {model_to_note.name}</em>'
                '</p>'
            )
            post_content += footer_html

        article_data = {
            'title': article.title,
            'author_name': character.name,
            'author_email': character.email,
            'content': post_content,
            'categories': [category_id],
            'tags': tag_ids,
            'status': status,
        }

        if character.wp_user:
            article_data['author'] = character.wp_user.id
        else:
            logging.info(
                f'No wp user found posting as anonymous for: {character.name}')

        article_data['meta'] = {
            '_tsai_quiz_enabled': True,
        }
        if search_results:
            article_data['meta']['_tsai_search_results'] = json.dumps(search_results)

        response = requests.post(
            site.wp_host + POSTS_URL, headers=headers, json=article_data)
        elapsed = time.time() - start_time

        if response.status_code == 201:
            logging.debug(f"WP API: post_article ({elapsed:.2f}s)")
            return response.json()
        else:
            logging.info(f'Failed to add article: {response.text} ({elapsed:.2f}s)')

    def get_categories(self, site: SiteConfig) -> Optional[List[Dict[str, Any]]]:
        start_time = time.time()
        endpoint = f'{site.wp_host + CATEGORIES_URL}?per_page=100'

        response = requests.get(endpoint)
        elapsed = time.time() - start_time

        if response.status_code == 200:
            categories = response.json()
            logging.debug(f"WP API: get_categories returned {len(categories)} categories ({elapsed:.2f}s)")
            return categories
        else:
            logging.info(f"Failed to fetch categories: {response.status_code} ({elapsed:.2f}s)")

    def find_category_by_name(self, categories: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
        if not name:
            return None
        target = html.unescape(name).strip().lower()
        for category in categories:
            cat_name = category.get("name")
            if cat_name and html.unescape(cat_name).strip().lower() == target:
                return category
        return None

    def create_category(self, site: SiteConfig, name: str) -> Optional[Dict[str, Any]]:
        token = self.get_jwt_token(site)
        if isinstance(token, dict):
            logging.error(f'Failed to get JWT token: {token}')
            return None
        headers = {'Authorization': f'Bearer {token}'}
        response = requests.post(
            site.wp_host + CATEGORIES_URL,
            json={'name': name},
            headers=headers
        )
        if response.status_code in (200, 201):
            return response.json()
        logging.error(f'Failed to create category "{name}": {response.status_code} {response.text}')
        return None

    def _find_wp_user_by_name(self, name: str, wp_user_data: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
        for user in wp_user_data:
            # logging.info(f'user name is {user["name"]}')
            if user['name'] == name:
                return user
        return None

    def add_to_character(self, site: SiteConfig, character: Character, wp_user_data: List[Dict[str, Any]]) -> None:
        my_wp_user = self._find_wp_user_by_name(character.name, wp_user_data)
        if my_wp_user:
            # Convert dict to WpUser for proper typing
            # WordPress API returns id as int, but WpUser expects str
            from ..appTypes import WpUser
            character.wp_user = WpUser(id=str(my_wp_user['id']), name=my_wp_user['name'])

            # Lazy populate tsai_character_uuid meta for legacy users that
            # predate the meta field, or fix it if the WP user got re-linked
            # to a different Drupal character.
            existing_uuid = ''
            user_meta = my_wp_user.get('meta') if isinstance(my_wp_user, dict) else None
            if isinstance(user_meta, dict):
                existing_uuid = user_meta.get('tsai_character_uuid') or ''
            if character.storageId and existing_uuid != character.storageId:
                self._ensure_character_uuid_meta(site, str(my_wp_user['id']), character.storageId)
        else:
            logging.info(f'wp account not found! {character.name} - attempting registration')

            # Get admin token
            admin_token = self.get_jwt_token(site)

            if admin_token and not isinstance(admin_token, dict):
                # Generate secure random password (16 characters)
                password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))

                # Register new user with author role
                result = self.register_user(
                    site=site,
                    token=admin_token,
                    username=character.name,
                    email=character.email,
                    password=password,
                    first_name=character.name,
                    last_name=None,
                    roles=['author'],
                    character_uuid=character.storageId,
                )

                if result['success']:
                    from ..appTypes import WpUser
                    character.wp_user = WpUser(
                        id=str(result['user_id']),
                        name=result['username']
                    )
                    logging.info(f'Successfully registered WP user for {character.name}')
                else:
                    logging.error(f'Registration failed for {character.name}: {result["message"]}')
            else:
                logging.error(f'Could not obtain admin token for registration')

    def _ensure_character_uuid_meta(self, site: SiteConfig, wp_user_id: str, character_uuid: str) -> None:
        """PATCH a WP user's tsai_character_uuid meta to the given UUID.

        Used to backfill the meta on the first time add_to_character runs
        against a user that already existed before the meta field landed.
        Requires the tsai-plugin to have registered the meta with
        show_in_rest=true.
        """
        admin_token = self.get_jwt_token(site)
        if not admin_token or isinstance(admin_token, dict):
            logging.error(f'Could not obtain admin token to set tsai_character_uuid meta for WP user {wp_user_id}')
            return

        url = f'{site.wp_host}{USERS_URL}/{wp_user_id}'
        headers = {
            'Authorization': f'Bearer {admin_token}',
            'Content-Type': 'application/json',
        }
        payload = {'meta': {'tsai_character_uuid': character_uuid}}
        try:
            response = requests.post(url, headers=headers, json=payload)
            if response.status_code in (200, 201):
                logging.info(f'Set tsai_character_uuid={character_uuid} on WP user {wp_user_id}')
            else:
                logging.error(f'Failed to set tsai_character_uuid on WP user {wp_user_id}: {response.status_code} {response.text}')
        except requests.exceptions.RequestException as e:
            logging.error(f'Network error setting tsai_character_uuid on WP user {wp_user_id}: {e}')

    def get_comments(self, site: SiteConfig, article_id: int) -> Optional[List[Dict[str, Any]]]:
        comments_endpoint = f'{site.wp_host + COMMENTS_URL}?post={article_id}&per_page=100'

        comments_response = requests.get(comments_endpoint)

        if comments_response.status_code == 200:
            comments = comments_response.json()
            return comments
        else:
            logging.info("Failed to fetch comments:",
                         comments_response.status_code)

    def post_comment(self, site: SiteConfig, character: Character, post_id: int, comment: str, token: str, parent_comment_id: Optional[int] = None, author_name_override: Optional[str] = None, author_email_override: Optional[str] = None) -> Optional[Dict[str, Any]]:
        start_time = time.time()
        headers = {
            'Authorization': f'Bearer {token}'
        }

        author_name = author_name_override if author_name_override else character.name
        author_email = author_email_override if author_email_override else character.email
        comment_content = comment

        comment_data = {
            'post': post_id,
            'parent': parent_comment_id,
            'author_name': author_name,
            'author_email': author_email,
            'content': comment_content,
        }

        if not author_name_override and character.wp_user and isinstance(character.wp_user, WpUser):
            comment_data['author'] = character.wp_user.id

        response = requests.post(
            site.wp_host + COMMENTS_URL, headers=headers, json=comment_data)
        elapsed = time.time() - start_time

        if response.status_code == 201:
            logging.debug(f"WP API: post_comment ({elapsed:.2f}s)")
            return response.json()
        else:
            logging.info(f'Failed to add comment: {response.text} ({elapsed:.2f}s)')
            return None

    def find_comment_by_id(self, comments: List[Dict[str, Any]], comment_id: int | str) -> Optional[Dict[str, Any]]:
        for comment in comments:
            if str(comment['id']) == str(comment_id):
                return comment

    def make_comment_thread(self, comment: Dict[str, Any], all_comments: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        thread = []
        thread.append(comment)

        my_parent_id = comment['parent']

        counter = 1
        max_depth = 5
        while my_parent_id:
            counter += 1
            parent_comment = self.find_comment_by_id(
                all_comments, my_parent_id)
            if not parent_comment:
                logging.info(f'parent not found for ID {my_parent_id}')
                my_parent_id = None
            else:
                thread.append(parent_comment)
                my_parent_id = parent_comment.get('parent')

            # Limit thread depth but keep context
            if counter > max_depth:
                logging.info(f'COMMENT THREAD LIMIT REACHED (depth > {max_depth})')
                logging.info(f'Returning most recent {max_depth} comments for context')
                # Return the most recent max_depth comments (including target)
                return thread[:max_depth]

        logging.info(f'Comment thread built: {len(thread)} comment(s) deep')
        return thread

    def extract_images(self, article: Dict[str, Any]) -> List[str]:
        images = []
        soup = BeautifulSoup(article['content']['rendered'], 'html.parser')

        my_featured_image = None
        try:
            sizes = article["_embedded"]["wp:featuredmedia"][0]["media_details"]["sizes"]
            for size_key in ("large", "medium_large", "medium", "full"):
                if size_key in sizes and sizes[size_key].get("source_url"):
                    my_featured_image = sizes[size_key]["source_url"]
                    logging.info(f'embed media ({size_key}): {my_featured_image}')
                    break
        except Exception:
            pass

        for img in soup.find_all('img'):
            # BeautifulSoup Tag objects support dictionary-style access
            # Type narrowing: check if it's a Tag (not NavigableString or other)
            from bs4 import Tag
            if isinstance(img, Tag):
                src = img.get('src')
                if src:
                    images.append(str(src))

        if my_featured_image:
            images.append(my_featured_image)
        logging.info(f'Image URLs: {images}')

        return images

    def post_featured_image(self, site: SiteConfig, token: str, post_id: int, media_id: int) -> str | int:
        start_time = time.time()
        headers = {
            'Authorization': f'Bearer {token}'
        }
        logging.debug(f'media id is  {media_id}')

        post_data = {
            'featured_media': media_id
        }

        response = requests.post(
            site.wp_host + POSTS_URL + '/' + str(post_id), headers=headers, json=post_data)
        elapsed = time.time() - start_time

        if response.status_code == 200:
            logging.debug(f"WP API: post_featured_image ({elapsed:.2f}s)")
            return "success"
        else:
            logging.info(f'Failed to add featured media: {response.text} ({elapsed:.2f}s)')
            return response.status_code

    def update_media_meta(self, site: SiteConfig, token: str, media_id: int,
                          caption: Optional[str] = None, alt_text: Optional[str] = None) -> bool:
        """Set the caption (post_excerpt) and/or alt text on a media attachment.

        The caption is what the featured-image plugin filter renders under the
        featured image, and it also feeds SEO/accessibility for both images.
        Best-effort: returns True on success, False on failure without raising,
        so a metadata hiccup never aborts an already-published article.
        """
        payload: Dict[str, Any] = {}
        if caption and caption.strip():
            payload['caption'] = caption.strip()
        if alt_text and alt_text.strip():
            payload['alt_text'] = alt_text.strip()
        if not payload:
            return True

        headers = {'Authorization': f'Bearer {token}'}
        try:
            response = requests.post(
                f'{site.wp_host}{MEDIA_URL}/{media_id}', headers=headers, json=payload)
            if response.status_code == 200:
                logging.info(f'Updated media {media_id} meta: {list(payload.keys())}')
                return True
            logging.warning(f'Failed to update media {media_id} meta: {response.status_code} {response.text}')
            return False
        except Exception as e:
            logging.warning(f'Error updating media {media_id} meta: {e}')
            return False

    def get_tags(self, site: SiteConfig, token: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
        start_time = time.time()
        # Include hide_empty=false to get ALL tags, including those with 0 posts
        endpoint = f'{site.wp_host + TAGS_URL}?per_page=100&hide_empty=false'

        headers = {}
        if token:
            headers['Authorization'] = f'Bearer {token}'

        response = requests.get(endpoint, headers=headers)
        elapsed = time.time() - start_time

        if response.status_code == 200:
            tags = response.json()
            logging.debug(f"WP API: get_tags returned {len(tags)} tags ({elapsed:.2f}s)")
            return tags
        else:
            logging.error(f"Failed to fetch tags - Status: {response.status_code} ({elapsed:.2f}s)")
            logging.error(f"Response: {response.text}")
            return None

    def post_tag(self, site: SiteConfig, token: str, tag_name: str) -> int:
        start_time = time.time()
        headers = {
            'Authorization': f'Bearer {token}'
        }

        post_data = {
            'name': tag_name,
        }

        try:
            response = requests.post(
                site.wp_host + TAGS_URL, headers=headers, json=post_data)
            elapsed = time.time() - start_time

            # SUCCESS: Tag created
            if response.status_code == 201:
                tag_info = response.json()
                tag_id = tag_info['id']
                logging.debug(f"WP API: post_tag created '{tag_name}' (ID: {tag_id}) ({elapsed:.2f}s)")
                return tag_id

            # RECOVERABLE: Tag already exists
            elif response.status_code == 400:
                try:
                    error_data = response.json()

                    # Check if this is a "term_exists" error
                    if error_data.get('code') == 'term_exists':
                        # Extract term_id from nested data structure
                        term_id = error_data.get('data', {}).get('term_id')

                        if term_id and isinstance(term_id, int):
                            logging.debug(f"WP API: post_tag found existing '{tag_name}' (ID: {term_id}) ({elapsed:.2f}s)")
                            return term_id
                        else:
                            # term_exists but no valid term_id - this is unusual
                            logging.error(f'WordPress returned term_exists but missing term_id: {error_data}')
                            raise HTTPException(
                                status_code=500,
                                detail=f'Failed to create tag "{tag_name}": WordPress returned term_exists but no valid term_id'
                            )
                    else:
                        # Other 400 error (validation, permissions, etc.)
                        error_message = error_data.get('message', response.text)
                        logging.error(f'Failed to create tag "{tag_name}": {error_message} ({elapsed:.2f}s)')
                        raise HTTPException(
                            status_code=400,
                            detail=f'Failed to create tag "{tag_name}": {error_message}'
                        )

                except (ValueError, KeyError, AttributeError) as parse_error:
                    # JSON parsing failed or malformed response
                    logging.error(f'Failed to parse WordPress error response for tag "{tag_name}": {str(parse_error)}')
                    logging.error(f'Response body: {response.text}')
                    raise HTTPException(
                        status_code=500,
                        detail=f'Failed to create tag "{tag_name}": Malformed WordPress response'
                    )

            # UNRECOVERABLE: Other HTTP errors (401, 403, 500, etc.)
            else:
                error_msg = f'Failed to create tag "{tag_name}" - Status: {response.status_code} ({elapsed:.2f}s)'
                logging.error(error_msg)
                logging.error(f'Response: {response.text}')
                raise HTTPException(
                    status_code=response.status_code,
                    detail=f'Failed to create tag "{tag_name}": WordPress returned {response.status_code}'
                )

        except requests.exceptions.RequestException as network_error:
            # Network errors (timeout, connection refused, DNS failure, etc.)
            logging.error(f'Network error creating tag "{tag_name}": {str(network_error)}')
            raise HTTPException(
                status_code=503,
                detail=f'Failed to create tag "{tag_name}": Network error - {str(network_error)}'
            )
        except HTTPException:
            # Re-raise HTTPExceptions (already handled above)
            raise
        except Exception as unexpected_error:
            # Catch-all for unexpected errors
            logging.error(f'Unexpected error creating tag "{tag_name}": {str(unexpected_error)}')
            raise HTTPException(
                status_code=500,
                detail=f'Failed to create tag "{tag_name}": Unexpected error'
            )

    def find_tag_by_name(self, tags: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
        for tag in tags:
            tag_name = tag.get("name")
            if tag_name and tag_name.lower() == name.lower():
                return tag

    def get_users(self, site: SiteConfig, token: str) -> Optional[List[Dict[str, Any]]]:
        start_time = time.time()
        headers = {'Authorization': f'Bearer {token}'}

        # context=edit exposes registered user meta (e.g. tsai_character_uuid)
        # so callers like add_to_character can skip redundant PATCHes.
        url = site.wp_host + USERS_URL + '?per_page=100&context=edit'
        response = requests.get(url, headers=headers)
        elapsed = time.time() - start_time

        if response.status_code == 200:
            users = response.json()
            logging.debug(f"WP API: get_users returned {len(users)} users ({elapsed:.2f}s)")
            return users
        else:
            logging.info(f"Error: {response.status_code}, {response.text} ({elapsed:.2f}s)")

    # --- Article Series methods ---

    def get_series_terms(self, site: SiteConfig, token: str) -> Optional[List[Dict[str, Any]]]:
        """Get all article_series taxonomy terms"""
        start_time = time.time()
        headers = {'Authorization': f'Bearer {token}'}
        params = {'per_page': 100, 'hide_empty': False}

        response = requests.get(site.wp_host + ARTICLE_SERIES_URL, headers=headers, params=params)
        elapsed = time.time() - start_time

        if response.status_code == 200:
            terms = response.json()
            logging.debug(f"WP API: get_series_terms returned {len(terms)} terms ({elapsed:.2f}s)")
            return terms
        else:
            logging.error(f"Failed to fetch series terms: {response.status_code} {response.text} ({elapsed:.2f}s)")
            return None

    def get_series_articles(self, site: SiteConfig, token: str, series_term_id: int) -> Optional[List[Dict[str, Any]]]:
        """Get all posts in a series, ordered by date ascending, with _embed for featured images"""
        start_time = time.time()
        headers = {'Authorization': f'Bearer {token}'}
        params = {
            'article_series': series_term_id,
            '_embed': 'true',
            'per_page': 100,
            'orderby': 'date',
            'order': 'asc',
            'status': 'publish',
        }

        response = requests.get(site.wp_host + POSTS_URL, headers=headers, params=params)
        elapsed = time.time() - start_time

        if response.status_code == 200:
            articles = response.json()
            logging.debug(f"WP API: get_series_articles returned {len(articles)} articles ({elapsed:.2f}s)")
            return articles
        else:
            logging.error(f"Failed to fetch series articles: {response.status_code} {response.text} ({elapsed:.2f}s)")
            return None

    def create_series_term(self, site: SiteConfig, token: str, name: str) -> Optional[int]:
        """Create a new article_series taxonomy term, returns term ID"""
        start_time = time.time()
        headers = {'Authorization': f'Bearer {token}'}
        data = {'name': name}

        response = requests.post(site.wp_host + ARTICLE_SERIES_URL, headers=headers, json=data)
        elapsed = time.time() - start_time

        if response.status_code == 201:
            term = response.json()
            term_id = term['id']
            logging.debug(f"WP API: create_series_term '{name}' (ID: {term_id}) ({elapsed:.2f}s)")
            return term_id
        elif response.status_code == 400:
            # Term may already exist
            try:
                error_data = response.json()
                if error_data.get('code') == 'term_exists':
                    term_id = error_data.get('data', {}).get('term_id')
                    if term_id:
                        logging.debug(f"WP API: series term '{name}' already exists (ID: {term_id}) ({elapsed:.2f}s)")
                        return term_id
            except (ValueError, KeyError):
                pass
            logging.error(f"Failed to create series term '{name}': {response.text} ({elapsed:.2f}s)")
            return None
        else:
            logging.error(f"Failed to create series term '{name}': {response.status_code} {response.text} ({elapsed:.2f}s)")
            return None

    def set_post_series(self, site: SiteConfig, token: str, post_id: int, term_id: int, part_number: int, summary: str) -> bool:
        """Update a post with series term, part number, and summary meta"""
        start_time = time.time()
        headers = {'Authorization': f'Bearer {token}'}

        post_data = {
            'article_series': [term_id],
            'meta': {
                '_series_part_number': part_number,
                '_series_summary': summary,
            }
        }

        response = requests.post(
            site.wp_host + POSTS_URL + '/' + str(post_id), headers=headers, json=post_data)
        elapsed = time.time() - start_time

        if response.status_code == 200:
            logging.debug(f"WP API: set_post_series post {post_id} -> series {term_id}, part {part_number} ({elapsed:.2f}s)")
            return True
        else:
            logging.error(f"Failed to set post series: {response.status_code} {response.text} ({elapsed:.2f}s)")
            return False

    def register_user(self, site: SiteConfig, token: str, username: str, email: str, password: str, first_name: Optional[str] = None, last_name: Optional[str] = None, roles: Optional[List[str]] = None, character_uuid: Optional[str] = None) -> Dict[str, Any]:
        """
        Register a new user on WordPress site using admin credentials.

        Args:
            site: SiteConfig object with WordPress host information
            token: Admin JWT token for authentication
            username: Desired username (must be unique)
            email: User's email address (must be unique and valid)
            password: User's password
            first_name: Optional first name
            last_name: Optional last name
            roles: Optional list of role names (default: ['subscriber'])
            character_uuid: Optional Drupal character UUID stored on the user's
                tsai_character_uuid meta field. Requires the WP plugin to have
                that meta registered with show_in_rest.

        Returns:
            Dict with 'success', 'user_id', 'username', 'email', and 'message' keys

        Raises:
            HTTPException: If registration fails
        """
        headers = {
            'Authorization': f'Bearer {token}',
            'Content-Type': 'application/json'
        }

        user_data = {
            'username': username,
            'email': email,
            'password': password,
            'roles': roles if roles is not None else ['subscriber']  # Default to subscriber role for security
        }

        if first_name:
            user_data['first_name'] = first_name
        if last_name:
            user_data['last_name'] = last_name
        if character_uuid:
            user_data['meta'] = {'tsai_character_uuid': character_uuid}

        url = site.wp_host + USERS_URL
        logging.info(f'Attempting to register user: {username} at {url}')

        try:
            response = requests.post(url, headers=headers, json=user_data)

            if response.status_code == 201:
                user_info = response.json()
                logging.info(f'User registered successfully: {username} (ID: {user_info.get("id")})')
                return {
                    'success': True,
                    'user_id': user_info.get('id'),
                    'username': user_info.get('username'),
                    'email': user_info.get('email'),
                    'message': 'User registered successfully'
                }
            else:
                error_message = response.text
                logging.error(f'User registration failed: {response.status_code} {error_message}')

                # Parse common WordPress errors
                if response.status_code == 400:
                    try:
                        error_data = response.json()
                        error_message = error_data.get('message', error_message)
                    except:
                        pass

                return {
                    'success': False,
                    'user_id': None,
                    'username': None,
                    'email': None,
                    'message': f'Registration failed: {error_message}'
                }
        except requests.exceptions.RequestException as e:
            logging.error(f'Network error during user registration: {str(e)}')
            return {
                'success': False,
                'user_id': None,
                'username': None,
                'email': None,
                'message': f'Network error: {str(e)}'
            }

    def get_new_article_system_message(self, character: Character, categories: List[Dict[str, Any]], tags: List[Dict[str, Any]], character_description_override: Optional[str] = None, include_previous_articles: bool = True, series_context: Optional[Dict[str, Any]] = None) -> str:

        # Section 1: Introduction
        intro = "# Article Writing Task\n\n"
        intro += "You are writing an article for a website. Please create engaging, well-researched content that matches your unique voice and expertise.\n\n"
        intro += "## Body Formatting\n\n"
        intro += "**IMPORTANT:** Do not repeat the article title at the top of the body. The `title` field is rendered separately by the site, so starting the `body` with the title (as a heading, bold text, or plain text) causes it to appear twice. Begin the body directly with the article's opening content.\n\n"
        intro += "**IMPORTANT:** Do not include any `<img>` or `<figure>` tags in the body. The featured image and secondary image are generated and inserted automatically by the publishing pipeline; any image tags you write will render as broken images. If a previous part's content (shown later in this prompt) contains `<figure>` blocks, those were inserted by the pipeline after the fact — do not imitate them.\n\n"

        # Section 2: Author Information (use override if provided, otherwise character description)
        author_info = f"## Author Information\n\n"
        author_info += f"**Your Name:** {character.name}\n\n"
        author_info += f"**About You:**\n"
        author_info += character_description_override if character_description_override else character.description
        author_info += "\n\n"

        # Section 3: Previous Articles (conditional - based on include_previous_articles flag)
        previous_articles = ""
        if include_previous_articles:
            if character.articles and len(character.articles) > 0:
                articles_list = "\n".join([
                    f"- **{article['title']['rendered']}** _(Published: {article['date']})_"
                    for article in character.articles
                ])
                previous_articles = "## Previous Articles\n\n"
                previous_articles += "You have written the following articles:\n\n"
                previous_articles += f"{articles_list}\n\n"
            else:
                previous_articles = "## Previous Articles\n\n"
                previous_articles += "This will be your first article for this website.\n\n"

        # Section 3.5: Article Series (between Previous Articles and Category Selection)
        series_section = ""
        if series_context:
            series_section = "## Article Series\n\n"
            series_section += f"**Series Name:** {series_context['series_name']}\n\n"
            series_section += f"**This is Part {series_context['part_number']}** of an ongoing series.\n\n"

            # Previous part summaries
            if series_context.get('previous_parts'):
                series_section += "### Previous Parts\n\n"
                for part in series_context['previous_parts']:
                    series_section += f"**Part {part['part_number']}: {part['title']}**\n"
                    if part.get('summary'):
                        series_section += f"Summary: {part['summary']}\n\n"
                    else:
                        series_section += "\n"

            # Full content of the most recent part
            if series_context.get('latest_part_content'):
                series_section += "### Most Recent Part (Full Content)\n\n"
                series_section += f"{series_context['latest_part_content']}\n\n"

            # Series continuity instructions
            series_section += "### Series Continuity Instructions\n\n"
            series_section += "- Maintain consistency with the characters, setting, tone, and narrative established in previous parts\n"
            series_section += "- Advance the story/topic naturally — don't just recap\n"
            series_section += "- Reference events and details from previous parts where appropriate\n"
            series_section += "- The `series_summary` field in your response should be a 2-3 sentence summary of THIS part for future installments\n\n"

        # Section 4: Category Selection
        if series_context and series_context.get('series_category'):
            # Override category for series articles
            category_section = "## Category Selection\n\n"
            category_section += f"**IMPORTANT:** This article is part of a series. Use the same category as previous parts: **{series_context['series_category']}**\n\n"
        elif categories and len(categories) > 0:
            category_list = "\n".join([f"- {html.unescape(cat['name'])}" for cat in categories])
            category_section = "## Category Selection\n\n"
            category_section += "**IMPORTANT:** You must choose **exactly one category** from the list below:\n\n"
            category_section += f"{category_list}\n\n"
        else:
            category_section = "## Category Selection\n\n"
            category_section += "**WARNING:** No categories available. Please use 'Uncategorized'.\n\n"

        # Section 5: Tag Selection
        if series_context and series_context.get('series_tags'):
            # Override tags for series articles
            tag_list_str = ", ".join(series_context['series_tags'])
            tag_section = "## Tag Selection\n\n"
            tag_section += f"**Series Tags:** Use these tags from the series: {tag_list_str}\n"
            tag_section += "- You may add 1-2 additional tags if relevant to this specific part\n\n"
        elif tags and len(tags) > 0:
            tag_list = "\n".join([f"- {tag['name']}" for tag in tags])
            tag_section = "## Tag Selection\n\n"
            tag_section += "**Tagging Requirements:**\n"
            tag_section += "- Choose **2-3 tags** for your article\n"
            tag_section += "- Prefer existing tags when relevant\n"
            tag_section += "- You may create new tags if none fit well\n\n"
            tag_section += f"**Available Tags:**\n{tag_list}\n\n"
        else:
            tag_section = "## Tag Selection\n\n"
            tag_section += "**NOTE:** No existing tags. Please create 2-3 appropriate tags for your article.\n\n"

        # Combine all sections
        txt = intro + author_info + previous_articles + series_section + category_section + tag_section

        # Log instructions to file with metadata
        timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        log_content = f"""# Article Creation Instructions
Generated: {timestamp}
Character: {character.name}

## System Message

{txt}"""

        with open('logs/article-instructions.md', 'w') as file:
            file.write(log_content)

        return txt


    async def create_image(
        self, site: SiteConfig, article: ArticleResponse,
        reference_images: Optional[list[str]] = None,
        seed_image_instructions: Optional[str] = None,
    ) -> tuple[str, Optional[str]]:

        # Prefer the description the article LLM already wrote — in series mode
        # it was generated with the prior parts' images in view, so it already
        # carries the visual-continuity intent. Fall back to deriving a fresh
        # prompt from the article body only when no description is available.
        if article.featured_image_description and article.featured_image_description.strip():
            image_prompt_str = article.featured_image_description.strip()
            logging.info('[image] using article.featured_image_description as featured image prompt')
        else:
            content = f'Title: {article.title}\n\n{article.body}'
            instructions = (
                'You will be provided an article.  It could be about anything. '
                ' Your job is to read the article and create a prompt for generating a featured '
                'image for the article.'
            )
            image_prompt_response = chat_service.chat(instructions, content)
            image_prompt_str = str(image_prompt_response) if not isinstance(image_prompt_response, str) else image_prompt_response

        if reference_images:
            image_prompt_str = _series_style_lock_preamble(len(reference_images)) + image_prompt_str

        # If the caller supplied a user-authored seed instruction, prepend it
        # ahead of the style-lock preamble so the image model sees the user's
        # intent (e.g. "use this person as the main character") first.
        seed_text = (seed_image_instructions or '').strip()
        if seed_text:
            seed_preamble = (
                'The first reference image is a user-supplied seed image. '
                f"Apply the user's instructions for it: {seed_text}\n\n"
            )
            image_prompt_str = seed_preamble + image_prompt_str

        logging.info(f'Prompt: {image_prompt_str}')

        if _use_image_v2():
            url, base64_data = await image_v2_service.create_image_for_article(
                image_prompt_str, site, input_images=reference_images or None,
            )
            return url, base64_data

        # Deprecated: gpt-image-1.5 fallback (ARTICLE_IMAGE_API=v1)
        logging.info('[gpt-image-1.5] article image (legacy fallback path)')
        urls = await image_service.create_image(image_prompt_str, site)
        if not urls or len(urls) == 0:
            logging.error('No image URLs returned from image service.')
            raise HTTPException(
                500, 'No image URLs returned from image service.')

        base64_data = image_service.get_last_generated_base64()
        return urls[0], base64_data

    async def create_secondary_image(
        self, site: SiteConfig, article: ArticleResponse,
        featured_image_base64: Optional[str] = None,
        reference_images: Optional[list[str]] = None,
    ) -> str:
        """Create a secondary image that complements the featured image.

        When `reference_images` (series prior-part URLs/base64) are provided, they
        are passed to the image model alongside `featured_image_base64` so style/
        setting continuity is anchored visually, not just textually. A style-lock
        preamble is prepended to the prompt in that case.
        """

        content = f'Title: {article.title}\n\n{article.body}'

        if _use_image_v2():
            # Prefer the description the article LLM wrote (with series images in view).
            if article.secondary_image_description and article.secondary_image_description.strip():
                image_prompt_str = article.secondary_image_description.strip()
                logging.info('[image] using article.secondary_image_description as secondary image prompt')
            else:
                instructions = (
                    'You will be provided an article. Your job is to read the article and create a prompt '
                    'for generating a secondary image that complements the article content. '
                    'The image should be visually interesting and thematically relevant. '
                    'Think in terms of mood, color, texture, and visual elements that evoke the themes '
                    'of the article. '
                    'Create a detailed prompt for generating this complementary image.'
                )
                image_prompt_response = chat_service.chat(instructions, content)
                image_prompt_str = str(image_prompt_response) if not isinstance(
                    image_prompt_response, str) else image_prompt_response

            # Build input_images: current featured first (for in-article composition
            # continuity), then series references (for cross-part style continuity).
            input_images: list[str] = []
            if featured_image_base64:
                input_images.append(featured_image_base64)
            if reference_images:
                for ref in reference_images:
                    if ref and ref not in input_images:
                        input_images.append(ref)

            if reference_images:
                image_prompt_str = _series_style_lock_preamble(len(reference_images)) + image_prompt_str

            logging.info(f'Secondary Image Prompt: {image_prompt_str}')

            url, _ = await image_v2_service.create_image_for_article(
                image_prompt_str, site, input_images=input_images or None,
            )
            return url

        # Deprecated: gpt-image-1.5 fallback (ARTICLE_IMAGE_API=v1)
        logging.info('[gpt-image-1.5] article image (legacy fallback path)')
        if featured_image_base64:
            base64_size_kb = len(featured_image_base64) / 1024
            logging.info(f'Using vision analysis for secondary image. Featured image base64 size: {base64_size_kb:.2f} KB')

            from langchain_openai import ChatOpenAI
            from langchain_core.messages import SystemMessage, HumanMessage
            from typing import cast

            instructions = (
                'You will be provided an article and the featured image that was generated for it. '
                'Your job is to analyze the featured image and create a prompt for generating a '
                'secondary image that complements it. '
                'The secondary image should share similar mood, color palette, and thematic elements with '
                'the featured image, but show different content or perspective. '
                'Think in terms of visual harmony - if the featured image has warm sunset colors, the '
                'secondary might use similar warm tones. If the featured image shows an outdoor scene, '
                'the secondary might show a related but different outdoor perspective. '
                'Avoid duplicating the same subject or composition. '
                'Create a detailed prompt for generating this complementary image.'
            )

            vision_content: list[dict[str, Any]] = [{"type": "text", "text": content}]

            from app.services.image import get_default_image_format
            mime_type = f"image/{get_default_image_format()}"
            data_uri = f"data:{mime_type};base64,{featured_image_base64}"
            vision_content.append({
                "type": "image_url",
                "image_url": {"url": data_uri}
            })

            vision_model = ChatOpenAI(model="gpt-4o-mini")
            sys_msg = SystemMessage(content=instructions)
            human_msg = HumanMessage(content=cast(list[str | dict[str, Any]], vision_content))

            response = vision_model.invoke([sys_msg, human_msg])
            image_prompt_str = str(response.content) if not isinstance(
                response.content, str) else response.content
        else:
            logging.info('No featured image base64 data available - using text-only approach for secondary image')
            instructions = (
                'You will be provided an article. Your job is to read the article and create a prompt '
                'for generating a secondary image that complements the article content. '
                'The image should be visually interesting and thematically relevant. '
                'Think in terms of mood, color, texture, and visual elements that evoke the themes '
                'of the article. '
                'Create a detailed prompt for generating this complementary image.'
            )

            image_prompt_response = chat_service.chat(instructions, content)
            image_prompt_str = str(image_prompt_response) if not isinstance(
                image_prompt_response, str) else image_prompt_response

        logging.info(f'Secondary Image Prompt: {image_prompt_str}')

        urls = await image_service.create_image(image_prompt_str, site)
        if not urls or len(urls) == 0:
            logging.error('No secondary image URLs returned from image service.')
            raise HTTPException(
                500, 'No secondary image URLs returned from image service.')

        return urls[0]

    def insert_image_into_content(self, content: str, image_id: int, image_url: str, alt_text: str = "", caption: str = "") -> str:
        """Insert a 50% width right-floated image figure after the second paragraph.

        When `caption` is provided, a <figcaption> is rendered inside the figure so
        the caption displays under the image (block themes style
        .wp-block-image figcaption automatically).
        """

        # Parse HTML content
        soup = BeautifulSoup(content, 'html.parser')

        # Find all paragraphs
        paragraphs = soup.find_all('p')

        # Optional caption rendered under the image
        figcaption_html = ''
        if caption and caption.strip():
            safe_caption = html.escape(caption.strip())
            figcaption_html = f'\n  <figcaption class="wp-element-caption">{safe_caption}</figcaption>'

        # Create the figure HTML - 50% width, floated right
        figure_html = f'''<figure class="wp-block-image alignright size-large" style="width: 50%; float: right; margin: 1em 0 1em 1em;">
  <img src="{image_url}" alt="{alt_text}" class="wp-image-{image_id}" style="width: 100%; height: auto;" />{figcaption_html}
</figure>'''

        if len(paragraphs) >= 2:
            # Insert after the second paragraph for better content flow
            second_p = paragraphs[1]
            figure_tag = BeautifulSoup(figure_html, 'html.parser')
            second_p.insert_after(figure_tag)
            logging.info(f'Inserted image after second paragraph')
        elif len(paragraphs) == 1:
            # If only one paragraph, insert after it
            first_p = paragraphs[0]
            figure_tag = BeautifulSoup(figure_html, 'html.parser')
            first_p.insert_after(figure_tag)
            logging.info(f'Inserted image after first paragraph (only one paragraph found)')
        else:
            # If no paragraphs found, prepend the figure
            logging.warning('No paragraphs found in content, prepending image')
            return figure_html + content

        return str(soup)

    def update_post_content(self, site: SiteConfig, token: str, post_id: int, content: str) -> Dict[str, Any]:
        """Update the content of an existing WordPress post"""

        headers = {
            'Authorization': f'Bearer {token}'
        }

        # Normalize inline citations and convert any leaked TeX math so revised
        # bodies (e.g. from corrections, or the secondary-image insert path,
        # written back as HTML with no markdown rendering) stay consistent.
        post_data = {
            'content': normalize_citations(convert_math(content))
        }

        response = requests.post(
            site.wp_host + POSTS_URL + '/' + str(post_id), headers=headers, json=post_data)

        if response.status_code == 200:
            logging.info(f'Post content updated successfully for post {post_id}')
            return response.json()
        else:
            logging.error(f"Failed to update post content: {response.status_code} {response.text}")
            raise HTTPException(response.status_code, f"Failed to update post content: {response.text}")

    def update_post_status(self, site: SiteConfig, token: str, post_id: int, status: str) -> Dict[str, Any]:
        """Change an existing post's status (e.g. 'draft' -> 'publish')."""

        headers = {'Authorization': f'Bearer {token}'}
        response = requests.post(
            site.wp_host + POSTS_URL + '/' + str(post_id),
            headers=headers, json={'status': status})

        if response.status_code == 200:
            logging.info(f'Post {post_id} status set to {status}')
            return response.json()
        logging.error(f"Failed to update post status: {response.status_code} {response.text}")
        raise HTTPException(response.status_code, f"Failed to update post status: {response.text}")

    # DISABLED: This method is no longer used - AI now includes citations directly in article body
    # Kept here in case we want to re-enable automatic references section in the future
    def build_references_section(self, search_results: List[Dict]) -> str:
        """Build HTML references section from Tavily search results

        NOTE: Currently disabled - AI generates citations within article body

        Args:
            search_results: List of dicts with title, url, content, score, published_date

        Returns:
            HTML string with styled references section
        """
        if not search_results or len(search_results) == 0:
            return ""

        from urllib.parse import urlparse

        # Start references section with styled container
        html = '''
    <section class="article-references" style="
        margin-top: 2.5rem;
        padding: 1.5rem 2rem;
        background-color: #f8f9fa;
        border-left: 4px solid #1976d2;
        border-radius: 4px;
    ">
        <h3 style="
            color: #212121;
            font-weight: 600;
            margin-top: 0;
            margin-bottom: 1rem;
            font-size: 1.3rem;
        ">Sources &amp; References</h3>
        <ol style="
            color: #424242;
            line-height: 1.6;
            padding-left: 1.5rem;
            margin: 0;
        ">
    '''

        for result in search_results:
            title = result.get('title', 'Untitled Source')
            url = result.get('url', '#')
            content = result.get('content', '')
            score = result.get('score', 0.0)
            published_date = result.get('published_date', '')

            # Extract domain from URL
            try:
                parsed_url = urlparse(url)
                domain = parsed_url.netloc.replace('www.', '')
            except:
                domain = 'Unknown source'

            # Truncate excerpt to 150 characters for more compact display
            excerpt = content[:150] + '...' if len(content) > 150 else content

            # Build list item with all info on fewer lines
            score_percentage = int(score * 100)

            html += f'''
        <li style="margin-bottom: 1rem;"><a href="{url}" target="_blank" rel="noopener noreferrer"
               style="
                   color: #1976d2;
                   text-decoration: none;
                   font-weight: 500;
                   font-size: 1.05rem;
               ">
                {title}
            </a>
            <br>
            <span style="
                color: #757575;
                font-size: 0.85rem;
            "><strong>{domain}</strong>'''

            # Add publication date and relevance on same line
            if published_date:
                html += f' • Published: {published_date}'

            html += f' • Relevance: {score_percentage}%'

            html += '</span>'

            # Add excerpt if available
            if excerpt:
                html += f'''
            <br>
            <span style="
                color: #616161;
                font-size: 0.9rem;
                font-style: italic;
            ">"{excerpt}"</span>'''

            html += '</li>\n'

        # Close section
        html += '''
        </ol>
    </section>
    '''

        return html

Youez - 2016 - github.com/yon3zu
LinuXploit