| 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 : |
"""Article Studio agent tools.
These are the tools an in-app LangChain agent uses to let a site admin *create
and improve articles conversationally* — the same building blocks create_articles.py
and improve_article.py (the create-series / improve-article 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``.
Blocking WordPress/LLM calls run through ``asyncio.to_thread`` so the SSE event
loop keeps serving other requests.
- ``create_article`` calls ``ArticleCreationService.create_article`` in-process — the
same method the CLI and the /article endpoint use — always with
``post_status='draft'``. Nothing this agent writes goes live until the admin asks
for it and ``publish_article`` runs.
- Tools that change an article append a typed *frame* (``article_draft`` /
``article_improved``) to ``self.frames``. The streaming endpoint drains them after
the agent run and emits them over SSE so the frontend canvas can update. Tool
*return values* stay short human-readable strings for the LLM to reason over.
Site scoping — the thing to review carefully:
- The toolset is bound to ONE site at construction and **no tool takes a site
parameter**, so the agent has no way to name a different one.
- ``_resolve_post`` is the only path any tool has to a WordPress post. It always
fetches from ``self.site.wp_host``, and rejects a URL whose host belongs to another
site. Do NOT reuse ``improve_article.resolve_site`` here: that function scans every
configured site and matches on host, which is right for an admin CLI and wrong for
a studio, where it would let a pasted URL reach someone else's site.
"""
import asyncio
import json
import logging
from typing import List, Optional, Tuple
from urllib.parse import urlparse
import httpx
from bs4 import BeautifulSoup
from langchain_core.tools import StructuredTool
from app.appTypes import ArticleResponse, Character, SiteConfig
from app.services.article_creation_service import ArticleCreationService
from app.services.chat import ChatService
from app.services.corrections_service import run_corrections_core
from app.services.image import ImageService
from app.services.wp import WpService
from app.utilities.citations import normalize_citations
from app.utilities.fact_check import fact_check_author_name, find_fact_check_comment
API_BASE = "http://127.0.0.1:8000/api/v1"
EDITOR_EMAIL = "editor@turmansolutions.ai"
article_creation_service = ArticleCreationService()
def _editor_character() -> Character:
"""Minimal Character used as the `character` arg for post_comment.
Identifying fields are overridden via author_name_override / author_email_override,
mirroring improve_article.stub_character.
"""
return Character(name="Editor", email=EDITOR_EMAIL, description="", enabled=True, date="")
class ArticleStudioToolset:
"""Builds the studio tools bound to a single site and collects UI frames.
Construct one per agent run: ``toolset = ArticleStudioToolset(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}"
self.wp_service = WpService()
self.chat_service = ChatService()
self.image_service = ImageService()
# Typed SSE frames accumulated by mutating tools, drained by the endpoint.
self.frames: List[dict] = []
self._wp_token: Optional[str] = None
# ---- 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 _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")
async def _token(self) -> str:
"""Service JWT for this site's WordPress, fetched once per agent run."""
if not self._wp_token:
token = await asyncio.to_thread(self.wp_service.get_jwt_token, self.site)
if not isinstance(token, str):
raise RuntimeError(f"Could not authenticate with {self.site.wp_host}")
self._wp_token = token
return self._wp_token
# ---- Post resolution (the site-scoping kernel) ---------------------------
async def _resolve_post(self, ref: str) -> Tuple[Optional[dict], Optional[str]]:
"""Resolve a post reference to a WP post on THIS site.
`ref` may be a full URL, a post slug, or a numeric post id. Returns
(post, None) on success or (None, refusal_message) on failure — the
refusal string is handed straight back to the agent as the tool result.
A URL pointing at any host other than this site's wp_host is refused.
This is the boundary that keeps the studio from touching another site's
content, so it must stay the single entry point for every post-facing tool.
"""
ref = (ref or "").strip()
if not ref:
return None, "No article reference given. Provide the article's URL, slug, or post id."
site_host = urlparse(self.site.wp_host).hostname
slug: Optional[str] = None
post_id: Optional[int] = None
if ref.startswith("http://") or ref.startswith("https://"):
parsed = urlparse(ref)
if parsed.hostname != site_host:
return None, (
f"Refused: {parsed.hostname} is not this site. This studio can only work on "
f"articles on {site_host}."
)
parts = [p for p in parsed.path.split("/") if p]
if not parts:
return None, f"Could not extract an article slug from the URL path: {parsed.path}"
slug = parts[-1]
elif ref.isdigit():
post_id = int(ref)
else:
slug = ref
token = await self._token()
if post_id is not None:
post = await asyncio.to_thread(self.wp_service.get_article, self.site, token, post_id)
if not post:
return None, f"No article found with post id {post_id} on {site_host}."
return post, None
post = await asyncio.to_thread(self._fetch_post_by_slug, slug, token)
if not post:
return None, f"No article found with slug '{slug}' on {site_host}."
return post, None
def _fetch_post_by_slug(self, slug: str, token: str) -> Optional[dict]:
"""Fetch a post by slug from this site, including drafts (blocking)."""
import requests
response = requests.get(
f"{self.site.wp_host}/wp-json/wp/v2/posts",
params={"slug": slug, "_embed": "true", "status": "publish,draft,pending,future"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if response.status_code != 200:
logging.warning(
f"Article Studio post lookup failed for '{slug}': "
f"{response.status_code} {response.text[:200]}")
return None
posts = response.json()
return posts[0] if posts else None
# ---- Shared post helpers -------------------------------------------------
@staticmethod
def _article_from_post(post: dict) -> ArticleResponse:
"""Minimal ArticleResponse from a WP post (mirrors improve_article.build_article)."""
return ArticleResponse(
title=post.get("title", {}).get("rendered", ""),
body=post.get("content", {}).get("rendered", ""),
category="",
tags=[],
)
@staticmethod
def _search_results_from_post(post: dict) -> Optional[list]:
raw = (post.get("meta") or {}).get("_tsai_search_results")
if not raw:
return None
try:
return json.loads(raw) if isinstance(raw, str) else raw
except (ValueError, TypeError) as e:
logging.warning(f"Could not parse _tsai_search_results meta: {e}")
return None
def _draft_frame(self, post: dict, title: str, body: str) -> None:
self.frames.append({
"type": "article_draft",
"title": title,
"body": body,
"link": post.get("link"),
"postId": post.get("id"),
"status": post.get("status", "draft"),
})
# ---- Tools ---------------------------------------------------------------
async def list_characters(self) -> str:
"""List the characters (authors) that exist on this site, with their slugs and
a short description. Call this first so you can pick or confirm the author an
article should be written by."""
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. Articles are written by a character, "
"so one must be created in the Character Studio first.")
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 list_recent_articles(self, character_slug: Optional[str] = None, count: int = 10) -> str:
"""List recent articles on this site, most recent first, with their titles,
slugs, status and post ids. Pass character_slug to see only that author's
articles. Use this to find an article the admin wants to improve.
Args:
character_slug: Optional character slug to filter by author.
count: How many articles to list (1-50).
"""
count = max(1, min(count, 50))
author_id = None
if character_slug:
data = await self._fetch_character(character_slug)
if not data:
return f"No character found with slug '{character_slug}'."
character = Character(**data)
token = await self._token()
wp_users = await asyncio.to_thread(self.wp_service.get_users, self.site, token)
await asyncio.to_thread(self.wp_service.add_to_character, self.site, character, wp_users)
if not character.wp_user:
return (f"Character '{character_slug}' has no WordPress author account, so it has "
f"no articles yet.")
author_id = character.wp_user.id
token = await self._token()
posts = await asyncio.to_thread(
self.wp_service.get_recent_articles, self.site, token, count, author_id)
if not posts:
return "No articles found."
lines = []
for p in posts:
title = p.get("title", {}).get("rendered", "")
lines.append(
f"- [{p.get('status', '?')}] {title} (slug: {p.get('slug')}, id: {p.get('id')}, "
f"{p.get('date', '')[:10]})")
return "Recent articles:\n" + "\n".join(lines)
async def create_article(
self,
character_slug: str,
topic: str,
research: Optional[bool] = None,
generate_featured_image: bool = False,
series_name: Optional[str] = None,
) -> str:
"""Write a new article as a DRAFT, authored by one of this site's characters.
The article is saved as an unpublished WordPress draft — it never goes live
until publish_article is called. Review the draft with the admin first.
Args:
character_slug: Slug of the character who writes it (from list_characters).
topic: The brief for THIS article — what it should cover, angle, and any
must-hit points. Be specific; this is added to the character's standing
writing brief, it does not replace their persona.
research: Whether to research current events via web search before writing.
Defaults to the character's own setting.
generate_featured_image: Whether to generate a featured image now. Leave
false for a fast draft; set_featured_image can add one later.
series_name: Optional series to file the article under.
"""
data = await self._fetch_character(character_slug)
if not data:
return (f"No character found with slug '{character_slug}'. "
f"Use list_characters to see valid slugs.")
character = Character(**data)
# character_description_override REPLACES the "About You" section of the
# article system message (see WpService.get_new_article_system_message), so
# compose the persona plus this article's brief rather than passing the bare
# topic — otherwise the character's voice is erased for this article.
description_override = (
f"{(character.description or '').rstrip()}\n\n"
f"## This Article\n\n{topic}"
)
do_research = (bool(character.researchCurrentEvents) if research is None else research)
try:
result = await article_creation_service.create_article(
site=self.site,
character=character,
character_description_override=description_override,
generate_featured_image=generate_featured_image,
generate_secondary_image=False,
research_current_events=do_research,
include_previous_articles=True,
enable_fact_checking=False,
enable_corrections=False,
series_name=series_name,
post_status='draft',
)
except Exception as e: # noqa: BLE001 - surface generation failure to the agent
logging.error(f"Article Studio create_article failed for '{character_slug}': {e}")
return f"Article creation failed: {e}"
self.frames.append({
"type": "article_draft",
"title": result.article.title,
"body": result.article.body, # markdown; frontend renders it
"link": result.permalink,
"postId": result.post_id,
"status": "draft",
})
preview = (result.article.body or "")[:600]
return (f"Created DRAFT article '{result.article.title}' (post id {result.post_id}) "
f"by {character.name}. Preview: {preview}")
async def get_article(self, ref: str) -> str:
"""Read an article on this site so you can review or critique it. Returns the
title, status and full body.
Args:
ref: The article's URL, slug, or numeric post id.
"""
post, refusal = await self._resolve_post(ref)
if refusal:
return refusal
article = self._article_from_post(post)
return (f"Title: {article.title}\n"
f"Status: {post.get('status')}\n"
f"Post id: {post.get('id')}\n"
f"Link: {post.get('link')}\n\n"
f"Body (HTML):\n{article.body}")
async def revise_article(self, ref: str, instructions: str) -> str:
"""Rewrite an article's body according to the admin's instructions (e.g.
"tighten the intro", "add a section on X", "cut the third paragraph"). The
whole body is rewritten and saved back to WordPress; the article's status is
unchanged, so a draft stays a draft.
Args:
ref: The article's URL, slug, or numeric post id.
instructions: What to change, in plain language. Be specific.
"""
post, refusal = await self._resolve_post(ref)
if refusal:
return refusal
article = self._article_from_post(post)
images = await asyncio.to_thread(self.wp_service.extract_images, post)
try:
revised_body, summary = await asyncio.to_thread(
self.chat_service.revise_article_body,
article.title, article.body, instructions, images or [],
)
except Exception as e: # noqa: BLE001
logging.error(f"Article Studio revise failed for post {post.get('id')}: {e}")
return f"Revision failed: {e}"
token = await self._token()
await asyncio.to_thread(
self.wp_service.update_post_content, self.site, token, post["id"], revised_body)
self._draft_frame(post, article.title, revised_body)
return f"Revised '{article.title}'. {summary}"
async def set_featured_image(self, ref: str) -> str:
"""Generate a new featured image for an article and attach it, replacing any
existing one. Uses the same image path new articles use.
Args:
ref: The article's URL, slug, or numeric post id.
"""
post, refusal = await self._resolve_post(ref)
if refusal:
return refusal
article = self._article_from_post(post)
try:
featured_image, _ = await self.wp_service.create_image(self.site, article)
token = await self._token()
resp = await asyncio.to_thread(
self.image_service.compress_and_save, self.site, token, featured_image, 50)
if "error" in resp:
return (f"Image upload failed: {resp.get('error')} "
f"{resp.get('message', '')}".strip())
image_url = str(resp.get("source_url", featured_image))
await asyncio.to_thread(
self.wp_service.post_featured_image, self.site, token, post["id"], resp["id"])
except Exception as e: # noqa: BLE001
logging.error(f"Article Studio featured image failed for post {post.get('id')}: {e}")
return f"Featured image failed: {e}"
self.frames.append({
"type": "article_improved",
"postId": post["id"],
"operation": "featured_image",
"detail": image_url,
})
return f"New featured image set on '{article.title}': {image_url}"
async def fact_check(self, ref: str) -> str:
"""Fact-check an article and post the result as a comment on it, using the
opposite AI provider from the one that wrote it. Grounded on the article's
stored search results when it has them. Run this before corrections.
Args:
ref: The article's URL, slug, or numeric post id.
"""
post, refusal = await self._resolve_post(ref)
if refusal:
return refusal
article = self._article_from_post(post)
images = await asyncio.to_thread(self.wp_service.extract_images, post)
search_results = self._search_results_from_post(post)
try:
comment, model_name, provider_name = await asyncio.to_thread(
self.chat_service.fact_check_article,
article.title, article.body, images or [], None, search_results,
)
token = await self._token()
posted = await asyncio.to_thread(
lambda: self.wp_service.post_comment(
site=self.site,
character=_editor_character(),
post_id=post["id"],
comment=comment,
token=token,
author_name_override=fact_check_author_name(provider_name, model_name),
author_email_override=EDITOR_EMAIL,
)
)
except Exception as e: # noqa: BLE001
logging.error(f"Article Studio fact-check failed for post {post.get('id')}: {e}")
return f"Fact-check failed: {e}"
if not posted:
return ("The fact-check was generated but WordPress rejected the comment. "
"It may need to be posted manually.")
self.frames.append({
"type": "article_improved",
"postId": post["id"],
"operation": "fact_check",
"detail": f"{provider_name} {model_name}",
})
text = BeautifulSoup(comment, "html.parser").get_text(separator=" ").strip()
return (f"Fact-check posted on '{article.title}' via {provider_name} {model_name}. "
f"Findings: {text[:800]}")
async def corrections(self, ref: str, model_name: Optional[str] = None) -> str:
"""Act on an article's fact-check: post a corrections follow-up threaded under
it, and edit the article body when the fact-check found genuine factual errors.
Requires an existing fact-check comment — run fact_check first.
Note: on some sites comments are held for moderation and are not visible to
this tool until approved, so a fact-check posted moments ago may not be found.
Args:
ref: The article's URL, slug, or numeric post id.
model_name: Optional model to force (e.g. 'claude-sonnet-4-6'). Defaults to
a random pick between Claude and OpenAI.
"""
post, refusal = await self._resolve_post(ref)
if refusal:
return refusal
comments = await asyncio.to_thread(self.wp_service.get_comments, self.site, post["id"])
fact_check_comment = find_fact_check_comment(comments)
if not fact_check_comment:
return ("No fact-check comment found on this article; corrections need one. "
"Run fact_check first. (If a fact-check was just posted, it may still be "
"awaiting moderation.)")
article = self._article_from_post(post)
fact_check_html = fact_check_comment.get("content", {}).get("rendered", "")
fact_check_text = BeautifulSoup(fact_check_html, "html.parser").get_text(
separator="\n").strip()
images = await asyncio.to_thread(self.wp_service.extract_images, post)
try:
token = await self._token()
result = await asyncio.to_thread(
lambda: run_corrections_core(
wp_service=self.wp_service,
chat_service=self.chat_service,
site=self.site,
wp_token=token,
post_id=post["id"],
article_title=article.title,
# The revised body is written straight back to WordPress with no
# markdown rendering, so hand over the published HTML.
article_body=article.body,
fact_check_text=fact_check_text,
parent_comment_id=fact_check_comment.get("id"),
images=images or [],
search_results=self._search_results_from_post(post),
character=_editor_character(),
corrections_model_name=model_name,
)
)
except Exception as e: # noqa: BLE001
logging.error(f"Article Studio corrections failed for post {post.get('id')}: {e}")
return f"Corrections failed: {e}"
self.frames.append({
"type": "article_improved",
"postId": post["id"],
"operation": "corrections",
"detail": ("body updated" if result["body_updated"] else "no body edits needed"),
})
return (f"Corrections posted on '{article.title}' via {result['provider_name']} "
f"{result['model_name']}. "
+ ("The article body was updated." if result["body_updated"]
else "No factual edits were warranted; the body stands as written."))
async def fix_citations(self, ref: str) -> str:
"""Normalize an article's inline citations to the canonical clickable
superscript form, fixing older articles where raw markdown citations leaked
into the body as visible URLs. Pure text normalization — no AI, and a no-op
on a clean article.
Args:
ref: The article's URL, slug, or numeric post id.
"""
post, refusal = await self._resolve_post(ref)
if refusal:
return refusal
token = await self._token()
raw = await asyncio.to_thread(self._fetch_raw_content, post["id"], token)
fixed = normalize_citations(raw)
if fixed == raw:
return f"No citation changes needed for post {post['id']}."
await asyncio.to_thread(
self.wp_service.update_post_content, self.site, token, post["id"], fixed)
self.frames.append({
"type": "article_improved",
"postId": post["id"],
"operation": "fix_citations",
"detail": "citations normalized",
})
return f"Citations normalized on post {post['id']}."
def _fetch_raw_content(self, post_id: int, token: str) -> str:
"""Return the post's RAW stored content via an authenticated edit-context GET.
Using `raw` (not `rendered`) avoids re-storing WP's processed output — only the
citations should change.
"""
import requests
response = requests.get(
f"{self.site.wp_host}/wp-json/wp/v2/posts/{post_id}",
params={"context": "edit"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if response.status_code != 200:
raise RuntimeError(
f"Failed to fetch raw content: {response.status_code} {response.text[:200]}")
return response.json().get("content", {}).get("raw", "") or ""
async def publish_article(self, ref: str) -> str:
"""Publish a draft article, making it live and public on the site. Only call
this when the admin has explicitly asked for the article to be published.
Args:
ref: The article's URL, slug, or numeric post id.
"""
post, refusal = await self._resolve_post(ref)
if refusal:
return refusal
if post.get("status") == "publish":
return f"'{self._article_from_post(post).title}' is already published."
token = await self._token()
try:
updated = await asyncio.to_thread(
self.wp_service.update_post_status, self.site, token, post["id"], "publish")
except Exception as e: # noqa: BLE001
logging.error(f"Article Studio publish failed for post {post.get('id')}: {e}")
return f"Publish failed: {e}"
article = self._article_from_post(post)
self.frames.append({
"type": "article_draft",
"title": article.title,
"body": article.body,
"link": updated.get("link", post.get("link")),
"postId": post["id"],
"status": "publish",
})
return f"Published '{article.title}': {updated.get('link', post.get('link'))}"
# ---- Assembly ------------------------------------------------------------
@property
def tools(self) -> List[StructuredTool]:
"""LangChain StructuredTools (schemas inferred from signatures + docstrings)."""
specs = [
(self.list_characters, "list_characters"),
(self.list_recent_articles, "list_recent_articles"),
(self.create_article, "create_article"),
(self.get_article, "get_article"),
(self.revise_article, "revise_article"),
(self.set_featured_image, "set_featured_image"),
(self.fact_check, "fact_check"),
(self.corrections, "corrections"),
(self.fix_citations, "fix_citations"),
(self.publish_article, "publish_article"),
]
return [
StructuredTool.from_function(coroutine=fn, name=name)
for fn, name in specs
]