| 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/ |
Upload File : |
"""
Article Improvement Script
Improves an existing WordPress article without regenerating it. Several improvements are
supported, any combination can be requested in a single run:
1. --featured-image Generate (or replace) the featured image using the same prompt path
ArticleCreationService uses for new articles.
2. --fact-check Generate a fact-check comment and post it on the article. If the post
has _tsai_search_results meta, those results are passed in so the
fact-check is grounded on the same sources the article was written from.
3. --corrections Generate a corrections follow-up to the fact-check comment.
4. --fix-citations Rewrite inline citations to the canonical clickable superscript form,
fixing older articles where raw markdown citations like [[1]](url) leaked
into the body as visible URLs. Pure text normalization — no AI involved.
Usage:
python improve_article.py <article_url> [--featured-image] [--fact-check] [--fix-citations]
Examples:
python improve_article.py https://news.turmansolutions.ai/2026/05/06/some-post --featured-image
python improve_article.py https://news.turmansolutions.ai/2026/05/06/some-post --fact-check
python improve_article.py https://news.turmansolutions.ai/2026/05/06/some-post --fix-citations
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import random
import sys
import time
from typing import Optional
from urllib.parse import urlparse
import requests
from bs4 import BeautifulSoup
# IMPORTANT: Import cron_common FIRST to load .env before any app imports
import cron_common
from app.appTypes import ArticleResponse, Character, SiteConfig
from app.services.corrections_service import run_corrections_core
from app.services.image import ImageService
from app.utilities.citations import normalize_citations
from app.utilities.fact_check import (
fact_check_author_name,
find_fact_check_comment as select_fact_check_comment,
)
def parse_args() -> tuple[str, bool, bool, bool, bool, Optional[str]]:
parser = argparse.ArgumentParser(description="Improve an existing WordPress article.")
parser.add_argument("url", help="Full URL of the article to improve")
parser.add_argument("--featured-image", action="store_true",
help="Generate and set a new featured image")
parser.add_argument("--fact-check", action="store_true",
help="Generate and post a fact-check comment")
parser.add_argument("--corrections", action="store_true",
help="Generate a corrections follow-up to the fact-check comment "
"(edits the body if warranted; requires a fact-check comment)")
parser.add_argument("--corrections-model", default=None,
help="Model name to use for corrections (e.g. claude-sonnet-4-6). "
"Only applies with --corrections; defaults to the current behavior "
"(opposite-provider rule) when omitted.")
parser.add_argument("--fix-citations", action="store_true",
help="Normalize inline citations to the canonical clickable "
"superscript form (fixes leaked [[1]](url) markdown). No AI.")
args = parser.parse_args()
if not (args.featured_image or args.fact_check or args.corrections or args.fix_citations):
parser.error("at least one of --featured-image, --fact-check, --corrections or "
"--fix-citations is required")
return (args.url, args.featured_image, args.fact_check, args.corrections,
args.fix_citations, args.corrections_model)
def resolve_site(site_config_service, url: str) -> tuple[SiteConfig, str]:
"""Return (site, slug) for the given article URL.
Matches the URL host against wp_host of every loaded site. Slug is the last
non-empty path segment.
"""
parsed = urlparse(url)
host = parsed.hostname
if not host:
print(f"Could not parse host from URL: {url}")
sys.exit(1)
site: Optional[SiteConfig] = None
for s in site_config_service.get_all_sites():
if urlparse(s.wp_host).hostname == host:
site = s
break
if not site:
print(f"No site config matches host: {host}")
sys.exit(1)
path_parts = [p for p in parsed.path.split("/") if p]
if not path_parts:
print(f"Could not extract slug from URL path: {parsed.path}")
sys.exit(1)
slug = path_parts[-1]
return site, slug
def fetch_post(site: SiteConfig, slug: str) -> dict:
"""Fetch the WP post by slug with embeds for featured media."""
endpoint = f"{site.wp_host}/wp-json/wp/v2/posts"
response = requests.get(endpoint, params={"slug": slug, "_embed": "true"})
if response.status_code != 200:
print(f"Failed to fetch post (status {response.status_code}): {response.text[:200]}")
sys.exit(1)
posts = response.json()
if not posts:
print(f"No post found with slug '{slug}' on {site.wp_host}")
sys.exit(1)
return posts[0]
def build_article(post: dict) -> ArticleResponse:
"""Build a minimal ArticleResponse from a fetched WP post.
Only `title` and `body` are read by the image-prompt and fact-check paths.
"""
title = post.get("title", {}).get("rendered", "")
body = post.get("content", {}).get("rendered", "")
return ArticleResponse(title=title, body=body, category="", tags=[])
def parse_search_results(post: dict) -> Optional[list]:
meta = post.get("meta") or {}
raw = meta.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 stub_character() -> Character:
"""Minimal Character used as the `character` arg for post_comment.
All identifying fields are overridden via author_name_override / author_email_override
when posting the fact-check comment.
"""
return Character(
name="Editor",
email="editor@turmansolutions.ai",
description="",
enabled=True,
date="",
)
async def patch_featured_image(wp_service, image_service, site, wp_token, post_id: int,
article: ArticleResponse) -> str:
"""Generate a new featured image and attach it to the post. Returns the image URL."""
step_start = time.time()
featured_image, _ = await wp_service.create_image(site, article)
logging.info(f"Featured image generated: {featured_image}")
resp = image_service.compress_and_save(site, wp_token, featured_image, 50)
if "error" in resp:
raise RuntimeError(f"WP media upload failed: {resp.get('error')} - {resp.get('message', '')}")
image_id = resp["id"]
image_url = str(resp.get("source_url", featured_image))
wp_service.post_featured_image(site, wp_token, post_id, image_id)
logging.info(f"Featured image set on post {post_id} ({time.time() - step_start:.2f}s)")
return image_url
def patch_fact_check(wp_service, chat_service, site, wp_token, post: dict,
article: ArticleResponse) -> Optional[int]:
"""Generate a fact-check comment and post it on the article.
Returns the id of the posted fact-check comment (or None if posting failed).
"""
step_start = time.time()
images = wp_service.extract_images(post) or []
search_results = parse_search_results(post)
if search_results:
logging.info(f"Using {len(search_results)} stored search results for fact-check")
else:
logging.info("No stored search results found; fact-checking without sources")
fact_check_comment, model_name, provider_name = chat_service.fact_check_article(
article_title=article.title,
article_body=article.body,
images=images,
original_model=None,
search_results=search_results,
)
posted = wp_service.post_comment(
site=site,
character=stub_character(),
post_id=post["id"],
comment=fact_check_comment,
token=wp_token,
author_name_override=fact_check_author_name(provider_name, model_name),
author_email_override="editor@turmansolutions.ai",
)
logging.info(f"Fact-check comment posted on {post['id']} ({time.time() - step_start:.2f}s)")
return posted.get("id") if posted else None
def find_fact_check_comment(wp_service, site, post_id: int) -> Optional[dict]:
"""Return the existing fact-check comment on the post, or None."""
return select_fact_check_comment(wp_service.get_comments(site, post_id))
def patch_corrections(wp_service, chat_service, site, wp_token, post: dict,
article: ArticleResponse, corrections_model_name: Optional[str] = None) -> None:
"""Generate a corrections follow-up to the fact-check comment.
Posts a corrections comment threaded under the fact-check, and edits the
article body when the fact-check surfaced genuine factual errors. Requires
an existing fact-check comment to follow up on.
When `corrections_model_name` is given, that model is forced for the
corrections; otherwise one is picked at random (50/50) between Claude
Sonnet 4.6 and OpenAI GPT-5.5.
"""
step_start = time.time()
post_id = post["id"]
if not corrections_model_name:
corrections_model_name = random.choice(["claude-sonnet-4-6", "gpt-5.5"])
logging.info(f"No corrections model specified; randomly selected {corrections_model_name}")
print(f" corrections model (random): {corrections_model_name}")
# Locate the fact-check comment to reply to. Its rendered content feeds the
# generator. When run together with --fact-check, the comment was just posted
# and is returned by get_comments here.
fact_check_comment = find_fact_check_comment(wp_service, site, post_id)
if not fact_check_comment:
print("No fact-check comment found on this post; corrections require one. "
"Run with --fact-check first (or pass --fact-check --corrections together).")
sys.exit(1)
fact_check_comment_id = fact_check_comment.get("id")
fact_check_html = fact_check_comment.get("content", {}).get("rendered", "")
fact_check_text = BeautifulSoup(fact_check_html, "html.parser").get_text(separator="\n").strip()
search_results = parse_search_results(post)
if search_results:
logging.info(f"Using {len(search_results)} stored search results for corrections")
else:
logging.info("No stored search results found; correcting without sources")
result = run_corrections_core(
wp_service=wp_service,
chat_service=chat_service,
site=site,
wp_token=wp_token,
post_id=post_id,
article_title=article.title,
article_body=article.body,
fact_check_text=fact_check_text,
parent_comment_id=fact_check_comment_id,
images=wp_service.extract_images(post) or [],
search_results=search_results,
character=stub_character(),
corrections_model_name=corrections_model_name,
)
logging.info(f"Corrections comment posted on {post_id} ({time.time() - step_start:.2f}s)")
print(f"Corrections comment posted on post {post_id}")
if result["body_updated"]:
print("Article body updated")
def fetch_raw_content(site: SiteConfig, wp_token: str, post_id: int) -> 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. Falls back to an empty string if unavailable.
"""
endpoint = f"{site.wp_host}/wp-json/wp/v2/posts/{post_id}"
response = requests.get(
endpoint,
params={"context": "edit"},
headers={"Authorization": f"Bearer {wp_token}"},
)
if response.status_code != 200:
print(f"Failed to fetch raw content (status {response.status_code}): {response.text[:200]}")
sys.exit(1)
return response.json().get("content", {}).get("raw", "") or ""
def patch_fix_citations(wp_service, site, wp_token, post: dict) -> None:
"""Normalize inline citations in the article body and write it back if changed.
Reads the raw stored body, applies `normalize_citations`, and only PATCHes when
the normalization actually changes something (so a clean article is a no-op).
"""
step_start = time.time()
post_id = post["id"]
raw = fetch_raw_content(site, wp_token, post_id)
fixed = normalize_citations(raw)
if fixed == raw:
print(f"No citation changes needed for post {post_id}")
return
wp_service.update_post_content(site, wp_token, post_id, fixed)
logging.info(f"Citations normalized on {post_id} ({time.time() - step_start:.2f}s)")
print(f"Citations normalized; post {post_id} updated")
async def main() -> None:
(url, do_featured_image, do_fact_check, do_corrections, do_fix_citations,
corrections_model_name) = parse_args()
print(f"url: {url}")
if do_featured_image:
print("FEATURED IMAGE UPDATE ENABLED")
if do_fact_check:
print("FACT-CHECK UPDATE ENABLED")
if do_corrections:
print("CORRECTIONS UPDATE ENABLED")
if corrections_model_name:
print(f" corrections model override: {corrections_model_name}")
if do_fix_citations:
print("FIX-CITATIONS ENABLED")
cron_common.check_internet_connectivity()
cron_common.setup_logging("api.log")
script_start = time.time()
wp_service, _drupal_service, site_config_service, chat_service = cron_common.initialize_services()
image_service = ImageService()
site, slug = resolve_site(site_config_service, url)
print(f"site: {site.slug} slug: {slug}")
wp_token = cron_common.get_jwt_token(wp_service, site, site.slug)
post = fetch_post(site, slug)
post_id = post["id"]
print(f"post id: {post_id} title: {post.get('title', {}).get('rendered', '')}")
article = build_article(post)
if do_featured_image:
image_url = await patch_featured_image(
wp_service, image_service, site, wp_token, post_id, article
)
print(f"Featured image set: {image_url}")
if do_fix_citations:
patch_fix_citations(wp_service, site, wp_token, post)
if do_fact_check:
patch_fact_check(wp_service, chat_service, site, wp_token, post, article)
print(f"Fact-check comment posted on post {post_id}")
# Corrections depend on a fact-check existing, so run them last.
if do_corrections:
patch_corrections(wp_service, chat_service, site, wp_token, post, article,
corrections_model_name=corrections_model_name)
logging.info(f"Article improvement completed ({time.time() - script_start:.2f}s total)")
print(f"Improvement complete for post {post_id}")
if __name__ == "__main__":
try:
asyncio.run(main())
except SystemExit:
raise
except Exception as e:
logging.error(f"Improvement failed: {e}", exc_info=True)
print(f"Error: {e}")
sys.exit(1)