216 lines
6.5 KiB
Python
216 lines
6.5 KiB
Python
import logging
|
|
import re
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from arcade_tdk import ToolContext
|
|
from arcade_tdk.errors import ToolExecutionError
|
|
from bs4 import BeautifulSoup
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_MAX_BODY_LENGTH = 500 # Default max length for article body content
|
|
|
|
|
|
async def fetch_paginated_results(
|
|
client: httpx.AsyncClient,
|
|
url: str,
|
|
headers: dict[str, str],
|
|
params: dict[str, Any],
|
|
offset: int,
|
|
limit: int,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Fetch paginated results using offset and limit pattern.
|
|
|
|
This function internally manages pagination to fulfill the requested offset and limit,
|
|
fetching multiple pages as needed.
|
|
|
|
Args:
|
|
client: The HTTP client to use
|
|
url: The API endpoint URL
|
|
headers: Request headers including authorization
|
|
params: Base query parameters (without pagination params)
|
|
offset: Number of items to skip
|
|
limit: Number of items to return
|
|
|
|
Returns:
|
|
Dict containing:
|
|
- results: List of fetched items
|
|
- count: Number of items returned
|
|
- next_offset: Present only if more results are available
|
|
"""
|
|
# Calculate pagination parameters
|
|
# Most Zendesk APIs use 1-based page numbering
|
|
items_per_page = params.get("per_page", 100) # Use per_page from params or default to 100
|
|
start_page = (offset // items_per_page) + 1
|
|
start_index = offset % items_per_page
|
|
|
|
# Collect results across multiple pages if needed
|
|
all_results = []
|
|
current_page = start_page
|
|
items_collected = 0
|
|
has_more = False
|
|
last_page_had_more_items = False
|
|
|
|
while items_collected < limit:
|
|
# Set the current page
|
|
page_params = params.copy()
|
|
page_params["page"] = current_page
|
|
|
|
response = await client.get(url, headers=headers, params=page_params, timeout=30.0)
|
|
response.raise_for_status()
|
|
page_data = response.json()
|
|
|
|
# Extract results from current page (handle both "results" and "tickets" keys)
|
|
page_results = page_data.get("results", page_data.get("tickets", []))
|
|
|
|
# If this is the first page, skip to the start index
|
|
if current_page == start_page:
|
|
page_results = page_results[start_index:]
|
|
|
|
# Take only what we need to reach the limit
|
|
items_needed = limit - items_collected
|
|
results_to_add = page_results[:items_needed]
|
|
all_results.extend(results_to_add)
|
|
items_collected += len(results_to_add)
|
|
|
|
# Check if we left items on this page
|
|
if len(page_results) > items_needed:
|
|
last_page_had_more_items = True
|
|
|
|
# Check if there are more pages
|
|
has_more = page_data.get("next_page") is not None
|
|
|
|
# Stop if we've collected enough or no more pages
|
|
if items_collected >= limit or not has_more:
|
|
break
|
|
|
|
current_page += 1
|
|
|
|
# Build the response
|
|
result = {
|
|
"results": all_results,
|
|
"count": len(all_results),
|
|
}
|
|
|
|
# Add next_offset if there might be more results
|
|
# This happens when:
|
|
# 1. We got exactly the limit requested AND (there are more pages OR we left items on the page)
|
|
# 2. We didn't get the full limit but there are more pages available
|
|
if (len(all_results) == limit and (has_more or last_page_had_more_items)) or (
|
|
len(all_results) < limit and has_more
|
|
):
|
|
result["next_offset"] = offset + len(all_results)
|
|
|
|
return result
|
|
|
|
|
|
def clean_html_text(text: str | None) -> str:
|
|
"""Remove HTML tags and clean up text."""
|
|
if not text:
|
|
return ""
|
|
|
|
soup = BeautifulSoup(text, "html.parser")
|
|
clean_text: str = soup.get_text(separator=" ")
|
|
|
|
clean_text = re.sub(r"\n+", "\n", clean_text)
|
|
|
|
clean_text = re.sub(r"\s+", " ", clean_text)
|
|
|
|
clean_text = "\n".join(line.strip() for line in clean_text.split("\n"))
|
|
|
|
return clean_text.strip()
|
|
|
|
|
|
def truncate_text(
|
|
text: str | None, max_length: int, suffix: str = " ... [truncated]"
|
|
) -> str | None:
|
|
"""Truncate text to a maximum length with a suffix."""
|
|
if not text or len(text) <= max_length:
|
|
return text
|
|
|
|
truncate_at = max_length - len(suffix)
|
|
if truncate_at <= 0:
|
|
return suffix
|
|
|
|
return text[:truncate_at] + suffix
|
|
|
|
|
|
def process_article_body(body: str | None, max_length: int | None = None) -> str | None:
|
|
"""Process article body by cleaning HTML and optionally truncating."""
|
|
if not body:
|
|
return None
|
|
|
|
cleaned_text: str = clean_html_text(body)
|
|
|
|
if max_length and len(cleaned_text) > max_length:
|
|
result = truncate_text(cleaned_text, max_length)
|
|
return result
|
|
|
|
return cleaned_text
|
|
|
|
|
|
def process_search_results(
|
|
results: list[dict[str, Any]],
|
|
include_body: bool = False,
|
|
max_body_length: int | None = DEFAULT_MAX_BODY_LENGTH,
|
|
) -> list[dict[str, Any]]:
|
|
"""Process search results to clean up data and restructure with content and metadata."""
|
|
processed_results = []
|
|
|
|
for result in results:
|
|
body_content = result.get("body", "")
|
|
cleaned_content = None
|
|
|
|
if include_body and body_content:
|
|
cleaned_content = process_article_body(body_content, max_body_length)
|
|
|
|
processed_result: dict[str, Any] = {"content": cleaned_content, "metadata": {}}
|
|
|
|
for key, value in result.items():
|
|
if key != "body":
|
|
processed_result["metadata"][key] = value
|
|
|
|
processed_results.append(processed_result)
|
|
|
|
return processed_results
|
|
|
|
|
|
def validate_date_format(date_string: str) -> bool:
|
|
"""Validate that a date string matches YYYY-MM-DD format and is a valid date."""
|
|
from datetime import datetime
|
|
|
|
try:
|
|
parsed_date = datetime.strptime(date_string, "%Y-%m-%d")
|
|
# Ensure the input matches the expected format exactly
|
|
return parsed_date.strftime("%Y-%m-%d") == date_string
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def get_zendesk_subdomain(context: ToolContext) -> str:
|
|
"""
|
|
Get the Zendesk subdomain from secrets with proper error handling.
|
|
|
|
Args:
|
|
context: The tool context containing secrets
|
|
|
|
Returns:
|
|
The Zendesk subdomain
|
|
|
|
Raises:
|
|
ToolExecutionError: If the subdomain secret is not configured
|
|
"""
|
|
try:
|
|
subdomain = context.get_secret("ZENDESK_SUBDOMAIN")
|
|
except ValueError:
|
|
raise ToolExecutionError(
|
|
message="Zendesk subdomain is not set.",
|
|
developer_message=(
|
|
"Zendesk subdomain is not set. Make sure to set the "
|
|
"'ZENDESK_SUBDOMAIN' secret in the Arcade Dashboard."
|
|
),
|
|
) from None
|
|
else:
|
|
return subdomain
|