Skip to content

AI Workflows — Editorial copilot for XM Cloud pages with "on your data" AI

Created:
Updated:

Generic AI suggestions don’t work for enterprise content. When an editor asks for help rewriting a product description or localizing a landing page, the AI needs to understand your brand voice, product terminology, and content standards—not hallucinate generic marketing copy.

This post walks through building an editorial copilot for Sitecore XM Cloud that grounds all suggestions in your own content and guidelines using Azure OpenAI “On Your Data” with Azure AI Search. The focus is on practical implementation: how to structure the knowledge base, configure retrieval for quality results, design prompts that editors can actually use, and wire in human-in-the-loop approval before anything touches live content.


Architecture overview

The copilot sits between XM Cloud and Azure OpenAI, using a RAG (Retrieval-Augmented Generation) pattern to ground every suggestion in your own content:

Editorial Copilot

Azure AI Services

Sitecore XM Cloud

Pages & Items

Experience Edge

(Preview API)

Azure AI Search

(Hybrid + Semantic)

Azure OpenAI

GPT-4

FastAPI Service

Prompt Templates

Editor Review UI

Key design decisions:


Building the knowledge base

The quality of suggestions depends entirely on what you put in the knowledge base. I index three categories of content:

1. Brand and style guidelines

These are the non-negotiable rules for how content should sound:

2. Reference content

High-quality examples that demonstrate what “good” looks like:

3. Terminology and glossary

Domain-specific vocabulary the model needs to get right:

Chunking strategy

Azure AI Search’s default chunking (1,024 tokens) works for most content, but editorial content benefits from adjustments:

# Recommended chunking for editorial content
CHUNK_CONFIG = {
    "brand_guidelines": {
        "chunk_size": 512,      # Smaller chunks for dense rules
        "overlap": 128,         # 25% overlap for context
    },
    "reference_content": {
        "chunk_size": 1024,     # Larger chunks for full examples
        "overlap": 256,         # More overlap to preserve context
    },
    "glossary": {
        "chunk_size": 256,      # Small chunks, one term per chunk
        "overlap": 0,           # No overlap needed
    }
}

For brand guidelines, smaller chunks (512 tokens) with 25% overlap ensure individual rules don’t get split across chunks. For reference content, larger chunks (1,024 tokens) preserve enough context for the model to understand complete examples.

Use integrated vectorization with the Document Layout skill for semantic chunking:

{
  "name": "editorial-copilot-index",
  "fields": [
    {"name": "id", "type": "Edm.String", "key": true},
    {"name": "content", "type": "Edm.String", "searchable": true},
    {"name": "content_vector", "type": "Collection(Edm.Single)", "dimensions": 1536, "vectorSearchProfile": "default"},
    {"name": "category", "type": "Edm.String", "filterable": true},
    {"name": "locale", "type": "Edm.String", "filterable": true},
    {"name": "product_line", "type": "Edm.String", "filterable": true},
    {"name": "last_updated", "type": "Edm.DateTimeOffset", "filterable": true}
  ],
  "vectorSearch": {
    "profiles": [{
      "name": "default",
      "algorithm": "hnsw",
      "vectorizer": "text-embedding-ada-002"
    }]
  },
  "semantic": {
    "configurations": [{
      "name": "semantic-config",
      "prioritizedFields": {
        "contentFields": [{"fieldName": "content"}]
      }
    }]
  }
}

The category, locale, and product_line fields enable filtered retrieval—when an editor is working on a German product page, the copilot retrieves German brand guidelines and product-specific terminology.


Fetching content from Experience Edge

The copilot needs to read the current content before suggesting improvements. Experience Edge Preview API provides access to unpublished drafts.

GraphQL query for page content

query GetPageContent($itemId: String!, $language: String!) {
  item(path: $itemId, language: $language) {
    id
    name
    path
    template {
      name
    }
    fields {
      name
      value
      ... on RichTextField {
        value
      }
      ... on TextField {
        value
      }
    }
    children(first: 50) {
      results {
        id
        name
        template {
          name
        }
        fields {
          name
          value
        }
      }
    }
  }
}

Python client for Experience Edge

import httpx
from typing import Optional
from pydantic import BaseModel

