import asyncio import html import ipaddress import socket import urllib.parse from dataclasses import dataclass, field from datetime import datetime, timezone from html.parser import HTMLParser from typing import Any, Optional import uuid import httpx from sqlalchemy import select from sqlalchemy.orm import Session from app.models.database import SessionLocal from app.models.smart_models import KnowledgeBase, SyncJob from app.services.embedding_service import EmbeddingService from app.services.kb_chunking import embed_entry_for_retrieval from app.services.vector_store import VectorStore from app.utils.config import config_manager from app.utils.sync_control import check_cancel_flag, clear_cancel_flag, check_pause_flag, clear_pause_flag MAX_POST_CHARACTERS = 100_000 WP_PREVIEW_POSTS = 3 WP_SYNC_PER_PAGE = 100 EMBEDDING_BATCH_SIZE = 50 _PRIVATE_NETWORKS = [ ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("172.16.0.0/12"), ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128"), ipaddress.ip_network("fc00::/7"), ] class WordPressSyncError(ValueError): """Raised when a WordPress sync input or upstream response is invalid.""" class WordPressPostTypeError(WordPressSyncError): """Raised when the requested post type does not exist on the WordPress site.""" @dataclass class UpsertSummary: created: int = 0 updated: int = 0 skipped: int = 0 rows_to_embed: list = field(default_factory=list) @dataclass class WordPressEntry: title: str content: str source_url: str warnings: list[str] = field(default_factory=list) class _PlainTextHTMLParser(HTMLParser): def __init__(self) -> None: super().__init__() self._chunks: list[str] = [] self._current_href: Optional[str] = None def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: if tag in {"p", "div", "br", "li", "h1", "h2", "h3"}: self._chunks.append("\n") if tag == "a": attrs_dict = dict(attrs) self._current_href = attrs_dict.get("href") def handle_endtag(self, tag: str) -> None: if tag == "a": self._current_href = None if tag in {"p", "div", "li", "h1", "h2", "h3"}: self._chunks.append("\n") def handle_data(self, data: str) -> None: text = html.unescape(data).strip() if not text: return if self._current_href: self._chunks.append(f"{text} ({self._current_href})") else: self._chunks.append(text) self._chunks.append(" ") def get_text(self) -> str: lines = [" ".join(line.split()) for line in "".join(self._chunks).splitlines()] return "\n".join(line for line in lines if line).strip() def extract_wordpress_plain_text(html_content: str) -> str: parser = _PlainTextHTMLParser() parser.feed(html_content) return parser.get_text() def validate_public_https_url(url: str) -> str: parsed = urllib.parse.urlparse(url) if parsed.scheme != "https": raise WordPressSyncError("site_url must use HTTPS") hostname = parsed.hostname or "" if not hostname or hostname.lower() in {"localhost", "0.0.0.0"}: raise WordPressSyncError("site_url points to a local address") _validate_hostname_public(hostname) return url.rstrip("/") async def fetch_post_type_rest_base(site_url: str, post_type: str) -> str: """Return the WP REST base for a post type, or raise WordPressPostTypeError.""" api_url = f"{site_url}/wp-json/wp/v2/types" try: async with httpx.AsyncClient(timeout=15.0) as client: response = await client.get(api_url, follow_redirects=False) response.raise_for_status() types = response.json() except (httpx.HTTPError, ValueError) as exc: raise WordPressSyncError(f"Failed to fetch WordPress post types: {exc}") from exc if not isinstance(types, dict) or post_type not in types: raise WordPressPostTypeError(f"Post type '{post_type}' not found on this WordPress site") return types[post_type].get("rest_base") or post_type def _validate_hostname_public(hostname: str) -> None: addresses: set[str] = set() try: addresses.add(str(ipaddress.ip_address(hostname))) except ValueError: try: for family, _, _, _, sockaddr in socket.getaddrinfo(hostname, None): if family in {socket.AF_INET, socket.AF_INET6}: addresses.add(sockaddr[0]) except socket.gaierror as exc: raise WordPressSyncError("site_url hostname could not be resolved") from exc for raw_address in addresses: address = ipaddress.ip_address(raw_address) if any(address in network for network in _PRIVATE_NETWORKS): raise WordPressSyncError("site_url points to a private network") async def fetch_single_wordpress_post( site_url: str, source_url: str, rest_base: str ) -> WordPressEntry: """Fetch one WordPress post by permalink from the given rest_base endpoint.""" api_url = f"{site_url}/wp-json/wp/v2/{rest_base}" try: async with httpx.AsyncClient(timeout=15.0) as client: response = await client.get( api_url, params={"link": source_url, "per_page": 1, "_fields": "id,title,content,link,guid"}, follow_redirects=False, ) response.raise_for_status() posts = response.json() except (httpx.HTTPError, ValueError) as exc: raise WordPressSyncError(f"Failed to fetch WordPress post: {exc}") from exc if not isinstance(posts, list) or len(posts) == 0: raise WordPressSyncError(f"No WordPress post found at {source_url}") if posts[0]["link"].rstrip("/") != source_url.rstrip("/"): raise WordPressSyncError(f"No WordPress post found at {source_url}") return build_wordpress_entry(posts[0]) async def fetch_single_wordpress_post_by_url(source_url: str) -> WordPressEntry: """Fetch a single WordPress post by its public permalink via the WP REST API.""" parsed = urllib.parse.urlparse(source_url) site_url = f"{parsed.scheme}://{parsed.netloc}" api_url = f"{site_url}/wp-json/wp/v2/posts" try: async with httpx.AsyncClient(timeout=15.0) as client: response = await client.get( api_url, params={"link": source_url, "per_page": 1, "_fields": "id,title,content,link,guid"}, follow_redirects=False, ) response.raise_for_status() posts = response.json() except (httpx.HTTPError, ValueError) as exc: raise WordPressSyncError(f"Failed to fetch WordPress post: {exc}") from exc if not isinstance(posts, list) or len(posts) == 0: raise WordPressSyncError(f"No WordPress post found at {source_url}") if posts[0]["link"].rstrip("/") != source_url.rstrip("/"): raise WordPressSyncError(f"No WordPress post found at {source_url}") return build_wordpress_entry(posts[0]) async def fetch_wordpress_posts( site_url: str, page: int, per_page: int, rest_base: str = "posts" ) -> tuple[list[dict[str, Any]], int]: api_url = f"{site_url}/wp-json/wp/v2/{rest_base}" try: async with httpx.AsyncClient(timeout=15.0) as client: response = await client.get( api_url, params={"page": page, "per_page": per_page}, follow_redirects=False, ) response.raise_for_status() posts = response.json() except (httpx.HTTPError, ValueError) as exc: raise WordPressSyncError(f"Failed to fetch WordPress posts: {exc}") from exc if not isinstance(posts, list): raise WordPressSyncError("Expected a list of posts from WordPress API") total_pages = int(response.headers.get("X-WP-TotalPages", "1")) return posts, total_pages def build_wordpress_entry(post: dict[str, Any]) -> WordPressEntry: title = post.get("title", {}).get("rendered", "Untitled WP Post") source_url = post.get("link") or post.get("guid", {}).get("rendered") if not source_url: raise WordPressSyncError(f"Post '{title}': missing source URL") body = extract_wordpress_plain_text(post.get("content", {}).get("rendered", "")) if not body: raise WordPressSyncError(f"Post '{title}': empty content after HTML extraction") warnings = [] if len(body) > MAX_POST_CHARACTERS: body = body[:MAX_POST_CHARACTERS] warnings.append("Content truncated to 100000 characters") published = post.get("date") or "unknown" content = f"Published: {published}\nSource: {source_url}\n\n{body}" return WordPressEntry(title=title, content=content, source_url=source_url, warnings=warnings) async def preview_wordpress_entries( site_url: str, post_type: str = "post" ) -> tuple[str, list[WordPressEntry], list[str]]: safe_site_url = validate_public_https_url(site_url) rest_base = await fetch_post_type_rest_base(safe_site_url, post_type) posts, _ = await fetch_wordpress_posts(safe_site_url, page=1, per_page=WP_PREVIEW_POSTS, rest_base=rest_base) entries, errors = _build_entries(posts) return safe_site_url, entries, errors def _build_entries(posts: list[dict[str, Any]]) -> tuple[list[WordPressEntry], list[str]]: entries: list[WordPressEntry] = [] errors: list[str] = [] for post in posts: try: entries.append(build_wordpress_entry(post)) except WordPressSyncError as exc: errors.append(str(exc)) return entries, errors def process_wordpress_sync_job( tenant_id: str, job_id: str, site_url: str, rest_base: str = "posts", force: bool = False ) -> None: asyncio.run(process_wordpress_sync_job_async(tenant_id, job_id, site_url, rest_base=rest_base, force=force)) async def process_wordpress_sync_job_async( tenant_id: str, job_id: str, site_url: str, rest_base: str = "posts", force: bool = False ) -> None: safe_site_url = validate_public_https_url(site_url) tenant_uuid = uuid.UUID(tenant_id) job_uuid = uuid.UUID(job_id) config = config_manager.get_config() embedding_service = EmbeddingService( config.embedding_model, api_key=config.gemini_embedding_api_key, ) vector_store = VectorStore(config.chroma_persist_path) with SessionLocal() as db: job = _get_owned_job(db, tenant_uuid, job_uuid) if job is None: return if job.status in ("cancelled", "paused"): # job was stopped before Celery picked it up return job.status = "running" job.error_message = None job.updated_at = datetime.now(timezone.utc) db.commit() try: first_posts, total_pages = await fetch_wordpress_posts( safe_site_url, page=1, per_page=WP_SYNC_PER_PAGE, rest_base=rest_base ) all_posts = list(first_posts) for page in range(2, total_pages + 1): if _handle_cancel(db, job, job_id) or _handle_pause(db, job, job_id): return page_posts, _ = await fetch_wordpress_posts( safe_site_url, page=page, per_page=WP_SYNC_PER_PAGE, rest_base=rest_base ) all_posts.extend(page_posts) if _handle_cancel(db, job, job_id) or _handle_pause(db, job, job_id): return job.total = len(all_posts) db.commit() entries, errors = _build_entries(all_posts) job.failed = len(errors) if errors: job.error_message = "; ".join(errors[:5]) summary = _upsert_synced_entries(db, tenant_uuid, entries, force=force) if _handle_cancel(db, job, job_id) or _handle_pause(db, job, job_id): return _embed_rows(vector_store, embedding_service, tenant_uuid, summary.rows_to_embed, db) job.processed = len(entries) job.created = summary.created job.updated = summary.updated job.skipped = summary.skipped # Read current status directly from DB to detect a concurrent cancel without # overwriting the stats we just set on the in-memory object. db_status = db.execute( select(SyncJob.status).where(SyncJob.id == job_uuid) ).scalar_one() if db_status not in ("cancelled", "paused"): job.status = "completed" job.updated_at = datetime.now(timezone.utc) db.commit() except Exception as exc: # pylint: disable=broad-exception-caught db.rollback() failed_job = _get_owned_job(db, tenant_uuid, job_uuid) if failed_job is not None: failed_job.status = "failed" failed_job.error_message = f"{type(exc).__name__}: {exc}" failed_job.updated_at = datetime.now(timezone.utc) db.commit() raise def _handle_cancel(db: Session, job: SyncJob, job_id: uuid.UUID) -> bool: """Return True and mark job cancelled if the cancel flag is set. Clears the flag.""" if check_cancel_flag(str(job_id)): clear_cancel_flag(str(job_id)) job.status = "cancelled" job.updated_at = datetime.now(timezone.utc) db.commit() return True return False def _handle_pause(db: Session, job: SyncJob, job_id: uuid.UUID) -> bool: """Return True and mark job paused if the pause flag is set. Clears the flag.""" if check_pause_flag(str(job_id)): clear_pause_flag(str(job_id)) job.status = "paused" job.updated_at = datetime.now(timezone.utc) db.commit() return True return False def _get_owned_job(db: Session, tenant_id: uuid.UUID, job_id: uuid.UUID) -> Optional[SyncJob]: return db.scalars( select(SyncJob).where(SyncJob.id == job_id, SyncJob.tenant_id == tenant_id) ).first() def _upsert_synced_entries( db: Session, tenant_id: uuid.UUID, entries: list[WordPressEntry], force: bool = False ) -> UpsertSummary: summary = UpsertSummary() for entry in entries: kb = db.scalars( select(KnowledgeBase).where( KnowledgeBase.tenant_id == tenant_id, KnowledgeBase.source_url == entry.source_url, ) ).first() if kb is None: kb = KnowledgeBase( tenant_id=tenant_id, title=entry.title, content=entry.content, source_url=entry.source_url, origin="synced", ) db.add(kb) db.flush() summary.created += 1 summary.rows_to_embed.append(kb) continue if not force: summary.skipped += 1 continue if kb.title != entry.title or kb.content != entry.content: kb.title = entry.title kb.content = entry.content kb.origin = "synced" kb.is_indexed = False summary.updated += 1 summary.rows_to_embed.append(kb) db.commit() for row in summary.rows_to_embed: db.refresh(row) return summary def _embed_rows( vector_store: VectorStore, embedding_service: EmbeddingService, tenant_id: uuid.UUID, rows: list[KnowledgeBase], db: Session, ) -> None: for start in range(0, len(rows), EMBEDDING_BATCH_SIZE): for row in rows[start : start + EMBEDDING_BATCH_SIZE]: embed_entry_for_retrieval( embedding_service=embedding_service, vector_store=vector_store, tenant_id=str(tenant_id), entry_id=str(row.id), text=row.content, metadata={ "origin": row.origin, "source_url": row.source_url, }, ) row.is_indexed = True