| Server IP : 3.147.158.171 / Your IP : 216.73.216.216 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.johnturman.net/ |
Upload File : |
"""
Character Creation Script
Creates a character on a site by calling the local FastAPI endpoint.
Usage:
python create_character.py <name> <site> [options]
Arguments:
name Character name
site Site slug (e.g., jpt, tsai, sandbox)
Options:
--env <env> Environment (default: TSAI_ENV or development)
--email <email> Character email (default: <name>@example.com)
--description <text> Character description / system prompt
--provider <provider> OpenAI or Claude (default: Claude)
--model-name <name> Model name (default: claude-sonnet-4-5-20250929)
--temperature <t> Temperature (default: 0.7)
--max-tokens <n> Max tokens (default: 4096)
--disabled Create the character as disabled
Schedule Options (article and comment calendar schedules):
--article-days-of-week <d> Comma-separated days (0=Sun..6=Sat), e.g. "1,3,5"
--article-days-of-month <d> Comma-separated days (1-31), e.g. "1,15"
--article-preferred-hours <h> Comma-separated hours (0-23), e.g. "9,14"
--comment-days-of-week <d> Comma-separated days (0=Sun..6=Sat)
--comment-days-of-month <d> Comma-separated days (1-31)
--comment-preferred-hours <h> Comma-separated hours (0-23)
A character only posts when it has a schedule whose day constraints match.
Examples:
python create_character.py tester jpt
python create_character.py "AI Researcher" jpt --article-days-of-week "1,3,5" --description "You research tech trends."
python create_character.py blogger tsai --provider OpenAI --model-name gpt-4.1
python create_character.py scheduler jpt --article-days-of-month "1,15"
Tavily Search Options:
--research Enable Tavily web search for article research
--search-query <query> Search query (required when --research is set)
--search-domains-included <d> Comma-separated domains to include (e.g. "nytimes.com,bbc.com")
--search-domains-excluded <d> Comma-separated domains to exclude (e.g. "reddit.com")
--search-topic-type <type> Topic type: general, news (default: general)
--search-time-range <range> Time range: day, week, month, year (default: all time)
--max-search-results <n> Max results 1-20 (default: 5)
Examples:
python create_character.py researcher jpt --research --search-query "latest AI news" --search-topic-type news
"""
import argparse
import json
import os
import re
import secrets
import string
import sys
from datetime import date, datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / '.env')
import requests
def parse_int_list(value: str) -> list[int]:
"""Parse a comma-separated string of integers."""
return [int(x.strip()) for x in value.split(',')]
def build_schedule(days_of_week, days_of_month, preferred_hours):
"""Build a ContentSchedule dict if any schedule options are set."""
if not any([days_of_week, days_of_month, preferred_hours]):
return None
schedule = {}
if days_of_week is not None:
schedule["daysOfWeek"] = parse_int_list(days_of_week)
if days_of_month is not None:
schedule["daysOfMonth"] = parse_int_list(days_of_month)
if preferred_hours is not None:
schedule["preferredHours"] = parse_int_list(preferred_hours)
return schedule
def load_site_config(site_slug: str) -> str:
"""Return cb_host for the given site slug."""
from app.services.site_config_service import SiteConfigService
service = SiteConfigService()
site = service.get_site_by_slug(site_slug)
if site:
return site.cb_host
all_slugs = sorted(s.slug for s in service.get_all_sites())
print(f"Error: site '{site_slug}' not found")
print(f"Available sites: {', '.join(all_slugs)}")
sys.exit(1)
def resolve_unique_slug(base: str, cb_host: str, max_attempts: int = 50) -> str:
"""Walk `${base}`, `${base}-v2`, `${base}-v3`, ... until the API reports
the slug is available for the requesting site. Numbering starts at v2
because the original (un-suffixed) slug is conceptually v1. Falls back to
the last candidate after max_attempts to avoid runaway loops."""
url_template = "http://127.0.0.1:8000/api/v1/cms/character/slug-available/{slug}"
headers = {"Origin": f"https://{cb_host}"}
candidate = base
for attempt in range(max_attempts + 1):
try:
response = requests.get(url_template.format(slug=candidate), headers=headers, timeout=10)
response.raise_for_status()
if response.json().get("available"):
return candidate
except requests.RequestException as e:
print(f" Warning: slug availability check failed ({e}); proceeding with '{candidate}'")
return candidate
candidate = f"{base}-v{attempt + 2}"
return candidate
def main():
parser = argparse.ArgumentParser(description="Create a character via the FastAPI endpoint")
parser.add_argument("name", help="Character name")
parser.add_argument("site", help="Site slug (e.g., jpt, tsai)")
parser.add_argument("--email", default=None, help="Character email (default: <name>@example.com)")
parser.add_argument("--description", default="You are a helpful assistant.",
help="Character description / system prompt")
parser.add_argument("--provider", default="Claude", choices=["OpenAI", "Claude"],
help="LLM provider (default: Claude)")
parser.add_argument("--model-name", default="claude-sonnet-4-5-20250929",
help="Model name (default: claude-sonnet-4-5-20250929)")
parser.add_argument("--temperature", type=float, default=0.7, help="Temperature (default: 0.7)")
parser.add_argument("--max-tokens", type=int, default=16384, help="Max tokens (default: 16384)")
parser.add_argument("--slug", default=None, help="URL slug (default: auto-generated from name)")
parser.add_argument("--disabled", action="store_true", help="Create the character as disabled")
parser.add_argument("--env", default=os.getenv("TSAI_ENV", "development"),
choices=["development", "production", "scike_ai"],
help="Environment (default: TSAI_ENV or development)")
parser.add_argument("--skip-wp-registration", action="store_true",
help="Skip WordPress author user registration")
# Article schedule
parser.add_argument("--article-days-of-week", default=None,
help="Article days of week (comma-separated, 0=Sun..6=Sat)")
parser.add_argument("--article-days-of-month", default=None,
help="Article days of month (comma-separated, 1-31)")
parser.add_argument("--article-preferred-hours", default=None,
help="Article preferred hours (comma-separated, 0-23)")
# Comment schedule
parser.add_argument("--comment-days-of-week", default=None,
help="Comment days of week (comma-separated, 0=Sun..6=Sat)")
parser.add_argument("--comment-days-of-month", default=None,
help="Comment days of month (comma-separated, 1-31)")
parser.add_argument("--comment-preferred-hours", default=None,
help="Comment preferred hours (comma-separated, 0-23)")
# Tavily search options
parser.add_argument("--research", action="store_true",
help="Enable Tavily web search for article research")
parser.add_argument("--search-query", default=None,
help="Search query (required when --research is set)")
parser.add_argument("--search-domains-included", default=None,
help="Comma-separated domains to include (e.g. \"nytimes.com,bbc.com\")")
parser.add_argument("--search-domains-excluded", default=None,
help="Comma-separated domains to exclude (e.g. \"reddit.com\")")
parser.add_argument("--search-topic-type", default="general",
choices=["general", "news"],
help="Topic type (default: general)")
parser.add_argument("--search-time-range", default=None,
choices=["day", "week", "month", "year"],
help="Time range (default: all time)")
parser.add_argument("--max-search-results", type=int, default=5,
help="Max search results 1-20 (default: 5)")
args = parser.parse_args()
# Validate search options
if args.research and not args.search_query:
parser.error("--search-query is required when --research is set")
if args.max_search_results < 1 or args.max_search_results > 20:
parser.error("--max-search-results must be between 1 and 20")
email = args.email or f"{args.name.lower().replace(' ', '.')}@example.com"
base_slug = args.slug or re.sub(r'[^a-z0-9]+', '-', args.name.lower()).strip('-')
# Build schedules
article_schedule = build_schedule(
args.article_days_of_week,
args.article_days_of_month, args.article_preferred_hours)
comment_schedule = build_schedule(
args.comment_days_of_week,
args.comment_days_of_month, args.comment_preferred_hours)
# Look up cb_host (used as Origin header for site auth)
os.environ['TSAI_ENV'] = args.env
cb_host = load_site_config(args.site)
# Resolve a unique slug for this site by walking -v1, -v2, ... if needed.
slug = resolve_unique_slug(base_slug, cb_host)
if slug != base_slug:
print(f" Slug '{base_slug}' already taken — using '{slug}' instead.")
# Build request body
character = {
"name": args.name,
"slug": slug,
"email": email,
"description": args.description,
"enabled": not args.disabled,
"date": str(date.today()),
"model": {
"provider": args.provider,
"name": args.model_name,
"temperature": args.temperature,
"max_tokens": args.max_tokens,
"stream": True
}
}
if article_schedule:
character["articleSchedule"] = article_schedule
if comment_schedule:
character["commentSchedule"] = comment_schedule
# Tavily search fields
if args.research:
character["researchCurrentEvents"] = True
character["searchQuery"] = args.search_query
character["searchTopicType"] = args.search_topic_type
character["maxSearchResults"] = args.max_search_results
if args.search_domains_included:
character["searchDomainsIncluded"] = [
d.strip() for d in args.search_domains_included.split(',')]
if args.search_domains_excluded:
character["searchDomainsExcluded"] = [
d.strip() for d in args.search_domains_excluded.split(',')]
if args.search_time_range:
character["searchTimeRange"] = args.search_time_range
character["history"] = [
{"timestamp": datetime.now(timezone.utc).isoformat(), "description": "Created programmatically"}
]
body = {"character": character}
url = "http://127.0.0.1:8000/api/v1/cms/character/"
headers = {
"Origin": f"https://{cb_host}",
"Content-Type": "application/json"
}
print(f"Creating character '{args.name}' on site '{args.site}'...")
print(f" Provider: {args.provider}, Model: {args.model_name}")
print(f" Email: {email}")
print(f" Enabled: {not args.disabled}")
if article_schedule:
print(f" Article schedule: {article_schedule}")
if comment_schedule:
print(f" Comment schedule: {comment_schedule}")
if args.research:
print(f" Research: enabled")
print(f" Search query: {args.search_query}")
print(f" Topic type: {args.search_topic_type}, Max results: {args.max_search_results}")
if args.search_time_range:
print(f" Time range: {args.search_time_range}")
if args.search_domains_included:
print(f" Domains included: {args.search_domains_included}")
if args.search_domains_excluded:
print(f" Domains excluded: {args.search_domains_excluded}")
try:
response = requests.post(url, json=body, headers=headers, timeout=30)
except requests.ConnectionError:
print("Error: Could not connect to FastAPI server at http://127.0.0.1:8000")
print("Make sure the server is running: cd api && uvicorn app.main:app --reload")
sys.exit(1)
if response.status_code == 200:
print(f"Character created successfully!")
data = response.json()
print(json.dumps(data, indent=2))
# Drupal JSON:API node UUID (used for tsai_character_uuid WP user meta).
character_uuid = data.get("data", {}).get("data", {}).get("id")
if not args.skip_wp_registration:
password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
wp_reg_url = "http://127.0.0.1:8000/api/v1/user/register/"
wp_reg_body = {
"username": slug,
"email": email,
"password": password,
"first_name": args.name,
"site": cb_host,
"roles": ["author"],
"character_uuid": character_uuid,
}
try:
wp_response = requests.post(wp_reg_url, json=wp_reg_body, timeout=30)
if wp_response.status_code == 200:
wp_data = wp_response.json()
print(f"WordPress author registered: {wp_data['username']} (ID: {wp_data['user_id']})")
else:
print(f"Warning: WordPress registration failed ({wp_response.status_code}): {wp_response.text}")
except requests.ConnectionError:
print("Warning: Could not connect to register WordPress user.")
else:
print(f"Error {response.status_code}: {response.text}")
sys.exit(1)
if __name__ == "__main__":
main()