| 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.turmansolutions.ai/app/services/ |
Upload File : |
"""Character Studio agent tools.
These are the tools an in-app LangChain agent uses to let a site admin *create
and train characters conversationally* — the same building blocks the
create-character / refine-character / schedule-characters skills use, but callable
from a chat messenger instead of over SSH.
Design notes:
- Every tool is **async** so it can be awaited directly by ``AgentExecutor.ainvoke``.
- CRUD tools (list / create / update / schedule) call the *existing* local FastAPI
endpoints over HTTP with the site's ``Origin`` header — exactly the pattern
``create_character.py`` and the skills use. This reuses the endpoints' Drupal
session handling and per-site ownership checks rather than duplicating them.
- ``generate_test_article`` calls ``ArticleCreationService.create_article`` in-process
with ``post_status='draft'`` so the preview never goes live.
- Tools that mutate a character append a typed *frame* (``character_updated`` /
``article_preview``) to ``self.frames``. The streaming endpoint drains ``self.frames``
after the agent run and emits them over SSE so the frontend can update the live
character canvas / render the draft preview. Tool *return values* stay short
human-readable strings for the LLM to reason over.
"""
import json
import logging
import re
import secrets
import string
from datetime import date, datetime, timezone
from typing import List, Optional
import httpx
from langchain_core.tools import StructuredTool
from app.appTypes import Character, ContentSchedule, SiteConfig
from app.services.article_creation_service import ArticleCreationService
API_BASE = "http://127.0.0.1:8000/api/v1"
article_creation_service = ArticleCreationService()
def _slugify(name: str) -> str:
"""Match the slug derivation used by create_character.py."""
return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
class CharacterStudioToolset:
"""Builds the studio tools bound to a single site and collects UI frames.
Construct one per agent run: ``toolset = CharacterStudioToolset(site)``, pass
``toolset.tools`` to the agent, then after ``ainvoke`` emit ``toolset.frames``.
"""
def __init__(self, site: SiteConfig):
self.site = site
self.origin = f"https://{site.cb_host}"
# Typed SSE frames accumulated by mutating tools, drained by the endpoint.
self.frames: List[dict] = []
# ---- HTTP helpers (call the local API with this site's Origin) -----------
async def _get(self, path: str) -> httpx.Response:
async with httpx.AsyncClient(timeout=30) as client:
return await client.get(f"{API_BASE}{path}", headers={"Origin": self.origin})
async def _send(self, method: str, path: str, body: dict) -> httpx.Response:
headers = {"Origin": self.origin, "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=120) as client:
return await client.request(method, f"{API_BASE}{path}", json=body, headers=headers)
async def _fetch_character(self, slug: str) -> Optional[dict]:
"""Return the full character dict (as the API serializes it) or None."""
resp = await self._get(f"/cms/character/by-slug/{self.site.slug}/{slug}")
if resp.status_code != 200:
return None
return resp.json().get("data")
# ---- Tools ---------------------------------------------------------------
async def list_characters(self) -> str:
"""List the characters that already exist on this site (name, slug, and a
short description). Use this first so you avoid duplicate names and can
reference or refine an existing character."""
resp = await self._get("/cms/characters/")
if resp.status_code != 200:
return f"Could not list characters (HTTP {resp.status_code})."
chars = resp.json().get("data", [])
if not chars:
return "This site has no characters yet."
lines = []
for c in chars:
desc = (c.get("description") or "").strip().replace("\n", " ")
if len(desc) > 120:
desc = desc[:120] + "…"
lines.append(f"- {c.get('name')} (slug: {c.get('slug')}): {desc}")
return "Existing characters:\n" + "\n".join(lines)
async def check_name(self, name: str) -> str:
"""Check whether a character name (or its derived slug) is already taken on
this site. Call before create_character to pick a unique name."""
slug = _slugify(name)
resp = await self._get("/cms/characters/")
existing = resp.json().get("data", []) if resp.status_code == 200 else []
for c in existing:
if (c.get("slug") == slug) or ((c.get("name") or "").lower() == name.lower()):
return f"'{name}' is already taken (slug '{slug}'). Choose a different name."
return f"'{name}' is available (slug would be '{slug}')."
async def create_character(
self,
name: str,
description: str,
research: bool = False,
search_query: Optional[str] = None,
search_topic_type: str = "general",
search_time_range: Optional[str] = None,
max_search_results: int = 5,
search_depth: str = "basic",
chunks_per_source: int = 3,
enabled: bool = True,
) -> str:
"""Create a new character (persona) on this site and register its WordPress
author account.
Args:
name: Display name, ideally a unique 1-3 word person-like name.
description: The system prompt / writing brief, second person ("You are…"),
4-6 sentences covering identity, expertise, voice, audience, and a
quality bar. This is what drives the character's articles.
research: Enable Tavily web research for article generation. When true,
search_query is required.
search_query: The web-search query used to research articles (required if
research is true).
search_topic_type: 'general' or 'news'.
search_time_range: Optional 'day' | 'week' | 'month' | 'year'.
max_search_results: 1-20.
search_depth: 'basic' or 'advanced'.
chunks_per_source: 1-3.
enabled: Whether the character is active.
"""
if research and not search_query:
return "research is enabled but search_query is missing — provide a search_query."
slug = _slugify(name)
email = f"{name.lower().replace(' ', '.')}@example.com"
character = {
"name": name,
"slug": slug,
"email": email,
"description": description,
"enabled": enabled,
"date": str(date.today()),
"model": {"provider": "Claude", "name": "claude-sonnet-4-6",
"temperature": 0.7, "max_tokens": 16384, "stream": True},
"history": [{"timestamp": datetime.now(timezone.utc).isoformat(),
"description": "Created via Character Studio"}],
}
if research:
character.update({
"researchCurrentEvents": True,
"searchQuery": search_query,
"searchTopicType": search_topic_type,
"maxSearchResults": max_search_results,
"searchDepth": search_depth,
"chunksPerSource": chunks_per_source,
})
if search_time_range:
character["searchTimeRange"] = search_time_range
resp = await self._send("POST", "/cms/character/", {"character": character})
if resp.status_code != 200:
return f"Failed to create character (HTTP {resp.status_code}): {resp.text[:300]}"
# Drupal node UUID, used to link the WP author back to the character.
character_uuid = resp.json().get("data", {}).get("data", {}).get("id")
# Register the WordPress author (best-effort — mirrors create_character.py).
wp_note = ""
password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
reg_body = {
"username": slug, "email": email, "password": password,
"first_name": name, "site": self.site.cb_host,
"roles": ["author"], "character_uuid": character_uuid,
}
try:
reg = await self._send("POST", "/user/register/", reg_body)
if reg.status_code == 200:
wp_note = f" WordPress author '{slug}' registered."
else:
wp_note = f" (WordPress author registration failed: HTTP {reg.status_code}.)"
except Exception as e: # noqa: BLE001 - registration is non-fatal
wp_note = f" (WordPress author registration error: {e}.)"
full = await self._fetch_character(slug)
if full:
self.frames.append({"type": "character_updated", "character": full})
return f"Created character '{name}' (slug '{slug}').{wp_note}"
async def update_character(
self,
slug: str,
append_description: Optional[str] = None,
replace_description: Optional[str] = None,
research: Optional[bool] = None,
search_query: Optional[str] = None,
search_topic_type: Optional[str] = None,
search_time_range: Optional[str] = None,
max_search_results: Optional[int] = None,
search_depth: Optional[str] = None,
chunks_per_source: Optional[int] = None,
enabled: Optional[bool] = None,
) -> str:
"""Refine an existing character. Prefer append_description (add one focused
directive) over replace_description to preserve the persona. Adjust the
Tavily search settings to fix thin, stale, or off-topic research.
Only pass the fields you want to change; omit the rest.
"""
data = await self._fetch_character(slug)
if not data:
return f"No character found with slug '{slug}'. Use list_characters to see valid slugs."
if replace_description is not None:
data["description"] = replace_description
elif append_description:
data["description"] = (data.get("description") or "").rstrip() + "\n\n" + append_description
if research is not None:
data["researchCurrentEvents"] = research
if search_query is not None:
data["searchQuery"] = search_query
if search_topic_type is not None:
data["searchTopicType"] = search_topic_type
if search_time_range is not None:
data["searchTimeRange"] = search_time_range
if max_search_results is not None:
data["maxSearchResults"] = max_search_results
if search_depth is not None:
data["searchDepth"] = search_depth
if chunks_per_source is not None:
data["chunksPerSource"] = chunks_per_source
if enabled is not None:
data["enabled"] = enabled
resp = await self._send("PATCH", "/cms/character/", {"character": data})
if resp.status_code != 200:
return f"Failed to update character (HTTP {resp.status_code}): {resp.text[:300]}"
updated = resp.json().get("data", data)
self.frames.append({"type": "character_updated", "character": updated})
return f"Updated character '{updated.get('name', slug)}'."
async def set_schedule(
self,
slug: str,
article_days_of_week: Optional[List[int]] = None,
article_days_of_month: Optional[List[int]] = None,
article_preferred_hours: Optional[List[int]] = None,
comment_days_of_week: Optional[List[int]] = None,
comment_days_of_month: Optional[List[int]] = None,
comment_preferred_hours: Optional[List[int]] = None,
clear_article_schedule: bool = False,
clear_comment_schedule: bool = False,
) -> str:
"""Set (or clear) the character's calendar posting schedule. Days of week are
0=Sunday..6=Saturday; days of month are 1-31; hours are 0-23. A character only
posts when it has a schedule whose day constraints match."""
data = await self._fetch_character(slug)
if not data:
return f"No character found with slug '{slug}'."
def build(dow, dom, hours):
if not any([dow, dom, hours]):
return None
sched = {}
if dow:
sched["daysOfWeek"] = dow
if dom:
sched["daysOfMonth"] = dom
if hours:
sched["preferredHours"] = hours
return sched
if clear_article_schedule:
data["articleSchedule"] = None
else:
article = build(article_days_of_week, article_days_of_month, article_preferred_hours)
if article:
data["articleSchedule"] = article
if clear_comment_schedule:
data["commentSchedule"] = None
else:
comment = build(comment_days_of_week, comment_days_of_month, comment_preferred_hours)
if comment:
data["commentSchedule"] = comment
resp = await self._send("PATCH", "/cms/character/", {"character": data})
if resp.status_code != 200:
return f"Failed to set schedule (HTTP {resp.status_code}): {resp.text[:300]}"
updated = resp.json().get("data", data)
self.frames.append({"type": "character_updated", "character": updated})
return f"Updated posting schedule for '{updated.get('name', slug)}'."
async def generate_test_article(self, slug: str) -> str:
"""Generate a DRAFT test article for the character so you can review its
writing and critique it. The article is saved as an unpublished WordPress
draft (never goes live) with images and fact-checking disabled for speed.
Honors the character's own research settings so you can evaluate them."""
data = await self._fetch_character(slug)
if not data:
return f"No character found with slug '{slug}'."
character = Character(**data)
try:
result = await article_creation_service.create_article(
site=self.site,
character=character,
generate_featured_image=False,
generate_secondary_image=False,
research_current_events=bool(character.researchCurrentEvents),
include_previous_articles=False,
enable_fact_checking=False,
enable_corrections=False,
post_status='draft',
)
except Exception as e: # noqa: BLE001 - surface generation failure to the agent
logging.error(f"Character Studio test article failed for '{slug}': {e}")
return f"Test article generation failed: {e}"
self.frames.append({
"type": "article_preview",
"title": result.article.title,
"body": result.article.body, # markdown; frontend renders it
"link": result.permalink,
"postId": result.post_id,
})
preview = (result.article.body or "")[:600]
return (f"Generated a DRAFT test article titled '{result.article.title}'. "
f"Preview: {preview}")
# ---- Assembly ------------------------------------------------------------
@property
def tools(self) -> List[StructuredTool]:
"""LangChain StructuredTools (schemas inferred from signatures + docstrings)."""
specs = [
(self.list_characters, "list_characters"),
(self.check_name, "check_name"),
(self.create_character, "create_character"),
(self.update_character, "update_character"),
(self.set_schedule, "set_schedule"),
(self.generate_test_article, "generate_test_article"),
]
return [
StructuredTool.from_function(coroutine=fn, name=name)
for fn, name in specs
]