class XMCloudClient:
    def __init__(self, edge_url: str, api_key: str):
        self.edge_url = edge_url
        self.api_key = api_key

    async def fetch_item(self, item_id: str, language: str = "en") -> dict:
        query = """
        query GetItem($id: String!, $lang: String!) {
          item(path: $id, language: $lang) {
            id
            name
            path
            fields { name value }
          }
        }
        """
        async with httpx.AsyncClient(timeout=30) as client:
            response = await client.post(
                self.edge_url,
                json={
                    "query": query,
                    "variables": {"id": item_id, "lang": language}
                },
                headers={
                    "sc_apikey": self.api_key,
                    "Content-Type": "application/json"
                }
            )
            response.raise_for_status()
            return response.json()["data"]["item"]

    def extract_text_fields(self, item: dict) -> dict[str, str]:
        """Extract text content from item fields for copilot processing."""
        text_fields = {}
        for field in item.get("fields", []):
            name = field["name"]
            value = field.get("value", "")
            # Skip system fields and empty values
            if name.startswith("__") or not value:
                continue
            # Strip HTML for plain text processing
            text_fields[name] = self._strip_html(value)
        return text_fields

    def _strip_html(self, html: str) -> str:
        """Basic HTML stripping for text extraction."""
        import re
        clean = re.sub(r'<[^>]+>', ' ', html)
        return ' '.join(clean.split())

The Experience Edge Preview API has a rate limit of 80 requests per second for uncached responses. For batch operations, implement request queuing or use the Delivery API for published content.


Configuring Azure OpenAI “On Your Data”

The “On Your Data” feature handles the RAG pipeline: intent generation, retrieval, filtering, reranking, and response generation with citations.

Key configuration parameters

from openai import AzureOpenAI

client = AzureOpenAI(
    azure_endpoint="https://your-instance.openai.azure.com",
    api_key="your-api-key",
    api_version="2024-02-01"
)

def generate_suggestion(
    current_content: str,
    instruction: str,
    filters: dict = None
) -> dict:
    """Generate grounded editorial suggestions."""

    # Build filter expression for targeted retrieval
    filter_expr = None
    if filters:
        conditions = []
        if filters.get("locale"):
            conditions.append(f"locale eq '{filters['locale']}'")
        if filters.get("product_line"):
            conditions.append(f"product_line eq '{filters['product_line']}'")
        if conditions:
            filter_expr = " and ".join(conditions)

    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"{instruction}\n\nCurrent content:\n{current_content}"}
        ],
        extra_body={
            "data_sources": [{
                "type": "azure_search",
                "parameters": {
                    "endpoint": "https://your-search.search.windows.net",
                    "index_name": "editorial-copilot-index",
                    "authentication": {
                        "type": "api_key",
                        "key": "your-search-key"
                    },
                    "query_type": "vector_semantic_hybrid",
                    "semantic_configuration": "semantic-config",
                    "strictness": 3,           # 1-5, higher = stricter filtering
                    "top_n_documents": 5,      # Number of chunks to include
                    "in_scope": True,          # Only answer from retrieved docs
                    "filter": filter_expr
                }
            }]
        },
        temperature=0.3  # Lower temperature for consistent, grounded output
    )

    return {
        "suggestion": response.choices[0].message.content,
        "citations": extract_citations(response),
        "model": response.model
    }

def extract_citations(response) -> list[dict]:
    """Extract source citations from the response."""
    citations = []
    if hasattr(response.choices[0].message, 'context'):
        for citation in response.choices[0].message.context.get('citations', []):
            citations.append({
                "content": citation.get("content"),
                "title": citation.get("title"),
                "url": citation.get("url")
            })
    return citations

Tuning retrieval quality

The strictness and top_n_documents parameters are critical:

ParameterLow ValueHigh ValueEditorial Use Case
strictness1-2: More documents, some irrelevant4-5: Fewer documents, highly relevantStart at 3, increase if getting off-topic suggestions
top_n_documents3-5: Focused context10-15: Broader contextUse 5 for rewrites, 10 for comprehensive style checks

If the copilot isn’t finding relevant guidelines, reduce strictness. If it’s hallucinating or citing irrelevant content, increase strictness and verify your index contains the right documents.

Query type selection

Azure AI Search offers multiple query types:

