| Server IP : 3.147.158.171 / Your IP : 216.73.216.88 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/www/html/api.turmansolutions.ai/app/services/ |
Upload File : |
import 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 · {model_to_note.provider.value} · {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 & 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