Hybrid search catches both exact terminology matches (critical for product names) and semantically similar content (finding relevant examples even with different wording).


Prompt templates for editorial tasks

Each editorial action gets a dedicated prompt template. The system prompt establishes the copilot’s role and constraints:

System prompt

SYSTEM_PROMPT = """You are an editorial assistant for Sitecore XM Cloud content. Your role is to help editors improve content while maintaining brand voice and accuracy.

CONSTRAINTS:
- Base all suggestions on the retrieved brand guidelines and reference content
- Never invent product features, pricing, or claims not in the source material
- Preserve all product names, trademarks, and legal disclaimers exactly as written
- If you cannot find relevant guidance in the retrieved documents, say so explicitly
- Always cite which guidelines or examples informed your suggestions

OUTPUT FORMAT:
- Provide the revised content first
- Then list key changes with brief rationale
- Include citations in [Source: document name] format"""

Task-specific prompts

PROMPT_TEMPLATES = {
    "rewrite_clarity": """Rewrite the following content for improved clarity and readability.

Requirements:
- Maintain the same meaning and key messages
- Use shorter sentences where possible (aim for 15-20 words)
- Use active voice
- Keep the same approximate length

Current content:
{content}""",

    "adjust_tone": """Adjust the tone of this content to be more {target_tone}.

Target tone: {target_tone}
Target audience: {audience}

Maintain:
- All factual claims and product details
- Trademark and legal language
- Overall message and call to action

Current content:
{content}""",

    "shorten": """Shorten this content to approximately {target_length} characters while preserving the key message.

Priority order for what to keep:
1. Main value proposition
2. Key differentiators
3. Call to action
4. Supporting details

Current content ({current_length} characters):
{content}""",

    "localize": """Adapt this content for {target_locale} market.

Requirements:
- Translate to {target_language}
- Keep product names in English unless locale-specific names exist in guidelines
- Adapt idioms and cultural references appropriately
- Maintain brand voice as defined in localized guidelines
- Flag any content that may need legal review for this market

Source content ({source_locale}):
{content}""",

    "seo_optimize": """Optimize this content for the target keyword while maintaining readability.

Target keyword: {keyword}
Secondary keywords: {secondary_keywords}

Requirements:
- Include target keyword in first 100 characters if natural
- Use keyword variations, not repetition
- Maintain brand voice and clarity
- Suggest a meta description (150-160 characters)

Current content:
{content}"""
}

Few-shot examples for consistent output

For critical tasks like localization, include examples in the prompt:

LOCALIZATION_EXAMPLES = """
Example 1:
Source (en-US): "Get started with a free trial today!"
Target (de-DE): "Starten Sie noch heute mit einer kostenlosen Testversion!"
Note: Formal "Sie" used as per German brand guidelines.

Example 2:
Source (en-US): "XM Cloud powers your digital experiences."
Target (de-DE): "XM Cloud unterstützt Ihre digitalen Erlebnisse."
Note: Product name "XM Cloud" kept in English as per terminology guidelines.
"""

Human-in-the-loop approval workflow

Under the EU AI Act and enterprise governance requirements, human oversight is mandatory for content that will be published. The copilot generates suggestions; humans approve them.

Approval flow

Accept

Edit

Reject

Editor requests suggestion

Copilot generates options

Editor reviews in UI

Decision

Apply to XM Cloud draft

Editor modifies suggestion

Log rejection reason

Record to audit log

Audit logging

Every interaction must be logged for compliance:

from datetime import datetime
from pydantic import BaseModel
from typing import Optional

class AuditEntry(BaseModel):
    timestamp: datetime
    user_id: str
    action: str  # "generate", "accept", "reject", "modify"
    item_id: str
    field_name: str
    original_content: str
    suggested_content: str
    final_content: Optional[str]
    citations: list[dict]
    model_version: str
    rejection_reason: Optional[str]

async def log_copilot_action(entry: AuditEntry, db: Database):
    """Record copilot interaction for audit trail."""
    await db.audit_log.insert_one(entry.model_dump())

Applying changes to XM Cloud

When editors approve suggestions, apply them as draft updates:

async def apply_suggestion(
    item_id: str,
    field_name: str,
    new_value: str,
    xm_client: XMCloudManagementClient,
    audit_db: Database
) -> dict:
    """Apply approved suggestion to XM Cloud as a new draft version."""

    # Create new draft version (never overwrite published)
    result = await xm_client.update_item_field(
        item_id=item_id,
        field_name=field_name,
        value=new_value,
        version="draft"
    )

    # Log the application
    await audit_db.applied_changes.insert_one({
        "item_id": item_id,
        "field_name": field_name,
        "applied_at": datetime.utcnow(),
        "applied_by": get_current_user(),
        "draft_version": result["version"]
    })

    return result

Deployment options

Deploy the copilot as a standalone FastAPI service:

from fastapi import FastAPI, HTTPException, Depends
from fastapi.security import HTTPBearer

app = FastAPI(title="XM Cloud Editorial Copilot")
security = HTTPBearer()

@app.post("/suggestions/rewrite")
async def suggest_rewrite(
    request: RewriteRequest,
    token: str = Depends(security)
):
    """Generate rewrite suggestions for XM Cloud content."""
    # Validate user permissions
    user = await validate_token(token)
    if not user.can_use_copilot:
        raise HTTPException(403, "Copilot access not authorized")

    # Fetch current content from Experience Edge
    xm_client = XMCloudClient(settings.edge_url, settings.edge_api_key)
    item = await xm_client.fetch_item(request.item_id, request.language)
    current_content = item["fields"].get(request.field_name, "")

    # Generate grounded suggestion
    suggestion = await generate_suggestion(
        current_content=current_content,
        instruction=PROMPT_TEMPLATES["rewrite_clarity"].format(content=current_content),
        filters={"locale": request.language, "product_line": request.product_line}
    )

    # Log the generation
    await log_copilot_action(AuditEntry(
        timestamp=datetime.utcnow(),
        user_id=user.id,
        action="generate",
        item_id=request.item_id,
        field_name=request.field_name,
        original_content=current_content,
        suggested_content=suggestion["suggestion"],
        citations=suggestion["citations"],
        model_version=suggestion["model"]
    ))

    return suggestion

Integration with Sitecore Stream

For organizations using Sitecore Stream (the native AI platform), the copilot can complement Stream’s capabilities:

The custom copilot provides more control over retrieval sources, prompt engineering, and approval workflows than the out-of-box Stream features.


Measuring copilot effectiveness

Track both usage metrics and quality metrics:

Usage metrics

Quality metrics

async def calculate_copilot_metrics(db: Database, date_range: tuple) -> dict:
    """Calculate copilot effectiveness metrics."""
    start, end = date_range

    pipeline = [
        {"$match": {"timestamp": {"$gte": start, "$lte": end}}},
        {"$group": {
            "_id": "$action",
            "count": {"$sum": 1}
        }}
    ]

    results = await db.audit_log.aggregate(pipeline).to_list(None)
    counts = {r["_id"]: r["count"] for r in results}

    total_generated = counts.get("generate", 0)
    accepted = counts.get("accept", 0)
    modified = counts.get("modify", 0)
    rejected = counts.get("reject", 0)

    return {
        "total_suggestions": total_generated,
        "acceptance_rate": (accepted + modified) / total_generated if total_generated else 0,
        "modification_rate": modified / (accepted + modified) if (accepted + modified) else 0,
        "rejection_rate": rejected / total_generated if total_generated else 0
    }

Troubleshooting common issues

Suggestions ignore brand guidelines

  1. Check indexing: Verify brand guideline documents are in the search index
  2. Reduce strictness: Lower the strictness parameter to include more documents
  3. Add filters: Use category filters to prioritize brand content
  4. Improve chunking: Ensure guidelines aren’t split across chunks

Model hallucinates product details

  1. Set in_scope: true: Force model to only use retrieved content
  2. Increase strictness: Filter out tangentially related content
  3. Add explicit constraints: Include “do not invent details” in system prompt
  4. Upgrade model: GPT-4 follows grounding constraints better than GPT-3.5

Slow response times

  1. Check index size: Large indexes slow retrieval
  2. Reduce top_n_documents: Fewer documents = faster responses
  3. Use caching: Cache repeated queries for same content
  4. Optimize filters: Indexed filter fields are faster than content filters


Next Post
AI-Powered Stack — Working with AI as your Sitecore BA, architect, and PM